# tnt_mcp — MCP server for JTL-Shop

Lets a merchant connect ChatGPT / Claude / any MCP client to their shop to READ info and
perform a small allowlist of admin actions **without breaking anything**. Actions delegate to
the SAME services the admin panel uses (never reimplemented).

Plugin version **0.16.1** (`info.xml`), JTL-Shop 5.7.0+.

## Status

Installable and in real use. Migrations create `tnt_mcp_token` / `tnt_mcp_audit`
(`Migration20260723000000`), the OAuth tables (`Migration20260724000000`), the request log
(`Migration20260724100000`) and the change journal `tnt_mcp_change` / `tnt_mcp_change_row`
(`Migration20260804000000`). Nothing in the read/auth/audit layer is stubbed:

- `TokenGuard::fromRequest()` resolves a bearer token against `tnt_mcp_token` by **sha256 hash**,
  loads its granted scopes (JSON), records `dLastUsed`, and rejects unknown/revoked tokens
  (deny-by-default).
- `AuditLogger` appends every call to `tnt_mcp_audit`; best-effort, never blocks a call. The audit
  outcome distinguishes `ok`, `ok:<actionId>`, `refused:<reason>` and `failed:<actionId>`.
- `Server::dispatch()` is the single choke point: token guard → per-tool scope → mode guard →
  argument shape → run → audit.
- The argument-shape step is `Mcp\Batch\ArgumentShape::problems()`, the same check `batch dryRun`
  uses, applied to every `tools/call`. Every published schema declares `additionalProperties:false`;
  before this it declared it without meaning it, so an invented or misspelled argument was dropped
  in silence and the tool answered as if it had been honoured (a filter that never ran, reported as
  a result). A refusal reads `invalid arguments: …` and is audited as `denied:args`. Scope and mode
  are judged first, so a token that may not call a tool cannot probe its schema.

Design note: the MCP classes under `src/Mcp` are plain DI classes (no shop statics in the hot
path) so they stay unit-testable without the JTL runtime. `tests/` runs under `phpunit.xml.dist`
once `composer install` has populated `vendor/`.

## HTTP endpoint

The plugin exposes a real front-end route, so an external MCP client connects with a per-shop
bearer token and no admin session:

```
POST https://<shop>/tnt-mcp          Authorization: Bearer <token>
```

- `Bootstrap::boot()` registers the route on `HOOK_ROUTER_PRE_DISPATCH` via `Router::addRoute()`
  and delegates to `Mcp\Http\McpHttpEndpoint` with the live `DbInterface` from `getDB()`.
- Transport: **Streamable-HTTP, JSON response** (single POST → `application/json` JSON-RPC 2.0).
  This is the non-SSE happy path the modern MCP transport allows; it needs no long-lived process
  or shared session store, so it fits JTL's stateless per-request routing. Server-initiated SSE
  streaming is a deliberate follow-up.
- Methods: `initialize`, `ping`, `notifications/*` (acknowledged, 202), `tools/list`, `tools/call`.
  `initialize`/`ping` are open; anything touching tools requires a valid token and is scope-checked,
  mode-checked and audited by `Server::dispatch()`.
- Tools are registered in one place: `Mcp\ToolFactory::enabledTools()`.

### Context budget (the client is a model, and it re-reads all of this every turn)

Two rules the transport enforces, both measured on a live shop:

- **Tool results are sent compact.** `JSON_PRETTY_PRINT` was removed from `toolCall()`:
  `describe_shop_api` was 44 756 B pretty vs 19 132 B compact (57 % whitespace),
  `describe_workflow_capabilities` 48 595 vs 30 729. Indentation is information-free and every tool
  paid for it. `JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE` stay — they are also fewer bytes.
- **`tools/list` shows only what the token's scopes allow.** The full listing is 33 126 B (≈ 8 K
  tokens) and an MCP client re-sends it on every conversation turn; a read-only token drops 12 761 B
  (39 %). This is a context decision, not a security one — `Server::dispatch()` is still the
  boundary, and it still refuses. Listing is deliberately **not** filtered by mode: a destructive
  tool hidden in `limited` mode leaves the model unable to tell the operator what to switch on,
  whereas the call-time refusal names its reason.

### `batch` — many calls in one request

A pseudo-tool, answered by `Server::dispatch()` itself (`Mcp\Batch\BatchRunner`), advertised
alongside the real tools whenever the token may call at least one of them.

```json
{"name": "batch", "arguments": {"calls": [
  {"tool": "save_snippet",    "arguments": {"name": "VipMailer", "code": "return 1;"}},
  {"tool": "create_workflow", "arguments": {"description": "…", "trigger": "dateTime",
                                            "action": "snippet",
                                            "arguments": {"snippetName": "VipMailer"}}},
  {"tool": "set_schedule",    "arguments": {"workflowId": "{{1.workflow.id}}",
                                            "kind": "daily", "atTime": "07:00"}}
]}}
```

- **Up to `BatchRunner::MAX_CALLS` (25) entries, run in order.** Over the cap is an error naming the
  count; nothing runs. One HTTP request holds the whole batch, and a hundred writes behind one PHP
  timeout would fail in the middle with the client unsure of what landed.
- **`{{N.path.to.field}}`** in any string, at any depth, reads the result of entry `N` (zero-based,
  earlier only). A string that is *only* a reference keeps the value's type, so an integer id stays
  an integer. Anything unresolvable is an error for that entry — never a silent empty value, which
  would write a pointer to nothing and report success. Only `{{<digits>.<path>}}` is a reference;
  Smarty's `{$order->cBestellNr}` and a bare `{{name}}` pass through untouched.
- **It grants nothing.** Every entry re-enters `dispatch()`, so scope, `ModeGuard` (including the
  per-call classification `invoke_admin_action` needs) and the audit row happen per entry, exactly
  as if the client had sent them one at a time. The batch adds one further audit row, `batch:5/6`,
  carrying the tool names only — the per-entry rows already hold the arguments.
- **No rollback, and it does not pretend otherwise.** A failing entry never discards the entries
  that already succeeded; `results[i]` carries `ok` plus that tool's own payload (minus its `hint`,
  which the batch says once at the end). `stopOnError: true` stops at the first failure and reports
  the rest as `skipped`, for dependent chains. A tool that ran and *refused* counts as failed —
  unfinished work is unfinished either way.
- **Why not `items:[…]` on each write tool.** `save_snippet` declares `required:[name,code]`; a call
  carrying only `items` violates that schema, so clients that enforce schemas (OpenAI strict
  function calling) would refuse to emit it, and keeping both forms legal needs a root-level `anyOf`
  that strict mode does not accept either. Per-tool batching also still costs one request per tool,
  while the interesting work is heterogeneous. This costs ~250 B on the tool list once, describes no
  argument the model does not already have from `tools/list`, and takes three tools in one call.
- **Not JSON-RPC batching**, which `McpHttpEndpoint` still refuses: clients do not emit JSON-RPC
  batches and cannot be taught to. They emit tool calls, and this is a tool call.

#### `dryRun: true` — check the whole plan, write nothing

The cost of "no rollback" is a plan that fails at entry seven and leaves six objects behind. The
answer is not to reverse that stance — an undo would delete files and rows on a model's behalf, and
could itself fail halfway across two stores — but to move the check to *before* anything happens.
Send the identical batch again without the flag once it comes back clean.

```json
{"name": "batch", "arguments": {"dryRun": true, "calls": [ … ]}}
```

```json
{"batch": true, "dryRun": true, "ok": false, "count": 3, "checked": 2, "deferred": 1, "invalid": 1,
 "results": [
   {"i": 0, "tool": "save_snippet",    "ok": true,  "status": "checked"},
   {"i": 1, "tool": "create_workflow", "ok": false, "status": "checked", "result": {…}},
   {"i": 2, "tool": "set_schedule",    "ok": true,  "status": "deferred",
    "unresolved": ["{{1.workflow.id}}"]}
 ],
 "hint": "Nothing was written. Fix the entries reported ok:false and dry-run again."}
```

- **Four statuses, and the honesty is the point.** `checked` — the tool offered a write-free
  equivalent, it ran, this is its verdict. `deferred` — the entry uses `{{N.path}}`, and the object
  it points at was never created because nothing was written, so its *values* cannot be judged. The
  references responsible are named in `unresolved`, the batch counts them in `deferred`, and a green
  answer holding one says in its `hint` that it is not a promise: `ok:true` over a chained plan is a
  weaker claim than `ok:true` over an independent one. A reference does NOT automatically cost the
  deep check — when the tool's own check call carries none (`set_schedule` refers to a workflow but
  is checked on its cron expression), the check runs and the entry is `checked` with `unresolved`
  alongside.
  `unchecked` — the tool has no write-free equivalent (`delete_workflow` cannot rehearse).
  `invalid` — it will not work: bad shape, unknown tool, or a scope/mode refusal. Inventing a
  placeholder id so a deferred entry could be "checked" would produce confident errors about objects
  the plan was about to create, and teach the model to ignore the whole answer.
- **Scope and mode are decided for every entry**, deep check or not: "the token may not do this" is
  knowable without doing it, and the refusal is worded exactly as the real call's would be.
- **What has no deep check still gets a shape check** against the tool's own `inputSchema`
  (`Batch\ArgumentShape`): missing `required`, unknown keys on a closed schema — with a *did you
  mean* for a near miss — and top-level scalar types. Deliberately not a JSON-Schema validator: a
  shape check that cried wolf would be worse than none.
- **Tools declare their own check** via `Contracts\CheckableTool` (`checkCall()` → the write-free
  call, or `null`). tnt_mcp therefore holds no table of "save_snippet is checked by
  validate_snippet"; the tool that knows says so. The declared call is dispatched normally — scope,
  mode, audit — and dropped to `unchecked` if the token could not make it (`save_snippet` is
  `snippets:write`, `validate_snippet` is `snippets:read`, and the two are grantable separately).
  Its verdict is read from `ok`, `valid` **or** `accepted`: a validator answering `ok:true,
  valid:false` ran fine over input that is wrong, and that entry fails.
- **`stopOnError` is ignored.** With nothing being written there is no damage to stop, and one full
  report fixes the plan in one pass. The 25-entry cap still applies — it is about the HTTP request.
- **The audit row says `batch:dryRun:2/3`**, because `batch:3/3` beside no writes at all would look
  like three writes nobody can find.

### Verify live

```bash
# handshake (no token needed)
curl -sX POST https://<shop>/tnt-mcp -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'

# list tools (needs an active token from the plugin admin page)
curl -sX POST https://<shop>/tnt-mcp -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer <token>' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'

# call get_shop_info (token must carry the shop:read scope)
curl -sX POST https://<shop>/tnt-mcp -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer <token>' \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_shop_info","arguments":{}}}'
```

## OAuth 2.1 (PKCE + dynamic registration + discovery)

`/tnt-mcp` accepts BOTH a legacy static bearer token (unchanged, deny-by-default) **and** an
OAuth 2.1 access token, so the ChatGPT/Claude MCP connector can authorize itself. Built on
`league/oauth2-server` (committed vendor). Code lives under `src/Mcp/OAuth`.

Routes (registered in `Bootstrap::boot()` on `HOOK_ROUTER_PRE_DISPATCH`):

| Method + path | Purpose |
|---|---|
| `GET /.well-known/oauth-authorization-server` | RFC 8414 metadata (root, no locale prefix) |
| `GET /.well-known/oauth-protected-resource` | RFC 9728 metadata |
| `POST /tnt-mcp/register` | RFC 7591 dynamic client registration (public/PKCE client) |
| `GET /tnt-mcp/authorize` | authorization endpoint — **admin-gated consent** |
| `POST /tnt-mcp/token` | authorization_code + PKCE (S256) → access + refresh token |

The flow a connector actually walks: it POSTs an unauthenticated `tools/call`, gets **401** with
`WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource"`, follows the
two discovery documents, registers itself at `/tnt-mcp/register`, sends the admin to
`/tnt-mcp/authorize` with a PKCE challenge, and exchanges the returned code at `/tnt-mcp/token`.

- **Approval == JTL backend-admin login.** The consent screen only issues a code when
  `Shop::Container()->getAdminAccount()->logged()` is true (login + 2FA). No session → login prompt,
  never a code. See `JtlAdminSession`.
- **Single shop identity.** Every token acts as the shop (`ShopUserEntity`, id `shop`); no per-user
  tokens. Granted scopes are derived from the enabled tools (`ScopeCatalog`), so the discovery
  metadata, the default grant and the per-tool enforcement cannot drift apart.
- **The approving admin's rights are the connection's rights.** `invoke_admin_action` and
  `undo_admin_action` run as the administrator who approved the token
  (`TokenAdminResolver::loginFor()`). The **Integrationen** tab names that administrator per live
  token; the warning cannot be dismissed.
- **Keys.** RSA signing keypair + Defuse encryption key are generated on demand by `KeyManager`
  and stored **encrypted in the database** (`tnt_mcp_oauth_key`, AES-256-GCM under a key derived
  from the shop's `BLOWFISH_KEY`) — so a database dump alone cannot forge tokens, and no web
  server can serve them. They used to live in a `keys/` directory inside the document root, which
  only Apache's `.htaccess` protected; nginx shops served the private key. Existing installs are
  adopted by `Migration20260805000000` — the old files are imported and deleted, so tokens issued
  before the upgrade keep working. Rotating `BLOWFISH_KEY` makes the material unreadable and a new
  keypair is provisioned: connectors then re-consent.

## Tools (36)

`ToolFactory::enabledTools()` is the authoritative, ordered list; `tests/Unit/ToolFactoryTest.php`
pins the names, the count and the scope universe. The order is deliberate — it is the only routing
hint an LLM gets, so it runs "is anything wrong? → read → describe → perform → undo". Every
description is capped at 140 characters, because the tool list is prompt paid on every turn.

**Shop info & health** (`shop:read`)

| Tool | What it does |
|---|---|
| `shop_health` | Check the shop for trouble: errors, payment failures, stalled cron, setting changes. One call, no arguments. |
| `get_shop_info` | Basic shop identity: name, URL, default language and currency. |

**Settings** (`settings:read`, `set_setting` is `settings:write`) — the writable surface is the
allowlist in `settings-allowlist.yaml`, not every shop setting.

| Tool | What it does |
|---|---|
| `list_settings` | Every setting the server can read or write, with title, summary and read-only/write-only/both. |
| `get_setting` | Current value of one setting by key. |
| `describe_setting` | Label, description, input type and allowed/current/default value where the shop exposes them. |
| `set_setting` | Set one setting exactly as an admin would save it. **Full mode only.** |

**Database reads** (`db:read`)

| Tool | What it does |
|---|---|
| `run_sql_read` | One read-only SELECT. Row- and time-capped, single statement, guarded by `SqlReadGuard`. |
| `list_tables` | Shop tables grouped by domain (orders, customers, products, …) with approximate row counts. |
| `describe_table` | Columns, types, keys, related tables and example queries for one table. |

**Logs** (`logs:read`)

| Tool | What it does |
|---|---|
| `read_logs` | Shop logs (system, settings audit, payment, admin auth file) with level/time/text filters plus aggregated counts. |

**Analytics & orders** (`analytics:read`) — overview first, then the drill-downs, then the order book.

| Tool | What it does |
|---|---|
| `analytics_summary` | One-call overview for a window: sales, traffic, carts, top sellers, with pointers to the tool that expands each. |
| `sales_summary` | Revenue, order count, AOV for a window, with a time series and a breakdown by status and payment method. |
| `top_products` | Best sellers in a window, ranked by net revenue or units, with quantity and order count. |
| `visitor_statistics` | Visits, bots, unique IPs, conversion rate, top referrers and entry pages, plus a time series. |
| `top_searches` | Most-used search terms and the terms that returned nothing, with lifetime counts. |
| `cart_statistics` | Saved carts in a window: count, items, estimated value, abandonment rate, most-abandoned products. |
| `list_orders` | Orders in a time window, newest first, with totals, revenue and a status breakdown. |
| `search_orders` | Find orders by order number, customer name, company or e-mail within a window. |
| `get_order` | One order in full: status, totals, payment, shipping, addresses and all line items. |

**Shipping & payment configuration** (`shop:read`) — read these before writing them: a shipping
save is a full replace, so the read is the difference between an edit and a silent wipe.

| Tool | What it does |
|---|---|
| `list_shipping_methods` | Calculation type, countries, customer groups, and how many tiers and ZIP surcharges each has. |
| `get_shipping_method` | One method in full: price tiers, ZIP surcharges, countries, payment surcharges, languages, tax basis. |
| `list_payment_methods` | Module id, whether each is active and actually usable, sort order, customer-group limits. |
| `get_payment_method` | One method in full: module settings, languages, customer groups, mail flags, shipping methods offering it. |

**Admin actions** (`actions:read`; `invoke_admin_action` is `actions:write`)

| Tool | What it does |
|---|---|
| `list_admin_actions` | Start here for backend changes: a map of the changeable admin areas, then one area's actions with side effects and reversibility. |
| `describe_admin_action` | Full contract for one action: prerequisites, recipe, every field, side effects, what an undo cannot reverse. |
| `validate_admin_action` | Check a payload without performing it — shape, enum ids, co-required fields. Writes nothing. |
| `invoke_admin_action` | Perform one action, as the approving admin. Returns alerts plus a read-back proving the effect. |

**Journal & undo** — `list_recent_changes` is `actions:read`, `undo_admin_action` is `actions:write`.

| Tool | What it does |
|---|---|
| `list_recent_changes` | Changes made through this server, newest first, with what each one can and cannot undo. |
| `undo_admin_action` | Undo one change from `list_recent_changes`. Returns the plan first; applies nothing without `confirm: true`. |

**Guides** (`shop:read`)

| Tool | What it does |
|---|---|
| `get_guide` | Task guide for a shop area: the reads that resolve ids, an ordered walkthrough, the usual failures, what is impossible. Start at `index`. |

**AI connection** (`ai:read`, `set_ai_configuration` + `begin_ai_key_entry` are `ai:write`) — this
plugin owns the shop's LLM connection; it moved here out of tnt_workflows, where the merchant could
not find it and the tools that configure it did not live.

| Tool | What it does |
|---|---|
| `get_ai_status` | The entry point: one of six states, the configuration (never the key), budget, what is missing, and the exact next tool to call. |
| `set_ai_configuration` | Sets provider, default model, API address and the two spending brakes. Cannot set the key. |
| `begin_ai_key_entry` | Returns a one-time, 15-minute admin link the merchant opens to type the key in themselves. |
| `test_ai_connection` | Asks the provider whether the stored key works, and records the answer. |
| `list_available_models` | The models the configured provider offers for the stored key. |
| `estimate_prompt_cost` | What a prompt of a given size would cost, against the merchant's remaining budget. |

⚠️ **The API key never travels through MCP.** Not in a tool argument, not in a chat message. An
assistant that needs one calls `begin_ai_key_entry` and hands over the link; `get_guide` topic
`ai-setup` is the walkthrough, and `get_ai_status` names the next step in every state.

### Scopes

Ten, derived from the tools themselves by `ScopeCatalog` — a tool's `scope()` **is** the
definition:

`shop:read`, `settings:read`, `settings:write`, `db:read`, `logs:read`, `analytics:read`,
`actions:read`, `actions:write`, `ai:read`, `ai:write`.

`analytics:read` is separate from `db:read` on purpose: an integration may legitimately want shop
figures without the ability to run arbitrary SELECTs.

### Tools from other plugins

Another plugin can contribute tools by registering a `ToolProvider` with `ToolRegistry` from its own
`Bootstrap::boot()`. Provider tools are appended **after** the 30 built-ins. Built-ins always win a
name collision, then first provider wins; a duplicate is dropped with a log line, and a provider that
throws while building its tools is skipped entirely rather than taking the whole MCP surface down.
The **Integrationen** tab renders what was skipped and why.

## Modes: `limited` vs `full`

The plugin setting `tnt_mcp_mode` (default **limited**, the safe one) drives one central `ModeGuard`
in `Server::dispatch()`. Every tool declares an `EndpointClass` (`Reversible` / `Destructive`).

- **`full`** — everything the token's scopes allow.
- **`limited`** — only `Reversible` endpoints run. Destructive **and** unclassified endpoints are
  denied with `denied:mode` and a clear MCP error. In practice that means all 27 read tools work,
  while `set_setting` and `undo_admin_action` are refused.
- `invoke_admin_action` is judged **per call**, not per tool (`PerCallEndpointClass`): it fronts
  catalog entries of both classes, so in limited mode an action the catalog declares reversible runs
  and a destructive one does not. Arguments naming no classifiable action resolve to `null`, which
  limited mode denies — fail-safe, not fail-open.

### The read gate

`run_sql_read` never touches the shop's shared request connection. `src/Mcp/Db/ReadGate` opens its
own PDO with the shop's own credentials — `MYSQL_ATTR_MULTI_STATEMENTS => false`, a session
statement timeout (5s, capped at 30s), and every query inside `START TRANSACTION READ ONLY` — and
re-checks at that connection that it was handed exactly one statement opening with `SELECT`/`WITH`,
without trusting that `SqlReadGuard` ran first.

There is nothing to configure and no second database user to provision. Earlier versions offered
`tnt_mcp_ro_db_user` / `tnt_mcp_ro_db_pass`; those settings are gone and
`Migrations/Migration20260805190000` deletes any values a merchant had saved.

## Admin UI (adminmenu tabs)

Four backend tabs plus a settings tab render for a logged-in JTL admin (info.xml `<Customlink>` →
`adminmenu/index.php` dispatcher → `adminmenu/Controller/*` → `adminmenu/templates/*.tpl`). No secret
is ever shown (token hashes are never surfaced, OAuth token ids are masked to an 8-char prefix).

- **Start** (`Start`): the one page that answers "what do I do next?" — a setup checklist whose first
  incomplete row is the call to action (`src/Mcp/Admin/SetupChecklist`), the MCP endpoint URL with a
  copy button (rendered here and nowhere else in the plugin), the per-provider connect guides
  (ChatGPT, Claude, Gemini, Copilot) as an accordion with a live status dot from real OAuth token
  state (`src/Mcp/Admin/IntegrationsReport`), a one-row rights notice with the standing warning and
  the live connections it applies to behind a disclosure (`src/Mcp/Admin/ConsentReport`), and, also
  collapsed, the technical facts the old Server-Status tab opened on — mode, OAuth key presence,
  discovery URLs, token/client counts, plugin/PHP versions (`src/Mcp/Admin/ServerStatusReport`) plus
  the list of plugin-provided tools that were skipped.
  It replaces the former **Server-Status** and **Integrationen** tabs; both old names still route
  here, because a bookmark or an assistant's `?cPluginTab=` link outlives the rename.
- **Aktivität** (`Activity`): both halves of "what happened?" as sub-views — *Anfragen*, the MCP
  request log (endpoint/tool, params summary, auth mode, HTTP status, duration, timestamp,
  `src/Mcp/Observability/RequestLogger`), and *Änderungen*, the change journal every
  `invoke_admin_action` writes, with a per-row diff and an undo button — the merchant carries the
  risk, so they get the same list and the same undo the LLM has. The undo is POST-only, gated on the
  JTL backend XSRF token, and goes through the same `UndoService` with `confirm: true`. The audit
  outcome summary sits at the bottom, collapsed. Old tab names (`Anfrage-Protokoll`, `Änderungen`)
  still route here.
- **Zugriff** (`TokensAudit`, formerly "Tokens & Audit"): legacy static tokens, OAuth clients, issued
  OAuth access tokens (masked), and the audit log across the legacy + OAuth tables
  (`src/Mcp/Admin/TokenAuditOverview`).
- **KI** (`Ai`): one screen for the shop's LLM connection — a status banner fed by the same verdict
  `get_ai_status` returns (so the banner and the assistant can never disagree), provider/key/model,
  the cost brake, and a single "Speichern & testen". The key field is write-only; only its last four
  characters are ever rendered. This is where a `begin_ai_key_entry` link lands.
- **Einstellungen**: the operating mode, and whether a connected AI client may write the AI
  configuration (`tnt_mcp_ai_config_enabled`, on by default).

**Request logging** is hooked at the single dispatch choke point `McpHttpEndpoint::handle()`: it
times the whole request, delegates to `process()`, then records one row (success/error/denied alike)
into `tnt_mcp_request_log`. Never logs secrets (only method/tool + argument key names). The log is
bounded — `RequestLogger` prunes to `MAX_ROWS` (2000).

## Extending the catalog

The admin-action surface and the guides are data, not code, and they live in the plugin directory:

- **`catalog/actions/*.yaml`** — what `list_admin_actions` / `describe_admin_action` /
  `validate_admin_action` / `invoke_admin_action` can address. Ships nine files (coupon, cron,
  customer fields, e-mail blocklist, payment, redirect, separator, shipping, shipping surcharge).
  `ActionCatalog::fromDirectory()` reads the whole directory at call time, so a validated file you
  add is picked up with no PHP change and no enum to maintain — the tools' schemas are built from the
  catalog. Action ids are one flat namespace across all files, and loading is **all-or-nothing**: one
  invalid file fails the whole catalog rather than handing out a half-loaded one.
- **`catalog/guides/*.md`** — markdown with YAML front matter (`topic`, `title`, `summary`,
  `appliesTo`, `derivedFrom`). Ships eleven. `topic` must equal the file name. `get_guide`'s `index`
  is generated from the shipped guides' `summary:` lines, so an added guide shows up there and is
  served by `get_guide` immediately. One caveat: the tool's advertised JSON-Schema `topic` enum
  (`GetGuide::TOPICS`) is hand-maintained, so a strict client may not offer a newly added topic until
  that const is extended.
- **`catalog/table-domains.yaml`** — the domain grouping `list_tables` / `describe_table` use.
- **`settings-allowlist.yaml`** — the only settings `get_setting` / `set_setting` can touch.

## Known gaps

- Server-initiated SSE streaming for long-running or streamed tool output (the endpoint is
  JSON-response Streamable-HTTP only).
