Make the built-in discover/get-info/execute tools' MCP exposure check filterable (#243) - #244
Make the built-in discover/get-info/execute tools' MCP exposure check filterable (#243)#244raftaar1191 wants to merge 6 commits into
Conversation
Wraps the `meta.mcp.public` reads inside `McpAbilityHelperTrait` in a new `mcp_adapter_is_ability_public` filter so integrators can decide per ability — and per MCP server — whether the built-in `discover-abilities`, `get-ability-info`, and `execute-ability` tools should treat an ability as publicly exposed. The filter receives the current `McpServer` (or null when the ability is invoked outside an MCP request) so downstream code can implement per-server allowlists without touching the ability's own meta. Threading the server is done via a lightweight per-request holder on the `McpAdapter` singleton (`set_current_server` / `get_current_server`) that `ToolsHandler::call_tool()` sets on entry and clears in a `finally` block. Because ability callbacks only receive `$input` (that's core `WP_Ability::execute()` behavior), a holder is the least invasive way to expose the server without changing WP core. Default behavior is unchanged — the filter defaults to the ability's existing computed `mcp.public` value. Refs WordPress#243
|
The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the Unlinked AccountsThe following contributors have not linked their GitHub and WordPress.org accounts: @shigeki7777. Contributors, please read how to link your accounts to ensure your work is properly credited in WordPress releases. If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message. To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## trunk #244 +/- ##
============================================
+ Coverage 87.94% 87.99% +0.04%
- Complexity 1257 1266 +9
============================================
Files 53 54 +1
Lines 4066 4091 +25
============================================
+ Hits 3576 3600 +24
- Misses 490 491 +1
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
This PR makes the built-in MCP abilities’ “is this ability publicly exposed?” decision configurable by introducing a new mcp_adapter_is_ability_public filter and threading the active McpServer context down to that filter during tool execution. This enables per-server exposure policies while keeping default behavior aligned with the existing meta.mcp.public flag.
Changes:
- Add a per-request
current_serverholder onMcpAdapterwithset_current_server()/get_current_server(). - Set/clear the
current_serverinToolsHandler::call_tool()usingtry/finallyto avoid leaking state across calls. - Route MCP exposure checks through a new
mcp_adapter_is_ability_publicfilter and add unit tests covering filter behavior and the server holder.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| includes/Core/McpAdapter.php | Adds per-request current-server storage/accessors on the adapter singleton. |
| includes/Handlers/Tools/ToolsHandler.php | Sets/clears current server context around tool dispatch via try/finally. |
| includes/Abilities/McpAbilityHelperTrait.php | Makes MCP exposure checks filterable and centralizes the “public” decision. |
| tests/phpunit/Unit/Core/McpAdapterCurrentServerTest.php | Adds unit tests for set_current_server() / get_current_server(). |
| tests/phpunit/Unit/Abilities/DiscoverAbilitiesAbilityTest.php | Tests that the new filter can expose an otherwise non-public ability and captures $server. |
| tests/phpunit/Unit/Abilities/GetAbilityInfoAbilityTest.php | Tests that the new filter can bypass the exposure gate for get-info. |
| tests/phpunit/Unit/Abilities/ExecuteAbilityAbilityTest.php | Tests that the new filter can bypass the exposure gate for execute. |
Comments suppressed due to low confidence (1)
includes/Abilities/McpAbilityHelperTrait.php:43
- The
ability_not_public_mcperror message is now potentially misleading: exposure is determined viais_ability_mcp_public()(and can be overridden by themcp_adapter_is_ability_publicfilter), but the message still claims the cause ismcp.public!=true. If a site hides an ability via the filter even whenmcp.publicis true, this message will be incorrect. Consider making the message filter-agnostic (or include the filter in wording).
if ( ! self::is_ability_mcp_public( $ability ) ) {
return new WP_Error(
'ability_not_public_mcp',
sprintf( 'Ability "%s" is not exposed via MCP (mcp.public!=true)', $ability_name )
);
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
Thanks for turning this into a concrete PR so quickly. The current-server holder combined with A few small things seem worth pinning before merge:
Passing the server as the third argument is already a useful and backward-compatible foundation. Broader tenant or principal context can be added later without blocking this improvement. |
…bject Addresses review feedback on WordPress#244: 1. Rename the new filter from `mcp_adapter_is_ability_public` to `mcp_adapter_is_ability_exposed`. Once the decision can vary by principal, tenant, or server, "public" is inaccurate — an ability can be exposed to a specific context without being globally public. 2. Replace the 3rd positional `$server` filter argument with an explicit exposure context value object (`WP\MCP\Abilities\McpAbilityExposureContext`). Fields: - server (McpServer|null) - principal_id (int) - roles (array<int, string>) - site_id (int) - exposure_path (string enum via PATH_* constants) Context construction lives in a single `build_exposure_context()` inside the helper trait, so every exposure decision — no matter which built-in tool issued it — resolves "current server" and "current principal" identically. This closes the review's concern about drift between the three tools. 3. Each built-in tool passes its own exposure path: - discover-abilities → PATH_DISCOVER - get-ability-info → PATH_GET_INFO - execute-ability → PATH_EXECUTE 4. Rename the internal helper `is_ability_mcp_public()` → `is_ability_mcp_exposed( WP_Ability, string $exposure_path )` and give `check_ability_mcp_exposure()` the matching `$exposure_path` parameter, delegating to the same helper. 5. Preserve the `ability_not_public_mcp` WP_Error code and message for backward compatibility with clients that catch it. 6. Docblock now spells out two things the review called out: - exposure ≠ authorization (execute still runs the ability's own permission_callback); - cache-key hygiene (any downstream cache must key on the full context, not a subset). Tests: - McpAbilityExposureContextTest: value-object shape + path constants stability. - McpAbilityExposureInvariantsTest: SECURITY INVARIANT — all three tools use the same helper and receive an identical context shape (only `exposure_path` differs). - ExecuteAbilityAbilityTest::test_exposure_filter_does_not_replace_ ability_permission_check: SECURITY INVARIANT — even when the exposure filter widens exposure, the ability's own permission_callback still gates execution. - Existing three filter-override tests renamed and updated to assert the context object and its exposure path. Refs WordPress#243
…ity gate Addresses three review items on WordPress#244: 1. Integration test through ToolsHandler::call_tool() (ExposureFilterIntegrationTest) confirms the exposure filter actually receives the real McpServer instance during a live tool dispatch — not just through direct helper/holder tests. Also pins that the current-server holder is cleared after dispatch so subsequent unrelated calls do not observe stale server state. 2. `ability_not_public_mcp` WP_Error message no longer says "(mcp.public!=true)". Since the exposure filter can hide an ability whose `mcp.public` is true, that specific field reference in the message would mislead in filter-driven cases. The error code is unchanged for backward compatibility with any client catching it; only the human-readable string is trimmed. 3. SECURITY INVARIANT test in ExecuteAbilityAbilityTest asserts that an exposure filter returning true does not upgrade an unauthenticated principal into an authorized one — the tool-level authentication gate still fires first with `authentication_required`, before the exposure gate is ever consulted. Complements the existing test where the ability's own permission_callback rejects. Refs WordPress#243
|
Thanks for the review — all three items addressed in 1. Integration test through
2. Filter-agnostic error message — Dropping the 3. Auth-gate test — new
Agreed on the broader tenant/principal point — that's why the refactor to
|
`mcp-adapter/discover-abilities` declares no `input_schema`. When
`ToolsHandler::call_tool()` routes to `WP_Ability::execute()` and hits
`validate_input()`, a non-null input against an empty schema returns
`WP_Error 'ability_missing_input_schema'` — so `execute_callback` never
runs and the exposure filter never fires.
`AbilityArgumentNormalizer::normalize()` maps empty array → null for
schemaless abilities (mirroring how a real MCP client's `{}` decodes
to `[]` server-side). Passing `array()` in the test is what an actual
tool call looks like on the wire.
Applied to both integration tests for consistency, though only
`test_call_tool_threads_actual_server_into_exposure_filter` was
failing (`test_call_tool_clears_current_server_after_dispatch` still
passed because the finally clause fires whether the callback ran or
not).
Refs WordPress#243
Switch the target from a freshly-registered `test/integration-target`
to the pre-existing `test/always-allowed` fixture (which is registered
during `wp_abilities_api_init` and guaranteed to be in
`wp_get_abilities()` at test time), removing any ambiguity around the
in-test spoofed `register_ability_in_hook()` path.
Track every ability name the filter saw so that if it still fails, CI
output tells us whether:
- the filter never fired (discover callback never ran — inspect
call_tool return type in the failure message), or
- the filter fired for other abilities but not the target (target
ability not in the registry), or
- the filter fired for the target but `$context->server` was still
null (holder wasn't set).
Refs WordPress#243
`McpNameSanitizer::sanitize_name()` replaces forward slashes with hyphens when deriving the MCP tool name from an ability name (MCP spec disallows `/` in tool names). So the built-in ability `mcp-adapter/discover-abilities` is registered on the server under the tool name `mcp-adapter-discover-abilities`. The integration test was calling `call_tool` with the ability-style name, hitting `tool_not_found` → JSONRPCErrorResponse → discover callback never ran → filter never fired → `$captured_server` stayed at its `'unset'` sentinel. Diagnostic assertions surfaced this cleanly on the previous CI run. Real MCP clients see the sanitized name in `tools/list` responses, so using the sanitized form in the test now matches real client behavior. Refs WordPress#243
|
Nice turnaround — going by your writeup, all three items land the way we hoped:
One honesty note on where we stand: this is a read of your description, not an independent reproduction — we haven't pulled Squash vs. not is the maintainers' call; from our side it doesn't affect anything we flagged, since the error-code BC holds either way. |
Closes #243.
Summary
Adds a filter —
mcp_adapter_is_ability_exposed— that lets integrators decide, per ability and per request context, whether the three built-in MCP tools (discover-abilities,get-ability-info,execute-ability) treat an ability as exposed. Default behavior is unchanged: the filter defaults to the ability's existingmeta.mcp.publicflag, so unpatched sites are unaffected.Motivation
Today the three built-in tools decide what a client is allowed to see or invoke by reading
meta.mcp.publicdirectly on each ability. That is a single global boolean baked into the ability, so exposure cannot vary by request context — most importantly, an ability cannot be visible on one MCP server and hidden on another.See #243 for the full motivation and use cases.
Filter shape
McpAbilityExposureContextis an immutable value object with:serverMcpServer|nullprincipal_idintrolesarray<int, string>site_idintget_current_blog_id()exposure_pathstring(PATH_* constant)'discover','get_info', or'execute'Downstreams can differentiate exposure decisions by path — e.g. list an ability but block execution:
Design notes (why a context object, not positional args)
Ability callbacks only receive
$input— that's coreWP_Ability::execute()behavior — so the server can't be threaded through the ability call itself. Two things follow:McpAdaptersingleton (set_current_server()/get_current_server()) is set byToolsHandler::call_tool()on entry and cleared infinally. The trait'sbuild_exposure_context()consumes that holder and the ambient user/site once, in one place. All three tools therefore see an identical context shape — no drift.transport,feature_flags) can be added without changing the filter signature.Security contract (pinned in tests)
Two invariants are asserted by tests so future refactors can't silently break them:
McpAbilityExposureInvariantsTest::test_exposure_filter_receives_identical_context_shape_across_all_three_tools()registers one filter, invokes each of the three tools against the same ability, and asserts every non-path context field is identical.ExecuteAbilityAbilityTest::test_exposure_filter_does_not_replace_ability_permission_check()widens exposure with the filter but registers an ability whosepermission_callbackreturnsWP_Error— and asserts the execute path still rejects with the ability's error, not the exposure gate's.The filter docblock also spells this out and calls out cache-key hygiene: if a downstream caches the exposure decision, the key must include the ability name,
exposure_path, and every context field the callback reads. Missing dimensions cross-contaminate contexts.Backward compatibility
ability_not_public_mcpWP_Errorcode and message are preserved verbatim for clients catching them.meta.mcp.publicability metadata key is unchanged (it's the static default the filter starts from).mcp_adapter_is_ability_exposedis new in this PR; no prior name shipped.Files
Code (7 files)
includes/Abilities/McpAbilityExposureContext.php— new value object.includes/Abilities/McpAbilityHelperTrait.php— renamed helper, filter,build_exposure_context().includes/Abilities/DiscoverAbilitiesAbility.php— passesPATH_DISCOVER.includes/Abilities/GetAbilityInfoAbility.php— passesPATH_GET_INFO.includes/Abilities/ExecuteAbilityAbility.php— passesPATH_EXECUTE.includes/Core/McpAdapter.php—set_current_server()/get_current_server().includes/Handlers/Tools/ToolsHandler.php— set/clear the holder intry/finally.Tests (5 files)
tests/phpunit/Unit/Core/McpAdapterCurrentServerTest.php— accessor.tests/phpunit/Unit/Abilities/McpAbilityExposureContextTest.php— value object + path constants stability.tests/phpunit/Unit/Abilities/McpAbilityExposureInvariantsTest.php— SECURITY INVARIANT: same context shape across all three tools.tests/phpunit/Unit/Abilities/ExecuteAbilityAbilityTest.php— SECURITY INVARIANT: exposure ≠ authorization + renamed filter override test.DiscoverAbilitiesAbilityTest.php,GetAbilityInfoAbilityTest.php— renamed filter override tests asserting the context object shape.Verification
phpcs(WPCS ruleset) clean on all modifiedincludes/files.phpstan --memory-limit=1Gclean on all modifiedincludes/files.php -lclean on all touched files.wp-env/Docker); will validate in CI.Non-goals
meta.mcp.public.*_capabilityfilters — capability gating stays orthogonal.WP_Ability::execute()/ core WP.ToolsHandlersets/clears the current-server holder in this PR.ResourcesHandlerandPromptsHandlercan adopt the same pattern in a follow-up if the built-in tools ever grow resource/prompt equivalents; the three built-in abilities today are tool-only.