feat(tools): offer only the tools this credential can actually call (#91) - #92
Merged
Conversation
…eviewed
The gate's `bind` parameter exists to fold confirmation-relevant arguments
into the token's identity, and the integration wrappers were not passing it.
The only thing tying a confirm to its preview was `target` — the first of
id/post_id/attachment_id in the input — so on any partner tool taking an id
plus a payload, every other argument was unbound and the delegate closed over
the *confirm* call's input. Preview {post_id: 12, description: "approved"},
confirm {post_id: 12, description: "something else"}, and the second one ran.
The user approved one change and got another, which is the two-step confirm
doing the opposite of its job.
Now `bind` is a sha256 of the whole argument set minus confirm_token, key
sorted so a client reordering its JSON doesn't break a legitimate confirm.
`target` stays the bare id, so the log line is still a readable "#12" rather
than a hash. The target key list is widened past the original three and is
filterable — a key missing from it now costs legibility, never safety.
Also here: denial_reason() had no branch for a capability denial, the one
refusal whose fix is a different WordPress account rather than anything in
the Saddle dashboard. It fell through to the generic paragraph, which lists
three fixes and not that one. permission() now records the capability each
ability asks for as it registers — one line, and it covers free, Pro and the
wrappers alike, because all three build their callback there.
Closes #89
Refs #63
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GkZr73cqaSesHRDG89Yy8S
A fresh install sits at the read tier — that is the second non-negotiable — and then hands the agent all 66 free tools, 30 of which are refused on every single call. With Pro and the first-party integrations on, the same list runs to about 160. Every one of those unusable entries costs a full JSON schema in the agent's context window, and buys a guaranteed refusal. tools/list is now filtered to what the current credential could execute: the effective tier (already clamped by any OAuth scope), the owner's per-tool switches, and the capability the connected account holds. Both transports — and on a WordPress.org install the built-in one is the only transport there is, since the adapter stopped shipping in that zip. The filter runs at DISPATCH, never at registration. adapter_tool_names() is built during mcp_adapter_init, inside rest_api_init and before authentication; filtering there would read the wrong tier and force early user resolution on every REST request site-wide. So the adapter server is still registered with the complete set and mcp_adapter_tools_list narrows the response. Pause is deliberately excluded. It denies everything at call time anyway, and emptying the list would make every connected client reconnect on resume. The instructions lead with the pause warning instead. Filtering costs an agent the ability to say "that tool exists, you just have not enabled it", so that is bought back explicitly: the context now says how many tools are withheld and by which gate, and get-site-info reports the same counts. Two sentences instead of thirty schemas, and it is the half a user can act on. Along the way: is_callable_now() resolves the ability before reading the capability registry, because wp_get_abilities() is what lazily fires wp_abilities_api_init and fills it — reading it first silently skips the check on the first call of a request. Pinned by a test. The POT was regenerated and had drifted well past this change: it was missing context-bundle, list-templates, get-template and the block-theme bootstrap-design-system strings entirely — an entire feature's worth of msgids, which is the exact failure CLAUDE.md warns about. Refs #89 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GkZr73cqaSesHRDG89Yy8S
This was referenced Aug 15, 2026
…ilter # Conflicts: # tests/capabilities-test.php
7 tasks
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.
Closes #91
Refs #89 (stacked on #90 — review that one first; this branch contains its commit)
What
tools/listnow offers only the tools the current credential could actually execute. And because filtering costs discoverability, the instructions andget-site-infonow say how many tools are being withheld, and by which gate.Why
A fresh install defaults to
read. It then advertised all 66 free tools, 30 of which are refused on every call until the owner raises the level. With Pro and the integrations on, that list is ~160 tools — each one a full JSON schema in the agent's context window, bought in exchange for a guaranteed refusal.The deeper cost is that it made the default-safe design feel broken. The agent could not tell "this site cannot do that" from "this connection is not allowed to", so it reported the wrong thing.
denial_reason()already answers that well — but one wasted round trip at a time, after the context is already spent.How
Saddle_Capabilities::is_callable_now()judges one ability the same way, and in the same order, the permission callback would: authenticated → capability → per-tool switch → tier. Every ability in free, Pro and the integration wrappers declaresmeta['saddle']['tier']throughsaddle_ability_meta(), so one resolver covers the whole ~160.Two things worth knowing about where the filter runs:
adapter_tool_names()is built duringmcp_adapter_init— insiderest_api_init, before authentication. Filtering there would read the wrong tier and force early user resolution on every REST request site-wide. So the server is still registered with the complete set, andmcp_adapter_tools_list(insideToolsHandler::list_tools()) narrows the response. The built-in transport filters inlist_tools()for the same reason — and on a WordPress.org install that transport is the only one there is, sincebff1a99stopped shipping the adapter in that zip.The capability check needed a registry, and building one without touching 114 registration sites:
permission()records the capability each ability asks for as it registers, because free, Pro and the wrappers all build their callback there. (That line arrived in #90 fordenial_reason(); this PR is its second consumer.)is_callable_now()resolves the ability before reading that registry —wp_get_abilities()is what lazily fireswp_abilities_api_initand fills it, so reading it first silently skips every capability check on the first call of a request. There is a test named for that.Consequence to document
Most MCP clients read
tools/listat connect time, so raising the access level needs a reconnect before the new tools appear. Called out in the readme changelog; the Permissions screen should say it too (follow-up).Also in here
languages/saddle.potregenerated. It had drifted well past this change — missingcontext-bundle,list-templates,get-templateand the block-themebootstrap-design-systemstrings entirely. That is an entire feature's worth of msgids, and the exact failureCLAUDE.mdwarns about. 90 msgids added, 11 removed (all of them strings that no longer exist in source).Testing
composer test— 531 tests, 1830 assertions, green (1 pre-existing skip)composer lint— 0 errors; the 3 warnings are pre-existing, in files this PR doesn't touchSaddle_MCP_Transport_Test(read tier hides write/admin, raising widens, a switched-off tool disappears, pause leaves the list intact, the instructions state the count — and state nothing when nothing is withheld) andSaddle_MCP_Adapter_Transport_Test(the same filter over the real adapter, including a tier raised mid-session, which is what proves the dispatch-time timing)Saddle_Capabilities_Test: tier, per-tool switch, the capability an account lacks, anonymous, pause, and thathidden_tool_counts()accounts for every tool exactly onceWP_DEBUGonNo CI in this repo, so "green" means the commands above were run locally.
🤖 Generated with Claude Code
https://claude.ai/code/session_01GkZr73cqaSesHRDG89Yy8S