diff --git a/src/Admin/AdminNotices.php b/src/Admin/AdminNotices.php new file mode 100644 index 0000000..5282a39 --- /dev/null +++ b/src/Admin/AdminNotices.php @@ -0,0 +1,63 @@ + */ + public function messages(bool $onOwnPage): array + { + $messages = []; + if (! $this->status->oauthEnabled()) { + $messages[] = __( + 'LIT MCP: Der OAuth-Server ist deaktiviert (Filter wpmedia_mcp_oauth_server_enabled).', + 'wp-lit-mcp' + ) . ' ' . __('Claude kann sich nicht verbinden.', 'wp-lit-mcp'); + } + + $discovery = $this->status->discovery(false); + if (is_array($discovery) && ($discovery['status'] ?? 'good') === 'critical') { + $messages[] = __( + 'LIT MCP: Die OAuth-Discovery-Dokumente unter /.well-known/ sind nicht erreichbar.', + 'wp-lit-mcp' + ) . ' ' . __('Details unter Werkzeuge → Website-Zustand.', 'wp-lit-mcp'); + } + + if ($onOwnPage && ! $this->status->https()) { + $messages[] = __( + 'LIT MCP: Die Website läuft ohne HTTPS; claude.ai verbindet sich nur mit HTTPS-Websites.', + 'wp-lit-mcp' + ); + } + + return $messages; + } + + public function render(): void + { + if (! current_user_can('manage_options')) { + return; + } + + $screen = function_exists('get_current_screen') ? get_current_screen() : null; + $onOwnPage = is_object($screen) && $screen->id === 'settings_page_' . SettingsPage::SLUG; + foreach ($this->messages($onOwnPage) as $message) { + printf('

%s

', esc_html($message)); + } + } +} diff --git a/src/Admin/AdminTab.php b/src/Admin/AdminTab.php new file mode 100644 index 0000000..cdfd7a0 --- /dev/null +++ b/src/Admin/AdminTab.php @@ -0,0 +1,23 @@ +oauthEnabled = $oauthEnabled ?? static fn (): bool => (new OAuthContext())->is_enabled(); + } + + public static function serverUrl(): string + { + return rest_url(ServerRegistrar::NAMESPACE . '/' . ServerRegistrar::ROUTE); + } + + public function oauthEnabled(): bool + { + return ($this->oauthEnabled)(); + } + + public function adapterLoaded(): bool + { + return class_exists(McpAdapter::class); + } + + public function https(): bool + { + return str_starts_with((string) home_url(), 'https://'); + } + + /** @return array|null mcp-oauth's discovery self check (label, status, …). */ + public function discovery(bool $allowFetch): ?array + { + if (! $allowFetch) { + $cached = get_transient(HealthCheck::TRANSIENT_KEY); + + return is_array($cached) ? $cached : null; + } + + return (new HealthCheck(new OAuthContext()))->run_self_check(); + } + + /** @return array{ok: bool, message: string}|null */ + public function endpoint(bool $allowFetch): ?array + { + $cached = get_transient(self::ENDPOINT_TRANSIENT); + if (is_array($cached)) { + return $cached; + } + + if (! $allowFetch) { + return null; + } + + $response = wp_remote_post(self::serverUrl(), [ + 'timeout' => 5, + 'headers' => ['Content-Type' => 'application/json'], + 'body' => '{"jsonrpc":"2.0","id":1,"method":"ping"}', + ]); + + if (is_wp_error($response)) { + $result = ['ok' => false, 'message' => 'Endpunkt nicht erreichbar: ' . $response->get_error_message()]; + } else { + $code = (int) wp_remote_retrieve_response_code($response); + $challenge = (string) wp_remote_retrieve_header($response, 'www-authenticate'); + $result = $code === 401 && str_starts_with($challenge, 'Bearer') + ? ['ok' => true, 'message' => 'Ohne Token: 401 mit WWW-Authenticate – korrekt.'] + : ['ok' => false, 'message' => sprintf('Erwartet 401 mit WWW-Authenticate, erhalten HTTP %d.', $code)]; + } + + set_transient(self::ENDPOINT_TRANSIENT, $result, self::CACHE_SECONDS); + + return $result; + } + + /** @return list */ + public function checks(bool $allowFetch): array + { + $adapter = $this->adapterLoaded(); + $oauth = $this->oauthEnabled(); + $https = $this->https(); + $discovery = $this->discovery($allowFetch); + $endpoint = $this->endpoint($allowFetch); + + return [ + ['label' => 'MCP-Adapter', 'ok' => $adapter, 'message' => $adapter ? 'geladen' : 'fehlt'], + [ + 'label' => 'OAuth-Server', + 'ok' => $oauth, + 'message' => $oauth ? 'aktiv' : 'deaktiviert (Filter wpmedia_mcp_oauth_server_enabled)', + ], + [ + 'label' => 'HTTPS', + 'ok' => $https, + 'message' => $https ? 'aktiv' : 'Website läuft ohne HTTPS – claude.ai verbindet sich nur über HTTPS', + ], + [ + 'label' => 'Discovery (/.well-known/)', + 'ok' => $discovery === null ? null : ($discovery['status'] ?? '') === 'good', + 'message' => $discovery === null + ? 'noch nicht geprüft' + : wp_strip_all_tags((string) ($discovery['label'] ?? '')), + ], + [ + 'label' => 'Endpunkt ohne Token', + 'ok' => $endpoint['ok'] ?? null, + 'message' => $endpoint['message'] ?? 'noch nicht geprüft', + ], + ]; + } +} diff --git a/src/Admin/ConnectionTab.php b/src/Admin/ConnectionTab.php new file mode 100644 index 0000000..7f4ec63 --- /dev/null +++ b/src/Admin/ConnectionTab.php @@ -0,0 +1,58 @@ +' . esc_html__('MCP-Server', 'wp-lit-mcp') . ''; + printf( + '


' + . '

', + esc_html__('Server-URL für den Connector', 'wp-lit-mcp'), + esc_attr(ConnectionStatus::serverUrl()) + ); + echo '

' . esc_html__( + 'In claude.ai unter Einstellungen → Connectors → „Add custom connector“ eintragen;', + 'wp-lit-mcp' + ) . ' ' . esc_html__('Die Anmeldung läuft danach über diese Website.', 'wp-lit-mcp') . '

'; + + echo '

' . esc_html__('Status', 'wp-lit-mcp') . '

'; + foreach ($this->status->checks(true) as $check) { + $state = $check['ok'] === null ? '–' : ($check['ok'] ? '✔' : '✖'); + printf( + '', + esc_html($check['label']), + esc_html($state), + esc_html($check['message']) + ); + } + echo '
%s%s%s
'; + echo '

' . esc_html__('Weitere Prüfungen unter Werkzeuge → Website-Zustand.', 'wp-lit-mcp') . '

'; + } +} diff --git a/src/Admin/SettingsPage.php b/src/Admin/SettingsPage.php new file mode 100644 index 0000000..f635023 --- /dev/null +++ b/src/Admin/SettingsPage.php @@ -0,0 +1,116 @@ + */ + private array $tabs = []; + + /** @param list $tabs The first tab is the default. */ + public function __construct(array $tabs) + { + foreach ($tabs as $tab) { + $this->tabs[$tab->slug()] = $tab; + } + } + + public function register(): void + { + add_action('admin_menu', [$this, 'addMenu']); + add_action('admin_init', [$this, 'registerSettings']); + foreach ($this->tabs as $tab) { + $tab->registerActions(); + } + } + + public function addMenu(): void + { + add_options_page( + __('LIT MCP', 'wp-lit-mcp'), + __('LIT MCP', 'wp-lit-mcp'), + 'manage_options', + self::SLUG, + [$this, 'render'] + ); + } + + public function registerSettings(): void + { + foreach ($this->tabs as $tab) { + $tab->registerSettings(); + } + } + + /** @return list */ + public function tabSlugs(): array + { + return array_keys($this->tabs); + } + + public function currentTab(): string + { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab switch. + $requested = isset($_GET['tab']) ? sanitize_key(wp_unslash((string) $_GET['tab'])) : ''; + + return isset($this->tabs[$requested]) ? $requested : (string) array_key_first($this->tabs); + } + + /** @param array $args */ + public static function url(string $tab = '', array $args = []): string + { + $query = array_merge(['page' => self::SLUG], $tab !== '' ? ['tab' => $tab] : [], $args); + + return add_query_arg($query, admin_url('options-general.php')); + } + + public function render(): void + { + if (! current_user_can('manage_options')) { + return; + } + + $current = $this->currentTab(); + echo '

' . esc_html__('LIT MCP', 'wp-lit-mcp') . '

'; + $this->renderNotice(); + echo ''; + $this->tabs[$current]->render(); + echo '
'; + } + + private function renderNotice(): void + { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display only. + $key = isset($_GET['lit_mcp_notice']) ? sanitize_key(wp_unslash((string) $_GET['lit_mcp_notice'])) : ''; + $notice = match ($key) { + 'revoked' => ['success', __('Verbindung widerrufen.', 'wp-lit-mcp')], + 'revoke_failed' => ['error', __('Die Verbindung konnte nicht widerrufen werden.', 'wp-lit-mcp')], + 'cleared' => ['success', __('Protokoll geleert.', 'wp-lit-mcp')], + default => null, + }; + + if ($notice !== null) { + printf( + '

%s

', + esc_attr($notice[0]), + esc_html($notice[1]) + ); + } + } +} diff --git a/src/Admin/SiteHealth.php b/src/Admin/SiteHealth.php new file mode 100644 index 0000000..bb0e2f9 --- /dev/null +++ b/src/Admin/SiteHealth.php @@ -0,0 +1,63 @@ + __('LIT MCP Endpunkt', 'wp-lit-mcp'), + 'test' => [$this, 'run'], + ]; + + return $tests; + } + + /** @return array */ + public function run(): array + { + $oauth = $this->status->oauthEnabled(); + $endpoint = $oauth ? $this->status->endpoint(true) : null; + $good = $oauth && ($endpoint['ok'] ?? false) === true; + $detail = $oauth + ? (string) ($endpoint['message'] ?? '') + : __('Der OAuth-Server ist deaktiviert (Filter wpmedia_mcp_oauth_server_enabled).', 'wp-lit-mcp'); + + return [ + 'label' => $good + ? __('Der LIT-MCP-Endpunkt ist erreichbar und verlangt OAuth', 'wp-lit-mcp') + : __('Der LIT-MCP-Endpunkt ist nicht nutzbar', 'wp-lit-mcp'), + 'status' => $good ? 'good' : 'critical', + 'badge' => ['label' => 'LIT MCP', 'color' => $good ? 'blue' : 'red'], + 'description' => '

' . esc_html($detail) . '

', + 'actions' => sprintf( + '%s', + esc_url(SettingsPage::url('verbindung')), + esc_html__('Einstellungen → LIT MCP', 'wp-lit-mcp') + ), + 'test' => self::TEST, + ]; + } +} diff --git a/src/Plugin.php b/src/Plugin.php index 9c48969..641486c 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -6,6 +6,11 @@ namespace LIT\MCP; use LIT\MCP\Abilities\AbilityRegistrar; use LIT\MCP\Abilities\ExposurePolicy; +use LIT\MCP\Admin\AdminNotices; +use LIT\MCP\Admin\ConnectionStatus; +use LIT\MCP\Admin\ConnectionTab; +use LIT\MCP\Admin\SettingsPage; +use LIT\MCP\Admin\SiteHealth; use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\ChangeLog\ChangeLogCleanup; use LIT\MCP\ChangeLog\ChangeLogInstaller; @@ -30,6 +35,7 @@ final class Plugin public const VERSION = '0.1.0'; private static string $pluginFile = ''; + private static ?SettingsPage $settingsPage = null; public static function boot(string $pluginFile): void { @@ -68,9 +74,24 @@ final class Plugin $policy ))->register(); + $status = new ConnectionStatus(); + (new SiteHealth($status))->register(); + self::$settingsPage = new SettingsPage([ + new ConnectionTab($status), + ]); + if (is_admin()) { + self::$settingsPage->register(); + (new AdminNotices($status))->register(); + } + \WPMedia\MCP\OAuth\Bootstrap::instance(); } + public static function settingsPage(): ?SettingsPage + { + return self::$settingsPage; + } + public static function pluginDir(): string { return dirname(self::$pluginFile); diff --git a/tests/Unit/Admin/AdminNoticesTest.php b/tests/Unit/Admin/AdminNoticesTest.php new file mode 100644 index 0000000..d1a3312 --- /dev/null +++ b/tests/Unit/Admin/AdminNoticesTest.php @@ -0,0 +1,47 @@ +allows('oauthEnabled')->andReturn($oauth); + $status->allows('discovery')->with(false)->andReturn($discovery); + $status->allows('https')->andReturn($https); + + return $status; + } + + public function testWarnsAboutDisabledOAuthAndBrokenDiscoveryEverywhere(): void + { + $messages = (new AdminNotices($this->status(false, ['status' => 'critical'], true)))->messages(false); + + self::assertCount(2, $messages); + self::assertStringContainsString('OAuth-Server ist deaktiviert', $messages[0]); + self::assertStringContainsString('/.well-known/', $messages[1]); + } + + public function testHttpsHintOnlyOnTheSettingsPage(): void + { + $notices = new AdminNotices($this->status(true, null, false)); + + self::assertSame([], $notices->messages(false)); + self::assertCount(1, $notices->messages(true)); + } +} diff --git a/tests/Unit/Admin/ConnectionStatusTest.php b/tests/Unit/Admin/ConnectionStatusTest.php new file mode 100644 index 0000000..c6cff7d --- /dev/null +++ b/tests/Unit/Admin/ConnectionStatusTest.php @@ -0,0 +1,59 @@ +justReturn(false); + Functions\when('rest_url')->justReturn('https://kunde.de/wp-json/mcp/mcp-oauth-server'); + Functions\expect('wp_remote_post')->once()->with('https://kunde.de/wp-json/mcp/mcp-oauth-server', Mockery::type('array')) + ->andReturn(['response' => ['code' => 401]]); + Functions\when('wp_remote_retrieve_response_code')->justReturn(401); + Functions\when('wp_remote_retrieve_header')->justReturn('Bearer realm="https://kunde.de"'); + Functions\expect('set_transient')->once() + ->with('lit_mcp_endpoint_check', Mockery::on(static fn (array $value): bool => $value['ok'] === true), 300); + + $result = (new ConnectionStatus(static fn (): bool => true))->endpoint(true); + + self::assertTrue($result['ok']); + } + + public function testEndpointAnsweringWithoutChallengeIsReported(): void + { + Functions\when('get_transient')->justReturn(false); + Functions\when('rest_url')->justReturn('https://kunde.de/wp-json/mcp/mcp-oauth-server'); + Functions\when('wp_remote_post')->justReturn(['response' => ['code' => 200]]); + Functions\when('wp_remote_retrieve_response_code')->justReturn(200); + Functions\when('wp_remote_retrieve_header')->justReturn(''); + Functions\when('set_transient')->justReturn(true); + + $result = (new ConnectionStatus(static fn (): bool => true))->endpoint(true); + + self::assertFalse($result['ok']); + self::assertStringContainsString('200', $result['message']); + } + + public function testChecksWithoutFetchingUseOnlyCachedResults(): void + { + Functions\when('get_transient')->justReturn(false); + Functions\expect('wp_remote_post')->never(); + Functions\when('home_url')->justReturn('http://kunde.test'); + + $checks = (new ConnectionStatus(static fn (): bool => false))->checks(false); + + $byLabel = array_column($checks, null, 'label'); + self::assertFalse($byLabel['OAuth-Server']['ok']); + self::assertFalse($byLabel['HTTPS']['ok']); + self::assertNull($byLabel['Discovery (/.well-known/)']['ok']); + self::assertNull($byLabel['Endpunkt ohne Token']['ok']); + } +} diff --git a/tests/Unit/Admin/SettingsPageTest.php b/tests/Unit/Admin/SettingsPageTest.php new file mode 100644 index 0000000..4c20024 --- /dev/null +++ b/tests/Unit/Admin/SettingsPageTest.php @@ -0,0 +1,97 @@ +alias(static fn (string $path = ''): string => 'https://kunde.de/wp-admin/' . $path); + Functions\when('add_query_arg')->alias(static fn (array $args, string $url): string => $url . '?' . http_build_query($args)); + Functions\when('sanitize_key')->alias(static fn (string $key): string => strtolower((string) preg_replace('/[^a-z0-9_\-]/i', '', $key))); + } + + protected function tearDown(): void + { + unset($_GET['tab'], $_GET['lit_mcp_notice']); + parent::tearDown(); + } + + private function tab(string $slug, string $label): AdminTab + { + $tab = Mockery::mock(AdminTab::class); + $tab->allows('slug')->andReturn($slug); + $tab->allows('label')->andReturn($label); + $tab->allows('registerActions'); + $tab->allows('render')->andReturnUsing(static function () use ($slug): void { + echo '

Inhalt ' . $slug . '

'; + }); + + return $tab; + } + + public function testRendersTabsAndTheRequestedTab(): void + { + Functions\when('current_user_can')->justReturn(true); + $_GET['tab'] = 'zugriff'; + $page = new SettingsPage([$this->tab('verbindung', 'Verbindung'), $this->tab('zugriff', 'Zugriff')]); + + ob_start(); + $page->render(); + $html = (string) ob_get_clean(); + + self::assertStringContainsString('Inhalt zugriff', $html); + self::assertStringNotContainsString('Inhalt verbindung', $html); + self::assertMatchesRegularExpression('/tab=zugriff" class="nav-tab nav-tab-active"/', $html); + self::assertSame(['verbindung', 'zugriff'], $page->tabSlugs()); + } + + public function testUnknownTabFallsBackToTheFirstAndNoticesAreShown(): void + { + Functions\when('current_user_can')->justReturn(true); + $_GET['tab'] = '../../evil'; + $_GET['lit_mcp_notice'] = 'revoked'; + $page = new SettingsPage([$this->tab('verbindung', 'Verbindung')]); + + ob_start(); + $page->render(); + $html = (string) ob_get_clean(); + + self::assertSame('verbindung', $page->currentTab()); + self::assertStringContainsString('Inhalt verbindung', $html); + self::assertStringContainsString('notice-success', $html); + } + + public function testNothingIsRenderedWithoutManageOptions(): void + { + Functions\when('current_user_can')->justReturn(false); + + ob_start(); + (new SettingsPage([$this->tab('verbindung', 'Verbindung')]))->render(); + + self::assertSame('', ob_get_clean()); + } + + public function testRegisterHooksMenuSettingsAndTabActions(): void + { + $tab = Mockery::mock(AdminTab::class); + $tab->allows('slug')->andReturn('verbindung'); + $tab->expects('registerActions')->once(); + $page = new SettingsPage([$tab]); + Functions\expect('add_action')->once()->with('admin_menu', [$page, 'addMenu']); + Functions\expect('add_action')->once()->with('admin_init', [$page, 'registerSettings']); + + $page->register(); + } +} diff --git a/tests/Unit/Admin/SiteHealthTest.php b/tests/Unit/Admin/SiteHealthTest.php new file mode 100644 index 0000000..dad4cf2 --- /dev/null +++ b/tests/Unit/Admin/SiteHealthTest.php @@ -0,0 +1,55 @@ +alias(static fn (string $path = ''): string => 'https://kunde.de/wp-admin/' . $path); + Functions\when('add_query_arg')->alias(static fn (array $args, string $url): string => $url . '?' . http_build_query($args)); + } + + public function testAddsADirectTest(): void + { + $health = new SiteHealth(Mockery::mock(ConnectionStatus::class)); + + $tests = $health->addTest(['direct' => []]); + + self::assertSame([$health, 'run'], $tests['direct']['lit_mcp_endpoint']['test']); + } + + public function testGoodWhenOAuthIsOnAndTheEndpointChallenges(): void + { + $status = Mockery::mock(ConnectionStatus::class); + $status->allows('oauthEnabled')->andReturn(true); + $status->allows('endpoint')->with(true)->andReturn(['ok' => true, 'message' => '401 mit WWW-Authenticate']); + + $result = (new SiteHealth($status))->run(); + + self::assertSame('good', $result['status']); + self::assertSame('lit_mcp_endpoint', $result['test']); + } + + public function testCriticalWhenOAuthIsDisabled(): void + { + $status = Mockery::mock(ConnectionStatus::class); + $status->allows('oauthEnabled')->andReturn(false); + + $result = (new SiteHealth($status))->run(); + + self::assertSame('critical', $result['status']); + self::assertStringContainsString('deaktiviert', $result['description']); + } +}