MCP Tool Annotations Are the Seatbelt Your Database Queries Needed

The MCP 2025-11-25 spec added tool annotations — readOnlyHint, destructiveHint, idempotentHint — that let AI agents reason about database operation safety before executing. Here's how they work and why Faucet implements them correctly.

Most of the conversation about AI agent safety focuses on authentication and authorization: who can call what, which roles have which permissions, whether the API key is scoped correctly. That conversation matters. But there is a second layer of safety that gets far less attention — helping the agent itself understand whether the operation it is about to execute is safe to run without confirmation.

The MCP 2025-11-25 specification introduced tool annotations specifically to solve this problem. They are not access controls. They do not enforce anything. What they do is communicate the nature of a tool to the client, so that clients and agents can make informed decisions about whether to run something automatically or pause for human review.

For database access — where the gap between a safe SELECT and a catastrophic DELETE is a few characters — this distinction is fundamental.

What Tool Annotations Are

In MCP, every tool definition can include an annotations object. The 2025-11-25 spec defines four boolean hint properties:

  • readOnlyHint: The tool does not modify any data. It is safe to call repeatedly without side effects.
  • destructiveHint: The tool may delete or permanently alter data. Clients should request explicit user confirmation before invoking it.
  • idempotentHint: Calling the tool multiple times with the same arguments produces the same result. Safe to retry on failure.
  • openWorldHint: The tool interacts with external systems that may have effects beyond the MCP server itself (sending emails, charging payments, etc.).

Here is what the wire format looks like in a tools/list response:

{
  "name": "query_customers",
  "description": "Fetch customers matching filter criteria",
  "inputSchema": {
    "type": "object",
    "properties": {
      "status": { "type": "string" },
      "limit": { "type": "integer" }
    }
  },
  "annotations": {
    "readOnlyHint": true,
    "destructiveHint": false,
    "idempotentHint": true,
    "openWorldHint": false
  }
}

The spec is explicit that these are hints, not enforceable guarantees. A malicious or buggy server could lie about them. The warning in the spec is direct: clients MUST consider tool annotations to be untrusted unless they come from a trusted server. This is an important caveat we will return to.

But for a trusted server — one managing a database through a governed API layer — these annotations enable something powerful: the agent can reason about safety before committing to an action.

Why This Matters for Database Operations

Database operations fall into a natural safety hierarchy:

OperationSQLSafe to auto-execute?Annotation
Read recordsSELECTYesreadOnlyHint: true
Insert new recordINSERTMaybe, with confirmationidempotentHint: false
Update existing recordUPDATENeeds reviewdestructiveHint: true
Delete recordDELETERequires explicit confirmationdestructiveHint: true
Truncate tableTRUNCATENever auto-executedestructiveHint: true

Without annotations, an AI agent has no protocol-level way to distinguish between these categories. It sees a list of tools, each with a name and a description string. The agent has to infer safety from natural language — and natural language inference is unreliable when the cost of being wrong is data loss.

Consider a support agent tasked with “cleaning up inactive accounts.” Without annotations, the agent might:

  1. Call list_accounts with status=inactive to identify targets
  2. Immediately call delete_account for each result, because that is what “cleaning up” implies

With correct annotations, step 2 triggers a destructiveHint: true signal. A well-implemented MCP client will either pause and request user confirmation, or refuse to execute without explicit authorization. The annotation does not prevent the operation — it creates a checkpoint.

This is the difference between an agent that assists and an agent that causes incidents.

The Trust Caveat Is Real

The spec’s warning about untrusted annotations deserves emphasis. Any MCP server can claim any annotation values it wants. An attacker who controls an MCP server you connect to could label a DROP TABLE operation as readOnlyHint: true and your agent might execute it without confirmation.

This is why the trust model for MCP database servers matters enormously. When you connect an agent to a third-party MCP server you downloaded from the internet, you are trusting that server’s annotation claims. The MCP spec tells clients to treat annotations as untrusted from unknown sources — but most current MCP clients do not implement that skepticism correctly or at all.

The practical implication: for production database access, you want to run your own MCP server, one you control, with annotations you have verified. The server’s source code should be auditable. Its annotation values should be derived from its actual behavior, not declared in a config file.

This is exactly the architectural position Faucet occupies.

How Faucet Implements MCP Tool Annotations

Faucet’s built-in MCP server exposes a fixed set of eight tools, the same for every database you connect, and annotates each one according to what it actually does:

ToolSQL OperationreadOnlyHint
faucet_list_servicesnone (metadata)true
faucet_list_tablesnone (metadata)true
faucet_describe_tablenone (metadata)true
faucet_querySELECTtrue
faucet_insertINSERTfalse
faucet_updateUPDATEfalse
faucet_deleteDELETEfalse
faucet_raw_sqlarbitrary SQLfalse

Faucet sets readOnlyHint explicitly on every tool and leaves the other hints at their spec defaults. Under the spec, a tool that is not read-only defaults to destructiveHint: true and idempotentHint: false, so a compliant client treats faucet_query as safe to run freely and faucet_delete (and every other write tool) as destructive and worth a confirmation.

The annotations are not configured — they are fixed in the server code. faucet_query is structurally read-only because its implementation only issues a SELECT. faucet_delete is structurally destructive because it issues a DELETE, and it refuses to run without a filter, so a confused agent cannot wipe a whole table in one call. There is no annotation config to misconfigure or forget to update when you add a new table.

Connect Faucet to your database and start the server:

brew install faucetdb/tap/faucet

faucet db add --name mydb --driver postgres --dsn "postgres://user:pass@localhost:5432/mydb"
faucet serve

The MCP endpoint is available at http://localhost:8080/mcp (Streamable HTTP, authenticated with an X-API-Key header). For Claude Desktop, run Faucet as a local stdio server instead:

{
  "mcpServers": {
    "faucet": {
      "command": "faucet",
      "args": ["mcp"]
    }
  }
}

Now run tools/list against it and you get the same eight annotated tools no matter how many tables you have:

{
  "tools": [
    {
      "name": "faucet_query",
      "description": "Query records from a database table with optional filtering, field selection, ordering, and pagination...",
      "inputSchema": { ... },
      "annotations": {
        "readOnlyHint": true
      }
    },
    {
      "name": "faucet_delete",
      "description": "Delete records from a database table that match a filter expression. A filter is required to prevent accidental full-table deletes...",
      "inputSchema": { ... },
      "annotations": {
        "readOnlyHint": false
      }
    }
  ]
}

The agent connecting to this server gets a complete, correctly annotated tool surface for every table — without writing a line of MCP server code.

Combining Annotations with RBAC

Tool annotations tell the agent what an operation does. RBAC tells the server what the agent is allowed to do. These are complementary controls, and both are necessary.

An agent with a readOnlyHint: true tool might still try to call a write endpoint if it is confused. RBAC catches that at the server. An agent with write permissions but no destructiveHint guidance might execute a delete without pausing. Annotations catch that at the client.

In Faucet, you layer these controls with roles and API keys:

# A role that can read customers and create tickets, nothing else
faucet role create --name support-agent --description "Claude support bot"
faucet role grant --role support-agent --service mydb --component "_table/customers" --verbs GET
faucet role grant --role support-agent --service mydb --component "_table/tickets" --verbs GET,POST

faucet key create --role support-agent --label claude-support-bot

The support agent gets:

  • API-key-scoped RBAC: physically cannot call DELETE (or touch any other table) regardless of what it tries, because Faucet’s permissions fail closed
  • MCP tool annotations: faucet_query is labeled readOnlyHint: true; faucet_insert is not read-only, so clients treat it as non-idempotent
  • Filter-required mutations: even a role that is granted DELETE cannot run an unfiltered faucet_delete

Keep sensitive columns such as ssn out of the agent’s reach by pointing it at a table or view that does not include them; Faucet’s permissions are per table and per verb.

The annotations layer over RBAC to give the agent intent signal even within its permitted operation set. A POST to create a ticket is not destructive, but it is not idempotent either — the agent should not retry it silently on timeout. That is exactly what the non-read-only annotation, with its idempotentHint: false default, communicates.

The Tool List Explosion Problem

One practical issue with MCP and databases: a server that generates tools per table turns a schema with 100 tables into 400+ tools (list, get, create, delete per table). This overwhelms LLM context windows and makes tool selection unreliable.

Faucet avoids the problem by design. It does not generate tools per table. The eight faucet_* tools take the service and table as arguments, so a 3-table database and a 300-table database expose exactly the same tool list. The agent discovers tables on demand with faucet_list_tables and faucet_describe_table instead of loading every schema into context at startup.

A smaller tool list is also a security benefit. Every tool you expose is a potential attack vector for prompt injection. Minimizing the exposed surface area reduces risk, and RBAC still decides which tables each API key can actually reach.

What Good MCP Clients Do with Annotations

The annotations only matter if MCP clients act on them. The current client ecosystem is inconsistent. Claude Desktop respects destructiveHint and surfaces a confirmation prompt for destructive tools. Other clients vary.

What a fully compliant client should do:

  1. Read-only tools (readOnlyHint: true): Execute automatically as part of agent reasoning. No confirmation required. Safe to call multiple times as the agent refines its understanding.

  2. Non-idempotent write tools (idempotentHint: false): Present the agent’s intended action to the user before executing. Include the tool name and arguments in the confirmation.

  3. Destructive tools (destructiveHint: true): Require explicit user confirmation, not just passthrough. Display the target resource (which record, how many rows) before proceeding.

  4. Open-world tools (openWorldHint: true): Treat with the same caution as destructive tools, because the effects extend beyond the database.

Faucet’s annotation values are designed with this client behavior model in mind. Read queries are optimized to run freely. Write operations carry hints that responsible clients will surface to users. The architecture assumes agents will be aggressive — and builds in the signals to make clients appropriately cautious.

Why the Protocol Approach Beats Application-Level Heuristics

Before tool annotations existed, developers built safety heuristics into agent prompts: “always confirm before deleting data,” “never run destructive operations without asking.” These work sometimes, but they fail in predictable ways.

Prompt-level safety instructions are not enforced by the protocol. They can be overridden by subsequent instructions in the same conversation. They depend on the model’s interpretation of natural language, which varies by model version and context window state. They are invisible to the MCP client, which cannot apply its own confirmation UI because it does not know which tools are dangerous.

Protocol-level annotations solve each of these failure modes. They are in the wire protocol, not the prompt. They are consistent across models and clients. They are visible to the client layer, which can apply its own UI controls independently of what the model is instructed to do.

This is what makes the 2025-11-25 spec a meaningful safety milestone for agentic database access — not a marginal feature addition, but a structural improvement to how safety information flows through the system.

Getting Started

Faucet is open-source under the MIT license. The MCP server with tool annotations ships in the same binary as the REST API server.

# Install
brew install faucetdb/tap/faucet

# Add your database connection
faucet db add --name prod --driver postgres --dsn "postgres://user:pass@host:5432/db"

# Start the server (REST API at /api/v1, MCP at /mcp)
faucet serve

# Or run MCP over stdio for Claude Desktop (local use, admin rights)
faucet mcp

Supported databases: PostgreSQL, MySQL, MariaDB, SQL Server, Oracle, SQLite, and Snowflake.

GitHub: github.com/faucetdb/faucet

The MCP specification gave database tools a way to declare their safety profile in the protocol itself. The question is whether the servers you connect your agents to are actually implementing it correctly — or just asserting safe annotations on operations that are not.