Skip to main content
Version: 3.x

The MCP server, from the inside

Version
3.x
Updated
View markdown

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

ClassFileResponsibility
McpCommandadministrator/…/src/Console/McpCommand.php (outer repo)Boots the host, owns --read-only / --in-process / --call, supplies the dispatcher
ConfigboxMcpHelperhelpers/mcp.phpTransport, protocol, tool definitions, the writable policy
ConfigboxRuleAuthoringHelperhelpers/ruleauthoring.phpRule ⇄ engine token stream, and the vocabulary schema
ConfigboxMcpCalculationsHelperhelpers/mcpcalculations.phpCalculation bodies — formula, matrix, code
ConfigboxEntityApiHelperhelpers/entityapi.phpThe shared layer. Registry + CRUD + the write pipeline
ConfigboxTypeGeneratorhelpers/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. 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:

// 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

ToolReads or writesNotes
cbx_describe_entityreadFields, 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_entitiesreadListing columns plus the unfiltered total. Capped at 200
cbx_get_entityreadOne full record
cbx_create_entitywriteRefused, with the model's own messages, if the data is invalid
cbx_update_entitywritePartial: unsent fields keep their value
cbx_delete_entitywriteOne id per call. No bulk form
cbx_describe_rulesreadThe rule authoring vocabulary for a product, with its referenceable question and answer ids
cbx_describe_calculationsreadThe calculation vocabulary
cbx_get_calculation / cbx_set_calculationread / writeThe 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 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 entityThe writable flag in ConfigboxMcpHelper::getEntities(), and its area in ConfigboxApiTokenHelper::getEntityAreas() (or the model's getApiArea())
Expose a new entity for readingReturn a name from that model's getEntityName() — and place it in an area, or it defaults to catalog
Add an admin operationOne entry in ConfigboxMcpAdminHelper::getOperations() — the tool, its scope and its annotations are generated from it
Change what a field meansThe property's label / tooltip; both the tool schema and the HTTP schemas render them
Change storage behaviourConfigboxEntityApiHelper — the HTTP surface gets it too
Change a tool's wordingflattenForTool() or getToolDefinitions(), not the shared layer
Add a toolgetToolDefinitions(), executeTool(), getToolScopes() and getToolAnnotations() — the last two fail closed if forgotten, at tools/list
Change the rule vocabularyConfigboxRuleAuthoringHelper — 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