Skip to content

[PFR-4635] Tell MCP clients which tools only read - #20

Merged
brucesw merged 6 commits into
masterfrom
bruce/pfr-4635-tool-annotations
Oct 27, 2026
Merged

brucesw merged 6 commits into
masterfrom
bruce/pfr-4635-tool-annotations

Conversation

@brucesw

@brucesw brucesw commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

TL;DR: MCP clients group a server's tools by their readOnlyHint annotation, 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_components now gives every OpenAPI tool ToolAnnotations and a title taken from the operation summary. An operation can declare its access with the x-cortex-mcp-access OpenAPI 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-access Method readOnlyHint destructiveHint
read any true —
additive any false false
destructive or unknown any false true
absent GET true —
absent not GET false true

An 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.

idempotentHint stays 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. openWorldHint stays unset because clients don't use it for grouping.

FastMCP's fallback title is name.title(), which shows addCustomDataForEntityAdvanced as "Addcustomdataforentityadvanced". The summary gives "Add custom data for entity" instead.

The bundled spec

swagger.json exposes 28 tools: 26 GETs and 2 POSTs that only read, getMyWorkspace and queryPointInTimeMetrics. 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 than read, additive or destructive. 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

  • pytest passes locally (38 tests, 11 new), including with --log-level=ERROR, and ruff check is clean.
  • The spec test fails, naming the operation, when getMyWorkspace's key is removed, when its value is changed to readonly, and when an enabled GET sets Read.
  • I built the server from the bundled swagger.json and read tools/list through a client. All 28 tools arrive with readOnlyHint: true and their summary as title. A spec with writes gives destructiveHint as in the table above.

Linear: PFR-4635

🤖 Generated with Claude Code

brucesw and others added 5 commits October 23, 2026 15:29
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>
Comment on lines +57 to +60
# Without a title, FastMCP derives one from the operationId: "Addcustomdataforentity".
if route.summary:
component.title = route.summary
component.annotations = _tool_annotations(route)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Image

@brucesw
brucesw marked this pull request as ready for review October 27, 2026 15:48

ComponentType = OpenAPITool | OpenAPIResource | OpenAPIResourceTemplate

ACCESS_VALUES = ("read", "additive", "destructive")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we just have CRUD as access types?

@keithfz keithfz Oct 27, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or just read/write/delete if we can't identify that?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah agree with Keith here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Original plan was to have a followup PR annotating each tool individually in brain-backend. But, if the proposal is to do a simple mapping as follows, yeah I could absolutely do that. Seems good

Screenshot 2026-10-27 at 12 02 29 PM

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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):
Screenshot 2026-10-27 at 12 20 32 PM

Then I would introduce overrides in brain-backend to classify as follows:
Screenshot 2026-10-27 at 12 27 37 PM

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, that's right

Comment thread src/components/customizers.py Outdated
Comment on lines +120 to +128
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":

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can just make these booleans probably rather than strings?

might help if we introduce idempotentHint or openWorldHint in the future

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

keithfz
keithfz previously approved these changes Oct 27, 2026
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>
@brucesw
brucesw force-pushed the bruce/pfr-4635-tool-annotations branch from d800808 to 0d7c482 Compare October 27, 2026 16:42
@brucesw
brucesw requested a review from keithfz October 27, 2026 16:43
@brucesw
brucesw merged commit 1163025 into master Oct 27, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants