Fetching latest headlines…

Dev

Your Memory Server Did Not Say destructiveHint. By the Spec, That Means True.

Dev.toUnited States · NORTH AMERICA

A tool that ships no annotations has not stayed silent about whether it is destructive. Under the MCP schema it has answered true. Eugeniya Ivanova published a walkthrough on 2026-09-07 of getting an...

0 views0 likes0 comments

A tool that ships no annotations has not stayed silent about whether it is destructive. Under the MCP schema it has answered true.

The scanner asked for something the spec calls optional

Eugeniya Ivanova published a walkthrough on 2026-09-07 of getting an MCP server through the ChatGPT app directory review, and one section of it is about annotations. The scanner went through every tool and required explicit readOnlyHint, openWorldHint and destructiveHint values. Four of her read-only tools carried no destructiveHint, which the specification permits, and the scanner wanted it anyway. She added destructiveHint: false to those tools and wrote justifications for forty-odd annotation values. By her account, the tools themselves did not change.

Himanshu Kumar had measured the other end of the same field a week earlier, on 2026-08-30, auditing a deployed server: six tools, zero tools declaring any annotation. His sentence for it is the one worth keeping: "The server did not lie. It said nothing."

The arithmetic of the defaults is theirs, not mine. What follows is a count of how often those four names appear in the published source of seven source trees, and what the counting turned out to be unable to tell me.

Four defaults, and a note telling you not to act on them

ToolAnnotations is declared at line 1912 of schema/2026-07-28/schema.ts in the specification repository (commit 271ecc9, fetched 2026-09-07, 98,426 bytes). Four boolean fields, each with a documented default, and each default with a consequence when the field is missing.

Field Default in the schema What a client honoring defaults must assume when the field is absent
readOnlyHint false (line 1921) the tool modifies its environment
destructiveHint true (line 1931) the tool may perform destructive updates
idempotentHint false (line 1941) repeating the call has further effect
openWorldHint true (line 1951) the tool reaches an open world of external entities

Directly above that interface, at lines 1903 to 1908, sits a NOTE that has to travel with any argument built on those defaults: "all properties in ToolAnnotations are hints. They are not guaranteed to provide a faithful description of tool behavior", and "Clients should never make tool use decisions based on ToolAnnotations received from untrusted servers."

So the schema supplies defaults for a field it also instructs clients not to decide on. Both halves are in force, and the gap between them is the subject.

One silence, two lawful readings

Take a memory server whose save_memory tool ships no annotations. A client implementing the documented defaults must treat that tool as not read-only, destructive, non-idempotent and open-world. A client reading the absent object as "the server did not say, so no annotation gate applies" routes the same call straight through. Neither client is misbehaving. The schema backs the first, the NOTE backs the second, and the server said the identical nothing to both.

A second rule at line 1929 makes the reading depend on a different field: destructiveHint "is meaningful only when readOnlyHint == false". On a tool declaring readOnlyHint: true, omitting destructiveHint is exactly right, and reading true into it is an error. On a tool declaring nothing at all, readOnlyHint falls to its default of false, which makes destructiveHint meaningful, which makes its default of true apply. The same omission is clean in one place and loud in the other, and what separates them is a second field the tool also did not fill in. An empty annotation is not missing information. It is a value, and different clients will supply different ones.

Counting the four names in seven source trees

Method before numbers. For each project I downloaded the branch archive at a named commit, walked every regular file in it, and counted case-insensitive byte occurrences of the four field names. Not a search restricted to paths containing mcp: that filter would have missed the assertions in mem0's tests/test_memory_core.py. Case-insensitive because the Go SDK capitalizes the same fields, and a case-sensitive grep returns a false zero on any Go server.

Source tree Commit Files walked readOnlyHint destructiveHint idempotentHint openWorldHint
modelcontextprotocol/servers d73f99e 156 61 51 51 60
supermemoryai/supermemory 4d8a4eb 1,192 6 6 6 6
mem0ai/mem0 dae67f7 1,777 2 0 2 1
getzep/zep 54f63ee 936 0 0 0 0
getzep/graphiti b943c9e 360 0 0 0 0
topoteretes/cognee e93a4f0 3,594 0 0 0 0
MemoriLabs/Memori 10d6501 673 0 0 0 0

All rows probed 2026-09-07. GibsonAI/memori now redirects to MemoriLabs/Memori. The package I publish is not in the count, because measuring my own tree beside other people's, on a method I picked myself, is not a comparison a reader should have to trust.

Four rows read zero, and a zero from a weak probe proves nothing, so the walker needs a control. In the same pass it counted the word annotations in those four trees: 115 in zep, 30 in graphiti, 210 in cognee, 36 in Memori. The files were read. Three of the four also ship an MCP server in the same tree: zep at mcp/zep-mcp-server with 13 tools registered through mcp.AddTool, graphiti at mcp_server/src/graphiti_mcp_server.py with 13 @mcp.tool() decorators, and cognee at cognee-mcp/src. Memori is the exception: the integrations/openclaw/src/tools path with its 6 registerTool calls is an OpenClaw plugin, and the MCP server sits in a separate public repository, MemoriLabs/memori-mcp (commit e02957d, 12 files), documented as a hosted endpoint. That repository reads zero on all four fields as well, walked the same day. The rows describe servers that exist, and for Memori the server is one repository over.

The most useful result is a limit on the method itself. Supermemory reads 6 in every column, which sounds like six annotated tools. It is not. apps/mcp/src/server/tools/annotations.ts defines four named presets, all four fields set in each, and fifteen tool files import one of them. Counting literals undercounts the annotated tools by a factor of two and a half in the one project that factored them into a constant. This census answers whether a tree declares these fields at all. It does not count tools.

A zero in the destructiveHint column is not automatically a gap either. The mem0 plugin exposes one tool, a memory search, declaring readOnlyHint: True, idempotentHint: True and no destructiveHint. By line 1929 that omission is precisely the case the spec calls meaningless, so leaving it out is correct.

Graphiti shows most plainly what the defaults do when nothing is declared. Thirteen tools, every decorator a bare @mcp.tool(), and the names run from clear_graph, delete_entity_edge and delete_episode through to search_nodes, get_episodes and get_status. A client honoring the defaults treats all thirteen alike. On the first three that lands right. On the last three it does not.

Read your own tools/list

Source is not the wire. Three JSON-RPC lines on stdin get the answer from a running server, and this prints, per tool, which of the four it omits.

printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}}' \
  '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
  '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
| npx -y @modelcontextprotocol/server-memory 2>/dev/null \
| python3 -c 'import sys, json
FIELDS = ("readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint")
for line in sys.stdin:
    for t in (json.loads(line).get("result") or {}).get("tools") or []:
        a = t.get("annotations") or {}
        print(t["name"], "omits:", ", ".join(f for f in FIELDS if f not in a) or "nothing")'

Swap the npx line for your own server command. Against the reference server it prints omits: nothing nine times, matching its source: @modelcontextprotocol/server-memory fills all four fields on all nine tools (src/memory/index.ts, commit d73f99e). That package took 405,883 npm installs between 2026-08-08 and 2026-09-06, with the window set in the api.npmjs.org request. The example most servers learn their shape from fills everything.

openWorldHint, where the spec names memory as its example

Of the four fields, openWorldHint is the one where the specification picks a side by illustration. Lines 1948 and 1949: "For example, the world of a web search tool is open, whereas that of a memory tool is not." The default is true, and the worked example of false is a memory tool.

The reference server agrees with its own spec: false on all nine tools, backed by a local memory.jsonl file. Supermemory agrees too, false in all four presets. The mem0 plugin tool declares true.

The split is worth naming rather than resolving. The example in the spec fits memory that lives where the tool runs, a file or a local process, which is exactly what the reference server is, and the sentence does not say what to read when the tool hands the work somewhere further out. Servers taking the same line differently is what an optional hint permits, and the field cannot tell a client which reading a given server took. That is a limit of the hint, not a fault in anybody's server, and it is the limit the NOTE was warning about.

What this does not prove

This is a census of published source on one date, not a census of running servers. A live deployment can answer differently from the tree it was built from, and no hosted deployment was checked here, so nothing above transfers from a repository to a service.

A framework can attach annotations at registration or serialization time without the field name ever appearing in a project's own source. Every zero above means the four literals are absent from that tree at that commit. It does not mean the server answers tools/list without annotations.

The counts move under you. Two of the seven trees had head commits dated 2026-09-06, one day before this probe, so a run tomorrow counts a different tree. That is why every row names a commit rather than a branch, and why the table was rebuilt from scratch on the day of writing rather than carried over from an earlier pass.

None of this ranks memory engines, and none of it is a benchmark. Filling four hint fields is cheap and says nothing about retrieval quality. A project that leaves them empty may be right to, since the specification calls them optional and tells clients not to trust them when present.

Both readings of an absent field pass review. If your client documents which one it takes, I would like to read it.

Disclosure: I work on Mnemoverse, a memory engine for AI agents connected over MCP, so weigh the argument accordingly.

Comments (0)

Sign in to join the discussion

Be the first to comment!