The MCP server, from the inside
- Version
- 4.0 preview
- Updated
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
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.
KenedoModelno 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:
// 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:
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.
- One table.
getToolScopes()names the scope every tool needs.getToolDefinitions()filters by it andexecuteTool()enforces it, so a tool withheld fromtools/listis 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 ingetModelForEntity()by the entity's area at that level.cbx_set_calculationandcbx_import_product_packageadd a third check forcode:writewhen 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 toConfigboxApiTokenHelper::grantIncludes()— write covers read within an area, nothing across areas,store:readis every token's floor — which the REST controller'sauthenticate()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;--scopeand--presetnarrow it.POST /cb-api/v1/mcp(controllers/apiv1.php,handleMcp()) authenticates a bearer token and hands that token's scopes tosetGrantedScopes()— an HTTP caller holds only what its token was minted with. It also turns offsetLocalFilesystemTrust(), 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'sreadOnlyHint/destructiveHint/idempotentHintso 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 nodeletescope. - One audit line per call, in
executeTool(), to theauthorizationKLog category: tool, caller (token #12 "name"orstdio), 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 doescom_configbox_http_api.md— the same treatment for the REST surfacecom_configbox_entity_api.md— the shared layer, from a user's point of view