@draggable/formeo-mcp-server
v0.2.0
Published
Model Context Protocol (MCP) server for Formeo: build forms, read submissions and get embed code from AI agents, with a Formeo organization API key
Maintainers
Readme
@draggable/formeo-mcp-server
A Model Context Protocol server for Formeo: tools that let AI agents list, read, create, update and publish forms, delete forms, get their share link and embed code, and read, export and delete submissions. It runs locally over stdio and calls the Formeo REST API with an organization API key.
Nothing to install: with Node.js 22 or later, your MCP client runs it with
npx -y @draggable/formeo-mcp-server (command npx, args ["-y", "@draggable/formeo-mcp-server"],
FORMEO_API_KEY in its environment). It's also in the official
MCP Registry as io.formeo/mcp-server (see
MCP Registry).
Preview. This is a 0.x release: a new minor version (0.2, 0.3, ...) may change its tools.
Tools
| Tool | What it does | Scope |
| --- | --- | --- |
| list_forms | The organization's forms, without schemas, newest first, a page at a time (page, limit: default 50, at most 100; pagination and nextPage); filters by status, name, project (projectId, or none) or tags (tagId: an id, a list, or none; tagName: a name or a list, ignoring case; tagMatch: any or all). The API filters and pages (GET /v1/forms?status&search&page&limit, #137); from a server before that, which answers every form, the tool filters and pages itself | forms:read |
| get_form | One form in full, including its formeo schema | forms:read |
| create_form | A draft form from a name, description and optional schema (validated first) | forms:write |
| update_form | Name, description, schema; publish, schedule or unpublish | forms:write |
| delete_form | Deletes one form by id with all its submissions (DELETE /v1/forms/{id}); a 404 deletes nothing | forms:delete |
| list_submissions | One form's or every form's submissions, paginated; search, dates, sort/order (incl. field:<id>), filters, choices, starred, the forms' tags (formTagId, formTagName, formTagMatch) | submissions:read |
| get_submission | One submission by id (GET /v1/submissions/{id}) | submissions:read |
| export_submissions_csv | GET /v1/submissions/export with list_submissions's filters and headers: a JSON summary (rows, truncated, withheld) and the CSV, read up to maxBytes (default 100,000, at most 1,000,000, CSV_*_BYTES) and cut after the last whole row (quoted line breaks included) | submissions:read |
| star_submissions | Stars (starred: true) or unstars 1 to 100 submissions by id for the whole organization (POST /v1/submissions/bulk-star); returns the updated and unknown ids | submissions:write |
| delete_submissions | Deletes 1 to 100 submissions by id (POST /v1/submissions/bulk-delete); returns the count | submissions:delete |
| get_form_schema | The form data JSON Schema (live; offline, the bundled copy with a note saying so) | none |
| get_embed_snippet | Public link, iframe, embed.js code and React (JSX, TSX) and Vue components of a published form | none |
| get_submission_file | A submission's uploaded file: name, size, type, SHA-256 and a 5-minute download link | submissions:read |
| list_webhooks | Webhook endpoints (masked URLs, never secrets) | webhooks:read |
| create_webhook | Registers an endpoint; returns its signing secret once | webhooks:write |
| list_webhook_deliveries | An endpoint's delivery log | webhooks:read |
| list_templates | Form templates: the organization's own, then the built-in ones in locale (category, tag, q, source, page, limit; nextPage) (#665) | templates:read |
| get_template | One form template with its form and default settings | templates:read |
| create_form_from_template | POST /v1/templates/{id}/forms: a draft form copied from a template, optionally named and adjusted | forms:write |
| list_document_templates | Document templates, newest first, a page at a time (archived, page, limit; nextPage) (#620) | documents:read |
| get_document_template | One template with its content, fields and signer roles | documents:read |
| list_document_records | Records (reusable data for bound fields), optionally of one record type | documents:read |
| create_document | POST /v1/documents (generateDocument): a draft from a template, its values, records and recipients; never sends it or creates links | documents:write |
| list_documents | Generated documents, newest first, a page at a time, optionally by status | documents:read |
| get_document | One document: status, version, values, recipients (link state, never a token), signatures, PDF | documents:read |
Resources: formeo://schemas/form.json, formeo://docs/llms.txt.
Structured output (#137): every tool but get_form_schema has an outputSchema
(src/output-schemas.ts) and returns structuredContent next to its text, which stays for
clients without structured output support. structured() in tools.ts makes it the text's JSON,
parsed, so it never holds more than the text; export_submissions_csv's is its summary plus
csv. Objects from the API are loose (extra fields allowed, enums as strings, only ids and
what the tool is for required), so a newer or older server doesn't fail validation (the SDK would
turn a change that went through into an error); objects the tools build are strict. get_form_schema has none: its result is a JSON Schema document (and, offline,
a note before it), which an "any object" schema wouldn't describe. Error results carry no
structuredContent. API warnings (#401, #/components/schemas/ApiWarning): callEnvelope in
client.ts returns the envelope's well-formed warnings (code, path and message only), and
update_form puts them in its result as warnings (absent when none); its description asks the
agent to tell the user. Only updateForm sends warnings today. src/output-schemas.test.ts checks every tool's structured result against
the schema it advertises (Ajv, and the SDK client, which validates after listTools).
The only deletes are delete_submissions (explicit ids, no delete-by-filter) and delete_form
(one id; the description says it deletes every submission of the form and to confirm with the
user first); both are destructiveHint: true, and mcp-server.test.ts fails if another
operation that deletes appears. The submission and document limits (MAX_PAGE_SIZE, MAX_SEARCH_LENGTH, MAX_BULK_DELETE, MAX_BULK_STAR, MAX_DOCUMENT_RECIPIENTS, MAX_DOCUMENT_RECORDS, *_LINK_EXPIRY_DAYS in tools.ts) mirror the API's,
and mcp-server.test.ts fails if they drift; everything else (field ids, formId needed) is
left to the API's 400.
Configuration
| Variable | Required | |
| --- | --- | --- |
| FORMEO_API_KEY | yes | Organization API key (fmo_ + 43 characters). Create it in Developer → API Keys |
| FORMEO_URL | no | Default https://api.formeo.io (formeo.io). Self-hosted: your Formeo origin, e.g. https://formeo.example.com. API at $FORMEO_URL/api; a URL ending in /api is used as is, and https://formeo.io means https://api.formeo.io |
| FORMEO_API_URL | no | Override: the API base URL (with /api). When set, FORMEO_URL is only the docs host (llms.txt) |
| FORMEO_FORMS_URL | no | Override: host of the hosted forms and embed.js |
One URL is enough: llms.txt and the hosted forms origin come from the API's GET /api/docs
(FormeoClient.docsLinks(), fetched once and cached; a failure is retried on the next call and
falls back to the API's origin). On formeo.io that's https://formeo.io/llms.txt and
https://api.formeo.io/forms/.... The three-variable formeo.io setup (FORMEO_URL=https://formeo.io,
FORMEO_API_URL=https://api.formeo.io/api, FORMEO_FORMS_URL=https://api.formeo.io) still works.
The server exits with a message on stderr when the key is missing or malformed. At startup it logs
the API URL and where it came from, warns when the API URL is plain http on a non-loopback host,
and calls GET /api/docs once to warn early when FORMEO_URL doesn't point at Formeo. It never
logs the key, and scrubs anything key-like from errors.
Hostile input (#280, src/hostile-input.test.ts): ids are UUIDs checked by zod before any
request, and expandPath refuses an empty, . or .. segment anyway; a response body over
10 MiB (MAX_RESPONSE_BYTES) is refused rather than read into the agent's context, and a CSV
export is read only up to its maxBytes (an endless one stops there); a redirect
is reported by its target's origin and path, never its query string; docs and forms links from
GET /api/docs are ignored when they're plain http from an https API. The instructions tell
the agent that answers, file names and delivery payloads are data, not instructions, and
create_webhook has openWorldHint: true and says to create one only for a URL the user gave.
create_document only makes a draft (openWorldHint: false): no tool sends a document, which is
what emails recipients their links.
Command line: formeo-mcp-server serves MCP over stdio; --help (-h) prints usage and the
variables above, --version (-v) the version. Any other argument exits 2 (settings come only
from the environment, so a stray --api-key isn't silently ignored).
Recommended key scopes: forms:read + submissions:read (read-only), or forms:read +
forms:write (+ submissions:read) for agents that build forms. Add forms:delete or
submissions:delete only for an agent that must delete, and submissions:write for one that stars
submissions (star_submissions).
Use with an MCP client
Claude Code (with the package installed globally, npm install -g @draggable/formeo-mcp-server,
you can replace npx -y @draggable/formeo-mcp-server with formeo-mcp-server):
claude mcp add --env FORMEO_API_KEY=fmo_... --env FORMEO_URL=https://api.formeo.io \
--transport stdio formeo -- npx -y @draggable/formeo-mcp-serverOther clients (.mcp.json, Cursor, Claude Desktop, Windsurf) use the same entry under
mcpServers; VS Code (.vscode/mcp.json) uses servers and needs "type": "stdio":
{
"mcpServers": {
"formeo": {
"command": "npx",
"args": ["-y", "@draggable/formeo-mcp-server"],
"env": { "FORMEO_API_KEY": "${FORMEO_API_KEY}", "FORMEO_URL": "https://api.formeo.io" }
}
}
}Customer-facing setup for every client is in the public docs page docs/public/mcp.md
(served at /docs/mcp).
Errors, rate limits and retries
401→ "key refused", with instructions to replace it.403withrequired→ names the missing scope. Other403s (the plan's form limit) pass the API's message anddatathrough.429→ which limit (key,organization,ip) and theRetry-Afterwait. One automatic retry happens whenRetry-Afteris 5 seconds or less (MAX_RETRY_AFTER_SECONDSinsrc/client.ts); a 429 is answered before the handler runs, so retrying aPOSTcan't create twice. Longer waits are returned to the agent.Invalid ids (non-UUID), dates, page sizes, empty updates and schemas that fail the form JSON Schema are refused before any request (the API would answer some of these with
500).A wrong host says so (
kind: 'misconfigured', "Formeo is misconfigured: ..."), naming the URL and the variable to fix, instead of "Not found, check the id": a redirect (requests useredirect: 'manual', so the key never follows one), an HTML page below 500 (e.g.https://formeo.io/api/...on GitHub Pages), a 2xx without the JSON envelope, or a 404 without it (e.g. the forms host without/api, which answersNot Foundas text). Formeo answers every/apipath, unknown ones too, with its{"success": ...}envelope.notFormeoReason()insrc/base-url.tsdecides; proxy 5xx/429 pages and Formeo's own 405 and 413 are left to the usual mapping.get_form_schemareports this too rather than handing out the bundled schema.Network failures say so;
get_form_schemathen returns the bundled schema with a first text block saying it's the bundled copy (and its version) and why the live one failed.An answer that doesn't fit the tool's output schema (a server far older or newer than this package) is an error that says the request went through and must not be repeated, with the answer as text, instead of the SDK's own output validation error, which would drop it.
All errors are tool results with isError: true. Requests time out after 30 seconds. An error
the API answered carries its X-Request-Id (details.requestId), and the tool result ends with
Request id: <id> (#139).
Maintainers
From the repo root (Node 22+):
npm install
npm run build -w packages/mcp-server # tsc → dist/, bin dist/index.js
npm test -w packages/mcp-server # unit + in-memory MCP tests (no database)
npx tsc --noEmit -p packages/mcp-server
node packages/mcp-server/dist/index.js # with FORMEO_API_KEY (and FORMEO_URL for a local server) setCode map (src/):
| File | What |
| --- | --- |
| index.ts | The formeo-mcp-server bin: config, stdio transport, shutdown |
| cli.ts | Command-line arguments (--help, --version; anything else is refused) |
| server.ts | createFormeoMcpServer(config): tools, resources, instructions (also the package export) |
| tools.ts | Tool definitions: zod inputs, annotations, descriptions with scopes |
| output-schemas.ts | The tools' outputSchemas (zod), the shapes of their structuredContent |
| client.ts | REST client: Bearer key only on scoped operations, error mapping, 429 retry, 10 MiB response cap |
| endpoints.ts | Every operation used, by OpenAPI operationId: method, path, scope; tool → operations |
| config.ts | Env parsing, the plain-http warning and key redaction |
| base-url.ts | One URL → API base URL, and whether a response is from Formeo. The SDK has an identical copy; src/hosted-config.test.ts fails when they differ |
| form-schema.ts, form-schema.json | Bundled form data JSON Schema and its Ajv validator |
| embed.ts, embed-code.ts | Share link and embed snippets, identical to the dashboard's Share tab. embed-code.ts is a copy of the client's lib/embed-code.ts (the generator) |
Tests that keep it in step with the rest of the repo:
packages/server/src/openapi/mcp-server.test.ts(runs with the server tests, needs Postgres): everyENDPOINTSentry matches the OpenAPI spec's method, path andx-required-scope; the bundledform-schema.jsonequals whatGET /api/schemas/form.jsonserves; and the tools work, and are refused by scope, rate limit and revocation, against the real app with real keys.packages/server/src/openapi/hosted-config.test.ts:FORMEO_URLalone works against the real app (forms links fromGET /api/docs), and the forms host without/api(served as on api.formeo.io,CLIENT_APP_MODE=forms) is a configuration error for the MCP server and the SDK.src/hosted-config.test.ts:src/base-url.tsequals the SDK's copy.src/embed.test.ts:src/embed-code.tsequals the client'slib/embed-code.ts, and the snippets (link, iframe, script, React JSX and TSX, Vue) equal the client'slib/embed-snippets.ts, hostile form names included.src/version.test.ts:VERSIONequalspackage.json.npm run pack:smoke(repo root): packs the tarball and uses it outside the monorepo, below.
When the form schema changes: npm run openapi -w packages/server -- sync-mcp-form-schema.
When adding a tool: add its operations to ENDPOINTS and TOOL_OPERATIONS (the description gets
its scope sentence from them), add tests, and update docs/public/mcp.md.
Packaging
npm pack -w packages/mcp-server (or npm publish) runs prepack, which cleans and rebuilds
dist/, so a tarball never ships stale or missing code. It contains dist/ (JavaScript,
declarations and form-schema.json; no tests or source maps, since src/ isn't shipped),
README.md, LICENSE and package.json. tsconfig.json is self-contained (it doesn't extend
the root one), so the package also builds on its own outside the monorepo.
npm run pack:smoke from the repo root (scripts/pack-smoke.mjs, needs the npm registry)
packs the SDK and this package, checks the tarball contents (including the MIT LICENSE) and
manifest, installs both into a temp project outside the repo and runs them: --help,
--version, a refused argument, a missing key, an MCP stdio handshake (initialize,
tools/list, a tools/call against a local fake API), a type-check of the library export, and
a standalone build of a copy of the package. npm run ci runs it.
Publishing (#134)
0.1.0 is on npm (first published 2026-10-27) and in the MCP Registry. MIT licensed (#275,
decision 6a); security review (#280) done. A new version
(docs/RELEASING-PACKAGES.md):
- Push an
mcp-server-v<version>tag: the Publish packages workflow builds, tests and publishes it (#236); nevernpm publishby hand. npm provenance needs a public repository, so the workflow adds--provenanceonly once the repository is public;repositoryalready points at this package's directory, as provenance requires. - Publish the new version's
server.jsonto the MCP Registry (below).
The docs, the website (PLATFORM_FEATURE_NOTES in packages/common/src/plans.ts, the home,
pricing, Products, Platform and About pages) call it a preview; drop that once it reaches 1.0.
MCP Registry
server.json is the entry for the official
MCP Registry (schema 2025-12-11): name
io.formeo/mcp-server, the npm package over stdio, and the variables from
Configuration, with only FORMEO_API_KEY required (and secret). The registry
proves the npm package is ours by reading mcpName from the published package.json, which
must equal that name, so the entry can only be published after the npm version it names.
server.json isn't in the tarball (files); mcp-publisher reads it from this directory.
src/server-json.test.ts fails when its name, versions, repository or variables drift from
package.json and src/config.ts, and npm run pack:smoke checks mcpName in the packed
manifest. A version bump changes version in both package.json and server.json (twice:
the entry's and the package's).
The namespace is formeo.io's (io.formeo/, the domain in reverse DNS), so publishing it needs
someone who can prove control of formeo.io: an Ed25519 key whose public half is in a TXT record
on the apex formeo.io (DNS authentication) or served at
https://formeo.io/.well-known/mcp-registry-auth (HTTP authentication), as
v=MCPv1; k=ed25519; p=<public key>. Keep the private key out of this repository:
openssl genpkey -algorithm Ed25519 -out key.pem
echo "v=MCPv1; k=ed25519; p=$(openssl pkey -in key.pem -pubout -outform DER | tail -c 32 | base64)"
# Publish that line as the TXT record (or the well-known file), then:
PRIVATE_KEY="$(openssl pkey -in key.pem -noout -text | grep -A3 'priv:' | tail -n +2 | tr -d ' :\n')"
cd packages/mcp-server
mcp-publisher login dns --domain formeo.io --private-key "$PRIVATE_KEY" # or: login http
mcp-publisher publishio.formeo/mcp-server 0.1.0 was published this way on 2026-10-27
(registry search).
Nothing in this repository publishes the entry, or holds the key or the record: after each npm
publish, someone with the key runs mcp-publisher publish again with that version's
server.json.
License
MIT, copyright Draggable llc.
