From da9a5ba8df7ad96b18fceaf659ef16bc46eb2b25 Mon Sep 17 00:00:00 2001 From: Travis Tidwell Date: Fri, 9 Oct 2026 09:29:14 -0500 Subject: [PATCH 01/13] docs(openspec): propose freeze-1-0-tool-surface Step 2 of the 1.0 plan: singular tool names (server_status replaces hello), form_publish / form_revert split out of form_update, one list contract with total and hasMore, acceptNoHistory in place of the revisions consent prompts, structured tool errors, open output schemas, a tools/list size budget, and the matching skill and README updates. Co-Authored-By: Claude Opus 5.5 --- .../freeze-1-0-tool-surface/.openspec.yaml | 2 + .../changes/freeze-1-0-tool-surface/design.md | 93 +++++++++ .../freeze-1-0-tool-surface/proposal.md | 64 ++++++ .../specs/action-crud/spec.md | 26 +++ .../specs/action-types-discovery/spec.md | 22 +++ .../specs/form-create/spec.md | 20 ++ .../specs/form-list/spec.md | 18 ++ .../specs/form-publish/spec.md | 22 +++ .../specs/form-revert/spec.md | 16 ++ .../specs/form-revisions/spec.md | 27 +++ .../specs/form-update/spec.md | 28 +++ .../specs/project-map-routing/spec.md | 49 +++++ .../revisions-history-acceptance/spec.md | 28 +++ .../specs/role-create/spec.md | 17 ++ .../specs/role-list/spec.md | 17 ++ .../specs/server-config/spec.md | 19 ++ .../specs/server-status/spec.md | 21 ++ .../specs/tool-description-budget/spec.md | 15 ++ .../specs/tool-error-contract/spec.md | 23 +++ .../specs/tool-list-contract/spec.md | 31 +++ .../specs/tool-output-schemas/spec.md | 21 ++ .../changes/freeze-1-0-tool-surface/tasks.md | 187 ++++++++++++++++++ 22 files changed, 766 insertions(+) create mode 100644 openspec/changes/freeze-1-0-tool-surface/.openspec.yaml create mode 100644 openspec/changes/freeze-1-0-tool-surface/design.md create mode 100644 openspec/changes/freeze-1-0-tool-surface/proposal.md create mode 100644 openspec/changes/freeze-1-0-tool-surface/specs/action-crud/spec.md create mode 100644 openspec/changes/freeze-1-0-tool-surface/specs/action-types-discovery/spec.md create mode 100644 openspec/changes/freeze-1-0-tool-surface/specs/form-create/spec.md create mode 100644 openspec/changes/freeze-1-0-tool-surface/specs/form-list/spec.md create mode 100644 openspec/changes/freeze-1-0-tool-surface/specs/form-publish/spec.md create mode 100644 openspec/changes/freeze-1-0-tool-surface/specs/form-revert/spec.md create mode 100644 openspec/changes/freeze-1-0-tool-surface/specs/form-revisions/spec.md create mode 100644 openspec/changes/freeze-1-0-tool-surface/specs/form-update/spec.md create mode 100644 openspec/changes/freeze-1-0-tool-surface/specs/project-map-routing/spec.md create mode 100644 openspec/changes/freeze-1-0-tool-surface/specs/revisions-history-acceptance/spec.md create mode 100644 openspec/changes/freeze-1-0-tool-surface/specs/role-create/spec.md create mode 100644 openspec/changes/freeze-1-0-tool-surface/specs/role-list/spec.md create mode 100644 openspec/changes/freeze-1-0-tool-surface/specs/server-config/spec.md create mode 100644 openspec/changes/freeze-1-0-tool-surface/specs/server-status/spec.md create mode 100644 openspec/changes/freeze-1-0-tool-surface/specs/tool-description-budget/spec.md create mode 100644 openspec/changes/freeze-1-0-tool-surface/specs/tool-error-contract/spec.md create mode 100644 openspec/changes/freeze-1-0-tool-surface/specs/tool-list-contract/spec.md create mode 100644 openspec/changes/freeze-1-0-tool-surface/specs/tool-output-schemas/spec.md create mode 100644 openspec/changes/freeze-1-0-tool-surface/tasks.md 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..c1a32b81 --- /dev/null +++ b/openspec/changes/freeze-1-0-tool-surface/design.md @@ -0,0 +1,93 @@ +## 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 50,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, timeouts, the licence-probe cache, browser launch, logout — `harden-auth-and-errors`. +- Atomic local-state writes, mapping ancestor lookup — `harden-local-state-and-release`. +- Patch semantics for updates: `form_update`, `role_update` and `action_update` stay full replacement (PUT) in 1.0, documented as such. +- 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. + +**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`. The `code` set is the one in the `tool-error-contract` spec; `REDIRECTED` comes from PR #88's redirect refusal. +SDK-level input validation (zod failures before the handler runs) is produced by the SDK as text with no `structuredContent`; 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. + +**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. + +**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.** +The `cwd` description drops to ≤200 characters ("The user's working directory, absolute. Selects the Form.io project; see project_set and the server instructions."), because the full resolution rules already live in the server instructions every client receives once and in `project_set`. `project_set` keeps every rule it enforces and drops the rationale paragraphs (to the README). Output-schema field descriptions are trimmed to what a caller branches on. Measured target: ≤50,000 characters for the 22 tools after this change, 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 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`. + +## 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's `structuredContent` validated against the tool's `outputSchema`] → The SDK skips it for `isError` results on both sides (verified in `@modelcontextprotocol/sdk` server `mcp.js` and client `index.js`); a test asserts an error result 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 planner and both resources harnesses are run before and after per the plan's measurement rule. + +## 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..ff7ab59c --- /dev/null +++ b/openspec/changes/freeze-1-0-tool-surface/proposal.md @@ -0,0 +1,64 @@ +## 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 and add `structuredContent: { code, status?, body? }`, with `code` from a fixed set and Form.io's response body (truncated) included. +- 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`). +- 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. +- `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..4a48d79e --- /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 `structuredContent.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..0490a111 --- /dev/null +++ b/openspec/changes/freeze-1-0-tool-surface/specs/form-revert/spec.md @@ -0,0 +1,16 @@ +## 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`. + +#### 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 `structuredContent.code` `NOT_FOUND` +- **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..03501528 --- /dev/null +++ b/openspec/changes/freeze-1-0-tool-surface/specs/form-update/spec.md @@ -0,0 +1,28 @@ +## 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`) and SHALL ignore every other field — including the server-owned fields `form_get` returns — rather than refusing them. 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: 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-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..4b40cefc --- /dev/null +++ b/openspec/changes/freeze-1-0-tool-surface/specs/revisions-history-acceptance/spec.md @@ -0,0 +1,28 @@ +## 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, or to a form whose stored `revisions` setting is off when the caller does not enable it — 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: 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..fe1419aa --- /dev/null +++ b/openspec/changes/freeze-1-0-tool-surface/specs/tool-description-budget/spec.md @@ -0,0 +1,15 @@ +## ADDED Requirements + +### Requirement: The tool list fits a measured budget + +The serialized `tools/list` result SHALL NOT exceed 50,000 characters, and a test SHALL measure it. The `cwd` argument description SHALL NOT exceed 200 characters, and SHALL state what to pass (the user's working directory, absolute) and where the full rules are (the server instructions and `project_set`). 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 50,000 characters + +#### Scenario: The cwd description is short + +- **WHEN** any tool's `cwd` argument is inspected +- **THEN** its description is at most 200 characters and names the user's working directory 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..1c7c610c --- /dev/null +++ b/openspec/changes/freeze-1-0-tool-surface/specs/tool-error-contract/spec.md @@ -0,0 +1,23 @@ +## 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, and `structuredContent` of `{ code, status?, body? }`. `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`, `structuredContent.code` `VALIDATION_FAILED`, `status: 400`, and `body` holding Form.io's response +- **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 `structuredContent.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 `structuredContent.code` `NOT_CONFIGURED` and the existing remedy message 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..72aa42e9 --- /dev/null +++ b/openspec/changes/freeze-1-0-tool-surface/specs/tool-list-contract/spec.md @@ -0,0 +1,31 @@ +## 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`. 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 + +### 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..518d09c1 --- /dev/null +++ b/openspec/changes/freeze-1-0-tool-surface/tasks.md @@ -0,0 +1,187 @@ +## 1. Error contract + + +### Red + +- [ ] 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 +- [ ] 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 +- [ ] 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 + +- [ ] 1.4 Implement the typed errors in `formio-client.ts` and give the resolution error classes a `code` +- [ ] 1.5 Implement the mapper in `mcp-responses.ts` (`structuredContent: { code, status?, body? }`) + +### Refactor + +- [ ] 1.6 Review implementation and refactor as needed + +## 2. Open output schemas + + +### Red + +- [ ] 2.1 Write a failing test that lists every tool and asserts no `outputSchema` contains `additionalProperties: false` at any level +- [ ] 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 + +- [ ] 2.3 Pass each `outputSchema` as `z.looseObject(...)` and let Mixed fields accept `null` in `output-schemas.ts` + +### Refactor + +- [ ] 2.4 Review implementation and refactor as needed + +## 3. Renames and server_status + + +### Red + +- [ ] 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` +- [ ] 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 + +- [ ] 3.3 Rename the two list tools and their files/tests; replace `hello` with `server_status` built on `reportProject` +- [ ] 3.4 Update `capability-quality`, `server-empty-env`, `project-resolution-errors` and `mcpb-build` tests and `scripts/build-mcpb.ts`'s smoke call + +### Refactor + +- [ ] 3.5 Review implementation and refactor as needed + +## 4. List contract + + +### Red + +- [ ] 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` +- [ ] 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 +- [ ] 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 + +- [ ] 4.4 Implement `withMeta` and the 416 handling in `formio-client.ts` +- [ ] 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 + +- [ ] 4.6 Review implementation and refactor as needed + +## 5. form_publish, form_revert, form_update draft + + +### Red + +- [ ] 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 +- [ ] 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 +- [ ] 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 + +- [ ] 5.4 Register `form_publish` and `form_revert` over `revisions/flows.ts` +- [ ] 5.5 Remove publish/revert from `form_update`; change draft mode to pick allowlisted fields + +### Refactor + +- [ ] 5.6 Review implementation and refactor as needed + +## 6. acceptNoHistory + + +### Red + +- [ ] 6.1 Write failing tests: unlicensed `form_create` / `form_update` without `acceptNoHistory` → `HISTORY_NOT_ACCEPTED`, no write; with it → write without `revisions` +- [ ] 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 +- [ ] 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 + +- [ ] 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 +- [ ] 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 + +- [ ] 6.6 Review implementation and refactor as needed + +## 7. Argument shapes: role_create, action formId + + +### Red + +- [ ] 7.1 Write failing tests: `role_create` takes `role: { title, description?, default?, admin? }` and POSTs it; flat `title` is rejected +- [ ] 7.2 Write failing tests: every action tool refuses a non-ObjectId `formId` with a message pointing to `form_get` for the `_id` + +### Green + +- [ ] 7.3 Change `role_create`'s input to the shared role schema used by `role_update` +- [ ] 7.4 Give every action tool's `formId` the ObjectId schema with the remedy message + +### Refactor + +- [ ] 7.5 Review implementation and refactor as needed + +## 8. Environment booleans and package exports + + +### Red + +- [ ] 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 +- [ ] 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 + +- [ ] 8.3 Implement `readBooleanEnv` in `config.ts` and use it for both variables +- [ ] 8.4 Add `exports` to `package.json` + +### Refactor + +- [ ] 8.5 Review implementation and refactor as needed + +## 9. Description budget + + +### Red + +- [ ] 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 + +- [ ] 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 +- [ ] 9.3 Confirm the server instructions still carry the full `cwd` and resolution guidance; run the planner and both resources eval harnesses before and after and record that scores did not drop + +### Refactor + +- [ ] 9.4 Review implementation and refactor as needed + +## 10. Skills and docs follow the surface + + +### Red + +- [ ] 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 +- [ ] 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 + +- [ ] 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` +- [ ] 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 + +- [ ] 10.5 Review implementation and refactor as needed + +## 11. Release notes and Definition of Done + + +### Red + +- [ ] 11.1 Confirm `pnpm check:releases` fails until the changeset exists + +### Green + +- [ ] 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 +- [ ] 11.3 Run `pnpm test`, `pnpm lint`, `pnpm format`, `pnpm check:releases` and `openspec validate --strict` + +### Refactor + +- [ ] 11.4 Review implementation and refactor as needed From adbdb7b9934fb8f8f48faa5b848926c79172c5f9 Mon Sep 17 00:00:00 2001 From: Travis Tidwell Date: Fri, 9 Oct 2026 09:49:37 -0500 Subject: [PATCH 02/13] feat(mcp): structured tool error codes Typed Form.io errors (FormioApiError, FormioNetworkError) and a code on every resolution error. Error results carry { code, status?, body? } in _meta["io.form/error"] and start their text with [CODE]; they carry no structuredContent, which clients validate against the success schema. Co-Authored-By: Claude Opus 5.5 --- .../changes/freeze-1-0-tool-surface/design.md | 6 +- .../freeze-1-0-tool-surface/proposal.md | 2 +- .../specs/form-publish/spec.md | 2 +- .../specs/form-revert/spec.md | 2 +- .../specs/tool-error-contract/spec.md | 14 +- .../changes/freeze-1-0-tool-surface/tasks.md | 12 +- .../__tests__/formio-client-errors.test.ts | 189 +++++++++++++++ .../src/__tests__/mcp-error-contract.test.ts | 166 +++++++++++++ .../src/__tests__/tool-error-contract.test.ts | 147 ++++++++++++ packages/mcp-server/src/committed-config.ts | 13 +- packages/mcp-server/src/formio-client.ts | 47 +++- packages/mcp-server/src/mcp-responses.ts | 21 +- packages/mcp-server/src/project-map.ts | 8 +- packages/mcp-server/src/project-resolver.ts | 21 +- packages/mcp-server/src/tool-errors.ts | 223 ++++++++++++++++++ 15 files changed, 828 insertions(+), 45 deletions(-) create mode 100644 packages/mcp-server/src/__tests__/formio-client-errors.test.ts create mode 100644 packages/mcp-server/src/__tests__/mcp-error-contract.test.ts create mode 100644 packages/mcp-server/src/__tests__/tool-error-contract.test.ts create mode 100644 packages/mcp-server/src/tool-errors.ts diff --git a/openspec/changes/freeze-1-0-tool-surface/design.md b/openspec/changes/freeze-1-0-tool-surface/design.md index c1a32b81..52fc17f5 100644 --- a/openspec/changes/freeze-1-0-tool-surface/design.md +++ b/openspec/changes/freeze-1-0-tool-surface/design.md @@ -44,8 +44,8 @@ Removed: `revisions/browser-prompts.ts`, the elicitation paths, the in-memory ap *Alternative considered:* elicitation with a refusal fallback — rejected; two behaviours by client capability is what made the prompt path hard to reason about. **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`. The `code` set is the one in the `tool-error-contract` spec; `REDIRECTED` comes from PR #88's redirect refusal. -SDK-level input validation (zod failures before the handler runs) is produced by the SDK as text with no `structuredContent`; 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. +`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 authentication failures raised by the login flow stay `INTERNAL` until `harden-auth-and-errors`. +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. **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. @@ -78,7 +78,7 @@ The shared preflight paragraph (15 skills, identical text enforced by `shared-pr - [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's `structuredContent` validated against the tool's `outputSchema`] → The SDK skips it for `isError` results on both sides (verified in `@modelcontextprotocol/sdk` server `mcp.js` and client `index.js`); a test asserts an error result passes through a validating client. +- [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 planner and both resources harnesses are run before and after per the plan's measurement rule. diff --git a/openspec/changes/freeze-1-0-tool-surface/proposal.md b/openspec/changes/freeze-1-0-tool-surface/proposal.md index ff7ab59c..f801c482 100644 --- a/openspec/changes/freeze-1-0-tool-surface/proposal.md +++ b/openspec/changes/freeze-1-0-tool-surface/proposal.md @@ -20,7 +20,7 @@ Fixing these after 1.0 would each be a breaking change. Fixing them now is one. - **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 and add `structuredContent: { code, status?, body? }`, with `code` from a fixed set and Form.io's response body (truncated) included. +- **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`). - 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. 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 index 4a48d79e..b075cd52 100644 --- 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 @@ -13,7 +13,7 @@ The server SHALL register a `form_publish` tool taking `cwd`, `formId` (a 24-cha #### Scenario: No draft to publish - **WHEN** `form_publish` is called for a form with no draft -- **THEN** the tool returns `isError: true` with `structuredContent.code` `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 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 index 0490a111..49adff20 100644 --- 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 @@ -12,5 +12,5 @@ The server SHALL register a `form_revert` tool taking `cwd`, `formId` (a 24-char #### 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 `structuredContent.code` `NOT_FOUND` +- **THEN** the tool returns `isError: true` with `_meta["io.form/error"].code` `NOT_FOUND` - **AND** no PUT is sent 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 index 1c7c610c..506669bc 100644 --- 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 @@ -2,22 +2,28 @@ ### 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, and `structuredContent` of `{ code, status?, body? }`. `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. +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`, `structuredContent.code` `VALIDATION_FAILED`, `status: 400`, and `body` holding Form.io's response +- **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 `structuredContent.code` `NETWORK_ERROR` and the message names `ECONNREFUSED` and the URL +- **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 `structuredContent.code` `NOT_CONFIGURED` and the existing remedy message +- **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/tasks.md b/openspec/changes/freeze-1-0-tool-surface/tasks.md index 518d09c1..8731b1c0 100644 --- a/openspec/changes/freeze-1-0-tool-surface/tasks.md +++ b/openspec/changes/freeze-1-0-tool-surface/tasks.md @@ -3,18 +3,18 @@ ### Red -- [ ] 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 -- [ ] 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 -- [ ] 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 +- [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 -- [ ] 1.4 Implement the typed errors in `formio-client.ts` and give the resolution error classes a `code` -- [ ] 1.5 Implement the mapper in `mcp-responses.ts` (`structuredContent: { code, status?, body? }`) +- [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 -- [ ] 1.6 Review implementation and refactor as needed +- [x] 1.6 Review implementation and refactor as needed ## 2. Open output schemas 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..fa391d18 --- /dev/null +++ b/packages/mcp-server/src/__tests__/formio-client-errors.test.ts @@ -0,0 +1,189 @@ +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'); + }); + + 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__/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__/tool-error-contract.test.ts b/packages/mcp-server/src/__tests__/tool-error-contract.test.ts new file mode 100644 index 00000000..45368806 --- /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 (_s, _c, { 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/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/formio-client.ts b/packages/mcp-server/src/formio-client.ts index 0c4dfc21..07139de6 100644 --- a/packages/mcp-server/src/formio-client.ts +++ b/packages/mcp-server/src/formio-client.ts @@ -3,6 +3,7 @@ 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 @@ -34,14 +35,33 @@ export interface FormioFetchOptions { // 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; + } +} + +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,7 +89,7 @@ export async function formioRawFetch( config: ResolvedFormioConfig, options?: FormioFetchOptions ): Promise { - const response = await fetch(url, buildFetchInit(config, options)); + const response = await send(url, buildFetchInit(config, options)); const parseResponse = (res: Response) => options?.responseType === 'text' ? res.text() : res.json(); @@ -85,13 +105,13 @@ export async function formioRawFetch( await clearToken(baseUrl); config.jwt = undefined; await ensureAuthenticated(config); - const retryResponse = await fetch(url, buildFetchInit(config, options)); + const retryResponse = await send(url, buildFetchInit(config, options)); if (!retryResponse.ok) { - throw new Error(formatApiError(retryResponse, url)); + throw await apiError(retryResponse, url); } return parseResponse(retryResponse); } - throw new Error(formatApiError(response, url)); + throw await apiError(response, url); } return parseResponse(response); @@ -128,9 +148,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..c714cc2f 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,23 @@ 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, }; } 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-resolver.ts b/packages/mcp-server/src/project-resolver.ts index e9228a32..ce4e850d 100644 --- a/packages/mcp-server/src/project-resolver.ts +++ b/packages/mcp-server/src/project-resolver.ts @@ -34,6 +34,7 @@ import { CommittedProjectConfig, findCommittedConfig, } from './committed-config.js'; +import { ToolError } from './tool-errors.js'; 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."; @@ -67,7 +68,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,7 +81,7 @@ 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; } @@ -161,6 +162,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 +186,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. ` + @@ -514,7 +524,10 @@ export function resolveProject( }: 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; diff --git a/packages/mcp-server/src/tool-errors.ts b/packages/mcp-server/src/tool-errors.ts new file mode 100644 index 00000000..80df68f8 --- /dev/null +++ b/packages/mcp-server/src/tool-errors.ts @@ -0,0 +1,223 @@ +/** + * 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; +} + +// Field-level messages: resourcejs reports a validation failure as an `errors` map +// keyed by field, and the submission validator as a `details` array. +function fieldMessages(record: Record): string[] { + const { errors, details } = record; + const entries = [ + ...(isRecord(errors) ? Object.values(errors) : []), + ...(Array.isArray(details) ? details : []), + ]; + return entries.flatMap((entry) => { + const message = isRecord(entry) ? stringField(entry, 'message') : undefined; + 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' }; +} From 2c4c60df07560a1101d6ae7cfe07d4d6713857b9 Mon Sep 17 00:00:00 2001 From: Travis Tidwell Date: Fri, 9 Oct 2026 09:56:21 -0500 Subject: [PATCH 03/13] feat(mcp): open output schemas; one boolean env parser; bin-only exports Every published outputSchema is a looseObject at every level and Mixed Form.io fields accept null, so a valid response with fields the schema does not list (_vid, pdfComponents, controller, esign, null settings) is no longer rejected. FORMIO_INSECURE_TLS and FORMIO_FORCE_BROWSER share readBooleanEnv. package.json exports only ./package.json; main removed. Co-Authored-By: Claude Opus 5.5 --- .../changes/freeze-1-0-tool-surface/tasks.md | 18 +- packages/mcp-server/package.json | 4 +- .../src/__tests__/boolean-env.test.ts | 71 ++++++++ .../src/__tests__/insecure-tls.test.ts | 30 ---- .../src/__tests__/output-schemas-open.test.ts | 50 ++++++ .../__tests__/output-schemas-replay.test.ts | 168 ++++++++++++++++++ .../src/__tests__/package-exports.test.ts | 61 +++++++ packages/mcp-server/src/config.ts | 13 +- packages/mcp-server/src/formio-client.ts | 16 +- packages/mcp-server/src/output-schemas.ts | 82 +++++---- .../mcp-server/src/tools/action_create.ts | 4 +- .../mcp-server/src/tools/action_delete.ts | 4 +- packages/mcp-server/src/tools/action_get.ts | 4 +- packages/mcp-server/src/tools/action_list.ts | 4 +- .../mcp-server/src/tools/action_type_get.ts | 4 +- .../mcp-server/src/tools/action_types_list.ts | 4 +- .../mcp-server/src/tools/action_update.ts | 4 +- packages/mcp-server/src/tools/form_create.ts | 4 +- packages/mcp-server/src/tools/form_get.ts | 4 +- packages/mcp-server/src/tools/form_list.ts | 4 +- .../mcp-server/src/tools/form_revision_get.ts | 4 +- .../src/tools/form_revisions_list.ts | 4 +- packages/mcp-server/src/tools/form_update.ts | 4 +- packages/mcp-server/src/tools/hello.ts | 4 +- .../mcp-server/src/tools/project_export.ts | 4 +- packages/mcp-server/src/tools/project_get.ts | 4 +- .../mcp-server/src/tools/project_import.ts | 4 +- packages/mcp-server/src/tools/project_set.ts | 4 +- packages/mcp-server/src/tools/role_create.ts | 4 +- packages/mcp-server/src/tools/role_list.ts | 4 +- packages/mcp-server/src/tools/role_update.ts | 4 +- 31 files changed, 460 insertions(+), 137 deletions(-) create mode 100644 packages/mcp-server/src/__tests__/boolean-env.test.ts delete mode 100644 packages/mcp-server/src/__tests__/insecure-tls.test.ts create mode 100644 packages/mcp-server/src/__tests__/output-schemas-open.test.ts create mode 100644 packages/mcp-server/src/__tests__/output-schemas-replay.test.ts create mode 100644 packages/mcp-server/src/__tests__/package-exports.test.ts diff --git a/openspec/changes/freeze-1-0-tool-surface/tasks.md b/openspec/changes/freeze-1-0-tool-surface/tasks.md index 8731b1c0..2cb2e658 100644 --- a/openspec/changes/freeze-1-0-tool-surface/tasks.md +++ b/openspec/changes/freeze-1-0-tool-surface/tasks.md @@ -21,16 +21,16 @@ ### Red -- [ ] 2.1 Write a failing test that lists every tool and asserts no `outputSchema` contains `additionalProperties: false` at any level -- [ ] 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 +- [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 -- [ ] 2.3 Pass each `outputSchema` as `z.looseObject(...)` and let Mixed fields accept `null` in `output-schemas.ts` +- [x] 2.3 Pass each `outputSchema` as `z.looseObject(...)` and let Mixed fields accept `null` in `output-schemas.ts` ### Refactor -- [ ] 2.4 Review implementation and refactor as needed +- [x] 2.4 Review implementation and refactor as needed ## 3. Renames and server_status @@ -125,17 +125,17 @@ ### Red -- [ ] 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 -- [ ] 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 +- [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 -- [ ] 8.3 Implement `readBooleanEnv` in `config.ts` and use it for both variables -- [ ] 8.4 Add `exports` to `package.json` +- [x] 8.3 Implement `readBooleanEnv` in `config.ts` and use it for both variables +- [x] 8.4 Add `exports` to `package.json` ### Refactor -- [ ] 8.5 Review implementation and refactor as needed +- [x] 8.5 Review implementation and refactor as needed ## 9. Description budget 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__/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__/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__/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..0618212e --- /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 (_s, _c, { 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/config.ts b/packages/mcp-server/src/config.ts index 4fe5d35c..47d2b351 100644 --- a/packages/mcp-server/src/config.ts +++ b/packages/mcp-server/src/config.ts @@ -208,7 +208,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 +251,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 07139de6..da58c063 100644 --- a/packages/mcp-server/src/formio-client.ts +++ b/packages/mcp-server/src/formio-client.ts @@ -1,22 +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'; } diff --git a/packages/mcp-server/src/output-schemas.ts b/packages/mcp-server/src/output-schemas.ts index dbd38515..23cf0bf0 100644 --- a/packages/mcp-server/src/output-schemas.ts +++ b/packages/mcp-server/src/output-schemas.ts @@ -8,9 +8,11 @@ * 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 accept `null`, because that is a value Form.io returns for them. */ import { z } from 'zod'; @@ -22,7 +24,7 @@ const identity = { machineName: z.string().optional().describe('Machine-readable unique name within the project'), }; -export const formShape = { +export const formDocument = z.looseObject({ ...identity, title: z.string().optional().describe('Human-readable form title'), name: z.string().optional().describe('API name of the form'), @@ -36,25 +38,23 @@ export const formShape = { tags: z.array(z.string()).optional().describe('Tags applied to the form'), access: z .array(z.looseObject({})) - .optional() + .nullish() .describe('Role-based access to the form definition itself'), submissionAccess: z .array(z.looseObject({})) - .optional() + .nullish() .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'), + settings: z.looseObject({}).nullish().describe('Form-level settings'), + properties: z.looseObject({}).nullish().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 = { +export const roleDocument = z.looseObject({ ...identity, title: z.string().optional().describe('Human-readable role title'), description: z.string().optional().describe('What the role is for'), @@ -64,11 +64,9 @@ export const roleShape = { .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(roleShape); +}); -export const actionShape = { +export const actionDocument = z.looseObject({ ...identity, name: z.string().optional().describe('Action type name, e.g. "save", "login", "email"'), title: z.string().optional().describe('Human-readable action title'), @@ -82,14 +80,12 @@ export const actionShape = { .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'), + condition: z.looseObject({}).nullish().describe('Condition gating whether the action runs'), settings: z .looseObject({}) - .optional() + .nullish() .describe('Action-type-specific settings; see action_type_get for the schema'), -}; - -export const actionDocument = z.looseObject(actionShape); +}); export const actionTypeDocument = z.looseObject({ name: z.string().optional().describe('Action type name to pass as `name` to action_create'), @@ -99,7 +95,7 @@ export const actionTypeDocument = z.looseObject({ defaults: z.looseObject({}).optional().describe('Default settings applied on creation'), }); -export const actionTypeInfoShape = { +export const actionTypeInfoDocument = 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'), @@ -111,8 +107,8 @@ export const actionTypeInfoShape = { .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'), -}; + access: z.looseObject({}).nullish().describe('Access requirements for configuring the action'), +}); export const revisionSummaryDocument = z.looseObject({ _vid: z @@ -128,7 +124,7 @@ export const revisionSummaryDocument = z.looseObject({ .describe('User who published the revision'), }); -export const templateShape = { +export const templateDocument = z.looseObject({ title: z.string().optional().describe('Project title'), name: z.string().optional().describe('Project machine name'), version: z.string().optional().describe('Template schema version'), @@ -137,47 +133,49 @@ export const templateShape = { 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'), -}; +}); // 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 = { +export const formsListOutput = z.looseObject({ forms: z.array(formDocument).describe('Matching forms, in the requested sort order'), count: z.number().describe('Number of forms returned by this call'), -}; +}); -export const rolesListShape = { +export const rolesListOutput = z.looseObject({ roles: z.array(roleDocument).describe('Roles defined in the project'), count: z.number().describe('Number of roles returned'), -}; +}); -export const actionsListShape = { +export const actionsListOutput = z.looseObject({ actions: z.array(actionDocument).describe('Actions configured on the form'), count: z.number().describe('Number of actions returned'), -}; +}); -export const actionTypesListShape = { +export const actionTypesListOutput = z.looseObject({ actionTypes: z .array(actionTypeDocument) .describe('Action types this deployment supports for the form'), count: z.number().describe('Number of action types returned'), -}; +}); -export const revisionsListShape = { +export const revisionsListOutput = z.looseObject({ revisions: z .array(revisionSummaryDocument) .describe('Published revision summaries, newest first'), count: z.number().describe('Number of revisions returned'), -}; +}); /** For tools whose only meaningful answer is "it worked". */ -export const acknowledgementShape = { +const acknowledgementShape = { ok: z.boolean().describe('True when the operation completed'), message: z.string().describe('Human-readable result detail'), }; -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 @@ -210,7 +208,7 @@ export const projectMappingShape = { .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' ), -}; +}); /** * What `project_get` reports: the resolved configuration, where each half came @@ -221,7 +219,7 @@ 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 = { +export const projectResolutionOutput = z.looseObject({ status: z .enum(['ok', 'not-configured', 'base-url-unresolved']) .describe( @@ -264,7 +262,7 @@ export const projectResolutionShape = { ), message: z.string().describe('The full human-readable report, including what to do next'), remedy: z - .object({ + .looseObject({ tool: z.string().describe('The tool to call — always project_set'), arguments: z .record(z.string(), z.string()) @@ -284,4 +282,4 @@ export const projectResolutionShape = { .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' ), -}; +}); diff --git a/packages/mcp-server/src/tools/action_create.ts b/packages/mcp-server/src/tools/action_create.ts index 7d64aca6..118d4861 100644 --- a/packages/mcp-server/src/tools/action_create.ts +++ b/packages/mcp-server/src/tools/action_create.ts @@ -2,7 +2,7 @@ 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'; @@ -19,7 +19,7 @@ export function registerActionCreateTool(server: McpServer, config: FormioConfig formId: resourceSegmentArgument('formId').describe('The form ID to attach the action to'), action: actionDefinitionSchema, }, - outputSchema: actionShape, + outputSchema: actionDocument, annotations: creates('Create a form action'), }, async ({ cwd, formId, action }) => { diff --git a/packages/mcp-server/src/tools/action_delete.ts b/packages/mcp-server/src/tools/action_delete.ts index 37b54484..05e72f29 100644 --- a/packages/mcp-server/src/tools/action_delete.ts +++ b/packages/mcp-server/src/tools/action_delete.ts @@ -2,7 +2,7 @@ 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 { resourceSegmentArgument } from './path-arguments.js'; @@ -18,7 +18,7 @@ export function registerActionDeleteTool(server: McpServer, config: FormioConfig formId: resourceSegmentArgument('formId').describe('The form ID the action belongs to'), actionId: resourceSegmentArgument('actionId').describe('The action ID to delete'), }, - outputSchema: acknowledgementShape, + outputSchema: acknowledgementOutput, annotations: removes('Delete a form action'), }, async ({ cwd, formId, actionId }) => { diff --git a/packages/mcp-server/src/tools/action_get.ts b/packages/mcp-server/src/tools/action_get.ts index 388ae811..a9a8b4be 100644 --- a/packages/mcp-server/src/tools/action_get.ts +++ b/packages/mcp-server/src/tools/action_get.ts @@ -2,7 +2,7 @@ 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 { resourceSegmentArgument } from './path-arguments.js'; @@ -18,7 +18,7 @@ export function registerActionGetTool(server: McpServer, config: FormioConfig) { formId: resourceSegmentArgument('formId').describe('The form ID the action belongs to'), actionId: resourceSegmentArgument('actionId').describe('The action ID to retrieve'), }, - outputSchema: actionShape, + outputSchema: actionDocument, annotations: reads('Get a form action'), }, async ({ cwd, formId, actionId }) => { diff --git a/packages/mcp-server/src/tools/action_list.ts b/packages/mcp-server/src/tools/action_list.ts index 47ee5a9c..32f682bd 100644 --- a/packages/mcp-server/src/tools/action_list.ts +++ b/packages/mcp-server/src/tools/action_list.ts @@ -2,7 +2,7 @@ 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'; @@ -17,7 +17,7 @@ export function registerActionListTool(server: McpServer, config: FormioConfig) cwd: cwdSchema, formId: resourceSegmentArgument('formId').describe('The form ID to list actions for'), }, - outputSchema: actionsListShape, + outputSchema: actionsListOutput, annotations: reads('List form actions'), }, async ({ cwd, formId }) => { diff --git a/packages/mcp-server/src/tools/action_type_get.ts b/packages/mcp-server/src/tools/action_type_get.ts index 20e0c11f..227ab879 100644 --- a/packages/mcp-server/src/tools/action_type_get.ts +++ b/packages/mcp-server/src/tools/action_type_get.ts @@ -2,7 +2,7 @@ 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 { resourceSegmentArgument } from './path-arguments.js'; @@ -22,7 +22,7 @@ export function registerActionTypeGetTool(server: McpServer, config: FormioConfi 'The action type name (e.g. "email", "save", "login")' ), }, - outputSchema: actionTypeInfoShape, + outputSchema: actionTypeInfoDocument, annotations: reads('Get an action type'), }, async ({ cwd, formId, actionName }) => { diff --git a/packages/mcp-server/src/tools/action_types_list.ts b/packages/mcp-server/src/tools/action_types_list.ts index 08258d70..5687d637 100644 --- a/packages/mcp-server/src/tools/action_types_list.ts +++ b/packages/mcp-server/src/tools/action_types_list.ts @@ -2,7 +2,7 @@ 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 { actionTypesListOutput } from '../output-schemas.js'; import { reads } from '../tool-annotations.js'; import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; import { resourceSegmentArgument } from './path-arguments.js'; @@ -19,7 +19,7 @@ export function registerActionTypesListTool(server: McpServer, config: FormioCon 'The form ID to list available action types for' ), }, - outputSchema: actionTypesListShape, + outputSchema: actionTypesListOutput, annotations: reads('List action types'), }, async ({ cwd, formId }) => { diff --git a/packages/mcp-server/src/tools/action_update.ts b/packages/mcp-server/src/tools/action_update.ts index 62fc79a3..aa357b39 100644 --- a/packages/mcp-server/src/tools/action_update.ts +++ b/packages/mcp-server/src/tools/action_update.ts @@ -2,7 +2,7 @@ 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'; @@ -20,7 +20,7 @@ export function registerActionUpdateTool(server: McpServer, config: FormioConfig 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 }) => { diff --git a/packages/mcp-server/src/tools/form_create.ts b/packages/mcp-server/src/tools/form_create.ts index f32eee49..d39c4e7c 100644 --- a/packages/mcp-server/src/tools/form_create.ts +++ b/packages/mcp-server/src/tools/form_create.ts @@ -3,7 +3,7 @@ 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 { gateRevisionsLicense, prefixVnote } from '../revisions/index.js'; @@ -39,7 +39,7 @@ export function registerFormCreateTool(server: McpServer, config: FormioConfig) .describe('Form.io form JSON definition'), note: z.string().optional().describe('Note describing the initial revision'), }, - outputSchema: formShape, + outputSchema: formDocument, annotations: creates('Create a form'), }, async ({ cwd, form: rawForm, note }) => { diff --git a/packages/mcp-server/src/tools/form_get.ts b/packages/mcp-server/src/tools/form_get.ts index cd6921a2..009b2e2d 100644 --- a/packages/mcp-server/src/tools/form_get.ts +++ b/packages/mcp-server/src/tools/form_get.ts @@ -3,7 +3,7 @@ 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 { projectPathArgument } from './path-arguments.js'; @@ -28,7 +28,7 @@ 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 }) => { diff --git a/packages/mcp-server/src/tools/form_list.ts b/packages/mcp-server/src/tools/form_list.ts index 38840d98..4d63b552 100644 --- a/packages/mcp-server/src/tools/form_list.ts +++ b/packages/mcp-server/src/tools/form_list.ts @@ -3,7 +3,7 @@ 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'; @@ -28,7 +28,7 @@ export function registerFormListTool(server: McpServer, config: FormioConfig) { .describe('Comma-separated fields to return (default: _id,title,name,path,type,tags)'), tags: z.array(z.string()).optional().describe('Filter by tags'), }, - outputSchema: formsListShape, + outputSchema: formsListOutput, annotations: reads('List forms'), }, async ({ cwd, type, limit, skip, sort, select, tags }) => { diff --git a/packages/mcp-server/src/tools/form_revision_get.ts b/packages/mcp-server/src/tools/form_revision_get.ts index 56d734f8..659efcd8 100644 --- a/packages/mcp-server/src/tools/form_revision_get.ts +++ b/packages/mcp-server/src/tools/form_revision_get.ts @@ -2,7 +2,7 @@ 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 { projectPathArgument, resourceSegmentArgument } from './path-arguments.js'; @@ -24,7 +24,7 @@ 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 }) => { diff --git a/packages/mcp-server/src/tools/form_revisions_list.ts b/packages/mcp-server/src/tools/form_revisions_list.ts index 60bcf90f..42fc904a 100644 --- a/packages/mcp-server/src/tools/form_revisions_list.ts +++ b/packages/mcp-server/src/tools/form_revisions_list.ts @@ -2,7 +2,7 @@ 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 { revisionsListOutput } from '../output-schemas.js'; import { reads } from '../tool-annotations.js'; import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; import { projectPathArgument } from './path-arguments.js'; @@ -19,7 +19,7 @@ export function registerFormRevisionsListTool(server: McpServer, config: FormioC 'Form ID (_id) or path alias (e.g. "user/login")' ), }, - outputSchema: revisionsListShape, + outputSchema: revisionsListOutput, annotations: reads('List form revisions'), }, async ({ cwd, formIdOrPath }) => { diff --git a/packages/mcp-server/src/tools/form_update.ts b/packages/mcp-server/src/tools/form_update.ts index 1a072f17..f8898efc 100644 --- a/packages/mcp-server/src/tools/form_update.ts +++ b/packages/mcp-server/src/tools/form_update.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { FormioConfig } from '../config.js'; import { formioFetch, MONGO_ID_PATTERN } 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 { @@ -80,7 +80,7 @@ export function registerFormUpdateTool(server: McpServer, config: FormioConfig) 'Revision identifier for `revert: true` — either the revision `_vid` (e.g. "3") or the revision document `_id` (24-char hex).' ), }, - outputSchema: formShape, + outputSchema: formDocument, annotations: overwrites('Update a form'), }, async ({ cwd, formId, form: rawForm, note, draft, publish, revert, version }) => { diff --git a/packages/mcp-server/src/tools/hello.ts b/packages/mcp-server/src/tools/hello.ts index 27958d1d..c827edd1 100644 --- a/packages/mcp-server/src/tools/hello.ts +++ b/packages/mcp-server/src/tools/hello.ts @@ -10,7 +10,9 @@ export function registerHelloTool(server: McpServer) { 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') }, + outputSchema: z.looseObject({ + greeting: z.string().describe('The greeting this server produced'), + }), annotations: local('Check the server is reachable', true), }, async ({ name }) => { diff --git a/packages/mcp-server/src/tools/project_export.ts b/packages/mcp-server/src/tools/project_export.ts index 4bf94315..7c722dc8 100644 --- a/packages/mcp-server/src/tools/project_export.ts +++ b/packages/mcp-server/src/tools/project_export.ts @@ -2,7 +2,7 @@ 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'; @@ -15,7 +15,7 @@ export function registerProjectExportTool(server: McpServer, config: FormioConfi inputSchema: { cwd: cwdSchema, }, - outputSchema: templateShape, + outputSchema: templateDocument, annotations: reads('Export the project template'), }, async ({ cwd }) => { diff --git a/packages/mcp-server/src/tools/project_get.ts b/packages/mcp-server/src/tools/project_get.ts index 949881c5..368323e3 100644 --- a/packages/mcp-server/src/tools/project_get.ts +++ b/packages/mcp-server/src/tools/project_get.ts @@ -1,7 +1,7 @@ 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'; @@ -27,7 +27,7 @@ export function registerProjectGetTool( 'Reads only. It resolves and reports; project_set is what records a choice.', ].join(' '), inputSchema: { cwd: cwdSchema }, - outputSchema: projectResolutionShape, + outputSchema: projectResolutionOutput, annotations: local('Report the project this directory resolves to', true), }, async ({ cwd }) => { diff --git a/packages/mcp-server/src/tools/project_import.ts b/packages/mcp-server/src/tools/project_import.ts index a9f4fea5..c2d462ec 100644 --- a/packages/mcp-server/src/tools/project_import.ts +++ b/packages/mcp-server/src/tools/project_import.ts @@ -3,7 +3,7 @@ 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'; @@ -17,7 +17,7 @@ export function registerProjectImportTool(server: McpServer, config: FormioConfi 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 }) => { diff --git a/packages/mcp-server/src/tools/project_set.ts b/packages/mcp-server/src/tools/project_set.ts index 6532c5b0..df3336f1 100644 --- a/packages/mcp-server/src/tools/project_set.ts +++ b/packages/mcp-server/src/tools/project_set.ts @@ -20,7 +20,7 @@ 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 { projectMappingOutput } from '../output-schemas.js'; import { local } from '../tool-annotations.js'; import { readProjectEntryForWrite, @@ -97,7 +97,7 @@ export function registerProjectSetTool(server: McpServer, options: ProjectSetOpt '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.' ), }), - outputSchema: projectMappingShape, + outputSchema: projectMappingOutput, // Writes only to the local project map — no Form.io request involved. annotations: local('Set the active project', false), }, diff --git a/packages/mcp-server/src/tools/role_create.ts b/packages/mcp-server/src/tools/role_create.ts index 83a875b4..c03681ab 100644 --- a/packages/mcp-server/src/tools/role_create.ts +++ b/packages/mcp-server/src/tools/role_create.ts @@ -3,7 +3,7 @@ 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'; @@ -19,7 +19,7 @@ export function registerRoleCreateTool(server: McpServer, config: FormioConfig) ...roleFields, title: z.string().describe('Role title'), }, - outputSchema: roleShape, + outputSchema: roleDocument, annotations: creates('Create a role'), }, async ({ cwd, ...role }) => { diff --git a/packages/mcp-server/src/tools/role_list.ts b/packages/mcp-server/src/tools/role_list.ts index eb66c0c1..7a149a36 100644 --- a/packages/mcp-server/src/tools/role_list.ts +++ b/packages/mcp-server/src/tools/role_list.ts @@ -3,7 +3,7 @@ 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'; @@ -17,7 +17,7 @@ export function registerRoleListTool(server: McpServer, config: FormioConfig) { cwd: cwdSchema, select: z.string().optional().describe('Comma-separated fields to return'), }, - outputSchema: rolesListShape, + outputSchema: rolesListOutput, annotations: reads('List roles'), }, async ({ cwd, select }) => { diff --git a/packages/mcp-server/src/tools/role_update.ts b/packages/mcp-server/src/tools/role_update.ts index 0c433f65..07851906 100644 --- a/packages/mcp-server/src/tools/role_update.ts +++ b/packages/mcp-server/src/tools/role_update.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { FormioConfig } from '../config.js'; import { formioFetch, MONGO_ID_PATTERN } 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'; @@ -25,7 +25,7 @@ export function registerRoleUpdateTool(server: McpServer, config: FormioConfig) .catchall(z.unknown()) .describe('Role document with updated fields'), }, - outputSchema: roleShape, + outputSchema: roleDocument, annotations: overwrites('Update a role'), }, async ({ cwd, roleId, role }) => { From 7a24224c26dd171ca18396f6b940be659f73c0c4 Mon Sep 17 00:00:00 2001 From: Travis Tidwell Date: Fri, 9 Oct 2026 10:06:03 -0500 Subject: [PATCH 04/13] feat(mcp)!: singular tool names, server_status, one list contract action_types_list -> action_type_list, form_revisions_list -> form_revision_list, hello -> server_status (server name, version and the cwd's project resolution, no Form.io request). form_list, role_list, action_list and form_revision_list share limit (default 100) / skip / sort / select and return total + hasMore read from Content-Range; a skip past the end is an empty page. form_list tags match all given tags; form_revision_list is newest first with compact fields. Co-Authored-By: Claude Opus 5.5 --- .../changes/freeze-1-0-tool-surface/tasks.md | 22 +-- .../src/__tests__/action_list.test.ts | 63 +++++++-- ..._list.test.ts => action_type_list.test.ts} | 31 +++-- .../src/__tests__/capability-quality.test.ts | 12 ++ .../src/__tests__/form_list.test.ts | 131 +++++++++--------- .../src/__tests__/form_revision_list.test.ts | 92 ++++++++++++ .../src/__tests__/form_revisions_list.test.ts | 49 ------- .../src/__tests__/formio-client-page.test.ts | 104 ++++++++++++++ .../mcp-server/src/__tests__/hello.test.ts | 38 ----- .../src/__tests__/list-contract.test.ts | 78 +++++++++++ .../src/__tests__/mcpb-build.test.ts | 7 +- .../project-resolution-errors.test.ts | 8 +- .../src/__tests__/role_list.test.ts | 53 ++++--- .../src/__tests__/server-empty-env.test.ts | 6 +- .../src/__tests__/server_status.test.ts | 99 +++++++++++++ .../src/__tests__/tool-path-arguments.test.ts | 4 +- packages/mcp-server/src/cli-launch.ts | 3 + packages/mcp-server/src/formio-client.ts | 90 +++++++++--- packages/mcp-server/src/output-schemas.ts | 84 +++++++---- packages/mcp-server/src/server.ts | 4 +- packages/mcp-server/src/tools/action_list.ts | 14 +- ...tion_types_list.ts => action_type_list.ts} | 17 +-- packages/mcp-server/src/tools/form_list.ts | 41 +++--- .../src/tools/form_revision_list.ts | 47 +++++++ .../src/tools/form_revisions_list.ts | 36 ----- packages/mcp-server/src/tools/hello.ts | 25 ---- packages/mcp-server/src/tools/index.ts | 13 +- .../mcp-server/src/tools/list-contract.ts | 112 +++++++++++++++ .../src/tools/project-resolution.ts | 71 ++++++++++ packages/mcp-server/src/tools/project_get.ts | 49 +------ packages/mcp-server/src/tools/role_list.ts | 14 +- .../mcp-server/src/tools/server_status.ts | 49 +++++++ scripts/build-mcpb.ts | 4 +- 33 files changed, 1042 insertions(+), 428 deletions(-) rename packages/mcp-server/src/__tests__/{action_types_list.test.ts => action_type_list.test.ts} (69%) create mode 100644 packages/mcp-server/src/__tests__/form_revision_list.test.ts delete mode 100644 packages/mcp-server/src/__tests__/form_revisions_list.test.ts create mode 100644 packages/mcp-server/src/__tests__/formio-client-page.test.ts delete mode 100644 packages/mcp-server/src/__tests__/hello.test.ts create mode 100644 packages/mcp-server/src/__tests__/list-contract.test.ts create mode 100644 packages/mcp-server/src/__tests__/server_status.test.ts rename packages/mcp-server/src/tools/{action_types_list.ts => action_type_list.ts} (59%) create mode 100644 packages/mcp-server/src/tools/form_revision_list.ts delete mode 100644 packages/mcp-server/src/tools/form_revisions_list.ts delete mode 100644 packages/mcp-server/src/tools/hello.ts create mode 100644 packages/mcp-server/src/tools/list-contract.ts create mode 100644 packages/mcp-server/src/tools/project-resolution.ts create mode 100644 packages/mcp-server/src/tools/server_status.ts diff --git a/openspec/changes/freeze-1-0-tool-surface/tasks.md b/openspec/changes/freeze-1-0-tool-surface/tasks.md index 2cb2e658..aee699d2 100644 --- a/openspec/changes/freeze-1-0-tool-surface/tasks.md +++ b/openspec/changes/freeze-1-0-tool-surface/tasks.md @@ -37,35 +37,35 @@ ### Red -- [ ] 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` -- [ ] 3.2 Write failing `server_status` tests: unconfigured → version + `status: "not-configured"` with no Form.io request; configured → version, status, both URLs and sources +- [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 -- [ ] 3.3 Rename the two list tools and their files/tests; replace `hello` with `server_status` built on `reportProject` -- [ ] 3.4 Update `capability-quality`, `server-empty-env`, `project-resolution-errors` and `mcpb-build` tests and `scripts/build-mcpb.ts`'s smoke call +- [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 -- [ ] 3.5 Review implementation and refactor as needed +- [x] 3.5 Review implementation and refactor as needed ## 4. List contract ### Red -- [ ] 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` -- [ ] 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 -- [ ] 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` +- [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 -- [ ] 4.4 Implement `withMeta` and the 416 handling in `formio-client.ts` -- [ ] 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 +- [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 -- [ ] 4.6 Review implementation and refactor as needed +- [x] 4.6 Review implementation and refactor as needed ## 5. form_publish, form_revert, form_update draft diff --git a/packages/mcp-server/src/__tests__/action_list.test.ts b/packages/mcp-server/src/__tests__/action_list.test.ts index ad5793a3..5c8440c4 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,27 +33,60 @@ 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); 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 69% 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..c0c6f677 100644 --- a/packages/mcp-server/src/__tests__/action_types_list.test.ts +++ b/packages/mcp-server/src/__tests__/action_type_list.test.ts @@ -10,50 +10,57 @@ 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', + name: 'action_type_list', arguments: { cwd: TEST_CWD, formId: 'nonexistent0000000000000' }, }); diff --git a/packages/mcp-server/src/__tests__/capability-quality.test.ts b/packages/mcp-server/src/__tests__/capability-quality.test.ts index e147f6d8..b97cb81d 100644 --- a/packages/mcp-server/src/__tests__/capability-quality.test.ts +++ b/packages/mcp-server/src/__tests__/capability-quality.test.ts @@ -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(); diff --git a/packages/mcp-server/src/__tests__/form_list.test.ts b/packages/mcp-server/src/__tests__/form_list.test.ts index 3ceb364b..46a8e72c 100644 --- a/packages/mcp-server/src/__tests__/form_list.test.ts +++ b/packages/mcp-server/src/__tests__/form_list.test.ts @@ -8,134 +8,133 @@ 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( - 'form', - expect.objectContaining({ limit: '5' }), - TEST_CONFIG - ); - }); - - it('passes skip and limit for pagination', async () => { - mockFormioFetch.mockResolvedValue([]); - const { client } = await createTestClient(registerFormListTool); - - await client.callTool({ name: 'form_list', arguments: { cwd: TEST_CWD, skip: 20, limit: 10 } }); - - 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({ skip: '20', limit: '10' }), - TEST_CONFIG + expect.objectContaining({ limit: '100', skip: '100' }), + TEST_CONFIG, + { withMeta: true } ); }); - it('passes sort parameter', async () => { - mockFormioFetch.mockResolvedValue([]); + it('passes type parameter', 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, type: 'resource' } }); expect(mockFormioFetch).toHaveBeenCalledWith( 'form', - expect.objectContaining({ sort: '-created' }), - TEST_CONFIG + expect.objectContaining({ type: 'resource' }), + TEST_CONFIG, + { withMeta: true } ); }); - it('overrides default select when provided', 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, select: '_id,title,components' }, + arguments: { cwd: TEST_CWD, limit: 10, skip: 20, sort: '-created', select: '_id,title' }, }); expect(mockFormioFetch).toHaveBeenCalledWith( 'form', - expect.objectContaining({ select: '_id,title,components' }), - TEST_CONFIG + { limit: '10', skip: '20', sort: '-created', select: '_id,title' }, + TEST_CONFIG, + { withMeta: true } ); }); - it('joins tags array into comma-separated string', 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, tags: ['survey', 'public'] }, + arguments: { cwd: TEST_CWD, tags: ['crm', 'intake'] }, }); - expect(mockFormioFetch).toHaveBeenCalledWith( - 'form', - expect.objectContaining({ tags: 'survey,public' }), - TEST_CONFIG - ); + const params = mockFormioFetch.mock.calls[0][1] as Record; + expect(params.tags__all).toBe('crm,intake'); + expect(params.tags).toBeUndefined(); }); + 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_revision_list.test.ts b/packages/mcp-server/src/__tests__/form_revision_list.test.ts new file mode 100644 index 00000000..8cdf1f29 --- /dev/null +++ b/packages/mcp-server/src/__tests__/form_revision_list.test.ts @@ -0,0 +1,92 @@ +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`, + { 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', + { 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 }); + }); +}); 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__/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__/list-contract.test.ts b/packages/mcp-server/src/__tests__/list-contract.test.ts new file mode 100644 index 00000000..8169f833 --- /dev/null +++ b/packages/mcp-server/src/__tests__/list-contract.test.ts @@ -0,0 +1,78 @@ +/** + * 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 { 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, 'total', 'hasMore'])); + expect(output.properties).not.toHaveProperty('count'); + } + ); + + describe('pageOf', () => { + it('has more while skip plus the page is short of the total', () => { + expect(pageOf({ items: [1, 2], total: 5, skip: 0 })).toEqual({ + items: [1, 2], + total: 5, + hasMore: true, + }); + expect(pageOf({ items: [4, 5], total: 5, skip: 3 })).toMatchObject({ hasMore: false }); + }); + + it('has no more past the end', () => { + expect(pageOf({ items: [], total: 3, skip: 500 })).toEqual({ + items: [], + total: 3, + hasMore: false, + }); + }); + + it('counts what it was given when Form.io reports no total', () => { + expect(pageOf({ items: [1, 2], total: undefined, skip: 10 })).toEqual({ + items: [1, 2], + total: 12, + hasMore: false, + }); + }); + }); +}); 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__/project-resolution-errors.test.ts b/packages/mcp-server/src/__tests__/project-resolution-errors.test.ts index 980ff7cc..b52b52c3 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,12 @@ 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_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_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 +79,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__/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__/server-empty-env.test.ts b/packages/mcp-server/src/__tests__/server-empty-env.test.ts index 12258363..68fde96c 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,17 @@ 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_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__/tool-path-arguments.test.ts b/packages/mcp-server/src/__tests__/tool-path-arguments.test.ts index 062e41de..c32430b3 100644 --- a/packages/mcp-server/src/__tests__/tool-path-arguments.test.ts +++ b/packages/mcp-server/src/__tests__/tool-path-arguments.test.ts @@ -24,7 +24,7 @@ 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, @@ -39,7 +39,7 @@ const VALID_ARGUMENTS: Record> = { 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/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/formio-client.ts b/packages/mcp-server/src/formio-client.ts index da58c063..fd2c75a3 100644 --- a/packages/mcp-server/src/formio-client.ts +++ b/packages/mcp-server/src/formio-client.ts @@ -21,6 +21,49 @@ 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 }; +} + +async function readResponse( + response: Response, + url: URL, + options?: FormioFetchOptions +): 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 @@ -80,31 +123,22 @@ export async function formioRawFetch( options?: FormioFetchOptions ): Promise { const response = await send(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 send(url, buildFetchInit(config, options)); - if (!retryResponse.ok) { - throw await apiError(retryResponse, url); - } - return parseResponse(retryResponse); - } - throw await apiError(response, url); + 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(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 @@ -118,6 +152,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, diff --git a/packages/mcp-server/src/output-schemas.ts b/packages/mcp-server/src/output-schemas.ts index 23cf0bf0..1996d65d 100644 --- a/packages/mcp-server/src/output-schemas.ts +++ b/packages/mcp-server/src/output-schemas.ts @@ -117,11 +117,9 @@ export const revisionSummaryDocument = z.looseObject({ .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'), + _vuser: z.string().nullish().describe('User who published the revision'), + created: z.string().optional().describe('ISO 8601 timestamp the revision was created'), 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'), }); export const templateDocument = z.looseObject({ @@ -136,36 +134,43 @@ export const templateDocument = z.looseObject({ }); // 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 formsListOutput = z.looseObject({ - 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, so +// `hasMore` can say whether another page exists. +function listOutput(key: string, item: z.ZodType, description: string) { + return z.looseObject({ + [key]: z.array(item).describe(description), + total: z.number().describe('Number of items Form.io holds for this query, across all pages'), + hasMore: z + .boolean() + .describe('True when items remain past this page; fetch them with a larger `skip`'), + }); +} -export const rolesListOutput = z.looseObject({ - roles: z.array(roleDocument).describe('Roles defined in the project'), - count: z.number().describe('Number of roles returned'), -}); +export const formsListOutput = listOutput( + 'forms', + formDocument, + 'Matching forms, in the requested sort order' +); -export const actionsListOutput = z.looseObject({ - actions: z.array(actionDocument).describe('Actions configured on the form'), - count: z.number().describe('Number of actions returned'), -}); +export const rolesListOutput = listOutput('roles', roleDocument, 'Roles defined in the project'); -export const actionTypesListOutput = z.looseObject({ - 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, + 'Actions configured on the form' +); -export const revisionsListOutput = z.looseObject({ - revisions: z - .array(revisionSummaryDocument) - .describe('Published revision summaries, newest first'), - count: z.number().describe('Number of revisions returned'), -}); +export const actionTypesListOutput = listOutput( + 'actionTypes', + actionTypeDocument, + 'Every action type this deployment supports for the form' +); + +export const revisionsListOutput = listOutput( + 'revisions', + revisionSummaryDocument, + 'Revision summaries, newest first unless `sort` says otherwise' +); /** For tools whose only meaningful answer is "it worked". */ const acknowledgementShape = { @@ -283,3 +288,22 @@ export const projectResolutionOutput = z.looseObject({ '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' ), }); + +/** + * 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().describe('Server name'), + version: z.string().describe('Server version'), + status: z + .enum(['ok', 'not-configured', 'base-url-unresolved']) + .describe("How the directory resolves, as project_get's `status` reports it"), + cwd: z.string().describe('Working directory the resolution was performed for'), + projectUrl: z.string().optional().describe('Project URL that resolves for that directory'), + baseUrl: z.string().optional().describe('Deployment hosting that project'), + projectUrlSource: z.string().optional().describe('Which layer supplied the project URL'), + baseUrlSource: z.string().optional().describe('Which layer supplied the base URL'), + message: z.string().describe('The resolution report, including what to do next'), +}); diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 1ec4c41a..3018c137 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 @@ -28,7 +28,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/tools/action_list.ts b/packages/mcp-server/src/tools/action_list.ts index 32f682bd..09abb642 100644 --- a/packages/mcp-server/src/tools/action_list.ts +++ b/packages/mcp-server/src/tools/action_list.ts @@ -1,10 +1,10 @@ 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 { actionsListOutput } from '../output-schemas.js'; import { reads } from '../tool-annotations.js'; import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; +import { fetchListPage, listArguments, listResult } from './list-contract.js'; import { resourceSegmentArgument } from './path-arguments.js'; export function registerActionListTool(server: McpServer, config: FormioConfig) { @@ -12,22 +12,20 @@ export function registerActionListTool(server: McpServer, config: FormioConfig) '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'), + ...listArguments(), }, 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 }); + 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_types_list.ts b/packages/mcp-server/src/tools/action_type_list.ts similarity index 59% rename from packages/mcp-server/src/tools/action_types_list.ts rename to packages/mcp-server/src/tools/action_type_list.ts index 5687d637..39f58303 100644 --- a/packages/mcp-server/src/tools/action_types_list.ts +++ b/packages/mcp-server/src/tools/action_type_list.ts @@ -5,14 +5,15 @@ import { toMcpStructuredResult, toMcpError } from '../mcp-responses.js'; import { actionTypesListOutput } from '../output-schemas.js'; import { reads } from '../tool-annotations.js'; import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; +import { listResult, pageOf } from './list-contract.js'; import { resourceSegmentArgument } from './path-arguments.js'; -export function registerActionTypesListTool(server: McpServer, config: FormioConfig) { +export function registerActionTypeListTool(server: McpServer, config: FormioConfig) { server.registerTool( - 'action_types_list', + 'action_type_list', { description: - 'List available action types for a form. Returns the catalog of action types the server supports.', + 'List the action types available for a form. Returns the whole catalog the server supports in one result.', inputSchema: { cwd: cwdSchema, formId: resourceSegmentArgument('formId').describe( @@ -25,11 +26,11 @@ export function registerActionTypesListTool(server: McpServer, config: FormioCon 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 }); + // 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[]) : []; + const page = pageOf({ items, total: items.length, skip: 0 }); + return toMcpStructuredResult(listResult('actionTypes', page)); } catch (error) { return toMcpError(error); } diff --git a/packages/mcp-server/src/tools/form_list.ts b/packages/mcp-server/src/tools/form_list.ts index 4d63b552..a32cb4cf 100644 --- a/packages/mcp-server/src/tools/form_list.ts +++ b/packages/mcp-server/src/tools/form_list.ts @@ -1,49 +1,46 @@ 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 { formsListOutput } from '../output-schemas.js'; import { reads } from '../tool-annotations.js'; import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; +import { fetchListPage, listArguments, listResult } from './list-contract.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' }; 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 Form.io project mapped to the user's current working directory, 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: 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 }); + // `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_revision_list.ts b/packages/mcp-server/src/tools/form_revision_list.ts new file mode 100644 index 00000000..0405e155 --- /dev/null +++ b/packages/mcp-server/src/tools/form_revision_list.ts @@ -0,0 +1,47 @@ +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, resolveProjectConfig } from '../project-resolver.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' }; + +export function registerFormRevisionListTool(server: McpServer, config: FormioConfig) { + server.registerTool( + 'form_revision_list', + { + description: + 'List published revision summaries for a single form in the Form.io project mapped to the current working directory, newest first, one page at a time. 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")' + ), + ...listArguments(DEFAULTS), + }, + outputSchema: revisionsListOutput, + annotations: reads('List form revisions'), + }, + async ({ cwd, formIdOrPath, ...query }) => { + try { + const cfg = resolveProjectConfig(cwd, config); + const base = isMongoId(formIdOrPath) ? `form/${formIdOrPath}` : formIdOrPath; + const page = await fetchListPage({ + path: `${base}/v`, + query, + config: cfg, + defaults: DEFAULTS, + }); + 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 42fc904a..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 { revisionsListOutput } 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: revisionsListOutput, - 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/hello.ts b/packages/mcp-server/src/tools/hello.ts deleted file mode 100644 index c827edd1..00000000 --- a/packages/mcp-server/src/tools/hello.ts +++ /dev/null @@ -1,25 +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: z.looseObject({ - 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..9d320997 100644 --- a/packages/mcp-server/src/tools/index.ts +++ b/packages/mcp-server/src/tools/index.ts @@ -5,15 +5,14 @@ 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 { 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 +20,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,12 +31,13 @@ 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); registerProjectExportTool(server, config); // Reports what the tools around it will resolve, so it takes the same config @@ -57,7 +58,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..f269fa7e --- /dev/null +++ b/packages/mcp-server/src/tools/list-contract.ts @@ -0,0 +1,112 @@ +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. + */ +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('Maximum number of items to return'), + skip: z + .number() + .int() + .nonnegative() + .default(0) + .describe("Number of items to skip; page with the result's `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[]; + total: number; + hasMore: boolean; +} + +export interface PageOfRequest { + items: T[]; + /** The total Form.io reported; absent when it reported none. */ + total: number | undefined; + skip: number; +} + +/** + * A page and where it sits. Without a reported total, the items seen so far are all + * that is known to exist. + */ +export function pageOf({ items, total, skip }: PageOfRequest): ListPage { + const known = total ?? skip + items.length; + return { items, total: known, hasMore: skip + items.length < known }; +} + +/** A page as a tool result: the items under the tool's own key, beside `total` and `hasMore`. */ +export function listResult(key: string, page: ListPage): Record { + return { [key]: page.items, 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 }); +} 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..914272e1 --- /dev/null +++ b/packages/mcp-server/src/tools/project-resolution.ts @@ -0,0 +1,71 @@ +import { FormioConfig } from '../config.js'; +import { ProjectReport, reportProject } from '../project-report.js'; +import { TOOL_REMEDIES } from './project-remedies.js'; + +export interface ToolProjectReportRequest { + /** The cwd the caller passed, if any. */ + cwd: string | undefined; + /** The server's own process directory, used when the caller passed none. */ + serverCwd: () => string; + 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({ + cwd, + serverCwd, + config, + notes, +}: ToolProjectReportRequest): ProjectReport { + return 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 ?? serverCwd(), + 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, + }); +} + +/** The report as a structured payload, omitting the halves it does not have. */ +export function projectReportPayload(report: ProjectReport): Record { + return { + 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, + }; +} + +/** + * 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_get.ts b/packages/mcp-server/src/tools/project_get.ts index 368323e3..8e51a59a 100644 --- a/packages/mcp-server/src/tools/project_get.ts +++ b/packages/mcp-server/src/tools/project_get.ts @@ -3,9 +3,12 @@ import { FormioConfig } from '../config.js'; import { toMcpError, toMcpStructuredResult } from '../mcp-responses.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, +} from './project-resolution.js'; export interface ProjectGetOptions { cwd?: () => string; @@ -31,48 +34,10 @@ export function registerProjectGetTool( annotations: local('Report the project this directory resolves to', 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, - }); - 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') - ); + const report = reportProjectForTool({ cwd, serverCwd: getServerCwd, config, notes }); + return toMcpStructuredResult(projectReportPayload(report), projectReportText(report)); } catch (error) { // "Could not answer at all" — an unreadable map, a formio.json that will // not parse. Deliberately NOT a "not-configured" status: that status diff --git a/packages/mcp-server/src/tools/role_list.ts b/packages/mcp-server/src/tools/role_list.ts index 7a149a36..67ee946a 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 { rolesListOutput } from '../output-schemas.js'; import { reads } from '../tool-annotations.js'; import { cwdSchema, resolveProjectConfig } from '../project-resolver.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.", + "List the roles defined in the Form.io project mapped to the user's current working directory, one page at a time.", inputSchema: { cwd: cwdSchema, - select: z.string().optional().describe('Comma-separated fields to return'), + ...listArguments(), }, 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 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/server_status.ts b/packages/mcp-server/src/tools/server_status.ts new file mode 100644 index 00000000..c2f9b670 --- /dev/null +++ b/packages/mcp-server/src/tools/server_status.ts @@ -0,0 +1,49 @@ +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, +} 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 the given working directory resolves to a Form.io project (the same answer project_get gives). Makes no Form.io request and needs no project or credentials, so it is the first thing to call 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 report = reportProjectForTool({ cwd, serverCwd: getServerCwd, config, notes }); + return toMcpStructuredResult( + { name: SERVER_NAME, version: SERVER_VERSION, ...projectReportPayload(report) }, + [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/scripts/build-mcpb.ts b/scripts/build-mcpb.ts index c180c244..adbb7b43 100644 --- a/scripts/build-mcpb.ts +++ b/scripts/build-mcpb.ts @@ -146,7 +146,7 @@ function manifestObject(version: string, tools: object[]) { 'Gives an assistant first-class tools over a Form.io project, so building a form, ' + 'wiring its server-side actions, and setting up roles is a conversation rather than a ' + 'REST session. Covers forms and revisions, roles, actions, and whole-project template ' + - 'import and export, plus a no-auth `hello` tool for checking the connection.', + 'import and export, plus a no-auth `server_status` tool for checking the connection.', author: { name: 'Form.io', url: 'https://form.io' }, repository: { type: 'git', url: 'https://github.com/formio/ai.git' }, homepage: 'https://form.io/ai', @@ -185,7 +185,7 @@ function manifestObject(version: string, tools: object[]) { // route to a project. // // Not marked required, because it is not: the server starts, lists its tools - // and answers `hello` without it, and the tools that do need a project say so + // and answers `server_status` without it, and the tools that do need a project say so // when called. Declaring it required told hosts to block on a value the // server can run without, which makes the server harder to try than it is. formio_project_url: { From 89f540478fb4bea9650768c8ae0acc5d49a926ea Mon Sep 17 00:00:00 2001 From: Travis Tidwell Date: Fri, 9 Oct 2026 10:19:55 -0500 Subject: [PATCH 05/13] feat(mcp)!: form_publish and form_revert; acceptNoHistory replaces consent prompts form_publish (formId, note) and form_revert (formId, version, note) are their own tools; form_update keeps the full update and draft: true, and a draft save takes the draft fields of the body it is given. Saving without revision history - unlicensed deployment, a form with revisions off, or an explicit revisions: "" - requires acceptNoHistory: true, otherwise the write is refused with HISTORY_NOT_ACCEPTED. The elicitation and local browser consent pages and the persisted consent file are removed. Co-Authored-By: Claude Opus 5.5 --- .../revisions-history-acceptance/spec.md | 8 +- .../changes/freeze-1-0-tool-surface/tasks.md | 24 +- ...auth-urls-come-from-the-deployment.test.ts | 5 +- .../__tests__/browser-prompts-launch.test.ts | 72 ---- .../src/__tests__/capability-quality.test.ts | 11 +- .../src/__tests__/form_create.test.ts | 21 +- .../mcp-server/src/__tests__/form_get.test.ts | 6 +- .../src/__tests__/form_publish.test.ts | 137 +++++++ .../src/__tests__/form_revert.test.ts | 154 ++++++++ .../src/__tests__/form_update.test.ts | 225 ++++-------- .../src/__tests__/local-server-edges.test.ts | 20 +- .../__tests__/output-schemas-replay.test.ts | 2 +- .../project-resolution-errors.test.ts | 2 + .../src/__tests__/revisions-flows.test.ts | 52 +++ .../revisions-history-acceptance.test.ts | 336 ++++++++++++++++++ .../revisions-license-base-url.test.ts | 71 ++-- .../src/__tests__/revisions.test.ts | 292 ++++----------- .../src/__tests__/runnable-remedies.test.ts | 15 +- .../src/__tests__/server-empty-env.test.ts | 2 + .../mcp-server/src/__tests__/test-helpers.ts | 28 ++ .../src/__tests__/tool-error-contract.test.ts | 2 +- .../src/__tests__/tool-path-arguments.test.ts | 10 +- .../src/revisions/browser-prompts.ts | 296 --------------- packages/mcp-server/src/revisions/flows.ts | 47 +-- packages/mcp-server/src/revisions/history.ts | 79 ++++ packages/mcp-server/src/revisions/index.ts | 6 +- packages/mcp-server/src/revisions/license.ts | 238 ++++--------- packages/mcp-server/src/revisions/tracking.ts | 127 ------- packages/mcp-server/src/tools/form_create.ts | 21 +- packages/mcp-server/src/tools/form_get.ts | 5 +- packages/mcp-server/src/tools/form_publish.ts | 44 +++ packages/mcp-server/src/tools/form_revert.ts | 51 +++ .../mcp-server/src/tools/form_revision_get.ts | 2 +- .../src/tools/form_revision_list.ts | 2 +- packages/mcp-server/src/tools/form_update.ts | 77 ++-- packages/mcp-server/src/tools/index.ts | 4 + 36 files changed, 1260 insertions(+), 1234 deletions(-) delete mode 100644 packages/mcp-server/src/__tests__/browser-prompts-launch.test.ts create mode 100644 packages/mcp-server/src/__tests__/form_publish.test.ts create mode 100644 packages/mcp-server/src/__tests__/form_revert.test.ts create mode 100644 packages/mcp-server/src/__tests__/revisions-flows.test.ts create mode 100644 packages/mcp-server/src/__tests__/revisions-history-acceptance.test.ts delete mode 100644 packages/mcp-server/src/revisions/browser-prompts.ts create mode 100644 packages/mcp-server/src/revisions/history.ts delete mode 100644 packages/mcp-server/src/revisions/tracking.ts create mode 100644 packages/mcp-server/src/tools/form_publish.ts create mode 100644 packages/mcp-server/src/tools/form_revert.ts 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 index 4b40cefc..5a57506c 100644 --- 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 @@ -2,7 +2,7 @@ ### 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, or to a form whose stored `revisions` setting is off when the caller does not enable it — 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. +`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 @@ -22,6 +22,12 @@ - **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 diff --git a/openspec/changes/freeze-1-0-tool-surface/tasks.md b/openspec/changes/freeze-1-0-tool-surface/tasks.md index aee699d2..69e2c32d 100644 --- a/openspec/changes/freeze-1-0-tool-surface/tasks.md +++ b/openspec/changes/freeze-1-0-tool-surface/tasks.md @@ -72,36 +72,36 @@ ### Red -- [ ] 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 -- [ ] 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 -- [ ] 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` +- [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 -- [ ] 5.4 Register `form_publish` and `form_revert` over `revisions/flows.ts` -- [ ] 5.5 Remove publish/revert from `form_update`; change draft mode to pick allowlisted fields +- [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 -- [ ] 5.6 Review implementation and refactor as needed +- [x] 5.6 Review implementation and refactor as needed ## 6. acceptNoHistory ### Red -- [ ] 6.1 Write failing tests: unlicensed `form_create` / `form_update` without `acceptNoHistory` → `HISTORY_NOT_ACCEPTED`, no write; with it → write without `revisions` -- [ ] 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 -- [ ] 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 +- [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 -- [ ] 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 -- [ ] 6.5 Remove the tests that covered the deleted prompt paths (`browser-prompts-launch`, the prompt cases in `revisions`, `local-server-edges`, `runnable-remedies`) +- [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 -- [ ] 6.6 Review implementation and refactor as needed +- [x] 6.6 Review implementation and refactor as needed ## 7. Argument shapes: role_create, action formId 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__/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 b97cb81d..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 @@ -146,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__/form_create.test.ts b/packages/mcp-server/src/__tests__/form_create.test.ts index 309935ff..44434256 100644 --- a/packages/mcp-server/src/__tests__/form_create.test.ts +++ b/packages/mcp-server/src/__tests__/form_create.test.ts @@ -1,28 +1,21 @@ -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, stubRevisionsLicence, TEST_CONFIG, TEST_CWD } from './test-helpers.js'; const mockFormioFetch = vi.fn(); vi.mock('../formio-client.js', () => ({ formioFetch: (...args: unknown[]) => mockFormioFetch(...args), })); -// Force the license gate to a no-op pass-through so the revisions consent -// prompt stays silent in tests. -vi.mock('../revisions/index.js', async (importOriginal) => ({ - ...(await importOriginal()), - gateRevisionsLicense: vi - .fn() - .mockImplementation(async (_s, _c, { form }: { form: Record }) => ({ - licensed: true, - form, - })), -})); - const { registerFormCreateTool } = await import('../tools/form_create.js'); describe('form_create tool', () => { beforeEach(() => { mockFormioFetch.mockReset(); + stubRevisionsLicence(true); + }); + + afterEach(() => { + vi.unstubAllGlobals(); }); it('is listed in available tools with skill-referencing description', async () => { 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_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..ee0983e5 --- /dev/null +++ b/packages/mcp-server/src/__tests__/form_revert.test.ts @@ -0,0 +1,154 @@ +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(); + }); +}); diff --git a/packages/mcp-server/src/__tests__/form_update.test.ts b/packages/mcp-server/src/__tests__/form_update.test.ts index d9e0d0e6..62eb47e4 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,84 @@ 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. + it("draft saves only the allowlisted fields of form_get's output", async () => { + const formId = '67890abcdef012345678abcd'; + const existingDraft = { _vid: 'draft', components: [{ type: 'old' }] }; + mockFormioFetch.mockResolvedValue(existingDraft); const { client } = await createTestClient(registerFormUpdateTool); + + const fromFormGet = { + _id: formId, + title: 'Contact', + name: 'contact', + path: 'contact', + created: '2026-01-01T00:00:00.000Z', + modified: '2026-01-02T00:00:00.000Z', + owner: 'abcdef012345678901234567', + access: [{ type: 'read_all', roles: [] }], + submissionAccess: [], + revisions: 'original', + components: [{ type: 'phoneNumber', key: 'phone' }], + settings: { theme: 'dark' }, + tags: ['common'], + display: 'form', + }; const result = await client.callTool({ name: 'form_update', - arguments: { - cwd: TEST_CWD, - formId: '67890abcdef012345678abcd', - form: { components: [], title: 'X' }, - draft: true, - note: 'n', + arguments: { cwd: TEST_CWD, formId, form: fromFormGet, draft: true, note: 'Add phone' }, + }); + + expect(result.isError).toBeFalsy(); + expect(mockFormioFetch).toHaveBeenCalledWith(`form/${formId}/draft`, {}, TEST_CONFIG, { + method: 'PUT', + body: { + _vid: 'draft', + components: fromFormGet.components, + settings: fromFormGet.settings, + tags: fromFormGet.tags, + display: fromFormGet.display, + _vnote: '@formio/mcp: Add phone', }, }); - 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: [] }); + 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: [] }, - publish: true, + form: { title: 'Renamed' }, + draft: true, note: 'n', }, }); expect(result.isError).toBe(true); - expect(result.content).toEqual([ - expect.objectContaining({ text: expect.stringMatching(/No draft exists/) }), - ]); + expect(mockFormioFetch).not.toHaveBeenCalled(); }); - it('publish ignores caller form, overlays draft allowlist on live, stamps _vnote', async () => { - const formId = '67890abcdef012345678abcd'; - const draft = { _vid: 'draft', components: [{ type: 'staged' }], title: 'IGNORED' }; - const live = { - _id: formId, - title: 'Live', - components: [{ type: 'old' }], - access: [{ role: 'admin' }], - }; - mockFormioFetch.mockImplementation( - (path: string, _p: unknown, _c: unknown, opts?: { method?: string }) => { - const isGet = !opts?.method; - if (isGet && path === `form/${formId}/draft`) return Promise.resolve(draft); - if (isGet && path === `form/${formId}`) return Promise.resolve(live); - return Promise.resolve({}); - } - ); - - const { client } = await createTestClient(registerFormUpdateTool); - await client.callTool({ - name: 'form_update', - arguments: { - cwd: TEST_CWD, - formId, - // draft publish flow ignores mcp tool caller form - form: { components: [{ type: 'IGNORED' }] }, - publish: true, - note: 'ship it', - }, - }); - - expect(mockFormioFetch).toHaveBeenCalledWith(`form/${formId}`, {}, TEST_CONFIG, { - method: 'PUT', - body: { - ...live, - components: [{ type: 'staged' }], - _vnote: '@formio/mcp: ship it', - }, + it('draft refuses with LICENSE_REQUIRED on a deployment without the revisions licence', async () => { + stubRevisionsLicence(false); + const { client } = await createTestClient(registerFormUpdateTool, { + projectUrl: freshProjectUrl(), }); - }); - - it('revert PUTs live overlaid with revision revert allowlist and stamps _vnote', async () => { - const formId = '67890abcdef012345678abcd'; - const revision = { - _vid: '3', - components: [{ type: 'v3' }], - tags: ['t'], - display: 'wizard', - title: 'IGNORED', - }; - const live = { _id: formId, title: 'Live', components: [{ type: 'current' }] }; - mockFormioFetch.mockImplementation( - (path: string, _p: unknown, _c: unknown, opts?: { method?: string }) => { - const isGet = !opts?.method; - if (isGet && path === `form/${formId}/v/3`) return Promise.resolve(revision); - if (isGet && path === `form/${formId}`) return Promise.resolve(live); - return Promise.resolve({}); - } - ); - - const { client } = await createTestClient(registerFormUpdateTool); - await client.callTool({ + const result = await client.callTool({ name: 'form_update', arguments: { cwd: TEST_CWD, - formId, - // revert flow ignores mcp tool caller form - form: { components: [{ type: 'IGNORED' }] }, - revert: true, - version: '3', - note: 'Reverted to version 3', - }, - }); - - expect(mockFormioFetch).toHaveBeenCalledWith(`form/${formId}`, {}, TEST_CONFIG, { - method: 'PUT', - body: { - ...live, - components: [{ type: 'v3' }], - tags: ['t'], - display: 'wizard', - _vnote: '@formio/mcp: Reverted to version 3', + formId: '67890abcdef012345678abcd', + form: { components: [] }, + draft: true, + note: 'n', }, }); + expect(result.isError).toBe(true); + expect((result._meta as Record)[ERROR_META_KEY]?.code).toBe( + 'LICENSE_REQUIRED' + ); + expect(mockFormioFetch).not.toHaveBeenCalled(); }); }); 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__/output-schemas-replay.test.ts b/packages/mcp-server/src/__tests__/output-schemas-replay.test.ts index 0618212e..3d6bcda2 100644 --- a/packages/mcp-server/src/__tests__/output-schemas-replay.test.ts +++ b/packages/mcp-server/src/__tests__/output-schemas-replay.test.ts @@ -26,7 +26,7 @@ vi.mock('../revisions/index.js', async (importOriginal) => ({ ...(await importOriginal()), gateRevisionsLicense: vi .fn() - .mockImplementation(async (_s, _c, { form }: { form: Record }) => ({ + .mockImplementation(async ({ form }: { form: Record }) => ({ licensed: true, form, })), 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 b52b52c3..1306eec2 100644 --- a/packages/mcp-server/src/__tests__/project-resolution-errors.test.ts +++ b/packages/mcp-server/src/__tests__/project-resolution-errors.test.ts @@ -24,6 +24,8 @@ 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_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: {} }, 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..35427670 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,8 @@ 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'); +const FORM_ID = '67890abcdef012345678abcd'; beforeEach(() => { vi.resetAllMocks(); @@ -97,95 +67,81 @@ describe('checkRevisionsLicensed', () => { }); }); -describe('confirmProceedWithoutRevisions', () => { - it('throws USER CANCELLED when user cancels via elicitation', async () => { - const baseUrl = uniqueBaseUrl(); +describe('requireRevisionsLicense', () => { + it('refuses with LICENSE_REQUIRED on an unlicensed deployment', async () => { stubLicensed(false); - const server = makeServer({ elicitation: true, elicit: elicitCancel() }); - await expect( - confirmProceedWithoutRevisions(server, cfgFor(baseUrl), 'create this form') - ).rejects.toThrow(/USER CANCELLED/); + 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('falls back to browser prompt when elicitation unsupported and persists on continue', async () => { - const baseUrl = uniqueBaseUrl(); - stubLicensed(false); - mockRequestLicenseConsent.mockResolvedValue('continue'); - const server = makeServer({ elicitation: false }); - - await confirmProceedWithoutRevisions(server, cfgFor(baseUrl), 'create this form'); - - expect(mockRequestLicenseConsent).toHaveBeenCalledWith(baseUrl, 'create this form'); - const data = JSON.parse(await fs.readFile(CONSENT_FILE, 'utf-8')); - expect(data[baseUrl]).toBe(true); - }); - - it('throws USER CANCELLED when browser prompt cancels', async () => { - const baseUrl = uniqueBaseUrl(); - stubLicensed(false); - mockRequestLicenseConsent.mockResolvedValue('cancel'); - const server = makeServer({ elicitation: false }); + it('passes on a licensed deployment', async () => { + stubLicensed(true); await expect( - confirmProceedWithoutRevisions(server, cfgFor(baseUrl), 'create this form') - ).rejects.toThrow(/USER CANCELLED/); - }); - - it('persists positive consent to file and skips re-prompt for same baseUrl', async () => { - const baseUrl = uniqueBaseUrl(); - stubLicensed(false); - const elicit = elicitAccept('continue'); - const server = makeServer({ elicitation: true, elicit }); - - await confirmProceedWithoutRevisions(server, cfgFor(baseUrl), 'create this form'); - - const stat = await fs.stat(CONSENT_FILE); - expect(stat.mode & 0o777).toBe(0o600); - const data = JSON.parse(await fs.readFile(CONSENT_FILE, 'utf-8')); - expect(data[baseUrl]).toBe(true); - - // second call → no re-prompt - await confirmProceedWithoutRevisions(server, cfgFor(baseUrl), 'create this form'); - expect(elicit).toHaveBeenCalledTimes(1); + requireRevisionsLicense(cfgFor(uniqueBaseUrl()), 'publish this form') + ).resolves.toBeUndefined(); }); }); 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('refuses with HISTORY_NOT_ACCEPTED when licensed and the body sets revisions to ""', async () => { + stubLicensed(true); + + const attempt = gateRevisionsLicense({ + cfg: cfgFor(uniqueBaseUrl()), + actionLabel: 'update this form', + form: { revisions: '', components: [] }, + }); + await expect(attempt).rejects.toMatchObject({ code: 'HISTORY_NOT_ACCEPTED' }); + }); + + it('passes a body setting revisions to "" through when licensed and accepted', async () => { + stubLicensed(true); + + const form = { revisions: '', components: [] }; + const result = await gateRevisionsLicense({ + cfg: cfgFor(uniqueBaseUrl()), + actionLabel: 'update this form', + form, + acceptNoHistory: true, + }); + expect(result).toEqual({ licensed: true, form }); + }); + it('passes through unchanged when licensed', async () => { stubLicensed(true); - const server = makeServer({ elicitation: true, elicit: vi.fn() }); const form = { revisions: 'original' as const, components: [] }; - const result = await gateRevisionsLicense(server, cfgFor(uniqueBaseUrl()), { + const result = await gateRevisionsLicense({ + cfg: cfgFor(uniqueBaseUrl()), actionLabel: 'update this form', - requiresRevisions: false, form, }); expect(result.licensed).toBe(true); @@ -193,147 +149,49 @@ describe('gateRevisionsLicense', () => { }); }); -describe('gateRevisionsTracking', () => { +describe('gateFormHistory', () => { const cfg = cfgFor('https://tracking.local'); it.each(['original', 'current'] as const)( - 'no prompt when caller opted in via revisions: "%s"', + 'does not read the stored form when the caller enables 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(); + await gateFormHistory({ cfg, formId: FORM_ID, form: { revisions: mode, components: [] } }); expect(mockFormioFetch).not.toHaveBeenCalled(); - expect(out.revisions).toBe(mode); } ); - it('no prompt when licensed is false', async () => { - const elicit = vi.fn(); - const server = makeServer({ elicitation: true, elicit }); - const out = await gateRevisionsTracking(server, { - formId: uniqueFormId(), - form: { components: [] }, - licensed: false, + it('does not read the stored form when the caller accepted saving without history', async () => { + await gateFormHistory({ cfg, - }); - expect(elicit).not.toHaveBeenCalled(); - expect(out).toEqual({ components: [] }); - }); - - it('prompts when stored revisions is falsy and applies enable-original', async () => { - const formId = uniqueFormId(); - mockFormioFetch.mockResolvedValue({ name: 'demo', revisions: '' }); - const elicit = elicitAccept('enable-original'); - const server = makeServer({ elicitation: true, elicit }); - - const out = await gateRevisionsTracking(server, { - formId, + formId: FORM_ID, form: { components: [] }, - licensed: true, - cfg, - }); - expect(elicit).toHaveBeenCalledTimes(1); - expect(out.revisions).toBe('original'); - }); - - it('prompts even when caller passes revisions: "" and applies enable-current', async () => { - const formId = uniqueFormId(); - mockFormioFetch.mockResolvedValue({ name: 'demo', revisions: '' }); - const elicit = elicitAccept('enable-current'); - const server = makeServer({ elicitation: true, elicit }); - - const out = await gateRevisionsTracking(server, { - formId, - form: { revisions: '', components: [] }, - licensed: true, - cfg, + acceptNoHistory: true, }); - expect(elicit).toHaveBeenCalledTimes(1); - expect(out.revisions).toBe('current'); + expect(mockFormioFetch).not.toHaveBeenCalled(); }); - 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 when the stored form has revisions on', async () => { + mockFormioFetch.mockResolvedValue({ name: 'demo', revisions: 'original' }); - const out = await gateRevisionsTracking(server, { - formId, - form: { revisions: '', components: [] }, - licensed: true, - cfg, - }); - expect(out).toEqual({ components: [] }); - expect('revisions' in out).toBe(false); - - // Second call for same formId — no prompt, no API GET re-fetch needed beyond first. - mockFormioFetch.mockClear(); - elicit.mockClear(); - const out2 = await gateRevisionsTracking(server, { - formId, - form: { components: [{ type: 'textfield', key: 'x' }] }, - licensed: true, - cfg, - }); - expect(elicit).not.toHaveBeenCalled(); - expect(out2).toEqual({ components: [{ type: 'textfield', key: 'x' }] }); + await gateFormHistory({ cfg, formId: FORM_ID, form: { components: [] } }); + expect(mockFormioFetch).toHaveBeenCalledWith(`form/${FORM_ID}`, {}, cfg); }); - it('throws when user cancels', async () => { - const formId = uniqueFormId(); - mockFormioFetch.mockResolvedValue({ name: 'demo', revisions: '' }); - const server = makeServer({ elicitation: true, elicit: elicitCancel() }); + // 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: '' }); - await expect( - gateRevisionsTracking(server, { - formId, - form: { components: [] }, - licensed: true, + const attempt = gateFormHistory({ cfg, - }) - ).rejects.toThrow(/User declined/); - }); - - it('falls back to browser prompt when elicitation unsupported', async () => { - const formId = uniqueFormId(); - mockFormioFetch.mockResolvedValue({ name: 'demo', revisions: '' }); - mockRequestRevisionsConsent.mockResolvedValue('enable-original'); - const server = makeServer({ elicitation: false }); - - const out = await gateRevisionsTracking(server, { - formId, - form: { components: [] }, - licensed: true, - cfg, - }); - - expect(mockRequestRevisionsConsent).toHaveBeenCalledWith('demo', formId); - expect(out.revisions).toBe('original'); - }); - - it('does not prompt when stored.revisions is truthy', async () => { - const formId = uniqueFormId(); - mockFormioFetch.mockResolvedValue({ name: 'demo', revisions: 'original' }); - const elicit = vi.fn(); - const server = makeServer({ elicitation: true, elicit }); - - const out = await gateRevisionsTracking(server, { - formId, - form: { components: [] }, - licensed: true, - cfg, - }); - expect(elicit).not.toHaveBeenCalled(); - expect(out).toEqual({ components: [] }); - }); + 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__/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 68fde96c..5344f25b 100644 --- a/packages/mcp-server/src/__tests__/server-empty-env.test.ts +++ b/packages/mcp-server/src/__tests__/server-empty-env.test.ts @@ -53,6 +53,8 @@ describe('createServer with an empty environment', () => { 'form_get', 'form_list', 'form_update', + 'form_publish', + 'form_revert', 'form_revision_list', 'form_revision_get', 'role_create', 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__/tool-error-contract.test.ts b/packages/mcp-server/src/__tests__/tool-error-contract.test.ts index 45368806..56da80af 100644 --- a/packages/mcp-server/src/__tests__/tool-error-contract.test.ts +++ b/packages/mcp-server/src/__tests__/tool-error-contract.test.ts @@ -14,7 +14,7 @@ vi.mock('../revisions/index.js', async (importOriginal) => ({ ...(await importOriginal()), gateRevisionsLicense: vi .fn() - .mockImplementation(async (_s, _c, { form }: { form: Record }) => ({ + .mockImplementation(async ({ form }: { form: Record }) => ({ licensed: true, form, })), 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 c32430b3..5ec9f89d 100644 --- a/packages/mcp-server/src/__tests__/tool-path-arguments.test.ts +++ b/packages/mcp-server/src/__tests__/tool-path-arguments.test.ts @@ -26,13 +26,9 @@ const VALID_ARGUMENTS: Record> = { form_get: { 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 }, 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..67dca52a 100644 --- a/packages/mcp-server/src/revisions/flows.ts +++ b/packages/mcp-server/src/revisions/flows.ts @@ -1,5 +1,6 @@ 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 +13,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 +44,30 @@ 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 server-owned fields it carries (_id, title, path, created, …) are dropped +// rather than refused. 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; } 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 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 +78,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, { diff --git a/packages/mcp-server/src/revisions/history.ts b/packages/mcp-server/src/revisions/history.ts new file mode 100644 index 00000000..bed0f251 --- /dev/null +++ b/packages/mcp-server/src/revisions/history.ts @@ -0,0 +1,79 @@ +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; + } + const stored = (await formioFetch(`form/${formId}`, {}, cfg)) as Record; + 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..3f788203 100644 --- a/packages/mcp-server/src/revisions/license.ts +++ b/packages/mcp-server/src/revisions/license.ts @@ -1,14 +1,8 @@ -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 { ToolError } from '../tool-errors.js'; import { COMMITTED_CONFIG_FILE } from '../committed-config.js'; // ─── License detection ────────────────────────────────────────────────────── @@ -18,10 +12,6 @@ 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. // 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,6 +24,10 @@ 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 })}`; } +// 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. export async function checkRevisionsLicensed( cfg: ResolvedFormioConfig ): Promise { @@ -59,173 +53,91 @@ export async function checkRevisionsLicensed( return revisionsLicensed; } -// ─── Persistent consent store ─────────────────────────────────────────────── -// `~/.formio/revisions-license-consent.json`, keyed by `baseUrl`. Only -// positive consent ("continue") is persisted; cancel is transient. -const DEFAULT_CACHE_DIR = path.join(os.homedir(), '.formio'); -const CACHE_FILE = 'revisions-license-consent.json'; - -async function readCache(cacheDir: string): Promise> { - const filePath = path.join(cacheDir, CACHE_FILE); - try { - const contents = await fs.readFile(filePath, 'utf-8'); - const parsed = JSON.parse(contents) as Record; - const result: Record = {}; - for (const [k, v] of Object.entries(parsed)) { - if (v === true) result[k] = true; - } - return result; - } catch { - return {}; - } -} - -async function writeCache(cacheDir: string, data: Record): Promise { - await fs.mkdir(cacheDir, { recursive: true }); - const filePath = path.join(cacheDir, CACHE_FILE); - await fs.writeFile(filePath, JSON.stringify(data), { mode: 0o600 }); -} - -async function readRevisionsLicenseConsent( - baseUrl: string, - cacheDir: string = DEFAULT_CACHE_DIR -): Promise { - const data = await readCache(cacheDir); - return data[baseUrl] === true; -} - -async function saveRevisionsLicenseConsent( - baseUrl: string, - cacheDir: string = DEFAULT_CACHE_DIR -): Promise { - const data = await readCache(cacheDir); - data[baseUrl] = true; - await writeCache(cacheDir, data); +// 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, and with the Base URL + * remedy where the licence cannot be read. + */ +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 licensed = await checkRevisionsLicensed(cfg); + if (licensed === 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 (!licensed) { + 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; } -// 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 RevisionsLicenseGateOptions { + cfg: ResolvedFormioConfig; + actionLabel: string; + form: Record; + acceptNoHistory?: boolean; } -// 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 }> { +/** + * 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. Returns the resolved licensed flag and + * the body to send. + */ +export async function gateRevisionsLicense({ + cfg, + actionLabel, + form, + acceptNoHistory, +}: RevisionsLicenseGateOptions): 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. + // 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 (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.` - ); + 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.` - ); + if (licensed) { + gateDisabledHistory({ form, acceptNoHistory }); + return { licensed, 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 (!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, 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/tools/form_create.ts b/packages/mcp-server/src/tools/form_create.ts index d39c4e7c..5f77f56d 100644 --- a/packages/mcp-server/src/tools/form_create.ts +++ b/packages/mcp-server/src/tools/form_create.ts @@ -13,7 +13,7 @@ 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 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. On a deployment licensed for revisions, 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.', inputSchema: { cwd: cwdSchema, form: z @@ -33,27 +33,36 @@ export function registerFormCreateTool(server: McpServer, config: FormioConfig) 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 to true only after the user agrees to save without revision history. Without it, a save that would keep no history (a deployment without the revisions licence, or a form whose revisions are off) is refused with HISTORY_NOT_ACCEPTED and nothing is written.' + ), }, 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 { 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 + // The gate already stripped `revisions` on unlicensed deployments; on // licensed ones default to 'original' unless the caller overrode. ...(licensed ? { revisions: 'original', ...form } : form), ...(note && { _vnote: prefixVnote(note) }), diff --git a/packages/mcp-server/src/tools/form_get.ts b/packages/mcp-server/src/tools/form_get.ts index 009b2e2d..5185a341 100644 --- a/packages/mcp-server/src/tools/form_get.ts +++ b/packages/mcp-server/src/tools/form_get.ts @@ -6,6 +6,7 @@ import { toMcpStructuredResult, toMcpError } from '../mcp-responses.js'; import { formDocument } from '../output-schemas.js'; import { reads } from '../tool-annotations.js'; import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; +import { noDraft } from '../revisions/index.js'; import { projectPathArgument } from './path-arguments.js'; export function registerFormGetTool(server: McpServer, config: FormioConfig) { @@ -41,9 +42,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_publish.ts b/packages/mcp-server/src/tools/form_publish.ts new file mode 100644 index 00000000..529f91da --- /dev/null +++ b/packages/mcp-server/src/tools/form_publish.ts @@ -0,0 +1,44 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { FormioConfig } from '../config.js'; +import { MONGO_ID_PATTERN } from '../formio-client.js'; +import { toMcpStructuredResult, toMcpError } from '../mcp-responses.js'; +import { formDocument } from '../output-schemas.js'; +import { overwrites } from '../tool-annotations.js'; +import { cwdSchema, resolveProjectConfig } from '../project-resolver.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 Form.io project mapped to the user's current working directory. The draft's components, settings, tags, properties, controller, esign and display replace the live form's, and `note` is recorded as the revision note. Save a draft first with form_update and draft: true; a form with no draft fails with NO_DRAFT. Needs the revisions licence (Security Module) on the deployment.", + inputSchema: { + cwd: cwdSchema, + formId: z + .string() + .regex(MONGO_ID_PATTERN, 'Must be a 24-character MongoDB ObjectId') + .describe('The _id of the form whose draft to publish'), + 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 { + const cfg = resolveProjectConfig(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..9152fb5a --- /dev/null +++ b/packages/mcp-server/src/tools/form_revert.ts @@ -0,0 +1,51 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { FormioConfig } from '../config.js'; +import { MONGO_ID_PATTERN } from '../formio-client.js'; +import { toMcpStructuredResult, toMcpError } from '../mcp-responses.js'; +import { formDocument } from '../output-schemas.js'; +import { overwrites } from '../tool-annotations.js'; +import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; +import { requireRevisionsLicense, revertToRevision } from '../revisions/index.js'; +import { resourceSegmentArgument } from './path-arguments.js'; + +export function registerFormRevertTool(server: McpServer, config: FormioConfig) { + server.registerTool( + 'form_revert', + { + description: + "Revert a form in the Form.io project mapped to the user's current working directory to a prior revision. The revision's components, tags, properties and display replace the live form's, and `note` is recorded as the revision note. Find the revision with form_revision_list. Needs the revisions licence (Security Module) on the deployment.", + inputSchema: { + cwd: cwdSchema, + formId: z + .string() + .regex(MONGO_ID_PATTERN, 'Must be a 24-character MongoDB ObjectId') + .describe('The _id of the form to revert'), + 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 { + const cfg = resolveProjectConfig(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 659efcd8..391b3825 100644 --- a/packages/mcp-server/src/tools/form_revision_get.ts +++ b/packages/mcp-server/src/tools/form_revision_get.ts @@ -12,7 +12,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 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, call `form_revert` with its `_vid`.', inputSchema: { cwd: cwdSchema, formIdOrPath: projectPathArgument('formIdOrPath').describe( diff --git a/packages/mcp-server/src/tools/form_revision_list.ts b/packages/mcp-server/src/tools/form_revision_list.ts index 0405e155..cd1d39f1 100644 --- a/packages/mcp-server/src/tools/form_revision_list.ts +++ b/packages/mcp-server/src/tools/form_revision_list.ts @@ -17,7 +17,7 @@ export function registerFormRevisionListTool(server: McpServer, config: FormioCo 'form_revision_list', { description: - 'List published revision summaries for a single form in the Form.io project mapped to the current working directory, newest first, one page at a time. 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`.', + 'List published revision summaries for a single form in the Form.io project mapped to the current working directory, newest first, one page at a time. To inspect a specific revision body, call `form_revision_get` with the desired `_vid`. To revert the live form to a prior revision, call `form_revert` with its `_vid`.', inputSchema: { cwd: cwdSchema, formIdOrPath: projectPathArgument('formIdOrPath').describe( diff --git a/packages/mcp-server/src/tools/form_update.ts b/packages/mcp-server/src/tools/form_update.ts index f8898efc..3d783edc 100644 --- a/packages/mcp-server/src/tools/form_update.ts +++ b/packages/mcp-server/src/tools/form_update.ts @@ -7,14 +7,12 @@ import { formDocument } from '../output-schemas.js'; import { overwrites } from '../tool-annotations.js'; import { cwdSchema, resolveProjectConfig } from '../project-resolver.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( @@ -22,8 +20,7 @@ export function registerFormUpdateTool(server: McpServer, config: FormioConfig) { 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.', + 'Pass `draft: true` to save the change as a draft instead of the live form; publish it with form_publish, and restore a prior revision with form_revert.', ].join(' '), inputSchema: { cwd: cwdSchema, @@ -46,7 +43,7 @@ export function registerFormUpdateTool(server: McpServer, config: FormioConfig) .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()) @@ -54,80 +51,52 @@ export function registerFormUpdateTool(server: McpServer, config: FormioConfig) 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.' + 'Required note describing the diff (live form vs updated body) — no action preambles ("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.' + "When true, save a draft (PUT /form/{formId}/draft) instead of the live form. Only the draft fields of `form` are saved (components, settings, tags, properties, controller, esign, display), merged over any existing draft; every other field is ignored, so form_get's output can be passed as is." ), - publish: z + acceptNoHistory: z .boolean() .optional() .describe( - 'When true, publish the current draft. Caller `form` body is ignored; only allowlisted revision fields flow from existing draft to live (PUT /form/{formId}).' - ), - revert: z - .boolean() - .optional() - .describe( - 'When true, revert the live form to a prior revision. Requires `version`. Caller `form` body is ignored; only allowlisted revision fields flow from the revision to live.' - ), - version: 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 to true only after the user agrees to save without revision history. Without it, a save that would keep no history (a deployment without the revisions licence, or a form whose revisions are off) is refused with HISTORY_NOT_ACCEPTED and nothing is written.' ), }, 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, - }); - - 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, + const { licensed, form } = await gateRevisionsLicense({ cfg, + actionLabel: 'update this form', + form: rawForm, + acceptNoHistory, }); + if (licensed) { + await gateFormHistory({ cfg, formId, form, 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/index.ts b/packages/mcp-server/src/tools/index.ts index 9d320997..8f9dc296 100644 --- a/packages/mcp-server/src/tools/index.ts +++ b/packages/mcp-server/src/tools/index.ts @@ -10,6 +10,8 @@ import { registerActionUpdateTool } from './action_update.js'; import { registerFormCreateTool } from './form_create.js'; import { registerFormGetTool } from './form_get.js'; import { registerFormListTool } from './form_list.js'; +import { registerFormPublishTool } from './form_publish.js'; +import { registerFormRevertTool } from './form_revert.js'; import { registerFormRevisionGetTool } from './form_revision_get.js'; import { registerFormRevisionListTool } from './form_revision_list.js'; import { registerFormUpdateTool } from './form_update.js'; @@ -39,6 +41,8 @@ export function registerAllTools( registerFormRevisionGetTool(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. From 81dcf217200120b56cfc5e3d471280d18a744d74 Mon Sep 17 00:00:00 2001 From: Travis Tidwell Date: Fri, 9 Oct 2026 10:35:33 -0500 Subject: [PATCH 06/13] feat(mcp)!: nested role_create, ObjectId formId, tool-list budget role_create takes a role object like role_update. Every formId argument (the action tools, form_update, form_publish, form_revert) shares one ObjectId check whose refusal points to form_get for the _id; UNKNOWN_ACTION_TYPE is wired. tools/list is trimmed from 80,656 to 48,649 characters under a tested 50,000 budget: the cwd description is 192 characters with the full rules in the server instructions, and project_set's rationale moves to the README. Co-Authored-By: Claude Opus 5.5 --- .../changes/freeze-1-0-tool-surface/design.md | 4 +- .../changes/freeze-1-0-tool-surface/tasks.md | 18 +- packages/mcp-server/README.md | 12 + .../src/__tests__/action_create.test.ts | 2 + .../src/__tests__/action_list.test.ts | 2 +- .../src/__tests__/action_type_get.test.ts | 20 ++ .../src/__tests__/action_type_list.test.ts | 2 +- .../src/__tests__/form-id-argument.test.ts | 106 +++++++ .../project-resolution-errors.test.ts | 2 +- .../src/__tests__/role_create.test.ts | 45 ++- .../__tests__/tool-description-budget.test.ts | 68 ++++ packages/mcp-server/src/output-schemas.ts | 290 +++++++----------- packages/mcp-server/src/project-resolver.ts | 5 +- packages/mcp-server/src/server.ts | 1 + .../mcp-server/src/tools/action-schema.ts | 32 +- .../mcp-server/src/tools/action_create.ts | 17 +- .../mcp-server/src/tools/action_delete.ts | 4 +- packages/mcp-server/src/tools/action_get.ts | 4 +- packages/mcp-server/src/tools/action_list.ts | 5 +- .../mcp-server/src/tools/action_type_get.ts | 18 +- .../mcp-server/src/tools/action_type_list.ts | 7 +- .../mcp-server/src/tools/action_update.ts | 4 +- packages/mcp-server/src/tools/form-id.ts | 26 ++ packages/mcp-server/src/tools/form_create.ts | 23 +- packages/mcp-server/src/tools/form_get.ts | 2 +- packages/mcp-server/src/tools/form_list.ts | 2 +- packages/mcp-server/src/tools/form_publish.ts | 10 +- packages/mcp-server/src/tools/form_revert.ts | 10 +- .../mcp-server/src/tools/form_revision_get.ts | 2 +- .../src/tools/form_revision_list.ts | 2 +- packages/mcp-server/src/tools/form_update.ts | 37 +-- .../mcp-server/src/tools/list-contract.ts | 14 +- .../mcp-server/src/tools/project_export.ts | 2 +- packages/mcp-server/src/tools/project_get.ts | 7 +- .../mcp-server/src/tools/project_import.ts | 2 +- packages/mcp-server/src/tools/project_set.ts | 20 +- packages/mcp-server/src/tools/role-schema.ts | 8 +- packages/mcp-server/src/tools/role_create.ts | 11 +- packages/mcp-server/src/tools/role_list.ts | 3 +- packages/mcp-server/src/tools/role_update.ts | 9 +- .../mcp-server/src/tools/server_status.ts | 2 +- 41 files changed, 529 insertions(+), 331 deletions(-) create mode 100644 packages/mcp-server/src/__tests__/form-id-argument.test.ts create mode 100644 packages/mcp-server/src/__tests__/tool-description-budget.test.ts create mode 100644 packages/mcp-server/src/tools/form-id.ts diff --git a/openspec/changes/freeze-1-0-tool-surface/design.md b/openspec/changes/freeze-1-0-tool-surface/design.md index 52fc17f5..136f9555 100644 --- a/openspec/changes/freeze-1-0-tool-surface/design.md +++ b/openspec/changes/freeze-1-0-tool-surface/design.md @@ -54,7 +54,7 @@ SDK-level input validation (zod failures before the handler runs) is produced by 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.** -The `cwd` description drops to ≤200 characters ("The user's working directory, absolute. Selects the Form.io project; see project_set and the server instructions."), because the full resolution rules already live in the server instructions every client receives once and in `project_set`. `project_set` keeps every rule it enforces and drops the rationale paragraphs (to the README). Output-schema field descriptions are trimmed to what a caller branches on. Measured target: ≤50,000 characters for the 22 tools after this change, 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 and call `project_get`). +The `cwd` description drops to ≤200 characters ("The user's working directory, absolute. Selects the Form.io project; see project_set and the server instructions."), because the full resolution rules already live in the server instructions every client receives once and in `project_set`. `project_set` keeps every rule it enforces and drops the rationale paragraphs (to the README). Output-schema field descriptions are trimmed to what a caller branches on. Measured: 80,656 characters at the start of this group (the new tools and open schemas had grown it from 68,551), 48,649 after, for 23 tools; the ≤50,000 budget is 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 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.) @@ -80,7 +80,7 @@ The shared preflight paragraph (15 skills, identical text enforced by `shared-pr - [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 planner and both resources harnesses are run before and after per the plan's measurement rule. +- [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 diff --git a/openspec/changes/freeze-1-0-tool-surface/tasks.md b/openspec/changes/freeze-1-0-tool-surface/tasks.md index 69e2c32d..1e988069 100644 --- a/openspec/changes/freeze-1-0-tool-surface/tasks.md +++ b/openspec/changes/freeze-1-0-tool-surface/tasks.md @@ -108,17 +108,17 @@ ### Red -- [ ] 7.1 Write failing tests: `role_create` takes `role: { title, description?, default?, admin? }` and POSTs it; flat `title` is rejected -- [ ] 7.2 Write failing tests: every action tool refuses a non-ObjectId `formId` with a message pointing to `form_get` for the `_id` +- [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 -- [ ] 7.3 Change `role_create`'s input to the shared role schema used by `role_update` -- [ ] 7.4 Give every action tool's `formId` the ObjectId schema with the remedy message +- [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 -- [ ] 7.5 Review implementation and refactor as needed +- [x] 7.5 Review implementation and refactor as needed ## 8. Environment booleans and package exports @@ -142,16 +142,16 @@ ### Red -- [ ] 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 +- [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 -- [ ] 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 -- [ ] 9.3 Confirm the server instructions still carry the full `cwd` and resolution guidance; run the planner and both resources eval harnesses before and after and record that scores did not drop +- [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 -- [ ] 9.4 Review implementation and refactor as needed +- [x] 9.4 Review implementation and refactor as needed ## 10. Skills and docs follow the surface diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 0bff69f6..3c684e44 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -217,6 +217,18 @@ 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. +- **`cwd` keys the mapping.** Without it the mapping is keyed by the server process's own working directory, which is fixed when the client spawns the server and is often not where the user is working. +- **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 | 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 5c8440c4..dd09e43a 100644 --- a/packages/mcp-server/src/__tests__/action_list.test.ts +++ b/packages/mcp-server/src/__tests__/action_list.test.ts @@ -93,7 +93,7 @@ describe('action_list tool', () => { 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_type_list.test.ts b/packages/mcp-server/src/__tests__/action_type_list.test.ts index c0c6f677..5bae02f0 100644 --- a/packages/mcp-server/src/__tests__/action_type_list.test.ts +++ b/packages/mcp-server/src/__tests__/action_type_list.test.ts @@ -61,7 +61,7 @@ describe('action_type_list tool', () => { const result = await client.callTool({ name: 'action_type_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__/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__/project-resolution-errors.test.ts b/packages/mcp-server/src/__tests__/project-resolution-errors.test.ts index 1306eec2..43dfde34 100644 --- a/packages/mcp-server/src/__tests__/project-resolution-errors.test.ts +++ b/packages/mcp-server/src/__tests__/project-resolution-errors.test.ts @@ -29,7 +29,7 @@ const PROJECT_SCOPED_CALLS: Array<{ name: string; args: Record { 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_type_list', args: { formId: OBJECT_ID } }, { name: 'action_type_get', args: { formId: OBJECT_ID, actionName: 'save' } }, 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__/tool-description-budget.test.ts b/packages/mcp-server/src/__tests__/tool-description-budget.test.ts new file mode 100644 index 00000000..94d52db4 --- /dev/null +++ b/packages/mcp-server/src/__tests__/tool-description-budget.test.ts @@ -0,0 +1,68 @@ +/** + * 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 what to pass and where the full rules are + * (the server instructions and project_set), which every client receives once. + */ + +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'; + +const TOOL_LIST_BUDGET = 50_000; +const CWD_DESCRIPTION_BUDGET = 200; + +async function listTools(): Promise { + const client = await connectTools((server) => registerAllTools(server, TEST_CONFIG)); + const { tools } = await client.listTools(); + return tools; +} + +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 the user's working directory and where the full rules are", async () => { + const missing = cwdDescriptions(await listTools()).filter( + ({ description }) => + !/user's working directory/i.test(description) || + !description.includes('project_set') || + !/server instructions/i.test(description) + ); + expect(missing.map(({ tool }) => tool)).toEqual([]); + }); + + // The short description points here, so the full rule has to be here. + it('leaves the full cwd and resolution rules in the server instructions', () => { + expect(SERVER_INSTRUCTIONS).toMatch(/Pass cwd[^.]*on every project-scoped call/); + expect(SERVER_INSTRUCTIONS).toMatch(/server's own working directory, which is fixed at spawn/); + expect(SERVER_INSTRUCTIONS).toMatch(/no environment variable removes the need for it/); + 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/output-schemas.ts b/packages/mcp-server/src/output-schemas.ts index 1996d65d..a1a454cf 100644 --- a/packages/mcp-server/src/output-schemas.ts +++ b/packages/mcp-server/src/output-schemas.ts @@ -12,170 +12,134 @@ * 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 accept `null`, because that is a value Form.io returns for them. + * Mixed are typed `unknown`: Mixed holds any value, `null` included. */ import { z } from 'zod'; +// Field descriptions are kept only where a caller branches on the value or passes it +// on; a self-explanatory field (`title`, `created`, …) is typed and left undescribed, +// because every description here is repeated on every tool that returns the document. 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'), + _id: z.string().optional(), + created: z.string().optional(), + modified: z.string().optional(), + machineName: z.string().optional(), }; +const looseList = z.array(z.looseObject({})); +// A Mongoose Mixed field: any value, `null` included. +const mixed = z.unknown().optional(); + export const formDocument = z.looseObject({ ...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({})) - .nullish() - .describe('Role-based access to the form definition itself'), - submissionAccess: z - .array(z.looseObject({})) - .nullish() - .describe('Role-based access to the submissions of this form'), + title: z.string().optional(), + name: z.string().optional(), + path: z.string().optional(), + type: z.string().optional().describe('"form" or "resource"'), + display: z.string().optional().describe('"form", "wizard" or "pdf"'), + components: looseList.optional(), + tags: z.array(z.string()).optional(), + access: looseList.nullish(), + submissionAccess: looseList.nullish(), revisions: z .union([z.string(), z.boolean()]) .optional() - .describe('Revision mode: "original", "current", or false when disabled'), - settings: z.looseObject({}).nullish().describe('Form-level settings'), - properties: z.looseObject({}).nullish().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'), + .describe('"original", "current", or empty/false when history is off'), + settings: mixed, + properties: mixed, + project: z.string().optional(), + owner: z.string().nullish(), }); export const roleDocument = z.looseObject({ ...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'), + title: z.string().optional(), + description: z.string().optional(), + admin: z.boolean().optional(), + default: z.boolean().optional(), + project: z.string().optional(), }); export const actionDocument = z.looseObject({ ...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({}).nullish().describe('Condition gating whether the action runs'), - settings: z - .looseObject({}) - .nullish() - .describe('Action-type-specific settings; see action_type_get for the schema'), + name: z.string().optional().describe('Action type name'), + 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.describe('Type-specific settings; action_type_get gives the schema'), }); -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'), -}); +const actionTypeFields = { + name: z.string().optional().describe('Pass as `name` to action_create'), + title: z.string().optional(), + description: z.string().optional(), + priority: z.number().optional(), + defaults: z.looseObject({}).optional(), +}; + +export const actionTypeDocument = z.looseObject(actionTypeFields); export const actionTypeInfoDocument = 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'), + ...actionTypeFields, 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({}).nullish().describe('Access requirements for configuring the action'), + .describe('Its components are the keys valid in `settings` on action_create'), + access: mixed, }); 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'), - _vuser: z.string().nullish().describe('User who published the revision'), - created: z.string().optional().describe('ISO 8601 timestamp the revision was created'), - modified: z.string().optional().describe('ISO 8601 timestamp the revision was published'), + .describe('Revision number; pass as `version` to form_revision_get or form_revert'), + _id: z.string().optional(), + _vnote: z.string().optional(), + _vuser: z.string().nullish(), + created: z.string().optional(), + modified: z.string().optional(), }); export const templateDocument = z.looseObject({ - 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'), + title: z.string().optional(), + name: z.string().optional(), + version: z.string().optional(), + description: z.string().optional(), + 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. `total` is the collection size Form.io reports, not the page length, so // `hasMore` can say whether another page exists. -function listOutput(key: string, item: z.ZodType, description: string) { +function listOutput(key: string, item: z.ZodType) { return z.looseObject({ - [key]: z.array(item).describe(description), - total: z.number().describe('Number of items Form.io holds for this query, across all pages'), - hasMore: z - .boolean() - .describe('True when items remain past this page; fetch them with a larger `skip`'), + [key]: z.array(item), + total: z.number().describe('Items Form.io holds for this query, across all pages'), + hasMore: z.boolean().describe('True when items remain; fetch them with a larger `skip`'), }); } -export const formsListOutput = listOutput( - 'forms', - formDocument, - 'Matching forms, in the requested sort order' -); +export const formsListOutput = listOutput('forms', formDocument); -export const rolesListOutput = listOutput('roles', roleDocument, 'Roles defined in the project'); +export const rolesListOutput = listOutput('roles', roleDocument); -export const actionsListOutput = listOutput( - 'actions', - actionDocument, - 'Actions configured on the form' -); +export const actionsListOutput = listOutput('actions', actionDocument); -export const actionTypesListOutput = listOutput( - 'actionTypes', - actionTypeDocument, - 'Every action type this deployment supports for the form' -); +export const actionTypesListOutput = listOutput('actionTypes', actionTypeDocument); -export const revisionsListOutput = listOutput( - 'revisions', - revisionSummaryDocument, - 'Revision summaries, newest first unless `sort` says otherwise' -); +export const revisionsListOutput = listOutput('revisions', revisionSummaryDocument); /** For tools whose only meaningful answer is "it worked". */ const acknowledgementShape = { - ok: z.boolean().describe('True when the operation completed'), - message: z.string().describe('Human-readable result detail'), + ok: z.boolean(), + message: z.string(), }; export const acknowledgementOutput = z.looseObject(acknowledgementShape); @@ -188,30 +152,23 @@ export const projectMappingOutput = z.looseObject({ 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' + 'True when the directory is ready for a deployment call. False: the record WAS written, but a committed formio.json governs and supplies no Base URL — make the edit `message` names; do not retry' ), - cwd: z.string().describe('Working directory the mapping is keyed against'), + cwd: z.string(), 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" + 'Project URL the next call targets; under a committed formio.json, the one that file names, not the one recorded here' ), + baseUrl: z.string().optional().describe('The deployment serving `projectUrl`, from its record'), 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' - ), + .describe('False when the mapping was already in place; true means the record changed'), 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' + 'True when the pair was recorded with `project set --force`; not a reason to re-record it' ), }); @@ -224,69 +181,49 @@ export const projectMappingOutput = z.looseObject({ * contract. `ok` is the only status with both URLs; `base-url-unresolved` has a * project and no deployment; `not-configured` has neither. */ -export const projectResolutionOutput = z.looseObject({ - 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'), +const resolutionStatus = z.enum(['ok', 'not-configured', 'base-url-unresolved']); + +const resolutionFields = { + cwd: z.string(), + projectUrl: z.string().optional().describe('Absent when status is not-configured'), + baseUrl: z.string().optional().describe('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)' - ), + .describe('committed, mapping, or environment (the weakest)'), baseUrlSource: z .string() .optional() - .describe( - 'Which layer supplied the base URL: committed, mapping, environment, derived (read off the project URL), or unresolved' - ), + .describe('committed, mapping, environment, derived, or unresolved'), + message: z.string().describe('The full report, including what to do next'), +}; + +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: 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' + 'True when the pair was recorded with `project set --force`; not a reason to distrust it' ), - shadowed: z - .array(z.string()) - .describe('Layers that could have supplied a URL and were overridden, in precedence order'), + shadowed: z.array(z.string()).describe('Layers overridden by a narrower one'), 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'), + .describe('Deployments recorded with no project beside them, so unreadable'), remedy: z .looseObject({ - 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'), + 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()).describe('Anything set aside while resolving'), }); /** @@ -295,15 +232,8 @@ export const projectResolutionOutput = z.looseObject({ * on are documented here; the rest of project_get's report passes through. */ export const serverStatusOutput = z.looseObject({ - name: z.string().describe('Server name'), - version: z.string().describe('Server version'), - status: z - .enum(['ok', 'not-configured', 'base-url-unresolved']) - .describe("How the directory resolves, as project_get's `status` reports it"), - cwd: z.string().describe('Working directory the resolution was performed for'), - projectUrl: z.string().optional().describe('Project URL that resolves for that directory'), - baseUrl: z.string().optional().describe('Deployment hosting that project'), - projectUrlSource: z.string().optional().describe('Which layer supplied the project URL'), - baseUrlSource: z.string().optional().describe('Which layer supplied the base URL'), - message: z.string().describe('The resolution report, including what to do next'), + name: z.string(), + version: z.string(), + status: resolutionStatus.describe("As project_get's `status`"), + ...resolutionFields, }); diff --git a/packages/mcp-server/src/project-resolver.ts b/packages/mcp-server/src/project-resolver.ts index ce4e850d..b438ea69 100644 --- a/packages/mcp-server/src/project-resolver.ts +++ b/packages/mcp-server/src/project-resolver.ts @@ -36,8 +36,11 @@ import { } from './committed-config.js'; import { ToolError } from './tool-errors.js'; +// Repeated on every project-scoped tool, so it carries what to pass and the order +// resolution follows, and leaves the rest to the server instructions and project_set, +// which every client receives once. A test holds it to 200 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."; + "The user's working directory, absolute; pass it on every call. It selects the project: formio.json, else the project_set mapping, else FORMIO_PROJECT_URL (weakest). Rules: server instructions."; // 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 diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 3018c137..60cd76f6 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -17,6 +17,7 @@ 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.`, + "Pass cwd, set to the user's current working directory as an absolute path, on every project-scoped call: omitted, it resolves against this server's own working directory, which is fixed at spawn and may resolve to a different project, and no environment variable removes the need for it.", "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.', `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.`, diff --git a/packages/mcp-server/src/tools/action-schema.ts b/packages/mcp-server/src/tools/action-schema.ts index 8586ac43..b3f2fe99 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', @@ -33,17 +34,34 @@ const actionCondition = z ), }) .optional() - .describe('Condition for when action runs'); + .describe('When the action runs'); 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().describe('Action type name, from action_type_list'), + title: z.string(), + handler: z.array(z.string()).describe('"before" and/or "after"'), + method: z.array(z.string()).describe('e.g. "create", "update"'), + settings: z + .record(z.string(), z.unknown()) + .optional() + .describe("Keys from action_type_get's settingsForm"), 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 118d4861..511b1bf9 100644 --- a/packages/mcp-server/src/tools/action_create.ts +++ b/packages/mcp-server/src/tools/action_create.ts @@ -4,9 +4,9 @@ import { formioFetch } from '../formio-client.js'; import { toMcpStructuredResult, toMcpError } from '../mcp-responses.js'; import { actionDocument } from '../output-schemas.js'; import { creates } from '../tool-annotations.js'; -import { actionDefinitionSchema } from './action-schema.js'; +import { actionDefinitionSchema, unknownActionType } from './action-schema.js'; import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; -import { resourceSegmentArgument } from './path-arguments.js'; +import { formIdArgument, requireFormId } from './form-id.js'; export function registerActionCreateTool(server: McpServer, config: FormioConfig) { server.registerTool( @@ -16,7 +16,7 @@ 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: actionDocument, @@ -24,21 +24,14 @@ export function registerActionCreateTool(server: McpServer, config: FormioConfig }, async ({ cwd, formId, action }) => { try { + requireFormId(formId); const cfg = resolveProjectConfig(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 05e72f29..ce6810b2 100644 --- a/packages/mcp-server/src/tools/action_delete.ts +++ b/packages/mcp-server/src/tools/action_delete.ts @@ -5,6 +5,7 @@ import { toMcpError, toMcpStructuredResult } from '../mcp-responses.js'; import { acknowledgementOutput } from '../output-schemas.js'; import { removes } from '../tool-annotations.js'; import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; +import { formIdArgument, requireFormId } from './form-id.js'; import { resourceSegmentArgument } from './path-arguments.js'; export function registerActionDeleteTool(server: McpServer, config: FormioConfig) { @@ -15,7 +16,7 @@ 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: acknowledgementOutput, @@ -23,6 +24,7 @@ export function registerActionDeleteTool(server: McpServer, config: FormioConfig }, async ({ cwd, formId, actionId }) => { try { + requireFormId(formId); const cfg = resolveProjectConfig(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 diff --git a/packages/mcp-server/src/tools/action_get.ts b/packages/mcp-server/src/tools/action_get.ts index a9a8b4be..f8b1d68e 100644 --- a/packages/mcp-server/src/tools/action_get.ts +++ b/packages/mcp-server/src/tools/action_get.ts @@ -5,6 +5,7 @@ import { toMcpStructuredResult, toMcpError } from '../mcp-responses.js'; import { actionDocument } from '../output-schemas.js'; import { reads } from '../tool-annotations.js'; import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; +import { formIdArgument, requireFormId } from './form-id.js'; import { resourceSegmentArgument } from './path-arguments.js'; export function registerActionGetTool(server: McpServer, config: FormioConfig) { @@ -15,7 +16,7 @@ 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: actionDocument, @@ -23,6 +24,7 @@ export function registerActionGetTool(server: McpServer, config: FormioConfig) { }, async ({ cwd, formId, actionId }) => { try { + requireFormId(formId); const cfg = resolveProjectConfig(cwd, config); const action = (await formioFetch(`form/${formId}/action/${actionId}`, {}, cfg)) as Record< string, diff --git a/packages/mcp-server/src/tools/action_list.ts b/packages/mcp-server/src/tools/action_list.ts index 09abb642..9d055645 100644 --- a/packages/mcp-server/src/tools/action_list.ts +++ b/packages/mcp-server/src/tools/action_list.ts @@ -4,8 +4,8 @@ import { toMcpStructuredResult, toMcpError } from '../mcp-responses.js'; import { actionsListOutput } from '../output-schemas.js'; import { reads } from '../tool-annotations.js'; import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; +import { formIdArgument, requireFormId } from './form-id.js'; import { fetchListPage, listArguments, listResult } from './list-contract.js'; -import { resourceSegmentArgument } from './path-arguments.js'; export function registerActionListTool(server: McpServer, config: FormioConfig) { server.registerTool( @@ -15,7 +15,7 @@ export function registerActionListTool(server: McpServer, config: FormioConfig) '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: actionsListOutput, @@ -23,6 +23,7 @@ export function registerActionListTool(server: McpServer, config: FormioConfig) }, async ({ cwd, formId, ...query }) => { try { + requireFormId(formId); const cfg = resolveProjectConfig(cwd, config); const page = await fetchListPage({ path: `form/${formId}/action`, query, config: cfg }); return toMcpStructuredResult(listResult('actions', page)); diff --git a/packages/mcp-server/src/tools/action_type_get.ts b/packages/mcp-server/src/tools/action_type_get.ts index 227ab879..cabfd847 100644 --- a/packages/mcp-server/src/tools/action_type_get.ts +++ b/packages/mcp-server/src/tools/action_type_get.ts @@ -4,7 +4,9 @@ import { formioFetch } from '../formio-client.js'; import { toMcpStructuredResult, toMcpError } from '../mcp-responses.js'; import { actionTypeInfoDocument } from '../output-schemas.js'; import { reads } from '../tool-annotations.js'; +import { unknownActionType } from './action-schema.js'; import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; +import { formIdArgument, requireFormId } from './form-id.js'; import { resourceSegmentArgument } from './path-arguments.js'; export function registerActionTypeGetTool(server: McpServer, config: FormioConfig) { @@ -15,9 +17,7 @@ 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")' ), @@ -27,6 +27,7 @@ export function registerActionTypeGetTool(server: McpServer, config: FormioConfi }, async ({ cwd, formId, actionName }) => { try { + requireFormId(formId); const cfg = resolveProjectConfig(cwd, config); try { const typeInfo = (await formioFetch( @@ -39,11 +40,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 index 39f58303..a9e5a5bf 100644 --- a/packages/mcp-server/src/tools/action_type_list.ts +++ b/packages/mcp-server/src/tools/action_type_list.ts @@ -5,8 +5,8 @@ import { toMcpStructuredResult, toMcpError } from '../mcp-responses.js'; import { actionTypesListOutput } from '../output-schemas.js'; import { reads } from '../tool-annotations.js'; import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; +import { formIdArgument, requireFormId } from './form-id.js'; import { listResult, pageOf } from './list-contract.js'; -import { resourceSegmentArgument } from './path-arguments.js'; export function registerActionTypeListTool(server: McpServer, config: FormioConfig) { server.registerTool( @@ -16,15 +16,14 @@ export function registerActionTypeListTool(server: McpServer, config: FormioConf 'List the action types available for a form. Returns the whole catalog the server supports in one result.', inputSchema: { cwd: cwdSchema, - formId: resourceSegmentArgument('formId').describe( - 'The form ID to list available action types for' - ), + formId: formIdArgument(), }, outputSchema: actionTypesListOutput, annotations: reads('List action types'), }, async ({ cwd, formId }) => { try { + requireFormId(formId); const cfg = resolveProjectConfig(cwd, config); // Form.io does not page this route: the body is the whole catalog. const catalog = await formioFetch(`form/${formId}/actions`, {}, cfg); diff --git a/packages/mcp-server/src/tools/action_update.ts b/packages/mcp-server/src/tools/action_update.ts index aa357b39..6a031548 100644 --- a/packages/mcp-server/src/tools/action_update.ts +++ b/packages/mcp-server/src/tools/action_update.ts @@ -6,6 +6,7 @@ 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 { formIdArgument, requireFormId } from './form-id.js'; import { resourceSegmentArgument } from './path-arguments.js'; export function registerActionUpdateTool(server: McpServer, config: FormioConfig) { @@ -16,7 +17,7 @@ export function registerActionUpdateTool(server: McpServer, config: FormioConfig '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.', 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, }, @@ -25,6 +26,7 @@ export function registerActionUpdateTool(server: McpServer, config: FormioConfig }, async ({ cwd, formId, actionId, action }) => { try { + requireFormId(formId); const cfg = resolveProjectConfig(cwd, config); const updated = (await formioFetch(`form/${formId}/action/${actionId}`, {}, cfg, { method: 'PUT', 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..2583f3a2 --- /dev/null +++ b/packages/mcp-server/src/tools/form-id.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; +import { MONGO_ID_PATTERN } from '../formio-client.js'; +import { ToolError } from '../tool-errors.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, so a refusal carries the + * `INVALID_ARGUMENT` code: a schema failure is reported by the SDK as bare text. + */ +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 { + if (!MONGO_ID_PATTERN.test(formId)) { + throw new ToolError({ + code: 'INVALID_ARGUMENT', + message: `formId ${JSON.stringify(formId)} is not accepted: 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 5f77f56d..bf95f2c6 100644 --- a/packages/mcp-server/src/tools/form_create.ts +++ b/packages/mcp-server/src/tools/form_create.ts @@ -13,23 +13,18 @@ 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. On a deployment licensed for revisions, 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().describe('URL path, relative to the project'), + components: z.array(z.record(z.string(), z.unknown())), + type: z.enum(['form', 'resource']).optional().describe('Default "form"'), + display: z.enum(['form', 'wizard', 'pdf']).optional().describe('Default "form"'), + tags: z.array(z.string()).optional(), revisions: z .enum(['current', 'original', '']) .optional() @@ -44,7 +39,7 @@ export function registerFormCreateTool(server: McpServer, config: FormioConfig) .boolean() .optional() .describe( - 'Set to true only after the user agrees to save without revision history. Without it, a save that would keep no history (a deployment without the revisions licence, or a form whose revisions are off) is refused with HISTORY_NOT_ACCEPTED and nothing is written.' + '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: formDocument, diff --git a/packages/mcp-server/src/tools/form_get.ts b/packages/mcp-server/src/tools/form_get.ts index 5185a341..a9cf7d93 100644 --- a/packages/mcp-server/src/tools/form_get.ts +++ b/packages/mcp-server/src/tools/form_get.ts @@ -14,7 +14,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( diff --git a/packages/mcp-server/src/tools/form_list.ts b/packages/mcp-server/src/tools/form_list.ts index a32cb4cf..40bf6563 100644 --- a/packages/mcp-server/src/tools/form_list.ts +++ b/packages/mcp-server/src/tools/form_list.ts @@ -15,7 +15,7 @@ export function registerFormListTool(server: McpServer, config: FormioConfig) { 'form_list', { description: - "List forms from the Form.io project mapped to the user's current working directory, one page at a time, optionally filtered by type and tags.", + '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'), diff --git a/packages/mcp-server/src/tools/form_publish.ts b/packages/mcp-server/src/tools/form_publish.ts index 529f91da..9c761349 100644 --- a/packages/mcp-server/src/tools/form_publish.ts +++ b/packages/mcp-server/src/tools/form_publish.ts @@ -1,11 +1,11 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import { FormioConfig } from '../config.js'; -import { MONGO_ID_PATTERN } from '../formio-client.js'; import { toMcpStructuredResult, toMcpError } from '../mcp-responses.js'; import { formDocument } from '../output-schemas.js'; import { overwrites } from '../tool-annotations.js'; import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; +import { formIdArgument, requireFormId } from './form-id.js'; import { publishDraft, requireRevisionsLicense } from '../revisions/index.js'; export function registerFormPublishTool(server: McpServer, config: FormioConfig) { @@ -13,13 +13,10 @@ export function registerFormPublishTool(server: McpServer, config: FormioConfig) 'form_publish', { description: - "Publish a form's draft as its live version in the Form.io project mapped to the user's current working directory. The draft's components, settings, tags, properties, controller, esign and display replace the live form's, and `note` is recorded as the revision note. Save a draft first with form_update and draft: true; a form with no draft fails with NO_DRAFT. Needs the revisions licence (Security Module) on the deployment.", + "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: z - .string() - .regex(MONGO_ID_PATTERN, 'Must be a 24-character MongoDB ObjectId') - .describe('The _id of the form whose draft to publish'), + formId: formIdArgument(), note: z .string() .describe( @@ -31,6 +28,7 @@ export function registerFormPublishTool(server: McpServer, config: FormioConfig) }, async ({ cwd, formId, note }) => { try { + requireFormId(formId); const cfg = resolveProjectConfig(cwd, config); await requireRevisionsLicense(cfg, "publish this form's draft"); return toMcpStructuredResult( diff --git a/packages/mcp-server/src/tools/form_revert.ts b/packages/mcp-server/src/tools/form_revert.ts index 9152fb5a..b88c65e9 100644 --- a/packages/mcp-server/src/tools/form_revert.ts +++ b/packages/mcp-server/src/tools/form_revert.ts @@ -1,11 +1,11 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import { FormioConfig } from '../config.js'; -import { MONGO_ID_PATTERN } from '../formio-client.js'; import { toMcpStructuredResult, toMcpError } from '../mcp-responses.js'; import { formDocument } from '../output-schemas.js'; import { overwrites } from '../tool-annotations.js'; import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; +import { formIdArgument, requireFormId } from './form-id.js'; import { requireRevisionsLicense, revertToRevision } from '../revisions/index.js'; import { resourceSegmentArgument } from './path-arguments.js'; @@ -14,13 +14,10 @@ export function registerFormRevertTool(server: McpServer, config: FormioConfig) 'form_revert', { description: - "Revert a form in the Form.io project mapped to the user's current working directory to a prior revision. The revision's components, tags, properties and display replace the live form's, and `note` is recorded as the revision note. Find the revision with form_revision_list. Needs the revisions licence (Security Module) on the deployment.", + "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: z - .string() - .regex(MONGO_ID_PATTERN, 'Must be a 24-character MongoDB ObjectId') - .describe('The _id of the form to revert'), + formId: formIdArgument(), version: resourceSegmentArgument('version').describe( 'The revision to restore: its _vid (e.g. "3") or its revision document _id' ), @@ -35,6 +32,7 @@ export function registerFormRevertTool(server: McpServer, config: FormioConfig) }, async ({ cwd, formId, version, note }) => { try { + requireFormId(formId); const cfg = resolveProjectConfig(cwd, config); await requireRevisionsLicense(cfg, 'revert this form'); return toMcpStructuredResult( diff --git a/packages/mcp-server/src/tools/form_revision_get.ts b/packages/mcp-server/src/tools/form_revision_get.ts index 391b3825..0d3313e7 100644 --- a/packages/mcp-server/src/tools/form_revision_get.ts +++ b/packages/mcp-server/src/tools/form_revision_get.ts @@ -12,7 +12,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, call `form_revert` with its `_vid`.', + '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( diff --git a/packages/mcp-server/src/tools/form_revision_list.ts b/packages/mcp-server/src/tools/form_revision_list.ts index cd1d39f1..771102ee 100644 --- a/packages/mcp-server/src/tools/form_revision_list.ts +++ b/packages/mcp-server/src/tools/form_revision_list.ts @@ -17,7 +17,7 @@ export function registerFormRevisionListTool(server: McpServer, config: FormioCo 'form_revision_list', { description: - 'List published revision summaries for a single form in the Form.io project mapped to the current working directory, newest first, one page at a time. To inspect a specific revision body, call `form_revision_get` with the desired `_vid`. To revert the live form to a prior revision, call `form_revert` with its `_vid`.', + "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( diff --git a/packages/mcp-server/src/tools/form_update.ts b/packages/mcp-server/src/tools/form_update.ts index 3d783edc..44a88bd8 100644 --- a/packages/mcp-server/src/tools/form_update.ts +++ b/packages/mcp-server/src/tools/form_update.ts @@ -1,11 +1,12 @@ 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 { formDocument } from '../output-schemas.js'; import { overwrites } from '../tool-annotations.js'; import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; +import { formIdArgument, requireFormId } from './form-id.js'; import { gateFormHistory, gateRevisionsLicense, @@ -19,26 +20,21 @@ export function registerFormUpdateTool(server: McpServer, config: FormioConfig) '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.", - 'Pass `draft: true` to save the change as a draft instead of the live form; publish it with form_publish, and restore a prior revision with form_revert.', + 'Replace a form in the project `cwd` resolves to with the complete updated JSON: read it with form_get, apply the change using the formio-schema skill, and send the whole form.', + '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().describe('URL path, relative to the project'), + 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() @@ -47,23 +43,23 @@ export function registerFormUpdateTool(server: McpServer, config: FormioConfig) ), }) .catchall(z.unknown()) - .describe('Complete updated Form.io form JSON definition'), + .describe('The complete updated form JSON'), note: z .string() .describe( - 'Required note describing the diff (live form vs updated body) — no action preambles ("Saved draft:", "Updated:").' + 'What changed against the live form, with no preamble ("Saved draft:", "Updated:")' ), draft: z .boolean() .optional() .describe( - "When true, save a draft (PUT /form/{formId}/draft) instead of the live form. Only the draft fields of `form` are saved (components, settings, tags, properties, controller, esign, display), merged over any existing draft; every other field is ignored, so form_get's output can be passed as is." + "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." ), acceptNoHistory: z .boolean() .optional() .describe( - 'Set to true only after the user agrees to save without revision history. Without it, a save that would keep no history (a deployment without the revisions licence, or a form whose revisions are off) is refused with HISTORY_NOT_ACCEPTED and nothing is written.' + '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: formDocument, @@ -71,6 +67,7 @@ export function registerFormUpdateTool(server: McpServer, config: FormioConfig) }, async ({ cwd, formId, form: rawForm, note, draft, acceptNoHistory }) => { try { + requireFormId(formId); const cfg = resolveProjectConfig(cwd, config); if (draft) { diff --git a/packages/mcp-server/src/tools/list-contract.ts b/packages/mcp-server/src/tools/list-contract.ts index f269fa7e..c1411348 100644 --- a/packages/mcp-server/src/tools/list-contract.ts +++ b/packages/mcp-server/src/tools/list-contract.ts @@ -22,18 +22,8 @@ export interface ListArgumentDefaults { /** 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('Maximum number of items to return'), - skip: z - .number() - .int() - .nonnegative() - .default(0) - .describe("Number of items to skip; page with the result's `hasMore`"), + 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() diff --git a/packages/mcp-server/src/tools/project_export.ts b/packages/mcp-server/src/tools/project_export.ts index 7c722dc8..8c20ec0a 100644 --- a/packages/mcp-server/src/tools/project_export.ts +++ b/packages/mcp-server/src/tools/project_export.ts @@ -11,7 +11,7 @@ export function registerProjectExportTool(server: McpServer, config: FormioConfi '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, }, diff --git a/packages/mcp-server/src/tools/project_get.ts b/packages/mcp-server/src/tools/project_get.ts index 8e51a59a..689c8711 100644 --- a/packages/mcp-server/src/tools/project_get.ts +++ b/packages/mcp-server/src/tools/project_get.ts @@ -24,10 +24,9 @@ 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: projectResolutionOutput, diff --git a/packages/mcp-server/src/tools/project_import.ts b/packages/mcp-server/src/tools/project_import.ts index c2d462ec..5e875b98 100644 --- a/packages/mcp-server/src/tools/project_import.ts +++ b/packages/mcp-server/src/tools/project_import.ts @@ -12,7 +12,7 @@ export function registerProjectImportTool(server: McpServer, config: FormioConfi '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'), diff --git a/packages/mcp-server/src/tools/project_set.ts b/packages/mcp-server/src/tools/project_set.ts index df3336f1..74898798 100644 --- a/packages/mcp-server/src/tools/project_set.ts +++ b/packages/mcp-server/src/tools/project_set.ts @@ -64,14 +64,14 @@ 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 working directory by recording its Project URL in ~/.formio/projects.json, keyed by `cwd` (omitted, the server's own spawn directory, which may not be the user's).", + 'Call it whenever the user asks to set, change, or switch the active project; acknowledging in text persists nothing.', + `It needs no restart, and precedence runs: a committed ${COMMITTED_CONFIG_FILE}, then the working-directory mapping, then FORMIO_PROJECT_URL in the environment, the weakest — so a mapping written here DOES override the environment, and a committed file overrides both. Read \`ok\` and \`projectUrl\` on the result: they report the pair that actually resolves.`, + 'Pass projectUrl alone: the Base URL, which builds the portal-login URL and keys the cached token, is derived — https://api.form.io for a form.io host, the parent path for a sub-directory project. Pass baseUrl only after the server reports it cannot be determined (a path-less project URL on a customer domain).', + `To record the target with the code instead, write a committed ${COMMITTED_CONFIG_FILE} yourself in the application's folder: {"projectUrl": "..."}, plus "baseUrl" only when it cannot be derived. This server reads that file 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 @@ -83,18 +83,18 @@ export function registerProjectSetTool(server: McpServer, options: ProjectSetOpt .url({ protocol: /^https?$/ }) .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 URL of the Form.io project: https://examples.form.io on the hosted cloud (never https://api.form.io), or https://myproject.mysite.com or https://forms.mysite.com/myproject on a customer deployment. Omit it only to add a baseUrl to THIS DIRECTORY'S OWN mapping. Where another record holds the project, a baseUrl alone is refused: add it to a committed formio.json by hand, or, for FORMIO_PROJECT_URL in the environment, 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." + "The user's working directory, absolute, that the mapping is keyed against. Omitted, the server's own spawn directory is used. Rules: project_set and the server instructions." ), baseUrl: z .url({ protocol: /^https?$/ }) .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; it builds the portal-login URL and keys the cached token. Usually omitted: it is derived from projectUrl. Pass it when the server reports it cannot be determined; it may carry a sub-path. Refused for a form.io host, which only https://api.form.io serves. Omitted, the value mapped for this directory is kept, unless the call re-points it to a different project.' ), }), outputSchema: projectMappingOutput, diff --git a/packages/mcp-server/src/tools/role-schema.ts b/packages/mcp-server/src/tools/role-schema.ts index 97750d3e..af20e3f3 100644 --- a/packages/mcp-server/src/tools/role-schema.ts +++ b/packages/mcp-server/src/tools/role-schema.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -export const roleFields = { +const roleFields = { title: z.string().optional().describe('Role title'), description: z.string().optional().describe('Role description'), default: z @@ -9,3 +9,9 @@ export const roleFields = { .describe('When true, role is assigned to every new authenticated user'), admin: z.boolean().optional().describe('When true, holders bypass access checks'), }; + +/** A role document, as role_update replaces 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().describe('Role title') }); diff --git a/packages/mcp-server/src/tools/role_create.ts b/packages/mcp-server/src/tools/role_create.ts index c03681ab..4b885acb 100644 --- a/packages/mcp-server/src/tools/role_create.ts +++ b/packages/mcp-server/src/tools/role_create.ts @@ -1,28 +1,25 @@ 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 { roleDocument } from '../output-schemas.js'; import { creates } from '../tool-annotations.js'; -import { roleFields } from './role-schema.js'; +import { newRoleSchema } from './role-schema.js'; import { cwdSchema, resolveProjectConfig } from '../project-resolver.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: roleDocument, annotations: creates('Create a role'), }, - async ({ cwd, ...role }) => { + async ({ cwd, role }) => { try { const cfg = resolveProjectConfig(cwd, config); const created = (await formioFetch('role', {}, cfg, { diff --git a/packages/mcp-server/src/tools/role_list.ts b/packages/mcp-server/src/tools/role_list.ts index 67ee946a..c84ceaef 100644 --- a/packages/mcp-server/src/tools/role_list.ts +++ b/packages/mcp-server/src/tools/role_list.ts @@ -10,8 +10,7 @@ export function registerRoleListTool(server: McpServer, config: FormioConfig) { server.registerTool( 'role_list', { - description: - "List the roles defined in the Form.io project mapped to the user's current working directory, one page at a time.", + description: 'List the roles defined in the project `cwd` resolves to, one page at a time.', inputSchema: { cwd: cwdSchema, ...listArguments(), diff --git a/packages/mcp-server/src/tools/role_update.ts b/packages/mcp-server/src/tools/role_update.ts index 07851906..5cd9e763 100644 --- a/packages/mcp-server/src/tools/role_update.ts +++ b/packages/mcp-server/src/tools/role_update.ts @@ -5,7 +5,7 @@ import { formioFetch, MONGO_ID_PATTERN } from '../formio-client.js'; import { toMcpStructuredResult, toMcpError } from '../mcp-responses.js'; import { roleDocument } from '../output-schemas.js'; import { overwrites } from '../tool-annotations.js'; -import { roleFields } from './role-schema.js'; +import { roleSchema } from './role-schema.js'; import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; export function registerRoleUpdateTool(server: McpServer, config: FormioConfig) { @@ -13,17 +13,14 @@ export function registerRoleUpdateTool(server: McpServer, config: FormioConfig) '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. This is a full replacement — include all fields you want to preserve.', 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'), + role: roleSchema.describe('Role document with updated fields'), }, outputSchema: roleDocument, annotations: overwrites('Update a role'), diff --git a/packages/mcp-server/src/tools/server_status.ts b/packages/mcp-server/src/tools/server_status.ts index c2f9b670..3607a901 100644 --- a/packages/mcp-server/src/tools/server_status.ts +++ b/packages/mcp-server/src/tools/server_status.ts @@ -25,7 +25,7 @@ export function registerServerStatusTool( 'server_status', { description: - "Report this server's name and version and how the given working directory resolves to a Form.io project (the same answer project_get gives). Makes no Form.io request and needs no project or credentials, so it is the first thing to call when other tools fail.", + "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), From 94ca61949783367bfeeef4c21ceb3c79df6f058f Mon Sep 17 00:00:00 2001 From: Travis Tidwell Date: Fri, 9 Oct 2026 10:44:20 -0500 Subject: [PATCH 07/13] docs(skills): follow the 1.0 tool surface Skills and READMEs name the current 23 tools: action_type_list, form_revision_list, server_status, form_publish, form_revert, nested role_create, the list contract (total / hasMore), [CODE] errors and acceptNoHistory. The shared preflight paragraph lists every writing tool. The server README gains a conventions section and a CLI reference (flags and exit codes). New skill-tests checks keep retired names out and the preflight writing-tool list complete. Co-Authored-By: Claude Opus 5.5 --- PROCESS.md | 2 +- README.md | 39 +++++--- llms-install.md | 2 +- .../changes/freeze-1-0-tool-surface/tasks.md | 10 +- packages/mcp-server/DOCKERHUB.md | 2 +- packages/mcp-server/README.md | 72 +++++++++++---- .../preflight-blocking-scope.test.ts | 2 +- .../preflight-writing-tools.test.ts | 52 +++++++++++ .../retired-tool-surface.test.ts | 71 +++++++++++++++ .../retired-tool-surface.ts | 91 +++++++++++++++++++ plugin/README.md | 4 +- plugin/skills/formio-actions/SKILL.md | 24 ++--- .../formio-actions/references/action-types.md | 2 +- plugin/skills/formio-angular/SKILL.md | 2 +- .../formio-angular-form/SKILL.md | 2 +- .../formio-angular-resources/SKILL.md | 2 +- plugin/skills/formio-api/SKILL.md | 4 +- .../formio-api/references/project-actions.md | 14 ++- .../references/project-form-revisions.md | 49 +++++----- .../formio-api/references/project-forms.md | 2 + .../formio-api/references/project-roles.md | 2 + plugin/skills/formio-application/SKILL.md | 2 +- plugin/skills/formio-auth/SKILL.md | 2 +- .../references/roles-and-permissions.md | 2 +- plugin/skills/formio-form-builder/SAVE.md | 4 + plugin/skills/formio-form-builder/SKILL.md | 2 +- plugin/skills/formio-form/SKILL.md | 2 +- plugin/skills/formio-react/SKILL.md | 2 +- .../formio-react/formio-react-form/SKILL.md | 2 +- .../formio-react-resources/SKILL.md | 2 +- .../references/phase-b-emission.md | 4 +- plugin/skills/formio-schema/SKILL.md | 2 +- plugin/skills/formio-sdk/SKILL.md | 2 +- plugin/skills/formio-sdk/references/roles.md | 2 + 34 files changed, 381 insertions(+), 99 deletions(-) create mode 100644 packages/skill-tests/src/skill-descriptions/preflight-writing-tools.test.ts create mode 100644 packages/skill-tests/src/skill-descriptions/retired-tool-surface.test.ts create mode 100644 packages/skill-tests/src/skill-descriptions/retired-tool-surface.ts 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..c2d1cff0 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` | Replace a form with its complete updated 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` | Full-replacement update of a role (`roleId`, `role`). Include all fields you want preserved. | ### 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` | Full-replacement update of an action. | | `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 takes `cwd`, the user's working directory as an absolute path, and resolves the project from it on each call. +- **`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. `action_type_list` returns its `actionTypes` the same way, always with `hasMore: false`. +- **Updates replace.** `form_update`, `role_update` and `action_update` send the full document (PUT); a field left out is not kept. +- **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/tasks.md b/openspec/changes/freeze-1-0-tool-surface/tasks.md index 1e988069..aeabdabb 100644 --- a/openspec/changes/freeze-1-0-tool-surface/tasks.md +++ b/openspec/changes/freeze-1-0-tool-surface/tasks.md @@ -158,17 +158,17 @@ ### Red -- [ ] 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 -- [ ] 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 +- [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 -- [ ] 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` -- [ ] 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` +- [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 -- [ ] 10.5 Review implementation and refactor as needed +- [x] 10.5 Review implementation and refactor as needed ## 11. Release notes and Definition of Done 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 3c684e44..f9e73205 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 @@ -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` | Replace a form with its complete updated 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` | Full-replacement update of a role (`roleId`, `role`). Include all fields you want preserved. | ### 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` | Full-replacement update of an action. | | `action_delete` | Detach an action from a form. | ### Project @@ -233,7 +235,16 @@ The tool's description carries only the rules a caller acts on. The reasons behi | 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 takes `cwd`, the user's working directory as an absolute path, and resolves the project from it on each call. +- **`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. `action_type_list` returns its `actionTypes` the same way, always with `hasMore: false`. +- **Updates replace.** `form_update`, `role_update` and `action_update` send the full document (PUT); a field left out is not kept. +- **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. --- @@ -299,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/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..cdd5cdca 100644 --- a/plugin/skills/formio-actions/SKILL.md +++ b/plugin/skills/formio-actions/SKILL.md @@ -26,7 +26,7 @@ That ban is on **build-time** work — the configuring you do in this session. I 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..619143ec 100644 --- a/plugin/skills/formio-angular/SKILL.md +++ b/plugin/skills/formio-angular/SKILL.md @@ -43,7 +43,7 @@ That ban is on **build-time** work — the configuring you do in this session. I 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..2257a2b6 100644 --- a/plugin/skills/formio-angular/formio-angular-form/SKILL.md +++ b/plugin/skills/formio-angular/formio-angular-form/SKILL.md @@ -111,7 +111,7 @@ That ban is on **build-time** work — the configuring you do in this session. I 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..a60aeb40 100644 --- a/plugin/skills/formio-angular/formio-angular-resources/SKILL.md +++ b/plugin/skills/formio-angular/formio-angular-resources/SKILL.md @@ -28,7 +28,7 @@ That ban is on **build-time** work — the configuring you do in this session. I 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..9827dcf7 100644 --- a/plugin/skills/formio-api/SKILL.md +++ b/plugin/skills/formio-api/SKILL.md @@ -26,7 +26,7 @@ That ban is on **build-time** work — the configuring you do in this session. I 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. @@ -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..e13cb07e 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 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..baf9ea41 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 diff --git a/plugin/skills/formio-api/references/project-roles.md b/plugin/skills/formio-api/references/project-roles.md index c06cdcb2..b87c0029 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 diff --git a/plugin/skills/formio-application/SKILL.md b/plugin/skills/formio-application/SKILL.md index 677a2e74..f88d5a21 100644 --- a/plugin/skills/formio-application/SKILL.md +++ b/plugin/skills/formio-application/SKILL.md @@ -28,7 +28,7 @@ In this skill that first call is Step 3's import, and asking there is the whole 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..5aabb725 100644 --- a/plugin/skills/formio-auth/SKILL.md +++ b/plugin/skills/formio-auth/SKILL.md @@ -22,7 +22,7 @@ That ban is on **build-time** work — the configuring you do in this session. I 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-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..8d8d8c56 100644 --- a/plugin/skills/formio-form-builder/SKILL.md +++ b/plugin/skills/formio-form-builder/SKILL.md @@ -26,7 +26,7 @@ That ban is on **build-time** work — the configuring you do in this session. I 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..7d8883c4 100644 --- a/plugin/skills/formio-form/SKILL.md +++ b/plugin/skills/formio-form/SKILL.md @@ -30,7 +30,7 @@ That ban is on **build-time** work — the configuring you do in this session. I 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/SKILL.md b/plugin/skills/formio-react/SKILL.md index 9a0829e5..21143be1 100644 --- a/plugin/skills/formio-react/SKILL.md +++ b/plugin/skills/formio-react/SKILL.md @@ -48,7 +48,7 @@ That ban is on **build-time** work — the configuring you do in this session. I 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..db190de8 100644 --- a/plugin/skills/formio-react/formio-react-form/SKILL.md +++ b/plugin/skills/formio-react/formio-react-form/SKILL.md @@ -40,7 +40,7 @@ That ban is on **build-time** work — the configuring you do in this session. I 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..3a2e67d5 100644 --- a/plugin/skills/formio-react/formio-react-resources/SKILL.md +++ b/plugin/skills/formio-react/formio-react-resources/SKILL.md @@ -28,7 +28,7 @@ That ban is on **build-time** work — the configuring you do in this session. I 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..74cbf40a 100644 --- a/plugin/skills/formio-resource-planner/references/phase-b-emission.md +++ b/plugin/skills/formio-resource-planner/references/phase-b-emission.md @@ -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..a557668b 100644 --- a/plugin/skills/formio-schema/SKILL.md +++ b/plugin/skills/formio-schema/SKILL.md @@ -31,7 +31,7 @@ That ban is on **build-time** work — the configuring you do in this session. I 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..09697b1d 100644 --- a/plugin/skills/formio-sdk/SKILL.md +++ b/plugin/skills/formio-sdk/SKILL.md @@ -26,7 +26,7 @@ That ban is on **build-time** work — the configuring you do in this session. I 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/references/roles.md b/plugin/skills/formio-sdk/references/roles.md index 0f2b517d..c60c68ae 100644 --- a/plugin/skills/formio-sdk/references/roles.md +++ b/plugin/skills/formio-sdk/references/roles.md @@ -102,4 +102,6 @@ Inside this workspace, prefer the first-party MCP tools: | List roles | `role_list` | `new Formio(rolesUrl).loadRoles()` | | Update a role | `role_update` | `new Formio(roleUrl).saveRole(role)` | +`role_create` takes the role as a nested `role` object — `role: { title, description }` — the same document `saveRole(role)` posts; `role_update` takes it the same way. + There is no first-party `role_delete` MCP tool today — use the SDK or `DELETE ${roleUrl}` for deletion. From 9e28a1d3b7146351e22ec46a2caea594630ff8bf Mon Sep 17 00:00:00 2001 From: Travis Tidwell Date: Fri, 9 Oct 2026 10:45:09 -0500 Subject: [PATCH 08/13] chore(release): major changeset for the 1.0 tool surface Co-Authored-By: Claude Opus 5.5 --- .changeset/freeze-1-0-tool-surface.md | 17 +++++++++++++++++ .../changes/freeze-1-0-tool-surface/tasks.md | 8 ++++---- 2 files changed, 21 insertions(+), 4 deletions(-) create mode 100644 .changeset/freeze-1-0-tool-surface.md 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/openspec/changes/freeze-1-0-tool-surface/tasks.md b/openspec/changes/freeze-1-0-tool-surface/tasks.md index aeabdabb..f4e35c22 100644 --- a/openspec/changes/freeze-1-0-tool-surface/tasks.md +++ b/openspec/changes/freeze-1-0-tool-surface/tasks.md @@ -175,13 +175,13 @@ ### Red -- [ ] 11.1 Confirm `pnpm check:releases` fails until the changeset exists +- [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 -- [ ] 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 -- [ ] 11.3 Run `pnpm test`, `pnpm lint`, `pnpm format`, `pnpm check:releases` and `openspec validate --strict` +- [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 -- [ ] 11.4 Review implementation and refactor as needed +- [x] 11.4 Review implementation and refactor as needed From b82b707fd4b51e64887a8b2b4d8a4a26b6007ea3 Mon Sep 17 00:00:00 2001 From: Travis Tidwell Date: Fri, 9 Oct 2026 11:05:00 -0500 Subject: [PATCH 09/13] fix(mcp): resolve review of the 1.0 tool surface - The revisions licence probe is three-state; only a definite answer is cached, and an undetermined licence neither strips history nor refuses as unlicensed. draft/publish/revert report the probe's own failure. - project_set, token validation and browser-login failures carry codes (INVALID_ARGUMENT, NETWORK_ERROR, AUTH_REQUIRED); role_update's roleId check moved into the handler. A test drives every registered tool into an error and requires a [CODE]. - form_revision_list excludes the draft row; form_revert refuses version "draft" and points to form_publish. - A full page with no reported total sets hasMore; the per-form history check selects only the fields it reads; Form.io errors given as arrays reach the message. Co-Authored-By: Claude Opus 5.5 --- .../changes/freeze-1-0-tool-surface/design.md | 10 +- .../src/__tests__/auth-failure-codes.test.ts | 83 ++++++++++ .../every-tool-error-contract.test.ts | 120 ++++++++++++++ .../src/__tests__/form_create.test.ts | 36 ++++- .../src/__tests__/form_revert.test.ts | 14 ++ .../src/__tests__/form_revision_list.test.ts | 23 ++- .../src/__tests__/form_update.test.ts | 34 ++++ .../__tests__/formio-client-errors.test.ts | 24 +++ .../src/__tests__/list-contract.test.ts | 20 ++- .../__tests__/project_set-error-codes.test.ts | 60 +++++++ .../src/__tests__/revisions.test.ts | 150 ++++++++++++++++-- .../src/__tests__/role_update.test.ts | 7 + .../src/__tests__/token-validation.test.ts | 19 +++ packages/mcp-server/src/auth.ts | 18 ++- packages/mcp-server/src/config.ts | 5 +- packages/mcp-server/src/formio-client.ts | 26 ++- packages/mcp-server/src/mcp-responses.ts | 14 ++ packages/mcp-server/src/revisions/history.ts | 6 +- packages/mcp-server/src/revisions/license.ts | 128 +++++++++++---- packages/mcp-server/src/token-validation.ts | 9 +- packages/mcp-server/src/tool-errors.ts | 14 +- .../mcp-server/src/tools/action_type_list.ts | 2 +- packages/mcp-server/src/tools/form-id.ts | 18 +-- packages/mcp-server/src/tools/form_create.ts | 3 +- packages/mcp-server/src/tools/form_revert.ts | 13 ++ .../src/tools/form_revision_list.ts | 5 + packages/mcp-server/src/tools/form_update.ts | 6 +- .../mcp-server/src/tools/list-contract.ts | 16 +- packages/mcp-server/src/tools/object-id.ts | 25 +++ packages/mcp-server/src/tools/project_set.ts | 32 +++- packages/mcp-server/src/tools/role_update.ts | 13 +- 31 files changed, 845 insertions(+), 108 deletions(-) create mode 100644 packages/mcp-server/src/__tests__/auth-failure-codes.test.ts create mode 100644 packages/mcp-server/src/__tests__/every-tool-error-contract.test.ts create mode 100644 packages/mcp-server/src/__tests__/project_set-error-codes.test.ts create mode 100644 packages/mcp-server/src/tools/object-id.ts diff --git a/openspec/changes/freeze-1-0-tool-surface/design.md b/openspec/changes/freeze-1-0-tool-surface/design.md index 136f9555..1b67afc7 100644 --- a/openspec/changes/freeze-1-0-tool-surface/design.md +++ b/openspec/changes/freeze-1-0-tool-surface/design.md @@ -23,7 +23,7 @@ This change follows `confine-requests-to-project-origin` (PR #88), whose path-ar **Non-Goals:** -- 401 handling, timeouts, the licence-probe cache, browser launch, logout — `harden-auth-and-errors`. +- 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 full replacement (PUT) in 1.0, documented as such. - Cutting the 1.0.0 version — the plan's release step. @@ -43,12 +43,14 @@ The flows already exist as `publishDraft` / `revertToVersion`; the tools are thi 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 not cached, so the next call 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 authentication failures raised by the login flow stay `INTERNAL` until `harden-auth-and-errors`. -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. +`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 (no browser, login timeout, a login port that cannot be bound, a login form that cannot be resolved) are `AUTH_REQUIRED` with their messages unchanged. `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. +`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. 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..76b3a441 --- /dev/null +++ b/packages/mcp-server/src/__tests__/auth-failure-codes.test.ts @@ -0,0 +1,83 @@ +// Every way the browser login can fail is the same answer to a caller: no credential +// was obtained, so the call needs authentication it does not have. Each failure keeps +// its own prose — that is what tells the user what to do — and carries AUTH_REQUIRED, +// so a client branching on the code sees an authentication failure rather than +// INTERNAL. + +import net from 'node:net'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { authenticate } from '../auth.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 AUTH_REQUIRED', () => { + 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: 'AUTH_REQUIRED' }); + await expect(attempt).rejects.toThrow(/Could not start the Form\.io login server/); + } 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: 'AUTH_REQUIRED' }); + await expect(attempt).rejects.toThrow(/Invalid URL/); + }); +}); 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_create.test.ts b/packages/mcp-server/src/__tests__/form_create.test.ts index 44434256..5a570fc9 100644 --- a/packages/mcp-server/src/__tests__/form_create.test.ts +++ b/packages/mcp-server/src/__tests__/form_create.test.ts @@ -1,8 +1,15 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { createTestClient, stubRevisionsLicence, TEST_CONFIG, TEST_CWD } from './test-helpers.js'; +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), })); @@ -139,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_revert.test.ts b/packages/mcp-server/src/__tests__/form_revert.test.ts index ee0983e5..a6af6764 100644 --- a/packages/mcp-server/src/__tests__/form_revert.test.ts +++ b/packages/mcp-server/src/__tests__/form_revert.test.ts @@ -151,4 +151,18 @@ describe('form_revert tool', () => { 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(); + } + ); }); diff --git a/packages/mcp-server/src/__tests__/form_revision_list.test.ts b/packages/mcp-server/src/__tests__/form_revision_list.test.ts index 8cdf1f29..3a3d03a1 100644 --- a/packages/mcp-server/src/__tests__/form_revision_list.test.ts +++ b/packages/mcp-server/src/__tests__/form_revision_list.test.ts @@ -33,7 +33,7 @@ describe('form_revision_list tool', () => { expect(mockFormioFetch).toHaveBeenCalledWith( `form/${id}/v`, - { limit: '100', skip: '0', sort: '-_vid', select: COMPACT_SELECT }, + { _vid__ne: 'draft', limit: '100', skip: '0', sort: '-_vid', select: COMPACT_SELECT }, TEST_CONFIG, { withMeta: true } ); @@ -72,7 +72,7 @@ describe('form_revision_list tool', () => { expect(mockFormioFetch).toHaveBeenCalledWith( 'contact/v', - { limit: '100', skip: '0', sort: '_vid', select: '_vid,title' }, + { _vid__ne: 'draft', limit: '100', skip: '0', sort: '_vid', select: '_vid,title' }, TEST_CONFIG, { withMeta: true } ); @@ -89,4 +89,23 @@ describe('form_revision_list tool', () => { 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_update.test.ts b/packages/mcp-server/src/__tests__/form_update.test.ts index 62eb47e4..0326ca51 100644 --- a/packages/mcp-server/src/__tests__/form_update.test.ts +++ b/packages/mcp-server/src/__tests__/form_update.test.ts @@ -245,4 +245,38 @@ describe('form_update tool', () => { ); expect(mockFormioFetch).not.toHaveBeenCalled(); }); + + // 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'; + 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() + ); + + mockFormioFetch.mockClear(); + await client.callTool({ + name: 'form_update', + arguments: { cwd: TEST_CWD, formId, form, note: 'n' }, + }); + expect(mockFormioFetch).toHaveBeenCalledWith(`form/${formId}`, {}, expect.anything(), { + method: 'PUT', + 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 index fa391d18..458de02f 100644 --- a/packages/mcp-server/src/__tests__/formio-client-errors.test.ts +++ b/packages/mcp-server/src/__tests__/formio-client-errors.test.ts @@ -110,6 +110,30 @@ describe('formioFetch raises a FormioApiError for an error response', () => { 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')); diff --git a/packages/mcp-server/src/__tests__/list-contract.test.ts b/packages/mcp-server/src/__tests__/list-contract.test.ts index 8169f833..9f89edf3 100644 --- a/packages/mcp-server/src/__tests__/list-contract.test.ts +++ b/packages/mcp-server/src/__tests__/list-contract.test.ts @@ -51,16 +51,18 @@ describe('the list contract', () => { describe('pageOf', () => { it('has more while skip plus the page is short of the total', () => { - expect(pageOf({ items: [1, 2], total: 5, skip: 0 })).toEqual({ + 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 })).toMatchObject({ hasMore: false }); + 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 })).toEqual({ + expect(pageOf({ items: [], total: 3, skip: 500, limit: 100 })).toEqual({ items: [], total: 3, hasMore: false, @@ -68,11 +70,21 @@ describe('the list contract', () => { }); it('counts what it was given when Form.io reports no total', () => { - expect(pageOf({ items: [1, 2], total: undefined, skip: 10 })).toEqual({ + expect(pageOf({ items: [1, 2], total: undefined, skip: 10, limit: 5 })).toEqual({ items: [1, 2], total: 12, hasMore: false, }); }); + + // 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], + total: 12, + hasMore: true, + }); + }); }); }); 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..b777fdbc --- /dev/null +++ b/packages/mcp-server/src/__tests__/project_set-error-codes.test.ts @@ -0,0 +1,60 @@ +// 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', + ], + ])('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.test.ts b/packages/mcp-server/src/__tests__/revisions.test.ts index 35427670..49ff7364 100644 --- a/packages/mcp-server/src/__tests__/revisions.test.ts +++ b/packages/mcp-server/src/__tests__/revisions.test.ts @@ -32,6 +32,14 @@ const stubLicensed = (licensed: boolean) => ); const uniqueBaseUrl = () => `https://license-${randomUUID()}.local`; + +// 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(() => { @@ -40,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() @@ -65,6 +108,23 @@ describe('checkRevisionsLicensed', () => { await checkRevisionsLicensed(cfgFor(baseUrl)); expect(fetchSpy).toHaveBeenCalledTimes(1); }); + + // A failed probe is not an answer: caching it would pin "unknown" on a deployment + // that was only briefly unreachable. + it('does not cache an unknown answer — a transient failure then success is licensed', async () => { + const baseUrl = uniqueBaseUrl(); + const fetchSpy = vi + .fn() + .mockRejectedValueOnce(networkFailure()) + .mockResolvedValueOnce({ ok: false, status: 503 }) + .mockResolvedValue({ ok: true, text: () => Promise.resolve('sac = true') }); + vi.stubGlobal('fetch', fetchSpy); + + expect(await checkRevisionsLicensed(cfgFor(baseUrl))).toMatchObject({ state: 'unknown' }); + expect(await checkRevisionsLicensed(cfgFor(baseUrl))).toMatchObject({ state: 'unknown' }); + expect(await checkRevisionsLicensed(cfgFor(baseUrl))).toEqual({ state: 'licensed' }); + expect(fetchSpy).toHaveBeenCalledTimes(3); + }); }); describe('requireRevisionsLicense', () => { @@ -83,6 +143,32 @@ describe('requireRevisionsLicense', () => { requireRevisionsLicense(cfgFor(uniqueBaseUrl()), 'publish this form') ).resolves.toBeUndefined(); }); + + // 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(); + + 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 attempt = requireRevisionsLicense(cfgFor(baseUrl), 'revert this form'); + + 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', () => { @@ -135,6 +221,47 @@ describe('gateRevisionsLicense', () => { expect(result).toEqual({ licensed: true, form }); }); + // 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 withoutRevisions = { components: [] }; + const passed = await gateRevisionsLicense({ + cfg: cfgFor(uniqueBaseUrl()), + actionLabel: 'update this form', + form: withoutRevisions, + }); + expect(passed.form).toBe(withoutRevisions); + }); + + // 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 attempt = gateRevisionsLicense({ + cfg: cfgFor(uniqueBaseUrl()), + actionLabel: 'update this form', + form: { revisions: '', components: [] }, + }); + await expect(attempt).rejects.toMatchObject({ code: 'HISTORY_NOT_ACCEPTED' }); + await expect(attempt).rejects.toThrow(/sets revisions to ""/); + }); + it('passes through unchanged when licensed', async () => { stubLicensed(true); @@ -174,7 +301,12 @@ describe('gateFormHistory', () => { mockFormioFetch.mockResolvedValue({ name: 'demo', revisions: 'original' }); await gateFormHistory({ cfg, formId: FORM_ID, form: { components: [] } }); - expect(mockFormioFetch).toHaveBeenCalledWith(`form/${FORM_ID}`, {}, cfg); + // 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. diff --git a/packages/mcp-server/src/__tests__/role_update.test.ts b/packages/mcp-server/src/__tests__/role_update.test.ts index 97983877..65ead9e5 100644 --- a/packages/mcp-server/src/__tests__/role_update.test.ts +++ b/packages/mcp-server/src/__tests__/role_update.test.ts @@ -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__/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/auth.ts b/packages/mcp-server/src/auth.ts index 9aee1680..3142826e 100644 --- a/packages/mcp-server/src/auth.ts +++ b/packages/mcp-server/src/auth.ts @@ -3,12 +3,19 @@ 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'; + +// However the browser login fails, the call is left without a credential, so every +// such failure is AUTH_REQUIRED; the message is what says which failure and its remedy. +function loginFailed(message: string, cause?: unknown): ToolError { + return new ToolError({ code: 'AUTH_REQUIRED', message, cause }); +} // 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 +216,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 +248,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(loginFailed(message, err)); } }); @@ -269,9 +276,10 @@ export async function authenticate( // it would leave the caller waiting out the whole login timeout. if (err) { rejectJwt( - new Error( + loginFailed( `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.` + `Free that port, or set FORMIO_AUTH_PORT to one that is available.`, + err ) ); return; @@ -301,7 +309,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/config.ts b/packages/mcp-server/src/config.ts index 47d2b351..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'; } } diff --git a/packages/mcp-server/src/formio-client.ts b/packages/mcp-server/src/formio-client.ts index fd2c75a3..43ba8e0b 100644 --- a/packages/mcp-server/src/formio-client.ts +++ b/packages/mcp-server/src/formio-client.ts @@ -52,11 +52,13 @@ async function readPage(response: Response, url: URL): Promise { return { data: await response.json(), total }; } -async function readResponse( - response: Response, - url: URL, - options?: FormioFetchOptions -): Promise { +interface ReadResponseRequest { + response: Response; + url: URL; + options?: FormioFetchOptions; +} + +async function readResponse({ response, url, options }: ReadResponseRequest): Promise { if (options?.withMeta) { return readPage(response, url); } @@ -89,7 +91,11 @@ async function readBody(response: Response): Promise { } } -async function send(url: URL, init: RequestInit): Promise { +/** + * `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) { @@ -135,10 +141,14 @@ export async function formioRawFetch( await clearToken(baseUrl); config.jwt = undefined; await ensureAuthenticated(config); - return readResponse(await send(url, buildFetchInit(config, options)), url, options); + return readResponse({ + response: await send(url, buildFetchInit(config, options)), + url, + options, + }); } - return readResponse(response, url, options); + return readResponse({ response, url, options }); } // Whether a built URL addresses the project: the same origin, and a path that IS diff --git a/packages/mcp-server/src/mcp-responses.ts b/packages/mcp-server/src/mcp-responses.ts index c714cc2f..e6951804 100644 --- a/packages/mcp-server/src/mcp-responses.ts +++ b/packages/mcp-server/src/mcp-responses.ts @@ -45,3 +45,17 @@ export function toMcpError(error: unknown, notes: readonly string[] = []) { 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/revisions/history.ts b/packages/mcp-server/src/revisions/history.ts index bed0f251..6009b177 100644 --- a/packages/mcp-server/src/revisions/history.ts +++ b/packages/mcp-server/src/revisions/history.ts @@ -66,7 +66,11 @@ export async function gateFormHistory({ if (enablesHistory || acceptNoHistory) { return; } - const stored = (await formioFetch(`form/${formId}`, {}, cfg)) as Record; + // 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; } diff --git a/packages/mcp-server/src/revisions/license.ts b/packages/mcp-server/src/revisions/license.ts index 3f788203..18810344 100644 --- a/packages/mcp-server/src/revisions/license.ts +++ b/packages/mcp-server/src/revisions/license.ts @@ -2,16 +2,56 @@ import { BASE_URL_UNRESOLVED_GUIDANCE, ResolvedFormioConfig } from '../config.js import { gateDisabledHistory, historyNotAccepted } from './history.js'; import { stripRevisions } from './helpers.js'; import { BaseUrlUnresolvedError, baseUrlWriteCommand } from '../project-resolver.js'; -import { ToolError } from '../tool-errors.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`. Only a definite answer is cached per `baseUrl` — license is +// deployment-wide — and a probe that produced none is asked again next time. +const revisionsLicensedByBaseUrl = new Map(); const SAC_PATTERN = /\bsac\s*=\s*(true|false)\b/i; +// 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. @@ -24,33 +64,33 @@ 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 })}`; } -// 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. +// 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 { +): 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; + const licence = await probeLicence(new URL('config.js', `${baseUrl.replace(/\/*$/, '/')}`)); + if (licence.state !== 'unknown') { + revisionsLicensedByBaseUrl.set(baseUrl, licence); } + return licence; +} - revisionsLicensedByBaseUrl.set(baseUrl, revisionsLicensed); - return revisionsLicensed; +// 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, + }); } // Why the licence cannot be read: the probe is a GET to the deployment, and no @@ -76,18 +116,22 @@ function licenceUndetermined( /** * Drafts, publishing and reverting exist only where the deployment is licensed for - * revisions. Refuses with LICENSE_REQUIRED where it is not, and with the Base URL - * remedy where the licence cannot be read. + * 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 { - const licensed = await checkRevisionsLicensed(cfg); - if (licensed === undefined) { + const licence = await checkRevisionsLicensed(cfg); + if (licence === undefined) { throw licenceUndetermined(cfg, actionLabel); } - if (!licensed) { + 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.`, @@ -102,35 +146,51 @@ export interface RevisionsLicenseGateOptions { acceptNoHistory?: boolean; } +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; +} + /** * 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. Returns the resolved licensed flag and - * the body to send. + * (`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<{ licensed: boolean; form: Record }> { - const licensed = await checkRevisionsLicensed(cfg); +}: 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 (licensed === undefined) { + if (licence === undefined) { if ('revisions' in form) { throw licenceUndetermined(cfg, actionLabel); } return { licensed: false, form }; } - if (licensed) { + // 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 }; + } + if (licence.state === 'licensed') { gateDisabledHistory({ form, acceptNoHistory }); - return { licensed, form }; + return { licensed: true, form }; } if (!acceptNoHistory) { throw historyNotAccepted({ @@ -139,5 +199,5 @@ export async function gateRevisionsLicense({ remedy: 'if they agree to save without history, retry with acceptNoHistory: true.', }); } - return { licensed, form: stripRevisions(form) }; + return { licensed: false, form: stripRevisions(form) }; } 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 index 80df68f8..60cc02b9 100644 --- a/packages/mcp-server/src/tool-errors.ts +++ b/packages/mcp-server/src/tool-errors.ts @@ -82,16 +82,26 @@ function stringField(record: Record, key: string): string | und 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 the submission validator as a `details` array. +// 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 = isRecord(entry) ? stringField(entry, 'message') : undefined; + const message = entryMessage(entry); return message ? [message] : []; }); } diff --git a/packages/mcp-server/src/tools/action_type_list.ts b/packages/mcp-server/src/tools/action_type_list.ts index a9e5a5bf..9556f299 100644 --- a/packages/mcp-server/src/tools/action_type_list.ts +++ b/packages/mcp-server/src/tools/action_type_list.ts @@ -28,7 +28,7 @@ export function registerActionTypeListTool(server: McpServer, config: FormioConf // 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[]) : []; - const page = pageOf({ items, total: items.length, skip: 0 }); + const page = pageOf({ items, total: items.length, skip: 0, limit: items.length }); return toMcpStructuredResult(listResult('actionTypes', page)); } catch (error) { return toMcpError(error); diff --git a/packages/mcp-server/src/tools/form-id.ts b/packages/mcp-server/src/tools/form-id.ts index 2583f3a2..d7b7df6d 100644 --- a/packages/mcp-server/src/tools/form-id.ts +++ b/packages/mcp-server/src/tools/form-id.ts @@ -1,6 +1,5 @@ import { z } from 'zod'; -import { MONGO_ID_PATTERN } from '../formio-client.js'; -import { ToolError } from '../tool-errors.js'; +import { requireObjectId } from './object-id.js'; /** * The one rule for `formId`: it is always a form's 24-character ObjectId. Reads that @@ -8,8 +7,7 @@ import { ToolError } from '../tool-errors.js'; * 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, so a refusal carries the - * `INVALID_ARGUMENT` code: a schema failure is reported by the SDK as bare text. + * 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)"); @@ -17,10 +15,10 @@ export function formIdArgument() { /** Refuses a `formId` that is not an ObjectId, before any request is made. */ export function requireFormId(formId: string): void { - if (!MONGO_ID_PATTERN.test(formId)) { - throw new ToolError({ - code: 'INVALID_ARGUMENT', - message: `formId ${JSON.stringify(formId)} is not accepted: 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.`, - }); - } + 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 bf95f2c6..353a0555 100644 --- a/packages/mcp-server/src/tools/form_create.ts +++ b/packages/mcp-server/src/tools/form_create.ts @@ -58,7 +58,8 @@ export function registerFormCreateTool(server: McpServer, config: FormioConfig) method: 'POST', body: { // The gate already stripped `revisions` on unlicensed deployments; on - // licensed ones default to 'original' unless the caller overrode. + // 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_revert.ts b/packages/mcp-server/src/tools/form_revert.ts index b88c65e9..0586aaad 100644 --- a/packages/mcp-server/src/tools/form_revert.ts +++ b/packages/mcp-server/src/tools/form_revert.ts @@ -8,6 +8,18 @@ import { cwdSchema, resolveProjectConfig } from '../project-resolver.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( @@ -33,6 +45,7 @@ export function registerFormRevertTool(server: McpServer, config: FormioConfig) async ({ cwd, formId, version, note }) => { try { requireFormId(formId); + refuseDraftVersion(version); const cfg = resolveProjectConfig(cwd, config); await requireRevisionsLicense(cfg, 'revert this form'); return toMcpStructuredResult( diff --git a/packages/mcp-server/src/tools/form_revision_list.ts b/packages/mcp-server/src/tools/form_revision_list.ts index 771102ee..d4793e8c 100644 --- a/packages/mcp-server/src/tools/form_revision_list.ts +++ b/packages/mcp-server/src/tools/form_revision_list.ts @@ -12,6 +12,10 @@ import { projectPathArgument } from './path-arguments.js'; // 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', @@ -37,6 +41,7 @@ export function registerFormRevisionListTool(server: McpServer, config: FormioCo query, config: cfg, defaults: DEFAULTS, + filters: PUBLISHED_ONLY, }); return toMcpStructuredResult(listResult('revisions', page)); } catch (error) { diff --git a/packages/mcp-server/src/tools/form_update.ts b/packages/mcp-server/src/tools/form_update.ts index 44a88bd8..f241ae49 100644 --- a/packages/mcp-server/src/tools/form_update.ts +++ b/packages/mcp-server/src/tools/form_update.ts @@ -80,13 +80,15 @@ export function registerFormUpdateTool(server: McpServer, config: FormioConfig) ); } - const { licensed, form } = await gateRevisionsLicense({ + const { licensed, licenceUnknown, form } = await gateRevisionsLicense({ cfg, actionLabel: 'update this form', form: rawForm, acceptNoHistory, }); - if (licensed) { + // 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, acceptNoHistory }); } diff --git a/packages/mcp-server/src/tools/list-contract.ts b/packages/mcp-server/src/tools/list-contract.ts index c1411348..52e5340d 100644 --- a/packages/mcp-server/src/tools/list-contract.ts +++ b/packages/mcp-server/src/tools/list-contract.ts @@ -57,15 +57,21 @@ export interface PageOfRequest { /** 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, the items seen so far are all - * that is known to exist. + * that is known to exist, 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 }: PageOfRequest): ListPage { - const known = total ?? skip + items.length; - return { items, total: known, hasMore: skip + items.length < known }; +export function pageOf({ items, total, skip, limit }: PageOfRequest): ListPage { + const seen = skip + items.length; + if (total === undefined) { + return { items, total: seen, hasMore: items.length === limit }; + } + return { items, total, hasMore: seen < total }; } /** A page as a tool result: the items under the tool's own key, beside `total` and `hasMore`. */ @@ -98,5 +104,5 @@ export async function fetchListPage({ }; 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 }); + 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_set.ts b/packages/mcp-server/src/tools/project_set.ts index 74898798..ebb23e6c 100644 --- a/packages/mcp-server/src/tools/project_set.ts +++ b/packages/mcp-server/src/tools/project_set.ts @@ -19,7 +19,8 @@ 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 { 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 { @@ -78,9 +79,13 @@ export function registerProjectSetTool(server: McpServer, options: ProjectSetOpt // 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: https://examples.form.io on the hosted cloud (never https://api.form.io), or https://myproject.mysite.com or https://forms.mysite.com/myproject on a customer deployment. Omit it only to add a baseUrl to THIS DIRECTORY'S OWN mapping. Where another record holds the project, a baseUrl alone is refused: add it to a committed formio.json by hand, or, for FORMIO_PROJECT_URL in the environment, pass both." @@ -91,7 +96,7 @@ export function registerProjectSetTool(server: McpServer, options: ProjectSetOpt "The user's working directory, absolute, that the mapping is keyed against. Omitted, the server's own spawn directory is used. Rules: project_set and the server instructions." ), baseUrl: z - .url({ protocol: /^https?$/ }) + .string() .optional() .describe( 'The deployment hosting the project; it builds the portal-login URL and keys the cached token. Usually omitted: it is derived from projectUrl. Pass it when the server reports it cannot be determined; it may carry a sub-path. Refused for a form.io host, which only https://api.form.io serves. Omitted, the value mapped for this directory is kept, unless the call re-points it to a different project.' @@ -101,7 +106,7 @@ export function registerProjectSetTool(server: McpServer, options: ProjectSetOpt // Writes only to the local project map — no Form.io request involved. annotations: local('Set the active project', false), }, - async ({ projectUrl, cwd, baseUrl: baseUrlArg }) => { + catchToolErrors(async ({ projectUrl, cwd, baseUrl: baseUrlArg }) => { const entryCwd = cwd ?? getServerCwd(); const mapped = readProjectEntryForWrite(entryCwd); // An entry that EXISTS and cannot be honoured is not an absent one. A record's @@ -144,8 +149,16 @@ export function registerProjectSetTool(server: McpServer, options: ProjectSetOpt // 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'), + }); }; const committed = findCommittedConfig(entryCwd, { onNote: (message) => walkNotes.push(message), @@ -321,7 +334,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 +414,6 @@ export function registerProjectSetTool(server: McpServer, options: ProjectSetOpt }, fullMessage ); - } + }) ); } diff --git a/packages/mcp-server/src/tools/role_update.ts b/packages/mcp-server/src/tools/role_update.ts index 5cd9e763..250a9b56 100644 --- a/packages/mcp-server/src/tools/role_update.ts +++ b/packages/mcp-server/src/tools/role_update.ts @@ -1,12 +1,13 @@ 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 { roleDocument } from '../output-schemas.js'; import { overwrites } from '../tool-annotations.js'; import { roleSchema } from './role-schema.js'; import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; +import { requireObjectId } from './object-id.js'; export function registerRoleUpdateTool(server: McpServer, config: FormioConfig) { server.registerTool( @@ -16,10 +17,7 @@ export function registerRoleUpdateTool(server: McpServer, config: FormioConfig) 'Update an existing role in the project `cwd` resolves to. This is a full replacement — include all fields you want to preserve.', 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'), + roleId: z.string().describe('The _id of the role to update, a 24-character ObjectId'), role: roleSchema.describe('Role document with updated fields'), }, outputSchema: roleDocument, @@ -27,6 +25,11 @@ export function registerRoleUpdateTool(server: McpServer, config: FormioConfig) }, async ({ cwd, roleId, role }) => { try { + requireObjectId({ + argument: 'roleId', + value: roleId, + remedy: "roleId takes a role's 24-character ObjectId _id. Read it with role_list.", + }); const cfg = resolveProjectConfig(cwd, config); const updated = (await formioFetch(`role/${roleId}`, {}, cfg, { method: 'PUT', From bdd5804d78e482897ff75e0b68720641c3edc9bf Mon Sep 17 00:00:00 2001 From: Travis Tidwell Date: Fri, 9 Oct 2026 14:52:55 -0500 Subject: [PATCH 10/13] fix(mcp): resolve review of #91 - form_revert refuses any version that resolves to the draft revision ("latest", the draft's _id), checked on the fetched revision. - A draft save refuses a changed non-draft field (title, path, access, ...) instead of dropping it; values form_get returns unchanged pass. - Login-form failures keep their code (NETWORK_ERROR, NOT_FOUND, ...); only no-browser and timeout are AUTH_REQUIRED. - An undetermined licence is remembered for 60 seconds. - project_set refuses empty URLs with INVALID_ARGUMENT. - Docs, descriptions and formio-api references state Form.io's PUT semantics: top-level fields left out keep their stored value; arrays and objects sent replace the stored ones whole. - total is omitted when Form.io reports none; form_list refuses a tag containing a comma; action_type_list returns its catalog directly. Co-Authored-By: Claude Opus 5.5 --- README.md | 10 +- .../changes/freeze-1-0-tool-surface/design.md | 6 +- .../specs/form-revert/spec.md | 8 +- .../specs/form-update/spec.md | 8 +- .../specs/tool-list-contract/spec.md | 7 +- packages/mcp-server/README.md | 10 +- .../src/__tests__/auth-failure-codes.test.ts | 44 ++++- .../src/__tests__/form_list.test.ts | 20 +++ .../src/__tests__/form_revert.test.ts | 26 +++ .../src/__tests__/form_update.test.ts | 170 +++++++++++++++--- .../src/__tests__/list-contract.test.ts | 46 ++++- .../__tests__/project_set-error-codes.test.ts | 9 + .../src/__tests__/revisions.test.ts | 29 ++- .../src/__tests__/role_update.test.ts | 4 +- .../src/__tests__/update-semantics.test.ts | 22 +++ packages/mcp-server/src/auth.ts | 35 +++- packages/mcp-server/src/output-schemas.ts | 11 +- packages/mcp-server/src/revisions/flows.ts | 85 ++++++++- packages/mcp-server/src/revisions/license.ts | 29 ++- .../mcp-server/src/tools/action_type_list.ts | 4 +- .../mcp-server/src/tools/action_update.ts | 2 +- packages/mcp-server/src/tools/form_list.ts | 14 ++ packages/mcp-server/src/tools/form_update.ts | 11 +- .../mcp-server/src/tools/list-contract.ts | 28 +-- packages/mcp-server/src/tools/project_set.ts | 10 ++ packages/mcp-server/src/tools/role-schema.ts | 2 +- packages/mcp-server/src/tools/role_update.ts | 2 +- .../formio-api/references/project-actions.md | 2 +- .../formio-api/references/project-forms.md | 6 +- .../formio-api/references/project-roles.md | 2 +- 30 files changed, 549 insertions(+), 113 deletions(-) create mode 100644 packages/mcp-server/src/__tests__/update-semantics.test.ts diff --git a/README.md b/README.md index c2d1cff0..88ff1b88 100644 --- a/README.md +++ b/README.md @@ -267,7 +267,7 @@ The bundled `@formio/mcp` server exposes these 23 tools. Skills prefer these ove | `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` | Replace a form with its complete updated JSON. Call `form_get` first, edit with `formio-schema`, then update. `draft: true` saves a draft instead of the live form. | +| `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. | @@ -279,7 +279,7 @@ The bundled `@formio/mcp` server exposes these 23 tools. Skills prefer these ove | --- | --- | | `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` | Full-replacement update of a role (`roleId`, `role`). Include all fields you want preserved. | +| `role_update` | Update a role (`roleId`, `role`). Fields left out keep their stored value. | ### Actions @@ -290,7 +290,7 @@ The bundled `@formio/mcp` server exposes these 23 tools. Skills prefer these ove | `action_create` | Attach a new action to 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` | Full-replacement update of an action. | +| `action_update` | Update an action. Fields left out keep their stored value. | | `action_delete` | Detach an action from a form. | ### Project @@ -312,8 +312,8 @@ The bundled `@formio/mcp` server exposes these 23 tools. Skills prefer these ove - **`cwd`.** Every tool takes `cwd`, the user's working directory as an absolute path, and resolves the project from it on each call. - **`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. `action_type_list` returns its `actionTypes` the same way, always with `hasMore: false`. -- **Updates replace.** `form_update`, `role_update` and `action_update` send the full document (PUT); a field left out is not kept. +- **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/openspec/changes/freeze-1-0-tool-surface/design.md b/openspec/changes/freeze-1-0-tool-surface/design.md index 1b67afc7..f73e5edc 100644 --- a/openspec/changes/freeze-1-0-tool-surface/design.md +++ b/openspec/changes/freeze-1-0-tool-surface/design.md @@ -25,7 +25,7 @@ This change follows `confine-requests-to-project-origin` (PR #88), whose path-ar - 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 full replacement (PUT) in 1.0, documented as such. +- 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 @@ -43,10 +43,10 @@ The flows already exist as `publishDraft` / `revertToVersion`; the tools are thi 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 not cached, so the next call 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. +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 (no browser, login timeout, a login port that cannot be bound, a login form that cannot be resolved) are `AUTH_REQUIRED` with their messages unchanged. `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`. +`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`.** 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 index 49adff20..e0f05b5d 100644 --- 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 @@ -2,7 +2,7 @@ ### 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`. +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 @@ -14,3 +14,9 @@ The server SHALL register a `form_revert` tool taking `cwd`, `formId` (a 24-char - **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-update/spec.md b/openspec/changes/freeze-1-0-tool-surface/specs/form-update/spec.md index 03501528..b25c6d03 100644 --- 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 @@ -2,7 +2,7 @@ ### 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`) and SHALL ignore every other field — including the server-owned fields `form_get` returns — rather than refusing them. It SHALL NOT accept `publish`, `revert` or `version`; publishing and reverting are `form_publish` and `form_revert`. +`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 @@ -10,6 +10,12 @@ - **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 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 index 72aa42e9..46e928db 100644 --- 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 @@ -2,7 +2,7 @@ ### 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`. 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. +`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 @@ -21,6 +21,11 @@ - **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. diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index f9e73205..dd9fce37 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -184,7 +184,7 @@ The bundled `@formio/mcp` server exposes these 23 tools. Skills prefer these ove | `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` | Replace a form with its complete updated JSON. Call `form_get` first, edit with `formio-schema`, then update. `draft: true` saves a draft instead of the live form. | +| `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. | @@ -196,7 +196,7 @@ The bundled `@formio/mcp` server exposes these 23 tools. Skills prefer these ove | --- | --- | | `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` | Full-replacement update of a role (`roleId`, `role`). Include all fields you want preserved. | +| `role_update` | Update a role (`roleId`, `role`). Fields left out keep their stored value. | ### Actions @@ -207,7 +207,7 @@ The bundled `@formio/mcp` server exposes these 23 tools. Skills prefer these ove | `action_create` | Attach a new action to 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` | Full-replacement update of an action. | +| `action_update` | Update an action. Fields left out keep their stored value. | | `action_delete` | Detach an action from a form. | ### Project @@ -241,8 +241,8 @@ The tool's description carries only the rules a caller acts on. The reasons behi - **`cwd`.** Every tool takes `cwd`, the user's working directory as an absolute path, and resolves the project from it on each call. - **`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. `action_type_list` returns its `actionTypes` the same way, always with `hasMore: false`. -- **Updates replace.** `form_update`, `role_update` and `action_update` send the full document (PUT); a field left out is not kept. +- **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/packages/mcp-server/src/__tests__/auth-failure-codes.test.ts b/packages/mcp-server/src/__tests__/auth-failure-codes.test.ts index 76b3a441..5a0b814d 100644 --- a/packages/mcp-server/src/__tests__/auth-failure-codes.test.ts +++ b/packages/mcp-server/src/__tests__/auth-failure-codes.test.ts @@ -1,12 +1,13 @@ -// Every way the browser login can fail is the same answer to a caller: no credential -// was obtained, so the call needs authentication it does not have. Each failure keeps -// its own prose — that is what tells the user what to do — and carries AUTH_REQUIRED, -// so a client branching on the code sees an authentication failure rather than -// INTERNAL. +// 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 } from '../auth.js'; +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() })); @@ -17,7 +18,7 @@ const CONFIG: ResolvedFormioConfig = { forceBrowser: true, }; -describe('a failed browser login carries AUTH_REQUIRED', () => { +describe('a failed browser login carries the code of its cause', () => { const originalEnv = process.env; beforeEach(() => { @@ -60,8 +61,9 @@ describe('a failed browser login carries AUTH_REQUIRED', () => { authTimeoutMs: 60_000, }); - await expect(attempt).rejects.toMatchObject({ code: 'AUTH_REQUIRED' }); + 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())); } @@ -77,7 +79,31 @@ describe('a failed browser login carries AUTH_REQUIRED', () => { } ); - await expect(attempt).rejects.toMatchObject({ code: 'AUTH_REQUIRED' }); + 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__/form_list.test.ts b/packages/mcp-server/src/__tests__/form_list.test.ts index 46a8e72c..46cb857d 100644 --- a/packages/mcp-server/src/__tests__/form_list.test.ts +++ b/packages/mcp-server/src/__tests__/form_list.test.ts @@ -120,6 +120,26 @@ describe('form_list tool', () => { expect(params.tags).toBeUndefined(); }); + // 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); + + const result = (await client.callTool({ + name: 'form_list', + arguments: { cwd: TEST_CWD, tags: ['crm', 'sales,emea'] }, + })) as { + isError?: boolean; + content: Array<{ text?: string }>; + _meta?: Record; + }; + + 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) => { diff --git a/packages/mcp-server/src/__tests__/form_revert.test.ts b/packages/mcp-server/src/__tests__/form_revert.test.ts index a6af6764..21ffb8a9 100644 --- a/packages/mcp-server/src/__tests__/form_revert.test.ts +++ b/packages/mcp-server/src/__tests__/form_revert.test.ts @@ -165,4 +165,30 @@ describe('form_revert tool', () => { 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_update.test.ts b/packages/mcp-server/src/__tests__/form_update.test.ts index 0326ca51..657a2a2f 100644 --- a/packages/mcp-server/src/__tests__/form_update.test.ts +++ b/packages/mcp-server/src/__tests__/form_update.test.ts @@ -167,47 +167,130 @@ describe('form_update tool', () => { // 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'; - const existingDraft = { _vid: 'draft', components: [{ type: 'old' }] }; - mockFormioFetch.mockResolvedValue(existingDraft); + mockFormioFetch.mockResolvedValue(STORED_DRAFT); const { client } = await createTestClient(registerFormUpdateTool); - const fromFormGet = { - _id: formId, - title: 'Contact', - name: 'contact', - path: 'contact', - created: '2026-01-01T00:00:00.000Z', - modified: '2026-01-02T00:00:00.000Z', - owner: 'abcdef012345678901234567', - access: [{ type: 'read_all', roles: [] }], - submissionAccess: [], - revisions: 'original', - components: [{ type: 'phoneNumber', key: 'phone' }], - settings: { theme: 'dark' }, - tags: ['common'], - display: 'form', - }; const result = await client.callTool({ name: 'form_update', - arguments: { cwd: TEST_CWD, formId, form: fromFormGet, draft: true, note: 'Add phone' }, + 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: { - _vid: 'draft', - components: fromFormGet.components, - settings: fromFormGet.settings, - tags: fromFormGet.tags, - display: fromFormGet.display, + ...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({ @@ -246,6 +329,45 @@ describe('form_update tool', () => { expect(mockFormioFetch).not.toHaveBeenCalled(); }); + // 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'; + mockFormioFetch.mockResolvedValue({ _id: formId, name: 'demo', revisions: '' }); + 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 }; + + 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/); + }); + // 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. diff --git a/packages/mcp-server/src/__tests__/list-contract.test.ts b/packages/mcp-server/src/__tests__/list-contract.test.ts index 9f89edf3..b03562d1 100644 --- a/packages/mcp-server/src/__tests__/list-contract.test.ts +++ b/packages/mcp-server/src/__tests__/list-contract.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'; import { registerAllTools } from '../tools/index.js'; -import { pageOf } from '../tools/list-contract.js'; +import { listResult, pageOf } from '../tools/list-contract.js'; import { connectTools } from './test-helpers.js'; const PAGED = { @@ -44,11 +44,26 @@ describe('the list contract', () => { async (name, key) => { const tool = (await listedTools()).get(name); const output = tool?.outputSchema as JsonSchemaObject; - expect(output.required).toEqual(expect.arrayContaining([key, 'total', 'hasMore'])); + 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({ @@ -69,12 +84,10 @@ describe('the list contract', () => { }); }); - it('counts what it was given when Form.io reports no total', () => { - expect(pageOf({ items: [1, 2], total: undefined, skip: 10, limit: 5 })).toEqual({ - items: [1, 2], - total: 12, - 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 @@ -82,9 +95,24 @@ describe('the list contract', () => { 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], - total: 12, 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__/project_set-error-codes.test.ts b/packages/mcp-server/src/__tests__/project_set-error-codes.test.ts index b777fdbc..921e9ed5 100644 --- a/packages/mcp-server/src/__tests__/project_set-error-codes.test.ts +++ b/packages/mcp-server/src/__tests__/project_set-error-codes.test.ts @@ -37,6 +37,15 @@ describe('project_set refusals carry a code', () => { { 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); diff --git a/packages/mcp-server/src/__tests__/revisions.test.ts b/packages/mcp-server/src/__tests__/revisions.test.ts index 49ff7364..1b9b8705 100644 --- a/packages/mcp-server/src/__tests__/revisions.test.ts +++ b/packages/mcp-server/src/__tests__/revisions.test.ts @@ -109,9 +109,10 @@ describe('checkRevisionsLicensed', () => { expect(fetchSpy).toHaveBeenCalledTimes(1); }); - // A failed probe is not an answer: caching it would pin "unknown" on a deployment - // that was only briefly unreachable. - it('does not cache an unknown answer — a transient failure then success is licensed', 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(); const fetchSpy = vi .fn() @@ -119,10 +120,26 @@ describe('checkRevisionsLicensed', () => { .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 }); - expect(await checkRevisionsLicensed(cfgFor(baseUrl))).toMatchObject({ state: 'unknown' }); - expect(await checkRevisionsLicensed(cfgFor(baseUrl))).toMatchObject({ state: 'unknown' }); - expect(await checkRevisionsLicensed(cfgFor(baseUrl))).toEqual({ state: 'licensed' }); + 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); + + clock.ms += 1; + expect(await check()).toMatchObject({ state: 'unknown', failure: { code: 'UPSTREAM_ERROR' } }); + expect(fetchSpy).toHaveBeenCalledTimes(2); + + clock.ms += 60_000; + expect(await check()).toEqual({ state: 'licensed' }); + expect(fetchSpy).toHaveBeenCalledTimes(3); + + // A definite answer does not expire. + clock.ms += 10 * 60_000; + expect(await check()).toEqual({ state: 'licensed' }); expect(fetchSpy).toHaveBeenCalledTimes(3); }); }); diff --git a/packages/mcp-server/src/__tests__/role_update.test.ts b/packages/mcp-server/src/__tests__/role_update.test.ts index 65ead9e5..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 () => { 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/auth.ts b/packages/mcp-server/src/auth.ts index 3142826e..6d12de95 100644 --- a/packages/mcp-server/src/auth.ts +++ b/packages/mcp-server/src/auth.ts @@ -11,10 +11,24 @@ export interface AuthenticateOptions { const DEFAULT_AUTH_HOST = '127.0.0.1'; -// However the browser login fails, the call is left without a credential, so every -// such failure is AUTH_REQUIRED; the message is what says which failure and its remedy. -function loginFailed(message: string, cause?: unknown): ToolError { - return new ToolError({ code: 'AUTH_REQUIRED', message, cause }); +// 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 @@ -248,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(loginFailed(message, err)); + rejectJwt(loginFormFailed(err)); } }); @@ -275,12 +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( - loginFailed( - `Could not start the Form.io login server on ${host}:${config.authPort ?? 0}: ${err.message}. ` + + 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.`, - err - ) + cause: err, + }) ); return; } diff --git a/packages/mcp-server/src/output-schemas.ts b/packages/mcp-server/src/output-schemas.ts index a1a454cf..3b77f8cf 100644 --- a/packages/mcp-server/src/output-schemas.ts +++ b/packages/mcp-server/src/output-schemas.ts @@ -116,12 +116,17 @@ export const templateDocument = z.looseObject({ }); // List payloads are wrapped in a named field because structuredContent must be an -// object. `total` is the collection size Form.io reports, not the page length, so -// `hasMore` can say whether another page exists. +// 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().describe('Items Form.io holds for this query, across all pages'), + 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`'), }); } diff --git a/packages/mcp-server/src/revisions/flows.ts b/packages/mcp-server/src/revisions/flows.ts index 67dca52a..54845b1c 100644 --- a/packages/mcp-server/src/revisions/flows.ts +++ b/packages/mcp-server/src/revisions/flows.ts @@ -1,3 +1,4 @@ +import { isDeepStrictEqual } from 'node:util'; import { ResolvedFormioConfig } from '../config.js'; import { formioFetch } from '../formio-client.js'; import { ToolError } from '../tool-errors.js'; @@ -45,8 +46,8 @@ function pickFields( } // A draft holds only DRAFT_FIELDS. The body is usually what form_get returned, so -// the server-owned fields it carries (_id, title, path, created, …) are dropped -// rather than refused. A body with none of the draft fields would stage nothing +// 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); @@ -61,10 +62,77 @@ function draftFields(form: Record): Record { 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) { 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, ...fields, _vnote: prefixVnote(_vnote) }, @@ -88,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/license.ts b/packages/mcp-server/src/revisions/license.ts index 18810344..0e587fe4 100644 --- a/packages/mcp-server/src/revisions/license.ts +++ b/packages/mcp-server/src/revisions/license.ts @@ -8,9 +8,15 @@ import { COMMITTED_CONFIG_FILE } from '../committed-config.js'; // ─── License detection ────────────────────────────────────────────────────── // Resolves the deployment's Security Module flag (`sac`) from the anonymous -// `/config.js`. Only a definite answer is cached per `baseUrl` — license is -// deployment-wide — and a probe that produced none is asked again next time. -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; @@ -64,21 +70,28 @@ 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 + 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; + if (cached !== undefined && now() < cached.expiresAt) return cached.licence; const licence = await probeLicence(new URL('config.js', `${baseUrl.replace(/\/*$/, '/')}`)); - if (licence.state !== 'unknown') { - revisionsLicensedByBaseUrl.set(baseUrl, licence); - } + revisionsLicensedByBaseUrl.set(baseUrl, { + licence, + expiresAt: licence.state === 'unknown' ? now() + UNKNOWN_LICENCE_TTL_MS : Infinity, + }); return licence; } diff --git a/packages/mcp-server/src/tools/action_type_list.ts b/packages/mcp-server/src/tools/action_type_list.ts index 9556f299..50d302bc 100644 --- a/packages/mcp-server/src/tools/action_type_list.ts +++ b/packages/mcp-server/src/tools/action_type_list.ts @@ -6,7 +6,6 @@ import { actionTypesListOutput } from '../output-schemas.js'; import { reads } from '../tool-annotations.js'; import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; import { formIdArgument, requireFormId } from './form-id.js'; -import { listResult, pageOf } from './list-contract.js'; export function registerActionTypeListTool(server: McpServer, config: FormioConfig) { server.registerTool( @@ -28,8 +27,7 @@ export function registerActionTypeListTool(server: McpServer, config: FormioConf // 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[]) : []; - const page = pageOf({ items, total: items.length, skip: 0, limit: items.length }); - return toMcpStructuredResult(listResult('actionTypes', page)); + return toMcpStructuredResult({ actionTypes: items, total: items.length, hasMore: false }); } 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 6a031548..f6cac063 100644 --- a/packages/mcp-server/src/tools/action_update.ts +++ b/packages/mcp-server/src/tools/action_update.ts @@ -14,7 +14,7 @@ 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: formIdArgument(), diff --git a/packages/mcp-server/src/tools/form_list.ts b/packages/mcp-server/src/tools/form_list.ts index 40bf6563..530dff43 100644 --- a/packages/mcp-server/src/tools/form_list.ts +++ b/packages/mcp-server/src/tools/form_list.ts @@ -6,10 +6,23 @@ import { formsListOutput } from '../output-schemas.js'; import { reads } from '../tool-annotations.js'; import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; import { fetchListPage, listArguments, listResult } from './list-contract.js'; +import { ToolError } from '../tool-errors.js'; // 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', @@ -30,6 +43,7 @@ export function registerFormListTool(server: McpServer, config: FormioConfig) { }, async ({ cwd, type, tags, ...query }) => { try { + requireCommaFreeTags(tags); const cfg = resolveProjectConfig(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 }; diff --git a/packages/mcp-server/src/tools/form_update.ts b/packages/mcp-server/src/tools/form_update.ts index f241ae49..58529f73 100644 --- a/packages/mcp-server/src/tools/form_update.ts +++ b/packages/mcp-server/src/tools/form_update.ts @@ -20,7 +20,7 @@ export function registerFormUpdateTool(server: McpServer, config: FormioConfig) 'form_update', { description: [ - 'Replace a form in the project `cwd` resolves to with the complete updated JSON: read it with form_get, apply the change using the formio-schema skill, and send the whole form.', + '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: { @@ -43,7 +43,7 @@ export function registerFormUpdateTool(server: McpServer, config: FormioConfig) ), }) .catchall(z.unknown()) - .describe('The complete updated form JSON'), + .describe('The updated form JSON'), note: z .string() .describe( @@ -53,7 +53,7 @@ export function registerFormUpdateTool(server: McpServer, config: FormioConfig) .boolean() .optional() .describe( - "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." + "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." ), acceptNoHistory: z .boolean() @@ -80,6 +80,9 @@ export function registerFormUpdateTool(server: McpServer, config: FormioConfig) ); } + // 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', @@ -89,7 +92,7 @@ export function registerFormUpdateTool(server: McpServer, config: FormioConfig) // 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, acceptNoHistory }); + await gateFormHistory({ cfg, formId, form: rawForm, acceptNoHistory }); } return toMcpStructuredResult( diff --git a/packages/mcp-server/src/tools/list-contract.ts b/packages/mcp-server/src/tools/list-contract.ts index 52e5340d..68cd5f2d 100644 --- a/packages/mcp-server/src/tools/list-contract.ts +++ b/packages/mcp-server/src/tools/list-contract.ts @@ -8,7 +8,8 @@ import { formioFetch } from '../formio-client.js'; * 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. + * 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; @@ -48,7 +49,8 @@ export interface ListQuery { export interface ListPage { items: T[]; - total: number; + /** The total Form.io reported; absent when it reported none. */ + total?: number; hasMore: boolean; } @@ -62,21 +64,27 @@ export interface PageOfRequest { } /** - * A page and where it sits. Without a reported total, the items seen so far are all - * that is known to exist, and a full page may have more behind it — so `hasMore` - * then says whether the page came back full. + * 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 { - const seen = skip + items.length; if (total === undefined) { - return { items, total: seen, hasMore: items.length === limit }; + return { items, hasMore: items.length === limit }; } - return { items, total, hasMore: seen < total }; + return { items, total, hasMore: skip + items.length < total }; } -/** A page as a tool result: the items under the tool's own key, beside `total` and `hasMore`. */ +/** + * 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, total: page.total, hasMore: page.hasMore }; + return { + [key]: page.items, + ...(page.total === undefined ? {} : { total: page.total }), + hasMore: page.hasMore, + }; } export interface FetchListPageRequest { diff --git a/packages/mcp-server/src/tools/project_set.ts b/packages/mcp-server/src/tools/project_set.ts index ebb23e6c..5836c14e 100644 --- a/packages/mcp-server/src/tools/project_set.ts +++ b/packages/mcp-server/src/tools/project_set.ts @@ -160,6 +160,16 @@ export function registerProjectSetTool(server: McpServer, options: ProjectSetOpt 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), }); diff --git a/packages/mcp-server/src/tools/role-schema.ts b/packages/mcp-server/src/tools/role-schema.ts index af20e3f3..4574c9de 100644 --- a/packages/mcp-server/src/tools/role-schema.ts +++ b/packages/mcp-server/src/tools/role-schema.ts @@ -10,7 +10,7 @@ const roleFields = { admin: z.boolean().optional().describe('When true, holders bypass access checks'), }; -/** A role document, as role_update replaces it. */ +/** 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. */ diff --git a/packages/mcp-server/src/tools/role_update.ts b/packages/mcp-server/src/tools/role_update.ts index 250a9b56..368ada11 100644 --- a/packages/mcp-server/src/tools/role_update.ts +++ b/packages/mcp-server/src/tools/role_update.ts @@ -14,7 +14,7 @@ export function registerRoleUpdateTool(server: McpServer, config: FormioConfig) 'role_update', { description: - 'Update an existing role in the project `cwd` resolves to. 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().describe('The _id of the role to update, a 24-character ObjectId'), diff --git a/plugin/skills/formio-api/references/project-actions.md b/plugin/skills/formio-api/references/project-actions.md index e13cb07e..bb1ed034 100644 --- a/plugin/skills/formio-api/references/project-actions.md +++ b/plugin/skills/formio-api/references/project-actions.md @@ -182,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-forms.md b/plugin/skills/formio-api/references/project-forms.md index baf9ea41..2a430c85 100644 --- a/plugin/skills/formio-api/references/project-forms.md +++ b/plugin/skills/formio-api/references/project-forms.md @@ -113,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 b87c0029..d387ec76 100644 --- a/plugin/skills/formio-api/references/project-roles.md +++ b/plugin/skills/formio-api/references/project-roles.md @@ -96,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 | | -------------- | ------ | ---------------------------------------- | From 0fb8c57c776a1caf54f2d71df0e4ffb7677dcf42 Mon Sep 17 00:00:00 2001 From: Travis Tidwell Date: Fri, 9 Oct 2026 15:20:38 -0500 Subject: [PATCH 11/13] feat(mcp): resolve the directory from the client's workspace roots When a call passes no cwd, the server picks the directory to resolve the project for: the client's roots (MCP roots/list; one root as is, several narrowed to the one holding a project record, otherwise INVALID_ARGUMENT listing them), then CLAUDE_PROJECT_DIR, then its own working directory. Roots are cached, re-read after roots/list_changed, and time out after 2s. project_get and server_status report cwdSource; only a fallback to the server's directory tells the agent to pass cwd. cwd stays optional, and the skills' preflight calls project_get without it. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- .../changes/freeze-1-0-tool-surface/design.md | 4 + .../freeze-1-0-tool-surface/proposal.md | 2 + .../project-directory-resolution/spec.md | 66 +++ .../changes/freeze-1-0-tool-surface/tasks.md | 19 + packages/mcp-server/README.md | 6 +- .../src/__tests__/project-resolver.test.ts | 19 +- packages/mcp-server/src/__tests__/setup.ts | 3 + .../__tests__/tool-description-budget.test.ts | 25 +- .../src/__tests__/workspace-directory.test.ts | 460 ++++++++++++++++++ packages/mcp-server/src/output-schemas.ts | 4 + packages/mcp-server/src/project-report.ts | 11 +- packages/mcp-server/src/project-resolver.ts | 26 +- packages/mcp-server/src/server.ts | 6 +- .../mcp-server/src/tools/action_create.ts | 5 +- .../mcp-server/src/tools/action_delete.ts | 5 +- packages/mcp-server/src/tools/action_get.ts | 5 +- packages/mcp-server/src/tools/action_list.ts | 5 +- .../mcp-server/src/tools/action_type_get.ts | 5 +- .../mcp-server/src/tools/action_type_list.ts | 5 +- .../mcp-server/src/tools/action_update.ts | 5 +- packages/mcp-server/src/tools/form_create.ts | 5 +- packages/mcp-server/src/tools/form_get.ts | 5 +- packages/mcp-server/src/tools/form_list.ts | 5 +- packages/mcp-server/src/tools/form_publish.ts | 5 +- packages/mcp-server/src/tools/form_revert.ts | 5 +- .../mcp-server/src/tools/form_revision_get.ts | 5 +- .../src/tools/form_revision_list.ts | 5 +- packages/mcp-server/src/tools/form_update.ts | 5 +- .../src/tools/project-resolution.ts | 67 ++- .../mcp-server/src/tools/project_export.ts | 5 +- packages/mcp-server/src/tools/project_get.ts | 9 +- .../mcp-server/src/tools/project_import.ts | 5 +- packages/mcp-server/src/tools/project_set.ts | 25 +- packages/mcp-server/src/tools/role_create.ts | 5 +- packages/mcp-server/src/tools/role_list.ts | 5 +- packages/mcp-server/src/tools/role_update.ts | 5 +- .../mcp-server/src/tools/server_status.ts | 10 +- .../mcp-server/src/workspace-directory.ts | 208 ++++++++ .../cwd-from-client-roots.test.ts | 82 ++++ plugin/skills/formio-actions/SKILL.md | 2 +- plugin/skills/formio-angular/SKILL.md | 2 +- .../formio-angular-form/SKILL.md | 2 +- .../formio-angular-resources/SKILL.md | 2 +- plugin/skills/formio-api/SKILL.md | 4 +- plugin/skills/formio-application/IMPORT.md | 4 +- plugin/skills/formio-application/SKILL.md | 2 +- plugin/skills/formio-auth/SKILL.md | 2 +- .../formio-auth/references/token-swap.md | 2 +- plugin/skills/formio-form-builder/SKILL.md | 2 +- plugin/skills/formio-form/SKILL.md | 2 +- plugin/skills/formio-form/references/setup.md | 2 +- .../references/project-urls.md | 4 +- plugin/skills/formio-react/SKILL.md | 2 +- .../formio-react/formio-react-form/SKILL.md | 2 +- .../formio-react-resources/SKILL.md | 2 +- .../references/phase-b-emission.md | 2 +- plugin/skills/formio-schema/SKILL.md | 2 +- plugin/skills/formio-sdk/SKILL.md | 4 +- plugin/skills/formio-sdk/references/auth.md | 2 +- plugin/skills/formio-sdk/references/files.md | 2 +- plugin/skills/formio-sdk/references/forms.md | 2 +- .../skills/formio-sdk/references/plugins.md | 2 +- .../skills/formio-sdk/references/projects.md | 2 +- .../skills/formio-sdk/references/rendering.md | 2 +- plugin/skills/formio-sdk/references/roles.md | 2 +- plugin/skills/formio-sdk/references/setup.md | 2 +- .../formio-sdk/references/submissions.md | 2 +- 68 files changed, 1076 insertions(+), 142 deletions(-) create mode 100644 openspec/changes/freeze-1-0-tool-surface/specs/project-directory-resolution/spec.md create mode 100644 packages/mcp-server/src/__tests__/workspace-directory.test.ts create mode 100644 packages/mcp-server/src/workspace-directory.ts create mode 100644 packages/skill-tests/src/skill-descriptions/cwd-from-client-roots.test.ts diff --git a/README.md b/README.md index 88ff1b88..884eaa42 100644 --- a/README.md +++ b/README.md @@ -310,7 +310,7 @@ The bundled `@formio/mcp` server exposes these 23 tools. Skills prefer these ove ### Conventions every tool follows -- **`cwd`.** Every tool takes `cwd`, the user's working directory as an absolute path, and resolves the project from it on each call. +- **`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 one root holding a project record (a committed `formio.json` or a directory mapping), otherwise the call fails with `INVALID_ARGUMENT` listing them; 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`, and a `roots/list` that does not answer within 2 seconds counts as no roots. `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 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. diff --git a/openspec/changes/freeze-1-0-tool-surface/design.md b/openspec/changes/freeze-1-0-tool-surface/design.md index f73e5edc..88b63bfa 100644 --- a/openspec/changes/freeze-1-0-tool-surface/design.md +++ b/openspec/changes/freeze-1-0-tool-surface/design.md @@ -76,6 +76,10 @@ The shared preflight paragraph (15 skills, identical text enforced by `shared-pr **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 single one that resolves a project record, else `INVALID_ARGUMENT` listing them); `CLAUDE_PROJECT_DIR` (documented by Claude Code for spawned servers); the process's working directory. Roots are read once per session through the SDK's `listRoots`, 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`. The `cwd` argument stays, optional, for monorepos with a `formio.json` per app and for clients without roots. Roots are read 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 is cached like an answer until the next `list_changed`, so a client that never answers costs the timeout once rather than on every call. 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 `cwdWasNamed: source !== "server"`, 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. diff --git a/openspec/changes/freeze-1-0-tool-surface/proposal.md b/openspec/changes/freeze-1-0-tool-surface/proposal.md index f801c482..ae884193 100644 --- a/openspec/changes/freeze-1-0-tool-surface/proposal.md +++ b/openspec/changes/freeze-1-0-tool-surface/proposal.md @@ -22,6 +22,7 @@ Fixing these after 1.0 would each be a breaking change. Fixing them now is one. - **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. @@ -40,6 +41,7 @@ Fixing these after 1.0 would each be a breaking change. Fixing them now is one. - `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 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..125fa728 --- /dev/null +++ b/openspec/changes/freeze-1-0-tool-surface/specs/project-directory-resolution/spec.md @@ -0,0 +1,66 @@ +## 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 one root whose directory resolves a project record (a committed `formio.json` found by the upward walk, or a directory mapping) is used; +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 paths; 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 be treated as no roots, and resolution SHALL continue with the next source. + +When the client reports several roots, the caller passes no `cwd`, and either none or more than one of the roots resolves a project record, the tool SHALL refuse with code `INVALID_ARGUMENT` and a message listing the roots and asking for `cwd`. + +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, ambiguous + +- **WHEN** the client reports two roots and both, or neither, resolve a project record, and a tool is called with no `cwd` +- **THEN** the tool returns code `INVALID_ARGUMENT` listing both roots and asking for `cwd` + +#### 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 + +#### 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`, the report SHALL say the directory is the server process's own working directory 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/tasks.md b/openspec/changes/freeze-1-0-tool-surface/tasks.md index f4e35c22..1363263f 100644 --- a/openspec/changes/freeze-1-0-tool-surface/tasks.md +++ b/openspec/changes/freeze-1-0-tool-surface/tasks.md @@ -185,3 +185,22 @@ ### 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 the SDK, cache, 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/README.md b/packages/mcp-server/README.md index dd9fce37..e71e8b6b 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -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: @@ -224,7 +224,7 @@ The bundled `@formio/mcp` server exposes these 23 tools. Skills prefer these ove 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. -- **`cwd` keys the mapping.** Without it the mapping is keyed by the server process's own working directory, which is fixed when the client spawns the server and is often not where the user is working. +- **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. @@ -239,7 +239,7 @@ The tool's description carries only the rules a caller acts on. The reasons behi ### Conventions every tool follows -- **`cwd`.** Every tool takes `cwd`, the user's working directory as an absolute path, and resolves the project from it on each call. +- **`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 one root holding a project record (a committed `formio.json` or a directory mapping), otherwise the call fails with `INVALID_ARGUMENT` listing them; 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`, and a `roots/list` that does not answer within 2 seconds counts as no roots. `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 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. diff --git a/packages/mcp-server/src/__tests__/project-resolver.test.ts b/packages/mcp-server/src/__tests__/project-resolver.test.ts index 2013a0b6..18bc0a30 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 what an omitted cwd + // resolves to and when to pass one. Which RECORD wins for that directory — the + // committed file, the mapping, the environment — is the server instructions' + // to state, once; a shortened copy here is the one that drifts. + it('says what an omitted cwd defaults to and when to pass one', () => { + expect(cwdSchema.description).toMatch(/defaults to the client's workspace root/); + expect(cwdSchema.description).toMatch(/cwdSource "server"/); + 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__/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__/tool-description-budget.test.ts b/packages/mcp-server/src/__tests__/tool-description-budget.test.ts index 94d52db4..f32f68ea 100644 --- a/packages/mcp-server/src/__tests__/tool-description-budget.test.ts +++ b/packages/mcp-server/src/__tests__/tool-description-budget.test.ts @@ -4,8 +4,8 @@ * 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 what to pass and where the full rules are - * (the server instructions and project_set), which every client receives once. + * project-scoped tool, so it says only what it defaults to and when to pass it, and + * leaves the full rules to the server instructions, which every client receives once. */ import type { Tool } from '@modelcontextprotocol/sdk/types.js'; @@ -46,21 +46,26 @@ describe('tool description budget', () => { expect(over.map(({ tool, description }) => `${tool}: ${description.length}`)).toEqual([]); }); - it("says that cwd is the user's working directory and where the full rules are", async () => { + it('says that cwd defaults to the client workspace root and when to pass it', async () => { const missing = cwdDescriptions(await listTools()).filter( ({ description }) => - !/user's working directory/i.test(description) || - !description.includes('project_set') || - !/server instructions/i.test(description) + !/optional/i.test(description) || + !/defaults to the client's workspace root/i.test(description) || + !/another directory/i.test(description) || + !/cwdSource "server"/.test(description) ); expect(missing.map(({ tool }) => tool)).toEqual([]); }); - // The short description points here, so the full rule has to be here. + // 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(/Pass cwd[^.]*on every project-scoped call/); - expect(SERVER_INSTRUCTIONS).toMatch(/server's own working directory, which is fixed at spawn/); - expect(SERVER_INSTRUCTIONS).toMatch(/no environment variable removes the need for it/); + 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"[^.]*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__/workspace-directory.test.ts b/packages/mcp-server/src/__tests__/workspace-directory.test.ts new file mode 100644 index 00000000..9627b813 --- /dev/null +++ b/packages/mcp-server/src/__tests__/workspace-directory.test.ts @@ -0,0 +1,460 @@ +// 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 { 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; +} + +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, {}, { 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([ + ['both', ['https://one.form.io', 'https://two.form.io']], + ['neither', [undefined, undefined]], + ])( + 'refuses with INVALID_ARGUMENT when %s of two roots resolves a project', + 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('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('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.each(['argument', 'claude-project-dir'] as const)( + 'adds no server-directory note for %s', + async (source) => { + const app = workspace('app', 'https://app.form.io'); + if (source === 'claude-project-dir') { + vi.stubEnv('CLAUDE_PROJECT_DIR', app); + } + const { client } = await connect(); + + const result = await call(client, 'project_get', source === 'argument' ? { cwd: app } : {}); + + expect(report(result).cwdSource).toBe(source); + expect(text(result)).not.toMatch(/own working directory/i); + } + ); +}); + +// 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'); + const two = workspace('two'); + 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'); + const two = workspace('two'); + 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 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/output-schemas.ts b/packages/mcp-server/src/output-schemas.ts index 3b77f8cf..72cef0f5 100644 --- a/packages/mcp-server/src/output-schemas.ts +++ b/packages/mcp-server/src/output-schemas.ts @@ -16,6 +16,7 @@ */ import { z } from 'zod'; +import { CWD_SOURCES } from './workspace-directory.js'; // Field descriptions are kept only where a caller branches on the value or passes it // on; a self-explanatory field (`title`, `created`, …) is typed and left undescribed, @@ -190,6 +191,9 @@ const resolutionStatus = z.enum(['ok', 'not-configured', 'base-url-unresolved']) const resolutionFields = { cwd: z.string(), + cwdSource: z + .enum(CWD_SOURCES) + .describe('Where cwd came from; "server" means pass cwd on later calls'), projectUrl: z.string().optional().describe('Absent when status is not-configured'), baseUrl: z.string().optional().describe('Absent unless status is ok'), projectUrlSource: z diff --git a/packages/mcp-server/src/project-report.ts b/packages/mcp-server/src/project-report.ts index d728468c..bb4c4b70 100644 --- a/packages/mcp-server/src/project-report.ts +++ b/packages/mcp-server/src/project-report.ts @@ -157,12 +157,13 @@ 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. @@ -326,7 +327,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 the MCP server's own working directory, 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 +374,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}, the MCP server's own working directory. Pass cwd, set to the user's directory, on later Form.io tool calls.` ); } @@ -504,7 +505,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 the MCP server's own working directory, 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 b438ea69..28bc3c19 100644 --- a/packages/mcp-server/src/project-resolver.ts +++ b/packages/mcp-server/src/project-resolver.ts @@ -36,11 +36,11 @@ import { } from './committed-config.js'; import { ToolError } from './tool-errors.js'; -// Repeated on every project-scoped tool, so it carries what to pass and the order -// resolution follows, and leaves the rest to the server instructions and project_set, -// which every client receives once. A test holds it to 200 characters. +// Repeated on every project-scoped tool, so it says what it defaults to and when to +// pass it, and leaves the order and the rest to the server instructions, which every +// client receives once. A test holds it to 200 characters. const CWD_DESCRIPTION = - "The user's working directory, absolute; pass it on every call. It selects the project: formio.json, else the project_set mapping, else FORMIO_PROJECT_URL (weakest). Rules: server instructions."; + 'Optional. Directory whose Form.io project to use; defaults to the client\'s workspace root. Pass it to target another directory or when project_get reports cwdSource "server".'; // 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 @@ -97,10 +97,10 @@ function missingProjectError({ cwd, mapCwd, unpaired }: MissingProject): Project // 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}, the MCP server's own working directory, 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 @@ -253,6 +253,13 @@ 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; } interface MappedEntryRead { @@ -524,6 +531,7 @@ export function resolveProject( { cacheDir, onNote = (message) => process.stderr.write(`${message}\n`), + cwdWasNamed = Boolean(cwd), }: ResolveProjectOptions = {} ): ProjectResolution { if (cwd && !path.isAbsolute(cwd)) { @@ -636,7 +644,7 @@ export function resolveProject( } if (!winner) { throw missingProjectError({ - cwd: cwd || undefined, + cwd: cwdWasNamed ? mapCwd : undefined, mapCwd, // 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 @@ -661,9 +669,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}, the MCP server's own working directory. Pass cwd, set to the user's directory, on later Form.io tool calls.` ); } diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 60cd76f6..18690f94 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -17,9 +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.`, - "Pass cwd, set to the user's current working directory as an absolute path, on every project-scoped call: omitted, it resolves against this server's own working directory, which is fixed at spawn and may resolve to a different project, and no environment variable removes the need for it.", - "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 one root holding a project record, else 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", 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.', diff --git a/packages/mcp-server/src/tools/action_create.ts b/packages/mcp-server/src/tools/action_create.ts index 511b1bf9..f05e7d35 100644 --- a/packages/mcp-server/src/tools/action_create.ts +++ b/packages/mcp-server/src/tools/action_create.ts @@ -5,7 +5,8 @@ import { toMcpStructuredResult, toMcpError } from '../mcp-responses.js'; import { actionDocument } from '../output-schemas.js'; import { creates } from '../tool-annotations.js'; import { actionDefinitionSchema, unknownActionType } 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'; export function registerActionCreateTool(server: McpServer, config: FormioConfig) { @@ -25,7 +26,7 @@ export function registerActionCreateTool(server: McpServer, config: FormioConfig async ({ cwd, formId, action }) => { try { requireFormId(formId); - const cfg = resolveProjectConfig(cwd, config); + const cfg = await resolveToolConfig({ server, cwd, config }); const catalog = (await formioFetch(`form/${formId}/actions`, {}, cfg)) as Array<{ name: string; }>; diff --git a/packages/mcp-server/src/tools/action_delete.ts b/packages/mcp-server/src/tools/action_delete.ts index ce6810b2..a02a112e 100644 --- a/packages/mcp-server/src/tools/action_delete.ts +++ b/packages/mcp-server/src/tools/action_delete.ts @@ -4,7 +4,8 @@ import { formioFetch } from '../formio-client.js'; import { toMcpError, toMcpStructuredResult } from '../mcp-responses.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'; @@ -25,7 +26,7 @@ export function registerActionDeleteTool(server: McpServer, config: FormioConfig async ({ cwd, formId, actionId }) => { try { requireFormId(formId); - const cfg = resolveProjectConfig(cwd, config); + 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 f8b1d68e..ab3b1b19 100644 --- a/packages/mcp-server/src/tools/action_get.ts +++ b/packages/mcp-server/src/tools/action_get.ts @@ -4,7 +4,8 @@ import { formioFetch } from '../formio-client.js'; import { toMcpStructuredResult, toMcpError } from '../mcp-responses.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'; @@ -25,7 +26,7 @@ export function registerActionGetTool(server: McpServer, config: FormioConfig) { async ({ cwd, formId, actionId }) => { try { requireFormId(formId); - const cfg = resolveProjectConfig(cwd, config); + 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 9d055645..e146efd3 100644 --- a/packages/mcp-server/src/tools/action_list.ts +++ b/packages/mcp-server/src/tools/action_list.ts @@ -3,7 +3,8 @@ import { FormioConfig } from '../config.js'; import { toMcpStructuredResult, toMcpError } from '../mcp-responses.js'; import { actionsListOutput } 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 { fetchListPage, listArguments, listResult } from './list-contract.js'; @@ -24,7 +25,7 @@ export function registerActionListTool(server: McpServer, config: FormioConfig) async ({ cwd, formId, ...query }) => { try { requireFormId(formId); - const cfg = resolveProjectConfig(cwd, config); + 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) { diff --git a/packages/mcp-server/src/tools/action_type_get.ts b/packages/mcp-server/src/tools/action_type_get.ts index cabfd847..3a3559c6 100644 --- a/packages/mcp-server/src/tools/action_type_get.ts +++ b/packages/mcp-server/src/tools/action_type_get.ts @@ -5,7 +5,8 @@ import { toMcpStructuredResult, toMcpError } from '../mcp-responses.js'; import { actionTypeInfoDocument } from '../output-schemas.js'; import { reads } from '../tool-annotations.js'; import { unknownActionType } 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'; @@ -28,7 +29,7 @@ export function registerActionTypeGetTool(server: McpServer, config: FormioConfi async ({ cwd, formId, actionName }) => { try { requireFormId(formId); - const cfg = resolveProjectConfig(cwd, config); + const cfg = await resolveToolConfig({ server, cwd, config }); try { const typeInfo = (await formioFetch( `form/${formId}/actions/${actionName}`, diff --git a/packages/mcp-server/src/tools/action_type_list.ts b/packages/mcp-server/src/tools/action_type_list.ts index 50d302bc..c240a089 100644 --- a/packages/mcp-server/src/tools/action_type_list.ts +++ b/packages/mcp-server/src/tools/action_type_list.ts @@ -4,7 +4,8 @@ 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, resolveProjectConfig } from '../project-resolver.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) { @@ -23,7 +24,7 @@ export function registerActionTypeListTool(server: McpServer, config: FormioConf async ({ cwd, formId }) => { try { requireFormId(formId); - const cfg = resolveProjectConfig(cwd, config); + 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[]) : []; diff --git a/packages/mcp-server/src/tools/action_update.ts b/packages/mcp-server/src/tools/action_update.ts index f6cac063..c3352422 100644 --- a/packages/mcp-server/src/tools/action_update.ts +++ b/packages/mcp-server/src/tools/action_update.ts @@ -5,7 +5,8 @@ import { toMcpStructuredResult, toMcpError } from '../mcp-responses.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'; @@ -27,7 +28,7 @@ export function registerActionUpdateTool(server: McpServer, config: FormioConfig async ({ cwd, formId, actionId, action }) => { try { requireFormId(formId); - const cfg = resolveProjectConfig(cwd, config); + 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_create.ts b/packages/mcp-server/src/tools/form_create.ts index 353a0555..fa34458e 100644 --- a/packages/mcp-server/src/tools/form_create.ts +++ b/packages/mcp-server/src/tools/form_create.ts @@ -5,7 +5,8 @@ import { formioFetch } from '../formio-client.js'; import { toMcpStructuredResult, toMcpError } from '../mcp-responses.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) { @@ -47,7 +48,7 @@ export function registerFormCreateTool(server: McpServer, config: FormioConfig) }, async ({ cwd, form: rawForm, note, acceptNoHistory }) => { try { - const cfg = resolveProjectConfig(cwd, config); + const cfg = await resolveToolConfig({ server, cwd, config }); const { licensed, form } = await gateRevisionsLicense({ cfg, actionLabel: 'create this form', diff --git a/packages/mcp-server/src/tools/form_get.ts b/packages/mcp-server/src/tools/form_get.ts index a9cf7d93..022e9bb5 100644 --- a/packages/mcp-server/src/tools/form_get.ts +++ b/packages/mcp-server/src/tools/form_get.ts @@ -5,7 +5,8 @@ import { formioFetch, isMongoId } from '../formio-client.js'; import { toMcpStructuredResult, toMcpError } from '../mcp-responses.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'; @@ -34,7 +35,7 @@ export function registerFormGetTool(server: McpServer, config: FormioConfig) { }, 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; diff --git a/packages/mcp-server/src/tools/form_list.ts b/packages/mcp-server/src/tools/form_list.ts index 530dff43..556f9e50 100644 --- a/packages/mcp-server/src/tools/form_list.ts +++ b/packages/mcp-server/src/tools/form_list.ts @@ -4,7 +4,8 @@ import { FormioConfig } from '../config.js'; import { toMcpStructuredResult, toMcpError } from '../mcp-responses.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'; @@ -44,7 +45,7 @@ export function registerFormListTool(server: McpServer, config: FormioConfig) { async ({ cwd, type, tags, ...query }) => { try { requireCommaFreeTags(tags); - const cfg = resolveProjectConfig(cwd, config); + 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({ diff --git a/packages/mcp-server/src/tools/form_publish.ts b/packages/mcp-server/src/tools/form_publish.ts index 9c761349..db2a233a 100644 --- a/packages/mcp-server/src/tools/form_publish.ts +++ b/packages/mcp-server/src/tools/form_publish.ts @@ -4,7 +4,8 @@ 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, 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 { publishDraft, requireRevisionsLicense } from '../revisions/index.js'; @@ -29,7 +30,7 @@ export function registerFormPublishTool(server: McpServer, config: FormioConfig) async ({ cwd, formId, note }) => { try { requireFormId(formId); - const cfg = resolveProjectConfig(cwd, config); + 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 diff --git a/packages/mcp-server/src/tools/form_revert.ts b/packages/mcp-server/src/tools/form_revert.ts index 0586aaad..bb366b05 100644 --- a/packages/mcp-server/src/tools/form_revert.ts +++ b/packages/mcp-server/src/tools/form_revert.ts @@ -4,7 +4,8 @@ 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, 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 { requireRevisionsLicense, revertToRevision } from '../revisions/index.js'; import { resourceSegmentArgument } from './path-arguments.js'; @@ -46,7 +47,7 @@ export function registerFormRevertTool(server: McpServer, config: FormioConfig) try { requireFormId(formId); refuseDraftVersion(version); - const cfg = resolveProjectConfig(cwd, config); + const cfg = await resolveToolConfig({ server, cwd, config }); await requireRevisionsLicense(cfg, 'revert this form'); return toMcpStructuredResult( (await revertToRevision({ formId, version, _vnote: note, cfg })) as Record< diff --git a/packages/mcp-server/src/tools/form_revision_get.ts b/packages/mcp-server/src/tools/form_revision_get.ts index 0d3313e7..e32f60d8 100644 --- a/packages/mcp-server/src/tools/form_revision_get.ts +++ b/packages/mcp-server/src/tools/form_revision_get.ts @@ -4,7 +4,8 @@ import { formioFetch, isMongoId } from '../formio-client.js'; import { toMcpStructuredResult, toMcpError } from '../mcp-responses.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) { @@ -29,7 +30,7 @@ export function registerFormRevisionGetTool(server: McpServer, config: FormioCon }, 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 index d4793e8c..273ba5a9 100644 --- a/packages/mcp-server/src/tools/form_revision_list.ts +++ b/packages/mcp-server/src/tools/form_revision_list.ts @@ -4,7 +4,8 @@ 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, 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 { projectPathArgument } from './path-arguments.js'; @@ -34,7 +35,7 @@ export function registerFormRevisionListTool(server: McpServer, config: FormioCo }, async ({ cwd, formIdOrPath, ...query }) => { try { - const cfg = resolveProjectConfig(cwd, config); + const cfg = await resolveToolConfig({ server, cwd, config }); const base = isMongoId(formIdOrPath) ? `form/${formIdOrPath}` : formIdOrPath; const page = await fetchListPage({ path: `${base}/v`, diff --git a/packages/mcp-server/src/tools/form_update.ts b/packages/mcp-server/src/tools/form_update.ts index 58529f73..7de60f1e 100644 --- a/packages/mcp-server/src/tools/form_update.ts +++ b/packages/mcp-server/src/tools/form_update.ts @@ -5,7 +5,8 @@ import { formioFetch } from '../formio-client.js'; import { toMcpStructuredResult, toMcpError } from '../mcp-responses.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, @@ -68,7 +69,7 @@ export function registerFormUpdateTool(server: McpServer, config: FormioConfig) async ({ cwd, formId, form: rawForm, note, draft, acceptNoHistory }) => { try { requireFormId(formId); - const cfg = resolveProjectConfig(cwd, config); + const cfg = await resolveToolConfig({ server, cwd, config }); if (draft) { await requireRevisionsLicense(cfg, 'save a draft of this form'); diff --git a/packages/mcp-server/src/tools/project-resolution.ts b/packages/mcp-server/src/tools/project-resolution.ts index 914272e1..fe90b7b6 100644 --- a/packages/mcp-server/src/tools/project-resolution.ts +++ b/packages/mcp-server/src/tools/project-resolution.ts @@ -1,12 +1,49 @@ -import { FormioConfig } from '../config.js'; +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, workspaceDirectory } from '../workspace-directory.js'; import { TOOL_REMEDIES } from './project-remedies.js'; -export interface ToolProjectReportRequest { +export interface ToolDirectoryRequest { + server: McpServer; /** The cwd the caller passed, if any. */ cwd: string | undefined; - /** The server's own process directory, used when the caller passed none. */ - serverCwd: () => string; + /** 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 { dir, source } = await toolDirectory({ server, cwd }); + return resolveProjectConfig(dir, config, { cwdWasNamed: source !== 'server' }); +} + +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 @@ -21,31 +58,31 @@ export interface ToolProjectReportRequest { * project_get and server_status both give, from the resolver every other tool uses. */ export function reportProjectForTool({ - cwd, - serverCwd, + directory, config, notes, }: ToolProjectReportRequest): ProjectReport { return 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 ?? serverCwd(), + cwd: directory.dir, 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, + // Only the server's own directory — fixed at spawn, and often not where the user + // is — makes the answer say so and withhold a remedy keyed to it. A directory + // the client or its launch named is where the user is working. + cwdWasNamed: directory.source !== 'server', }); } /** The report as a structured payload, omitting the halves it does not have. */ -export function projectReportPayload(report: ProjectReport): Record { +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 } : {}), diff --git a/packages/mcp-server/src/tools/project_export.ts b/packages/mcp-server/src/tools/project_export.ts index 8c20ec0a..a4d2c5ea 100644 --- a/packages/mcp-server/src/tools/project_export.ts +++ b/packages/mcp-server/src/tools/project_export.ts @@ -4,7 +4,8 @@ import { formioFetch } from '../formio-client.js'; import { toMcpStructuredResult, toMcpError } from '../mcp-responses.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( @@ -20,7 +21,7 @@ export function registerProjectExportTool(server: McpServer, config: FormioConfi }, 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 689c8711..85724f78 100644 --- a/packages/mcp-server/src/tools/project_get.ts +++ b/packages/mcp-server/src/tools/project_get.ts @@ -8,6 +8,7 @@ import { projectReportPayload, projectReportText, reportProjectForTool, + toolDirectory, } from './project-resolution.js'; export interface ProjectGetOptions { @@ -35,8 +36,12 @@ export function registerProjectGetTool( async ({ cwd }) => { const notes: string[] = []; try { - const report = reportProjectForTool({ cwd, serverCwd: getServerCwd, config, notes }); - return toMcpStructuredResult(projectReportPayload(report), projectReportText(report)); + const directory = await toolDirectory({ server, cwd, serverCwd: getServerCwd }); + const report = reportProjectForTool({ directory, config, notes }); + return toMcpStructuredResult( + projectReportPayload(report, directory), + projectReportText(report) + ); } catch (error) { // "Could not answer at all" — an unreadable map, a formio.json that will // not parse. Deliberately NOT a "not-configured" status: that status diff --git a/packages/mcp-server/src/tools/project_import.ts b/packages/mcp-server/src/tools/project_import.ts index 5e875b98..61d81d6a 100644 --- a/packages/mcp-server/src/tools/project_import.ts +++ b/packages/mcp-server/src/tools/project_import.ts @@ -5,7 +5,8 @@ import { formioFetch } from '../formio-client.js'; import { toMcpError, toMcpStructuredResult } from '../mcp-responses.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( @@ -22,7 +23,7 @@ export function registerProjectImportTool(server: McpServer, config: FormioConfi }, 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 5836c14e..c5694a44 100644 --- a/packages/mcp-server/src/tools/project_set.ts +++ b/packages/mcp-server/src/tools/project_set.ts @@ -23,6 +23,7 @@ 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 { readProjectEntryForWrite, unusableRecordProjectUrl, @@ -68,7 +69,7 @@ export function registerProjectSetTool(server: McpServer, options: ProjectSetOpt // 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 a working directory by recording its Project URL in ~/.formio/projects.json, keyed by `cwd` (omitted, the server's own spawn directory, which may not be the user's).", + 'Set the active Form.io project for a working directory by recording its Project URL in ~/.formio/projects.json, keyed by the directory `cwd` resolves to, as every tool resolves it.', 'Call it whenever the user asks to set, change, or switch the active project; acknowledging in text persists nothing.', `It needs no restart, and precedence runs: a committed ${COMMITTED_CONFIG_FILE}, then the working-directory mapping, then FORMIO_PROJECT_URL in the environment, the weakest — so a mapping written here DOES override the environment, and a committed file overrides both. Read \`ok\` and \`projectUrl\` on the result: they report the pair that actually resolves.`, 'Pass projectUrl alone: the Base URL, which builds the portal-login URL and keys the cached token, is derived — https://api.form.io for a form.io host, the parent path for a sub-directory project. Pass baseUrl only after the server reports it cannot be determined (a path-less project URL on a customer domain).', @@ -93,7 +94,7 @@ export function registerProjectSetTool(server: McpServer, options: ProjectSetOpt // The SAME schema every reader validates against. A write must not accept // what a read cannot key on. cwd: cwdSchema.describe( - "The user's working directory, absolute, that the mapping is keyed against. Omitted, the server's own spawn directory is used. Rules: project_set and the server instructions." + 'Optional. Directory whose mapping to write; defaults to the client\'s workspace root. Pass it to target another directory or when project_get reports cwdSource "server".' ), baseUrl: z .string() @@ -107,7 +108,13 @@ export function registerProjectSetTool(server: McpServer, options: ProjectSetOpt annotations: local('Set the active project', false), }, catchToolErrors(async ({ projectUrl, cwd, baseUrl: baseUrlArg }) => { - const entryCwd = cwd ?? getServerCwd(); + // 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; + // Only the server's own directory is one nobody chose for this write. + const named = directory.source !== 'server'; 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 @@ -142,10 +149,10 @@ 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 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.`; // 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. @@ -282,9 +289,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 the MCP server's own working directory. 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. @@ -334,7 +341,7 @@ export function registerProjectSetTool(server: McpServer, options: ProjectSetOpt baseConfig: { projectUrl: getEnvProjectUrl(), baseUrl: getEnvBaseUrl() }, remedies: TOOL_REMEDIES, notes: reportNotes, - cwdWasNamed: Boolean(cwd), + cwdWasNamed: named, }); } catch (error) { // NOT swallowed. A committed file is checked for shape where it is read and diff --git a/packages/mcp-server/src/tools/role_create.ts b/packages/mcp-server/src/tools/role_create.ts index 4b885acb..7a34d01c 100644 --- a/packages/mcp-server/src/tools/role_create.ts +++ b/packages/mcp-server/src/tools/role_create.ts @@ -5,7 +5,8 @@ import { toMcpStructuredResult, toMcpError } from '../mcp-responses.js'; import { roleDocument } from '../output-schemas.js'; import { creates } from '../tool-annotations.js'; import { newRoleSchema } from './role-schema.js'; -import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; +import { cwdSchema } from '../project-resolver.js'; +import { resolveToolConfig } from './project-resolution.js'; export function registerRoleCreateTool(server: McpServer, config: FormioConfig) { server.registerTool( @@ -21,7 +22,7 @@ export function registerRoleCreateTool(server: McpServer, config: FormioConfig) }, 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 c84ceaef..98e43990 100644 --- a/packages/mcp-server/src/tools/role_list.ts +++ b/packages/mcp-server/src/tools/role_list.ts @@ -3,7 +3,8 @@ import { FormioConfig } from '../config.js'; import { toMcpStructuredResult, toMcpError } from '../mcp-responses.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) { @@ -20,7 +21,7 @@ export function registerRoleListTool(server: McpServer, config: FormioConfig) { }, async ({ cwd, ...query }) => { try { - const cfg = resolveProjectConfig(cwd, config); + const cfg = await resolveToolConfig({ server, cwd, config }); const page = await fetchListPage({ path: 'role', query, config: cfg }); return toMcpStructuredResult(listResult('roles', page)); } catch (error) { diff --git a/packages/mcp-server/src/tools/role_update.ts b/packages/mcp-server/src/tools/role_update.ts index 368ada11..b2b3380b 100644 --- a/packages/mcp-server/src/tools/role_update.ts +++ b/packages/mcp-server/src/tools/role_update.ts @@ -6,7 +6,8 @@ import { toMcpStructuredResult, toMcpError } from '../mcp-responses.js'; import { roleDocument } from '../output-schemas.js'; import { overwrites } from '../tool-annotations.js'; import { roleSchema } from './role-schema.js'; -import { cwdSchema, resolveProjectConfig } from '../project-resolver.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) { @@ -30,7 +31,7 @@ export function registerRoleUpdateTool(server: McpServer, config: FormioConfig) value: roleId, remedy: "roleId takes a role's 24-character ObjectId _id. Read it with role_list.", }); - const cfg = resolveProjectConfig(cwd, config); + 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 index 3607a901..14fcffcc 100644 --- a/packages/mcp-server/src/tools/server_status.ts +++ b/packages/mcp-server/src/tools/server_status.ts @@ -9,6 +9,7 @@ import { projectReportPayload, projectReportText, reportProjectForTool, + toolDirectory, } from './project-resolution.js'; export interface ServerStatusOptions { @@ -34,9 +35,14 @@ export function registerServerStatusTool( const notes: string[] = []; const heading = `${SERVER_NAME} ${SERVER_VERSION}`; try { - const report = reportProjectForTool({ cwd, serverCwd: getServerCwd, config, notes }); + const directory = await toolDirectory({ server, cwd, serverCwd: getServerCwd }); + const report = reportProjectForTool({ directory, config, notes }); return toMcpStructuredResult( - { name: SERVER_NAME, version: SERVER_VERSION, ...projectReportPayload(report) }, + { + name: SERVER_NAME, + version: SERVER_VERSION, + ...projectReportPayload(report, directory), + }, [heading, projectReportText(report)].join('\n') ); } catch (error) { diff --git a/packages/mcp-server/src/workspace-directory.ts b/packages/mcp-server/src/workspace-directory.ts new file mode 100644 index 00000000..946cf29a --- /dev/null +++ b/packages/mcp-server/src/workspace-directory.ts @@ -0,0 +1,208 @@ +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 { findCommittedConfig } from './committed-config.js'; +import { readProjectEntry } 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. + */ +export function rootDirectories(roots: ReadonlyArray<{ uri: string }>): string[] { + const dirs = roots.flatMap(({ uri }) => { + if (!uri.startsWith('file://')) { + return []; + } + try { + return [fileURLToPath(uri)]; + } catch { + return []; + } + }); + return [...new Set(dirs)]; +} + +/** + * Whether a directory resolves a project record of its own: a committed formio.json + * naming a project found by the upward walk, or a directory mapping naming one. The + * environment is not asked — it answers for every directory alike, so it cannot tell + * one root from another. + * + * A committed file that cannot be read counts: it is a record the user wrote for + * that directory, and choosing that root lets the call fail naming the file rather + * than refusing for an ambiguity that is not there. An unreadable project map answers + * for no directory in particular, so it counts for none. + */ +export function hasProjectRecord(dir: string): boolean { + try { + if (findCommittedConfig(dir)?.projectUrl) { + return true; + } + } catch { + return true; + } + try { + return Boolean(readProjectEntry(dir)?.env.FORMIO_PROJECT_URL); + } catch { + return false; + } +} + +export interface ChooseRootRequest { + roots: string[]; + hasProjectRecord: (dir: string) => boolean; +} + +/** + * The root a call with no `cwd` resolves against. One root is used as is. Of several, + * the one that resolves a project record is used; none or more than one is 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, hasProjectRecord }: ChooseRootRequest): string | undefined { + if (roots.length <= 1) { + return roots[0]; + } + const recorded = roots.filter(hasProjectRecord); + if (recorded.length === 1) { + return recorded[0]; + } + throw new ToolError({ + code: 'INVALID_ARGUMENT', + message: `The client reports ${roots.length} workspace roots and ${recorded.length === 0 ? 'none' : 'more than one'} of them resolves a Form.io project record: ${roots.join(', ')}. Pass cwd set to the directory to use.`, + }); +} + +/** 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; + hasProjectRecord?: (dir: string) => boolean; +} + +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 counts as no roots and is kept the same + * way: 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 recorded = options.hasProjectRecord ?? hasProjectRecord; + const cache: { roots?: Promise } = {}; + + server.setNotificationHandler(RootsListChangedNotificationSchema, () => { + delete cache.roots; + }); + + const listRoots = async (): Promise => { + try { + const { roots } = await server.request({ method: 'roots/list' }, RootsAnswer, { timeout }); + return rootDirectories(roots); + } catch { + return []; + } + }; + + const clientRoots = (): Promise => { + if (!server.getClientCapabilities()?.roots) { + return Promise.resolve([]); + } + cache.roots ??= listRoots(); + return cache.roots; + }; + + return { + async resolve({ cwd, serverCwd = () => process.cwd() }) { + if (cwd !== undefined) { + return { dir: cwd, source: 'argument' }; + } + const root = chooseRoot({ roots: await clientRoots(), hasProjectRecord: recorded }); + 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..da93572b --- /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`[^.]*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/plugin/skills/formio-actions/SKILL.md b/plugin/skills/formio-actions/SKILL.md index cdd5cdca..aabcbe74 100644 --- a/plugin/skills/formio-actions/SKILL.md +++ b/plugin/skills/formio-actions/SKILL.md @@ -22,7 +22,7 @@ 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`, the directory is the MCP server's own, which is fixed at spawn 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. diff --git a/plugin/skills/formio-angular/SKILL.md b/plugin/skills/formio-angular/SKILL.md index 619143ec..4f938a53 100644 --- a/plugin/skills/formio-angular/SKILL.md +++ b/plugin/skills/formio-angular/SKILL.md @@ -39,7 +39,7 @@ 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`, the directory is the MCP server's own, which is fixed at spawn 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. diff --git a/plugin/skills/formio-angular/formio-angular-form/SKILL.md b/plugin/skills/formio-angular/formio-angular-form/SKILL.md index 2257a2b6..e95bff72 100644 --- a/plugin/skills/formio-angular/formio-angular-form/SKILL.md +++ b/plugin/skills/formio-angular/formio-angular-form/SKILL.md @@ -107,7 +107,7 @@ 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`, the directory is the MCP server's own, which is fixed at spawn 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. diff --git a/plugin/skills/formio-angular/formio-angular-resources/SKILL.md b/plugin/skills/formio-angular/formio-angular-resources/SKILL.md index a60aeb40..554202fe 100644 --- a/plugin/skills/formio-angular/formio-angular-resources/SKILL.md +++ b/plugin/skills/formio-angular/formio-angular-resources/SKILL.md @@ -24,7 +24,7 @@ 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`, the directory is the MCP server's own, which is fixed at spawn 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. diff --git a/plugin/skills/formio-api/SKILL.md b/plugin/skills/formio-api/SKILL.md index 9827dcf7..8d74f003 100644 --- a/plugin/skills/formio-api/SKILL.md +++ b/plugin/skills/formio-api/SKILL.md @@ -22,7 +22,7 @@ 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`, the directory is the MCP server's own, which is fixed at spawn 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. @@ -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. 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 f88d5a21..ddadee54 100644 --- a/plugin/skills/formio-application/SKILL.md +++ b/plugin/skills/formio-application/SKILL.md @@ -22,7 +22,7 @@ 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`, the directory is the MCP server's own, which is fixed at spawn 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. diff --git a/plugin/skills/formio-auth/SKILL.md b/plugin/skills/formio-auth/SKILL.md index 5aabb725..ee1bbd69 100644 --- a/plugin/skills/formio-auth/SKILL.md +++ b/plugin/skills/formio-auth/SKILL.md @@ -18,7 +18,7 @@ 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`, the directory is the MCP server's own, which is fixed at spawn 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. diff --git a/plugin/skills/formio-auth/references/token-swap.md b/plugin/skills/formio-auth/references/token-swap.md index 54846eea..6bf1018e 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` — 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/SKILL.md b/plugin/skills/formio-form-builder/SKILL.md index 8d8d8c56..62c9df34 100644 --- a/plugin/skills/formio-form-builder/SKILL.md +++ b/plugin/skills/formio-form-builder/SKILL.md @@ -22,7 +22,7 @@ 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`, the directory is the MCP server's own, which is fixed at spawn 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. diff --git a/plugin/skills/formio-form/SKILL.md b/plugin/skills/formio-form/SKILL.md index 7d8883c4..46b86bb2 100644 --- a/plugin/skills/formio-form/SKILL.md +++ b/plugin/skills/formio-form/SKILL.md @@ -26,7 +26,7 @@ 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`, the directory is the MCP server's own, which is fixed at spawn 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. diff --git a/plugin/skills/formio-form/references/setup.md b/plugin/skills/formio-form/references/setup.md index 4cc61baa..345cceaa 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` — 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..89736134 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`, the answer is about the MCP server's own directory, which is fixed at spawn and 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 21143be1..c806d19b 100644 --- a/plugin/skills/formio-react/SKILL.md +++ b/plugin/skills/formio-react/SKILL.md @@ -44,7 +44,7 @@ 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`, the directory is the MCP server's own, which is fixed at spawn 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. diff --git a/plugin/skills/formio-react/formio-react-form/SKILL.md b/plugin/skills/formio-react/formio-react-form/SKILL.md index db190de8..73e9e87c 100644 --- a/plugin/skills/formio-react/formio-react-form/SKILL.md +++ b/plugin/skills/formio-react/formio-react-form/SKILL.md @@ -36,7 +36,7 @@ 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`, the directory is the MCP server's own, which is fixed at spawn 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. diff --git a/plugin/skills/formio-react/formio-react-resources/SKILL.md b/plugin/skills/formio-react/formio-react-resources/SKILL.md index 3a2e67d5..5ff33b4b 100644 --- a/plugin/skills/formio-react/formio-react-resources/SKILL.md +++ b/plugin/skills/formio-react/formio-react-resources/SKILL.md @@ -24,7 +24,7 @@ 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`, the directory is the MCP server's own, which is fixed at spawn 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. 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 74cbf40a..1299b794 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 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 diff --git a/plugin/skills/formio-schema/SKILL.md b/plugin/skills/formio-schema/SKILL.md index a557668b..834f8d05 100644 --- a/plugin/skills/formio-schema/SKILL.md +++ b/plugin/skills/formio-schema/SKILL.md @@ -27,7 +27,7 @@ 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`, the directory is the MCP server's own, which is fixed at spawn 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. diff --git a/plugin/skills/formio-sdk/SKILL.md b/plugin/skills/formio-sdk/SKILL.md index 09697b1d..cf0de19b 100644 --- a/plugin/skills/formio-sdk/SKILL.md +++ b/plugin/skills/formio-sdk/SKILL.md @@ -22,7 +22,7 @@ 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`, the directory is the MCP server's own, which is fixed at spawn 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. @@ -54,7 +54,7 @@ Never use `@formio/js/lib/...` deep imports or `