# suppa-mcp-2

MCP (Model Context Protocol) server for the **Suppa 2.0** platform ([modern.suppa.me](https://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`](https://pypi.org/project/suppa-mcp/) package, published to npm as **`suppa-mcp-2`**.

[![npm version](https://img.shields.io/npm/v/suppa-mcp-2)](https://www.npmjs.com/package/suppa-mcp-2)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

---

## Quick start

### Run directly with npx

```bash
npx suppa-mcp-2
```

### Or install from npm

```bash
npm install -g suppa-mcp-2
```

No API key needed to start — the server authenticates interactively via the `suppa_login` tool (see [Authentication](#authentication)).

---

## The `suppa` command

The same 96 operations the MCP server exposes are available as a command, for
shells, scripts and CI:

```bash
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 5
```

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

---

## 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 already used by existing entity titles), falling back to `en,uk`. Set e.g. `en,uk` to force it. |
| `SUPPA_TZ` | `Europe/Kyiv` | Timezone |
| `SUPPA_MCP_LOGIN_PORT` | `8080` | Local loopback port for the browser login callback |
| `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-2
```

That 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`:

```json
{
  "mcpServers": {
    "suppa-2.0": {
      "command": "npx",
      "args": ["-y", "suppa-mcp-2"]
    }
  }
}
```

## Use with Cursor

Add to `.cursor/mcp.json` in your project:

```json
{
  "mcpServers": {
    "suppa-2.0": {
      "command": "npx",
      "args": ["-y", "suppa-mcp-2"]
    }
  }
}
```

## Use with VS Code (Copilot)

Add to `.vscode/mcp.json`:

```json
{
  "servers": {
    "suppa-2.0": {
      "command": "npx",
      "args": ["-y", "suppa-mcp-2"]
    }
  }
}
```

Then run **`suppa_login`** once per tenant to authenticate.

---

## 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_check` also
  carries `version` in 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 `OUTDATED` banner 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_entity` before 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 with `confirm=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. |

```text
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) or `set_ids`. Neither is HTTP 400.
- **A custom field cannot be created NOT NULL** on this platform build, in either form — so
  `suppa_add_custom_field` has no `required` parameter at all. Per-context obligation lives on
  the link row instead: set `required` on the `CustomFieldSetFields` record.
- **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_field` adds 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.
- **`readOnly` and `defaultValue` on 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). `required` IS enforced, but only when the record's
  custom-values row is written: a create that omits the `customFields` envelope entirely still
  succeeds.
- **`/schema` lags** up to a minute behind an add or a remove; `suppa_resolve_custom_fields`
  is current immediately. `suppa_add_custom_field` confirms 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_field` soft-deletes the metadata and the
  platform *renames* the column to `<name>_<timestamp>` rather than dropping it, so
  `suppa_restore_custom_field` brings the field and every value back. The NAME stays reserved
  while it is parked — a new field cannot take it until the old one is purged, which is not
  something this server can do.
- **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_record` restores both — its confirmation
  preview names the second table, and it leaves a `{Entity}CustomFields` alone 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:

```text
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

99 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_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_update_entity_options` | Toggle the 16 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 two below plus the bundled
`skills/suppa-form-v2` skill. Neither touches a tenant: both 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) |
| `suppa_write_form_draft` | Save a checked draft to a form's `schema` column. Gates first, reads before it merges, creates no version — it never publishes |

### 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:

```bash
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:

```bash
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

```bash
# 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 dev
```

### Run 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

```bash
npm run live:install -- <harness-dir>   # rebuild, pack, install, verify
npm run live:verify  -- <harness-dir>   # re-check before a long sweep
```

npm 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

```bash
# 1. Make sure you're logged in
npm login

# 2. Build & publish
npm publish
```

The `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
│   ├── 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.md
```

---

## License

MIT — see [LICENSE](LICENSE).
