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.