# Your agent's tool manifest is a comment until something reads it

> A scheduled agent declared six MCP tools, called none of them, and reported ok. Enforcing that declaration at fire time cut selection from 1-of-942 to 1-of-6.

- Author: Chad Priest
- Published: 2026-08-28
- Canonical URL: https://blog.vodou.ai/enforce-agent-tool-declarations/
- Tags: ai-agents, mcp, architecture, observability

---

Almost every agent framework now has a place to declare what a task needs. A `required_tools` array, a manifest, a frontmatter block, an allowlist in a YAML file. Go and check whether anything in your runtime actually reads it before the turn starts. In mine, for months, nothing did.

The field was populated. The UI rendered it. A scheduled agent could name six tools, call zero of them, produce a paragraph of confident prose, and write `ok` to the run log. Nothing lied. Nothing was checked either.

## A scheduled agent named six tools, called none of them, and reported ok

I have four standing agents that fire on cron. Two of them were producing output that read fine and contained nothing. When I went looking for the cause I found the honest version of it in my own notes: the two agents producing nothing were exactly the two with no tool declaration to enforce. The declaration was the only thing that could have caught it, and where it was absent there was nothing to catch.

So `required_tools` had to stop being decoration. One declaration, two separate jobs at run time.

The first is refusal. Before the fire endpoint calls into the model at all, it resolves every declared `server/tool` pair against the live catalog:

```sql
SELECT t.name
FROM tools t
JOIN mcp_servers s ON s.id = t.server_id
WHERE s.name = ? AND t.name = ? AND s.active = 1;
```

Zero rows means the skill is broken, and it refuses to fire, naming the entry that failed. `active = 1` carries as much weight as existence. A server that is registered but deactivated cannot answer, and firing at it reproduces exactly the failure the gate exists to prevent. A deregistered server, a renamed tool, a revoked integration: all of these are knowable in a millisecond, and finding out after a multi-minute LLM turn wastes the turn and buries the cause in prose.

The second job is bounding. The set that resolves becomes the turn's tool allowlist. My install exposes 942 tools. A skill that declares six should see six.

**Diagram: Tool selection space for one scheduled agent**

Tools visible to a scheduled turn drops from 942 to 6

```text
  before: whole catalog : 942 tools  ################################ (problem)
  after: declared set   :   6 tools  # (fixed)
```

The placement matters more than the number. The allowlist is enforced in `runVodouCore` in `MCP-servers/Vodou-Console/src/llm.ts`, the single choke point every MCP call passes through, and not in the system prompt. A prompt-level restriction is a request. This is a bound. It is read from the database at fire time, so revoking a server takes effect on the next run rather than the next deploy.

## The verifier existed, and the lane with all four live agents never called it

This is the part I did not enjoy finding. The engine has had a verifier since it was written that resolves every step's server and tool against the catalog before a skill is accepted. It just served the file-backed skill system, the one with a `SKILL.md` and an `actions.json` on disk.

The other skill system, the one that creates a database row and arms a cron from a single English sentence, validated the name regex, the prompt length, the delivery enum and the history window, then inserted and scheduled. No check that the named tools exist. Two skill systems, one verifier, and the lane holding all four live standing agents was the lane without it.

Capability built in one place while the product lives in another. That is not a bug you can find by reading either lane on its own.

**Diagram: What one English sentence has to survive now**

Skill creation path now goes through the verifier and a dry run before a cron is armed

```text
  [One sentence] --> [Verifier]
  [Verifier] --all refs resolve--> [Dry run (fixed)]
  [Verifier] --any ref missing or inactive--> [Refused (problem)]
  [Dry run (fixed)] --> [Cron armed (fixed)]

  notes:
    One sentence: "every morning, brief me"
    Verifier: same tool resolution as fire time
    Refused: names the unresolvable ref

  Before this, the sentence went straight to an armed cron.
```

Sharing the verifier was not enough on its own. The catalog lookups it used did not filter on server activity, so creation would have happily accepted a reference that the fire-time gate refuses. That combination is the worst possible arrangement: a skill accepted at creation, then quietly refusing to run on its schedule, with the failure surfacing days later when someone notices the briefing stopped arriving. Both ends now apply the same predicate, from one function, so they cannot drift.

I also had to fix the tool's own description, which told the model that a cron "won't fire yet" when it does in fact fire on creation. A creation tool that understates its own side effects is a tool the model will use carelessly.

## `morning-briefing` printed `0 13 * * *` and fires at 13:05

I deployed the new standing-agents list and read its output against the live schedule the same afternoon. It printed `0 13 * * *`. The agent fires at 13:05, confirmed three times that day.

Two tables held a schedule for that skill and they disagreed. The endpoint preferred the skill metadata copy. The scheduler reads the other one.

**Diagram (beforeafter)**

Before, the UI showed the metadata schedule; after, it shows the scheduler's and flags the mismatch

```text
  BEFORE: Before
    - reads skills_meta.schedule_cron
    - prints 0 13 * * *
    - actual fire time 13:05
    - user sets their morning around it

  AFTER: After
    - reads the scheduler's row
    - prints the time that fires
    - mismatch shown as a finding
    - which record to correct is the operator's call
```

The scheduler's copy wins the display because it is the one that acts. Printing the other shows a time the product does not honour, which is worse than showing nothing, because a user would plan around it. And the disagreement is surfaced rather than silently reconciled. Two records disagreeing about when an unattended agent runs is a finding about that install, not a rendering detail, and deciding which one is wrong is not a decision a display endpoint gets to make. One of my four agents is affected. The other three agree.

## The property: every displayed constraint is read at run time by the path it constrains

Here is the transferable version, stated so you can check it rather than agree with it.

For every field your product presents as a constraint, there is a read of that field on the execution path it claims to constrain, at the time that path runs. And where the same constraint is checked at write time and at run time, both checks evaluate the same predicate, from one function.

Both halves fail independently. A field nobody reads is a comment with a schema. A field read at write time with a looser predicate than run time is worse than no check at all, because it converts an immediate error into a delayed one.

The schedule bug is the same property in a different organ: a value the product displays as a fact about behaviour, that no longer comes from the code producing the behaviour.

## Join your job table to your tool registry and count the rows that cannot resolve

Three checks against your own stack. Nothing here is mine.

First, do your declared tool references still resolve? Adapt the table names:

```sql
SELECT j.name, d.server_name, d.tool_name
FROM jobs j
JOIN job_declared_tools d ON d.job_id = j.id
LEFT JOIN tools t
  ON t.name = d.tool_name
LEFT JOIN servers s
  ON s.name = d.server_name AND s.enabled = 1
WHERE t.id IS NULL OR s.id IS NULL;
```

Passing is zero rows. Every row you get back is a scheduled job that will spend a full model turn to discover something this query found instantly. Note the `s.enabled = 1`: drop it and the query passes on servers that cannot answer.

Second, does what a run declared match what it called? If your runs emit structured logs:

```bash
jq -r 'select(.event=="run_end")
       | [.run_id, (.declared_tools|length), (.called_tools|length), .status]
       | @tsv' runs.jsonl | awk -F'\t' '$2>0 && $3==0'
```

Every line printed is a run that declared tools, called none, and still reported a status. If that status is `ok`, you have the failure I had. If your runs do not log `called_tools` at all, that is the finding: you cannot answer this question about your own system.

Third, if a schedule is stored in two places (a display record and a scheduler record, which is extremely common once a UI grows over a job runner):

```sql
SELECT a.name, a.schedule AS displayed, b.cron AS actually_fires
FROM job_metadata a
JOIN scheduler_entries b ON b.job_name = a.name
WHERE a.schedule <> b.cron;
```

Zero rows is the answer you want. Any row is a job whose advertised time is fiction, and you found it in under a minute.

## The papers say write the contract down. Mine was written down.

There is a real body of work converging on this shape. [SoK: Agentic Skills](https://arxiv.org/html/2602.20867v1) defines a skill as a callable module packaging procedural knowledge with explicit applicability conditions and execution policies. [Contractual Skills](https://arxiv.org/html/2605.22634v1) argues that informal prose controls are hard to review, test, or connect to runtime guardrails, and proposes structured contract fields in `SKILL.md`. [ContractSkill](https://arxiv.org/html/2603.20340v3) makes the sharpest version of the point: the bottleneck is not generation quality, it is that skills stay implicit and therefore cannot be checked or repaired.

All correct, and all about the writing side. My contract was already written down, in a typed column, rendered in the UI. The gap was that the sentence "this skill needs these six tools" had no consumer on the path where it mattered. [Contract2Tool](https://arxiv.org/html/2606.07904v1) gets closest to the runtime half, inferring preconditions and effects so contracts can be evaluated before a call, and [AWS's write-up on controlled tool orchestration](https://aws.amazon.com/blogs/devops/flexibility-to-framework-building-mcp-servers-with-controlled-tool-orchestration/) is the only piece I read that treats enforcement order as the design problem rather than the schema.

The specification literature will tell you what to write. Almost none of it will tell you to go and grep for the read.

## Still open: two records disagree on `morning-briefing` and a human picks

The two records disagreeing about `morning-briefing` still disagree. I made the product show the one that acts and flag the conflict, and I left the reconciliation to a human, because I do not know which value was the intent. That is defensible, and it is also unfinished: a flag nobody clears is a slower version of the problem I just wrote a thousand words about.

The `required_tools` contract also only binds skills that declare tools. A skill with an empty declaration gets the whole catalog and no fire-time check, which is exactly the state the two silent agents were in. Making the declaration mandatory is the obvious next move and it breaks every existing skill row, so it is a migration, not a patch.

Three commits, 25 files, one afternoon, and the useful output was not the feature. It was learning that I had a verifier and a lane that never called it.

---

Source: [Your agent's tool manifest is a comment until something reads it](https://blog.vodou.ai/enforce-agent-tool-declarations/) by Chad Priest, from Building Vodou in Public.
