diff --git a/docs/superpowers/plans/2026-09-25-etappe-0-anbindung.md b/docs/superpowers/plans/2026-09-25-etappe-0-anbindung.md new file mode 100644 index 0000000..6787380 --- /dev/null +++ b/docs/superpowers/plans/2026-09-25-etappe-0-anbindung.md @@ -0,0 +1,2386 @@ +# 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; 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`, `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()); + } + + private function ability(string $name, bool $direct): Ability + { + $ability = Mockery::mock(Ability::class); + $ability->allows('name')->andReturn($name); + $ability->allows('isDirect')->andReturn($direct); + + 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); + + 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; + + /** @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) { + array_push($abilities, ...$integration->abilities()); + } + + 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 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 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); + $indirect = Mockery::mock(Ability::class); + $indirect->allows('name')->andReturn('lit/rare-tool'); + $indirect->allows('isDirect')->andReturn(false); + + $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) " +``` diff --git a/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md b/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md index dc87ba6..bfd73e2 100644 --- a/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md +++ b/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md @@ -104,7 +104,7 @@ Die Bibliothek (v1.1.x) hat Lücken, die WP-LIT-MCP schließt: | Lücke | Lösung | |---|---| | Kein Rollen-Gate (jeder eingeloggte Benutzer kann autorisieren) | Eigene Capability `lit_mcp_use`, vergeben an die in den Einstellungen freigegebenen Rollen. Gesetzt über die Adapter-Filter `mcp_adapter_{discover_abilities,get_ability_info,execute_ability}_capability` und zusätzlich in jeder Ability geprüft. | -| Erzeugtes Application Password gilt für alle REST-Routen (Issue #62) | Filter auf die REST-Authentifizierung: Application Passwords, die von mcp-oauth erzeugt wurden, werden außerhalb der MCP-Route abgewiesen. Entfällt, sobald upstream PR #64 veröffentlicht ist. | +| Erzeugtes Application Password gilt für alle REST-Routen (Issue #62) | Code-Analyse (v1.1.2): Das Klartext-Passwort wird verworfen, die Token-Prüfung läuft nur in der MCP-Route – vermutlich nicht ausnutzbar. Etappe 0 prüft das per Ende-zu-Ende-Test (Bearer-Token gegen `/wp/v2/users/me` muss 401 liefern). Nur falls der Test fehlschlägt: Filter auf `rest_authentication_errors`, der Bearer-Requests außerhalb der MCP-Route abweist. | | Server fest mit drei Meta-Tools, keine direkten Tools | WP-LIT-MCP registriert den Server `mcp-oauth-server` auf `mcp_adapter_init` mit Priorität < 10 selbst, mit `OAuthHttpTransport` und eigener Tool-Liste. Folge bis upstream PR #63: harmloser „duplicate server“-Log-Eintrag. | | Adapter auf `^0.5` gepinnt | Akzeptiert; Beobachtung der Upstream-Entwicklung. |