WebMCP 

Extension key

neoblack_webmcp

Package name

neoblack/webmcp

Version

0.2

Language

en

Author

Frank Nägler & contributors

License

This document is published under the Creative Commons BY 4.0 license.


A declarative WebMCP tool framework for TYPO3. Define agent tools server-side as small PHP providers; the extension collects them into a per-page manifest and a generic JavaScript runtime registers each tool against document.modelContext / navigator.modelContext. Optional, privacy-preserving first-party analytics are included.


Table of contents:

Introduction 

What is WebMCP? 

WebMCP exposes page-scoped tools to AI agents through the browser's ModelContext interface (document.modelContext, with a fallback to navigator.modelContext used by Chrome's origin trial). An agent operating the page can discover these tools and call them – for example to search content, navigate to a section, or open a pre-filled contact e-mail.

What this extension does 

The extension turns tool definitions into a declarative concern:

  • A tool is a small PHP class implementing \Neoblack\Webmcp\Tool\ToolProviderInterface. It returns a Manifest describing the tool's name, JSON schema and behaviour.
  • Behaviour is expressed through one of four primitivesnavigate, search, mailto and static – whose generic interpreters live in a single JavaScript runtime (webmcp.js). No per-tool JavaScript is required.
  • A ToolManifestProcessor (a TYPO3 data processor) collects every registered provider into one JSON block per page; the runtime reads it and registers the tools.

Because tools are pure server-side configuration, other extensions and site packages can add their own without touching JavaScript. For behaviour that no primitive covers, a tool may point at its own ES module (escape hatch).

Privacy-preserving analytics 

An optional first-party ingest endpoint (/webmcp-event) logs one row per tool call – only the tool name and a coarse, self-reported client hint, never free text, cookies or IP. A backend module visualises the usage. Analytics can be disabled in the extension configuration.

Feature detection & progressive enhancement 

Everything degrades gracefully: without a ModelContext implementation, without configuration, or with a malformed manifest, nothing is registered and regular visitors are unaffected.

Installation 

Composer 

Install the extension with Composer:

composer require neoblack/webmcp
Copied!

Then set up the extension (creates the analytics table tx_neoblackwebmcp_event):

vendor/bin/typo3 extension:setup --extension neoblack_webmcp
Copied!

Requirements 

  • TYPO3 v14.3+
  • PHP 8.2+

The extension ships no site configuration of its own. It only becomes active once at least one tool provider is registered and the ToolManifestProcessor plus the webmcp.js runtime are wired into your site (see Configuration). Registering the tools themselves is described in Writing tools.

Quickstart 

This walkthrough takes you from a freshly installed extension to a working tool that an AI agent can call on the page. It uses the static primitive, so no JavaScript and no external index are required.

Prerequisites 

  • The extension is installed and set up (see Installation).
  • You have a site package where you can add PHP classes and TypoScript.

Step 1 – Write a tool provider 

Create a small PHP class in your site package (or any extension). Because the ToolProviderInterface carries the #[AutoconfigureTag('webmcp.tool')] attribute, an autoconfigured service implementing it is picked up automatically — no Services.yaml entry is needed as long as your extension enables autoconfiguration.

Classes/Tool/ServicesToolProvider.php
<?php

declare(strict_types=1);

namespace Vendor\SitePackage\Tool;

use Neoblack\Webmcp\Tool\Manifest;
use Neoblack\Webmcp\Tool\Primitive;
use Neoblack\Webmcp\Tool\ToolProviderInterface;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;

final class ServicesToolProvider implements ToolProviderInterface
{
    public function name(): string
    {
        return 'list_services';
    }

    public function manifest(ContentObjectRenderer $cObj, array $processedData): ?Manifest
    {
        return new Manifest(
            name: 'list_services',
            description: 'List the services this company offers.',
            inputSchema: ['type' => 'object', 'properties' => new \stdClass()],
            primitive: Primitive::StaticList,
            data: [
                'items' => [
                    ['title' => 'Consulting', 'url' => 'https://example.org/consulting'],
                    ['title' => 'Development', 'url' => 'https://example.org/development'],
                ],
                'resultKey' => 'services',
                'text' => [
                    'heading' => 'Our services:',
                    'line' => '{n}. {title} – {url}',
                ],
            ],
        );
    }
}
Copied!

Step 2 – Wire the manifest into the page 

Add the three pieces described in Configuration to your site package. In short:

TypoScript setup
page.10.dataProcessing {
    40 = Neoblack\Webmcp\DataProcessing\ToolManifestProcessor
    40 {
        endpoint = /webmcp-event
        as = webmcpConfigJson
    }
}

page.includeJSFooter {
    webmcp = EXT:neoblack_webmcp/Resources/Public/JavaScript/webmcp.js
    webmcp.defer = 1
}
Copied!

Render the JSON block once in your page template:

Fluid page template
<f:if condition="{webmcpConfigJson}">
    <script type="application/json" id="webmcp-config"><f:format.raw>{webmcpConfigJson}</f:format.raw></script>
</f:if>
Copied!

Step 3 – Verify it in the browser 

Reload a frontend page and open the browser's developer console.

  1. Confirm the manifest is on the page:

    JSON.parse(document.getElementById('webmcp-config').textContent).tools
    Copied!

    You should see your list_services tool in the returned array.

  2. Confirm the runtime registered it against the ModelContext. A ModelContext implementation is only present in agent-capable browsers (or Chrome with the origin trial enabled); if document.modelContext and navigator.modelContext are both undefined, the page is fine — there is simply no agent surface to register against. See Troubleshooting if the tool is missing where you do expect it.

Once an agent operates the page, it can discover list_services and call it; the runtime returns the curated list and (unless disabled) records one anonymous usage row visible in the backend module.

Next steps 

  • Swap static for another primitive — search, navigate or mailto — see Writing tools.
  • Return null from manifest() to hide a tool on pages where it does not apply.
  • Inspect usage in the System > WebMCP backend module (see Analytics).

Configuration 

Extension configuration 

Set these in the TYPO3 backend under Admin Tools > Settings > Extension Configuration > neoblack_webmcp (the defaults live in ext_conf_template.txt).

analyticsEnabled

analyticsEnabled
Type
boolean
Default
1

Log each WebMCP tool call (tool name + coarse client hint, no PII) to tx_neoblackwebmcp_event and expose the backend module. Turn off to disable the ingest endpoint (/webmcp-event is then passed through).

analyticsRateLimit

analyticsRateLimit
Type
integer
Default
60

Maximum accepted ingest calls per client IP per minute (0 = unlimited). Protects the public endpoint against flooding and statistics pollution; excess calls are answered with 429 Too Many Requests. The limiter uses the extension's own cache and only stores a hashed, short-lived counter — no plaintext IP.

Wiring the manifest into your site 

Three pieces connect the tools to the page. All of them live in your site package / TypoScript, so you stay in control of where the tools are exposed.

1. Emit the manifest 

Add the data processor to the page's FLUIDTEMPLATE (or PAGEVIEW). If a tool provider relies on an earlier data processor (e.g. a MenuProcessor), make sure that runs first.

page.10.dataProcessing {
    # optional: a menu a navigate tool can build on
    35 = menu
    35 {
        entryLevel = 0
        levels = 1
        as = webmcpTopics
    }
    40 = Neoblack\Webmcp\DataProcessing\ToolManifestProcessor
    40 {
        endpoint = /webmcp-event
        as = webmcpConfigJson
    }
}
Copied!

endpoint

endpoint
Type
string
Default
/webmcp-event

Analytics beacon target written into the manifest.

as

as
Type
string
Default
webmcpConfigJson

Variable the JSON manifest is assigned to.

2. Render the JSON block 

Output the manifest once per page inside a <script> tag with the id webmcp-config (the id the runtime looks for):

<f:if condition="{webmcpConfigJson}">
    <script type="application/json" id="webmcp-config"><f:format.raw>{webmcpConfigJson}</f:format.raw></script>
</f:if>
Copied!

3. Include the runtime 

page.includeJSFooter {
    webmcp = EXT:neoblack_webmcp/Resources/Public/JavaScript/webmcp.js
    webmcp.defer = 1
}
Copied!

Backend module 

When analytics is enabled, the System > WebMCP module visualises tool usage. Its data model, retention and the hardening of the public ingest endpoint are described in Analytics.

Writing tools 

A tool is a PHP class implementing \Neoblack\Webmcp\Tool\ToolProviderInterface. Thanks to the #[AutoconfigureTag('webmcp.tool')] attribute on the interface, any autoconfigured service implementing it is picked up automatically – in this extension, a site package, or any third-party extension. No manual Services.yaml wiring is needed.

The interface 

interface ToolProviderInterface
{
    // Context-free, stable name. Used for the analytics whitelist before
    // the frontend is resolved. Must equal the Manifest name.
    public function name(): string;

    // Return the tool's Manifest, or null to omit it for this request
    // (e.g. a blog tool on a site without a blog). $processedData carries
    // the results of data processors that ran earlier in the same content
    // object, so you can build on them (e.g. a menu).
    public function manifest(ContentObjectRenderer $cObj, array $processedData): ?Manifest;
}
Copied!

The manifest 

new Manifest(
    name: 'search_articles',
    description: 'Search all articles …',
    inputSchema: [ /* JSON schema for the arguments */ ],
    primitive: Primitive::Search,
    data: [ /* primitive-specific payload, see below */ ],
    moduleUrl: null, // optional escape hatch, see below
);
Copied!

The runtime injects a client string property into every tool's input schema automatically (for the optional analytics hint), so you do not declare it yourself.

Primitives 

Every tool maps to exactly one primitive. The generic runtime interprets the data payload; you never write JavaScript.

mailto 

Build a pre-filled mailto: link and open it. No server storage.

primitive: Primitive::Mailto,
data: [
    'to' => base64_encode('me@example.org'),      // base64, kept out of source
    'subjectTemplate' => 'Request – {anliegen}',
    'bodyLines' => [                              // "Label: value" lines
        ['label' => 'Name', 'param' => 'name'],
        ['label' => 'Organisation', 'param' => 'org', 'optional' => true],
    ],
    'messageParam' => 'message',                  // free text block at the end
    'successTemplate' => 'A pre-filled e-mail to {to} has been opened.',
]
Copied!

static 

Return a curated list verbatim.

primitive: Primitive::StaticList,
data: [
    'items' => [['title' => 'Software', 'url' => 'https://…']],
    'resultKey' => 'services',
    'text' => ['heading' => 'Services:', 'line' => '{n}. {title} – {url}'],
]
Copied!

Template placeholders 

The text templates use {field} placeholders filled from the item (or, for headings, from {count} / {query}). {n} yields the 1-based index of the current line.

Escape hatch 

If no primitive fits, leave primitive at any value, keep data minimal and set moduleUrl to the URL of an ES module exporting an execute function. The runtime imports the module lazily — on the tool's first call — and delegates to it. A moduleUrl is only used when no built-in primitive matches, so a custom module always wins the fallback, never overrides a primitive.

The contract 

// your-tool.js  (served same-origin, or CORS-enabled for dynamic import)
export function execute(args, ctx) {
    // args: the tool arguments the agent passed, already normalised
    //       (the runtime unwraps an { arguments: {…} } envelope for you).
    //       Includes the optional `client` analytics hint if the agent set it.
    //
    // ctx:  { tool, config }
    //   ctx.tool   – this tool's manifest object
    //                { name, description, inputSchema, primitive, data, moduleUrl }
    //   ctx.config – the whole page manifest { endpoint, tools: [...] }

    // Return an MCP tool result. `content` is required; `structuredContent`
    // is optional machine-readable output. You may return a Promise.
    return {
        content: [{ type: 'text', text: 'Done.' }],
        structuredContent: { ok: true },
    };
}
Copied!

Notes:

  • Analytics is already handled. The runtime fires the usage beacon before importing your module, so you do not call the endpoint yourself.
  • Keep ``data`` for your own config. Anything the primitives don't use is yours; read it from ctx.tool.data.
  • Errors surface to the agent. A thrown error or rejected Promise propagates as the tool call's failure — return a normal result for the "no match / unavailable" case instead of throwing.
  • Loading is best-effort and lazy. The module is fetched only on first call; a failed import means that one call fails, nothing else on the page is affected.

Analytics 

Every tool call sends a same-origin beacon to the configured endpoint. The ToolRegistry::toolNames() list (all registered providers) is the whitelist the ingest middleware validates against – it follows your tools automatically. For the recorded fields, retention and endpoint hardening see Analytics.

See also 

  • Quickstart – a full end-to-end example using the static primitive.
  • Architecture – how providers, the registry, the processor and the runtime fit together.
  • Analytics – what the beacon records and how the ingest endpoint is protected.

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) 

ToolProvider(s)                 one small PHP class per tool
    │  manifest($cObj, $processedData)  → Manifest | null
    ▼
ToolRegistry::collect()         drops providers that return null
    │  list<Manifest>
    ▼
ToolManifestProcessor           TYPO3 data processor
    │  JSON: { endpoint, tools:[…] }   (JSON_HEX_* escaped)
    ▼
<script id="webmcp-config">     rendered once per page by your template
    │
    ▼
webmcp.js runtime               reads the block, feature-detects ModelContext
    │  per tool: picks the primitive interpreter (or imports moduleUrl)
    ▼
document.modelContext.registerTool()   agent can now discover & call the tool
Copied!

Key classes:

  • \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.
  • Resources/Public/JavaScript/webmcp.js – the generic runtime holding all four primitive interpreters and the escape-hatch loader.

Ingesting usage events (call time) 

webmcp.js  ──POST /webmcp-event──►  EventMiddleware
  navigator.sendBeacon                 │  same-origin guard (Sec-Fetch-Site)
  { tool, client }                     │  RateLimiter::allow(ip, limit)
                                       │  tool ∈ ToolRegistry::toolNames() ?
                                       ▼
                                 EventRepository::log(tool, client, ts)
                                       │
                                       ▼
                              tx_neoblackwebmcp_event  (append-only)

Backend:  DashboardController ──► StatisticsService ──► EventRepository
          (System > WebMCP module)     aggregates by tool / client / day
Copied!

Key classes:

  • \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 

The middleware runs early, before the frontend page is resolved, so it cannot rely on a rendered manifest. It therefore validates incoming events against ToolRegistry::toolNames() — the context-free provider names — rather than against the per-page manifest. This is why ToolProviderInterface::name() must be stable and must equal the Manifest name.

Analytics 

The extension ships an optional, first-party usage log: one anonymous row per tool call, visualised in a backend module. It is enabled by default and can be switched off entirely (see Configuration).

What is recorded 

Every tool call fires a small same-origin beacon (navigator.sendBeacon, with a fetch fallback) to the ingest endpoint. The EventMiddleware appends exactly two values per call:

  • the tool name – validated against the registered providers ( ToolRegistry::toolNames()); events for unknown names are not stored.
  • a client hint – a short, self-reported or coarsely detected label of the calling agent.

Nothing else is stored: no free text (search queries, e-mail contents, names), no cookies, no IP address, no user agent string.

The client hint 

The hint is best-effort and resolved client-side in this order:

  1. the agent's optional client argument, if it sets one (the runtime adds a client property to every tool's input schema automatically);
  2. otherwise a coarse match against known agent markers in the User-Agent (e.g. Claude, ChatGPT, GeminiBot);
  3. otherwise the literal unbekannt ("unknown").

Server-side the value is sanitised (letters, digits, space and ._-/() only) and capped at 64 characters before it is stored.

Data model 

Events live in a single append-only table, created by extension:setup:

tx_neoblackwebmcp_event
uid     int          primary key
crdate  int          unix timestamp of the call
tool    varchar(64)  registered tool name
client  varchar(64)  sanitised client hint
Copied!

There is no pid, no relation to a user or session, and no additional payload column — the schema itself makes it impossible to store PII.

Retention and deletion 

The table is append-only; the extension does not prune it automatically. Because a row is fully anonymous, there is no per-subject deletion to perform, but you remain free to trim or clear the log at any time, for example:

-- drop everything older than 90 days
DELETE FROM tx_neoblackwebmcp_event WHERE crdate < UNIX_TIMESTAMP() - 90*86400;

-- clear the log completely
TRUNCATE tx_neoblackwebmcp_event;
Copied!

If you schedule this, a simple periodic DELETE on crdate is enough; the crdate column is indexed.

The ingest endpoint 

POST /webmcp-event is public by design (agents call it without a backend session). It is protected by several deliberate constraints:

  • Feature-gated: when analyticsEnabled is off the endpoint is inert and the request is passed through untouched.
  • Same-origin guard: requests whose Sec-Fetch-Site header is present and neither same-origin nor same-site are rejected with 204.
  • Rate limited: at most analyticsRateLimit calls per client IP per minute (default 60; 0 disables). Excess calls receive 429. The limiter stores only a hashed IP + time-window counter that expires after two windows — the plaintext IP is never persisted.
  • Whitelisted: only registered tool names are logged; anything else is handed down the middleware stack.

Backend module 

When analytics is enabled, the System > WebMCP module shows calls per tool and per client, a per-day timeline and the most recent events. The timeframe (7 / 30 / 90 days) and tool/client filters are selectable; the accepted tool names are derived automatically from the registered providers.

The module is not page-related: it has no page tree and shows site-wide figures, mirroring the event table which has no pid. It lives in the System menu; there is nothing to pick in a tree.

The WebMCP backend module showing calls per tool and a timeline

The WebMCP dashboard: calls per tool/client, and a per-day timeline.

The WebMCP backend module showing also the recent calls in a table

The WebMCP dashboard: The most recent events.

Troubleshooting 

Everything in the extension degrades silently on purpose — a missing manifest, an absent ModelContext or a malformed config simply registers nothing. That is good for visitors but means failures are quiet. Work through the checks below in order.

No tools are registered 

Check, from the outside in:

  1. Is the JSON block on the page? View source and look for <script type="application/json" id="webmcp-config">. In the console:

    document.getElementById('webmcp-config')?.textContent
    Copied!
    • Empty / missing → the manifest was not rendered. Verify the Fluid snippet (Step 2 in Quickstart) is in your template and that the variable name matches the data processor's as (default webmcpConfigJson).
  2. Does the block contain your tool?

    JSON.parse(document.getElementById('webmcp-config').textContent).tools
    Copied!
    • Empty array → no provider yielded a manifest. Either the provider is not registered (see below) or its manifest() returned null for this request.
  3. Is the runtime loaded? Confirm webmcp.js is included in the page footer (the includeJSFooter TypoScript).
  4. Is there a ModelContext to register against?

    document.modelContext || navigator.modelContext
    Copied!
    • undefined in both → the browser has no agent surface. This is expected in a normal browser; the runtime intentionally does nothing. Test with an agent-capable client or Chrome with the WebMCP origin trial enabled.

My provider is never called 

The provider is a service tagged webmcp.tool. If it is not picked up:

  • Confirm your extension enables autoconfiguration in Configuration/Services.yaml (_defaults with `autoconfigure: true`). The tag is inherited from the interface's #[AutoconfigureTag('webmcp.tool')] only when autoconfiguration is on.
  • Otherwise tag the service manually (see the note in Quickstart).
  • Clear the TYPO3 caches after adding a new service.

A tool depends on an earlier data processor 

Providers receive $processedData from data processors that ran before the ToolManifestProcessor in the same content object. If your provider builds on, say, a MenuProcessor output, that processor must have a lower key so it runs first (see the ordered example in Configuration).

Analytics events are not recorded 

  • Is analytics enabled? Check analyticsEnabled in the extension configuration. When off, /webmcp-event is passed through and nothing is stored.
  • Is the tool name whitelisted? Only names returned by a registered provider's name() are logged, and name() must equal the Manifest name. A mismatch silently drops the event.
  • Getting 429s? You are hitting the rate limit (analyticsRateLimit calls per IP per minute). Raise it or set 0 to disable — see Analytics.
  • Getting 204 but no row? A 204 is also returned for cross-site posts rejected by the Sec-Fetch-Site guard and for the normal success path. Confirm the beacon is a genuine same-origin POST to /webmcp-event.

The manifest looks corrupted / cut off 

Tool values are embedded verbatim inside a <script> block. The processor escapes < > & ' " as \uXXXX (JSON_HEX_*), so a </script> inside an editor-controlled value cannot break out. If you see raw HTML entities where you expected characters, do not disable those flags — decode on read instead.

Changelog 

This extension is experimental. Until a 1.0.0 release, the API described in this documentation may change or break between versions without notice.

Unreleased 

  • (nothing yet)

0.2.0 - 2026-07-19 

  • Breaking: the backend module moved from the Web group to System, and its route identifier changed from web_webmcp to system_webmcp. The module no longer carries a page tree — its statistics are site-wide. Update any backend user/group access rights and hardcoded module links accordingly.
  • Changed: the backend module icon was redrawn in the three-colour TYPO3 v14 icon style.
  • Added: an ext_emconf.php so the extension can be published to and installed from the TER.
  • Documentation: added a Quickstart, an Architecture overview, a dedicated Analytics chapter (data model, retention, endpoint hardening), a Troubleshooting guide and this changelog.

0.1.0 

Initial experimental release.

  • Declarative tool framework: define agent tools as server-side PHP providers ( ToolProviderInterface) collected into a per-page manifest.
  • Four behaviour primitives interpreted by a single generic runtime (webmcp.js): navigate, search, mailto, static.
  • Escape hatch: a tool may point at its own ES module for behaviour no primitive covers.
  • Optional, privacy-preserving first-party analytics: anonymous per-call logging via /webmcp-event, rate limiting, and a System > WebMCP backend module.
  • Requires TYPO3 v14.3+ and PHP 8.2+.