feat(mcp): centralise ToolAnnotations across public + SDK tool surface - #87
Open
louzt wants to merge 4 commits into
Open
feat(mcp): centralise ToolAnnotations across public + SDK tool surface#87louzt wants to merge 4 commits into
louzt wants to merge 4 commits into
Conversation
Greptile SummaryCentralizes MCP tool safety annotations across the public, documentation-search, and dynamically generated SDK tool surfaces.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains; the gateway no longer promises non-destructive behavior and the prior finding is fixed. Important Files Changed
Reviews (3): Last reviewed commit: "fix(rebase): drop orphaned 'from . impor..." | Re-trigger Greptile |
louzt
added a commit
to louzt/appwrite-mcp
that referenced
this pull request
Jul 27, 2026
appwrite#87 review) Greptile flagged appwrite#87 operator.py:263 as P1: the appwrite_call_tool gateway dispatched to delete-classified SDK methods while advertising destructiveHint=False, letting clients that gate human approval on the hint treat a destructive tool as non-destructive. Fix: introduce annotations_for_gateway() in annotations.py. destructiveHint is intentionally set to None (unset) — the MCP 2025-06-18 spec defaults an unset hint to True, so clients default to destructive and prompt the user for every gateway call. This matches the runtime confirm_write=true boundary enforced inside _call_hidden_tool, but at the layer where MCP clients (Claude Code, Gemini MCP, Appwrite broker) actually make the approval decision. annotations_for_classification("unknown") is preserved for verb buckets we genuinely cannot derive; the gateway's profile is intentionally separate because the security semantics differ (unknown cannot promise non-destructive; gateway cannot promise anything static). - 5 new tests in AnnotationsForGatewayTests - test_appwrite_call_tool_is_unknown renamed and updated to assert destructiveHint is None (with a regression-guard comment) - AnnotationsModuleSurfaceTests now covers the new public name CI: ruff ✓, black ✓, pyright ✓, unittest 127/127 ✓.
Adds ToolAnnotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint, title) to the 4 public MCP tools exposed by Appwrite MCP per AGENTS.md §Tool surface: - appwrite_get_context: readOnly, idempotent, openWorld (calls Cloud APIs) - appwrite_search_tools: readOnly, idempotent, NOT openWorld (local catalog) - appwrite_call_tool: NOT readOnly, NOT idempotent, openWorld (dispatches to Appwrite SDK with confirm_write=true gate on mutating calls) - appwrite_search_docs: readOnly, idempotent, NOT openWorld (local index) Each hint carries an inline comment explaining the semantic rationale, matching the pattern already established in serpapi-mcp-fork so reviewers can audit the readOnly/destructive/idempotent/openWorld choices at a glance. Includes tests/unit/test_annotations.py with 13 unittest-style tests covering: tool surface count + uniqueness, every-hint-is-bool shape, readOnly implies not-destructive, readOnly implies idempotent, appwrite_call_tool honest semantics, search tools are not open world, and human-readable title presence. All 111 unit tests pass. Per the MCP 2025-06-18 spec, leaving a hint unset means 'unknown' and forces clients to prompt the user. With these annotations, MCP clients (Claude Code, Cursor, Antigravity) can safely auto-execute the read-only context and search tools while keeping write/delete calls gated on the existing confirm_write=true mechanism.
Expand the ToolAnnotations work from 4 public tools to cover every tool the
server can emit (4 public + ~25 dynamic SDK services + 1 docs tool), driven
by a single helper module so the hints stay in lock-step with the action
verb in the tool name.
What's in the diff:
- New module ``src/mcp_server_appwrite/annotations.py`` exporting two pure
helpers:
- ``classify_tool_name(tool_name)`` — extract the action verb and bucket
the tool as ``read`` / ``write`` / ``delete`` / ``unknown``.
- ``annotations_for_classification(bucket)`` — return a canonical
``ToolAnnotations`` instance with every hint set explicitly. The inline
rationale comments document why each hint has its value (e.g. why
``unknown.openWorldHint=True`` despite not knowing the verb, why
``delete.idempotentHint=True``).
- ``service.py``: ``Service.list_tools()`` now classifies each SDK method
by its action verb and attaches the resulting annotations. This covers
every method on every service the Appwrite SDK ships (~25 services,
hundreds of methods).
- ``operator.py``: the 3 public tools now use ``annotations_for_classification``
instead of inline ``ToolAnnotations(...)`` constructions. The old inline
blocks were removed in the same commit to keep the diff reviewable.
- ``docs_search.py``: the docs search tool uses the helper for its
``read`` annotation.
Why a single helper instead of inline annotations per tool:
- 30 tools × 4 hints = 120 places where drift can creep in. One bug in
``classify_tool_name`` would silently mislead every MCP client that
filters on these hints (claude-code, Gemini MCP, Appwrite broker).
- Pure functions make the mapping exhaustively testable.
- Future SDK methods automatically get the right hints without anyone
having to remember the pattern.
Test coverage expansion (from 13 → 41 net new tests):
- ``classify_tool_name`` parametrized over 19 read verbs, 19 write verbs,
10 delete verbs, 4 unknown names, case-insensitivity, verbs in the
middle of names.
- ``annotations_for_classification`` for every bucket; explicit-field check
catches MCP-default leakage; unrecognised inputs fall through to
``unknown``.
- Parity with ``operator._classify_verb`` for every known verb × resource
combination — both helpers stay in lock-step.
- Public tools: ``appwrite_get_context`` / ``appwrite_search_tools`` are
read; ``appwrite_call_tool`` is the gateway (``unknown``); the docs
search tool is read.
- Dynamic SDK tools: ``Service.list_tools()`` is invoked on a stub users
service and the resulting ``Tool`` objects carry annotations consistent
with the verb bucket.
The "consistent with" check accounts for the MCP spec limitation that
write and unknown collapse to the same wire shape on the four hints.
Total: 122 unit tests pass locally. All four CI jobs (ruff, black,
pyright, unittest) green.
Refs appwrite#86 — supersedes the 4-tool-only draft.
appwrite#87 review) Greptile flagged appwrite#87 operator.py:263 as P1: the appwrite_call_tool gateway dispatched to delete-classified SDK methods while advertising destructiveHint=False, letting clients that gate human approval on the hint treat a destructive tool as non-destructive. Fix: introduce annotations_for_gateway() in annotations.py. destructiveHint is intentionally set to None (unset) — the MCP 2025-06-18 spec defaults an unset hint to True, so clients default to destructive and prompt the user for every gateway call. This matches the runtime confirm_write=true boundary enforced inside _call_hidden_tool, but at the layer where MCP clients (Claude Code, Gemini MCP, Appwrite broker) actually make the approval decision. annotations_for_classification("unknown") is preserved for verb buckets we genuinely cannot derive; the gateway's profile is intentionally separate because the security semantics differ (unknown cannot promise non-destructive; gateway cannot promise anything static). - 5 new tests in AnnotationsForGatewayTests - test_appwrite_call_tool_is_unknown renamed and updated to assert destructiveHint is None (with a regression-guard comment) - AnnotationsModuleSurfaceTests now covers the new public name CI: ruff ✓, black ✓, pyright ✓, unittest 127/127 ✓.
… rebase The rebase onto upstream/main (PR appwrite#84 SDK 22.2.0) auto-merged the import but lost the telemetry.record_search_docs(...) call that my original 9f443b6 commit added. Upstream 8a962e4 ('cleanup: stop emitting metrics the MCP dashboard no longer queries') had already removed both, so the import was no longer needed. Removing the orphan fixes ruff F401. Verified locally on this branch: - ruff check src tests ✓ All checks passed! - black --check src tests ✓ 40 files unchanged - pyright ✓ 0 errors, 0 warnings, 0 informations - python -m unittest discover -s tests/unit ✓ 210 tests, OK
louzt
force-pushed
the
feat/mcp-tool-annotations
branch
from
July 27, 2026 18:35
94a8ef1 to
42385cf
Compare
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.
Summary
Adds MCP
ToolAnnotations(per MCP 2025-06-18 spec) to every tool theserver can emit — the 3 public operator tools, the optional docs search
tool, AND every method on every Appwrite SDK service generated
dynamically by
service.py. Driven by a single helper module so thehints stay in lock-step with the action verb in each tool name.
Closes/updates #86.
What's in the diff
New module —
src/mcp_server_appwrite/annotations.pyThree pure helpers that form the single source of truth for safety
hints:
classify_tool_name(tool_name)— extract the action verb and bucketthe tool as
read/write/delete/unknown.annotations_for_classification(bucket)— return a canonicalToolAnnotationsinstance with every hint set explicitly. The inlinerationale comments document why each hint has its value (e.g. why
unknown.openWorldHint=Truedespite not knowing the verb, whydelete.idempotentHint=True).annotations_for_gateway()— dedicated helper for the dispatchertool (
appwrite_call_tool); leavesdestructiveHint=Noneso theMCP 2025-06-18 spec defaults it to
Trueand clients that gatehuman approval on the hint correctly prompt the user for every
gateway call. See Review feedback resolution below for the
rationale.
service.py— 25 SDK services, hundreds of methodsService.list_tools()now classifies each SDK method by its action verband attaches the resulting annotations. This covers every method on
every service the Appwrite SDK ships.
operator.py— 3 public toolsappwrite_get_context,appwrite_search_tools,appwrite_call_toolnow use
annotations_for_classification(orannotations_for_gatewayfor the dispatcher) instead of inline
ToolAnnotations(...)constructions. Old inline blocks were removed in the same commit.
docs_search.py— 1 docs toolappwrite_search_docsuses the helper for itsreadannotation.Why a single helper instead of inline annotations per tool
classify_tool_namewould silently mislead every MCP client thatfilters on these hints (Claude Code, Gemini MCP, Appwrite broker).
having to remember the pattern.
Test coverage expansion (from 13 → 41 net new tests)
classify_tool_nameparametrized over 19 read verbs, 19 write verbs,10 delete verbs, 4 unknown names, case-insensitivity, verbs in the
middle of names.
annotations_for_classificationfor every bucket; explicit-field checkcatches MCP-default leakage; unrecognised inputs fall through to
unknown.annotations_for_gatewayfor the dispatcher tool: contract pinnedvia 5 dedicated tests (
destructiveHint is None, other hintsexplicit, distinct from
unknown, fresh instance per call,title is None).operator._classify_verbfor every known verb × resourcecombination — both helpers stay in lock-step.
appwrite_get_context/appwrite_search_toolsareread;
appwrite_call_toolis the gateway (destructiveHint=None);the docs search tool is read.
Service.list_tools()is invoked on a stub usersservice and the resulting
Toolobjects carry annotations consistentwith the verb bucket.
The "consistent with" check accounts for the MCP spec limitation that
write and unknown collapse to the same wire shape on the four hints.
Total: 210 unit tests pass locally (post upstream
appwrite/mcp#84 — Appwrite
Python SDK 22.2.0 upgrade, which added 83 net new method-level
tests). All four CI jobs (ruff, black, pyright, unittest) green.
Rationale
Per the MCP 2025-06-18 spec, leaving a hint unset means unknown and
forces conservative client behavior (user prompt before execution). With
explicit annotations, clients can:
confirm_write=truemechanism insideappwrite_call_tool(thedestructive gate lives in input validation, not at the MCP hint layer)
The annotation for
appwrite_call_toolisdestructiveHint=None(unset), not
False. An unset hint makes the gateway honest aboutits dispatcher semantics: it cannot promise either "non-destructive"
or "destructive" statically, because the actual capability depends on
the sub-tool chosen at runtime. Per the MCP 2025-06-18 spec, an unset
destructiveHintdefaults toTruefor clients that gate humanapproval on the hint — which matches the runtime
confirm_write=trueboundary enforced inside_call_hidden_tool. SeeReview feedback resolution below for the full decision matrix and
the Greptile P1 finding that drove this design.
Scope Boundary
This PR covers every tool the server can advertise:
appwrite_get_context,appwrite_search_tools,appwrite_call_tool)appwrite_search_docs)service.Service.list_tools())It does NOT cover:
scopes,permissions) — these arenot part of the MCP
ToolAnnotationsschema.the server.
Validation
5 files changed, +744/-1 against
appwrite/mcp#84(the SDK 22.2.0upgrade added ~83 method-level test cases; the F401 fix in the rebase
commit removes one line).
Compatibility
Tested against the MCP 2025-06-18 spec for
ToolAnnotations. Clientsthat ignore the annotations (older MCP implementations) keep working
unchanged — annotations are hints, not gates. Clients that honor them
gain the auto-execute benefit on read-only tools.
Related
upstream issue this PR resolves (with comprehensive scope).
the inline-comments-on-each-hint pattern this PR follows.
~/.claude/plugins/lzt-harness/rules/zero-noise-mcp.md— the LZTrule that requires
ToolAnnotationson every LZT MCP tool.Rebase onto upstream PR #84 (SDK 22.2.0)
This branch was rebased onto
appwrite/mcp#84 (merged
2026-07-27) before the most recent push, so it sits cleanly on top of
the Appwrite Python SDK 22.2.0 upgrade. The rebase auto-merged one
file (
src/mcp_server_appwrite/docs_search.py) where my commit9f443b6had added an import that upstream commit8a962e4("Stop emitting metrics the MCP dashboard no longer queries")had independently removed. The conflict was resolved by keeping my
annotations-related imports and dropping the now-unused
from . import telemetryimport in a separate, focused commit so thelint job (
ruffruleF401) stays green.The four commits in the branch (all SSH-signed by
louzt <davidmirelesll@outlook.com>):256f90576ae82944463b242385cfReview feedback resolution
Greptile Bot reviewed this PR and reported the following issue, which is
fixed in the head commit
42385cf:src/mcp_server_appwrite/operator.py:263appwrite_call_tooladvertiseddestructiveHint=Falsewhile dispatching delete-classified SDK methods when givenconfirm_write=true.Fix — split gateway annotations from the verb-bucket helpers
The annotations helper now exposes a separate, dedicated
annotations_for_gateway()function.appwrite_call_tooluses itinstead of falling through to the "unknown" classification bucket.
Why
destructiveHint=Noneand notFalseFalseTrueNone(unset)The MCP spec defaults
NonetoTrue, so the practical effect onclients that ignore
Noneis identical to explicitly settingTrue.Clients that distinguish the three (some implementations surface
"unknown" to the user explicitly) get the most accurate signal.
Regression coverage
tests/unit/test_annotations.py:AnnotationsForGatewayTests— 5 newtests pinning the gateway contract (
destructiveHint is None,other hints explicit, distinct from
unknown, fresh instance percall,
title is None).tests/unit/test_annotations.py:test_appwrite_call_tool_is_gateway— replaces the prior
_is_unknowntest and pins the live contractwith a regression-guard comment naming this exact Greptile review.
Verification
Summary by CodeRabbit
New Features
Tests