Skip to content

feat(mcp)!: freeze the 1.0 tool surface - #91

Merged
travist merged 13 commits into
mainfrom
freeze-1-0-tool-surface
Oct 9, 2026
Merged

travist merged 13 commits into
mainfrom
freeze-1-0-tool-surface

Conversation

@travist

@travist travist commented Oct 9, 2026 •

Copy link
Copy Markdown
Member

Why

1.0.0 makes the @formio/mcp tool surface a contract. Before this change it was inconsistent in ways an agent paid for on every call, and parts of it were wrong:

  • Tool names mixed singular and plural, and hello reported nothing a caller needed.
  • form_update folded four operations behind flags. Publish and revert ignored the required form body, and draft mode refused fields form_get returns.
  • Every list stopped at Form.io's default page of 10 without saying so. Revisions came back oldest first, count was the page length, and the multi-tag filter matched nothing.
  • Every output schema was closed at the top level, so a client that validates output rejected real Form.io responses (_vid, pdfComponents, esign, settings: null).
  • Form writes could stop mid-call for an out-of-band consent prompt (elicitation, or a local browser page).
  • Errors were prose only, and Form.io's response body was dropped.
  • tools/list cost ~68.5k characters.

What changes — BREAKING for any client calling the tools directly

  • Renames: action_types_list → action_type_list, form_revisions_list → form_revision_list, hello → server_status. server_status returns the server name and version plus the cwd's project resolution, without making a Form.io request. The old names are not registered.
  • form_publish / form_revert are now separate tools. form_update keeps the full update and draft: true. Draft mode saves the draft fields of the body it is given, so form_get's output can be passed back.
  • Form addressing: formId is always a 24-character ObjectId. formIdOrPath accepts either form and is used by form_get, form_revision_list and form_revision_get. These follow the routes Form.io resolves by path: the action-type catalog route does not resolve a path.
  • role_create takes a nested role object, like role_update.
  • List contract:
    • Arguments: limit (default 100), skip, sort, select.
    • Results: total and hasMore, read from Content-Range. A full page with an unknown total reports hasMore: true.
    • Behaviour: count is removed; a skip past the end returns an empty page; tags match all of the given tags; form_revision_list is newest first and excludes the unpublished draft row.
  • acceptNoHistory replaces the consent prompts. A write that would save without revision history needs acceptNoHistory: true, otherwise it is refused with HISTORY_NOT_ACCEPTED so the agent can ask the user. That covers three cases: an unlicensed deployment, a form with revisions off, or an explicit revisions: "". The elicitation path, the local browser consent pages and the persisted consent file are removed. The licence probe has three states. Only a definite answer is cached, and an undetermined licence never strips history.
  • Errors: every tool error's text starts with [CODE] from a fixed set, and the result carries { code, status?, body? } in _meta["io.form/error"]. Error results carry no structuredContent, because clients validate it against the tool's success schema even when isError is set. Text after the prefix is unchanged, so the error part is additive.
  • Output schemas are open at every level, and Mixed Form.io fields accept null.
  • Description budget: tools/list is ~37.6k characters (from ~68.5k), under a tested 42,000 budget. The cwd description is 82 characters; output schemas describe only the fields callers branch on (status, total, hasMore, remedy, ok) and declare only the document fields callers act on — they stay open, so every other Form.io field passes through; nested Form.io body fields are documented by the skills; project_set states only the rules it enforces.
  • cwd comes from the client. A call without cwd resolves its project for the client's workspace root (MCP roots/list; one root as is, several narrowed to the one holding a project record, ambiguous ones refused with INVALID_ARGUMENT), then CLAUDE_PROJECT_DIR, then the server's own directory — all through the one resolver project get uses. project_get / server_status report cwdSource; only a guessed directory tells the agent to pass cwd. Roots are cached, re-read on roots/list_changed, keep the last good list on a failed re-read, and time out after 2s. cwd stays optional; the skills' preflight calls project_get without it.
  • FORMIO_INSECURE_TLS and FORMIO_FORCE_BROWSER share one boolean parser.
  • @formio/mcp exports only ./package.json; the package is its formio-mcp binary.
  • The CLI's flags and exit codes are documented as the 1.0 reference, unchanged.
  • Skills and READMEs follow the surface: the preflight paragraph in all 14 gated skills, the formio-actions and formio-api references, and the role_create examples. The server README gains a conventions section and a CLI reference.

~/.formio/projects.json keeps its current format. That was decided during the proposal, because renaming its keys would need a migration or break existing mappings.

Verification

  • pnpm test: @formio/mcp 1543, @formio/skill-tests 1273. pnpm lint, pnpm format, pnpm check:releases and openspec validate --strict also pass.
  • New guards:
    • every registered tool is driven into an error, and the result must carry a [CODE];
    • every output schema is open;
    • realistic Form.io documents replay through a validating client;
    • the tools/list size budget;
    • skill text must not name retired tools, flat role_create fields, list count, or the consent prompt;
    • the preflight list of writing tools is complete.
  • Checked live against a Form.io Enterprise deployment: sort=-_vid puts the draft revision first, _vid__ne=draft removes it, and Content-Range reports the reduced total, including on a 206 partial page.
  • Independent code reviews were run on the branch (10 findings, resolved in b82b707), on this PR (10 findings, resolved in bdd5804), and on the cwd change (9 resolved in 54076dc, 1 declined).

Release note

This carries a major changeset for @formio/mcp and @formio/ai. Merging it does not publish anything. Hold the Version Packages PR until the remaining 1.0 changes land (auth and error hardening, local state and release, skill corrections, routing, planner delta mode, drift guards). Merging the Version Packages PR before then would release 1.0.0 early.

Known follow-ups, not in this PR:

  • the MCP Inspector screenshot in the server README still shows hello;
  • the .mcpb bundle reports version 0.0.0 (this predates the branch);
  • plugin/README.md's environment-fallback sentence.

OpenSpec change: openspec/changes/freeze-1-0-tool-surface/.

🤖 Generated with Claude Code

travist and others added 13 commits October 9, 2026 09:49
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…nsent 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
- Several roots with no project record fall through to CLAUDE_PROJECT_DIR
  and the server's directory, so environment-only setups keep working;
  only roots resolving to different records are refused.
- Roots are compared by the record they resolve to, so roots inside one
  repository sharing a formio.json are one project; root paths are
  normalized before de-duplication.
- A failed or slow roots re-read keeps the last good roots.
- A broken formio.json or mapping entry counts as a record, so its error
  surfaces instead of another root being chosen.
- CLAUDE_PROJECT_DIR is a guess like the server's own directory: it is
  reported and tells the agent to pass cwd.
- CLAUDE.md, the design and the skills describe the preflight without cwd.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The cwd description is 82 characters (full rules stay in the server
instructions); output schemas describe only the fields callers branch on
(status, total, hasMore, remedy, ok) and declare only the document fields
callers act on, staying open so every other field passes through; nested
Form.io body fields leave their documentation to the skills; project_set
states only the rules it enforces. No tool, argument or schema removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@travist
travist merged commit 3649d61 into main Oct 9, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant