Tools
A tool is a PHP class that gives the agent a real capability: run a command, read a file, query the database. The agent reads each tool’s name and description, picks the one that fits the task, and calls it.
phpClaw ships nine tools in core and 85 more across the eight adapters. Every one of them is built the same way, and this page describes that way first, because the rest follows from it.
Register as many tools as you need; the agent decides which to call:
use PhpClaw\Claw;use PhpClaw\Tools\DatabaseQueryTool;use PhpClaw\Tools\ShellTool;
$agent = Claw::builder() ->tools([ new ShellTool(allowlist: ['df', 'uptime', 'hostname']), new DatabaseQueryTool($pdo), ]) ->build();
echo $agent->send('How many users signed up today, and what is the server uptime?')->text;The lifecycle
Section titled “The lifecycle”You never write execute(). HasToolExecutionContract owns it and calls four methods you do
write:
execute($input) ├─ plan($input) → ['input' => array, 'result' => ?string] │ a non-null result returns immediately ├─ perform($plan['input']) → array ├─ verify($execution, $in) → ['result' => ?string] │ a non-null result returns immediately └─ complete($execution, $in) → string
on ToolException: recover($exception, $attempt) retried while $attempt < maxRecoveryAttempts(), then rethrown| Method | Job | Returns |
|---|---|---|
plan() | authorise, normalise, validate | the input the rest of the run uses, plus an optional finished result that short-circuits everything after it |
perform() | do the work | an internal array, unformatted |
verify() | check the raw result before the model sees it | ['result' => null] to continue, or a finished result |
complete() | build the response envelope | the JSON string the model receives |
Two consequences worth knowing. Authorisation lives in plan(), so a caller without the
capability never reaches perform(). And recover() is only reachable because verify() throws
ToolException on an incomplete infrastructure result.
Retries differ between core and adapter tools
Section titled “Retries differ between core and adapter tools”maxRecoveryAttempts() returns 1 on the contract trait. Core’s own binding trait overrides it
to 0, so an infrastructure failure in a core tool surfaces to the caller unchanged instead of
being retried once.
Response encoding differs too
Section titled “Response encoding differs too”The contract trait’s encodeResult() runs html_entity_decode recursively over every string in
the envelope. Core’s binding trait overrides it to encode verbatim, because decoding entities in
file bytes or process output would hand back content the source never held.
Binding traits
Section titled “Binding traits”HasToolExecutionContract leaves three methods abstract, because core cannot answer them alone:
| Abstract | Answers |
|---|---|
requiredCapability() | which platform capability this tool needs |
runningInConsole() | whether this request came through the platform’s console |
callerHasCapability() | whether the current caller holds a capability |
A binding trait supplies all three. That is the only structural difference between a core tool and an adapter tool.
Core ships HasCoreToolBinding:
trait HasCoreToolBinding{ use HasToolExecutionContract;
public function requiredCapability(): string { return ToolAuthorizerInterface::CAPABILITY; // 'phpclaw_use_tools' }
public function isEligibleForRouting(): bool { return $this->runningInConsole() || $this->callerHasCapability($this->requiredCapability()); }
protected function runningInConsole(): bool { return $this->authorizer !== null && $this->authorizer->runningInConsole(); }
protected function callerHasCapability(string $capability): bool { return $this->authorizer === null || $this->authorizer->allows(); }}Each adapter ships a trait of the same name that pulls in core’s and binds its own platform. Drupal’s:
namespace PhpClaw\Drupal\Tools\Concerns;
use PhpClaw\Drupal\DrupalConsole;use PhpClaw\Tools\Concerns\HasToolExecutionContract as CoreToolExecutionContract;
trait HasToolExecutionContract{ use CoreToolExecutionContract;
protected function runningInConsole(): bool { return DrupalConsole::isActive(); }
protected function callerHasCapability(string $capability): bool { return (bool) \Drupal::currentUser()->hasPermission($capability); }}An adapter tool imports its own adapter’s trait, never core’s directly.
The nine built-in tools
Section titled “The nine built-in tools”Seven register automatically. db_query and zip_package are declared default: false and
register only when you add them yourself.
| Tool | Tool name | Catalogue key | Auto-registers | Mutating | Needs config |
|---|---|---|---|---|---|
ShellTool | shell_exec | shell | yes | per invocation | allowlist |
HttpTool | http_request | http | yes | no | none |
FileReadTool | file_read | file_read | yes | no | workspaceRoot |
FileWriteTool | file_write | file_write | yes | yes | workspaceRoot, allowPhpWrite |
FileEditTool | file_edit | file_edit | yes | yes | workspaceRoot |
CodeSearchTool | code_search | code_search | yes | no | workspaceRoot |
ProjectTool | project_info | project_info | yes | no | projectRoot |
DatabaseQueryTool | db_query | db_query | no | no | none |
ZipPackagerTool | zip_package | zip_package | no | yes | workspaceRoot |
Tool name is what name() returns: the name the model calls, the name a deny list matches, and
the name the router pins. Catalogue key is the name in the tool’s #[Tool] attribute. They
differ for two tools, so use shell_exec and http_request anywhere you refer to a tool by name.
shell_exec
Section titled “shell_exec”new ShellTool(allowlist: ['ls', 'pwd', 'df', 'date', 'uptime']);Executes commands from an explicit allowlist. Anything outside the allowlist is refused, and a separate hard-blocked list is refused even if an adapter adds it to the allowlist. Shell metacharacters are rejected rather than escaped.
Output is truncated at 8192 bytes of stdout and 10000 bytes of stderr, with a 5 second process timeout and a 10 MB hard read ceiling.
The default allowlist is ls, pwd, df, cat, head, tail, grep, wc, date, uptime,
hostname and whoami. Those twelve are also the commands the approval gate treats as read-only,
even when you pass a different allowlist.
The hard-blocked commands, the metacharacter rule, blocked system paths and output cleaning are listed under Security: shell.
http_request
Section titled “http_request”Outbound GET and POST only, behind SSRF validation. Responses truncate at 8192 bytes, with a 10 second timeout. The address checks and redirect handling are described under Security: HTTP.
file_read
Section titled “file_read”new FileReadTool(workspaceRoot: '/var/www/app/storage/phpclaw');Reads one file from the sandboxed workspace, which defaults to storage/phpclaw under the current
working directory. Truncates at
16384 bytes and sniffs the first 8192 bytes to refuse binaries. It refuses sensitive extensions,
file names and directories from BlockedPaths. The three file tools do not apply identical lists;
see Security: files.
file_write
Section titled “file_write”new FileWriteTool(workspaceRoot: '/var/www/app/storage/phpclaw', allowPhpWrite: true);Writes into the same sandbox, up to 10 MB, through an atomic write. PHP files (php, phtml,
phar) are refused unless the tool is constructed with allowPhpWrite: true. It always refuses
executable extensions (sh, bash, exe, bat, cmd, ps1) and the core, system and sysext
folders, as well as sensitive extensions and directories, but not sensitive file names, so it
will write .htaccess or id_rsa.
file_edit
Section titled “file_edit”Replaces one unique string in a workspace file, so an edit that matches nothing or matches twice
fails instead of guessing. Caps files at 2 MB and additionally blocks .github, .git and
.circleci, plus build files such as composer.json and package.json.
It refuses to edit a file that file_read has not read during the same run.
code_search
Section titled “code_search”Searches a pattern across workspace files. Scans at most 1000 rows, pages at 8192 bytes, skips
files over 1 MB and skips vendor, node_modules, .git and .idea.
It returns file and line for each match, with total and truncated in the payload.
context_lines adds the matched line and that many lines around it. is_regex: true treats the
pattern as a complete PCRE, delimiters included: pass /beta|gamma/, since a bare beta|gamma is
refused as an invalid pattern. A truncated result carries
next_offset, which the model passes back as offset for the next page. The tool’s description
names the workspace root, so the model does not guess paths.
project_info
Section titled “project_info”new ProjectTool(projectRoot: '/var/www/app');Detects the framework or CMS, lists Composer packages and prints the file tree, skipping
vendor, node_modules, .git, .idea, storage and cache. It recognises WordPress, Joomla,
OpenCart, PrestaShop, Laravel, Symfony, Magento and Drupal from marker files such as wp-config.php,
artisan and bin/console. depth sets the tree depth, default 2, clamped into 1 to 4 rather than refused. Its description tells the
model to call it first on a new task.
db_query
Section titled “db_query”new DatabaseQueryTool($pdo);new DatabaseQueryTool($pdo, '/var/www/app/storage/phpclaw'); // where large results are writtenRead-only SELECT against the live database, taking the query in sql, capped at 100 rows and 8192
bytes of output.
Results over the byte cap return a preview plus a spill_path, a JSON Lines file you can read
with shell. Off by default.
What the query check allows and refuses:
| Query | Result |
|---|---|
SELECT ... UNION SELECT ... | allowed |
WITH x AS (SELECT ...) SELECT ... | allowed |
WITH x AS (SELECT ...) DELETE ... | refused: DELETE |
SELECT 1; SELECT 2 | refused: only a single statement; one trailing ; is fine |
any --, # or /* */ comment | refused |
INTO OUTFILE, LOAD_FILE, INFORMATION_SCHEMA, FOR UPDATE | refused |
It also refuses any query naming password, passwd, secret, private_key, api_key,
api_token, access_token, secret_key or auth_token.
zip_package
Section titled “zip_package”Zips a workspace folder into {output_name}.zip in the temp directory, for CMS installation. Every
file sits under a top-level folder named output_name, the layout a CMS uploader expects. It needs
PHP’s zip extension. Off by default.
Who may run a tool
Section titled “Who may run a tool”Every tool declares a capability, and guardCapability() decides in two steps:
if ($this->runningInConsole()) { return null; // allowed}
if ($this->callerHasCapability($this->requiredCapability())) { return null; // allowed}
return $this->error('FORBIDDEN', ...);The console is checked first and unconditionally. A CLI caller reaches every registered tool without any capability check at all. That is deliberate: the console is already an authenticated administrative context on every one of these platforms.
Through the web, the capability decides. Within an adapter every tool uses the same capability, except the database tool on Laravel and Symfony, so the tool set is not split into tiers: a user who holds the capability reaches the same tools as any other user who holds it. Which roles or groups hold it is set per adapter; see the page for your adapter.
| Adapter | Tools | Capability | Console test |
|---|---|---|---|
| WordPress | 22 | phpclaw_use_chat | defined('WP_CLI') && WP_CLI |
| Drupal | 15 | use phpclaw chat | DrupalConsole::isActive() |
| PrestaShop | 14 | AdminPhpClawDebug | PHPCLAW_PS_CONSOLE |
| Magento | 10 | PhpClaw_Magento::phpclaw_chat | identity()->runningInConsole() |
| OpenCart | 10 | access | PHPCLAW_OC_CONSOLE |
| Joomla | 6 | phpclaw.chat.use | application is a ConsoleApplication |
| Laravel | 6 | phpclaw.chat, phpclaw.manage-all | LaravelConsole::isInteractive() |
| Symfony | 2 | ROLE_PHPCLAW_CHAT, ROLE_PHPCLAW_MANAGE_ALL | consoleContext()?->isConsole() |
| core | 9 | phpclaw_use_tools | the authorizer’s runningInConsole() |
Laravel and Symfony are the two that use a second, higher capability: their database tool requires the manage-all ability or role, the rest require the chat one.
Offered versus allowed
Section titled “Offered versus allowed”These are two separate decisions.
Offered is whether the model sees the tool in its schema list, decided by
isEligibleForRouting(). Allowed is whether a call succeeds, decided by guardCapability() in
plan().
| Tools | isEligibleForRouting() | Effect |
|---|---|---|
| every adapter tool | always true | always offered; a caller without the capability gets FORBIDDEN when the call runs |
| core’s nine | console, or the caller holds the capability | offered only to callers who would be allowed |
Because no authorizer is bound, core’s check also always resolves to true in practice, so every
tool in every adapter is offered to every caller, and the capability is enforced at call time.
Core’s nine tools have no active gate
Section titled “Core’s nine tools have no active gate”The mechanism is fully built. All nine core tools implement AuthorizableToolInterface, and two
places would bind an authorizer: ToolRegistry’s constructor, and ToolCatalogue when the config
array carries an authorizer key.
Neither is supplied. Claw constructs its registry as new ToolRegistry with no argument, no
adapter passes an authorizer config key, and no class outside core’s own test suite implements
ToolAuthorizerInterface. With no authorizer bound, callerHasCapability() short-circuits to
true:
return $this->authorizer === null || $this->authorizer->allows();So phpclaw_use_tools is a capability nothing evaluates, and core’s nine tools allow every caller
that can reach them. Adapter tools are unaffected: they resolve capabilities through their own
platform and are gated normally.
Human approval for mutating tools
Section titled “Human approval for mutating tools”Core installs no gate: with a plain builder, mutating tools run without asking. (Every adapter
installs one.) Install the built-in gate with withHumanApproval(), or your own with
approvalGate():
$agent = Claw::builder()->withHumanApproval()->build();When a gate is set, it is consulted before each tool call. CliApprovalGate decides which calls
need a human:
| Tool implements | Behaviour |
|---|---|
PerInvocationMutabilityInterface | isMutating($input) decides per call |
MutatingToolInterface | always requires approval |
| neither | passes silently |
The gate is fail-closed. With no interactive terminal it throws HumanDeniedException rather
than allowing the call. The agent catches that, skips the tool, and hands the model a
{"status": "denied"} result, so the run continues. Web requests have no terminal, so in a web chat
every mutating call is refused this way.
ShellTool is the per-invocation case: a command whose name is in the read-only allowlist is
treated as non-mutating, anything else needs approval, and an empty command counts as mutating.
Among core’s tools, file_write, file_edit and zip_package are always mutating, and shell
decides per call.
In a terminal, answering n denies the call the same way: the model gets the denied result and the
run continues.
The gate is checked inside the agent loop, not through a hook, so a hook listener cannot block a tool call. The MCP server calls tools directly and never consults a gate.
A custom gate implements one method; throw to deny, return to allow:
use PhpClaw\Agent\Contracts\ApprovalGateInterface;use PhpClaw\Exceptions\HumanDeniedException;use PhpClaw\Tools\Contracts\ToolInterface;
final class PolicyGate implements ApprovalGateInterface{ public function check(string $toolName, array $toolInput, ?ToolInterface $tool = null): void { if ($toolName === 'file_write') { throw new HumanDeniedException($toolName, $toolInput); } }}
$agent = Claw::builder()->approvalGate(new PolicyGate)->build();Which tools a turn sees
Section titled “Which tools a turn sees”Sending every tool on every turn wastes context and confuses smaller models, so phpClaw narrows the set. Two independent stages do this.
Stage 1: the profile sets a budget
Section titled “Stage 1: the profile sets a budget”ToolProfileResolver::resolve() maps provider and model to a profile, and maxTools() turns that
into a number:
| Profile | Budget | When |
|---|---|---|
minimal | 5 | provider is ollama or groq, and the model is not a medium one |
standard | 8 | provider is ollama or groq, and the model matches a medium pattern |
full | unlimited (0) | every other provider |
Medium patterns are 30b, 32b, 34b, 35b, 65b, 70b and 72b, matched as substrings of
the model id.
That number is passed to the builder as maxToolsPerTurn, which becomes the router’s budget. All
eight adapters wire both halves.
Note that ToolProfileResolver::filter() applies the deny list only. The profile budget travels
separately, through maxTools(), and is enforced by the router.
Stage 2: the router ranks and slices
Section titled “Stage 2: the router ranks and slices”ToolRouter runs once per call, before the iteration loop, against the user’s message. The tool
set it produces is then fixed for the whole run.
It returns early and filters nothing when the number of tools is already within budget, so on a
full profile with few tools the router never engages.
When it does engage:
- Pinning. A tool the message names is kept regardless of score. Both
use ShellToolandshell_execare detected, and matching is substring in both directions, so a partial mention still pins. - Ranking. Everything else is scored on six weighted signals.
- Slicing. The budget is filled with pinned tools first, then the highest-ranked remainder.
- Sorting. The result is sorted by name, so an identical selection is byte-identical across messages.
| Signal | Weight | Source |
|---|---|---|
intent | 16 | ToolRoutingMetadata::$intents |
domain | 8 | ToolRoutingMetadata::$domains |
tag | 4 | ToolRoutingMetadata::$tags |
example | 2 | ToolRoutingMetadata::$examples |
name | 1 | the tool name |
description | 1 | the tool description |
Message words shorter than three characters and a list of stopwords are dropped before matching. Ties break on tool name, never on declaration order.
The per-turn budget
Section titled “The per-turn budget”With no explicit budget, the router derives one from the model id. Fragments are tried in this order and the first match wins:
| Order | Model id contains | Budget |
|---|---|---|
| 1 | haiku | 6 |
| 2 | flash | 6 |
| 3 | gpt-3.5 | 5 |
| 4 | mini | 8 |
| 5 | gemini | 15 |
| 6 | sonnet | 12 |
| 7 | gpt-4o | 15 |
| 8 | opus | 20 |
| anything else | 10 |
So gpt-4o-mini gets 8 (mini before gpt-4o) and gemini-3.5-flash-lite gets 6 (flash before
gemini). Fragments match on token boundaries, not as bare substrings, because a plain substring
test resolves every non-Flash Gemini id as mini.
Set maxToolsPerTurn(ToolRouter::UNLIMITED) to disable the cap entirely.
Tool result size
Section titled “Tool result size”Every tool result is sent again on each later turn, so one oversized result costs many times. A
single result is cut at an estimated 2500 tokens (10,000 characters) and ends with a note telling the
model it was cut and to narrow the request or page with offset. Change or disable the ceiling on the builder; 0 disables the cut:
$agent = Claw::builder()->maxToolResultTokens(10000)->build();Provider schema shapes
Section titled “Provider schema shapes”ToolRegistry::schemas() formats each tool for the target provider and caches the result per
provider name.
| Providers | Shape |
|---|---|
openai, groq, gemini, mistral, ollama | { type: 'function', function: { name, description, parameters } } |
| everything else, including unknown names | { name, description, input_schema } |
An unknown provider gets the Anthropic-native shape as a safe default.
One fix happens on the way out: a tool whose properties is an empty PHP array would serialise as
[], which providers reject, so it is swapped for an object and emitted as {}.
Tools implementing ToolRoutingInterface are excluded from the schema list when
isEligibleForRouting() returns false.
Remote tool profiles
Section titled “Remote tool profiles”A remote profile selects among tools already installed locally. It cannot add a tool, and cannot cause code to be downloaded or executed.
$agent = Claw::builder() ->withRemoteToolProfile('https://example.com/profile.json') ->build();Call withRemoteToolProfile() more than once to apply several profiles in order.
{ "profile": "support", "tools": ["file_read", "code_search"], "max_tools_per_turn": 6 }The fetch is constrained: HTTPS only, SSRF-validated with the resolved address pinned, no redirects followed, 5 second timeout, 64 KB response cap, and a one hour file cache. Any failure logs a warning and leaves the tool list untouched.
A profile’s max_tools_per_turn applies only when no explicit budget was set in code.
Denying tools
Section titled “Denying tools”The builder has no deny setter. Remove tools from the list before you pass it in, with
ToolProfileResolver::filter(). Entries are tool names, or group references that expand to names:
use PhpClaw\Tools\ToolProfileResolver;
$tools = ToolProfileResolver::filter( $tools, deny: ['shell_exec', 'group:writes'], groups: ['group:writes' => ['file_write', 'file_edit']],);
$agent = Claw::builder()->tools($tools)->build();This is the path every adapter uses: each reads its configured deny list and filters its tool roster this way before building the agent.
ToolRegistry::register() also accepts $deny and $groups, and on that path it does log a warning
for a group member matching no tool. The engine does not use it: Claw registers the already
filtered list with no deny arguments.
Writing a tool
Section titled “Writing a tool”The simplest tool
Section titled “The simplest tool”ToolInterface alone is enough. Write execute() yourself and return a string:
use PhpClaw\Tools\Contracts\ToolInterface;
final class ServerTimeTool implements ToolInterface{ public function name(): string { return 'server_time'; // unique, snake_case }
public function description(): string { return 'Return the current server time.'; // the model reads this to decide }
public function inputSchema(): array { return ['type' => 'object', 'properties' => []]; // sent to the provider as {} }
public function execute(array $input): string { return json_encode(['time' => date(DATE_ATOM)]); }}
$agent = Claw::builder()->tools([new ServerTimeTool])->build();A tool with parameters describes them in the schema:
public function inputSchema(): array{ return [ 'type' => 'object', 'properties' => [ 'key' => ['type' => 'string', 'description' => 'The cache key to look up.'], 'store' => ['type' => 'string', 'description' => 'Cache store name. Defaults to "default".'], ], 'required' => ['key'], ];}The rest of this section builds the fuller, lifecycle-based kind used by core and the adapters.
1. Implement the contracts you need
Section titled “1. Implement the contracts you need”| Contract | Declares | Required |
|---|---|---|
ToolInterface | name(), description(), inputSchema(), execute() | yes |
AuthorizableToolInterface | withAuthorizer() | for core-style tools |
ToolRoutingInterface | isEligibleForRouting(), routingMetadata() | to be routable |
MutatingToolInterface | nothing, it is a marker | if the tool always changes state |
PerInvocationMutabilityInterface | isMutating($input) | if it depends on the input |
ConfigurableToolInterface | configure(), isConfigured() | if it needs runtime config |
ResettableInterface | reset() | if it holds per-run state |
All nine core tools implement ToolInterface, AuthorizableToolInterface and
ToolRoutingInterface. Four are mutating, two are resettable, and one is per-invocation.
A tool that always changes state adds the marker:
final class SendInvoiceTool implements ToolInterface, MutatingToolInterface{ // ...}A tool that changes state only for some inputs decides per call; return true when unsure:
final class CacheTool implements ToolInterface, PerInvocationMutabilityInterface{ public function isMutating(array $input): bool { return ($input['action'] ?? '') !== 'status'; }}2. Use your adapter’s binding trait
Section titled “2. Use your adapter’s binding trait”final class DrupalCacheTool implements ToolInterface, ToolRoutingInterface{ use HasToolExecutionContract; // the Drupal trait
private const REQUIRED_CAPABILITY = 'use phpclaw chat'; private const MAX_LIMIT = 500; private const ALLOWED_KEYS = ['schema', 'limit', 'offset'];3. Write plan() in the canonical order
Section titled “3. Write plan() in the canonical order”Authorise, normalise, validate:
protected function plan(array $input): array{ $forbidden = $this->guardCapability('read Drupal cache bin sizes');
if ($forbidden !== null) { return ['input' => $input, 'result' => $forbidden]; }
$input = InputNormaliser::flattenArrayValues($input);
return ['input' => $input, 'result' => $this->validate($input)];}4. Throw from verify() so recover() can run
Section titled “4. Throw from verify() so recover() can run”protected function verify(array $execution, array $input): array{ if ($execution['type'] === 'query' && ! is_array($execution['payload']['cache_bins'] ?? null)) { throw new ToolException('DrupalCacheTool returned an incomplete cache bin result.'); }
return ['result' => null];}Only a ToolException wrapping a previous exception is treated as retryable infrastructure
failure. Anything else is rethrown immediately.
What the lifecycle gives you
Section titled “What the lifecycle gives you”These live on the core trait, so a tool does not reimplement them.
| Helper | Returns | Error code |
|---|---|---|
guardCapability(string $subject) | null when allowed, else a finished error envelope | FORBIDDEN |
rejectUnknownArguments(array $input, array $allowed) | null when every key is allowed | UNKNOWN_ARGUMENT |
validatePaging(array $input, int $maxLimit, int $maxOffset) | null when paging is in range | INVALID_LIMIT, INVALID_OFFSET |
success(array $data, array $meta, array $warnings = []) | the success envelope | n/a |
error(string $code, string $message, array $context = []) | the failure envelope | yours |
recover(ToolException $e, int $attempt) | `array{result: string | null}: a finished result, or [‘result’ => null]` to retry |
encodeResult(array $result) | JSON, throwing ToolException on encode failure | n/a |
The envelope
Section titled “The envelope”success():
{ "success": true, "data": { }, "meta": { }, "warnings": [] }error() adds error and fixes two fields:
{ "success": false, "error": { "code": "FORBIDDEN", "message": "..." }, "data": null, "meta": { "mode": "error" }, "warnings": []}Helping the router find your tool
Section titled “Helping the router find your tool”public function routingMetadata(): ToolRoutingMetadata{ return new ToolRoutingMetadata( domains: ['cache', 'performance'], tags: ['cache', 'bin', 'flush'], intents: ['inspect cache', 'clear cache'], examples: ['how big is the render cache'], );}intents carries sixteen times the weight of the tool name, so spend the effort there.
Checklist
Section titled “Checklist”- the four lifecycle methods, and no
execute()of your own - your adapter’s binding trait, not core’s
guardCapability()first inplan()const ALLOWED_KEYSplusrejectUnknownArguments()'additionalProperties' => falsein the input schema- every query bounded, and every collection sorted by a stable secondary key
MutatingToolInterfaceif it changes state, orPerInvocationMutabilityInterfaceif that depends on the inputroutingMetadata()if the tool should be routable- every input validated before use, and nothing secret in the output
- user-supplied paths kept inside a sandbox, and outbound URLs limited to known hosts
- failures thrown as
ToolException