---
title: "Writing tools"
manual: "WebMCP tool for TYPO3"
version: "0.4"
permalink: "https://docs.typo3.org/permalink/neoblack/webmcp:developer@0.4"
source: "Developer/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.

# Writing tools {#developer}

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](https://docs.typo3.org/permalink/neoblack/webmcp:the-interface@0.4)
-   [The manifest](https://docs.typo3.org/permalink/neoblack/webmcp:the-manifest@0.4)
-   [Read-only hint](https://docs.typo3.org/permalink/neoblack/webmcp:read-only-hint@0.4)
-   [Untrusted-content hint](https://docs.typo3.org/permalink/neoblack/webmcp:untrusted-content-hint@0.4)
-   [Consequential hint](https://docs.typo3.org/permalink/neoblack/webmcp:consequential-hint@0.4)
-   [Debugging annotation](https://docs.typo3.org/permalink/neoblack/webmcp:debugging-annotation@0.4)
-   [Defaults per primitive](https://docs.typo3.org/permalink/neoblack/webmcp:defaults-per-primitive@0.4)
-   [Recommended limits](https://docs.typo3.org/permalink/neoblack/webmcp:recommended-limits@0.4)
-   [Primitives](https://docs.typo3.org/permalink/neoblack/webmcp:primitives@0.4)
-   [Template placeholders](https://docs.typo3.org/permalink/neoblack/webmcp:template-placeholders@0.4)
-   [Confirming side effects](https://docs.typo3.org/permalink/neoblack/webmcp:confirming-side-effects@0.4)
-   [Escape hatch](https://docs.typo3.org/permalink/neoblack/webmcp:escape-hatch@0.4)
-   [Analytics](https://docs.typo3.org/permalink/neoblack/webmcp:analytics@0.4)

## The interface {#the-interface}

-   **interface ToolProviderInterface**

    -   *Fully qualified name:* `\Neoblack\Webmcp\Tool\ToolProviderInterface`

    Implemented by every tool and tagged `webmcp.tool` via the interface's
    `#[AutoconfigureTag]` attribute.

    -   **name()**

        Context-free, stable tool name. Used for the analytics whitelist before
        the frontend is resolved, so it must equal the `Manifest` name.

        *Returns:* `string` – the stable tool name

    -   **manifest($cObj, $processedData)**

        Build the tool's manifest for the current request.

        -   *param ContentObjectRenderer $cObj:* the current content object renderer
        -   *param array $processedData:*

            results of data processors that ran earlier
            in the same content object, so you can build on them (e.g. a menu)

        *Returns:* a `Manifest`, or `null` to omit the tool for this request (e.g. a blog tool on a site without a blog)

## The manifest {#the-manifest}

-   **class Manifest**

    -   *Fully qualified name:* `\Neoblack\Webmcp\Tool\Manifest`

    The serialisable description of a single tool. A provider returns one of
    these; the data processor collects them into the page's JSON block.

Construct it with named arguments:

**Building a Manifest**

```php
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
    // optional overrides, see the hint sections below:
    // readOnly: …, title: …, untrustedContent: …, consequential: …, debugging: …
);
```

| Argument | Type | Purpose |
| --- | --- | --- |
| `name` | `string` | Tool name; must equal `ToolProviderInterface::name()`. |
| `description` | `string` | Human-readable description shown to the agent. |
| `inputSchema` | `array` | JSON schema for the tool arguments. |
| `primitive` | `Primitive` | One of the four behaviour primitives. |
| `data` | `array` | Primitive-specific payload (see below). |
| `moduleUrl` | `?string` | Optional ES-module URL for the escape hatch. |
| `readOnly` | `?bool` | Override the read-only hint. `null` (default) derives it from the primitive; see below. |
| `title` | `?string` | Optional human-readable label for UI display. The machine-stable `name` is used when omitted. |
| `untrustedContent` | `?bool` | Override the untrusted-content hint. `null` (default) derives it from the primitive; see below. |
| `consequential` | `?bool` | Override the consequential hint. `null` (default) derives it from the primitive; see below. |
| `debugging` | `bool` | Mark the tool as a debugging aid. Default `false`; the annotation is only emitted when set to `true`. |

> [!NOTE]
> 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.

## Read-only hint {#read-only-hint}

Each manifest carries a WebMCP `annotations.readOnlyHint` flag telling the agent
whether the tool merely reads state or changes it. Agents use it to decide whether
a call may run without user confirmation.

The value is derived from the primitive: `search` and `static` are read-only,
`navigate` (changes the browser location) and `mailto` (opens a mail client)
are not. Pass `readOnly: true`/`false` explicitly to override the default — for
example when an escape-hatch module built on the `search` primitive actually
mutates state.

## Untrusted-content hint {#untrusted-content-hint}

The manifest also carries a WebMCP `annotations.untrustedContentHint` flag. It
tells the agent that the tool's *output* may contain untrusted, third-party data
that should be treated with caution — a prompt-injection defence.

It is derived from the primitive: only `search` is flagged, because its results
come from a JSON index that can hold user-generated content. `static` is curated,
and `navigate` / `mailto` only return messages the runtime built itself, so all
three default to `false`. Pass `untrustedContent: true`/`false` to override —
for instance when a `static` list is assembled from user-supplied data, or an
escape-hatch module returns third-party content.

## Consequential hint {#consequential-hint}

`annotations.consequentialHint` tells the agent that a call has a consequence
the user may want to review before it happens. It is derived from the primitive:
only `mailto` is flagged, because it hands a composed message to the visitor's
mail client — an action outside your site. `navigate` only moves within the
site (and can ask the user itself, see [Confirming side effects](https://docs.typo3.org/permalink/neoblack/webmcp:confirming-side-effects@0.4)), `search`
and `static` merely read. Pass `consequential: true`/`false` to override,
for example for an escape-hatch module that submits data.

## Debugging annotation {#debugging-annotation}

`debugging: true` emits `annotations.debugging`, which the specification
defines for tools meant for development and testing. It is never set by default;
only set it for tools you do not want agents to use in normal operation.

## Defaults per primitive {#defaults-per-primitive}

| Primitive | `readOnlyHint` | `untrustedContentHint` | `consequentialHint` | `debugging` |
| --- | --- | --- | --- | --- |
| `navigate` | `false` | `false` | `false` | omitted |
| `search` | `true` | `true` | `false` | omitted |
| `mailto` | `false` | `false` | `true` | omitted |
| `static` | `true` | `false` | `false` | omitted |

## Recommended limits {#recommended-limits}

The specification requires tool names of 1–128 characters from
`A-Z a-z 0-9 _ - .`. Beyond that, Chrome's
[Secure tools](https://developer.chrome.com/docs/ai/webmcp/secure-tools)
guidance recommends tighter budgets. They are **recommendations, not rules**:

| What | Recommended maximum |
| --- | --- |
| Tool name, parameter name | 30 characters |
| Tool description | 500 characters |
| Parameter description | 150 characters |
| Text output of one call | 1,500 characters |

The `\Neoblack\Webmcp\DataProcessing\ToolManifestProcessor` checks every
manifest with `\Neoblack\Webmcp\Tool\ManifestValidator` and writes a
**warning to the TYPO3 log** for each name or description over these budgets and
for names outside the specification's pattern. The tool is emitted regardless.
The output budget is enforced at runtime via [outputLimit](https://docs.typo3.org/permalink/neoblack/webmcp:confval-dataprocessor-outputlimit@0.4).

> [!NOTE]
> Tools are only registered in the top-level document, never inside iframes
> (see [Configuration](https://docs.typo3.org/permalink/neoblack/webmcp:configuration@0.4)).

## Primitives {#primitives}

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

**navigate**

Navigate the browser to a URL chosen from a fixed option set.

**navigate primitive**

```php
primitive: Primitive::Navigate,
data: [
    'param' => 'kategorie',                       // which argument selects
    'options' => [
        ['match' => 'software', 'label' => 'Software', 'url' => 'https://…'],
    ],
    'confirm' => 'Open „{label}"?',               // optional; see below
    'messages' => [                               // optional templates
        'success' => 'Navigating to „{label}".',
        'unknown' => 'Unknown option. Available: {options}.',
        'cancelled' => 'Navigation to „{label}" was cancelled.',
    ],
]
```

**search**

Fetch a same-origin JSON index, filter it by the query terms, return
structured hits.

**search primitive**

```php
primitive: Primitive::Search,
data: [
    'indexUrl' => 'https://…/index.json',
    'queryParam' => 'query',
    'limitParam' => 'limit',
    'limitDefault' => 10,
    'queryRequired' => true,                      // false: list all when empty
    'searchFields' => ['title', 'teaser'],        // fields searched
    'resultKey' => 'results',
    'resultFields' => ['title' => 'title', 'cat' => 'categoryLabel'],
    'deepLinkTemplate' => 'https://…/blog#q={query}', // optional
    'text' => [                                   // optional output templates
        'heading' => '{count} hits for „{query}“:',
        'headingAll' => '{count} entries:',
        'line' => '{n}. {title} – {url}',
        'emptyQuery' => 'No hits for „{query}“.',
        'emptyAll' => 'No entries.',
    ],
]
```

> [!TIP]
> `resultFields` may be a list (pick fields 1:1) or a map
> (`output => source` rename); omit it to pass items through
> unchanged.

```plantuml
actor Agent
participant "webmcp.js" as RT
participant "JSON index\n(same-origin)" as IDX

Agent -> RT : call with query + limit
RT -> RT : send analytics beacon
RT -> IDX : fetch indexUrl\n(cached per page)
IDX --> RT : items
RT -> RT : filter by searchFields\n(all query terms must match)
RT -> RT : slice to limit,\nproject resultFields
RT --> Agent : text + structuredContent (hits)
```

How the search primitive resolves a query
**mailto**

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

**mailto primitive**

```php
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
    'confirm' => 'Send this e-mail to {to}?',      // optional; see below
    'successTemplate' => 'A pre-filled e-mail to {to} has been opened.',
]
```

**static**

Return a curated list verbatim.

**static primitive**

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

## Template placeholders {#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.

## Confirming side effects {#confirming-side-effects}

The `navigate` and `mailto` primitives change state — they move the browser or
open a mail client. Set a `confirm` message on their `data` to require a
human-in-the-loop confirmation *before* the side effect runs. The runtime uses
`requestUserInteraction()` where the browser offers it on the `execute`
callback's second argument and otherwise a plain `confirm()`.

> [!NOTE]
> The current specification draft (2026-10-02) does not define
> `requestUserInteraction()`; Chrome lists it as planned. In today's
> implementations the `confirm()` fallback is therefore the usual path. If the user declines, the tool returns an `isError` result

(`navigate` uses the `cancelled` message when set) and the side effect never
happens.

Omit `confirm` to keep the previous behaviour — the tool runs without asking. The
`confirm` string is filled with the same `{placeholders}` as the other
messages (`navigate`: the chosen option; `mailto`: `{to}` and `{subject}`).

## Escape hatch {#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.

```plantuml
actor Agent
participant "webmcp.js" as RT
participant "your-tool.js\n(ES module)" as MOD

Agent -> RT : first tool call
RT -> RT : send analytics beacon
RT -> MOD : import(moduleUrl)\n(once, then cached)
MOD --> RT : { execute }
RT -> MOD : execute(args, ctx)
MOD --> RT : MCP result\n(content, structuredContent)
RT --> Agent : result
```

Escape hatch — a custom module loads lazily on first call

### The contract {#the-contract}

**your-tool.js — custom execute() implementation**

```javascript
// 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, client }
    //   ctx.tool   – this tool's manifest object
    //                { name, description, inputSchema, primitive, data, moduleUrl }
    //   ctx.config – the whole page manifest { endpoint, tools: [...] }
    //   ctx.client – the second argument the browser passed to execute():
    //                in the current draft an object with an AbortSignal
    //                ({ signal }); older implementations passed a client
    //                with requestUserInteraction(). May be undefined.

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

> [!TIP]
> **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 and read it from `ctx.tool.data`.

> [!WARNING]
> **Errors surface to the agent.** A thrown error or rejected Promise
> propagates as the tool call's failure — for a recoverable "no match /
> unavailable" case return a normal result with `isError: true` instead of
> throwing, so the agent learns the call failed but the page stays healthy:
>
> ```javascript
> return { content: [{ type: 'text', text: 'No match.' }], isError: true };
> ```
>
> Loading is lazy and best-effort: a failed import fails only that one call,
> nothing else on the page is affected. The built-in primitives already flag
> their failure paths (e.g. `navigate` with an unknown option) this way.

## Analytics {#analytics}

Every tool call sends a same-origin beacon to the configured endpoint. The
`\Neoblack\Webmcp\Registry\ToolRegistry::toolNames()` list (all registered
providers) is the whitelist the ingest middleware validates against – it follows
your tools automatically.

> [!NOTE]
> **See also**
>
> -   [Quickstart](https://docs.typo3.org/permalink/neoblack/webmcp:quickstart@0.4) – a full end-to-end example using the `static` primitive.
> -   [Architecture](https://docs.typo3.org/permalink/neoblack/webmcp:architecture@0.4) – how providers, the registry, the processor and the
>     runtime fit together.
> -   [Analytics](https://docs.typo3.org/permalink/neoblack/webmcp:analytics@0.4) – what the beacon records and how the ingest endpoint is
>     protected.
