Files
WP-LIT-MCP/docs/superpowers/plans/2026-09-25-etappe-0-anbindung.md
T

2387 lines
78 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/<bereich>-<aktion>`; der Adapter macht daraus Tool-Namen `lit-<bereich>-<aktion>`.
- 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) <noreply@anthropic.com>`.
- 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<string>` (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
<?xml version="1.0"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="tests/bootstrap.php"
colors="true">
<testsuites>
<testsuite name="unit">
<directory>tests/Unit</directory>
</testsuite>
</testsuites>
</phpunit>
```
`phpcs.xml.dist`:
```xml
<?xml version="1.0"?>
<ruleset name="wp-lit-mcp">
<file>src</file>
<file>wp-lit-mcp.php</file>
<exclude-pattern>/vendor/*</exclude-pattern>
<arg value="sp"/>
<arg name="extensions" value="php"/>
<arg name="basepath" value="."/>
<rule ref="PSR12"/>
<rule ref="WordPress.Security"/>
<rule ref="WordPress.DB.PreparedSQL"/>
<rule ref="WordPress.DB.PreparedSQLPlaceholders"/>
<rule ref="WordPress.WP.AlternativeFunctions"/>
<rule ref="WordPress.WP.I18n">
<properties>
<property name="text_domain" type="array">
<element value="wp-lit-mcp"/>
</property>
</properties>
</rule>
<rule ref="PHPCompatibilityWP"/>
<config name="testVersion" value="8.1-"/>
</ruleset>
```
`.gitignore`:
```
/vendor/
.phpunit.result.cache
```
`tests/bootstrap.php`:
```php
<?php
declare(strict_types=1);
if (! defined('ABSPATH')) {
define('ABSPATH', __DIR__ . '/');
}
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/Stubs/WpError.php';
require_once __DIR__ . '/Stubs/WpUser.php';
```
`tests/Stubs/WpError.php`:
```php
<?php
declare(strict_types=1);
if (! class_exists('WP_Error')) {
class WP_Error
{
public function __construct(
private string $code = '',
private string $message = '',
private mixed $data = null
) {
}
public function get_error_code(): string
{
return $this->code;
}
public function get_error_message(): string
{
return $this->message;
}
public function get_error_data(): mixed
{
return $this->data;
}
}
}
```
`tests/Stubs/WpUser.php`:
```php
<?php
declare(strict_types=1);
if (! class_exists('WP_User')) {
class WP_User
{
public int $ID = 0;
public array $roles = [];
public function __construct(int $id = 0, array $roles = [])
{
$this->ID = $id;
$this->roles = $roles;
}
}
}
```
`tests/Unit/TestCase.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit;
use Brain\Monkey;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use PHPUnit\Framework\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
use MockeryPHPUnitIntegration;
protected function setUp(): void
{
parent::setUp();
Monkey\setUp();
}
protected function tearDown(): void
{
Monkey\tearDown();
parent::tearDown();
}
}
```
- [ ] **Step 4: Failing Test für die Abhängigkeitsprüfung**
`tests/Unit/DependenciesTest.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit;
use LIT\MCP\Dependencies;
final class DependenciesTest extends TestCase
{
public function testReportsNothingMissingWhenAllClassesExist(): void
{
$deps = new Dependencies(static fn (string $class): bool => 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
<?php
declare(strict_types=1);
namespace LIT\MCP;
/**
* Checks that the libraries WP-LIT-MCP builds on are loadable.
*/
final class Dependencies
{
private const REQUIRED = [
'wordpress/mcp-adapter' => '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<string> 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
<?php
declare(strict_types=1);
namespace LIT\MCP;
/**
* Wires WP-LIT-MCP into WordPress.
*/
final class Plugin
{
private static string $pluginFile = '';
public static function boot(string $pluginFile): void
{
self::$pluginFile = $pluginFile;
$missing = (new Dependencies())->missing();
if ($missing !== []) {
self::noticeMissing($missing);
return;
}
}
public static function pluginDir(): string
{
return dirname(self::$pluginFile);
}
/**
* @param list<string> $missing
*/
public static function noticeMissing(array $missing): void
{
add_action('admin_notices', static function () use ($missing): void {
printf(
'<div class="notice notice-error"><p>%s</p></div>',
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
<?php
/**
* Plugin Name: WP-LIT-MCP
* Description: MCP-Server für WordPress, Divi 5 und Pods – mit OAuth für claude.ai.
* Version: 0.1.0
* Requires at least: 6.9
* Requires PHP: 8.1
* Author: Jan-Niclas Loosen - Loosen-IT
* Author URI: https://loosen-it.de
* License: GPL-2.0-or-later
* Text Domain: wp-lit-mcp
*/
declare(strict_types=1);
defined('ABSPATH') || exit;
$lit_mcp_autoload = __DIR__ . '/vendor/autoload.php';
if (! is_readable($lit_mcp_autoload)) {
add_action('admin_notices', static function (): void {
echo '<div class="notice notice-error"><p>'
. esc_html__('WP-LIT-MCP: Abhängigkeiten fehlen. Bitte "composer install --no-dev" im Plugin-Ordner ausführen.', 'wp-lit-mcp')
. '</p></div>';
});
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) <noreply@anthropic.com>"
```
---
### 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<string>`, `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
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit;
use Brain\Monkey\Filters;
use Brain\Monkey\Functions;
use LIT\MCP\Access;
use WP_User;
final class AccessTest extends TestCase
{
public function testDefaultsToAdministratorOnly(): void
{
Functions\expect('get_option')->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
<?php
declare(strict_types=1);
namespace LIT\MCP;
use WP_User;
/**
* Role gate: only users whose role is allowed get the lit_mcp_use capability.
*
* The capability is granted dynamically via user_has_cap, so no role objects
* in the database are modified.
*/
final class Access
{
public const CAPABILITY = 'lit_mcp_use';
public const OPTION = 'lit_mcp_allowed_roles';
private const DEFAULT_ROLES = ['administrator'];
private const ADAPTER_META_TOOLS = ['discover_abilities', 'get_ability_info', 'execute_ability'];
public function register(): void
{
add_filter('user_has_cap', [$this, 'grantCapability'], 10, 4);
foreach (self::ADAPTER_META_TOOLS as $tool) {
add_filter("mcp_adapter_{$tool}_capability", static fn (): string => self::CAPABILITY);
}
}
/**
* @return list<string>
*/
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<string, bool> $allcaps
* @param list<string> $caps
* @param array<int, mixed> $args
* @return array<string, bool>
*/
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) <noreply@anthropic.com>"
```
---
### 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<Ability> */ abilities(): array; }`
- `IntegrationRegistry::__construct(Integration ...$integrations)`, `available(): list<Integration>`, `abilities(): list<Ability>`, `directAbilityNames(): list<string>`.
- `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
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Integrations\Integration;
use LIT\MCP\Integrations\IntegrationRegistry;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class IntegrationRegistryTest extends TestCase
{
public function testOnlyAvailableIntegrationsContributeAbilities(): void
{
$direct = $this->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<Ability> $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
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Abilities;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\AbilityRegistrar;
use LIT\MCP\Integrations\Integration;
use LIT\MCP\Integrations\IntegrationRegistry;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
use WP_Error;
final class AbilityRegistrarTest extends TestCase
{
private function registrarFor(Ability $ability): AbilityRegistrar
{
$integration = Mockery::mock(Integration::class);
$integration->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
<?php
declare(strict_types=1);
namespace LIT\MCP\Abilities;
use WP_Error;
/**
* One capability of the MCP server, registered with the WordPress Abilities API.
*/
interface Ability
{
/** Ability name, e.g. "lit/guide". */
public function name(): string;
public function label(): string;
public function description(): string;
/** @return array<string, mixed> JSON Schema of the input. */
public function inputSchema(): array;
/** @return array<string, mixed>|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<string, mixed> $input */
public function permission(array $input): bool|WP_Error;
/** @param array<string, mixed> $input */
public function execute(array $input): mixed;
}
```
`src/Abilities/Errors.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Abilities;
use WP_Error;
/**
* Factory for the error objects abilities return. The MCP adapter turns them
* into tool results with isError: true.
*/
final class Errors
{
public static function forbidden(string $message): WP_Error
{
return new WP_Error('lit_forbidden', $message, ['status' => 403]);
}
/** @param array<string, string> $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
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations;
use LIT\MCP\Abilities\Ability;
/**
* A group of abilities that is only registered when its theme/plugin is active.
*/
interface Integration
{
public function id(): string;
public function isAvailable(): bool;
/** @return list<Ability> */
public function abilities(): array;
}
```
`src/Integrations/IntegrationRegistry.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations;
use LIT\MCP\Abilities\Ability;
final class IntegrationRegistry
{
/** @var list<Integration> */
private array $integrations;
public function __construct(Integration ...$integrations)
{
$this->integrations = array_values($integrations);
}
/** @return list<Integration> */
public function available(): array
{
return array_values(array_filter(
$this->integrations,
static fn (Integration $integration): bool => $integration->isAvailable()
));
}
/** @return list<Ability> */
public function abilities(): array
{
$abilities = [];
foreach ($this->available() as $integration) {
array_push($abilities, ...$integration->abilities());
}
return $abilities;
}
/** @return list<string> */
public function directAbilityNames(): array
{
$names = [];
foreach ($this->abilities() as $ability) {
if ($ability->isDirect()) {
$names[] = $ability->name();
}
}
return $names;
}
}
```
`src/Abilities/AbilityRegistrar.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Abilities;
use LIT\MCP\Access;
use LIT\MCP\Integrations\IntegrationRegistry;
use Throwable;
use WP_Error;
/**
* Registers all abilities of available integrations with the Abilities API and
* wraps them with the role gate and neutral error handling.
*/
final class AbilityRegistrar
{
public const CATEGORY = 'lit-mcp';
public function __construct(private IntegrationRegistry $registry)
{
}
public function register(): void
{
add_action('wp_abilities_api_categories_init', [$this, 'registerCategory']);
add_action('wp_abilities_api_init', [$this, 'registerAbilities']);
}
public function registerCategory(): void
{
wp_register_ability_category(self::CATEGORY, [
'label' => '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<string, mixed> */
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) <noreply@anthropic.com>"
```
---
### 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<string>` (sortiert), `get(string $topic): ?string`.
- `InstructionsBuilder::__construct(IntegrationRegistry $registry)`, `build(): string`.
- `GuideAbility::__construct(GuideRepository $guides)`; Name `lit/guide`; Output `{topic: string, topics: list<string>, 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
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Guides;
use LIT\MCP\Guides\GuideRepository;
use LIT\MCP\Tests\Unit\TestCase;
final class GuideRepositoryTest extends TestCase
{
private GuideRepository $guides;
protected function setUp(): void
{
parent::setUp();
$this->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<string, array{string}> */
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
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Guides;
use LIT\MCP\Guides\InstructionsBuilder;
use LIT\MCP\Integrations\Integration;
use LIT\MCP\Integrations\IntegrationRegistry;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class InstructionsBuilderTest extends TestCase
{
public function testNamesActiveIntegrationsAndCoreRules(): void
{
$core = Mockery::mock(Integration::class);
$core->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
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Core;
use LIT\MCP\Guides\GuideRepository;
use LIT\MCP\Integrations\Core\GuideAbility;
use LIT\MCP\Tests\Unit\TestCase;
use WP_Error;
final class GuideAbilityTest extends TestCase
{
private GuideAbility $ability;
protected function setUp(): void
{
parent::setUp();
$this->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
<?php
declare(strict_types=1);
namespace LIT\MCP\Guides;
/**
* Reads guide markdown files from one directory. Topic names are restricted to
* lowercase slugs, so no path outside the directory can ever be read.
*/
final class GuideRepository
{
private const TOPIC_PATTERN = '/^[a-z0-9]+(?:-[a-z0-9]+)*$/';
public function __construct(private string $directory)
{
}
/** @return list<string> */
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
<?php
declare(strict_types=1);
namespace LIT\MCP\Guides;
use LIT\MCP\Integrations\Integration;
use LIT\MCP\Integrations\IntegrationRegistry;
/**
* Builds the short server instructions every MCP client loads on connect.
* Keep this text short: it occupies context in every conversation.
*/
final class InstructionsBuilder
{
public function __construct(private IntegrationRegistry $registry)
{
}
public function build(): string
{
$active = implode(', ', array_map(
static fn (Integration $integration): string => $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
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Core;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Guides\GuideRepository;
use WP_Error;
final class GuideAbility implements Ability
{
private const DEFAULT_TOPIC = 'overview';
public function __construct(private GuideRepository $guides)
{
}
public function name(): string
{
return 'lit/guide';
}
public function label(): string
{
return 'Anleitung';
}
public function description(): string
{
return 'Liefert Anleitungen zur Bedienung dieses MCP-Servers. Ohne topic: Übersicht und Liste aller Themen.';
}
public function inputSchema(): array
{
return [
'type' => '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) <noreply@anthropic.com>"
```
---
### 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<array{id: string, active: bool, version: ?string}>` 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<string>, 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
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Core;
use Brain\Monkey\Functions;
use LIT\MCP\Access;
use LIT\MCP\Integrations\Core\EnvironmentProbe;
use LIT\MCP\Integrations\Core\SiteStatusAbility;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_User;
final class SiteStatusAbilityTest extends TestCase
{
public function testReportsSiteUserAndComponents(): void
{
Functions\when('get_bloginfo')->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
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Core;
use LIT\MCP\Integrations\Core\CoreIntegration;
use LIT\MCP\Tests\Unit\TestCase;
final class CoreIntegrationTest extends TestCase
{
public function testIsAlwaysAvailableAndProvidesGuideAndStatus(): void
{
$core = new CoreIntegration(dirname(__DIR__, 3) . '/Fixtures/guides');
self::assertSame('core', $core->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
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Core;
/**
* Detects the themes/plugins LIT MCP integrates with.
*/
class EnvironmentProbe
{
/** @return list<array{id: string, active: bool, version: ?string}> */
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
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Core;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Access;
use WP_Error;
final class SiteStatusAbility implements Ability
{
public function __construct(private EnvironmentProbe $probe, private Access $access)
{
}
public function name(): string
{
return 'lit/site-status';
}
public function label(): string
{
return 'Website-Status';
}
public function description(): string
{
return 'Überblick: Website, WordPress-Version, eigener Benutzer und Rollen, erkannte Integrationen (Divi, Pods, The Events Calendar).';
}
public function inputSchema(): array
{
return ['type' => '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
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Core;
use LIT\MCP\Access;
use LIT\MCP\Guides\GuideRepository;
use LIT\MCP\Integrations\Integration;
final class CoreIntegration implements Integration
{
public function __construct(private string $guidesDirectory)
{
}
public function id(): string
{
return 'core';
}
public function isAvailable(): bool
{
return true;
}
public function abilities(): array
{
return [
new GuideAbility(new GuideRepository($this->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) <noreply@anthropic.com>"
```
---
### 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
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Server;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Guides\InstructionsBuilder;
use LIT\MCP\Integrations\Integration;
use LIT\MCP\Integrations\IntegrationRegistry;
use LIT\MCP\Server\ServerRegistrar;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class ServerRegistrarTest extends TestCase
{
private function registry(): IntegrationRegistry
{
$direct = Mockery::mock(Ability::class);
$direct->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
<?php
declare(strict_types=1);
namespace LIT\MCP\Server;
use LIT\MCP\Guides\InstructionsBuilder;
use LIT\MCP\Integrations\IntegrationRegistry;
use WP\MCP\Core\McpAdapter;
use WP\MCP\Infrastructure\ErrorHandling\ErrorLogMcpErrorHandler;
use WPMedia\MCP\OAuth\Transport\OAuthHttpTransport;
/**
* Registers the OAuth-protected MCP server with our own tool list.
*
* wp-media/mcp-oauth hardcodes server id/route (token audience) and only three
* meta-tools. We register the same server id first (priority 5 < mcp-oauth's 10);
* mcp-oauth's later attempt fails with "duplicate server id" (logged via
* _doing_it_wrong in debug mode only) – see spec §4.2.
*/
final class ServerRegistrar
{
public const SERVER_ID = 'mcp-oauth-server';
public const NAMESPACE = 'mcp';
public const ROUTE = 'mcp-oauth-server';
public const META_TOOLS = [
'mcp-adapter/discover-abilities',
'mcp-adapter/get-ability-info',
'mcp-adapter/execute-ability',
];
/** @var callable(): bool */
private $oauthEnabled;
public function __construct(
private IntegrationRegistry $registry,
private InstructionsBuilder $instructions,
?callable $oauthEnabled = null
) {
$this->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<string, mixed>|null */
public function serverArguments(): ?array
{
if (! ($this->oauthEnabled)()) {
return null;
}
return [
'id' => self::SERVER_ID,
'namespace' => self::NAMESPACE,
'route' => self::ROUTE,
'name' => 'LIT MCP',
'description' => $this->instructions->build(),
'version' => '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) <noreply@anthropic.com>"
```
---
### 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
<?php
/**
* Mints an MCP access token for a user, exactly like mcp-oauth's TokenEndpoint
* does after a completed OAuth flow. For local integration tests only.
*
* Usage: wp eval-file tests/Integration/mint-token.php <user_login>
*/
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-<bereich>-<aktion>`).
- 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) <noreply@anthropic.com>"
```
---
### 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
<?php
/**
* Checks whether a The Events Calendar event created only through generic
* WordPress APIs (wp_insert_post + post meta) is found by TEC's own queries.
*
* Usage: wp eval-file tests/Integration/tec-probe.php
*/
declare(strict_types=1);
if (! class_exists('Tribe__Events__Main')) {
fwrite(STDERR, "The Events Calendar is not active\n");
exit(1);
}
$start = gmdate('Y-m-d', strtotime('+30 days')) . ' 18:00:00';
$end = gmdate('Y-m-d', strtotime('+30 days')) . ' 20:00:00';
$id = wp_insert_post([
'post_type' => '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) <noreply@anthropic.com>"
```
---
### 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: <Anzahl> Tests, Ergebnis
- mcp-smoke.sh: <Ausgabe der ok/FAIL-Zeilen>
## The Events Calendar
- found_by_tribe_get_events: <true/false>
- abgeleitete Felder: <Werte>
- Folgerung: Erweiterungspunkt lit_mcp_after_save mit TEC-Nachberechnung nötig: <ja/nein>
## 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) <noreply@anthropic.com>"
```