Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
78 KiB
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 (wieacorn-starter). - Ability-Namen
lit/<bereich>-<aktion>; der Adapter macht daraus Tool-Namenlit-<bereich>-<aktion>. - Kein Acorn. Keine eigene OAuth-Implementierung.
- Capability
lit_mcp_use; Optionlit_mcp_allowed_roles, Standard['administrator']. - MCP-Server: ID
mcp-oauth-server, Namespacemcp, Routemcp-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_Errormit Code-Präfixlit_(Adapter macht darausisError: 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 TrailerCo-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
- 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).
- Plugin aktiviert ohne
vendor/(Git-Checkout ohnecomposer install): Admin-Hinweis statt Fatal Error → Test in Task 1. - Guide-Topic mit Pfad-Tricks (
../../wp-config,overview.md, leere Strings): muss abgewiesen werden, nie Dateien außerhalbdocs/guides/lesen → Test in Task 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. - Exception in einer Ability (z. B. Divi-Funktion wirft): neutraler
WP_Errorlit_internal_errorohne 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-BasisklasseLIT\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(inboot()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, statischAccess::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(Codelit_forbidden),Errors::invalidInput(string $message, array $fields = []): \WP_Error(Codelit_validation_failed),Errors::notFound(string $message): \WP_Error(Codelit_not_found),Errors::internal(): \WP_Error(Codelit_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); Namelit/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ürdivi,pods,the-events-calendar.SiteStatusAbility::__construct(EnvironmentProbe $probe, Access $access); Namelit/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-known404 →ddev wp rewrite flushund erneut; bleibt es 404, im Ergebnisprotokoll (Task 9) festhalten. -
Tool-Namen anders als
lit-guide→ tatsächliche Namen aus dertools/list-Antwort übernehmen und Skript sowieInstructionsBuilder-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 mitAuthorization: Beareraußerhalb der Route/mcp/mcp-oauth-serverabweist (inkl. Unit-Test) – Spec §4.2. -
Argumentnamen des Meta-Tools
execute-abilityin Adapter 0.5 anders → ausvendor/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 beiwp-media/mcp-oauthnotieren (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>"