The AI tool-execution runtime for the Milpa PHP framework, built on
milpa/core. It runs the loop every Milpa module declares:plugin → capability → tool → verification → event → result.#[Tool]-attributed methods become a registry pipeline — resolve, validate, authorize, execute, audit — with policy gates, rate limiting, channel-aware rendering, and human/agent verification as first-class seams.
milpa/tool-runtime is where milpa/core's agent-tool-readiness seam becomes a working
engine. Milpa\Interfaces\Tooling\ToolProviderInterface and ToolRegistryInterface are
contracts defined in core; this package is the concrete ToolRegistry that resolves,
validates, authorizes, executes, and audits every call, plus the #[Tool] attribute that
lets a plain PHP method declare itself as agent-callable. No Doctrine, no HTTP kernel, no
concrete policy storage — those live in your host application.
composer require milpa/tool-runtimeAttribute a method with #[Tool]; parameters describe themselves with #[Param]:
use Milpa\ToolRuntime\Attributes\Param;
use Milpa\ToolRuntime\Attributes\Tool;
use Milpa\ToolRuntime\ToolResult;
final class NoteTools
{
#[Tool('list_notes', 'List saved notes', scopes: ['notes:read'])]
public function listNotes(
#[Param('Page number', clamp: [1, 1000])] int $page = 1
): ToolResult {
return ToolResult::success(['notes' => [], 'page' => $page]);
}
}ToolScanner reflects the class for #[Tool] methods and registers them; ToolRegistry
runs the full pipeline on every call:
use Milpa\ToolRuntime\Contracts\ToolContext;
use Milpa\ToolRuntime\ToolRegistry;
use Milpa\ToolRuntime\ToolScanner;
use Psr\Log\NullLogger;
$registry = new ToolRegistry(new NullLogger());
(new ToolScanner($registry))->scan(new NoteTools());
$result = $registry->call('list_notes', ['page' => 1], ToolContext::cli());
$result->success; // true
$result->data; // ['notes' => [], 'page' => 1]
$result->toJson(); // {"success":true,"data":{...},"message":null,"error":null,"meta":{...}}No ToolContext is required — call() defaults to ToolContext::cli() (full-access,
for scripts and tests). Real hosts build one per channel: ToolContext::mcp($requestId, $principal, $scopes) for an authenticated MCP caller, ToolContext::stdio($requestId) for a
trusted local stdio MCP server process (no per-caller auth — see
Authorize below), ToolContext::telegram($chatId, $userId), or a custom
new ToolContext(...) for a web session.
A PHP array has no native type that maps to JSON-Schema type: object on its own —
without an override, ToolScanner infers array -> schema type: array, and
SchemaValidator then requires that value to be a JSON list, rejecting an associative
payload like {"post_id": 1} outright. Pass type: 'object' on #[Param] to opt a PHP
array $param into type: object instead; add properties/requiredProperties to declare
its shape (both optional — omit them for an open object with no declared shape):
#[Tool('update_post', 'Update fields on a post')]
public function updatePost(
int $post_id,
#[Param('Fields to update', type: 'object', properties: [
'title' => ['type' => 'string'],
'body' => ['type' => 'string'],
])]
array $updates
): ToolResult {
// $updates arrives as a plain associative array — ['title' => ..., 'body' => ...] —
// no manual json_decode() needed; the host's JSON transport already decoded it that way.
return ToolResult::success(['post_id' => $post_id, 'updates' => $updates]);
}This is purely opt-in (tool-runtime 0.6): a bare array $param with no #[Param(type: ...)]
override keeps generating type: array and keeps requiring a list, exactly as before.
ToolScanner takes each wire argument name straight from ReflectionParameter::getName() —
there is no snake_case conversion. A PHP parameter string $instanceId produces the schema
property instanceId, not instance_id; a caller sending instance_id gets a "missing
required field" error. Every single-word parameter is unaffected (there is nothing to
convert), but a multi-word wire name must be matched by naming the PHP parameter itself in
that exact case — e.g. string $instance_id for a snake_case-conventioned tool family, or
string $instanceId for a camelCase one. Pick the PHP parameter name to match your tool
family's own wire convention; #[Param]'s description/type/etc. do not rename the
property.
Every ToolRegistry::call() runs the same six steps, in order, regardless of who is
calling — a human over cli, an LLM over mcp, or a bot over telegram:
-
Resolve — look up the tool by name; an unknown name is a typed
ToolResult::error()(ToolResult::TOOL_NOT_FOUND), never an exception. -
Validate —
SchemaValidatorchecks the arguments against the tool's JSON input schema (required fields, types), then applies numericclampsbefore execution. -
Authorize —
PolicyGatechecks the caller'sToolContextscopes against the tool's required scopes, then falls back to per-channel policy (cliallows all,mcpandwebrequire auth by default). A host can plug inPolicyRuleProviderInterfacefor database-backed rules, and an optionalRateLimiterInterfacethrottles bychannel:principal:tool. -
Confirm (mutating tools only) — a tool declared
confirm: true(or matching a channel'srequire_confirmation_for_mutatingpolicy) returns aconfirm_tokenon the first call instead of executing; the caller replays the same arguments plus that token to proceed.ConfirmationTokenStoreholds the pending action and its expiry.The redemption contract, precisely: on the first call,
ConfirmationTokenStore::create()snapshots the exact args of that call (name + args + a 60s-default expiry) and hands back a random token. The caller is expected to replay the same arguments plusconfirm_tokenon the second call — but the runtime does not diff or validate that replay:ToolRegistry::call()stripsconfirm_tokenoff the incoming args, callsConfirmationTokenStore::consume($token, $name), and — if the token is valid, unexpired, and minted for this tool name — discards whatever args the second call actually sent and executes with the args stored atcreate()time instead. A token is one-time-use (deleted on consume) and matched only by$name, not by argument identity. Practically: the second call's args (other thanconfirm_tokenitself) are inert — the tool executes with the first call's arguments, not the second's. -
Execute — the tool's callback runs with a soft timeout; a bare return value is wrapped in
ToolResult::success()automatically, and an uncaughtThrowablebecomesToolResult::error()(ToolResult::INTERNAL_ERROR) instead of propagating. -
Audit —
ToolAuditLoggerrecords every call (success, failure, or rejection) via PSR-3, redacting sensitive argument fields (password,token,secret, …) before they ever reach a log line. Since 0.5, the success/failure legs of this are event-driven — see Events below.
Since 0.5, one more thing happens between step 4 and step 5: if a MilpaEventDispatcherInterface
is wired, ToolRegistry dispatches tool.executing — a listener may short-circuit the call
(answer on the tool's behalf, e.g. a cache) or veto it outright, both without step 5 ever running.
See Events.
A ToolContext built with mode: 'plan' (or ToolContext::asPlan()) short-circuits after
step 3: it validates and authorizes but never executes, returning the would-be plan instead
— a dry-run for any tool, for free.
Denials say which check failed. When step 3 denies a call, ToolResult::error()'s
message names the specific check and what was missing — not a bare "forbidden". A channel
that requires auth reports "channel 'mcp' requires an authenticated principal (require_auth) — none provided."; a scope mismatch reports "Missing required scope for tool 'resolve_verification'. Need one of: verification:resolve — context has: tasks:write."; a
block_mutating channel policy names the tool and the channel; a PolicyRuleProviderInterface
denial names the rule id, the tool, and the channel; a rate-limit denial names the exact
channel:principal:tool key that hit its budget. The error code stays
ToolResult::FORBIDDEN (or ToolResult::RATE_LIMITED) either way — callers that match on the
code are unaffected; only the message got specific enough to debug from the error alone.
Trusted local stdio MCP servers: a no-auth mcp transport (an editor or agent runtime
spawning your server as a child process, with no separate per-caller identity to authenticate)
should build its ToolContext with ToolContext::stdio($requestId) — it hard-codes
principal: 'stdio' and the wildcard ['*'] scope, the same "process boundary IS the trust
boundary" shape ToolContext::cli() already uses for CLI scripts. This exists because the
mcp channel's built-in policy sets require_auth: true: a bare new ToolContext(channel: 'mcp') (no principal) hits exactly the denial described above, one call at a time, with no
documented way out before this factory existed.
Since 0.5 (the event-driven retrofit), ToolRegistry accepts an optional
Milpa\Interfaces\Event\MilpaEventDispatcherInterface as its second constructor argument — the
same nullable-dispatcher-in-the-ctor pattern HumanVerifier already uses below. Without one,
call() behaves exactly as it did before 0.5: there is no interception point, and
ToolAuditLogger still logs every call (see below) — "no dispatcher" never means "no audit
trail", it only means "nothing can intercept."
With a dispatcher wired, three events fire around every call:
| Event | When | Carries | Slot? |
|---|---|---|---|
tool.executing |
PRE — after resolve, validate/clamp, PolicyGate::authorize(), rate-limiting, and the confirm-gate have all already run and passed |
Events\ToolExecutingEvent($name, $ctx, $args) |
Yes — a Milpa\Events\InterceptionSlot travels alongside it |
tool.executed |
POST — a call finished, live or via a cache short-circuit | Events\ToolExecutedEvent($name, $ctx, $args, $result, $cacheServed) |
No — readonly notification |
tool.failed |
POST — the tool's own callback threw | Events\ToolFailedEvent($name, $ctx, $args, $exception, $tookMs) |
No — readonly notification |
tool.executing is dispatched from exactly one place inside ToolRegistry::call(): after
every gate — resolve → validate/clamp → authorize → rate-limit → confirm — has already run and
said yes, and before call_user_func() ever invokes the tool's callback. This ordering is
load-bearing, not incidental: a tool.executing listener (a cache plugin, say) only gets a turn
once authorization has already cleared the call. A denied or rate-limited call returns before
tool.executing is ever dispatched — the listener is never invoked for it, full stop. Moving this
dispatch any earlier (e.g. "before validate" or "before authorize") would let a cache hit stand in
for an authorization check that never ran — an auth bypass wearing a cache's clothes.
A listener on tool.executing can answer on the tool's behalf via the InterceptionSlot
dispatched alongside the event — the tool's real callback never runs:
use Milpa\Events\InterceptionSlot;
use Milpa\ToolRuntime\Events\ToolExecutingEvent;
use Milpa\ToolRuntime\ToolResult;
$dispatcher->subscribe('tool.executing', function (string $eventName, array $payload): void {
/** @var ToolExecutingEvent $event */
$event = $payload['event'];
/** @var InterceptionSlot $slot */
$slot = $payload['slot'];
$cached = $myCache->get($event->name, $event->args);
if ($cached !== null) {
// Short-circuit: the real callback never runs; ToolRegistry::call() returns this
// result instead. tool.executed STILL fires afterwards, marked cacheServed: true —
// a cache hit is never invisible to audit/metrics listeners.
$slot->shortCircuit(ToolResult::success($cached));
}
});
$registry = new ToolRegistry($logger, $dispatcher);Because the anchor sits strictly after authorize(), this cache plugin can never answer a
call PolicyGate already denied — the security property, verified end-to-end (including a real
EventDispatcher, a real denied principal, and an assertion that the cache listener's invocation
count stays at zero) in tests/Events/CacheShortCircuitTest.php.
A listener may instead call $slot->stop() (without shortCircuit()) for a pure veto — call()
then returns ToolResult::blocked('Tool execution vetoed by an event listener') without ever
invoking the callback, and without a replacement result.
ToolRegistry's constructor subscribes its internal ToolAuditLogger to tool.executed /
tool.failed whenever a dispatcher is supplied — ToolAuditLogger::onToolExecuted() /
onToolFailed() reproduce the exact log lines the registry used to emit imperatively (including
the soft-timeout warning, now driven off $result->meta['timeout_exceeded'] instead of a direct
call). When no dispatcher is wired, call() invokes those same listener methods directly
instead of going through dispatch() — so "no dispatcher" still means "full audit trail", never
"silently stopped logging."
Validation failures, authorization denials, and rate-limit rejections keep logging exactly as
they did before 0.5 (logValidationFailure() / logAuthFailure() / a direct log() call in the
rate-limit branch) — those all happen before the security anchor, so there is no tool.* event
yet dispatched for them to hang off.
Some actions can't be authorized by scopes alone — they need a human or another agent to
say yes. milpa/core defines the seam: Milpa\Interfaces\Verification\VerifierInterface,
whose verify() returns a VerificationResult that may be PENDING and resolve later.
This package ships the reference implementation:
-
HumanVerifierimplementsVerifierInterface.verify()cannot decide synchronously, so it returnsVerificationResult::pending()and dispatchesverification.requested; a latergrant()/reject()call resolves it and dispatchesverification.granted/verification.rejected. -
VerificationToolexposesHumanVerifieras two tools — the same registry pipeline every other tool runs through, no special-cased transport:request_verification(subject, policy?, requested_by?, request_id?)opens a verification and returns itsrequest_id. Its schema has nodecisionorprincipalfield at all — this tool can never grant or reject anything, only open a request.resolve_verification(request_id, decision, principal, subject?, reason?)resolves a pending one.request_id,decision(grant|reject), andprincipalare required;subjectandreasonare optional. Whensubjectis omitted, the resolvedVerificationRequestis built viaVerificationRequest::forResolution()(subjectstaysnull, identified purely byrequest_id) — the earliersubject = $request_idfallback (tool-runtime 0.3) is gone, so averification.granted/verification.rejectedlistener never sees a fabricated subject. The tool's own success message still showsrequest_idin place ofsubjectfor readability; that is display-only formatting, not theVerificationRequest'ssubjectfield.
Tool-runtime 0.2 shipped this as a single combined tool that mixed both phases behind one schema, distinguished only by whether
decisionwas present — a shape whose name also invited reading it as "the caller can verify itself". 0.3 splits it into the two tools above; the old combined tool no longer exists.Both tools register with
ToolOptions(mutating: true, requiresConfirmation: false)— the registry's generic step-4 confirmation gate (see The pipeline) is deliberately bypassed for both, becausehandleRequest()/handleResolve()together already are the two-phase confirmation protocol (open a request, resolve it later). Stacking the registry's confirm-token gate on top of that would recreate the confusing 3-4 call choreography tool-runtime 0.2 already killed for the combined tool — see Changed in 0.2: the double-gate bypass for that history.⚠️ The bypass is not absolute: a channel whose policy setsrequire_confirmation_for_mutating(the built-intelegrampolicy does) still gates anymutating: truetool viaPolicyGate::requiresConfirmation(), regardless of the tool's ownrequiresConfirmationflag. Oncli,mcp, andweb(none of which set that policy by default) the bypass is total.
Calling either tool via $registry->call() runs its handler directly — no generic
confirm-token wrapper in between. A full request → resolve round trip is exactly two calls,
one per tool:
use Milpa\ToolRuntime\Verification\HumanVerifier;
use Milpa\ToolRuntime\Verification\VerificationTool;
(new VerificationTool(new HumanVerifier()))->register($registry);
$request = $registry->call('request_verification', [
'subject' => 'gate:report.publish',
], $ctx);
// -> ToolResult success, data: [
// 'subject' => 'gate:report.publish', 'policy' => 'single',
// 'request_id' => '06a1dda5-...',
// ]
// handleRequest() ran on THIS call — HumanVerifier::verify() ran and dispatched
// `verification.requested`. No confirm_token anywhere: the registry gate never ran.
$registry->call('resolve_verification', [
'request_id' => $request->data['request_id'],
'decision' => 'grant',
'principal' => 'agent:claude',
], $ctx);
// -> ToolResult success, data: [
// 'status' => 'passed', 'reason' => null, 'verifier' => 'human_verifier',
// 'principal' => 'agent:claude', 'missing' => [], 'metadata' => [],
// ]
// HumanVerifier::grant() ran and dispatched `verification.granted`.Echo the request_id from the first call's response back on resolve_verification — it is
HumanVerifier's own correlation id (#7), not the registry's confirm_token; neither tool
mints or expects a confirm_token.
Because the two phases are separate tools — not one tool with a conditional field — a host's
policy can allow request_verification to any principal that can reach the registry while
restricting resolve_verification to specific principals, using the same scopes mechanism
every other tool in this package uses. VerificationTool's constructor takes an optional
resolveScopes list, applied only to resolve_verification's ToolOptions:
use Milpa\ToolRuntime\Contracts\ToolContext;
use Milpa\ToolRuntime\Verification\HumanVerifier;
use Milpa\ToolRuntime\Verification\VerificationTool;
// request_verification stays open (empty scopes, the pre-split default); resolve_verification
// requires 'verification:resolve' — a scope only reviewer contexts carry.
(new VerificationTool(new HumanVerifier(), resolveScopes: ['verification:resolve']))
->register($registry);
$worker = new ToolContext(principal: 'agent:worker', channel: 'mcp', scopes: ['tasks:write']);
$reviewer = new ToolContext(
principal: 'agent:reviewer',
channel: 'mcp',
scopes: ['tasks:write', 'verification:resolve'],
);
$request = $registry->call('request_verification', ['subject' => 'gate:report.publish'], $worker);
// -> success: $worker has no 'verification:resolve' scope, but request_verification never checks it.
$registry->call('resolve_verification', [
'request_id' => $request->data['request_id'], 'decision' => 'grant', 'principal' => 'agent:worker',
], $worker);
// -> FORBIDDEN: "Missing required scope for tool 'resolve_verification'. Need one of:
// verification:resolve — context has: tasks:write." (see the pipeline's Authorize step
// for the full FORBIDDEN-message-clarity contract)
$registry->call('resolve_verification', [
'request_id' => $request->data['request_id'], 'decision' => 'grant', 'principal' => 'agent:reviewer',
], $reviewer);
// -> success: $reviewer carries the required scope.tests/Verification/VerificationToolPolicyDividendTest.php pins exactly this scenario.
The same request → resolve round trip is also reachable by calling handleRequest() /
handleResolve() directly, independent of any ToolRegistry — useful when you don't have a
registry at hand (e.g. a unit test):
use Milpa\ToolRuntime\Verification\HumanVerifier;
use Milpa\ToolRuntime\Verification\VerificationTool;
$tool = new VerificationTool(new HumanVerifier($eventDispatcher));
$request = $tool->handleRequest(['subject' => 'gate:report.publish']);
// -> ToolResult::confirmation(), $request->data['request_id'] === '06a1dda5-...'
// HumanVerifier::verify() ran and dispatched `verification.requested`.
$tool->handleResolve([
'request_id' => $request->data['request_id'],
'decision' => 'grant',
'principal' => 'agent:claude',
]);
// -> ToolResult::success(), data: ['status' => 'passed', 'principal' => 'agent:claude', ...]
// HumanVerifier::grant() ran and dispatched `verification.granted`.Any other VerifierInterface implementation — a deterministic rule, a quorum vote, an
external approval service — plugs into the same seam.
Before 0.2, the combined verification tool used requiresConfirmation: true, so any call
through ToolRegistry::call() — request or resolve alike — hit the registry's own step-4
confirmation gate before the tool's own handler ever ran. Opening a request took two
registry calls just to reach the request phase (which itself returned a confirmation, this
one carrying request_id) — and resolving it needed a third, itself gated the same way.
The registry's generic wrapper carries no request_id (it knows nothing about
HumanVerifier), so a caller only ever saw the correlation id after redeeming a token they
didn't know they'd need. 0.2 set requiresConfirmation: false instead, since the handler's
own request_id round trip already is the confirmation protocol — the registry's generic
one was pure overhead for this tool specifically. 0.3's split preserves that same
requiresConfirmation: false decision on both request_verification and
resolve_verification — see Verification
above.
HumanVerifier dispatches three events — verification.requested (from verify()),
verification.granted and verification.rejected (from grant() / reject()) — through
the optional MilpaEventDispatcherInterface passed to its constructor. Every dispatch uses
the same payload shape: a single key, 'event', holding the event object, not a
flattened array of the request's fields:
$dispatcher->dispatch('verification.requested', ['event' => $requestedEvent]);
// $requestedEvent instanceof Milpa\Events\VerificationRequestedEvent
$dispatcher->dispatch('verification.granted', ['event' => $grantedEvent]);
// $grantedEvent instanceof Milpa\Events\VerificationGrantedEvent
$dispatcher->dispatch('verification.rejected', ['event' => $rejectedEvent]);
// $rejectedEvent instanceof Milpa\Events\VerificationRejectedEventA listener reaches the data through the event object's accessors, not array keys —
$payload['subject'] is always null/undefined; the subject lives at
$payload['event']->getRequest()->subject:
| Event | Accessors |
|---|---|
VerificationRequestedEvent |
getRequest(): VerificationRequest, getRequestId(): ?string |
VerificationGrantedEvent |
getRequest(): VerificationRequest, getResult(): VerificationResult, getRequestId(): ?string |
VerificationRejectedEvent |
getRequest(): VerificationRequest, getResult(): VerificationResult, getRequestId(): ?string |
A listener that wants to work with any of the three (or with a future verifier's events)
should branch on the event's class, or narrow via getRequest()/getResult(), rather than
assume a flat array — this is defined and enforced by the event classes' own docblocks in
milpa/core (Milpa\Events\Verification{Requested,Granted,Rejected}Event).
| Layer | Package | Owns |
|---|---|---|
| Contracts | milpa/core |
ToolProviderInterface, ToolRegistryInterface, VerifierInterface, capability/verification value objects and events — the seams, not the engine. |
| Runtime | milpa/tool-runtime (this package) |
The concrete ToolRegistry pipeline, #[Tool]/#[Param] attributes + ToolScanner, SchemaValidator, PolicyGate, rate limiting, channel rendering, ToolAuditLogger, and the HumanVerifier reference verifier (request_verification / resolve_verification). |
| Your app | your host / plugins | Concrete PolicyRuleProviderInterface (e.g. Doctrine-backed rules), LoggerInterface, channel renderers, and where policy decisions and audit logs are actually persisted. |
The types you construct and pass around day to day:
| Type | What it is |
|---|---|
Contracts\ToolContext |
Who/where/what-scopes for one call — principal, channel, scopes, mode. Named constructors per channel: cli(), mcp(), stdio() (trusted local stdio MCP server), telegram(). |
ToolResult |
The uniform return shape — success, data, message, error, meta. Factories for common shapes: success(), error(), paginated(), detail(), confirmation(), blocked(). |
ToolRegistry |
The pipeline: register() to add a tool by hand, call() to run resolve→validate→authorize→execute→audit, getToolSummaries() (plain-array LLM/MCP wire shape) / getToolDefinitions() (typed list<ToolDefinition>) / getToolsWithinBudget() for LLM/MCP exposure. |
Rendering\RendererRegistry |
Picks a ChannelRendererInterface for a ToolResult based on ToolContext::$channel, falling back to a default renderer or raw JSON. |
Contracts\LlmServiceInterface |
The seam a plugin implements to provide LLM access (generateResponse()) and other plugins consume to get one, without depending on a specific provider. |
Events\ToolExecutingEvent / Events\ToolExecutedEvent / Events\ToolFailedEvent |
The three tool.* event VOs (0.5) dispatched around ToolRegistry::call() — see Events. |
- PHP ≥ 8.3
milpa/core^0.6 (theInterceptionSlot/MilpaEventDispatcherInterfacekeystone the Events seam is built on)psr/log^3milpa/events(optional, dev-only) — the referenceMilpaEventDispatcherInterfaceimplementation; any conformant implementation works, this package has no hard dependency on it
Full API reference: getmilpa.github.io/tool-runtime — generated straight from the source DocBlocks and dressed with the Milpa design system.
Contributions are welcome — see CONTRIBUTING.md. Please report security issues via SECURITY.md, and note that this project follows a Code of Conduct.
Apache-2.0 © Rodrigo Vicente - TeamX Agency.
Milpa is designed, built, and maintained by Rodrigo Vicente - TeamX Agency.