suppa-mcp-2
v1.42.6
Published
MCP server for the Suppa platform (modern.suppa.me) — Tasks, Docs/Pages, Entities & Schema, Forms, Automations, and file attachments, with multi-tenant Google auth. Node port of PyPI suppa-mcp.
Maintainers
Readme
suppa-mcp-2
MCP (Model Context Protocol) server for the Suppa 2.0 platform (modern.suppa.me). Allows LLMs (Claude, GPT, Copilot, Cursor, etc.) to manage Tasks, Docs/Pages, Entities & Schema, Forms, Automations, and file attachments through standardised MCP tools — with multi-tenant Google authentication.
This is the Node.js/TypeScript port of the Python suppa-mcp package, published to npm as suppa-mcp-2.
Quick start
Run directly with npx
npx suppa-mcp-2Or install from npm
npm install -g suppa-mcp-2No API key needed to start — the server authenticates interactively via the suppa_login tool (see Authentication).
The suppa command
The same 104 operations the MCP server exposes are available as a command, for shells, scripts and CI:
npm install -g suppa-mcp-2
suppa help
# Or without a global install, use --package to select the CLI bin:
npx --package=suppa-mcp-2 -- suppa help
suppa use-tenant --tenant modern-expo
suppa search-records --entity-name Tasks --limit 5Both shells are generated from one table, so neither can drift from the other.
The command validates arguments against the same schema before sending
anything, and carries the same approval gates: --confirm is never implied.
Nothing about a value is guessed: numbers are plain decimals, a flag is never
read as another flag's value, and --context-value 007 stays the text 007.
JSON, non-ASCII text and typed numbers travel in --args-file <path> or
--args-stdin (a JSON object of arguments) — a shell that rewrites quotes,
as PowerShell does, cannot touch them there. --tenant <alias> points one
call elsewhere; --pretty re-indents the answer. Exit codes: 0 done, 1
refused or not fully done (passed: false, created: false, a non-zero
failed), 2 refused before anything was sent.
For an endpoint no command covers there is suppa api <METHOD> <path> — the raw
call, with the CLI's session and tenant resolution. It exists only in the CLI,
never as an MCP tool, and it is gated because it skips every check the commands
make. Reads (GET, and POST to …/select, …/search, …/query, …/count) are
sent at once. Anything else prints the request and sends nothing. With
--confirm it is sent, but only to a tenant named by --tenant, never to the
remembered one. The permanent delete stays refused. suppa help api has the
flags. In Git Bash, leave off the path's leading slash (core/schema): MSYS
rewrites an argument that starts with / into a Windows path, and the command
refuses that path and says why.
Authentication
There is no API key to paste. Run the suppa_login tool from your MCP client — it opens a browser for Google sign-in, then stores the resulting tokens in the OS keychain (Windows Credential Manager / macOS Keychain / libsecret). The server is multi-tenant: log in to more than one Suppa tenant and switch with suppa_use_tenant.
Credentials stay on your machine, are sent only in the Authorization header to your configured SUPPA_BASE_URL, and are never exposed to the AI agent. A SUPPA_API_KEY env var is an optional CI-only alternative.
Configuration
All variables are optional — the server works out of the box and authenticates via suppa_login.
| Variable | Default | Description |
|---|---|---|
| SUPPA_BASE_URL | https://modern.suppa.me | Platform base URL (the active tenant) |
| SUPPA_API_KEY | (none) | JWT / API key; skips interactive login when set (CI) |
| SUPPA_REFRESH_TOKEN | (none) | Refresh token; paired with SUPPA_API_KEY for long-lived CI sessions |
| SUPPA_LANG | en | Language (en / uk) |
| SUPPA_LANGS | (auto) | Languages a multi-language title/Name is written into. When unset, resolved from the platform: the languages the tenant has enabled (SystemPreferences.availableLanguages, what the builder UI reads), else the language keys of existing entity titles, falling back to en,uk. Only the 24 Localizations language columns are kept, and en is always included. Set e.g. en,uk to force it. |
| SUPPA_TZ | Europe/Kyiv | Timezone, sent as X-Timezone. Must be an IANA name (Europe/Kyiv, UTC): the platform refuses an offset such as +03:00 on every request, so an invalid value falls back to the default with a warning on stderr. |
| SUPPA_MCP_LOGIN_PORT | 8080 | Local port of the sign-in page (listens on 127.0.0.1 and [::1]). Another port works only once http://localhost:<port> is in the Google OAuth client's Authorized JavaScript origins — ask an admin first |
| SUPPA_MCP_HOME | ~/.suppa-mcp | Directory for the tenant registry |
| SUPPA_HTTP_TIMEOUT | 60 | Per-request timeout, seconds |
| SUPPA_MCP_DEBUG | (off) | 1 logs each request line to stderr |
| SUPPA_MCP_NO_SELFTEST | (off) | Set to skip the install-time self-check |
| SUPPA_MCP_NO_CACHE_REPAIR | (off) | Set to keep the server from removing its own broken _npx/<hash> directory after an incomplete download (see below) |
The install checks itself
Every npm install / npx of this package runs a self-check before anything
else: it confirms each declared bin is on disk, then starts the server in a
child process and makes it speak MCP — initialize, then tools/list. A
healthy install prints one line:
suppa-mcp-2: self-check ok — v1.30.0, 96 tools.A broken one says what is wrong, what the host will show you instead, and the
command that fixes it. This exists because the two faults that reached users —
a bin npx could not choose, and a truncated download missing ajv — were both
invisible to a test suite that only ever drove dist/bin.js directly, which is
the one path an MCP host never takes.
It never fails an install (a package that refuses to install is worse than one
that installs and complains), it touches no network, and it runs its child with
repair disabled so it can never delete the package while npm is still writing
it. It reports on stderr, never stdout — under npx the install and the
server share one pipe, and stdout on that pipe is the JSON-RPC stream.
SUPPA_MCP_NO_SELFTEST=1 turns it off; node dist/selfcheckCli.js runs it by
hand.
One caveat worth knowing. npm 11 does not run lifecycle scripts by default —
it prints 1 package has install scripts not yet covered by allowScripts and
skips them, so on those versions the self-check (and the entity-code guards) do
not run until you allow them:
npm approve-scripts suppa-mcp-2That is npm's policy, not something this package can opt out of. It is why the server also checks itself at startup: an incomplete install is caught there in every case, whatever npm decided about scripts.
When npx leaves a half-downloaded package
An interrupted npx download leaves the package in the cache with files
missing, and the symptom points at the wrong thing: Node cannot resolve a
dependency, the process dies before the MCP handshake, and the host reports
CONNECT_TIMEOUT / CONNECTION_CLOSED / Server disconnected — as though the
server had crashed, when it never ran.
The server now recognises this and repairs it. On a module-resolution failure it
removes only its own _npx/<hash> directory and exits, so the next launch
downloads a clean copy; the message on stderr names the missing module and the
directory. It never clears the npm cache as a whole, never touches a global
install or a project's node_modules, and never runs on a healthy start — a
repair is only ever a response to a failure that already happened. A second
failure within a minute leaves the directory alone, so a host that restarts the
server cannot loop.
Use with Claude Desktop
Add to your claude_desktop_config.json:
{
"mcpServers": {
"suppa-2.0": {
"command": "npx",
"args": ["-y", "suppa-mcp-2"]
}
}
}Use with Cursor
Add to .cursor/mcp.json in your project:
{
"mcpServers": {
"suppa-2.0": {
"command": "npx",
"args": ["-y", "suppa-mcp-2"]
}
}
}Use with VS Code (Copilot)
Add to .vscode/mcp.json:
{
"servers": {
"suppa-2.0": {
"command": "npx",
"args": ["-y", "suppa-mcp-2"]
}
}
}Then run suppa_login once per tenant to authenticate.
Windows: prefer node over npx
On Windows npx runs through a .cmd shim, so a host that starts the server that way gets a chain
of processes — cmd → npx → node — and ends only the first one. The server exits on its own
once its client is gone (its stdin closes), and npx follows it; but a direct launch has no chain
to leave behind and starts faster. Install once and point the host at the entry file:
npm i -g suppa-mcp-2
npm root -g # e.g. C:\Users\<you>\AppData\Roaming\npm\node_modules{
"mcpServers": {
"suppa-2.0": {
"command": "node",
"args": ["C:\\Users\\<you>\\AppData\\Roaming\\npm\\node_modules\\suppa-mcp-2\\dist\\bin.js"]
}
}
}Update with npm i -g suppa-mcp-2@latest; the path stays the same.
Which version is running (and is it current)
An MCP client keeps whatever build it started with — an npx cache or a
node_modules copy — for the whole session. Publishing a fix changes nothing
for that session, so "the fix isn't working" and "I'm running last week's
server" look identical from the inside. Two mechanisms keep them apart:
suppa_version— read-only, asks the npm registry live, and reports the running version, the published one, and the upgrade command. Ask it first whenever a tool or behaviour you expect is missing.suppa_health_checkalso carriesversionin every report, so a bug report names its build without anyone remembering to ask.- An automatic notice. The server checks the registry in the background
(detached, cached for 6h, 4s timeout, silent when offline — startup never
waits). If the running build is behind, the next session's instructions open
with an
OUTDATEDbanner telling the agent to say so in its first reply, and to restart the client after updating.
Upgrading: npm i -g suppa-mcp-2@latest, or pin suppa-mcp-2@latest in the
client's MCP config — then restart the client. Without the restart the old
process, and its old code, keep serving the session.
On the source side, the version is stated in package.json only.
scripts/check-version-literals.mjs runs inside npm test (and so inside
prepublishOnly) and fails the build if a version string is typed into src/:
the handshake once announced a hardcoded 1.6.1 for every release after it,
which made the one diagnostic question unanswerable.
Agent workflow
When an AI agent connects, the server provides built-in instructions describing the correct workflow. Auth first, then discover schema before reading or writing data:
┌─────────────────────────────────────────────────────────┐
│ 0. AUTHENTICATE (once per tenant) │
│ suppa_login — Google browser sign-in │
│ suppa_use_tenant — switch active tenant │
├─────────────────────────────────────────────────────────┤
│ 1. DISCOVER ENTITIES │
│ suppa_list_entities(search?: "task") │
│ suppa_search_entities — fuzzy-find + record count │
├─────────────────────────────────────────────────────────┤
│ 2. DISCOVER FIELDS │
│ suppa_describe_entity(entity_name) │
│ suppa_list_field_types / suppa_list_tabular_parts │
├─────────────────────────────────────────────────────────┤
│ 3. READ DATA │
│ suppa_search_records / suppa_get_record │
│ suppa_search_tasks / suppa_get_task / … │
│ suppa_list_docs / suppa_read_page / suppa_get_blocks │
│ suppa_list_automations / suppa_get_automation │
├─────────────────────────────────────────────────────────┤
│ 4. WRITE DATA (destructive/edit tools are confirm-gated)│
│ suppa_create_record / suppa_update_record │
│ suppa_copy_record / suppa_restore_record │
│ suppa_create_entity / suppa_add_field │
│ suppa_create_automation / suppa_publish_automation │
└─────────────────────────────────────────────────────────┘Important: discover with
suppa_list_entities→suppa_describe_entitybefore reading or writing — field names, types, and filters vary between entities. Edit/delete tools return a confirmation preview first and only apply when re-called withconfirm=true.
What the server does not send back
A tool result is not paid once: the whole conversation is re-sent on every turn, so a long string returned early is charged again on every later turn and is still occupying the window when compaction fires. These bytes are therefore removed on the way out — each chosen because it carries nothing the reader does not already hold or is not told:
| Removed | Why it is safe |
|---|---|
| $readAccess, $updateAccess, $instanceAccess | Lists of field names, identical on every row of an entity, read by nothing in this server. Suppressed at the source with getAccessByFields: false where the endpoint documents the flag, and stripped by name in makeRequest everywhere else. Two bounds, both deliberate: only these three key names (never a blanket $-prefix rule, which would silently eat a future key that matters), and only at record level — the response object, and each row of a returned array. The strip never descends into a value, so a json-typed field holding data that happens to use these names is left exactly as stored. |
| Long values in a confirmation preview | proposed_changes was built by copying the tool's own arguments back out. The value is verbatim in the same tool call, so it is named and sized instead: <4231 chars — unchanged from this call's arguments>. Short values stay verbatim, and target — the half that says what is about to change — is never touched. |
| Long values a write echoed back | An insert with no returning projection answers with the whole stored record, so the HTML just uploaded returns with it. Replaced only when byte-identical to what was sent, and the marker says what was checked — <4231 chars — identical to the value this call wrote>, not a claim about what you typed (a task description passes through wrapHtml() on the way out). If the platform normalised the markup or filled a default, the stored value differs — that difference is real information and comes back in full. |
| Schema reads (suppa_describe_entity) | A field lists only what departs from the platform defaults: constraints that are null, flags that are false/0 and options at their default (canGroup/canSort/showInTable/editFromTable/canFilter true, readOnly/searchable false) are omitted, and a title keeps its language keys only. The result leads with a _legend stating exactly that rule, and full=true returns the platform's raw schema. Measured on a live tenant: Tasks 43,316 → 16,687 characters, Users 14,829 → 4,858. suppa_list_entities reduces each title the same way (24,840 → 13,132 for 202 entities; full=true keeps it whole). |
| deletedAt: null on expanded relations | The platform adds deletedAt to every relation it expands — null on every live row, nine times per task. Dropped only inside relations named by a projection this server wrote (tasks, docs, forms, automations), never inside a json value and never where the projection asked for it; a non-null value — the related row is deleted — always comes back. A projection the caller supplied (suppa_search_records) is returned as sent. |
| A stage's status object | suppa_list_workflows / suppa_list_stages return status as its value ("completed") instead of the expanded enum row; the value is the one word every stage decision reads. 68,565 → 41,119 characters for 162 workflows. |
| A task's second description | suppa_get_task drops plainDescription when it is exactly htmlDescription with the tags removed — the HTML is what an edit has to send back, and the text is one strip away. When the two differ, both come back. |
The key is always kept, so "the field was written" is never in doubt. A marker is not the stored text: quote the value to the user from your own arguments.
The same discipline applies to what every session pays before the first call. The instructions block is budgeted (test/instructionsBudget.test.ts), and tools/list goes out without the JSON-Schema boilerplate no model reads — the $schema URI on each tool, "default": null on optional parameters, the two-branch anyOf where a type array says the same — while every property, required list, enum, non-null default and description is kept and compared against the SDK's own output in test/toolsListEconomy.test.ts. Measured on the shipped listing: 103,058 characters for 89 tools uncompacted, 95,074 compacted; 100,867 for the 93 tools of 1.27.0, of which the four custom-field tools are 5,007. The ceiling in that test was raised from 96,000 to 101,500 deliberately, in the same commit and with the arithmetic in its comment.
Deletes are reversible — permanent delete is blocked
This server performs reversible deletes only. Every delete tool calls the platform's
soft delete, POST /core/data/:entity/remove, which sets deletedAt and leaves the row in
the table; suppa_restore_record clears it again.
The platform's permanent delete — POST /core/data/:entity/delete, where "the rows leave
the table and cannot be restored" — is deliberately unavailable here, and the block is
not a tool-level check that a future tool could miss: src/httpClient.ts refuses that route
shape at the single choke point every request to a 2.0 tenant passes through — makeRequest
and makeRequestRawBytes — before any fetch. No tool, parameter, confirm=true or direct
makeRequest call can reach it, and an attempt raises
REFUSED BY POLICY — permanent delete is not available through this server. …
Use the reversible pair instead: /remove … undone with suppa_restore_record.Neighbouring routes are untouched: /remove, /restore, /copy/:id,
/core/builder/:entity/remove-fields and an entity legitimately named DeleteRequests all
work normally. Erasing data for good is an admin action in the platform's own UI.
Custom fields
A tenant can add columns to an entity from the running product, and they are scoped to a context — in practice, one project. The pieces:
| Piece | What it is |
|---|---|
| options.customFields on the entity | Turns the feature on and creates the {Entity}CustomFields extension table. suppa_update_entity_options. |
| A set (CustomFieldSets) | Entity + context field + context value, e.g. Tasks.project = "2556". An ordinary record — the generic tools reach it. |
| A link row (CustomFieldSetFields) | Puts one field in one set, with required / readOnly / defaultValue. Also an ordinary record. |
| The values | Live on the OWNER record under the reserved customFields key. |
suppa_update_entity_options("Tasks", '{"customFields": true}', confirm=true)
suppa_add_custom_field("Tasks", "budget", "numeric", context_field="project", context_value=2556)
suppa_update_record("Tasks", 900, '{"customFields": {"budget": 600}}', confirm=true)
suppa_get_record("Tasks", 900, '{"id": true, "customFields": {"budget": true}}')The confirm=true on the write is not optional: every tool that changes existing data
returns a confirmation preview first and applies nothing until it is re-called with it.
Five things worth knowing before the platform tells you the hard way, all verified against a live 2.0 tenant. The first two are refusals that name the next step; the rest are behaviour:
- A custom field must belong to a set. Pass
context_field+context_value(the set for that context is created if it has none) orset_ids. Neither is HTTP 400. - A custom field cannot be created NOT NULL on this platform build, in either form — so
suppa_add_custom_fieldhas norequiredparameter at all. Per-context obligation lives on the link row instead: setrequiredon theCustomFieldSetFieldsrecord. - One field can live in SEVERAL contexts. The link row is the membership, so the same
column and the same values are offered in each —
suppa_link_custom_fieldadds or removes that membership. Verified live: a field resolved in two contexts at once and a record in the newly linked one accepted a value for it. readOnlyanddefaultValueon the link row are NOT enforced by the data API — an update through a readOnly link stored the value, and a record created without one did not receive the default (both verified live).requiredIS enforced, but only when the record's custom-values row is written: a create that omits thecustomFieldsenvelope entirely still succeeds./schemalags up to a minute behind an add or a remove;suppa_resolve_custom_fieldsis current immediately.suppa_add_custom_fieldconfirms itself through resolve when it was given a context, and says so when the field it just created is not in the answer. The other three writers return the platform's own rows — read them back with resolve, not with a schema call, or you will be told the old field list for the next minute.- Removal is reversible.
suppa_remove_custom_fieldsoft-deletes the metadata and the platform renames the column to<name>_<timestamp>rather than dropping it, sosuppa_restore_custom_fieldbrings the field and every value back. The NAME is freed (entity-builder, 2026-09-25): a new field can take it, but that field starts empty, and the removed one can then not be restored while its twin lives.suppa_apply_custom_fieldstherefore never re-creates a removed field — it reports it underparked. - Restoring a record restores its values with it. Deleting a record soft-deletes the row
holding its custom values; the platform's own restore does not clear that row, which reads
back as stale values (or as none, with
include_deleted_relations=false) and makes the next write create a second values row.suppa_restore_recordrestores both — its confirmation preview names the second table, and it leaves a{Entity}CustomFieldsalone when the tenant has an entity of that name (the platform keeps its own extension tables out of the schema listing, so a listed one is the tenant's).
Two rules that are easy to get wrong
An enum's values belong to the group named by the field's subType. That is the only
binding the platform enforces — the value picker filters on it and the enum validation checks
it. The familiar Entity.field shape is a client convention, not something the backend
generates or parses; live tenants also carry groups called Salutation or department. So
for a field that already exists, read the name, never rebuild it:
suppa_add_enum_values(values="high:High,low:Low", entity_name="TasksCustomFields", field_name="stage")enum_name on its own stays for a group whose field does not exist yet. Passing both, with
a name that disagrees with the field, is refused rather than silently preferred — values
written under the wrong name are invisible to the field.
An entity name is at most 30 characters, letters, numbers and underscores. The platform
types it as @Length(1, 30) and the rename path applies the same bound, so
suppa_create_entity refuses a longer one before sending anything. The {Name}CustomFields
extension table is created outside that check and is not subject to it.
Available tools
104 tools across the areas below.
Session & identity
| Tool | Description |
|---|---|
| suppa_get_me | Current authenticated user's profile |
| suppa_login | Google browser sign-in for a tenant (stores tokens in OS keychain) |
| suppa_use_tenant | Switch the active tenant (alias / host / URL) |
| suppa_list_tenants | List known tenants + which is active / authenticated |
| suppa_logout | Remove stored credentials for a tenant |
| suppa_diagnostics | Validate the whole multi-tenant setup |
| suppa_health_check | Config + upstream connectivity check (reports version) |
| suppa_version | Running build vs newest published, and how to upgrade |
| suppa_validate_entity_code | Run the schema gate over an app repo before calling entity code done |
Tasks
| Tool | Description |
|---|---|
| suppa_search_tasks | Search tasks (my / active / overdue / due-today filters, paginated); task_ids reads several known tasks in one call; fields_json narrows each row |
| suppa_count_tasks | Count tasks matching filters |
| suppa_get_task | Get a single task (full details, or only fields_json) |
| suppa_create_task | Create a task |
| suppa_update_task | Update a task (confirm-gated) |
| suppa_delete_task | Delete a task (confirm-gated) |
| suppa_move_task | Move a task to another stage (confirm-gated) |
| suppa_close_task | Close a task (confirm-gated) |
| suppa_add_comment | Add a comment (supports @mentions) |
| suppa_get_comments | Get comments on a task |
| suppa_attach_file | Attach a local file to a task |
| suppa_list_workflows | List Task workflows |
| suppa_list_stages | List stages of a workflow |
| suppa_list_task_types | List task types |
| suppa_search_users | Search users (feeds assignee / mentions) |
Docs & pages
| Tool | Description |
|---|---|
| suppa_list_docs | List docs (fields_json narrows each row) |
| suppa_get_doc | Get a doc (page tree included, or only fields_json) |
| suppa_create_doc | Create a doc |
| suppa_update_doc | Update a doc (confirm-gated) |
| suppa_delete_doc | Delete a doc (confirm-gated) |
| suppa_list_pages | List pages of a doc (fields_json narrows each row) |
| suppa_get_page | Get a page (metadata, or only fields_json) |
| suppa_create_page | Create a page |
| suppa_update_page | Update a page (confirm-gated) |
| suppa_delete_page | Delete a page (confirm-gated) |
| suppa_get_blocks | Get raw blocks of a page |
| suppa_read_page | Read a page rendered to text |
| suppa_create_blocks | Create blocks from a JSON array (11 block types) |
| suppa_insert_block | Insert a block at a position |
| suppa_update_block | Update a block (confirm-gated) |
| suppa_delete_block | Delete a block (confirm-gated) |
| suppa_reorder_blocks | Reorder blocks on a page |
Entities & records
| Tool | Description |
|---|---|
| suppa_list_entities | List entities (tables) |
| suppa_describe_entity | Field definitions + types for an entity (compact by default; full=true for the raw schema) |
| suppa_list_tabular_parts | List tabular parts (ТЧ) of an entity |
| suppa_search_records | Search / filter records in any entity — nested AND/OR groups, auto-diagnoses an empty result (deleted_scope finds soft-deleted rows) |
| suppa_validate_select | Check a select/filter payload against the entity without running it — names the field that does not resolve |
| suppa_query | Run one read-only SuppaSQL statement (SELECT/WITH) with the caller's read permissions — check a SQL chart or table source before writing the form |
| suppa_get_record | Get a single record (deleted_scope reads a soft-deleted one) |
| suppa_create_entity | Create a new entity (options_json: comments / globalSearch / hierarchy …) |
| suppa_create_tabular_part | Create a tabular part (owned rows) of a parent |
| suppa_add_field | Add a field to an entity (options_json: searchable / showInTable / canFilter …) |
| suppa_update_field | Edit an existing field — name, title, flags, options, encryption (confirm-gated) |
| suppa_change_field_type | Change a field's type, converting every row — the platform's plan first, a refused plan never sent, a loss (locale loss, restoring an earlier column) sent only when accepted by name, a default the new type cannot hold replaced in the same call (confirm-gated) |
| suppa_update_entity_options | Toggle the 20 per-entity features — comments, hierarchy, approvals, customFields … (confirm-gated) |
| suppa_add_enum_values | Add values to an enum field — the group is read from the field's subType |
| suppa_resolve_custom_fields | Which custom fields are live for a record or context, and their values |
| suppa_add_custom_field | Create a custom field in one context (its set is created if missing) |
| suppa_add_custom_fields_batch | Create MANY custom fields in ONE atomic call — one bad entry creates nothing |
| suppa_link_custom_field | Put an existing custom field into another context, take it out, or set required there (confirm-gated) |
| suppa_apply_custom_fields | Apply a custom-field declaration FILE from the repo — idempotent, never deletes, dry run by default |
| suppa_remove_custom_field | Soft-remove custom fields; the column is parked, not dropped (confirm-gated) |
| suppa_restore_custom_field | Put a removed custom field and its values back (confirm-gated) |
| suppa_set_title_field | Set the representative/title field |
| suppa_list_field_types | List supported field types |
| suppa_create_record | Create a record — or a batch, by passing an array of records (one request) |
| suppa_update_record | Update a record (confirm-gated) |
| suppa_delete_record | Soft-delete a record — reversible (confirm-gated); permanent delete is blocked server-wide |
| suppa_restore_record | Undo a soft delete: clear deletedAt on a record (confirm-gated) |
| suppa_copy_record | Duplicate a record, optionally with its files / related records |
| suppa_attach_file_to_record | Attach a local file to any record |
| suppa_list_record_files | List the files attached to a record |
| suppa_download_file | Download one attachment (original, preview, or generated PDF) |
| suppa_download_files_zip | Download several attachments as a ZIP |
Automations
| Tool | Description |
|---|---|
| suppa_list_automations | List automation workflows |
| suppa_get_automation | Get one automation as a normalized graph (nodes + edges) |
| suppa_list_automation_runs | List runs (executions) of an automation |
| suppa_get_automation_run | Get one run with per-node steps + trigger payload |
| suppa_list_automation_node_types | Node-type catalog (authoritative + fallback) |
| suppa_get_automation_node_schema | Parameter schema for one node type |
| suppa_search_entities | Fuzzy-find an entity by name/title (+ record count) |
| suppa_create_automation | Create an automation (pre-flight validated; draft) |
| suppa_update_automation | Update an automation (read-modify-write; confirm-gated) |
| suppa_delete_automation | Delete an automation (confirm-gated) |
| suppa_publish_automation | Publish the draft snapshot (confirm-gated) |
| suppa_unpublish_automation | Deactivate an automation (confirm-gated) |
Forms
| Tool | Description |
|---|---|
| suppa_list_forms | List forms |
| suppa_get_form | Get a form |
| suppa_create_form | Create a form (optionally generated from an entity) |
| suppa_update_form | Update a form (confirm-gated) |
| suppa_generate_form_schema | Generate a grid form schema from an entity |
| suppa_add_field_to_form | Add a field element to a form (confirm-gated) |
| suppa_list_form_field_types | List form element types |
The seven above are Suppa 1.0 forms: they read and write data.formShema (the platform's
misspelling) and the v1 field types. A Suppa 2.0 entity-builder form is a different product —
the schema column on the row since 2.1, 58 component types — and is served by the six below plus the bundled
skills/suppa-form-v2 skill. The first two touch no tenant: they read files inside the package.
| Tool | Description |
|---|---|
| suppa_form_kb | The 2.0 form knowledge base: the 58 element types, one type's prop table, a behaviour note, a reference topic, a template |
| suppa_validate_form_schema | Run the 2.0 form gate over a draft before writing it — 37 structural error codes (the authoring lints live in the source agent, not here), each chart's and each custom chart source's readiness, and widget code: every widget template is compiled with the builder's own compiler (@vue/compiler-dom 3.5.12, the esm-bundler production build the builder runs) into the compiledRender the runtime executes, and checked against the widget sandbox |
| suppa_write_form_draft | Save a checked draft through the builder's own save (PUT /core/forms/:id/schema) — gated first against the live form (what the form already has does not block), guarded by the form's revision, recorded in History; saves the widget renders the gate compiled, never a hand-written one; variables only when the draft lists some. It never publishes |
| suppa_create_form_v2 | Create a 2.0 form (create / update / custom) with its draft in one transaction — the routing flag and default variable the builder adds, optionally filed into a folder (confirm-gated) |
| suppa_form_versions | A form's published versions, the component diff between two of them, and a summary of recent draft saves (read-only) |
| suppa_form_lifecycle | Publish, roll back (restore + publish), discard (unrecoverable) or promote — gated on the form gate (a stored widget with an empty render is refused) and on the version belonging to the form (confirm-gated) |
Migration (Suppa 1.0 → 2.0) & workspace
| Tool | Description |
|---|---|
| suppa_migrate_entities_from_v1 | Migrate 1.0 entities → 2.0 (dry-run / create / gap-fill / reconcile) |
| suppa_read_v1_entities | Read entity list from a Suppa 1.0 tenant |
| suppa_read_v1_entity_props | Read 1.0 entity field definitions |
| suppa_read_v1_enum | Read 1.0 enum values |
| suppa_create_workspace | Create a workspace + nav item |
| suppa_add_menu_item | Add a navigation-menu item |
Prompts
Guided workflows clients can expose as slash commands:
| Prompt | Arguments | Description |
|---|---|---|
| plan_my_day | — | Triage my Suppa tasks into a prioritized day plan |
| build_entity | entity_purpose | Guided design + creation of a new entity, fields, enums, and a form |
| migrate_from_v1 | entity_names? | Guided Suppa 1.0 → 2.0 schema migration (dry-run, review, execute) |
| write_entity_code | entity_purpose | Guided: write v2 application schema code (entities, seeds, registration) instead of API calls |
Bundled agent skill: suppa-entity-code
The package ships an agent skill at skills/suppa-entity-code/ (also installed
via npm) that writes Suppa v2 application schema code — *.entity.ts
classes, *.seed.ts seeds, and the EntityModule.forFeature() registration —
following the platform's declarative-migration rules, where the entity class
is the migration.
Two flows:
- Flow A — design a new entity/seed in an application repo.
- Flow B — port a Suppa 1.0 entity to v2 code, using the same
type-mapping authority as
suppa_migrate_entities_from_v1.
It includes a static validator that catches everything tsc cannot:
node <this-skill-dir>/scripts/validate-suppa-schema.mjs <app-root>
# (installed via npm: node_modules/suppa-mcp-2/skills/suppa-entity-code/scripts/validate-suppa-schema.mjs)Checks: SQL-string defaults, decorator misuse, thunk-less relations, an
inverse selector wrongly passed to @ManyToOne/@ManyToMany (W609) or
pointing at a non-relation property such as id on any of the four relation
decorators (E111), seed idempotency (importKeyFields / $key), forbidden
seed keys, numeric relation ids, reverse @OneToMany references, forFeature
membership, seed dependency order, dist/ autoload paths, tsconfig decorator
flags, unknown @Entity()/@Column() options or field types, and tabular-part rules —
the type: 'tabular-part' + relationEntityName + owner triad, owner
resolving to the right parent, and owner first in importKeyFields
(E108/E109/E110/W607) — validated against the installed @suppa/sdk typings
when resolvable, falling back to the doc tables otherwise
(E101/E102/E107/E501/E502). Options/records/registration arrays delivered via
spread or by reference can't be statically checked and fire a W606 warning
instead. An entity or seed left out of EntityModule.forFeature() fires
E301/E302, naming the module file to edit so the fix is a find-and-add, not a
search. Use the write_entity_code MCP prompt to drive it from any client.
Make the rules unskippable. Stating a rule in a skill, in the server instructions and in a tool description is optional at the moment code gets written — an agent may open none of them, and that is exactly how an entity shipped with restated defaults and four bare relation stand-ins. Install the guards into an application repo:
npx --package=suppa-mcp-2 -- suppa-entity-guards .
# or, without npx:
node node_modules/suppa-mcp-2/skills/suppa-entity-code/scripts/install-entity-code-guards.mjs .It is idempotent, and it refuses to write a hook whose path does not resolve — hooks pointing at nothing behave exactly like no hooks at all.
Adding the package runs this for you. A postinstall installs the guards into
the repo the install was run from, so a developer who never reads this file still
gets them. It refuses far more often than it acts: never when
SUPPA_NO_GUARD_INSTALL is set, never in CI (where hooks do nothing and the CI job
is its own layer), never for a global install, and never for a repo with no sign of
Suppa schema code — no @suppa/* dependency and no *.entity.ts. A package install
is not permission to configure an unrelated project. It also never fails an install:
whatever happens, it exits 0 and prints one line saying what to run by hand.
One caveat worth knowing: npm ci --ignore-scripts skips it, which is common in CI
and in locked-down organisations. There the CI job is the layer that holds.
Five layers, each covering a way the previous one is missed: Claude Code hooks
put the rules in an agent's context before it writes *.entity.ts and the
gate's findings in front of it after, a Stop hook refuses to end a turn
while a changed schema file still fails, .githooks/pre-commit refuses the
commit, a .gitlab/suppa-schema-gate.yml job refuses the merge request, and a
managed section in AGENTS.md / CLAUDE.md carries the same rules to editors
with no hook support. Re-running the installer is safe, --dry-run prints the
plan, and details are in skills/suppa-entity-code/references/enforcement.md.
It also enforces the authoring rules the platform doc does not: a
relation property must carry a TypeScript type that agrees with its decorator
(E114/E115/W610, and W615 when the type names no class at all — any), enum
member keys are PascalCase (W611), no field option may be written at its
platform default (W612), @Enum/@MultiEnum take a global name only when
the enum is actually shared (W613), and a relation target that already exists on
the tenant is declared as a stub: @Entity({ name, key }) + extends
SystemBaseEntity, an empty body, alone in a <name>.stub.entity.ts file,
registered in forFeature(). A bare export class Accounts {} satisfies the
thunk but registers nothing, so the platform cannot resolve the relation — the
target is chased through its import (relative, .js-suffixed or tsconfig-aliased)
into the file that declares it and reported as E116. A stub that kept its fields
is E117, because each field is a column the migration would create on a table
another application owns; the .stub.entity.ts name is what makes that rule
checkable (W617 when it is missing, W616 when stubs share a file).
A stub stands in for a table so a relation can reach it. To add columns to an
entity you do not own — a platform system entity, or another application's — the
platform supports an extension: the entity's existing name, only your own
columns, in <owner>.extension.entity.ts (SKILL.md rule 20,
references/extensions.md). The mode is chosen by ownership at start, so the
filename is what lets the gate apply extension mode's restrictions before the
build does: entity options are ignored there (W618), @OneToMany /
@ManyToManyBackRef (E118) and primary: true (E119) are refused, nullable:
false needs a default (E120), and @Index/@Check may cover only the columns
you declare (E121). A relation to an entity you do not own names its target by
string and is typed by shape — right there, W620 anywhere else.
Those, plus
owner first in a tabular part's importKeyFields (W607) and no inverse on
@ManyToOne/@ManyToMany (W609), are the rule violations — W607, W609,
W610, W611, W612, W613, W615, W616, W617, W618, W619, W620, W621, W622, W623, W625 — and like errors they exit 1, because a
rule that only warns is a rule that gets ignored. --no-strict demotes them for a legacy repo, and says so:
they print as [rule violation, demoted] and the summary ends NOT a clean
run. Advisory warnings — W601-W606, W608, W614, W624 — never block; they report what
the checker could not see. W624 is the one to read rather than skim: it names a
check that could NOT run, which is not the same as a check that passed.
Tabular parts (owned child sub-tables like checklist items or order lines)
and the remove-and-re-add-a-field semantics — a re-added field restores its
old data because the key defaults to <EntityName>.<fieldName> — are both
covered; see references/tabular-parts.md and references/registration.md.
Development
# Install dependencies
npm install
# Build
npm run build
# Run locally (stdio)
node dist/bin.js
# Test with MCP Inspector
npm run inspect
# Tests
npm test
# Watch mode
npm run devRun the tests through this package, not through npx
npm test and npm --prefix <path>/suppa-mcp-node test both use the vitest in
this package's node_modules. npx vitest does not: with nothing resolvable
locally it downloads the newest major from the registry, and from the repo root
it also never reads this package's vitest.config.ts. It still collects these
test files, and the failures it invents look real — a phantom timeout from
exactly that mistake was believed long enough for a testTimeout bump to be
committed on the strength of it.
Both halves now refuse rather than mislead: vitest.config.ts asserts the
running vitest is the one installed here, and the repo root carries a
vitest.config.mjs whose only job is to stop a run started from there.
Driving a real build against a tenant
npm run live:install -- <harness-dir> # rebuild, pack, install, verify
npm run live:verify -- <harness-dir> # re-check before a long sweepnpm resolves a file: dependency by name and version, so repacking after a
rebuild — same filename, same version — lets npm install decide there is
nothing to do and leave the previous build in node_modules, exit 0, no
warning. A sweep against a stale build reports confidence it did not earn.
live:install names the tarball after a hash of its own contents, clears the
target's node_modules and lockfile, and then compares every packed file
byte for byte against what npm extracted. Only that last step is trusted; the
manifest it leaves behind is what live:verify re-checks later.
Publish to npm
# 1. Make sure you're logged in
npm login
# 2. Build & publish
npm publishThe prepublishOnly script automatically runs tsc + the full test suite before publishing.
Project structure
suppa-mcp-node/
├── src/
│ ├── bin.ts # Entry point — stdio transport
│ ├── server.ts # McpServer + all 95 tool + 4 prompt registrations
│ ├── config.ts # Env / .env config surface
│ ├── store.ts # Credentials in the OS keychain (@napi-rs/keyring)
│ ├── registry.ts # Non-secret tenant registry (~/.suppa-mcp/tenants.json)
│ ├── tenants.ts # Tenant context, active tenant, resolve, diagnostics
│ ├── auth.ts # JWT refresh (single-flight) + browser Google login
│ ├── refreshLock.ts # one refresh per tenant across processes (~/.suppa-mcp/locks)
│ ├── httpClient.ts # fetch client: retry, envelope unwrap, 401→refresh→retry
│ ├── utils.ts # filters, dates, JSON params, multipart, confirm gate
│ └── tools/
│ ├── tasks.ts docs.ts entity.ts forms.ts
│ ├── automation.ts # read + write automation tools
│ ├── migrate.ts nameMap.ts v1Fields.ts
│ └── workspace.ts
├── skills/suppa-entity-code/ # bundled agent skill: write v2 schema code + validator
├── test/ # vitest suite (ports the Python test suite)
├── scripts/ # live read-only / write verify scripts
├── package.json
├── tsconfig.json
├── vitest.config.ts
└── README.mdLicense
MIT — see LICENSE.
