milpa / tool-runtime
AI tool-execution runtime for the Milpa PHP framework: registry pipeline (resolve, validate, authorize, execute, audit), #[Tool] attributes, schema validation, policy gate, rate limiting, channel rendering, and human verification.
Requires
- php: >=8.3
- milpa/core: ^0.6
- psr/log: ^3
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.65
- milpa/events: *
- phpstan/phpdoc-parser: ^2.3
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.5
README
Milpa ToolRuntime
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.
Install
composer require milpa/tool-runtime
Quick example
Attribute 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.
Object-shaped parameters
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.
Parameter names on the wire
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.
The pipeline
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.
Events: tool.executing / tool.executed / tool.failed
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 |
The security anchor
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.
The cache-plugin recipe
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.
ToolAuditLogger is a listener, not an imperative call
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.
Verification: request_verification / resolve_verification
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 sets
require_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.
Through the registry: request → resolve in two calls
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.
The policy dividend: restrict resolve_* without touching request_*
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.
Direct usage: calling the handlers without a registry
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.
Changed in 0.2: the double-gate bypass
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.
Events: what the payload actually carries
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\VerificationRejectedEvent
A 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).
What lives where
| 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. |
API de facto
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. |
Requirements
- 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
Documentation
Full API reference: getmilpa.github.io/tool-runtime — generated straight from the source DocBlocks and dressed with the Milpa design system.
Contributing
Contributions are welcome — see CONTRIBUTING.md. Please report security issues via SECURITY.md, and note that this project follows a Code of Conduct.
License
Apache-2.0 © Rodrigo Vicente - TeamX Agency.
Milpa is designed, built, and maintained by Rodrigo Vicente - TeamX Agency.