09. An AI agent platform's tool registry as ground truth
An agent platform's tool registry; runtime call guardrail; the MCP server itself
Exercises per-tool vet --at, the real aontu-mcp over JSON-RPC, agentsmd, generation
Rendered from
use-cases/09-agent-tools/README.md
in the engine repository. The models, the expected output and the
check.sh that drives the CLI over all of it are in
use-cases/09-agent-tools/.
The scenario
An agent platform (“Orion”) runs a fleet of LLM agents that call
tools. The platform needs ONE document that answers, for every tool:
what exists, what arguments a call may carry (and must refuse), what a
call can cost (side-effect class, rate limit, timeout), and who may
call it. Today this truth is usually scattered across a TypeScript
tool file, a JSON Schema, a wiki page and the dispatcher’s if
statements, and those copies drift. This is aontu’s home turf: the
MCP ecosystem’s tool-definition problem.
The model exercises the full loop:
registry.aon: the registry itself: six tools, closed schemas, constraint atoms, enums, generated call schemas, derived fields, a derived docs table.guard.aon: the dispatcher’s vet entrypoint: one wire schema per tool, generated from the registry’s argument schemas.data/call-*.json: agent-emitted calls{tool, arguments}, vetted withaontu vet --at $.guard.<tool>: the runtime guardrail, asserted by exit code and error code incheck.sh.- The real
aontu-mcpserver driven over stdio JSON-RPC (initialize, tools/list, tools/call of vet) fromcheck.sh. - The
aontu agentsmdstanza and itsaontu hashpin.
./check.sh runs everything and asserts every outcome (19 checks).
The model tree
guard.aon is the registry plus the runtime guardrail. tools is what
exists and argschemas what each call may carry; guard is generated
from them by a pack() over the schema map, so a call is vetted at
$.guard.<tool> and the two can never drift. ToolSpec, Role and
SideEffect are the vocabulary the entries are written in.
$
├── Role "admin"|"operator"|"analyst"|...
├── SideEffect "readonly"|"write"|"destructive"
├── ToolSpec
│ ├── allowed_roles [&:$.Role]
│ ├── description string&length(integer&min(24)...
│ ├── owner re("^[a-z][a-z0-9-]*@corp[.]e...
│ ├── rate_limit (2)
│ ├── requires_approval boolean
│ ├── side_effect "readonly"|"write"|"destructive"
│ └── timeout_ms integer&min(100)&max(600000)
├── argschemas
│ ├── create_ticket (5)
│ ├── delete_records (3)
│ ├── http_request (5)
│ ├── read_file (2)
│ ├── search_docs (3)
│ └── send_email (3)
├── docs
│ ├── header "| tool | effect | rpm | appr...
│ └── table (6)
├── guard
│ ├── create_ticket (2)
│ ├── delete_records (2)
│ ├── http_request (2)
│ ├── read_file (2)
│ ├── search_docs (2)
│ └── send_email (2)
├── registry
│ ├── owner "platform-tools@corp.example"
│ ├── platform "orion"
│ └── version "1.4.0"
└── tools
├── create_ticket (7)
├── delete_records (7)
├── http_request (7)
├── read_file (7)
├── search_docs (7)
└── send_email (7)
aontu view doc --depth 2 guard.aon draws it, and check.sh pins it
with --out --check. A key with (n) after it is a container the
depth bound stopped at, and n is how many keys are not drawn; a
leaf carries its canon, which is the kind of thing it is rather
than its value.
How the model is designed
-
argschemasis the spine. The set of tool names is the key set of one closed map.tools(metadata) isclose(pack($.argschemas, $.ToolSpec)), so metadata for a tool with no argument schema is a located[aontu/closed]error (bad/rogue-tool.aon), and an argument schema with no metadata leaves requiredToolSpecfields unresolved and the registry refuses to generate. Both drift directions are refused the moment the registry is evaluated, from onepack. -
The wire schema is generated, not written.
guard: pack( $.argschemas, close({tool: key(), arguments: _}))makes the{tool, arguments}envelope per tool;close()survives the_clone, so a hallucinated argument is[aontu/closed]. The guard lives in its own file,guard.aon, which includesregistry.aon:aontu registry.aonemits only the concrete registry, andaontu vet --at $.guard.<tool> guard.aon call.jsonis the dispatcher’s one command. -
Constraint atoms are the guardrail vocabulary:
re()for URL / email / id shapes,min/maxbounds,length()on strings,length(max(n))andunique()on lists, enum disjunctions formethod,priority,scope,side_effect. Alength(min(n))written on a templated list ([&: ...]) refuses at composition, so “at least one” is a dispatcher rule rather than a schema one. -
type()marks keep every schema out of the generated JSON while it still constrains:aontu registry.aonemits only the concrete registry (tools, docs): the golden inexpected/registry.json.Role,SideEffectandToolSpecare separate top-level marked fields, each referenced by absolute path. -
Derived truth:
requires_approval: match(.side_effect, destructive, true, false)per tool, and a markdown docs table computed from the tool entries. The rule and the table rows are written once per tool, each naming its tool by absolute path.aontu model whytraces the flag back to thematch()rule. -
deprecate()sunsetshttp_request.max_redirects: still admitted, warned on, with the replacement path in the warning. -
No defaults near enforcement. Optional arguments carry constraints but no
*defaults, so the dispatcher owns runtime defaults and the schema owns the admissible range;dry_run: booleanis required and explicit ondelete_records, so destruction says what it means. -
The vet verdict is the dispatcher’s decision table. valid (exit 0) dispatches; invalid (1) refuses and feeds the findings back to the agent; incomplete (3) asks for the missing argument; error (4) is an unknown tool. All four states fall out of one command,
aontu vet --at "$.guard.$tool" guard.aon call.json, with no per-tool code. The findings carryexpected,actual, and both sites (schema file:line:col and data file:line:col), and--format json/--format sarifrender the same report for machines. The two conflict findings for the cleartext URL call:$ aontu vet --at '$.guard.http_request' guard.aon data/call-http-bad.json verdict: invalid $.guard.http_request.url: constraint [conflict] [aontu/constraint]: Cannot unify values at path $.guard.http_request.url expected: re("^https://")&length(integer&min(0)&max(2048)) actual: "http://169.254.169.254/latest/meta-data/" data: data/call-http-bad.json:4:12 ("http://169.254.169.254/latest/meta-data/") schema: registry.aon:64:19 (re("^https://")&length(integer&min(0)&max(2048))) $.guard.http_request.method: empty [conflict] [aontu/empty]: Cannot unify values at path $.guard.http_request.method data: data/call-http-bad.json:5:15 ("DELETE") schema: registry.aon:65:13 ("GET"|"HEAD")Each finding names the line in the call and the line in the schema, which is the repair material an agent needs to try again.
-
The MCP server is the same guardrail over JSON-RPC.
check.shstartsaontu-mcp, completes the initialize handshake, lists the tools, and callsvetfor a good call and a bad one. The vet report is the tool result: a refusal comes back asverdict: invalidwithisError: false, so the report is the answer the agent reads, not a protocol failure.check.shalso times 100 vets through one server process and 20 cold CLI spawns and prints both, so you can weigh holding a server open against shelling out per call on your own machine. -
agentsmd+hash:aontu agentsmd registry.aonemits the AGENTS.md stanza (the pin, the top-level keys, the shape, and the verbs that query the document), and the stanza’s pin is byte-identical toaontu hash registry.aon, so an agent can cheaply detect that the truth changed.
What check.sh proves
-
aontu registry.aonmatchesexpected/registry.jsonbyte for byte: the six tool entries, their derivedrequires_approvalflags and the docs table come out concrete, and thetype()-marked schemas stay out. -
aontu --canon registry.aonkeeps the constraints, enums and deprecations:re("^https://"),"GET"|"HEAD"anddeprecate(integer&min(0)&max(10)all appear in the canonical form. -
aontu model get '$.tools.delete_records' registry.aonmatchesexpected/tool-delete-records.json: one tool’s merged truth, as a dispatcher pulls it. -
aontu model why '$.tools.delete_records.requires_approval' registry.aontraces the flag to itsmatch()rule:$.tools.delete_records.requires_approval = true 1. type(("readonly"|"write")|"destructive") registry.aon:24:13 2. boolean registry.aon:30:22 3. match(.side_effect,"destructive",true,false) registry.aon:168:24 -
data/call-search-ok.jsonisverdict: valid, exit 0. -
data/call-http-bad.json(a cleartext URL at the metadata service, methodDELETE) isverdict: invalid, exit 1, with a located[aontu/constraint]finding namingre("^https://")(the transcript above). -
data/call-delete-extra.jsoncarries a hallucinatedcascadeargument ondelete_records: refused byclose(),[aontu/closed]at$.arguments.cascade, exit 1. -
data/call-search-missing.jsonomits the requiredquery:verdict: incomplete,[aontu/mapval_required]at$.guard.search_docs.query, exit 3, so the dispatcher asks for the argument instead of dispatching. -
data/call-unknown-tool.jsonnamesdrop_database:verdict: error,no_pathat the anchor$.guard.drop_database, exit 4. -
data/call-http-deprecated.jsonsendsmax_redirects:verdict: valid, exit 0, with the deprecation warningrenamed (use $.argschemas.http_request.redirects) (since 1.4.0)in the report. -
data/call-ticket-dup-labels.jsonrepeats a label:verdict: invalid, exit 1,[aontu/constraint]at$.guard.create_ticket.labelsagainstlength(integer&min(0)&max(10))&unique(). -
registry.aonalone is not the guardrail entrypoint:vet --at '$.guard.search_docs' registry.aon data/call-search-missing.jsonisverdict: errorwithno_path, exit 4, because$.guardexists only inguard.aon. -
bad/rogue-tool.aonregistersaudit_logmetadata with no argument schema:[aontu/closed]: Cannot resolve value at path $.tools.audit_log, exit 1. -
bad/conflicting-rate.aonrestates thesearch_docsrate limit as 240 against the published 120:[aontu/scalar_value]: Cannot unify values at path $.tools.search_docs.rate_limit.per_minute, exit 1. -
aontu agentsmd registry.aonemits the<!-- aontu:begin -->stanza with the top-level keys, and its pin equals the output ofaontu hash registry.aon. -
The MCP server:
initializeanswers with the server nameaontu,tools/listmatchesexpected/mcp-tools.json(vet,get,why,diff,canon,summary,subsume,breaking,set,relations,reaches,view,hash,trim,jsonschema), and thevettool’sinputSchemarequiresschemaanddata. -
tools/callofvetadmits the good call (valid,isError: false) and refuses the bad one withinvalidand theconstraintfinding code. -
100
vetcalls are answered by one server process, and the elapsed time is printed. -
20 cold-start CLI vets are timed and printed: the per-call price of shelling out instead of holding the server open.
Running it
From this directory, ./check.sh runs all 19 assertions and exits 0.
The dispatcher’s two moves, by hand:
aontu registry.aon # the concrete registry
aontu vet --at '$.guard.search_docs' guard.aon data/call-search-ok.json # vet one call at its tool's anchor