MCP tool annotations and approval boundaries
MCP tool annotations can describe expected behavior, but only tests of real side effects should determine which agent actions need approval.

MCP tool annotations are useful documentation. They are also an easy place to make an unsafe approval system feel tidy. If a client treats readOnlyHint, destructiveHint, or idempotentHint as authorization, the server author has effectively written the user's approval policy without proving that the implementation earns it.
That is backwards. An annotation can help explain a prompt, sort a tool list, or suggest a sensible default for a human reviewing an action. Approval must follow the request the server will execute, the credential it will use, the target it will reach, and the side effects it can trigger. I have seen too many integrations call an endpoint named get, return a polite JSON object, and still create work elsewhere.
The distinction matters most with agents because agents retry, compose tools, and act at a pace that makes a small classification mistake expensive. A tool that is safe once can be unsafe in a loop. A tool that is read-only against one API can be an export mechanism against another. A tool that appears idempotent can create duplicate work when a timeout hides the first success.
The annotations describe behavior, they do not grant authority
The Model Context Protocol tool specification describes annotations as hints about a tool's behavior. That wording is deliberate: a client can use them to improve its interface, but it cannot safely use an unverified declaration as a security decision.
The three fields at issue describe different claims:
readOnlyHint: trueclaims that the tool does not modify its environment.destructiveHint: trueclaims that the tool may perform destructive updates.idempotentHint: trueclaims that repeated calls with the same arguments have no additional effect on the environment.
Those claims do not cover the full risk of a call. A tool may read an entire customer database, send the result to the agent, and honestly mark itself read-only. Another tool may write only an access timestamp, which sounds minor until that timestamp changes retention, billing, or an incident record. Idempotence says nothing about whether the first effect was acceptable.
The specification also gives the fields conservative defaults. readOnlyHint defaults to false. idempotentHint defaults to false. destructiveHint defaults to true, and it only has useful meaning when the tool is not read-only. Do not replace those defaults with a homemade rule such as "missing metadata means safe enough." Missing metadata often means the server author did not think through the classification.
There is another uncomfortable point: a benign server can be wrong. A developer adds readOnlyHint: true because the handler performs a SELECT, then a library layer refreshes a token, writes a cache entry, or invokes a request hook. The annotation remains true long after the behavior changed. Nobody set out to deceive the client, but the client still made a bad decision if it auto-approved the action.
A read does not mean a harmless result
A read-only operation can disclose data, consume a scarce resource, or activate behavior in a remote service. Treating "does not write" as "does not need approval" is a category error.
Consider a tool named get_build_log that accepts a job identifier. The server performs a read from its build system and returns the output. It can correctly declare readOnlyHint: true. Yet the log may contain source code, environment details, signed download URLs, or credentials that another system accidentally printed. Sending that response back to an autonomous agent changes who can use that information, even if the build system's database remains untouched.
The same problem appears in administrative APIs. get_user might return recovery codes. list_invoices might expose bank details. search_documents can become bulk extraction when an agent increases a page size or iterates through every prefix. The side effect is disclosure, and the annotation has no field for disclosure sensitivity.
Read operations can also alter the remote service. Some APIs update last_accessed_at, burn a single-use download token, register a preview, or issue a metered query. A cache miss can warm an expensive downstream service. These effects do not make every read dangerous, but they make an unqualified read-only approval rule indefensible.
Classify the call on two separate axes: does it mutate a system, and what can it reveal or cause outside that system? A low-risk status probe and a bulk export can both be non-mutating. They should not receive the same approval treatment.
A practical review record should name the data boundary in plain language. "Reads deployment status for project A" is reviewable. "Calls get_status" is not. The latter hides the target, the scope, the account, and the fact that a similarly named method may mean something different on another server.
Test the handler against a disposable target
You cannot establish the safety of an annotation by reading a tool name or an input schema. Run the server where you can observe its request, its response, and the state before and after the call.
Start with a fixture account that contains records you can lose. Give it a separate API credential and route notification webhooks to a capture endpoint. Record the server's outgoing requests, database state if you control it, audit events, emails, queued jobs, and billing or usage counters. A response body is evidence, but it is not the whole record.
Use a small test matrix for every tool that might influence approvals:
- Call it once with ordinary valid input and save the complete before-and-after state.
- Call it again with byte-for-byte identical input and compare every observable effect.
- Call it with an absent resource, an already-completed operation, and an invalid field.
- Interrupt the client after the server receives the request, then retry the same call.
- Run two identical calls concurrently if agents can issue them concurrently.
The timeout case catches a frequent failure. Suppose create_ticket sends the ticket request, then the connection breaks before the server returns. The agent sees an error and retries. If the ticket system has no idempotency token, the tool creates two tickets. Marking the handler idempotent because its code accepts the same input twice does not change the remote result.
Record an output shape that forces people to inspect effects rather than trust a green response:
case: retry after response timeout
request: {"title":"rotate staging certificate","request_id":"test-104"}
first call: transport timeout after request received
second call: 201 {"ticket":"842"}
remote records: ["841", "842"]
result: not idempotent without a remote idempotency mechanism
The request_id in that example helps only if the remote API stores and enforces it. A client-generated identifier that the server ignores is just decoration. Test that enforcement by repeating the exact identifier and checking whether the remote system returns the original operation rather than creating another one.
Keep the tests with the server. Annotation drift usually arrives with a code change, dependency update, or new endpoint. A passing test that checks the declared hint against observable behavior has more value than a comment next to the tool definition.
Read-only claims fail at the boundaries
The easiest false readOnlyHint comes from looking only at the main database query. The meaningful boundary includes every service called by the handler and every action caused by its response.
Take a server tool that fetches a document. Its primary request is GET /documents/42, but the handler may first exchange a refresh token, issue a temporary download URL, update a local cache, and write an access event. Each operation can fail differently. Each operation may be subject to different credentials and audit requirements.
Do not accept the argument that a write is too small to count. Small writes create their own failure modes. A last-viewed marker can influence retention. A cache can preserve content after access should have ended. An access event can notify an owner. A usage counter can move an account into a paid tier. Ask whether the write changes a fact that another person, process, or bill will observe. If it does, document it.
Response-triggered behavior deserves the same scrutiny. A tool that returns a signed link may cause the agent to fetch it later. A tool that returns an executable command may lead an agent to run it in a different channel. The first tool remains read-only in a narrow sense, but an approval screen that says "safe read" gives the human a false picture of the next action the agent can take.
A good server separates operations when the risks differ. get_document_metadata can remain a narrow inspection call. create_download_link should be its own tool because it creates a bearer capability, even if the underlying document bytes are unchanged. That division helps agents choose correctly and gives reviewers a statement they can actually approve.
Destructive is about reversibility, not a verb list
destructiveHint should reflect whether a call can cause harmful updates that are hard to reverse, not whether the tool name contains delete. Teams get this wrong in both directions.
Some obvious verbs are reversible in one system and permanent in another. archive may merely hide a record, or it may begin a purge timer. disable_user may keep every entitlement and file intact, or it may revoke access in a way that strands an automated process. replace_config may update a draft, or it may cause an immediate production deployment. The handler needs target-aware information that a single Boolean cannot express.
Some tools with innocent names are plainly destructive. sync_members may remove accounts absent from the submitted list. apply_labels may overwrite a carefully maintained taxonomy. reconcile may correct an external ledger with journal entries that nobody should create casually. A server author who marks these false because the API can technically undo them is hiding the operational cost of repair.
Treat reversibility as a sequence, not a checkbox. Ask who can undo the result, what evidence they need, how long the undo remains available, and whether a later process consumes the change before anyone can reverse it. If a human has to reconstruct intent from logs after a bulk update, the action deserves a destructive classification even when an API exposes an inverse method.
The popular bad recommendation is to reserve approval only for explicit deletion. It is popular because it keeps agents moving and makes a demo look smooth. It fails in production because destructive changes are usually replacements, revocations, sends, or reconciliations. Approve the meaningful state change, not the vocabulary used to describe it.
For an action that affects a collection, require the review record to include the selection rule and count. "Sync users" is too vague. "Remove 14 inactive contractors selected by the supplied IDs" lets a person judge scope. If the server cannot report that scope before acting, it has not given the client enough information for a serious approval prompt.
Idempotence must survive retries and concurrency
idempotentHint is a narrow claim: identical arguments should produce no additional effect after the first call. It does not mean a call is safe, cheap, reversible, or appropriate for an agent to repeat forever.
A status update can be idempotent if setting state=closed twice leaves the same record closed. But the handler stops being idempotent if it sends an email each time, appends an audit comment each time, or increments a version counter. People often inspect the database row and miss the secondary effects that users notice first.
Input equality also needs a precise definition. JSON object ordering should not matter. A server that treats omitted note differently from note: "" may receive what the agent considers the same request but execute two distinct updates. Time values, generated defaults, and relative expressions such as tomorrow make the claim weaker because they change the effective command while the visible arguments look stable.
Concurrency is where casual idempotence collapses. Two worker processes can both check that an object does not exist, then both create it. A unique constraint, a transactional upsert, or a remote idempotency facility can prevent this. A memory cache in one MCP server process cannot protect a deployment that runs more than one process.
Use an idempotency record only after you define its scope. Store a caller-supplied token with the authenticated identity, the normalized request body, the result, and an expiry appropriate for the operation. Reject a reused token with different normalized input. Otherwise an agent can accidentally attach an old token to a new request and receive the result of a different action.
Do not auto-retry simply because the hint is true. Retry only failures where you know whether the server received the call. If you cannot know, an idempotency mechanism at the actual mutation point is the cure. The client-side hint is not.
Build approvals from the executed action
An approval system should answer: which process is asking, which credential will be used, which external target will receive the request, what state or data is in scope, and what will happen if the call succeeds. Tool annotations can make that explanation shorter. They cannot supply facts the server did not expose.
Keep session approval and per-call approval separate. Session approval suits a known agent process performing a bounded run with ordinary capabilities. Per-call approval suits credentials that can transfer money, alter production access, send messages, disclose sensitive records, or create an irreversible external commitment. The decision belongs to the credential and action context, not to an optimistic readOnlyHint.
A useful prompt names the concrete operation: "Agent signed by this authority will use the deployment credential to restart service X in account Y." A weak prompt says: "Allow tool deploy?" The first gives a reviewer something to evaluate. The second asks them to trust an implementation detail.
Sallyport follows that separation by keeping API and SSH credentials in its encrypted vault, executing the HTTP or SSH action itself, and returning the result to the agent rather than the credential. Its session authorization identifies the requesting process, while a per-credential setting can require a decision on each use. That is a better place to put human control than a server-provided Boolean.
Even with a gate in front of credentials, keep an activity record that captures the final request target and result. Approval answers whether the action may proceed. An audit record answers what happened when it did. Do not merge those questions into one vague event called "tool used."
Make annotation checks part of server maintenance
The right use for MCP tool annotations is honest communication backed by tests. A server author should set them conservatively, document any boundary case, and change them when behavior changes. A client author should consume them as one input to interface design, never as the only input to permission.
Add tests that deliberately contradict a claimed property. For a read-only declaration, fail the test if the fixture sees a write, an outbound notification, a credential refresh, or a capability created for later retrieval. For a destructive declaration, test the failure path and the undo path, including what happens after a downstream job consumes the change. For idempotence, run the same normalized request after a simulated timeout and under concurrent callers.
Do not hide a mismatch by changing the test target until it passes. Either narrow the tool so the hint becomes true, change the annotation, or expose the effect in the approval details. Each option tells the next maintainer what the code actually does.
Run sp audit verify as part of incident review when Sallyport is the action gateway. It verifies the encrypted hash chain offline, so you can check whether the recorded action history has held together without opening the vault. That does not prove a server's annotation was honest, but it gives you a tamper-evident record of the calls that followed it.
The practical standard is simple: an annotation should survive an adversarial test against the effect that matters to the user. If it cannot, leave it conservative and keep the approval where the action happens.
FAQ
Can I safely auto-approve an MCP tool with readOnlyHint?
Treat it as a claim that needs evidence, not as a permission grant. Inspect the server implementation, then run the tool against a disposable target and check every side effect it can cause.
What does idempotentHint actually guarantee?
It says the author believes repeated calls with the same arguments have no additional effect on the environment. That still leaves external systems, retry behavior, timestamps, notifications, and argument normalization for you to test.
Are MCP tool annotations a security boundary?
No. The Model Context Protocol specification describes these fields as behavioral hints, not a substitute for a client security decision. A malicious, outdated, or simply mistaken server can publish misleading metadata.
Which MCP tools should still require approval?
Keep approval when a call can change business state, expose sensitive data, start a costly job, or reach a system outside the controlled test target. A harmless-looking name and an optimistic annotation do not remove those risks.
How do I test whether a tool is idempotent?
Use a disposable account or local fixture, record the initial state, call the tool twice with identical arguments, and compare the resulting state and external evidence. Repeat with malformed input and an interrupted request, because retry paths often reveal the damage.
Can a destructive tool be safe when nothing changes?
A deletion tool can be non-destructive for a particular record because the record no longer exists, yet destructive in general. Approval design should classify the capability and the target context, not only the outcome of one call.
What happens when MCP annotations are missing?
Assume omission is conservative, then inspect the field semantics before writing automation. In current MCP tool annotations, an omitted destructiveHint defaults to true, while omitted readOnlyHint and idempotentHint default to false.
How can a read-only API call still have side effects?
A tool can return a plausible response, create an audit entry, update a last-accessed field, trigger a webhook, or charge an account while leaving the obvious resource unchanged. Check downstream records, network calls, and system logs rather than only the tool response.
How should I design approvals for autonomous coding agents?
Approve the agent run only after you identify the code-signing authority and intended scope, then reserve per-call approval for credentials or actions where each use needs a human decision. Metadata can help write the approval copy, but it should not decide the approval outcome.
How does Sallyport help control MCP actions?
Sallyport keeps credentials outside the agent and lets a person approve a session or require approval for every use of a selected credential. That supports an approval scheme based on the action actually executed rather than on an annotation supplied by the server.