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
{
// 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;
}
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
);
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.
search
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.',
],
]
resultFields may be a list (pick fields 1:1) or a map (output => source
rename); omit it to pass items through unchanged.
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.',
]
static
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.
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 },
};
}
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
Tool 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
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.