# Etappe 0 – Anbindung: Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Ein installierbares Plugin-Gerüst, das über `wp-media/mcp-oauth` + `wordpress/mcp-adapter` einen OAuth-geschützten MCP-Server mit eigenen, direkt sichtbaren Tools (`lit-guide`, `lit-site-status`) bereitstellt, mit Rollen-Gate – und die Entscheidungsgrundlage „mcp-oauth bleibt / Wechsel zu mcp-connect“ sowie „Events-Erweiterungspunkt nötig ja/nein“. **Architecture:** WP-LIT-MCP bindet mcp-oauth per Composer ein und registriert auf `mcp_adapter_init` (Priorität 5, vor mcp-oauth) den Server `mcp-oauth-server` selbst – mit mcp-oauths `OAuthHttpTransport` und eigener Tool-Liste. Abilities werden über ein kleines `Ability`-Interface definiert und vom `AbilityRegistrar` bei der WordPress Abilities API angemeldet; jede Ausführung ist durch die Capability `lit_mcp_use` (nur freigegebene Rollen) geschützt. **Tech Stack:** PHP ≥ 8.1, WordPress 7.0 Abilities API, `wp-media/mcp-oauth` ^1.1 (zieht `wordpress/mcp-adapter` ^0.5), Composer, PHPUnit 9.6 + Brain Monkey + Mockery, phpcs (PSR-12 + WordPress-Security-Sniffs), DDEV für Integrationstests. **Spec:** `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` (insb. §4, §9, §10, §11, §12 Etappe 0) ## Global Constraints - PHP ≥ 8.1, WordPress ≥ 6.9 (getestet mit 7.0.3); Plugin-Header entsprechend. - Namespace `LIT\MCP\` → `src/`; PSR-4, eine Klasse pro Datei, PSR-12-Stil (wie `acorn-starter`). - Ability-Namen `lit/-`; der Adapter macht daraus Tool-Namen `lit--`. - Kein Acorn. Keine eigene OAuth-Implementierung. - Capability `lit_mcp_use`; Option `lit_mcp_allowed_roles`, Standard `['administrator']`. - MCP-Server: ID `mcp-oauth-server`, Namespace `mcp`, Route `mcp-oauth-server` (Endpoint `/wp-json/mcp/mcp-oauth-server`) – durch mcp-oauth vorgegeben (Token-Audience). - Meta-Tools des Adapters immer im Server: `mcp-adapter/discover-abilities`, `mcp-adapter/get-ability-info`, `mcp-adapter/execute-ability`. - Fachliche Fehler als `WP_Error` mit Code-Präfix `lit_` (Adapter macht daraus `isError: true`); nie Stacktraces an den Client. - Text-Domain `wp-lit-mcp`. - Git-Commits mit `git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit …` (keine Repo-Identität konfiguriert) und Trailer `Co-Authored-By: Claude Opus 5.5 (1M context) `. - Integrationstests laufen gegen die lokale DDEV-Installation `/home/janniclas/Projekte/WordPress` (`ddev wp …`, `ddev exec …`); Plugin-Pfad im Container: `/var/www/html/wp-content/plugins/WP-LIT-MCP`. ## Review Focus 1. **Nicht freigegebene Rolle mit gültigem Token** (z. B. Abonnent verbindet sich): Tool-Aufrufe und Meta-Tools müssen mit „forbidden“ scheitern, nicht still Daten liefern → Test in Task 2 (Unit) und Task 7 (Integration). 2. **Plugin aktiviert ohne `vendor/`** (Git-Checkout ohne `composer install`): Admin-Hinweis statt Fatal Error → Test in Task 1. 3. **Guide-Topic mit Pfad-Tricks** (`../../wp-config`, `overview.md`, leere Strings): muss abgewiesen werden, nie Dateien außerhalb `docs/guides/` lesen → Test in Task 4. 4. **Adapter oder mcp-oauth fehlt/deaktiviert** (Klasse nicht vorhanden, Filter `wpmedia_mcp_oauth_server_enabled` = false): Server-Registrierung darf nicht fataln und keinen ungeschützten Server anlegen → Test in Task 6. 5. **Exception in einer Ability** (z. B. Divi-Funktion wirft): neutraler `WP_Error` `lit_internal_error` ohne Stacktrace, Detail nur im PHP-Error-Log → Test in Task 3. --- ## File Structure ``` wp-lit-mcp.php Plugin-Header, Autoload-Prüfung, Boot composer.json / composer.lock phpunit.xml.dist, phpcs.xml.dist, .gitignore src/ Plugin.php verdrahtet alles (Hooks) Dependencies.php prüft Adapter/mcp-oauth-Verfügbarkeit Access.php Rollen-Gate, Capability lit_mcp_use, Adapter-Filter Abilities/ Ability.php Interface einer Ability AbilityRegistrar.php meldet Kategorien + Abilities an, kapselt Rechte/Fehler Errors.php Fabrik für WP_Error-Objekte Integrations/ Integration.php Interface einer Integration IntegrationRegistry.php sammelt verfügbare Integrationen Core/CoreIntegration.php immer verfügbar: guide, site-status Core/GuideAbility.php lit/guide Core/SiteStatusAbility.php lit/site-status Core/EnvironmentProbe.php erkennt Divi/Pods/TEC + Versionen Guides/ GuideRepository.php liest docs/guides/*.md sicher InstructionsBuilder.php erzeugt Server-Instructions Server/ ServerRegistrar.php registriert mcp-oauth-server mit eigenen Tools docs/guides/overview.md tests/ bootstrap.php Stubs/WpError.php, Stubs/WpUser.php Minimal-Stubs für Unit-Tests Unit/…Test.php Integration/mint-token.php erzeugt Test-JWT Integration/mcp-smoke.sh HTTP-Ende-zu-Ende gegen DDEV Integration/tec-probe.php The-Events-Calendar-Test docs/superpowers/notes/2026-09-25-etappe-0-ergebnisse.md ``` --- ### Task 1: Plugin-Gerüst, Composer, Test-Setup, Abhängigkeitsprüfung **Files:** - Create: `composer.json`, `phpunit.xml.dist`, `phpcs.xml.dist`, `.gitignore`, `wp-lit-mcp.php`, `src/Dependencies.php`, `src/Plugin.php`, `tests/bootstrap.php`, `tests/Stubs/WpError.php`, `tests/Stubs/WpUser.php`, `tests/Unit/TestCase.php`, `tests/Unit/DependenciesTest.php` **Interfaces:** - Produces: `LIT\MCP\Dependencies::missing(): list` (leere Liste = alles da); `LIT\MCP\Plugin::boot(string $pluginFile): void`; `LIT\MCP\Plugin::pluginDir(): string`; Test-Basisklasse `LIT\MCP\Tests\Unit\TestCase` (setUp/tearDown für Brain Monkey). - [ ] **Step 1: Composer-Datei anlegen** `composer.json`: ```json { "name": "loosen-it/wp-lit-mcp", "description": "MCP-Server für WordPress, Divi 5 und Pods mit OAuth für claude.ai", "type": "wordpress-plugin", "license": "GPL-2.0-or-later", "require": { "php": ">=8.1", "wp-media/mcp-oauth": "^1.1" }, "require-dev": { "brain/monkey": "^2.6", "dealerdirect/phpcodesniffer-composer-installer": "^1.0", "mockery/mockery": "^1.6", "phpcompatibility/phpcompatibility-wp": "^2.1", "phpunit/phpunit": "^9.6", "squizlabs/php_codesniffer": "^3.10", "wp-coding-standards/wpcs": "^3.1" }, "autoload": { "psr-4": { "LIT\\MCP\\": "src/" } }, "autoload-dev": { "psr-4": { "LIT\\MCP\\Tests\\": "tests/" } }, "scripts": { "test": "@php vendor/bin/phpunit", "lint": "@php vendor/bin/phpcs" }, "config": { "sort-packages": true, "platform": { "php": "8.1" }, "allow-plugins": { "dealerdirect/phpcodesniffer-composer-installer": true } } } ``` - [ ] **Step 2: Installieren und Versionen prüfen** Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && composer install` Expected: Erfolg; `composer show wp-media/mcp-oauth wordpress/mcp-adapter` zeigt `v1.1.2` und `v0.5.0`. - [ ] **Step 3: Test- und Lint-Konfiguration** `phpunit.xml.dist`: ```xml tests/Unit ``` `phpcs.xml.dist`: ```xml src wp-lit-mcp.php /vendor/* ``` `.gitignore`: ``` /vendor/ .phpunit.result.cache ``` `tests/bootstrap.php`: ```php code; } public function get_error_message(): string { return $this->message; } public function get_error_data(): mixed { return $this->data; } } } ``` `tests/Stubs/WpUser.php`: ```php ID = $id; $this->roles = $roles; } } } ``` `tests/Unit/TestCase.php`: ```php true); self::assertSame([], $deps->missing()); } public function testNamesEveryMissingComponent(): void { $deps = new Dependencies(static fn (string $class): bool => false); self::assertSame( ['wordpress/mcp-adapter', 'wp-media/mcp-oauth', 'WordPress Abilities API'], $deps->missing() ); } } ``` - [ ] **Step 5: Test laufen lassen – muss fehlschlagen** Run: `composer test -- --filter DependenciesTest` Expected: FAIL, „Class "LIT\MCP\Dependencies" not found“. - [ ] **Step 6: Implementierung** `src/Dependencies.php`: ```php 'WP\\MCP\\Core\\McpAdapter', 'wp-media/mcp-oauth' => 'WPMedia\\MCP\\OAuth\\Bootstrap', 'WordPress Abilities API' => 'WP_Ability', ]; /** @var callable(string): bool */ private $classExists; public function __construct(?callable $classExists = null) { $this->classExists = $classExists ?? static fn (string $class): bool => class_exists($class); } /** * @return list Human-readable names of missing components. */ public function missing(): array { $missing = []; foreach (self::REQUIRED as $name => $class) { if (! ($this->classExists)($class)) { $missing[] = $name; } } return $missing; } } ``` `src/Plugin.php` (in dieser Task nur Abhängigkeitsprüfung + Hinweis; spätere Tasks ergänzen `boot()`): ```php missing(); if ($missing !== []) { self::noticeMissing($missing); return; } } public static function pluginDir(): string { return dirname(self::$pluginFile); } /** * @param list $missing */ public static function noticeMissing(array $missing): void { add_action('admin_notices', static function () use ($missing): void { printf( '

%s

', esc_html(sprintf( /* translators: %s: comma separated list of missing components */ __('WP-LIT-MCP ist inaktiv. Es fehlt: %s', 'wp-lit-mcp'), implode(', ', $missing) )) ); }); } } ``` `wp-lit-mcp.php`: ```php

' . esc_html__('WP-LIT-MCP: Abhängigkeiten fehlen. Bitte "composer install --no-dev" im Plugin-Ordner ausführen.', 'wp-lit-mcp') . '

'; }); return; } require_once $lit_mcp_autoload; add_action('plugins_loaded', static function (): void { \LIT\MCP\Plugin::boot(__FILE__); }); ``` - [ ] **Step 7: Tests und Lint** Run: `composer test && composer lint` Expected: 2 Tests PASS; phpcs ohne Fehler. - [ ] **Step 8: Review-Focus 2 prüfen (Plugin ohne vendor)** Run: ```bash cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && mv vendor /tmp/claude-1000/lit-vendor-bak && cd /home/janniclas/Projekte/WordPress && ddev wp plugin activate WP-LIT-MCP && ddev wp eval 'echo "alive";'; cd wp-content/plugins/WP-LIT-MCP && mv /tmp/claude-1000/lit-vendor-bak vendor ``` Expected: `Plugin 'WP-LIT-MCP' activated.` und `alive` (kein Fatal Error). - [ ] **Step 9: Commit** ```bash git add composer.json composer.lock phpunit.xml.dist phpcs.xml.dist .gitignore wp-lit-mcp.php src tests git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: plugin scaffold with dependency check Co-Authored-By: Claude Opus 5.5 (1M context) " ``` --- ### Task 2: Rollen-Gate (`Access`) **Files:** - Create: `src/Access.php`, `tests/Unit/AccessTest.php` - Modify: `src/Plugin.php` (in `boot()` registrieren) **Interfaces:** - Consumes: – - Produces: `LIT\MCP\Access::CAPABILITY = 'lit_mcp_use'`, `Access::OPTION = 'lit_mcp_allowed_roles'`, `Access::allowedRoles(): list`, `Access::register(): void`, `Access::grantCapability(array $allcaps, array $caps, array $args, \WP_User $user): array`, statisch `Access::currentUserMayUse(): bool`. - [ ] **Step 1: Failing Tests** `tests/Unit/AccessTest.php`: ```php once()->with(Access::OPTION, ['administrator'])->andReturn(['administrator']); self::assertSame(['administrator'], (new Access())->allowedRoles()); } public function testRoleListIsFilterable(): void { Functions\when('get_option')->justReturn(['administrator']); Filters\expectApplied('lit_mcp_allowed_roles')->once()->andReturn(['administrator', 'editor']); self::assertSame(['administrator', 'editor'], (new Access())->allowedRoles()); } public function testGrantsCapabilityToAllowedRole(): void { Functions\when('get_option')->justReturn(['editor']); $caps = (new Access())->grantCapability([], [Access::CAPABILITY], [], new WP_User(5, ['editor'])); self::assertTrue($caps[Access::CAPABILITY]); } public function testDoesNotGrantCapabilityToOtherRoles(): void { Functions\when('get_option')->justReturn(['administrator']); $caps = (new Access())->grantCapability([], [Access::CAPABILITY], [], new WP_User(7, ['subscriber'])); self::assertArrayNotHasKey(Access::CAPABILITY, $caps); } public function testCorruptOptionFallsBackToAdministrator(): void { Functions\when('get_option')->justReturn('not-an-array'); self::assertSame(['administrator'], (new Access())->allowedRoles()); } public function testRegisterRoutesAdapterMetaToolCapabilitiesToOwnCapability(): void { // Brain Monkey records add_filter() but never runs callbacks, so we // capture each registered callback and invoke it ourselves. foreach (['discover_abilities', 'get_ability_info', 'execute_ability'] as $tool) { Filters\expectAdded("mcp_adapter_{$tool}_capability") ->once() ->whenHappen(static function (callable $callback): void { self::assertSame(Access::CAPABILITY, $callback('read')); }); } $access = new Access(); $access->register(); self::assertNotFalse(has_filter('user_has_cap', [$access, 'grantCapability'])); } } ``` - [ ] **Step 2: Run – muss fehlschlagen** Run: `composer test -- --filter AccessTest` Expected: FAIL, Klasse `LIT\MCP\Access` nicht gefunden. - [ ] **Step 3: Implementierung** `src/Access.php`: ```php self::CAPABILITY); } } /** * @return list */ public function allowedRoles(): array { $roles = get_option(self::OPTION, self::DEFAULT_ROLES); if (! is_array($roles)) { $roles = self::DEFAULT_ROLES; } $roles = apply_filters('lit_mcp_allowed_roles', $roles); return array_values(array_filter($roles, 'is_string')); } /** * @param array $allcaps * @param list $caps * @param array $args * @return array */ public function grantCapability(array $allcaps, array $caps, array $args, WP_User $user): array { if (! in_array(self::CAPABILITY, $caps, true)) { return $allcaps; } if (array_intersect($user->roles, $this->allowedRoles()) !== []) { $allcaps[self::CAPABILITY] = true; } return $allcaps; } public static function currentUserMayUse(): bool { return current_user_can(self::CAPABILITY); } } ``` In `src/Plugin.php` am Ende von `boot()` (nach der Abhängigkeitsprüfung) ergänzen: ```php (new Access())->register(); ``` - [ ] **Step 4: Run – muss bestehen** Run: `composer test -- --filter AccessTest && composer lint` Expected: 6 Tests PASS, phpcs sauber. - [ ] **Step 5: Commit** ```bash git add src/Access.php src/Plugin.php tests/Unit/AccessTest.php git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: role gate with lit_mcp_use capability Co-Authored-By: Claude Opus 5.5 (1M context) " ``` --- ### Task 3: Ability-Infrastruktur (Interface, Registrar, Fehler, Integrationen) **Files:** - Create: `src/Abilities/Ability.php`, `src/Abilities/Errors.php`, `src/Abilities/AbilityRegistrar.php`, `src/Integrations/Integration.php`, `src/Integrations/IntegrationRegistry.php`, `tests/Unit/Abilities/AbilityRegistrarTest.php`, `tests/Unit/Integrations/IntegrationRegistryTest.php` **Interfaces:** - Consumes: `Access::currentUserMayUse()`, `Access::CAPABILITY` (Task 2). - Produces: - `interface Ability { name(): string; label(): string; description(): string; inputSchema(): array; outputSchema(): ?array; annotations(): array; isDirect(): bool; isAvailable(): bool; permission(array $input): bool|\WP_Error; execute(array $input): mixed; }` - `Errors::forbidden(string $message): \WP_Error` (Code `lit_forbidden`), `Errors::invalidInput(string $message, array $fields = []): \WP_Error` (Code `lit_validation_failed`), `Errors::notFound(string $message): \WP_Error` (Code `lit_not_found`), `Errors::internal(): \WP_Error` (Code `lit_internal_error`). - `interface Integration { id(): string; isAvailable(): bool; /** @return list */ abilities(): array; }` - `IntegrationRegistry::__construct(Integration ...$integrations)`, `available(): list`, `abilities(): list` (nur Abilities verfügbarer Integrationen, die selbst `isAvailable()` true liefern), `directAbilityNames(): list`. - `AbilityRegistrar::CATEGORY = 'lit-mcp'`, `__construct(IntegrationRegistry $registry)`, `register(): void`, `registerCategory(): void`, `registerAbilities(): void`, `argsFor(Ability $ability): array`. - [ ] **Step 1: Failing Tests** `tests/Unit/Integrations/IntegrationRegistryTest.php`: ```php ability('lit/a', true); $indirect = $this->ability('lit/b', false); $hidden = $this->ability('lit/c', true); $registry = new IntegrationRegistry( $this->integration('core', true, [$direct, $indirect]), $this->integration('divi', false, [$hidden]) ); self::assertSame(['core'], array_map(static fn ($i) => $i->id(), $registry->available())); self::assertSame([$direct, $indirect], $registry->abilities()); self::assertSame(['lit/a'], $registry->directAbilityNames()); } public function testUnavailableAbilitiesAreSkippedEvenInAvailableIntegration(): void { $working = $this->ability('lit/a', true); $broken = $this->ability('lit/needs-missing-plugin', true, false); $registry = new IntegrationRegistry($this->integration('core', true, [$working, $broken])); self::assertSame([$working], $registry->abilities()); self::assertSame(['lit/a'], $registry->directAbilityNames()); } private function ability(string $name, bool $direct, bool $available = true): Ability { $ability = Mockery::mock(Ability::class); $ability->allows('name')->andReturn($name); $ability->allows('isDirect')->andReturn($direct); $ability->allows('isAvailable')->andReturn($available); return $ability; } /** @param list $abilities */ private function integration(string $id, bool $available, array $abilities): Integration { $integration = Mockery::mock(Integration::class); $integration->allows('id')->andReturn($id); $integration->allows('isAvailable')->andReturn($available); $integration->allows('abilities')->andReturn($abilities); return $integration; } } ``` `tests/Unit/Abilities/AbilityRegistrarTest.php`: ```php allows('id')->andReturn('core'); $integration->allows('isAvailable')->andReturn(true); $integration->allows('abilities')->andReturn([$ability]); return new AbilityRegistrar(new IntegrationRegistry($integration)); } private function ability(): Mockery\MockInterface { $ability = Mockery::mock(Ability::class); $ability->allows('name')->andReturn('lit/test'); $ability->allows('label')->andReturn('Test'); $ability->allows('description')->andReturn('Test ability'); $ability->allows('inputSchema')->andReturn(['type' => 'object', 'properties' => []]); $ability->allows('outputSchema')->andReturn(['type' => 'object']); $ability->allows('annotations')->andReturn(['readonly' => true]); $ability->allows('isDirect')->andReturn(true); $ability->allows('isAvailable')->andReturn(true); return $ability; } public function testArgsExposeAbilityPubliclyToMcpWithAnnotations(): void { $args = $this->registrarFor($this->ability())->argsFor($this->ability()); self::assertSame('Test', $args['label']); self::assertSame(AbilityRegistrar::CATEGORY, $args['category']); self::assertSame(['type' => 'object'], $args['output_schema']); self::assertTrue($args['meta']['mcp']['public']); self::assertSame(['readonly' => true], $args['meta']['annotations']); self::assertFalse($args['meta']['show_in_rest']); } public function testPermissionDeniedWithoutLitCapability(): void { Functions\when('current_user_can')->justReturn(false); $ability = $this->ability(); $ability->shouldNotReceive('permission'); $result = ($this->registrarFor($ability)->argsFor($ability)['permission_callback'])([]); self::assertInstanceOf(WP_Error::class, $result); self::assertSame('lit_forbidden', $result->get_error_code()); } public function testPermissionDelegatesToAbilityWhenGatePasses(): void { Functions\when('current_user_can')->justReturn(true); $ability = $this->ability(); $ability->expects('permission')->with(['x' => 1])->andReturn(true); $result = ($this->registrarFor($ability)->argsFor($ability)['permission_callback'])(['x' => 1]); self::assertTrue($result); } public function testNullInputIsPassedAsEmptyArray(): void { $ability = $this->ability(); $ability->expects('execute')->with([])->andReturn(['ok' => true]); $result = ($this->registrarFor($ability)->argsFor($ability)['execute_callback'])(null); self::assertSame(['ok' => true], $result); } public function testExceptionBecomesNeutralInternalError(): void { // error_log() is a PHP internal and cannot be stubbed; redirect it to a temp file instead. $log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log'); $previous = ini_set('error_log', $log); $ability = $this->ability(); $ability->expects('execute')->andThrow(new RuntimeException('secret path /var/www/x.php')); try { $result = ($this->registrarFor($ability)->argsFor($ability)['execute_callback'])([]); } finally { ini_set('error_log', (string) $previous); } self::assertInstanceOf(WP_Error::class, $result); self::assertSame('lit_internal_error', $result->get_error_code()); self::assertStringNotContainsString('/var/www', $result->get_error_message()); self::assertStringContainsString('lit/test failed', (string) file_get_contents($log)); unlink($log); } public function testRegisterAbilitiesCallsWordPressForEachAbility(): void { $ability = $this->ability(); Functions\expect('wp_register_ability')->once()->with('lit/test', Mockery::type('array')); $this->registrarFor($ability)->registerAbilities(); } } ``` - [ ] **Step 2: Run – muss fehlschlagen** Run: `composer test -- --filter 'AbilityRegistrarTest|IntegrationRegistryTest'` Expected: FAIL, Klassen/Interfaces nicht gefunden. - [ ] **Step 3: Implementierung** `src/Abilities/Ability.php`: ```php JSON Schema of the input. */ public function inputSchema(): array; /** @return array|null JSON Schema of the output, null for image results. */ public function outputSchema(): ?array; /** @return array{readonly?: bool, destructive?: bool, idempotent?: bool} */ public function annotations(): array; /** Whether the ability is listed as its own MCP tool (otherwise only via meta-tools). */ public function isDirect(): bool; /** * Whether the ability can work on this installation (e.g. a required * plugin function or version exists). Unavailable abilities are not * registered at all, so they can never fail at call time. */ public function isAvailable(): bool; /** @param array $input */ public function permission(array $input): bool|WP_Error; /** @param array $input */ public function execute(array $input): mixed; } ``` `src/Abilities/Errors.php`: ```php 403]); } /** @param array $fields */ public static function invalidInput(string $message, array $fields = []): WP_Error { return new WP_Error('lit_validation_failed', $message, ['status' => 400, 'fields' => $fields]); } public static function notFound(string $message): WP_Error { return new WP_Error('lit_not_found', $message, ['status' => 404]); } public static function internal(): WP_Error { return new WP_Error( 'lit_internal_error', 'Interner Fehler bei der Ausführung. Details stehen im PHP-Error-Log der Website.', ['status' => 500] ); } } ``` `src/Integrations/Integration.php`: ```php */ public function abilities(): array; } ``` `src/Integrations/IntegrationRegistry.php`: ```php */ private array $integrations; public function __construct(Integration ...$integrations) { $this->integrations = array_values($integrations); } /** @return list */ public function available(): array { return array_values(array_filter( $this->integrations, static fn (Integration $integration): bool => $integration->isAvailable() )); } /** @return list */ public function abilities(): array { $abilities = []; foreach ($this->available() as $integration) { foreach ($integration->abilities() as $ability) { if ($ability->isAvailable()) { $abilities[] = $ability; } } } return $abilities; } /** @return list */ public function directAbilityNames(): array { $names = []; foreach ($this->abilities() as $ability) { if ($ability->isDirect()) { $names[] = $ability->name(); } } return $names; } } ``` `src/Abilities/AbilityRegistrar.php`: ```php 'LIT MCP', 'description' => 'Website-Pflege für WordPress, Divi und Pods über MCP.', ]); } public function registerAbilities(): void { foreach ($this->registry->abilities() as $ability) { wp_register_ability($ability->name(), $this->argsFor($ability)); } } /** @return array */ public function argsFor(Ability $ability): array { $args = [ 'label' => $ability->label(), 'description' => $ability->description(), 'category' => self::CATEGORY, 'input_schema' => $ability->inputSchema(), 'permission_callback' => static function (mixed $input = null) use ($ability): bool|WP_Error { if (! Access::currentUserMayUse()) { return Errors::forbidden('Dein Benutzerkonto ist für LIT MCP nicht freigegeben.'); } return $ability->permission(is_array($input) ? $input : []); }, 'execute_callback' => static function (mixed $input = null) use ($ability): mixed { try { return $ability->execute(is_array($input) ? $input : []); } catch (Throwable $e) { error_log(sprintf('[wp-lit-mcp] %s failed: %s', $ability->name(), $e)); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log return Errors::internal(); } }, 'meta' => [ 'annotations' => $ability->annotations(), 'show_in_rest' => false, 'mcp' => ['public' => true, 'type' => 'tool'], ], ]; $output = $ability->outputSchema(); if ($output !== null) { $args['output_schema'] = $output; } return $args; } } ``` - [ ] **Step 4: Run – muss bestehen** Run: `composer test && composer lint` Expected: alle Tests PASS, phpcs sauber. - [ ] **Step 5: Commit** ```bash git add src/Abilities src/Integrations tests/Unit/Abilities tests/Unit/Integrations git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: ability and integration infrastructure Co-Authored-By: Claude Opus 5.5 (1M context) " ``` --- ### Task 4: Guides und Server-Instructions **Files:** - Create: `src/Guides/GuideRepository.php`, `src/Guides/InstructionsBuilder.php`, `src/Integrations/Core/GuideAbility.php`, `docs/guides/overview.md`, `tests/Unit/Guides/GuideRepositoryTest.php`, `tests/Unit/Guides/InstructionsBuilderTest.php`, `tests/Unit/Integrations/Core/GuideAbilityTest.php`, `tests/Fixtures/guides/overview.md`, `tests/Fixtures/guides/divi-shortform.md` **Interfaces:** - Consumes: `Ability`, `Errors` (Task 3), `IntegrationRegistry::available()` (Task 3). - Produces: - `GuideRepository::__construct(string $directory)`, `topics(): list` (sortiert), `get(string $topic): ?string`. - `InstructionsBuilder::__construct(IntegrationRegistry $registry)`, `build(): string`. - `GuideAbility::__construct(GuideRepository $guides)`; Name `lit/guide`; Output `{topic: string, topics: list, content: string}`. - [ ] **Step 1: Fixtures anlegen** `tests/Fixtures/guides/overview.md`: ```markdown # Übersicht Testinhalt Übersicht. ``` `tests/Fixtures/guides/divi-shortform.md`: ```markdown # Divi-Kurzform Testinhalt Kurzform. ``` - [ ] **Step 2: Failing Tests** `tests/Unit/Guides/GuideRepositoryTest.php`: ```php guides = new GuideRepository(dirname(__DIR__, 2) . '/Fixtures/guides'); } public function testListsTopicsSortedWithoutExtension(): void { self::assertSame(['divi-shortform', 'overview'], $this->guides->topics()); } public function testReturnsMarkdownOfKnownTopic(): void { self::assertStringContainsString('Testinhalt Kurzform', (string) $this->guides->get('divi-shortform')); } /** * @dataProvider maliciousTopics */ public function testRejectsAnythingThatIsNotAPlainTopicName(string $topic): void { self::assertNull($this->guides->get($topic)); } /** @return array */ public static function maliciousTopics(): array { return [ 'traversal' => ['../../wp-config'], 'with extension' => ['overview.md'], 'empty' => [''], 'absolute' => ['/etc/passwd'], 'unknown' => ['does-not-exist'], 'uppercase' => ['OVERVIEW'], ]; } public function testMissingDirectoryYieldsNoTopics(): void { self::assertSame([], (new GuideRepository('/nonexistent/dir'))->topics()); } } ``` `tests/Unit/Guides/InstructionsBuilderTest.php`: ```php allows('id')->andReturn('core'); $core->allows('isAvailable')->andReturn(true); $text = (new InstructionsBuilder(new IntegrationRegistry($core)))->build(); self::assertStringContainsString('Aktive Integrationen: core', $text); self::assertStringContainsString('lit-guide', $text); self::assertStringContainsString('Status', $text); self::assertLessThan(2000, strlen($text), 'Instructions must stay short – they are loaded in every conversation.'); } } ``` `tests/Unit/Integrations/Core/GuideAbilityTest.php`: ```php ability = new GuideAbility(new GuideRepository(dirname(__DIR__, 3) . '/Fixtures/guides')); } public function testWithoutTopicReturnsOverviewAndTopicList(): void { $result = $this->ability->execute([]); self::assertSame('overview', $result['topic']); self::assertSame(['divi-shortform', 'overview'], $result['topics']); self::assertStringContainsString('Testinhalt Übersicht', $result['content']); } public function testReturnsRequestedTopic(): void { $result = $this->ability->execute(['topic' => 'divi-shortform']); self::assertStringContainsString('Testinhalt Kurzform', $result['content']); } public function testUnknownTopicIsNotFoundErrorListingTopics(): void { $result = $this->ability->execute(['topic' => '../../wp-config']); self::assertInstanceOf(WP_Error::class, $result); self::assertSame('lit_not_found', $result->get_error_code()); self::assertStringContainsString('divi-shortform', $result->get_error_message()); } public function testIsDirectReadOnlyTool(): void { self::assertSame('lit/guide', $this->ability->name()); self::assertTrue($this->ability->isDirect()); self::assertTrue($this->ability->annotations()['readonly']); } } ``` - [ ] **Step 3: Run – muss fehlschlagen** Run: `composer test -- --filter 'GuideRepositoryTest|InstructionsBuilderTest|GuideAbilityTest'` Expected: FAIL, Klassen nicht gefunden. - [ ] **Step 4: Implementierung** `src/Guides/GuideRepository.php`: ```php */ public function topics(): array { $files = glob($this->directory . '/*.md'); if ($files === false) { return []; } $topics = []; foreach ($files as $file) { $topic = basename($file, '.md'); if (preg_match(self::TOPIC_PATTERN, $topic) === 1) { $topics[] = $topic; } } sort($topics); return $topics; } public function get(string $topic): ?string { if (preg_match(self::TOPIC_PATTERN, $topic) !== 1 || ! in_array($topic, $this->topics(), true)) { return null; } $content = file_get_contents($this->directory . '/' . $topic . '.md'); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents return $content === false ? null : $content; } } ``` `src/Guides/InstructionsBuilder.php`: ```php $integration->id(), $this->registry->available() )); return implode("\n", [ 'LIT MCP pflegt diese WordPress-Website (Inhalte, Medien, Divi 5, Pods).', 'Aktive Integrationen: ' . $active . '.', 'Regeln:', '- Vor dem ersten Arbeitsschritt lit-guide (ohne Topic) aufrufen; Details je Thema über lit-guide mit topic.', '- Änderungen erzeugen native Divi-Module; Variablen und Presets vor festen Werten; Code-Modul nur, wenn kein natives Modul passt.', '- Den Status (Entwurf/veröffentlicht) nie ändern, außer der Nutzer verlangt es ausdrücklich.', '- Schreib-Tools verlangen den aktuellen rev-Wert aus dem letzten Lesevorgang.', '- Fehler enthalten einen hint – ihm folgen, statt blind zu wiederholen.', ]); } } ``` `src/Integrations/Core/GuideAbility.php`: ```php 'object', 'properties' => [ 'topic' => [ 'type' => 'string', 'description' => 'Thema, z. B. "overview" oder "divi-shortform".', ], ], 'additionalProperties' => false, ]; } public function outputSchema(): ?array { return [ 'type' => 'object', 'properties' => [ 'topic' => ['type' => 'string'], 'topics' => ['type' => 'array', 'items' => ['type' => 'string']], 'content' => ['type' => 'string'], ], 'required' => ['topic', 'topics', 'content'], ]; } public function annotations(): array { return ['readonly' => true, 'destructive' => false, 'idempotent' => true]; } public function isDirect(): bool { return true; } public function isAvailable(): bool { return true; } public function permission(array $input): bool|WP_Error { return true; } public function execute(array $input): mixed { $topic = isset($input['topic']) && is_string($input['topic']) && $input['topic'] !== '' ? $input['topic'] : self::DEFAULT_TOPIC; $topics = $this->guides->topics(); $content = $this->guides->get($topic); if ($content === null) { return Errors::notFound(sprintf( 'Unbekanntes Thema. Verfügbar: %s', implode(', ', $topics) )); } return ['topic' => $topic, 'topics' => $topics, 'content' => $content]; } } ``` `docs/guides/overview.md`: ```markdown # LIT MCP – Übersicht Dieser Server pflegt eine WordPress-Website. Alle Änderungen laufen mit den Rechten des verbundenen WordPress-Benutzers. ## Grundregeln 1. **Divi-nativ:** Inhalte werden als echte Divi-5-Module erzeugt, damit sie im Visual Builder weiter bearbeitet werden können. Das Code-Modul nur verwenden, wenn kein natives Modul passt. 2. **Designsystem nutzen:** Globale Farben, Variablen und Presets der Website vor festen Werten verwenden. 3. **Status respektieren:** Entwürfe bleiben Entwürfe, veröffentlichte Inhalte bleiben veröffentlicht. Veröffentlichen nur auf ausdrücklichen Wunsch. 4. **Lesen vor Schreiben:** Vor Änderungen den aktuellen Stand lesen; Schreib-Tools verlangen den `rev` des letzten Lesevorgangs. ## Verfügbare Werkzeuge (Etappe 0) - `lit-guide` – diese Anleitungen. Weitere Themen: siehe Feld `topics`. - `lit-site-status` – Überblick über Website, aktive Integrationen und eigene Rechte. Weitere Werkzeuge kommen mit den nächsten Ausbaustufen hinzu. ``` - [ ] **Step 5: Run – muss bestehen** Run: `composer test && composer lint` Expected: alle Tests PASS, phpcs sauber. - [ ] **Step 6: Commit** ```bash git add src/Guides src/Integrations/Core/GuideAbility.php docs/guides tests/Unit/Guides tests/Unit/Integrations/Core tests/Fixtures git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: guide tool and server instructions Co-Authored-By: Claude Opus 5.5 (1M context) " ``` --- ### Task 5: Test-Tool `lit/site-status` und Core-Integration **Files:** - Create: `src/Integrations/Core/EnvironmentProbe.php`, `src/Integrations/Core/SiteStatusAbility.php`, `src/Integrations/Core/CoreIntegration.php`, `tests/Unit/Integrations/Core/SiteStatusAbilityTest.php`, `tests/Unit/Integrations/Core/CoreIntegrationTest.php` **Interfaces:** - Consumes: `Ability`, `Integration` (Task 3), `GuideAbility`, `GuideRepository` (Task 4), `Access` (Task 2). - Produces: - `EnvironmentProbe::components(): list` für `divi`, `pods`, `the-events-calendar`. - `SiteStatusAbility::__construct(EnvironmentProbe $probe, Access $access)`; Name `lit/site-status`; Output `{site: {name, url}, wordpress: string, user: {id, roles}, allowed_roles: list, components: list<…>}`. - `CoreIntegration::__construct(string $guidesDirectory)`; `id()` = `core`; immer verfügbar; liefert `[GuideAbility, SiteStatusAbility]`. - [ ] **Step 1: Failing Tests** `tests/Unit/Integrations/Core/SiteStatusAbilityTest.php`: ```php alias(static fn (string $key) => ['name' => 'Kunde', 'version' => '7.0.3'][$key]); Functions\when('home_url')->justReturn('https://kunde.de'); Functions\when('wp_get_current_user')->justReturn(new WP_User(3, ['editor'])); Functions\when('get_option')->justReturn(['administrator', 'editor']); // EnvironmentProbe is deliberately non-final so it can be mocked; Access is final, so use the real one. $probe = Mockery::mock(EnvironmentProbe::class); $probe->allows('components')->andReturn([['id' => 'divi', 'active' => true, 'version' => '5.2.1']]); $result = (new SiteStatusAbility($probe, new Access()))->execute([]); self::assertSame(['name' => 'Kunde', 'url' => 'https://kunde.de'], $result['site']); self::assertSame('7.0.3', $result['wordpress']); self::assertSame(['id' => 3, 'roles' => ['editor']], $result['user']); self::assertSame(['administrator', 'editor'], $result['allowed_roles']); self::assertSame('divi', $result['components'][0]['id']); } } ``` `tests/Unit/Integrations/Core/CoreIntegrationTest.php`: ```php id()); self::assertTrue($core->isAvailable()); self::assertSame( ['lit/guide', 'lit/site-status'], array_map(static fn ($a) => $a->name(), $core->abilities()) ); } } ``` - [ ] **Step 2: Run – muss fehlschlagen** Run: `composer test -- --filter 'SiteStatusAbilityTest|CoreIntegrationTest'` Expected: FAIL, Klassen nicht gefunden. - [ ] **Step 3: Implementierung** `src/Integrations/Core/EnvironmentProbe.php`: ```php */ public function components(): array { $theme = wp_get_theme(); $isDivi = $theme->get_template() === 'Divi'; $parent = $theme->parent(); $diviVersion = $isDivi ? (string) ($parent ? $parent->get('Version') : $theme->get('Version')) : null; $tecActive = class_exists('Tribe__Events__Main'); return [ ['id' => 'divi', 'active' => $isDivi, 'version' => $diviVersion], ['id' => 'pods', 'active' => defined('PODS_VERSION'), 'version' => defined('PODS_VERSION') ? (string) PODS_VERSION : null], [ 'id' => 'the-events-calendar', 'active' => $tecActive, 'version' => $tecActive ? (string) constant('Tribe__Events__Main::VERSION') : null, ], ]; } } ``` `src/Integrations/Core/SiteStatusAbility.php`: ```php 'object', 'properties' => [], 'additionalProperties' => false]; } public function outputSchema(): ?array { return [ 'type' => 'object', 'properties' => [ 'site' => ['type' => 'object'], 'wordpress' => ['type' => 'string'], 'user' => ['type' => 'object'], 'allowed_roles' => ['type' => 'array', 'items' => ['type' => 'string']], 'components' => ['type' => 'array'], ], 'required' => ['site', 'wordpress', 'user', 'allowed_roles', 'components'], ]; } public function annotations(): array { return ['readonly' => true, 'destructive' => false, 'idempotent' => true]; } public function isDirect(): bool { return true; } public function isAvailable(): bool { return true; } public function permission(array $input): bool|WP_Error { return true; } public function execute(array $input): mixed { $user = wp_get_current_user(); return [ 'site' => ['name' => get_bloginfo('name'), 'url' => home_url()], 'wordpress' => get_bloginfo('version'), 'user' => ['id' => $user->ID, 'roles' => array_values($user->roles)], 'allowed_roles' => $this->access->allowedRoles(), 'components' => $this->probe->components(), ]; } } ``` `src/Integrations/Core/CoreIntegration.php`: ```php guidesDirectory)), new SiteStatusAbility(new EnvironmentProbe(), new Access()), ]; } } ``` - [ ] **Step 4: Run – muss bestehen** Run: `composer test && composer lint` Expected: alle Tests PASS, phpcs sauber. - [ ] **Step 5: Commit** ```bash git add src/Integrations/Core tests/Unit/Integrations/Core git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: site-status test tool and core integration Co-Authored-By: Claude Opus 5.5 (1M context) " ``` --- ### Task 6: Server-Registrierung und vollständiger Boot **Files:** - Create: `src/Server/ServerRegistrar.php`, `tests/Unit/Server/ServerRegistrarTest.php` - Modify: `src/Plugin.php` (vollständiger Boot) **Interfaces:** - Consumes: `IntegrationRegistry::directAbilityNames()`, `AbilityRegistrar` (Task 3), `InstructionsBuilder::build()` (Task 4), `CoreIntegration` (Task 5), `Access` (Task 2), `Dependencies` (Task 1). - Produces: - `ServerRegistrar::SERVER_ID = 'mcp-oauth-server'`, `NAMESPACE = 'mcp'`, `ROUTE = 'mcp-oauth-server'`, `META_TOOLS = ['mcp-adapter/discover-abilities', 'mcp-adapter/get-ability-info', 'mcp-adapter/execute-ability']`. - `ServerRegistrar::__construct(IntegrationRegistry $registry, InstructionsBuilder $instructions, ?callable $oauthEnabled = null)`, `register(): void`, `serverArguments(): ?array` (null = nicht registrieren), `createServer(): void`. - [ ] **Step 1: Failing Tests** `tests/Unit/Server/ServerRegistrarTest.php`: ```php allows('name')->andReturn('lit/guide'); $direct->allows('isDirect')->andReturn(true); $direct->allows('isAvailable')->andReturn(true); $indirect = Mockery::mock(Ability::class); $indirect->allows('name')->andReturn('lit/rare-tool'); $indirect->allows('isDirect')->andReturn(false); $indirect->allows('isAvailable')->andReturn(true); $core = Mockery::mock(Integration::class); $core->allows('id')->andReturn('core'); $core->allows('isAvailable')->andReturn(true); $core->allows('abilities')->andReturn([$direct, $indirect]); return new IntegrationRegistry($core); } private function instructions(): InstructionsBuilder { // InstructionsBuilder is final (not mockable) and pure – use the real one. return new InstructionsBuilder($this->registry()); } public function testServerUsesOauthRouteDirectToolsPlusMetaToolsAndInstructions(): void { $args = (new ServerRegistrar($this->registry(), $this->instructions(), static fn () => true))->serverArguments(); self::assertSame('mcp-oauth-server', $args['id']); self::assertSame('mcp', $args['namespace']); self::assertSame('mcp-oauth-server', $args['route']); self::assertStringContainsString('Aktive Integrationen: core', $args['description']); self::assertSame( ['lit/guide', 'mcp-adapter/discover-abilities', 'mcp-adapter/get-ability-info', 'mcp-adapter/execute-ability'], $args['tools'] ); self::assertNotContains('lit/rare-tool', $args['tools']); } public function testNoServerWhenOauthIsDisabled(): void { $registrar = new ServerRegistrar($this->registry(), $this->instructions(), static fn () => false); self::assertNull($registrar->serverArguments()); } public function testCreateServerOutsideAdapterInitDoesNothing(): void { Functions\when('doing_action')->justReturn(false); $oauthChecked = false; $registrar = new ServerRegistrar( $this->registry(), $this->instructions(), static function () use (&$oauthChecked): bool { $oauthChecked = true; return true; } ); $registrar->createServer(); self::assertFalse($oauthChecked, 'createServer must bail out before touching the adapter'); } } ``` - [ ] **Step 2: Run – muss fehlschlagen** Run: `composer test -- --filter ServerRegistrarTest` Expected: FAIL, Klasse nicht gefunden. - [ ] **Step 3: Implementierung** `src/Server/ServerRegistrar.php`: ```php oauthEnabled = $oauthEnabled ?? static fn (): bool => (bool) apply_filters('wpmedia_mcp_oauth_server_enabled', true); } public function register(): void { add_action('mcp_adapter_init', [$this, 'createServer'], 5); } /** @return array|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' => '0.1.0', 'tools' => array_merge($this->registry->directAbilityNames(), 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; } $result = McpAdapter::instance()->create_server( $args['id'], $args['namespace'], $args['route'], $args['name'], $args['description'], $args['version'], [OAuthHttpTransport::class], ErrorLogMcpErrorHandler::class, null, $args['tools'] ); if (is_wp_error($result)) { error_log('[wp-lit-mcp] MCP server registration failed: ' . $result->get_error_message()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log } } } ``` `src/Plugin.php` – `boot()` vollständig ersetzen durch: ```php public static function boot(string $pluginFile): void { self::$pluginFile = $pluginFile; $missing = (new Dependencies())->missing(); if ($missing !== []) { self::noticeMissing($missing); return; } (new Access())->register(); $registry = new IntegrationRegistry( new CoreIntegration(self::pluginDir() . '/docs/guides') ); (new AbilityRegistrar($registry))->register(); (new ServerRegistrar($registry, new InstructionsBuilder($registry)))->register(); \WPMedia\MCP\OAuth\Bootstrap::instance(); } ``` und die `use`-Zeilen ergänzen: ```php use LIT\MCP\Abilities\AbilityRegistrar; use LIT\MCP\Guides\InstructionsBuilder; use LIT\MCP\Integrations\Core\CoreIntegration; use LIT\MCP\Integrations\IntegrationRegistry; use LIT\MCP\Server\ServerRegistrar; ``` Hinweis: `Plugin::boot()` läuft auf `plugins_loaded` (siehe `wp-lit-mcp.php`) – das ist der von mcp-oauth empfohlene Zeitpunkt (spätestens vor `rest_api_init`@15). - [ ] **Step 4: Run – muss bestehen** Run: `composer test && composer lint` Expected: alle Tests PASS, phpcs sauber. - [ ] **Step 5: Smoke-Test in WordPress** Run: ```bash cd /home/janniclas/Projekte/WordPress && ddev wp plugin activate WP-LIT-MCP && ddev wp eval ' do_action("rest_api_init"); $s = \WP\MCP\Core\McpAdapter::instance()->get_server("mcp-oauth-server"); echo $s ? "server ok: " . implode(",", array_keys($s->get_tools())) : "NO SERVER";' ``` Expected: `server ok: ` gefolgt von Tool-Namen, die `lit-guide` und `lit-site-status` enthalten. Falls `get_server`/`get_tools` in Adapter 0.5 anders heißen: in `vendor/wordpress/mcp-adapter/includes/Core/McpAdapter.php` und `McpServer.php` die Getter nachsehen und den Befehl anpassen – das Ergebnis muss dasselbe zeigen. - [ ] **Step 6: Commit** ```bash git add src/Server src/Plugin.php tests/Unit/Server git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: register OAuth MCP server with direct tools Co-Authored-By: Claude Opus 5.5 (1M context) " ``` --- ### Task 7: Ende-zu-Ende-Test über HTTP (Token, Rollen-Gate, Scope) **Files:** - Create: `tests/Integration/mint-token.php`, `tests/Integration/mcp-smoke.sh` **Interfaces:** - Consumes: laufendes Plugin (Task 6), mcp-oauth-Klassen `WPMedia\MCP\OAuth\Auth\JWT::encode(array $payload, string $secret): string`, `WPMedia\MCP\OAuth\Auth\SecretManager::get_secret(): string`. - Produces: `bash tests/Integration/mcp-smoke.sh` – Exit-Code 0 bei Erfolg; wird in Etappe 1+ wiederverwendet. - [ ] **Step 1: Token-Skript** `tests/Integration/mint-token.php` (läuft über `wp eval-file`, Argument = Benutzer-Login): ```php */ declare(strict_types=1); use WPMedia\MCP\OAuth\Auth\JWT; use WPMedia\MCP\OAuth\Auth\SecretManager; $login = $args[0] ?? ''; $user = get_user_by('login', $login); if (! $user) { fwrite(STDERR, "unknown user {$login}\n"); exit(1); } [, $item] = WP_Application_Passwords::create_new_application_password( $user->ID, ['name' => 'lit-mcp-integration-test', 'app_id' => 'lit-mcp-integration-test'] ); $now = time(); echo JWT::encode([ 'iss' => home_url(), 'aud' => get_rest_url(null, 'mcp/mcp-oauth-server'), 'sub' => (string) $user->ID, 'app_pass_id' => $item['uuid'], 'client_id' => 'lit-mcp-integration-test', 'scope' => 'mcp', 'iat' => $now, 'exp' => $now + 600, ], SecretManager::get_secret()); ``` - [ ] **Step 2: Smoke-Skript** `tests/Integration/mcp-smoke.sh`: ```bash #!/usr/bin/env bash # End-to-end checks of the OAuth-protected MCP endpoint. # Runs INSIDE the DDEV web container (wp-cli and curl available there): # cd /home/janniclas/Projekte/WordPress && ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/mcp-smoke.sh set -euo pipefail PLUGIN=wp-content/plugins/WP-LIT-MCP ENDPOINT=http://localhost/wp-json/mcp/mcp-oauth-server cd /var/www/html fail() { echo "FAIL: $*"; exit 1; } pass() { echo "ok - $*"; } mcp() { # $1 token, $2 json body, $3 session id (optional) -> prints headers+body local session_header=() [ -n "${3:-}" ] && session_header=(-H "Mcp-Session-Id: $3") curl -s -i -X POST "$ENDPOINT" \ -H "Authorization: Bearer $1" \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ "${session_header[@]}" -d "$2" } # 1. Discovery documents curl -s http://localhost/.well-known/oauth-protected-resource | grep -q '"resource"' \ || fail "protected-resource metadata missing" pass "RFC 9728 protected-resource metadata served" curl -s http://localhost/.well-known/oauth-authorization-server | grep -q 'client_id_metadata_document_supported' \ || fail "authorization-server metadata missing CIMD flag" pass "RFC 8414 authorization-server metadata advertises CIMD" # 2. Unauthenticated request -> 401 with WWW-Authenticate UNAUTH=$(curl -s -i -X POST "$ENDPOINT" -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}') echo "$UNAUTH" | head -1 | grep -q ' 401' || fail "expected 401 without token" echo "$UNAUTH" | grep -qi '^www-authenticate: Bearer' || fail "missing WWW-Authenticate header" pass "401 + WWW-Authenticate without token" # 3. Administrator: initialize, tools/list, tools/call ADMIN_LOGIN=$(wp user list --role=administrator --field=user_login | head -1) TOKEN=$(wp eval-file "$PLUGIN/tests/Integration/mint-token.php" "$ADMIN_LOGIN") INIT=$(mcp "$TOKEN" '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}') SESSION=$(echo "$INIT" | grep -i '^mcp-session-id:' | awk '{print $2}' | tr -d '\r') echo "$INIT" | grep -q 'LIT MCP pflegt' || fail "instructions missing in initialize result" pass "initialize returns LIT instructions (session: ${SESSION:-none})" LIST=$(mcp "$TOKEN" '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' "$SESSION") for tool in lit-guide lit-site-status mcp-adapter-execute-ability; do echo "$LIST" | grep -q "\"$tool\"" || fail "tools/list lacks $tool" done pass "tools/list contains direct tools and meta-tools" CALL=$(mcp "$TOKEN" '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"lit-site-status","arguments":{}}}' "$SESSION") echo "$CALL" | grep -q '"isError":false' || fail "lit-site-status failed: $CALL" echo "$CALL" | grep -q 'the-events-calendar' || fail "site-status lacks components" pass "tools/call lit-site-status works" # 4. Token must not authenticate other REST routes (mcp-oauth issue #62) ME=$(curl -s -o /dev/null -w '%{http_code}' http://localhost/wp-json/wp/v2/users/me -H "Authorization: Bearer $TOKEN") [ "$ME" = "401" ] || fail "MCP token authenticates /wp/v2/users/me (HTTP $ME) – scope filter needed" pass "MCP token is rejected on /wp/v2/users/me" # 5. Role gate: subscriber has a valid token but must not use tools wp user create lit-smoke-sub lit-smoke-sub@example.invalid --role=subscriber >/dev/null trap 'wp user delete lit-smoke-sub --yes >/dev/null 2>&1 || true' EXIT SUB_TOKEN=$(wp eval-file "$PLUGIN/tests/Integration/mint-token.php" lit-smoke-sub) SUB_INIT=$(mcp "$SUB_TOKEN" '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}') SUB_SESSION=$(echo "$SUB_INIT" | grep -i '^mcp-session-id:' | awk '{print $2}' | tr -d '\r') SUB_CALL=$(mcp "$SUB_TOKEN" '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"lit-site-status","arguments":{}}}' "$SUB_SESSION") echo "$SUB_CALL" | grep -q '"isError":true\|"error"' || fail "subscriber could call lit-site-status: $SUB_CALL" SUB_META=$(mcp "$SUB_TOKEN" '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"mcp-adapter-execute-ability","arguments":{"ability_name":"lit/site-status","parameters":{}}}}' "$SUB_SESSION") echo "$SUB_META" | grep -q '"isError":true\|"error"' || fail "subscriber could use execute-ability: $SUB_META" pass "role gate blocks subscriber (direct tool and meta-tool)" echo "ALL CHECKS PASSED" ``` - [ ] **Step 3: Ausführen** Run: `cd /home/janniclas/Projekte/WordPress && ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/mcp-smoke.sh` Expected: alle Zeilen `ok - …`, am Ende `ALL CHECKS PASSED`. Bei Abweichungen: - `.well-known` 404 → `ddev wp rewrite flush` und erneut; bleibt es 404, im Ergebnisprotokoll (Task 9) festhalten. - Tool-Namen anders als `lit-guide` → tatsächliche Namen aus der `tools/list`-Antwort übernehmen und Skript sowie `InstructionsBuilder`-Text angleichen (Spec: `lit--`). - Check 4 schlägt fehl → neue Task vor Task 8 einfügen: Filter auf `rest_authentication_errors`, der Requests mit `Authorization: Bearer` außerhalb der Route `/mcp/mcp-oauth-server` abweist (inkl. Unit-Test) – Spec §4.2. - Argumentnamen des Meta-Tools `execute-ability` in Adapter 0.5 anders → aus `vendor/wordpress/mcp-adapter/includes/Abilities/ExecuteAbilityAbility.php` (`input_schema`) übernehmen. - [ ] **Step 4: Commit** ```bash git add tests/Integration/mint-token.php tests/Integration/mcp-smoke.sh git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "test: end-to-end MCP smoke test against DDEV Co-Authored-By: Claude Opus 5.5 (1M context) " ``` --- ### Task 8: The-Events-Calendar-Probe (generisches Anlegen) **Files:** - Create: `tests/Integration/tec-probe.php` **Interfaces:** - Consumes: DDEV-Installation. - Produces: Befund „generisch anlegen funktioniert: ja/nein, fehlende abgeleitete Felder: …“ für Task 9. - [ ] **Step 1: Probe-Skript** `tests/Integration/tec-probe.php`: ```php 'tribe_events', 'post_status' => 'publish', 'post_title' => 'LIT Probe Event (wegwerfen)', ]); update_post_meta($id, '_EventStartDate', $start); update_post_meta($id, '_EventEndDate', $end); $derived = []; foreach (['_EventStartDateUTC', '_EventEndDateUTC', '_EventDuration', '_EventTimezone'] as $key) { $derived[$key] = get_post_meta($id, $key, true); } $found = tribe_get_events([ 'start_date' => gmdate('Y-m-d', strtotime('+29 days')), 'end_date' => gmdate('Y-m-d', strtotime('+31 days')), 'fields' => 'ids', ]); echo wp_json_encode([ 'event_id' => $id, 'found_by_tribe_get_events' => in_array($id, (array) $found, true), 'derived_meta' => $derived, ], JSON_PRETTY_PRINT) . "\n"; wp_delete_post($id, true); ``` - [ ] **Step 2: TEC lokal installieren, Probe ausführen, aufräumen** Run: ```bash cd /home/janniclas/Projekte/WordPress \ && ddev wp plugin install the-events-calendar --activate \ && ddev wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/tec-probe.php \ ; ddev wp plugin deactivate the-events-calendar && ddev wp plugin delete the-events-calendar ``` Expected: JSON mit `found_by_tribe_get_events` (true/false) und den abgeleiteten Meta-Werten. Beide Ergebnisse sind gültig – es ist ein Befund, kein Pass/Fail. Ausgabe für Task 9 kopieren. - [ ] **Step 3: Commit** ```bash git add tests/Integration/tec-probe.php git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "test: probe generic event creation for The Events Calendar Co-Authored-By: Claude Opus 5.5 (1M context) " ``` --- ### Task 9: Realer claude.ai-Test, MCP Inspector, Entscheidung (manuell, mit Jan-Niclas) **Files:** - Create: `docs/superpowers/notes/2026-09-25-etappe-0-ergebnisse.md` **Interfaces:** - Consumes: Tasks 1–8. - Produces: dokumentierte Entscheidungen für Etappe 1 (Anbindungsschicht; Events-Erweiterungspunkt; ggf. Scope-Filter). Dieser Task erfordert Jan-Niclas (Browser, claude.ai-Konto, öffentliche HTTPS-URL). Der ausführende Agent bereitet vor und dokumentiert, führt aber keine Aktionen im claude.ai-Konto aus. - [ ] **Step 1: Öffentliche HTTPS-URL bereitstellen** Jan-Niclas führt aus: `cd /home/janniclas/Projekte/WordPress && ddev share` (oder nutzt eine Staging-Seite mit installiertem Plugin). Die ausgegebene `https://…`-URL notieren. Hinweis: Bei `ddev share` muss WordPress die öffentliche URL kennen; falls Weiterleitungen auf `wordpress.ddev.site` auftreten, stattdessen Staging verwenden und das im Ergebnisprotokoll vermerken. - [ ] **Step 2: MCP Inspector** Run (lokal, Jan-Niclas im Browser): `npx @modelcontextprotocol/inspector` Im Inspector: Transport „Streamable HTTP“, URL `https://<öffentliche-url>/wp-json/mcp/mcp-oauth-server`, OAuth-Flow durchlaufen. Prüfen und notieren: Verbindung erfolgreich; ausgehandelte Protokollversion; `lit-guide` und `lit-site-status` sichtbar mit Schema; Aufruf beider Tools erfolgreich; Annotations (`readOnlyHint`) vorhanden. - [ ] **Step 3: claude.ai-Connector** In claude.ai: Einstellungen → Connectors → „Add custom connector“ → URL `https://<öffentliche-url>/wp-json/mcp/mcp-oauth-server`. Anmeldung/Zustimmung auf der WordPress-Seite durchlaufen. In einem Chat: „Rufe lit-site-status auf“ und „Zeig mir die LIT-Anleitung“. Notieren: Verbindung ok? Consent-Screen verständlich? Tools werden ohne Umweg über Meta-Tools genutzt? Werden die Server-Instructions befolgt (ruft Claude `lit-guide` von sich aus)? - [ ] **Step 4: Autorisierungs-Details prüfen** Im Browser-Netzwerk-Tab (oder Inspector-Log) beim OAuth-Flow nachsehen: - enthält die Weiterleitung nach der Zustimmung einen `iss`-Parameter (RFC 9207)? - wird der `resource`-Parameter (RFC 8707) akzeptiert? Fehlende Punkte als Upstream-Issue bei `wp-media/mcp-oauth` notieren (nicht selbst öffnen, Jan-Niclas entscheidet). - [ ] **Step 5: Ergebnisprotokoll schreiben** `docs/superpowers/notes/2026-09-25-etappe-0-ergebnisse.md` mit genau diesen Abschnitten füllen: ```markdown # Etappe 0 – Ergebnisse ## Automatische Tests - Unit: Tests, Ergebnis - mcp-smoke.sh: ## The Events Calendar - found_by_tribe_get_events: - abgeleitete Felder: - Folgerung: Erweiterungspunkt lit_mcp_after_save mit TEC-Nachberechnung nötig: ## MCP Inspector - Protokollversion: … - Tools/Schemas/Annotations: … ## claude.ai - Verbindung: … - Consent/Rollen-Gate: … - Nutzung direkter Tools / Instructions: … ## Autorisierung (RFC 9207 iss / RFC 8707 resource) - … ## Entscheidungen - Anbindungsschicht: mcp-oauth bleibt / Wechsel zu mcp-connect – Begründung - Scope-Filter für MCP-Token nötig: ja/nein - Offene Punkte für Etappe 1: … ``` - [ ] **Step 6: Commit** ```bash git add docs/superpowers/notes/2026-09-25-etappe-0-ergebnisse.md git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "docs: stage 0 results and decisions Co-Authored-By: Claude Opus 5.5 (1M context) " ```