Skip to content

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

Description

@raftaar1191

Problem

The three built-in abilities that ship with mcp-adaptermcp-adapter/discover-abilities, mcp-adapter/get-ability-info, and mcp-adapter/execute-ability — decide what a client is allowed to see or invoke by reading meta.mcp.public directly on each ability. That check is hard-coded, so the answer to "is this ability exposed via MCP?" is a single global boolean baked into the ability itself.

For simple installs this works fine. It breaks down as soon as an integrator wants exposure to depend on context rather than a static flag on the ability — most importantly, when the same ability should be visible on one MCP server but not another.

Where the decision lives

Both helpers in includes/Abilities/McpAbilityHelperTrait.php read $meta['mcp']['public'] directly:

  • is_ability_mcp_public() — used by DiscoverAbilitiesAbility::execute() to decide which abilities to list.
  • check_ability_mcp_exposure() — used by GetAbilityInfoAbility::check_permission() and ExecuteAbilityAbility::check_permission() to decide whether the call is allowed.

Neither read is wrapped in apply_filters(). The only extensibility the three abilities expose today is the *_capability filters, which control who can call the tool — not which abilities the tool is willing to talk about.

What a filter would enable

If both reads were wrapped in a single filter — mcp_adapter_is_ability_public( bool $is_public, WP_Ability $ability, ?McpServer $server ) — the default behavior stays identical (unpatched sites are unaffected), but integrators gain the ability to answer the "is this ability MCP-visible?" question dynamically and per-server.

Example: different exposure per MCP server

A single site can register multiple MCP servers (public storefront server, internal ops server, per-tenant server, etc.). Today, an ability is either mcp.public = true for all of them or none of them. With the filter, an integrator can decide per server based on the server actually handling the request:

add_filter( 'mcp_adapter_is_ability_public', function ( $is_public, $ability, $server ) {
    // Called outside a request context (WP-CLI, direct wp_get_ability(...)->execute()).
    if ( ! $server ) {
        return $is_public;
    }

    // Per-server allowlist stored by the operator in admin UI.
    $allowlist = my_plugin_get_allowlist_for_server( $server->get_server_id() );

    if ( in_array( $ability->get_name(), $allowlist, true ) ) {
        return true;
    }

    return $is_public;
}, 10, 3 );

Now internal/delete-user can be visible on the ops server but invisible on the public server, without touching the ability's own meta.

Other use cases the filter unlocks

  • Role / capability-aware exposure — only show analytics/export-report if current_user_can( 'view_reports' ), rather than gating with a static flag on the ability.
  • Tenant / site scoping in multi-tenant setups — expose tenant-specific abilities only to the matching server.
  • Feature flags / phased rollout — flip an ability from private to public via a runtime flag without re-registering it.
  • Auditability — a downstream can log every exposure decision (which server accepted or rejected which ability) by hooking the filter.

Why the workarounds don't cover this

  • Flip mcp.public = true on the ability (directly or via wp_register_ability_args in core) — this is all-or-nothing. The same flag also opts the ability into every other MCP auto-exposure path (RegisterAbilityAsMcpTool, RegisterAbilityAsMcpResource, RegisterAbilityAsMcpPrompt, DefaultServerFactory). Integrators who want the built-in three tools to see the ability only on specific servers cannot express that.
  • Register the ability directly as a tool on a specific server — clients can invoke it via the MCP protocol, but discover-abilities / get-ability-info / execute-ability on that same server still deny its existence, which is inconsistent from the client's point of view.

Proposed change

Three small pieces, all inside mcp-adapter. The ability callbacks receive only $input (that's core WP_Ability::execute() behavior), so the server is threaded via a per-request holder on the McpAdapter singleton rather than through the ability call itself.

1. Track the current server on McpAdapter

Add one private field and two accessors on the existing singleton (includes/Core/McpAdapter.php):

private ?McpServer $current_server = null;

public function set_current_server( ?McpServer $server ): void {
    $this->current_server = $server;
}

public function get_current_server(): ?McpServer {
    return $this->current_server;
}

2. Set / clear it around tool execution in ToolsHandler

In includes/Handlers/Tools/ToolsHandler.php, wrap the existing $mcp_tool->execute( $args ) call in try / finally so exceptions still clear the holder:

McpAdapter::instance()->set_current_server( $this->mcp );
try {
    $result = $mcp_tool->execute( $args );
} finally {
    McpAdapter::instance()->set_current_server( null );
}

Optional for consistency: apply the same wrapping in ResourcesHandler and PromptsHandler. Not strictly required for the three built-in tools (they're tool-only today) but keeps the accessor honest for any future use.

3. Wrap the two reads in McpAbilityHelperTrait with the filter

protected static function is_ability_mcp_public( \WP_Ability $ability ): bool {
    $meta      = $ability->get_meta();
    $is_public = (bool) ( $meta['mcp']['public'] ?? false );
    $server    = \WP\MCP\Core\McpAdapter::instance()->get_current_server();

    /**
     * Filters whether an ability is considered publicly exposed via MCP
     * for the purposes of the built-in discover/get-info/execute tools.
     *
     * @param bool                                  $is_public Whether the ability's meta.mcp.public is truthy.
     * @param \WP_Ability                           $ability   The ability being checked.
     * @param \WP\MCP\Core\McpServer|null           $server    The MCP server handling the current request, or null
     *                                                          if the ability is invoked outside a request context
     *                                                          (e.g. WP-CLI or direct wp_get_ability()->execute()).
     */
    return (bool) apply_filters( 'mcp_adapter_is_ability_public', $is_public, $ability, $server );
}

Apply the same filter inside check_ability_mcp_exposure() before returning the ability_not_public_mcp WP_Error.

Non-goals

  • No change to the default behavior. The filter defaults to the existing computed value, so unpatched sites remain unaffected.
  • No change to the *_capability filters — capability gating stays orthogonal.
  • No change to WP_Ability::execute() / core WP.

Happy to open a PR if the approach is acceptable.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status
    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions