Skip to content

Make the built-in discover/get-info/execute tools' MCP exposure check filterable (#243) - #244

Open
raftaar1191 wants to merge 6 commits into
WordPress:trunkfrom
raftaar1191:feat/filterable-mcp-exposure
Open

Make the built-in discover/get-info/execute tools' MCP exposure check filterable (#243)#244
raftaar1191 wants to merge 6 commits into
WordPress:trunkfrom
raftaar1191:feat/filterable-mcp-exposure

Conversation

@raftaar1191

@raftaar1191 raftaar1191 commented Jul 14, 2026

Copy link
Copy Markdown

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 existing meta.mcp.public flag, 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.public directly 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

apply_filters(
    'mcp_adapter_is_ability_exposed',
    bool                        $is_exposed,   // default: ability's meta.mcp.public
    \WP_Ability                 $ability,      // the ability being checked
    McpAbilityExposureContext   $context       // request context
);

McpAbilityExposureContext is an immutable value object with:

field type notes
server McpServer|null Null when invoked outside an MCP request (WP-CLI, cron, direct execute)
principal_id int 0 if unauthenticated
roles array<int, string> Role slugs of the current user
site_id int get_current_blog_id()
exposure_path string (PATH_* constant) 'discover', 'get_info', or 'execute'

Downstreams can differentiate exposure decisions by path — e.g. list an ability but block execution:

add_filter( 'mcp_adapter_is_ability_exposed', function ( $is_exposed, $ability, $context ) {
    if ( ! $context->server ) {
        return $is_exposed; // outside an MCP request
    }
    if ( 'internal/delete-user' === $ability->get_name() ) {
        // Visible on the ops server for all paths; visible on the public
        // server for discovery only.
        if ( 'ops' === $context->server->get_server_id() ) {
            return true;
        }
        return McpAbilityExposureContext::PATH_DISCOVER === $context->exposure_path;
    }
    return $is_exposed;
}, 10, 3 );

Design notes (why a context object, not positional args)

Ability callbacks only receive $input — that's core WP_Ability::execute() behavior — so the server can't be threaded through the ability call itself. Two things follow:

  1. Single point of context resolution. A lightweight per-request holder on the existing McpAdapter singleton (set_current_server() / get_current_server()) is set by ToolsHandler::call_tool() on entry and cleared in finally. The trait's build_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.
  2. Extensible surface. New context fields (e.g. 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:

  • Same helper, same context shape across the three tools. 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.
  • Exposure is not authorization. ExecuteAbilityAbilityTest::test_exposure_filter_does_not_replace_ability_permission_check() widens exposure with the filter but registers an ability whose permission_callback returns WP_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

  • The ability_not_public_mcp WP_Error code and message are preserved verbatim for clients catching them.
  • The meta.mcp.public ability metadata key is unchanged (it's the static default the filter starts from).
  • mcp_adapter_is_ability_exposed is 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 — passes PATH_DISCOVER.
  • includes/Abilities/GetAbilityInfoAbility.php — passes PATH_GET_INFO.
  • includes/Abilities/ExecuteAbilityAbility.php — passes PATH_EXECUTE.
  • includes/Core/McpAdapter.phpset_current_server() / get_current_server().
  • includes/Handlers/Tools/ToolsHandler.php — set/clear the holder in try/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 modified includes/ files.
  • phpstan --memory-limit=1G clean on all modified includes/ files.
  • php -l clean on all touched files.
  • PHPUnit not run locally (needs wp-env/Docker); will validate in CI.

Non-goals

  • No change to default behavior — the filter defaults to meta.mcp.public.
  • No change to *_capability filters — capability gating stays orthogonal.
  • No change to WP_Ability::execute() / core WP.
  • Only ToolsHandler sets/clears the current-server holder in this PR. ResourcesHandler and PromptsHandler can 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.

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
Copilot AI review requested due to automatic review settings July 14, 2026 19:17
@github-actions

Copy link
Copy Markdown

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 props-bot label.

Unlinked Accounts

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

Unlinked contributors: shigeki7777.

Co-authored-by: raftaar1191 <raftaar1191@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@codecov

codecov Bot commented Jul 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.14286% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 87.99%. Comparing base (d615585) to head (c5d5156).

Files with missing lines Patch % Lines
includes/Abilities/McpAbilityHelperTrait.php 95.00% 1 Missing ⚠️
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     
Flag Coverage Δ
unit 87.99% <97.14%> (+0.04%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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_server holder on McpAdapter with set_current_server() / get_current_server().
  • Set/clear the current_server in ToolsHandler::call_tool() using try/finally to avoid leaking state across calls.
  • Route MCP exposure checks through a new mcp_adapter_is_ability_public filter 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_mcp error message is now potentially misleading: exposure is determined via is_ability_mcp_public() (and can be overridden by the mcp_adapter_is_ability_public filter), but the message still claims the cause is mcp.public!=true. If a site hides an ability via the filter even when mcp.public is 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.

@shigeki7777

Copy link
Copy Markdown

Thanks for turning this into a concrete PR so quickly.

The current-server holder combined with try / finally looks like a pragmatic way to provide the server context without changing the WP_Ability callback contract. Centralizing both discovery and execution exposure checks through the same helper also addresses the consistency concern from #243.

A few small things seem worth pinning before merge:

  • Add an integration-level test confirming that the filter receives the actual McpServer when invoked through ToolsHandler::call_tool(), not only through direct holder/filter tests.
  • Keep the ability_not_public_mcp error message filter-agnostic. Since the filter can now hide an ability whose mcp.public value is true, the current mcp.public!=true wording may be misleading.
  • Add a test confirming that returning true from the exposure filter does not bypass the separate capability/permission check for an unauthorized principal.

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
@raftaar1191

Copy link
Copy Markdown
Author

Thanks for the review — all three items addressed in dfd614c.

1. Integration test through ToolsHandler::call_tool() — new tests/phpunit/Integration/ExposureFilterIntegrationTest.php:

  • test_call_tool_threads_actual_server_into_exposure_filter() — builds a real McpServer with mcp-adapter/discover-abilities in its tools, dispatches through ToolsHandler::call_tool(), and asserts the filter callback observed $context->server === $server (same instance) with exposure_path === PATH_DISCOVER.
  • test_call_tool_clears_current_server_after_dispatch() — verifies the finally clause actually clears the holder so subsequent unrelated calls don't observe stale server state.

2. Filter-agnostic error messageMcpAbilityHelperTrait::check_ability_mcp_exposure() now returns:

Ability "%s" is not exposed via MCP

Dropping the (mcp.public!=true) parenthetical — as you noted, that's misleading once the filter can hide an ability whose mcp.public value is true. The error code (ability_not_public_mcp) is intentionally unchanged to preserve BC for clients catching it; only the human-readable message is trimmed.

3. Auth-gate test — new ExecuteAbilityAbilityTest::test_exposure_filter_does_not_bypass_capability_check_for_unauthorized_principal():

  • Sets wp_set_current_user( 0 ), adds a filter returning true, uses an ability whose own permission_callback also returns true.
  • Asserts the failure is authentication_required (from the tool's own auth gate), not ability_not_public_mcp, not success.
  • Complements the existing test_exposure_filter_does_not_replace_ability_permission_check (which pins the ability-level permission gate). Together they assert: the exposure gate runs neither before the auth gate nor in place of the ability's permission_callback.

Agreed on the broader tenant/principal point — that's why the refactor to McpAbilityExposureContext in c5d5156 already carries principal_id, roles, and site_id alongside server and exposure_path, so the same integration surface can grow richer context without another BC bump.

php -l, phpcs, and phpstan all clean on the touched files. Happy to squash before merge.

`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
@shigeki7777

Copy link
Copy Markdown

Nice turnaround — going by your writeup, all three items land the way we hoped:

  1. Integration test — asserting $context->server === $server (same instance, not just same class) through a real ToolsHandler::call_tool() dispatch is exactly the pin we were after. The extra test_call_tool_clears_current_server_after_dispatch() is a welcome addition we didn't ask for: stale-holder leakage is the classic failure mode of the current-server pattern, and now it's pinned too.

  2. Message/code split — trimming the message while freezing the ability_not_public_mcp error code is the right BC trade-off. One small thought, take or leave: the code name itself still implies the mcp.public semantics, so a one-line docblock noting it's kept for BC (and no longer means mcp.public !== true) would spare future readers the same confusion the old message caused.

  3. Auth-gate test — asserting the failure is specifically authentication_required (not ability_not_public_mcp, not success) is stronger than a generic "it fails" check, and paired with the existing permission-callback test it covers both gates the exposure filter must not bypass.

One honesty note on where we stand: this is a read of your description, not an independent reproduction — we haven't pulled dfd614c and run the suite from this thread. Nothing in the writeup conflicts with what we reviewed, and we'll re-run our checks against the merged result on our next verification pass.

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.

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.

Make the built-in discover/get-info/execute tools' MCP exposure check filterable

3 participants