Skip to content

feat(tools): offer only the tools this credential can actually call (#91) - #92

Merged
ifahimreza merged 3 commits into
mainfrom
feat/tool-list-tier-filter
Aug 15, 2026
Merged

feat(tools): offer only the tools this credential can actually call (#91)#92
ifahimreza merged 3 commits into
mainfrom
feat/tool-list-tier-filter

Conversation

@ifahimreza

Copy link
Copy Markdown
Contributor

Closes #91
Refs #89 (stacked on #90 — review that one first; this branch contains its commit)

What

tools/list now offers only the tools the current credential could actually execute. And because filtering costs discoverability, the instructions and get-site-info now 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 declares meta['saddle']['tier'] through saddle_ability_meta(), so one resolver covers the whole ~160.

Two things worth knowing about where the filter runs:

  • At dispatch, never at registration. adapter_tool_names() is built during mcp_adapter_init — inside rest_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, and mcp_adapter_tools_list (inside ToolsHandler::list_tools()) narrows the response. The built-in transport filters in list_tools() for the same reason — and on a WordPress.org install that transport is the only one there is, since bff1a99 stopped shipping the adapter in that zip.
  • Pause is excluded on purpose. It denies everything at call time anyway, and emptying the list would force every connected client to reconnect on resume. The instructions lead with the pause warning instead.

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 for denial_reason(); this PR is its second consumer.)

is_callable_now() resolves the ability before reading that registry — wp_get_abilities() is what lazily fires wp_abilities_api_init and 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/list at 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.pot regenerated. It had drifted well past this change — missing context-bundle, list-templates, get-template and the block-theme bootstrap-design-system strings entirely. That is an entire feature's worth of msgids, and the exact failure CLAUDE.md warns 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 touch
  • Both transports covered: Saddle_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) and Saddle_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 that hidden_tool_counts() accounts for every tool exactly once
  • No notices or warnings with WP_DEBUG on

No CI in this repo, so "green" means the commands above were run locally.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GkZr73cqaSesHRDG89Yy8S

ifahimreza and others added 2 commits August 15, 2026 19:25
…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
…ilter

# Conflicts:
#	tests/capabilities-test.php
@ifahimreza
ifahimreza merged commit 06277d3 into main Aug 15, 2026
1 of 7 checks passed
@ifahimreza
ifahimreza deleted the feat/tool-list-tier-filter branch August 15, 2026 14:41
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.

tools/list advertises tools the credential can never call — 30 of them on a default install

1 participant