# The MCP server, from the inside

> How a tool call becomes a record. This is the implementation article: the layers, the transport, and the classes that do the work. For running the server and…

Source: CBX documentation, version 4.0 preview (unreleased). Canonical page: https://docs.configbox.at/docs/4.0-preview/technical/mcp_server_internals. Last updated 2026-08-25.

---
How a tool call becomes a record. This is the implementation article: the layers, the transport, and
the classes that do the work. For *running* the server and what the tools do, read
`com_configbox_mcp_server.md`; for the layer this shares with the HTTP API,
`com_configbox_http_api.md`.

All paths are relative to the component root (`components/com_configbox/`).

---

## 1. The whole path, once

A `tools/call` for `cbx_update_entity`, end to end:

```
  the MCP client (Claude, an IDE, a script)
      │   newline-delimited JSON-RPC 2.0 on stdin
      │
  configbox:mcp  (McpCommand)                              ← outer repo, Joomla-specific
      │   boots the host, then hands over
      │
  ConfigboxMcpHelper::runStdioLoop($dispatcher)            ← the TRANSPORT
      │   one line in → handleMessage() → one line out
      │
  ConfigboxMcpHelper::handleMessage()                      ← the PROTOCOL
      │   initialize / notifications/* / tools/list / tools/call / ping
      │
  $dispatcher(name, args)   ── usually a FRESH CHILD PROCESS ──┐
                                                               │
  ConfigboxMcpHelper::executeTool()                       ← the EDGE
      │   assertWritable()        --read-only            → error
      │   getModelForEntity($e, $forWrite = true)  policy → error
      │   flattenForTool()        typed exception → prose
      │
  ConfigboxEntityApiHelper::updateForModel()              ← the SHARED LAYER
      │   … identical from here on to the HTTP path …
      │
  KenedoModel (ConfigboxModelQuestions)                   ← the MODEL
```

Compare the same diagram in `com_configbox_http_api.md`: **below the shared layer the two are the
same code, not merely the same behaviour.** Above it they share nothing, and should not.

---

## 2. The classes

| Class | File | Responsibility |
|---|---|---|
| `McpCommand` | `administrator/…/src/Console/McpCommand.php` *(outer repo)* | Boots the host, owns `--read-only` / `--in-process` / `--call`, supplies the dispatcher |
| `ConfigboxMcpHelper` | `helpers/mcp.php` | Transport, protocol, tool definitions, the writable policy |
| `ConfigboxRuleAuthoringHelper` | `helpers/ruleauthoring.php` | Rule ⇄ engine token stream, and the vocabulary schema |
| `ConfigboxMcpCalculationsHelper` | `helpers/mcpcalculations.php` | Calculation bodies — formula, matrix, code |
| `ConfigboxEntityApiHelper` | `helpers/entityapi.php` | **The shared layer.** Registry + CRUD + the write pipeline |
| `ConfigboxTypeGenerator` | `helpers/typegen/` | The field schemas the tools advertise |

`ConfigboxMcpHelper` is the **platform-agnostic core**, exactly like `ConfigboxCliHelper`: no Joomla,
WordPress or Magento code. Each host ships a thin wrapper (`configbox:mcp`, `wp configbox mcp`,
`bin/magento configbox:mcp`) that bootstraps itself and calls in.

---

## 3. The transport, and why it is hand-rolled

MCP stdio: newline-delimited JSON-RPC 2.0. Hand-rolled because the component has **no composer and no
PSR-4 autoloading**, and pulling in an SDK would break its packaging. The surface needed is small:
`initialize`, `notifications/*`, `tools/list`, `tools/call`, `ping`.

**stdout is the protocol channel.** Nothing else may write to it while the loop runs, or the client's
JSON parser desynchronises. Diagnostics go to stderr through `logStderr()`. This is the single
easiest way to break an MCP server, and it breaks silently.

---

## 4. Process-per-call

```mermaid
sequenceDiagram
  participant C as MCP client
  participant S as server process (configbox:mcp)
  participant W as worker process (configbox:mcp --call)
  C->>S: tools/call
  S->>S: assert the tool's scope
  S->>W: spawn, same --scope list, one JSON line on stdin
  W->>W: boot Kenedo (no migration pass) · assert scope again · run the tool
  W-->>S: one JSON line, exit
  S-->>C: result or isError
  Note over S,W: a fatal inside the tool ends the worker, not the server
```

`runStdioLoop()` takes a `$toolDispatcher` callable, and the host wrapper is expected to pass one that
**spawns a fresh child process per `tools/call`** — the wrapper knows its own argv; the core cannot.
That is a correctness choice, not a performance one:

- Four controllers end the request outright by design (`adminorderslip`, `invoice`, `quotation`,
  `ipn` — a file download or a PSP webhook body *is* the whole response). Any tool reaching one of
  those would take a long-running dispatcher down with it.
- A fatal error inside one tool call kills the whole server; a child process contains it.

`--call` is what a child runs: read one tool call as JSON from stdin, execute, print, exit.

`dispatchInProcess()` is the degenerate dispatcher, for tests and for hosts that have not wired
spawning. **It is not equivalent.** A fatal in one call takes the server down, and the four
request-ending controllers still would.

> A third reason used to dominate this list and is now **retired**: models memoised their reads per
> call signature, so a second identical read in a long-running server returned the first one's rows,
> and a record changed in between — by anyone — was invisible. That was the only one of the three
> that produced quietly *wrong* answers rather than a visible failure. `KenedoModel` no longer
> memoises records at all, measured at a 0% hit rate for ~25 extra queries across a whole test
> workload, so the hazard is gone at the source rather than worked around.

---

## 5. Reads are wide, writes are narrow

`getEntities()` is the one place that decides both, and the split is the point:

```php
// Hand-listed, writable — catalog authoring
'product', 'page', 'question', 'answer', 'product-list',
'product-list-assignment', 'calculation', 'product-detail-pane'

// Everything else the type system knows — readable, not writable
foreach (ConfigboxEntityApiHelper::getEntityModels() as $entityName => $model) { … 'writable' => false }
```

(The hand-listed set is the excerpt's weak point — when it drifts from `getEntities()`, the code
wins. Today it is the eight names above.)

**The entity list comes from the shared registry**, not from a second discovery. This helper decides
only *policy*: which entities it may write, and what to label them. Two discoveries would eventually
disagree, and the disagreement shows up as an entity the tools list and the schemas do not describe.

Why writes are curated: most of the other entities are store configuration — payment methods,
connectors, tax — where a misunderstood instruction does real damage across every product at once.
`getModelForEntity($entity, $forWrite = true)` is where that is enforced, so the check sits next to
the work rather than in the tool listing.

`--read-only` is enforced **twice**: `getToolDefinitions()` stops advertising the write tools, *and*
`executeTool()` refuses them. Listing is a hint, not a fence — a client can call any name it likes.

---

## 6. The tools

| Tool | Reads or writes | Notes |
|---|---|---|
| `cbx_describe_entity` | read | Fields, JSON types, which are required or read-only, the listing columns, the filters, the active languages — **and, named explicitly, what this server cannot represent** |
| `cbx_list_entities` | read | Listing columns plus the unfiltered `total`. Capped at 200 |
| `cbx_get_entity` | read | One full record |
| `cbx_create_entity` | write | Refused, with the model's own messages, if the data is invalid |
| `cbx_update_entity` | write | Partial: unsent fields keep their value |
| `cbx_delete_entity` | write | **One id per call.** No bulk form |
| `cbx_describe_rules` | read | The rule authoring vocabulary for a product, with its referenceable question and answer ids |
| `cbx_describe_calculations` | read | The calculation vocabulary |
| `cbx_get_calculation` / `cbx_set_calculation` | read / write | The calculation **body** — formula, matrix or code. The parent row is an ordinary entity |

`buildFieldSchema()` delegates to `ConfigboxTypeGenerator::buildWriteSchema()` — one producer for
field semantics — and adds only what is MCP-specific: the live rule vocabulary spliced over the
generator's placeholder, and the guidance sentence naming the tool that finds valid ids for a
reference.

**`cbx_describe_entity` names what it cannot do.** A tool that quietly omits a field it does not
understand invites an assistant to conclude the field does not exist. Reporting it as unsupported is
the difference between a gap and a lie.

---

## 7. Errors: typed underneath, prose at the boundary

The shared layer throws `KenedoValidationException` / `KenedoNotFoundException` /
`KenedoSystemException`, because the HTTP surface needs the per-field issues to answer `422` and say
*where*. A JSON-RPC tool result has no status code and no field slot — it is text an assistant reads.

`flattenForTool()` is where that conversion happens:

```php
catch (KenedoNotFoundException $e)   → "No question found with id 26037."
catch (KenedoValidationException $e) → "This question is not valid. Field Title cannot be empty."
catch (KenedoSystemException $e)     → "Could not store this question. …"
```

**The wording is the wording these tools have always used.** When the CRUD pipeline was extracted out
of this file, the flattening moved here rather than being deleted, precisely so the tool contract did
not change while the layer underneath did.

---

## 8. Authorization: one table, two gates, and where the credential comes from

Every tool talks to `KenedoModel` through the shared layer. No Kenedo controller runs, so
`KenedoController::isAuthorized()` never fires — **the host's ACL is not what guards this surface.**
What guards it is a scope grant, checked in this class, the same way whatever the transport.

```mermaid
flowchart LR
  subgraph GRANT["the grant for this run"]
    S["stdio: every scope,\nor --scope / --preset"]
    H["HTTP: the verified\ntoken's scopes"]
  end
  S --> T
  H --> T
  T["getToolScopes()\ntool → area:level"] -- "filters" --> LIST["tools/list"]
  T -- "enforces" --> EXEC["executeTool()"]
  EXEC -- "then the entity's area" --> E["getModelForEntity()\ncustomer → customers:write"]
  T -. "every yes/no goes through" .-> G["ConfigboxApiTokenHelper::grantIncludes()\nwrite ⊇ read · nothing across areas · store:read floor"]
```

- **One table.** `getToolScopes()` names the scope every tool needs. `getToolDefinitions()` filters
  by it and `executeTool()` enforces it, so a tool withheld from `tools/list` is also refused when
  called by name. A tool missing from the table fails closed — `getScopeForTool()` throws, and it
  says it is a ConfigBox bug.
- **Two levels.** The generic entity tools are opened by `ANY_READ` / `ANY_WRITE` (a scope at that
  level in *some* area) and then gated per entity in `getModelForEntity()` by the entity's area at
  that level. `cbx_set_calculation` and `cbx_import_product_package` add a third check for
  `code:write` when what they carry is PHP — a conjunction that depends on the argument, so it lives
  in the tool, not the table.
- **One function for the rules.** `hasGrant()` delegates to
  `ConfigboxApiTokenHelper::grantIncludes()` — write covers read within an area, nothing across
  areas, `store:read` is every token's floor — which the REST controller's `authenticate()` also
  calls. HTTP tokens and stdio runs cannot disagree about what a grant means.
- **The grant is transport-dependent, and only that.** stdio defaults to every scope, because its
  trust boundary is **shell access to the site**: whoever can start this server can already run
  `configbox:run-task`, read the database or edit the files, so a credential prompt would be
  theatre; `--scope` and `--preset` narrow it. `POST /cb-api/v1/mcp` (`controllers/apiv1.php`,
  `handleMcp()`) authenticates a bearer token and hands *that token's* scopes to
  `setGrantedScopes()` — an HTTP caller holds only what its token was minted with. It also turns off
  `setLocalFilesystemTrust()`, so the product-transfer paths are confined to the transfer directory;
  a path never reaches a model, so no scope covers it.
- **Annotations are not authorization.** `getToolAnnotations()` supplies MCP's `readOnlyHint` /
  `destructiveHint` / `idempotentHint` so a *host* can confirm a destructive call with the person.
  Scope says whether the token may; annotation says whether the person should. That is why there is
  no `delete` scope.
- **One audit line per call**, in `executeTool()`, to the `authorization` KLog category: tool,
  caller (`token #12 "name"` or `stdio`), granted scopes — never the arguments.

The vocabulary itself — areas, presets, the entity map — is `com_configbox_api_tokens_and_scopes.md`.

What also stands between a mistake and the live store: the writable subset, one id per delete, the
model's own validation, and its `canDelete()` checks — so a delete refused for being referenced says
what references it.

---

## 9. Where to make a change

| You want to… | Change |
|---|---|
| Let a tool write a new entity | The `writable` flag in `ConfigboxMcpHelper::getEntities()`, and its area in `ConfigboxApiTokenHelper::getEntityAreas()` (or the model's `getApiArea()`) |
| Expose a new entity for reading | Return a name from that model's `getEntityName()` — and place it in an area, or it defaults to `catalog` |
| Add an admin operation | One entry in `ConfigboxMcpAdminHelper::getOperations()` — the tool, its scope and its annotations are generated from it |
| Change what a field means | The property's `label` / `tooltip`; both the tool schema and the HTTP schemas render them |
| Change storage behaviour | `ConfigboxEntityApiHelper` — the HTTP surface gets it too |
| Change a tool's wording | `flattenForTool()` or `getToolDefinitions()`, not the shared layer |
| Add a tool | `getToolDefinitions()`, `executeTool()`, `getToolScopes()` **and** `getToolAnnotations()` — the last two fail closed if forgotten, at `tools/list` |
| Change the rule vocabulary | `ConfigboxRuleAuthoringHelper` — the REST API and the admin editor speak it too |

---

## 10. See also

- `com_configbox_mcp_server.md` — running it, and what each tool does
- `com_configbox_http_api.md` — the same treatment for the REST surface
- `com_configbox_entity_api.md` — the shared layer, from a user's point of view
