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

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
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: …
);
Copied!
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.

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 

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 

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), search and static merely read. Pass consequential: true/false to override, for example for an escape-hatch module that submits data.

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 

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

Primitives 

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

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

search primitive
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.',
    ],
]
Copied!
Agentwebmcp.jsJSON indexAgentwebmcp.jsJSON index(same-origin)Agentwebmcp.jsJSON index(same-origin)call with query + limitsend analytics beaconfetch indexUrl(cached per page)itemsfilter by searchFields(all query terms must match)slice to limit,project resultFieldstext + structuredContent (hits)
How the search primitive resolves a query

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

mailto primitive
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.',
]
Copied!

Return a curated list verbatim.

static primitive
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.

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().

(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 

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.

Agentwebmcp.jsyour-tool.jsAgentwebmcp.jsyour-tool.js(ES module)Agentwebmcp.jsyour-tool.js(ES module)first tool callsend analytics beaconimport(moduleUrl)(once, then cached){ execute }execute(args, ctx)MCP result(content, structuredContent)result
Escape hatch — a custom module loads lazily on first call

The contract 

your-tool.js — custom execute() implementation
// 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 },
    };
}
Copied!

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.