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
171 lines
6.4 KiB
PHP
171 lines
6.4 KiB
PHP
<?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);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|