# Your MCP tools never told the agent what they can do

There is a moment, early in wiring up an MCP server, where you stop thinking about the *tools* and start thinking about the *feature*. You had a clean plan - this server exposes an invoice API to an agent - and by the time it works you have a dozen functions decorated with `@mcp.tool()`, each one a real capability handed to a model that will decide, on its own, when to call it.

Here is the thing that boots fine and passes every test anyway: not one of those tools tells the agent what it can *do*.

### The four words the spec gave you, and you didn't use

The Model Context Protocol has a small, quiet feature called tool annotations. Alongside a tool's name and input schema, you can declare four behavioural hints:

*   `readOnlyHint` - this tool does not change anything.
    
*   `destructiveHint` - this tool can perform irreversible updates or deletes.
    
*   `idempotentHint` - calling it twice is the same as calling it once.
    
*   `openWorldHint` - this tool reaches beyond a closed system (the internet, a third party).
    

They exist for one purpose: to let the thing on the other end reason about a tool's blast radius *before* it invokes it. A client can auto-approve the reads and pause for a human on the writes. An agent can prefer the idempotent path when it's unsure whether its last call landed. This is the difference between an agent that retries a `get` and an agent that retries a `charge`.

And they are almost never set. Open a handful of public MCP servers and you'll find tool after tool registered with a name, a schema, and nothing else. The annotation surface - the one part of the definition that is *about safety* - is empty.

### Why the gap is invisible

The problem with a missing annotation is that nothing breaks. Your server starts. The tool works. The agent calls it and gets a result. Your test suite, which checks that the tool *does the right thing*, has no opinion about whether the tool *told anyone what kind of thing it does*. There is no failure condition for silence.

So the gap survives review the way every documentation gap survives review: not because anyone decided it was fine, but because nothing ever asked the question.

And the cost is paid later, by the agent, at the worst possible time. A next-token predictor under pressure to be helpful is looking at a list of tool names. `get_invoice` and `delete_invoice` are four characters apart. Without a `destructiveHint` on the second one, the only thing standing between "read the invoice" and "delete the invoice" is the model reading the name carefully - which is precisely the thing we know models don't reliably do when the context gets long and the task gets urgent.

### hintgap: make the silence fail

`hintgap` is a static analysis CLI that turns that silence into a build failure. It parses your Python MCP server with the standard library `ast` module - it never imports your code, never runs it, never touches the network - finds the tool registrations, and reports the ones that leave the safety surface empty.

```text
$ hintgap examples/server.py

HG-NOHINTS  examples/server.py:34  (list_customers)
    tool 'list_customers' declares no safety annotations (readOnlyHint/destructiveHint/idempotentHint/openWorldHint) - the agent cannot reason about its blast radius
HG-NOHINTS  examples/server.py:41  (send_payment)
    tool 'send_payment' declares no safety annotations (readOnlyHint/destructiveHint/idempotentHint/openWorldHint) - the agent cannot reason about its blast radius
HG-WRITE-NOHINT  examples/server.py:41  (send_payment)
    tool 'send_payment' has a mutating verb in its name but is not marked destructiveHint=True (nor readOnlyHint=False) - an agent may treat a write/delete as a safe read
HG-NODESC  examples/server.py:48  (refresh_cache)
    tool 'refresh_cache' has no description (no description= and no docstring) - the agent has nothing to reason about but the name
HG-NOHINTS  examples/server.py:48  (refresh_cache)
    tool 'refresh_cache' declares no safety annotations (readOnlyHint/destructiveHint/idempotentHint/openWorldHint) - the agent cannot reason about its blast radius

5 gaps  ·  exit 1
```

It exits non-zero, so it drops into CI as one line.

### The three checks

hintgap has exactly three rules, and each one maps to a way the safety surface goes empty.

| Rule | Fires when | The risk it names |
| --- | --- | --- |
| **HG-NOHINTS** | a tool has no annotation hints at all | the agent can't tell a read from a mutation |
| **HG-WRITE-NOHINT** | a tool whose name implies mutation isn't marked `destructiveHint=True` (nor `readOnlyHint=False`) | a write the agent may treat as a safe read |
| **HG-NODESC** | a tool has no description and no docstring | the model has to guess the purpose from the name |

The middle rule is the one that matters most. It's not enough to notice that *some* tools lack annotations - the dangerous case is the specific tool that mutates state while looking, to a hurried reader, like a query. hintgap tokenizes the tool's name (splitting both `snake_case` and `camelCase`), checks each token against a curated set of mutating verbs - `create`, `update`, `delete`, `write`, `send`, `pay`, `remove`, `charge`, `refund`, `publish`, and more - and flags it if it doesn't explicitly declare that it's not a safe read. Read verbs (`get`, `list`, `fetch`, `search`) are absent from that set on purpose, so a genuine query tool never trips it.

![](https://cdn.hashnode.com/uploads/covers/6a548baa47c6120a79511a69/489a7947-ae2f-40a8-8057-2a6eb77010dc.svg align="center")

### It reads the shapes you actually write

hintgap understands the two ways people register MCP tools in Python.

**FastMCP decorators**, with or without a call, with or without arguments:

```python
@mcp.tool()                      # flagged: HG-NOHINTS
def list_customers(): ...

@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))   # clean
def get_invoice(id): ...
```

**Low-level SDK construction** - `types.Tool(...)` as returned from a `list_tools` handler, next to an `@app.call_tool` dispatcher:

```python
types.Tool(
    name="delete_file",          # flagged: HG-NOHINTS + HG-WRITE-NOHINT
    description="Delete a file.",
    inputSchema={...},
)
```

Annotations are read whether you pass a `ToolAnnotations(...)` object, a plain `{...}` dict, or the hints as direct keyword arguments. And to keep false positives near zero, the low-level detector only treats a `Tool(...)` call as a registration when it carries a `name=` - so an unrelated class you happen to have called `Tool` is left alone.

### Fixing a finding

Every finding has the same fix: say what the tool does.

```python
@mcp.tool(
    description="Permanently delete an invoice.",
    annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=True, idempotentHint=True),
)
def delete_invoice(invoice_id: str) -> str:
    ...
```

`destructiveHint=True` or `readOnlyHint=False` clears `HG-WRITE-NOHINT` - either one is an explicit statement that this is not a safe read. And if you've looked at a tool and decided the gap is acceptable, one comment silences it:

```python
@mcp.tool()  # hintgap: ignore
def ping() -> str:
    return "pong"
```

### What hintgap deliberately is not

This is opinionated hygiene, not a vulnerability scanner. An unannotated tool is not exploitable on its own; hintgap is a nudge toward a safer default, meant to go green once your server is annotated and stay green.

It checks *presence*, never *correctness*. A tool marked `readOnlyHint=True` that secretly writes to a database will pass - hintgap can't know your implementation, and by design never runs it to find out. It's the same trade every static, zero-config check makes: it can tell you the annotation is missing; it can't tell you the one you wrote is honest. That's still a large step up from an empty surface, because a wrong annotation is at least a reviewable claim, and an absent one is nothing at all.

If you're already thinking about MCP tool safety, hintgap sits in a specific slot. Tools that audit a tool *manifest* for security risk - unscoped file paths, shell execution, prompt injection in descriptions - are asking what a tool's schema *exposes*. Tools that lint the input *schema* for strictness are asking whether the model can call it with valid arguments. hintgap asks the one question those leave open: did you declare the four hints that tell an agent how much damage this tool can do? Nothing else, from the source, does that.

### Try it

hintgap is a single stdlib-only Python file, MIT licensed, Python 3.8+.

```bash
pip install hintgap
hintgap src/
```

Source, examples, and the full rule reference: **https://github.com/jay-tank/hintgap**

Run it over your MCP server once. If it comes back with a wall of `HG-NOHINTS`, that's not hintgap being noisy - that's the exact list of tools your agent has been calling blind.
