Files
WP-LIT-MCP/src/Server/ServerRegistrar.php
T
janniclas.loosenandClaude Opus 5.5 2f8a9f0c61 feat: configurable exposure policy for direct, indirect and disabled tools
Introduces ExposurePolicy (option lit_mcp_exposure) deciding per ability
whether it is a direct MCP tool, only reachable via mcp-adapter's
execute-ability meta-tool, or disabled entirely (not registered with the
Abilities API at all). Ability::isDirect() remains the default when no
override applies. lit/guide can never be disabled or made indirect;
lit/media-preview and lit/media-download (image/embedded-resource results)
can never be made indirect, only direct or disabled.

AbilityRegistrar and ServerRegistrar take an optional ExposurePolicy and
use it to filter registered abilities / compute the direct tool list.
IntegrationRegistry::directAbilityNames() is removed (superseded by
ExposurePolicy::directNames()). InstructionsBuilder now also takes the
policy and lists the currently direct tools by name (lit-<name>), noting
the rest are reachable via mcp-adapter-execute-ability as lit/<name>.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-26 03:04:14 +02:00

171 lines
6.4 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace LIT\MCP\Server;
use LIT\MCP\Abilities\ExposurePolicy;
use LIT\MCP\Guides\InstructionsBuilder;
use LIT\MCP\Integrations\IntegrationRegistry;
use LIT\MCP\Plugin;
use WP\MCP\Core\McpAdapter;
use WP\MCP\Infrastructure\ErrorHandling\ErrorLogMcpErrorHandler;
use WPMedia\MCP\OAuth\Context as OAuthContext;
use WPMedia\MCP\OAuth\Transport\OAuthHttpTransport;
use WPMedia\MCP\OAuth\Transport\ServerRegistrar as OAuthServerRegistrar;
/**
* Registers the OAuth-protected MCP server with our own tool list.
*
* `register()` disables mcp-adapter's default server (`mcp_adapter_create_default_server`)
* so no second, unprotected MCP endpoint is exposed alongside ours.
*
* wp-media/mcp-oauth hardcodes server id/route (token audience) and only three
* meta-tools, and registers its own server unconditionally on the same
* `mcp_adapter_init` action (priority 10). We register the same server id
* first (priority 5) and, right before creating it, unhook mcp-oauth's own
* `ServerRegistrar::register_server` callback so it never runs – see
* `unhookOAuthDefaultServer()`. This avoids the duplicate-server-id WP_Error
* (and the `_doing_it_wrong()` notice it used to trigger) entirely instead of
* merely tolerating it – see spec §4.2.
*/
final class ServerRegistrar
{
public const SERVER_ID = 'mcp-oauth-server';
public const NAMESPACE = 'mcp';
public const ROUTE = 'mcp-oauth-server';
public const META_TOOLS = [
'mcp-adapter/discover-abilities',
'mcp-adapter/get-ability-info',
'mcp-adapter/execute-ability',
];
/** @var callable(): bool */
private $oauthEnabled;
/** @var callable(array<string, mixed>): mixed */
private $createAdapterServer;
/** @var callable(string): bool */
private $classExists;
private ExposurePolicy $policy;
public function __construct(
private IntegrationRegistry $registry,
private InstructionsBuilder $instructions,
?callable $oauthEnabled = null,
?callable $createAdapterServer = null,
?callable $classExists = null,
?ExposurePolicy $policy = null
) {
$this->policy = $policy ?? new ExposurePolicy();
$this->oauthEnabled = $oauthEnabled
?? static fn (): bool => (new OAuthContext())->is_enabled();
$this->classExists = $classExists
?? static fn (string $class): bool => class_exists($class);
$this->createAdapterServer = $createAdapterServer
?? static function (array $args): mixed {
return McpAdapter::instance()->create_server(
$args['id'],
$args['namespace'],
$args['route'],
$args['name'],
$args['description'],
$args['version'],
[OAuthHttpTransport::class],
ErrorLogMcpErrorHandler::class,
(new OAuthContext())->observability_handler(),
$args['tools']
);
};
}
public function register(): void
{
// mcp-adapter's default server is a second, unprotected (plain HttpTransport,
// is_user_logged_in()) endpoint exposing execute-ability – and thus our own
// abilities. We register our own OAuth-protected server instead; mcp-oauth's
// ServerRegistrar registers the shared meta-abilities itself once this filter
// disables the default server (see its ensure_shared_abilities_registered()).
add_filter('mcp_adapter_create_default_server', '__return_false');
add_action('mcp_adapter_init', [$this, 'createServer'], 5);
}
/** @return array<string, mixed>|null */
public function serverArguments(): ?array
{
if (! ($this->oauthEnabled)()) {
return null;
}
return [
'id' => self::SERVER_ID,
'namespace' => self::NAMESPACE,
'route' => self::ROUTE,
'name' => 'LIT MCP',
'description' => $this->instructions->build(),
'version' => Plugin::VERSION,
'tools' => array_merge($this->policy->directNames($this->registry->abilities()), self::META_TOOLS),
];
}
public function createServer(): void
{
if (
! doing_action('mcp_adapter_init')
|| ! class_exists(McpAdapter::class)
|| ! class_exists(OAuthHttpTransport::class)
) {
return;
}
$args = $this->serverArguments();
if ($args === null) {
return;
}
$this->unhookOAuthDefaultServer();
$result = ($this->createAdapterServer)($args);
if (is_wp_error($result)) {
$message = '[wp-lit-mcp] MCP server registration failed: ' . $result->get_error_message();
error_log($message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
}
}
/**
* Removes wp-media/mcp-oauth's own `mcp_adapter_init` callback so it never attempts to
* register its (now redundant) copy of the `mcp-oauth-server` server.
*
* Reads the raw `WP_Hook` for `mcp_adapter_init` from `$GLOBALS['wp_filter']` – there is no
* public API to look up a hooked callback by the class of its bound object, only by the exact
* callable, which we don't have here. Removing a same-action, later-priority callback while an
* earlier-priority one for the same action is running is explicitly supported by
* `WP_Hook::remove_filter()` (it calls `resort_active_iterations()` for exactly this case).
* No-op if the hook has not fired yet, has no callbacks, or the mcp-oauth package is absent.
*/
public function unhookOAuthDefaultServer(): void
{
if (! ($this->classExists)(OAuthServerRegistrar::class)) {
return;
}
$hook = $GLOBALS['wp_filter']['mcp_adapter_init'] ?? null;
if (! is_object($hook) || ! isset($hook->callbacks) || ! is_array($hook->callbacks)) {
return;
}
foreach ($hook->callbacks as $priority => $callbacks) {
foreach ($callbacks as $callback) {
$function = $callback['function'] ?? null;
if (is_array($function) && ($function[0] ?? null) instanceof OAuthServerRegistrar) {
remove_action('mcp_adapter_init', $function, $priority);
}
}
}
}
}