Repository navigation
[PFR-4635] Tell MCP clients which tools only read - #20
Conversation
Each OpenAPI tool now carries ToolAnnotations and a title from the operation summary. brain-backend's x-cortex-mcp-access extension sets the hints; without it a GET is read-only and any other method is a destructive write. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
getMyWorkspace and queryPointInTimeMetrics only read, but as POSTs without x-cortex-mcp-access they were published as destructive. The bundled swagger.json now declares them read. An unknown x-cortex-mcp-access value still counts as destructive, and now logs a warning so a typo in the spec is visible. Tests cover that, GET overridden to destructive, and the default for every write method. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cess A non-GET tool without x-cortex-mcp-access is published as destructive, so a read POST that loses the key in a spec refresh would quietly start asking for approval. The new test fails for any MCP-enabled non-GET operation in swagger.json that doesn't declare it. The unknown-value test now also checks the warning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A typo such as "readonly" passed the spec test but made the tool destructive at runtime. The test now checks the value against the same ACCESS_VALUES the customizer uses, on GETs too, and says what to set. The warning test pins its logger's level so it doesn't depend on the root logger. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A PUT is idempotent by HTTP semantics, but not every Cortex PUT has been checked, and a client may retry a tool marked idempotent. Unset, the hint keeps the MCP default of false. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| # Without a title, FastMCP derives one from the operationId: "Addcustomdataforentity". | ||
| if route.summary: | ||
| component.title = route.summary | ||
| component.annotations = _tool_annotations(route) |
|
|
||
| ComponentType = OpenAPITool | OpenAPIResource | OpenAPIResourceTemplate | ||
|
|
||
| ACCESS_VALUES = ("read", "additive", "destructive") |
There was a problem hiding this comment.
should we just have CRUD as access types?
There was a problem hiding this comment.
or just read/write/delete if we can't identify that?
There was a problem hiding this comment.
Oh I didn't realize there was only readOnlyHint and destructiveHint, I think in this case GET as readOnlyHint and anything else that modifies data as destructiveHint is fine.
I was more so thinking we could label the operations more fine grained but seems like that's not part of the MCP spec.
There was a problem hiding this comment.
@akwirick spoke with Keith and we had a misunderstanding as to what's happening here. The protocol itself fixes the two annotations - readOnlyHint and destructiveHint.
The code as it is live now does this (different in that POST - Create defaults destructiveHint to false):

Then I would introduce overrides in brain-backend to classify as follows:

There was a problem hiding this comment.
Another way of saying that is we have readOnly, nonDestructiveWrites and destructiveWrites right?
I'm not gonna nit if it's the spec, just trying to understand the usage
| if access is None: | ||
| access = "read" if method == "GET" else "destructive" | ||
| elif access not in ACCESS_VALUES: | ||
| logger.warning( | ||
| f"Unknown x-cortex-mcp-access {access!r} on {method} {route.path}, " | ||
| "treating it as destructive" | ||
| ) | ||
| access = "destructive" | ||
| if access == "read": |
There was a problem hiding this comment.
nit: can just make these booleans probably rather than strings?
might help if we introduce idempotentHint or openWorldHint in the future
addddc6 to
6a1c848
Compare
The read-only and destructive hints are now worked out once and passed to a single ToolAnnotations. The hints sent to clients are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
d800808 to
0d7c482
Compare


TL;DR: MCP clients group a server's tools by their
readOnlyHintannotation, and none of the tools this server builds sets one. Claude, for example, then puts every Cortex tool under "Other tools", so a user can't auto-allow reads while still approving writes.customize_componentsnow gives every OpenAPI toolToolAnnotationsand atitletaken from the operation summary. An operation can declare its access with thex-cortex-mcp-accessOpenAPI extension. Without it, a GET is read-only and any other method is a destructive write.Detail, written by Claude
How the hints resolve
x-cortex-mcp-accessreadOnlyHintdestructiveHintreadadditivedestructiveor unknownAn unknown value logs a warning and counts as destructive, which is also the MCP spec's default, so a typo in the spec can't mark a write as safe.
idempotentHintstays unset, so it keeps the MCP default of false. A PUT is idempotent by HTTP semantics, but nobody has checked that every Cortex PUT is, and a client may retry a tool marked idempotent.openWorldHintstays unset because clients don't use it for grouping.FastMCP's fallback title is
name.title(), which showsaddCustomDataForEntityAdvancedas "Addcustomdataforentityadvanced". The summary gives "Add custom data for entity" instead.The bundled spec
swagger.jsonexposes 28 tools: 26 GETs and 2 POSTs that only read,getMyWorkspaceandqueryPointInTimeMetrics. Those two now declare"x-cortex-mcp-access": "read". Without it they would be published as destructive, so all 28 tools are now read-only. A new test,tests/test_spec.py, fails in two cases: an MCP-enabled operation other than a GET leaves the extension out, or any enabled operation sets a value other thanread,additiveordestructive. That keeps a spec refresh from dropping or mistyping the two keys unnoticed, and makes each new write tool state what it does to data.Verified
pytestpasses locally (38 tests, 11 new), including with--log-level=ERROR, andruff checkis clean.getMyWorkspace's key is removed, when its value is changed toreadonly, and when an enabled GET setsRead.swagger.jsonand readtools/listthrough a client. All 28 tools arrive withreadOnlyHint: trueand their summary astitle. A spec with writes givesdestructiveHintas in the table above.Linear: PFR-4635
🤖 Generated with Claude Code