---
title: "Architecture"
manual: "WebMCP tool for TYPO3"
version: "0.4"
permalink: "https://docs.typo3.org/permalink/neoblack/webmcp:architecture@0.4"
source: "Architecture/Index.rst"
rendered: "2026-10-07T22:31:54+00:00"
---

> [!WARNING]
> **Experimental.** This extension is experimental and not yet ready for
> production use. It is built on top of
> [WebMCP](https://github.com/webmachinelearning/webmcp), which is itself
> an experimental, early-stage proposal. Both the underlying specification
> and this extension's API may change or break at any time without notice.
> Use at your own risk.

# Architecture {#architecture}

The extension has two independent flows: **emitting tools** at page render time
and **ingesting usage events** at call time. They share only the tool registry.

-   [Emitting tools (frontend render)](https://docs.typo3.org/permalink/neoblack/webmcp:emitting-tools-frontend-render@0.4)
-   [Ingesting usage events (call time)](https://docs.typo3.org/permalink/neoblack/webmcp:ingesting-usage-events-call-time@0.4)
-   [Why the two flows are decoupled](https://docs.typo3.org/permalink/neoblack/webmcp:why-the-two-flows-are-decoupled@0.4)

## Emitting tools (frontend render) {#emitting-tools-frontend-render}

```plantuml
participant "Tool provider" as TP
participant "ToolRegistry" as TR
participant "ToolManifestProcessor" as TMP
participant "Page HTML" as PG
participant "webmcp.js" as RT
participant "ModelContext" as MC

TP -> TR : manifest($cObj, $processedData)
note right of TR : providers returning\nnull are dropped
TR -> TMP : list of Manifest
TMP -> PG : JSON block\n(JSON_HEX_* escaped)
PG -> RT : read #webmcp-config
note right of RT : per tool: primitive\ninterpreter or moduleUrl
loop each tool
  RT -> MC : registerTool(descriptor, { signal })
  MC --> RT : registered — or rejected\n(e.g. duplicate name: warn once, skip)
end
note right of MC : tools appear one by one;\nthere is no atomic registration
```

Emitting tools — from PHP provider to registered agent tool

| Class | Responsibility |
| --- | --- |
| `\Neoblack\Webmcp\Tool\ToolProviderInterface` | The contract each tool implements. Tagged `webmcp.tool` (autoconfigured). |
| `\Neoblack\Webmcp\Tool\Manifest` / `\Neoblack\Webmcp\Tool\Primitive` | The serialisable tool description and its behaviour selector. |
| `\Neoblack\Webmcp\Registry\ToolRegistry` | Collects manifests for the current request and exposes `toolNames()` for the analytics whitelist. |
| `\Neoblack\Webmcp\DataProcessing\ToolManifestProcessor` | Serialises the manifests into the page's JSON block. |
| `\Neoblack\Webmcp\Tool\ManifestValidator` | Checks each manifest against the spec's name pattern and Chrome's size recommendations; the processor logs findings as warnings. |
| `Resources/Public/JavaScript/webmcp.js` | The generic runtime holding all four primitive interpreters, the escape-hatch loader and the registration lifecycle. |

### Registration in the browser {#registration-in-the-browser}

The runtime in `webmcp.js` runs these steps once per page load:

1.  **Top-level check.** Inside an iframe it stops: agents only discover tools
    of the top-level document, and an embedding third-party page must not
    receive the tools.
1.  **Read the config block.** Missing, unparsable or tool-less → stop.
1.  **Feature detection.** `document.modelContext` is used when it offers
    `registerTool()`. Otherwise — only if [legacyNavigatorFallback](https://docs.typo3.org/permalink/neoblack/webmcp:confval-dataprocessor-legacynavigatorfallback@0.4) is on — the deprecated
    `navigator.modelContext`. Neither → stop silently.
1.  **Build descriptors.** Each manifest entry becomes a tool descriptor
    (name, title, description, input schema, annotations, `execute`).
    Entries without a name, with an unknown primitive and no `moduleUrl`, or
    reusing a name already used in the manifest are dropped. An entry whose
    data makes the primitive fail is reported once and skipped.
1.  **Register each tool individually.** Every tool gets its own
    `AbortController`; the runtime calls
    `registerTool(descriptor, { signal })` and accepts any return value
    (`undefined`, a Promise or an object). If a call throws or its Promise
    rejects — for example because another script on the page already took the
    name — that one tool is skipped and a single `console.warn` names it.
    All other tools are unaffected.
1.  **Teardown.** On `pagehide` all controllers are aborted, which
    unregisters the tools; if the page is restored from the back/forward cache
    (`pageshow` with `persisted`) they are registered again.

Every `execute` call is wrapped so that a synchronous throw becomes a
rejected Promise and text output is capped at [outputLimit](https://docs.typo3.org/permalink/neoblack/webmcp:confval-dataprocessor-outputlimit@0.4) characters.

> [!NOTE]
> Up to version 0.3 the runtime preferred `provideContext({ tools })` for an
> "atomic" registration. The specification removed `provideContext()`, so
> tools now appear one after another — this is intended by the specification.
> See [Security considerations](https://docs.typo3.org/permalink/neoblack/webmcp:security@0.4) for the background.

## Ingesting usage events (call time) {#ingesting-usage-events-call-time}

```plantuml
actor "Agent on page" as P
participant "webmcp.js" as JS
participant "EventMiddleware" as MW
participant "RateLimiter" as RL
participant "ToolRegistry" as TR
database "tx_neoblackwebmcp_event" as DB

P -> JS : tool call
JS -> MW : POST /webmcp-event\nsendBeacon { tool, client }
MW -> MW : same-origin guard\n(Sec-Fetch-Site)
MW -> RL : allow(ip, limit)?
MW -> TR : tool in toolNames()?
MW -> DB : log(tool, client, ts)

== Backend module ==

actor "Editor" as ED
participant "DashboardController" as DC
participant "StatisticsService" as SS
participant "EventRepository" as ER

ED -> DC : open System > WebMCP
DC -> SS : collect(filter)
SS -> ER : aggregate by tool / client / day
ER -> DB : SELECT
```

Ingesting usage events — from tool call to backend dashboard

| Class | Responsibility |
| --- | --- |
| `\Neoblack\Webmcp\Middleware\EventMiddleware` | The public ingest endpoint. Inert when analytics is disabled; passes unknown tools down the stack so it can coexist with other handlers. |
| `\Neoblack\Webmcp\Security\RateLimiter` | Fixed-window limiter keyed on a hashed IP + window number (no plaintext IP stored). |
| `\Neoblack\Webmcp\Domain\Repository\EventRepository` | The only class that writes/reads the event table. |
| `\Neoblack\Webmcp\Service\StatisticsService` | Aggregates rows into the DTOs the backend module renders. |
| `\Neoblack\Webmcp\Controller\DashboardController` | Thin backend controller; reads the filter, delegates, renders. |

## Why the two flows are decoupled {#why-the-two-flows-are-decoupled}

The middleware runs early, before the frontend page is resolved, so it cannot
rely on a rendered manifest. It therefore validates incoming events against
`\Neoblack\Webmcp\Registry\ToolRegistry::toolNames()` — the context-free
provider names — rather than against the per-page manifest.

> [!IMPORTANT]
> This is why `\Neoblack\Webmcp\Tool\ToolProviderInterface::name()` must be
> stable and must equal the `Manifest` name. A mismatch means valid tool
> calls are dropped by the ingest middleware.

> [!NOTE]
> **See also**
>
> -   [Writing tools](https://docs.typo3.org/permalink/neoblack/webmcp:developer@0.4) – the provider interface and manifest in detail.
> -   [Analytics](https://docs.typo3.org/permalink/neoblack/webmcp:analytics@0.4) – the event table and how the endpoint is hardened.
