Problem
The three built-in abilities that ship with mcp-adapter — mcp-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.
Problem
The three built-in abilities that ship with
mcp-adapter—mcp-adapter/discover-abilities,mcp-adapter/get-ability-info, andmcp-adapter/execute-ability— decide what a client is allowed to see or invoke by readingmeta.mcp.publicdirectly 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.phpread$meta['mcp']['public']directly:is_ability_mcp_public()— used byDiscoverAbilitiesAbility::execute()to decide which abilities to list.check_ability_mcp_exposure()— used byGetAbilityInfoAbility::check_permission()andExecuteAbilityAbility::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*_capabilityfilters, 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 = truefor all of them or none of them. With the filter, an integrator can decide per server based on the server actually handling the request:Now
internal/delete-usercan be visible on the ops server but invisible on the public server, without touching the ability's ownmeta.Other use cases the filter unlocks
analytics/export-reportifcurrent_user_can( 'view_reports' ), rather than gating with a static flag on the ability.Why the workarounds don't cover this
mcp.public = trueon the ability (directly or viawp_register_ability_argsin 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.discover-abilities/get-ability-info/execute-abilityon 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 coreWP_Ability::execute()behavior), so the server is threaded via a per-request holder on theMcpAdaptersingleton rather than through the ability call itself.1. Track the current server on
McpAdapterAdd one private field and two accessors on the existing singleton (
includes/Core/McpAdapter.php):2. Set / clear it around tool execution in
ToolsHandlerIn
includes/Handlers/Tools/ToolsHandler.php, wrap the existing$mcp_tool->execute( $args )call intry / finallyso exceptions still clear the holder:Optional for consistency: apply the same wrapping in
ResourcesHandlerandPromptsHandler. 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
McpAbilityHelperTraitwith the filterApply the same filter inside
check_ability_mcp_exposure()before returning theability_not_public_mcpWP_Error.Non-goals
*_capabilityfilters — capability gating stays orthogonal.WP_Ability::execute()/ core WP.Happy to open a PR if the approach is acceptable.