npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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-server

Other 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. 403 with required → names the missing scope. Other 403s (the plan's form limit) pass the API's message and data through.

  • 429 → which limit (key, organization, ip) and the Retry-After wait. One automatic retry happens when Retry-After is 5 seconds or less (MAX_RETRY_AFTER_SECONDS in src/client.ts); a 429 is answered before the handler runs, so retrying a POST can'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 use redirect: '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 answers Not Found as text). Formeo answers every /api path, unknown ones too, with its {"success": ...} envelope. notFormeoReason() in src/base-url.ts decides; proxy 5xx/429 pages and Formeo's own 405 and 413 are left to the usual mapping. get_form_schema reports this too rather than handing out the bundled schema.

  • Network failures say so; get_form_schema then 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) set

Code 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): every ENDPOINTS entry matches the OpenAPI spec's method, path and x-required-scope; the bundled form-schema.json equals what GET /api/schemas/form.json serves; 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_URL alone works against the real app (forms links from GET /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.ts equals the SDK's copy.
  • src/embed.test.ts: src/embed-code.ts equals the client's lib/embed-code.ts, and the snippets (link, iframe, script, React JSX and TSX, Vue) equal the client's lib/embed-snippets.ts, hostile form names included.
  • src/version.test.ts: VERSION equals package.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):

  1. Push an mcp-server-v<version> tag: the Publish packages workflow builds, tests and publishes it (#236); never npm publish by hand. npm provenance needs a public repository, so the workflow adds --provenance only once the repository is public; repository already points at this package's directory, as provenance requires.
  2. Publish the new version's server.json to 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 publish

io.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.