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

78 KiB
Raw Blame History

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:

{
    "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 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 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

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

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

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

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

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

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

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

/**
 * 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:

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
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

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

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:

        (new Access())->register();
  • Step 4: Run – muss bestehen

Run: composer test -- --filter AccessTest && composer lint Expected: 6 Tests PASS, phpcs sauber.

  • Step 5: Commit
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

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

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

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

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

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

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

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
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:

# Übersicht
Testinhalt Übersicht.

tests/Fixtures/guides/divi-shortform.md:

# Divi-Kurzform
Testinhalt Kurzform.
  • Step 2: Failing Tests

tests/Unit/Guides/GuideRepositoryTest.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

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

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

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

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

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:

# 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
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

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

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

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

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

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
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

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

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:

    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:

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:

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
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

/**
 * 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:

#!/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

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

/**
 * 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:

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
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:

# 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
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>"