Declare MCP tool annotations, including readOnlyHint - #63
Open
spawnia wants to merge 1 commit into
Open
Conversation
Clients cannot tell a reading tool from a writing one by name. The MCP spec covers this with ToolAnnotations (revision 2025-03-26), which FastMCP passes through, so declare them for every registered tool. execute_sql reports readOnlyHint=self.is_read_only — the same flag that enforces the read-only statement allowlist — so the hint states enforced behaviour rather than promising it. destructiveHint is only meaningful when readOnlyHint is false, so it is set only on the writing tools.
spawnia
marked this pull request as ready for review
August 27, 2026 11:01
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Declares
ToolAnnotationsfor every tool registered inregister_tools, most importantlyreadOnlyHint.Why
A client receiving
tools/listcannot tell a reading tool from a writing one. Names do not carry that information, andexecute_sqlin particular looks write-capable regardless of the server's mode. Some clients gate on this: Claude Code's plan mode, for example, prompts for confirmation on every call to a tool it classifies as write-capable, so schema questions during planning cost one prompt per query even for a server started withMCP_READ_ONLY=true.ToolAnnotationsis the spec's answer (readOnlyHint,destructiveHint; MCP revision 2025-03-26), and FastMCP passes it through the@mcp.tooldecorator. This server just was not declaring it.What
list_databasesreadOnlyHint=Truelist_tablesreadOnlyHint=Trueget_table_schemareadOnlyHint=Trueget_table_schema_with_relationsreadOnlyHint=Truelist_vector_storesreadOnlyHint=Truesearch_vector_storereadOnlyHint=Trueexecute_sqlreadOnlyHint=self.is_read_onlycreate_databasereadOnlyHint=False, destructiveHint=Falsecreate_vector_storereadOnlyHint=False, destructiveHint=Falseinsert_docs_vector_storereadOnlyHint=False, destructiveHint=Falsedelete_vector_storereadOnlyHint=False, destructiveHint=TruedestructiveHintis only meaningful whenreadOnlyHintis false, so it is left unset on the read-only rows.execute_sqlis the substantive one.self.is_read_onlyis in scope at registration and is the same flag that enforces the read-only statement allowlist inexecute_sql, so the annotation reports behaviour this server already enforces rather than promising good behaviour. That is what makes the hint worth acting on: the spec warns clients not to trust annotations from untrusted servers, and a hint backed by enforcement is the strongest thing a server can offer.Tests
Four cases added next to the existing ones in
src/tests/test_mcp_server.py, asserting the annotations on thetools/listresponse. Bothexecute_sqlshapes are covered — read-only mode givingreadOnlyHint=Trueand non-read-only mode givingFalse— since that conditional is the point of the change.Verified against a local MariaDB 11.4:
EMBEDDING_PROVIDER, plus oneSHOW-related case)tools/listover stdio withMCP_READ_ONLY=truereportsexecute_sqlwith{"readOnlyHint": true}, so the annotation reaches the client and is not merely present in the source