Repository navigation
Conversation
…sting forms The skills.sh remediation stated that the agent never touches submission data, and the toolset backed that with the absence of any submission tool. That over-corrected: a select reading a Resource shows an empty dropdown until someone adds rows, and nothing could check a form with a real submission. This adds that capability with the boundary enforced by the server rather than by prose. MCP server - submission_create / _list / _get / _update / _delete, registered through registerAllTools, each reaching only submissions this server created and signed for the calling working directory. - Every agent write carries a server-written metadata.agent tag. Its HMAC covers projectUrl, form, owner, session, purpose, nonce, and the full stored data; the stored data comes from a ?dryrun=1 pass, which normalizes (defaults, calculated values) without saving or running actions. - Per-directory 32-byte key in ~/.formio/mcp-submission-keys.json (0600); an unreadable store fails loudly and never names its path. - Reads force the tag filters and build the query from structured data.* filters with an operator allow-list. resourcejs splits keys on the first `__`, so `__` inside a field name is refused rather than reaching Mongo as an arbitrary $ selector. Every returned record is re-verified; failures are dropped and counted, and a get answers a 404 and an unowned record the same way. - Update and delete verify first; update keeps the tag identity and re-signs. Write results name the form actions that ran. - FormioApiError carries status and response body; server instructions state the scope and the action_list check. - Opt-in live suite (live-submission-behavior.test.ts) confirmed the dry-run and owner behavior against Form.io Enterprise 9.9.2. Skills - Canonical guideline formio-mcp-setup/references/agent-submissions.md: the two purposes, no reference rows in auth forms, invented values, action_list + preview + approval before every write, test cleanup. - formio-form-builder seeds an empty dropdown source and writes a test submission after SAVE; formio-application gains Step 3.6 for planner- marked `Seed: reference-data` Resources; formio-actions documents testing an action. - Prose that said no submission tool exists, no tool returns submission data, or only an admin seeds reference data now names the scoped tools; the ban on reading an end user's submission by any route stays. OpenSpec change agent-scoped-submission-tools carries the proposal, specs, design (with the resolved open questions), and tasks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tag's signature already binds the project, form, owner, session,
purpose, and the full stored data, so a copied tag verifies only on a
byte-identical copy of the agent's own row. A random per-tag nonce added no
protection on top of that, and two identical seed rows sharing a signature
is harmless.
metadata.agent is now exactly { source, session, purpose, sig }, and the
signature is deterministic. session stays because Mongo cannot verify an
HMAC and the index query needs it to return only this directory's rows;
purpose stays because test-row cleanup filters on it. Update no longer
carries a nonce forward. Spec, design (with the rationale), tasks, and the
changeset follow. Live suite re-run against form.test/sandbox: 5/5.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| @@ -0,0 +1,95 @@ | |||
| import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; | |||
There was a problem hiding this comment.
The first time I ran the submission_create tool I got this message indicating it couldn't read the submission back.
"The submission was written but can't be read back, so the test didn't fully work. Form.io stored it as submission 6abec34cba9cae5e41036401 on the Contact Us form. The server then found its signature doesn't match the stored record, so the submission tools can't fetch it, show it, or delete it.
Likely cause. The fields came back identical. The error's "fields: unknown" means no data field differed. The signature is built in src/tools/submission_create.ts from project, form, owner, session, purpose and data. With data matching, the likeliest culprits are:
- Owner (most likely). owner is taken from the dry run (dryRun.owner, line 65). If the dry run returns no owner, or a different one from the real save, the record is signed with owner: null but stored with your admin user's ID, and the check fails.
- The tag itself. If the server drops or rewrites metadata.agent on save, verification fails the same way.
I haven't confirmed which. Confirming means reading the raw stored record, and the agent-submission rules forbid me reading a submission any way other than these tools. You can check it in the portal: compare the record's owner with what a ?dryrun=1 POST returns.
Cleanup. Delete submission 6abec34cba9cae5e41036401 in the Form.io portal. Filtering on metadata.agent.source = agent finds it.
I can write a failing test that reproduces the owner mismatch and fix submission_create. One option is to sign using the owner the real save returns, though that changes what the signature guarantees."
There was a problem hiding this comment.
I noticed just now that the sigs are different in the verifyRecord fn.
There was a problem hiding this comment.
I believe reason 1 from the first comment checks out. I removed the owner field and verification started working.
"Owner (most likely). owner is taken from the dry run (dryRun.owner, line 65). If the dry run returns no owner, or a different one from the real save, the record is signed with owner: null but stored with your admin user's ID, and the check fails."
| export function signTag({ key, projectUrl, formId, owner, purpose, data }: SignTagInput): AgentTag { | ||
| const session = sessionLabel(key); | ||
| const sig = computeSig(key, { projectUrl, formId, owner, session, purpose, data }); | ||
| return { source: 'agent', session, purpose, sig }; |
There was a problem hiding this comment.
Since the object is named metadata.agent, I would remove the additional source property here.
| return { source: 'agent', session, purpose, sig }; | |
| return { session, purpose, sig }; |
| method: 'POST', | ||
| body: { data }, | ||
| })) as DryRun; | ||
| const owner = typeof dryRun.owner === 'string' ? dryRun.owner : null; |
There was a problem hiding this comment.
It looks like it's not intended for the MCP server to be able to override owner on a submission. In what case would the dry run return an owner?
blakekrammes
left a comment
There was a problem hiding this comment.
It works really well! This is an exciting feature that I enjoyed testing.
I had some issues with the owner not coming back from the dry run (see comments), which caused all the submission verifications to fail.
I manually reviewed all the files with the exception of:
- The skills tests
- The openspec artifacts
I also was able to configure the MCP server to run locally with a debugger, which was really helpful for understanding the tool calls and logic. It could be nice to commit that as a dev config.
| formIdOrPath: z.string().describe('Form ID (_id) or path'), | ||
| data: z | ||
| .record(z.string(), z.unknown()) | ||
| .describe('The submission data, keyed by component key; invented values only'), |
There was a problem hiding this comment.
'invented values only' wouldn't apply to reference data though.
| * so it reveals neither the key nor the directory's path; it is not a secret — the | ||
| * signature, not the label, is what a record must carry to verify. | ||
| */ | ||
| export function sessionLabel(key: Buffer): string { |
There was a problem hiding this comment.
It seems like the session label is really a directory label. Starting a new session in the same directory would yield the same dir key and session label.
Maybe dirLabel?
| server.registerTool( | ||
| 'submission_update', | ||
| { | ||
| description: `Replace the data of one submission the agent created; its purpose is kept. ${SCOPE_SENTENCE} Updating runs the form's update actions; the result names the ones that ran. ${RULES_SENTENCE}`, |
There was a problem hiding this comment.
My agent noted that the actions that are listed as having run include actions having conditions that were not met.
i.e. an action that fires after create that is conditionally run if, say data.priority is high, would be listed in actionTitlesFor even if it didn't execute (if data.priority was low).
A simple fix would just be to alter the language a bit. The agent may figure out which actions ran and which didn't on its own after the fact.
Summary
The skills.sh remediation (#80 and the Sep 9 pass) stated that the agent never touches submission data, and the toolset backed that by having no submission tool at all. That over-corrected. A
selectthat reads a Resource shows an empty dropdown until someone adds rows, and nothing could check a form's validation, conditional fields, calculated values, or actions with a real submission.This PR adds that capability, with the boundary enforced by the MCP server. The skills don't just ask the agent to behave: every
submission_*tool reaches only submissions this server created and signed for the calling working directory.OpenSpec change:
openspec/changes/agent-scoped-submission-tools/(proposal, specs, design with resolved open questions, tasks — 66/66).How the boundary works
metadata.agenttag with exactly four fields:source(picks out agent rows, for the index query and in the portal),session(a label derived from the directory's key, so list queries return only that directory's rows and other directories can't crowd them off a page),purpose(reference-dataortest, which test-row cleanup filters on), andsig. Thesigis a deterministic HMAC-SHA256 over the project, form, owner,session,purpose, and the full storeddata. Anyone who submits to a form can setmetadata, so a label alone would be a claim; the signature is the proof. A copied tag verifies only on a byte-identical copy of the agent's own row, which is also why no per-tag nonce is needed.?dryrun=1, which returns the data as Form.io will store it (defaults, server-calculated values). That data is what gets signed. The dry run saves nothing and runs no action, so there is no second write and no duplicate email or webhook. Create-then-PATCH was rejected because PATCH fires update actions.~/.formio/mcp-submission-keys.jsonwith mode0600. An unreadable store fails loudly, and the key and store path never appear in a tool result.Tools
submission_createreference-dataortestsubmissionsubmission_listdatafilterssubmission_getsubmission_updatesubmission_deleteThe server instructions also state the scope and the
action_listcheck, for clients without the skills.FormioApiErrornow carriesstatusand the responsebody, so a dry-run 400 reports Form.io's validation messages.Skills
formio-mcp-setup/references/agent-submissions.md(other skills link to it rather than restate it). It covers:reference-dataandtest;action_list+ preview + approval before every write, with a warning for live projects;formio-form-builderoffers to seed an empty dropdown source and to write a test submission after SAVE.formio-applicationgains Step 3.6, which seeds the Resources the planner marks with a newSeed: reference-dataline.formio-actionsdocuments testing an action with a test submission.Live verification
live-submission-behavior.test.tsis opt-in (it skips unlessFORMIO_IT_PROJECT_URL/FORMIO_IT_ADMIN_TOKENare set). Run against Form.io Enterprise 9.9.2 (form.test/sandbox), 5/5 passed, and its scratch forms and user were removed afterwards. It confirmed:POSTfills defaults and server-calculated values without saving or running an action;PUTchanges nothing;metadata.agentis stored verbatim;ownerto the admin's id is still stored as the owner.Test plan
pnpm test: 1200 mcp-server tests (+5 live, skipped without env) and 1093 skill-tests passpnpm lintandpnpm formatclean;openspec validate --strictpassesform.test/sandboxChangeset:
.changeset/agent-scoped-submission-tools.md(@formio/mcpminor,@formio/aiminor).🤖 Generated with Claude Code