Warning
Experimental. This extension is experimental and not yet ready for production use. It is built on top of 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
A tool is a PHP class implementing
\Neoblack\. 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. wiring is needed.
The interface
- interface ToolProviderInterface
-
- Fully qualified name
-
\Neoblack\
Webmcp\ Tool\ Tool Provider Interface
Implemented by every tool and tagged
webmcp.toolvia 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
Manifestname.- 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, ornullto 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:
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
);
| Argument | Type | Purpose |
|---|---|---|
name | string | Tool name; must equal
Tool. |
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. |
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
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.
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.
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.
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
'confirm' => 'Send this e-mail to {to}?', // optional; see below
'successTemplate' => 'A pre-filled e-mail to {to} has been opened.',
]
Return a curated list verbatim.
primitive: Primitive::StaticList,
data: [
'items' => [['title' => 'Software', 'url' => 'https://…']],
'resultKey' => 'services',
'text' => ['heading' => 'Services:', 'line' => '{n}. {title} – {url}'],
]
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 prefers
the WebMCP client's requestUserInteraction() (which lets the agent surface the
page to the user first) and falls back to a plain confirm() when the client
does not provide it. 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
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
// 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 WebMCP ModelContextClient (may be undefined); call
// ctx.client.requestUserInteraction(cb) for confirmations
// 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 },
};
}
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..
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:
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
Every tool call sends a same-origin beacon to the configured endpoint. The
\Neoblack\ list (all registered
providers) is the whitelist the ingest middleware validates against – it follows
your tools automatically.
See also
- Quickstart – a full end-to-end example using the
staticprimitive. - Architecture – how providers, the registry, the processor and the runtime fit together.
- Analytics – what the beacon records and how the ingest endpoint is protected.