Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2387 lines
78 KiB
Markdown
2387 lines
78 KiB
Markdown
# Etappe 0 – Anbindung: Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Ein installierbares Plugin-Gerüst, das über `wp-media/mcp-oauth` + `wordpress/mcp-adapter` einen OAuth-geschützten MCP-Server mit eigenen, direkt sichtbaren Tools (`lit-guide`, `lit-site-status`) bereitstellt, mit Rollen-Gate – und die Entscheidungsgrundlage „mcp-oauth bleibt / Wechsel zu mcp-connect“ sowie „Events-Erweiterungspunkt nötig ja/nein“.
|
||
|
||
**Architecture:** WP-LIT-MCP bindet mcp-oauth per Composer ein und registriert auf `mcp_adapter_init` (Priorität 5, vor mcp-oauth) den Server `mcp-oauth-server` selbst – mit mcp-oauths `OAuthHttpTransport` und eigener Tool-Liste. Abilities werden über ein kleines `Ability`-Interface definiert und vom `AbilityRegistrar` bei der WordPress Abilities API angemeldet; jede Ausführung ist durch die Capability `lit_mcp_use` (nur freigegebene Rollen) geschützt.
|
||
|
||
**Tech Stack:** PHP ≥ 8.1, WordPress 7.0 Abilities API, `wp-media/mcp-oauth` ^1.1 (zieht `wordpress/mcp-adapter` ^0.5), Composer, PHPUnit 9.6 + Brain Monkey + Mockery, phpcs (PSR-12 + WordPress-Security-Sniffs), DDEV für Integrationstests.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` (insb. §4, §9, §10, §11, §12 Etappe 0)
|
||
|
||
## Global Constraints
|
||
|
||
- PHP ≥ 8.1, WordPress ≥ 6.9 (getestet mit 7.0.3); Plugin-Header entsprechend.
|
||
- Namespace `LIT\MCP\` → `src/`; PSR-4, eine Klasse pro Datei, PSR-12-Stil (wie `acorn-starter`).
|
||
- Ability-Namen `lit/<bereich>-<aktion>`; der Adapter macht daraus Tool-Namen `lit-<bereich>-<aktion>`.
|
||
- Kein Acorn. Keine eigene OAuth-Implementierung.
|
||
- Capability `lit_mcp_use`; Option `lit_mcp_allowed_roles`, Standard `['administrator']`.
|
||
- MCP-Server: ID `mcp-oauth-server`, Namespace `mcp`, Route `mcp-oauth-server` (Endpoint `/wp-json/mcp/mcp-oauth-server`) – durch mcp-oauth vorgegeben (Token-Audience).
|
||
- Meta-Tools des Adapters immer im Server: `mcp-adapter/discover-abilities`, `mcp-adapter/get-ability-info`, `mcp-adapter/execute-ability`.
|
||
- Fachliche Fehler als `WP_Error` mit Code-Präfix `lit_` (Adapter macht daraus `isError: true`); nie Stacktraces an den Client.
|
||
- Text-Domain `wp-lit-mcp`.
|
||
- Git-Commits mit `git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit …` (keine Repo-Identität konfiguriert) und Trailer `Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>`.
|
||
- Integrationstests laufen gegen die lokale DDEV-Installation `/home/janniclas/Projekte/WordPress` (`ddev wp …`, `ddev exec …`); Plugin-Pfad im Container: `/var/www/html/wp-content/plugins/WP-LIT-MCP`.
|
||
|
||
## Review Focus
|
||
|
||
1. **Nicht freigegebene Rolle mit gültigem Token** (z. B. Abonnent verbindet sich): Tool-Aufrufe und Meta-Tools müssen mit „forbidden“ scheitern, nicht still Daten liefern → Test in Task 2 (Unit) und Task 7 (Integration).
|
||
2. **Plugin aktiviert ohne `vendor/`** (Git-Checkout ohne `composer install`): Admin-Hinweis statt Fatal Error → Test in Task 1.
|
||
3. **Guide-Topic mit Pfad-Tricks** (`../../wp-config`, `overview.md`, leere Strings): muss abgewiesen werden, nie Dateien außerhalb `docs/guides/` lesen → Test in Task 4.
|
||
4. **Adapter oder mcp-oauth fehlt/deaktiviert** (Klasse nicht vorhanden, Filter `wpmedia_mcp_oauth_server_enabled` = false): Server-Registrierung darf nicht fataln und keinen ungeschützten Server anlegen → Test in Task 6.
|
||
5. **Exception in einer Ability** (z. B. Divi-Funktion wirft): neutraler `WP_Error` `lit_internal_error` ohne Stacktrace, Detail nur im PHP-Error-Log → Test in Task 3.
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
```
|
||
wp-lit-mcp.php Plugin-Header, Autoload-Prüfung, Boot
|
||
composer.json / composer.lock
|
||
phpunit.xml.dist, phpcs.xml.dist, .gitignore
|
||
src/
|
||
Plugin.php verdrahtet alles (Hooks)
|
||
Dependencies.php prüft Adapter/mcp-oauth-Verfügbarkeit
|
||
Access.php Rollen-Gate, Capability lit_mcp_use, Adapter-Filter
|
||
Abilities/
|
||
Ability.php Interface einer Ability
|
||
AbilityRegistrar.php meldet Kategorien + Abilities an, kapselt Rechte/Fehler
|
||
Errors.php Fabrik für WP_Error-Objekte
|
||
Integrations/
|
||
Integration.php Interface einer Integration
|
||
IntegrationRegistry.php sammelt verfügbare Integrationen
|
||
Core/CoreIntegration.php immer verfügbar: guide, site-status
|
||
Core/GuideAbility.php lit/guide
|
||
Core/SiteStatusAbility.php lit/site-status
|
||
Core/EnvironmentProbe.php erkennt Divi/Pods/TEC + Versionen
|
||
Guides/
|
||
GuideRepository.php liest docs/guides/*.md sicher
|
||
InstructionsBuilder.php erzeugt Server-Instructions
|
||
Server/
|
||
ServerRegistrar.php registriert mcp-oauth-server mit eigenen Tools
|
||
docs/guides/overview.md
|
||
tests/
|
||
bootstrap.php
|
||
Stubs/WpError.php, Stubs/WpUser.php Minimal-Stubs für Unit-Tests
|
||
Unit/…Test.php
|
||
Integration/mint-token.php erzeugt Test-JWT
|
||
Integration/mcp-smoke.sh HTTP-Ende-zu-Ende gegen DDEV
|
||
Integration/tec-probe.php The-Events-Calendar-Test
|
||
docs/superpowers/notes/2026-09-25-etappe-0-ergebnisse.md
|
||
```
|
||
|
||
---
|
||
|
||
### Task 1: Plugin-Gerüst, Composer, Test-Setup, Abhängigkeitsprüfung
|
||
|
||
**Files:**
|
||
- Create: `composer.json`, `phpunit.xml.dist`, `phpcs.xml.dist`, `.gitignore`, `wp-lit-mcp.php`, `src/Dependencies.php`, `src/Plugin.php`, `tests/bootstrap.php`, `tests/Stubs/WpError.php`, `tests/Stubs/WpUser.php`, `tests/Unit/TestCase.php`, `tests/Unit/DependenciesTest.php`
|
||
|
||
**Interfaces:**
|
||
- Produces: `LIT\MCP\Dependencies::missing(): list<string>` (leere Liste = alles da); `LIT\MCP\Plugin::boot(string $pluginFile): void`; `LIT\MCP\Plugin::pluginDir(): string`; Test-Basisklasse `LIT\MCP\Tests\Unit\TestCase` (setUp/tearDown für Brain Monkey).
|
||
|
||
- [ ] **Step 1: Composer-Datei anlegen**
|
||
|
||
`composer.json`:
|
||
```json
|
||
{
|
||
"name": "loosen-it/wp-lit-mcp",
|
||
"description": "MCP-Server für WordPress, Divi 5 und Pods mit OAuth für claude.ai",
|
||
"type": "wordpress-plugin",
|
||
"license": "GPL-2.0-or-later",
|
||
"require": {
|
||
"php": ">=8.1",
|
||
"wp-media/mcp-oauth": "^1.1"
|
||
},
|
||
"require-dev": {
|
||
"brain/monkey": "^2.6",
|
||
"dealerdirect/phpcodesniffer-composer-installer": "^1.0",
|
||
"mockery/mockery": "^1.6",
|
||
"phpcompatibility/phpcompatibility-wp": "^2.1",
|
||
"phpunit/phpunit": "^9.6",
|
||
"squizlabs/php_codesniffer": "^3.10",
|
||
"wp-coding-standards/wpcs": "^3.1"
|
||
},
|
||
"autoload": {
|
||
"psr-4": { "LIT\\MCP\\": "src/" }
|
||
},
|
||
"autoload-dev": {
|
||
"psr-4": { "LIT\\MCP\\Tests\\": "tests/" }
|
||
},
|
||
"scripts": {
|
||
"test": "@php vendor/bin/phpunit",
|
||
"lint": "@php vendor/bin/phpcs"
|
||
},
|
||
"config": {
|
||
"sort-packages": true,
|
||
"platform": { "php": "8.1" },
|
||
"allow-plugins": {
|
||
"dealerdirect/phpcodesniffer-composer-installer": true
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Installieren und Versionen prüfen**
|
||
|
||
Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && composer install`
|
||
Expected: Erfolg; `composer show wp-media/mcp-oauth wordpress/mcp-adapter` zeigt `v1.1.2` und `v0.5.0`.
|
||
|
||
- [ ] **Step 3: Test- und Lint-Konfiguration**
|
||
|
||
`phpunit.xml.dist`:
|
||
```xml
|
||
<?xml version="1.0"?>
|
||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||
bootstrap="tests/bootstrap.php"
|
||
colors="true">
|
||
<testsuites>
|
||
<testsuite name="unit">
|
||
<directory>tests/Unit</directory>
|
||
</testsuite>
|
||
</testsuites>
|
||
</phpunit>
|
||
```
|
||
|
||
`phpcs.xml.dist`:
|
||
```xml
|
||
<?xml version="1.0"?>
|
||
<ruleset name="wp-lit-mcp">
|
||
<file>src</file>
|
||
<file>wp-lit-mcp.php</file>
|
||
<exclude-pattern>/vendor/*</exclude-pattern>
|
||
<arg value="sp"/>
|
||
<arg name="extensions" value="php"/>
|
||
<arg name="basepath" value="."/>
|
||
<rule ref="PSR12"/>
|
||
<rule ref="WordPress.Security"/>
|
||
<rule ref="WordPress.DB.PreparedSQL"/>
|
||
<rule ref="WordPress.DB.PreparedSQLPlaceholders"/>
|
||
<rule ref="WordPress.WP.AlternativeFunctions"/>
|
||
<rule ref="WordPress.WP.I18n">
|
||
<properties>
|
||
<property name="text_domain" type="array">
|
||
<element value="wp-lit-mcp"/>
|
||
</property>
|
||
</properties>
|
||
</rule>
|
||
<rule ref="PHPCompatibilityWP"/>
|
||
<config name="testVersion" value="8.1-"/>
|
||
</ruleset>
|
||
```
|
||
|
||
`.gitignore`:
|
||
```
|
||
/vendor/
|
||
.phpunit.result.cache
|
||
```
|
||
|
||
`tests/bootstrap.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
if (! defined('ABSPATH')) {
|
||
define('ABSPATH', __DIR__ . '/');
|
||
}
|
||
|
||
require_once __DIR__ . '/../vendor/autoload.php';
|
||
require_once __DIR__ . '/Stubs/WpError.php';
|
||
require_once __DIR__ . '/Stubs/WpUser.php';
|
||
```
|
||
|
||
`tests/Stubs/WpError.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
if (! class_exists('WP_Error')) {
|
||
class WP_Error
|
||
{
|
||
public function __construct(
|
||
private string $code = '',
|
||
private string $message = '',
|
||
private mixed $data = null
|
||
) {
|
||
}
|
||
|
||
public function get_error_code(): string
|
||
{
|
||
return $this->code;
|
||
}
|
||
|
||
public function get_error_message(): string
|
||
{
|
||
return $this->message;
|
||
}
|
||
|
||
public function get_error_data(): mixed
|
||
{
|
||
return $this->data;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Stubs/WpUser.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
if (! class_exists('WP_User')) {
|
||
class WP_User
|
||
{
|
||
public int $ID = 0;
|
||
public array $roles = [];
|
||
|
||
public function __construct(int $id = 0, array $roles = [])
|
||
{
|
||
$this->ID = $id;
|
||
$this->roles = $roles;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/TestCase.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit;
|
||
|
||
use Brain\Monkey;
|
||
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
|
||
use PHPUnit\Framework\TestCase as BaseTestCase;
|
||
|
||
abstract class TestCase extends BaseTestCase
|
||
{
|
||
use MockeryPHPUnitIntegration;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
Monkey\setUp();
|
||
}
|
||
|
||
protected function tearDown(): void
|
||
{
|
||
Monkey\tearDown();
|
||
parent::tearDown();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Failing Test für die Abhängigkeitsprüfung**
|
||
|
||
`tests/Unit/DependenciesTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit;
|
||
|
||
use LIT\MCP\Dependencies;
|
||
|
||
final class DependenciesTest extends TestCase
|
||
{
|
||
public function testReportsNothingMissingWhenAllClassesExist(): void
|
||
{
|
||
$deps = new Dependencies(static fn (string $class): bool => true);
|
||
|
||
self::assertSame([], $deps->missing());
|
||
}
|
||
|
||
public function testNamesEveryMissingComponent(): void
|
||
{
|
||
$deps = new Dependencies(static fn (string $class): bool => false);
|
||
|
||
self::assertSame(
|
||
['wordpress/mcp-adapter', 'wp-media/mcp-oauth', 'WordPress Abilities API'],
|
||
$deps->missing()
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Test laufen lassen – muss fehlschlagen**
|
||
|
||
Run: `composer test -- --filter DependenciesTest`
|
||
Expected: FAIL, „Class "LIT\MCP\Dependencies" not found“.
|
||
|
||
- [ ] **Step 6: Implementierung**
|
||
|
||
`src/Dependencies.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP;
|
||
|
||
/**
|
||
* Checks that the libraries WP-LIT-MCP builds on are loadable.
|
||
*/
|
||
final class Dependencies
|
||
{
|
||
private const REQUIRED = [
|
||
'wordpress/mcp-adapter' => 'WP\\MCP\\Core\\McpAdapter',
|
||
'wp-media/mcp-oauth' => 'WPMedia\\MCP\\OAuth\\Bootstrap',
|
||
'WordPress Abilities API' => 'WP_Ability',
|
||
];
|
||
|
||
/** @var callable(string): bool */
|
||
private $classExists;
|
||
|
||
public function __construct(?callable $classExists = null)
|
||
{
|
||
$this->classExists = $classExists ?? static fn (string $class): bool => class_exists($class);
|
||
}
|
||
|
||
/**
|
||
* @return list<string> Human-readable names of missing components.
|
||
*/
|
||
public function missing(): array
|
||
{
|
||
$missing = [];
|
||
foreach (self::REQUIRED as $name => $class) {
|
||
if (! ($this->classExists)($class)) {
|
||
$missing[] = $name;
|
||
}
|
||
}
|
||
|
||
return $missing;
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Plugin.php` (in dieser Task nur Abhängigkeitsprüfung + Hinweis; spätere Tasks ergänzen `boot()`):
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP;
|
||
|
||
/**
|
||
* Wires WP-LIT-MCP into WordPress.
|
||
*/
|
||
final class Plugin
|
||
{
|
||
private static string $pluginFile = '';
|
||
|
||
public static function boot(string $pluginFile): void
|
||
{
|
||
self::$pluginFile = $pluginFile;
|
||
|
||
$missing = (new Dependencies())->missing();
|
||
if ($missing !== []) {
|
||
self::noticeMissing($missing);
|
||
return;
|
||
}
|
||
}
|
||
|
||
public static function pluginDir(): string
|
||
{
|
||
return dirname(self::$pluginFile);
|
||
}
|
||
|
||
/**
|
||
* @param list<string> $missing
|
||
*/
|
||
public static function noticeMissing(array $missing): void
|
||
{
|
||
add_action('admin_notices', static function () use ($missing): void {
|
||
printf(
|
||
'<div class="notice notice-error"><p>%s</p></div>',
|
||
esc_html(sprintf(
|
||
/* translators: %s: comma separated list of missing components */
|
||
__('WP-LIT-MCP ist inaktiv. Es fehlt: %s', 'wp-lit-mcp'),
|
||
implode(', ', $missing)
|
||
))
|
||
);
|
||
});
|
||
}
|
||
}
|
||
```
|
||
|
||
`wp-lit-mcp.php`:
|
||
```php
|
||
<?php
|
||
|
||
/**
|
||
* Plugin Name: WP-LIT-MCP
|
||
* Description: MCP-Server für WordPress, Divi 5 und Pods – mit OAuth für claude.ai.
|
||
* Version: 0.1.0
|
||
* Requires at least: 6.9
|
||
* Requires PHP: 8.1
|
||
* Author: Jan-Niclas Loosen - Loosen-IT
|
||
* Author URI: https://loosen-it.de
|
||
* License: GPL-2.0-or-later
|
||
* Text Domain: wp-lit-mcp
|
||
*/
|
||
|
||
declare(strict_types=1);
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
$lit_mcp_autoload = __DIR__ . '/vendor/autoload.php';
|
||
|
||
if (! is_readable($lit_mcp_autoload)) {
|
||
add_action('admin_notices', static function (): void {
|
||
echo '<div class="notice notice-error"><p>'
|
||
. esc_html__('WP-LIT-MCP: Abhängigkeiten fehlen. Bitte "composer install --no-dev" im Plugin-Ordner ausführen.', 'wp-lit-mcp')
|
||
. '</p></div>';
|
||
});
|
||
return;
|
||
}
|
||
|
||
require_once $lit_mcp_autoload;
|
||
|
||
add_action('plugins_loaded', static function (): void {
|
||
\LIT\MCP\Plugin::boot(__FILE__);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 7: Tests und Lint**
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: 2 Tests PASS; phpcs ohne Fehler.
|
||
|
||
- [ ] **Step 8: Review-Focus 2 prüfen (Plugin ohne vendor)**
|
||
|
||
Run:
|
||
```bash
|
||
cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && mv vendor /tmp/claude-1000/lit-vendor-bak && cd /home/janniclas/Projekte/WordPress && ddev wp plugin activate WP-LIT-MCP && ddev wp eval 'echo "alive";'; cd wp-content/plugins/WP-LIT-MCP && mv /tmp/claude-1000/lit-vendor-bak vendor
|
||
```
|
||
Expected: `Plugin 'WP-LIT-MCP' activated.` und `alive` (kein Fatal Error).
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add composer.json composer.lock phpunit.xml.dist phpcs.xml.dist .gitignore wp-lit-mcp.php src tests
|
||
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: plugin scaffold with dependency check
|
||
|
||
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Rollen-Gate (`Access`)
|
||
|
||
**Files:**
|
||
- Create: `src/Access.php`, `tests/Unit/AccessTest.php`
|
||
- Modify: `src/Plugin.php` (in `boot()` registrieren)
|
||
|
||
**Interfaces:**
|
||
- Consumes: –
|
||
- Produces: `LIT\MCP\Access::CAPABILITY = 'lit_mcp_use'`, `Access::OPTION = 'lit_mcp_allowed_roles'`, `Access::allowedRoles(): list<string>`, `Access::register(): void`, `Access::grantCapability(array $allcaps, array $caps, array $args, \WP_User $user): array`, statisch `Access::currentUserMayUse(): bool`.
|
||
|
||
- [ ] **Step 1: Failing Tests**
|
||
|
||
`tests/Unit/AccessTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit;
|
||
|
||
use Brain\Monkey\Filters;
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Access;
|
||
use WP_User;
|
||
|
||
final class AccessTest extends TestCase
|
||
{
|
||
public function testDefaultsToAdministratorOnly(): void
|
||
{
|
||
Functions\expect('get_option')->once()->with(Access::OPTION, ['administrator'])->andReturn(['administrator']);
|
||
|
||
self::assertSame(['administrator'], (new Access())->allowedRoles());
|
||
}
|
||
|
||
public function testRoleListIsFilterable(): void
|
||
{
|
||
Functions\when('get_option')->justReturn(['administrator']);
|
||
Filters\expectApplied('lit_mcp_allowed_roles')->once()->andReturn(['administrator', 'editor']);
|
||
|
||
self::assertSame(['administrator', 'editor'], (new Access())->allowedRoles());
|
||
}
|
||
|
||
public function testGrantsCapabilityToAllowedRole(): void
|
||
{
|
||
Functions\when('get_option')->justReturn(['editor']);
|
||
|
||
$caps = (new Access())->grantCapability([], [Access::CAPABILITY], [], new WP_User(5, ['editor']));
|
||
|
||
self::assertTrue($caps[Access::CAPABILITY]);
|
||
}
|
||
|
||
public function testDoesNotGrantCapabilityToOtherRoles(): void
|
||
{
|
||
Functions\when('get_option')->justReturn(['administrator']);
|
||
|
||
$caps = (new Access())->grantCapability([], [Access::CAPABILITY], [], new WP_User(7, ['subscriber']));
|
||
|
||
self::assertArrayNotHasKey(Access::CAPABILITY, $caps);
|
||
}
|
||
|
||
public function testCorruptOptionFallsBackToAdministrator(): void
|
||
{
|
||
Functions\when('get_option')->justReturn('not-an-array');
|
||
|
||
self::assertSame(['administrator'], (new Access())->allowedRoles());
|
||
}
|
||
|
||
public function testRegisterRoutesAdapterMetaToolCapabilitiesToOwnCapability(): void
|
||
{
|
||
// Brain Monkey records add_filter() but never runs callbacks, so we
|
||
// capture each registered callback and invoke it ourselves.
|
||
foreach (['discover_abilities', 'get_ability_info', 'execute_ability'] as $tool) {
|
||
Filters\expectAdded("mcp_adapter_{$tool}_capability")
|
||
->once()
|
||
->whenHappen(static function (callable $callback): void {
|
||
self::assertSame(Access::CAPABILITY, $callback('read'));
|
||
});
|
||
}
|
||
|
||
$access = new Access();
|
||
$access->register();
|
||
|
||
self::assertNotFalse(has_filter('user_has_cap', [$access, 'grantCapability']));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run – muss fehlschlagen**
|
||
|
||
Run: `composer test -- --filter AccessTest`
|
||
Expected: FAIL, Klasse `LIT\MCP\Access` nicht gefunden.
|
||
|
||
- [ ] **Step 3: Implementierung**
|
||
|
||
`src/Access.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP;
|
||
|
||
use WP_User;
|
||
|
||
/**
|
||
* Role gate: only users whose role is allowed get the lit_mcp_use capability.
|
||
*
|
||
* The capability is granted dynamically via user_has_cap, so no role objects
|
||
* in the database are modified.
|
||
*/
|
||
final class Access
|
||
{
|
||
public const CAPABILITY = 'lit_mcp_use';
|
||
public const OPTION = 'lit_mcp_allowed_roles';
|
||
private const DEFAULT_ROLES = ['administrator'];
|
||
private const ADAPTER_META_TOOLS = ['discover_abilities', 'get_ability_info', 'execute_ability'];
|
||
|
||
public function register(): void
|
||
{
|
||
add_filter('user_has_cap', [$this, 'grantCapability'], 10, 4);
|
||
|
||
foreach (self::ADAPTER_META_TOOLS as $tool) {
|
||
add_filter("mcp_adapter_{$tool}_capability", static fn (): string => self::CAPABILITY);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @return list<string>
|
||
*/
|
||
public function allowedRoles(): array
|
||
{
|
||
$roles = get_option(self::OPTION, self::DEFAULT_ROLES);
|
||
if (! is_array($roles)) {
|
||
$roles = self::DEFAULT_ROLES;
|
||
}
|
||
|
||
$roles = apply_filters('lit_mcp_allowed_roles', $roles);
|
||
|
||
return array_values(array_filter($roles, 'is_string'));
|
||
}
|
||
|
||
/**
|
||
* @param array<string, bool> $allcaps
|
||
* @param list<string> $caps
|
||
* @param array<int, mixed> $args
|
||
* @return array<string, bool>
|
||
*/
|
||
public function grantCapability(array $allcaps, array $caps, array $args, WP_User $user): array
|
||
{
|
||
if (! in_array(self::CAPABILITY, $caps, true)) {
|
||
return $allcaps;
|
||
}
|
||
|
||
if (array_intersect($user->roles, $this->allowedRoles()) !== []) {
|
||
$allcaps[self::CAPABILITY] = true;
|
||
}
|
||
|
||
return $allcaps;
|
||
}
|
||
|
||
public static function currentUserMayUse(): bool
|
||
{
|
||
return current_user_can(self::CAPABILITY);
|
||
}
|
||
}
|
||
```
|
||
|
||
In `src/Plugin.php` am Ende von `boot()` (nach der Abhängigkeitsprüfung) ergänzen:
|
||
```php
|
||
(new Access())->register();
|
||
```
|
||
|
||
- [ ] **Step 4: Run – muss bestehen**
|
||
|
||
Run: `composer test -- --filter AccessTest && composer lint`
|
||
Expected: 6 Tests PASS, phpcs sauber.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/Access.php src/Plugin.php tests/Unit/AccessTest.php
|
||
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: role gate with lit_mcp_use capability
|
||
|
||
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Ability-Infrastruktur (Interface, Registrar, Fehler, Integrationen)
|
||
|
||
**Files:**
|
||
- Create: `src/Abilities/Ability.php`, `src/Abilities/Errors.php`, `src/Abilities/AbilityRegistrar.php`, `src/Integrations/Integration.php`, `src/Integrations/IntegrationRegistry.php`, `tests/Unit/Abilities/AbilityRegistrarTest.php`, `tests/Unit/Integrations/IntegrationRegistryTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Access::currentUserMayUse()`, `Access::CAPABILITY` (Task 2).
|
||
- Produces:
|
||
- `interface Ability { name(): string; label(): string; description(): string; inputSchema(): array; outputSchema(): ?array; annotations(): array; isDirect(): bool; permission(array $input): bool|\WP_Error; execute(array $input): mixed; }`
|
||
- `Errors::forbidden(string $message): \WP_Error` (Code `lit_forbidden`), `Errors::invalidInput(string $message, array $fields = []): \WP_Error` (Code `lit_validation_failed`), `Errors::notFound(string $message): \WP_Error` (Code `lit_not_found`), `Errors::internal(): \WP_Error` (Code `lit_internal_error`).
|
||
- `interface Integration { id(): string; isAvailable(): bool; /** @return list<Ability> */ abilities(): array; }`
|
||
- `IntegrationRegistry::__construct(Integration ...$integrations)`, `available(): list<Integration>`, `abilities(): list<Ability>`, `directAbilityNames(): list<string>`.
|
||
- `AbilityRegistrar::CATEGORY = 'lit-mcp'`, `__construct(IntegrationRegistry $registry)`, `register(): void`, `registerCategory(): void`, `registerAbilities(): void`, `argsFor(Ability $ability): array`.
|
||
|
||
- [ ] **Step 1: Failing Tests**
|
||
|
||
`tests/Unit/Integrations/IntegrationRegistryTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations;
|
||
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Integrations\Integration;
|
||
use LIT\MCP\Integrations\IntegrationRegistry;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
|
||
final class IntegrationRegistryTest extends TestCase
|
||
{
|
||
public function testOnlyAvailableIntegrationsContributeAbilities(): void
|
||
{
|
||
$direct = $this->ability('lit/a', true);
|
||
$indirect = $this->ability('lit/b', false);
|
||
$hidden = $this->ability('lit/c', true);
|
||
|
||
$registry = new IntegrationRegistry(
|
||
$this->integration('core', true, [$direct, $indirect]),
|
||
$this->integration('divi', false, [$hidden])
|
||
);
|
||
|
||
self::assertSame(['core'], array_map(static fn ($i) => $i->id(), $registry->available()));
|
||
self::assertSame([$direct, $indirect], $registry->abilities());
|
||
self::assertSame(['lit/a'], $registry->directAbilityNames());
|
||
}
|
||
|
||
private function ability(string $name, bool $direct): Ability
|
||
{
|
||
$ability = Mockery::mock(Ability::class);
|
||
$ability->allows('name')->andReturn($name);
|
||
$ability->allows('isDirect')->andReturn($direct);
|
||
|
||
return $ability;
|
||
}
|
||
|
||
/** @param list<Ability> $abilities */
|
||
private function integration(string $id, bool $available, array $abilities): Integration
|
||
{
|
||
$integration = Mockery::mock(Integration::class);
|
||
$integration->allows('id')->andReturn($id);
|
||
$integration->allows('isAvailable')->andReturn($available);
|
||
$integration->allows('abilities')->andReturn($abilities);
|
||
|
||
return $integration;
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Abilities/AbilityRegistrarTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Abilities;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\AbilityRegistrar;
|
||
use LIT\MCP\Integrations\Integration;
|
||
use LIT\MCP\Integrations\IntegrationRegistry;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
use RuntimeException;
|
||
use WP_Error;
|
||
|
||
final class AbilityRegistrarTest extends TestCase
|
||
{
|
||
private function registrarFor(Ability $ability): AbilityRegistrar
|
||
{
|
||
$integration = Mockery::mock(Integration::class);
|
||
$integration->allows('id')->andReturn('core');
|
||
$integration->allows('isAvailable')->andReturn(true);
|
||
$integration->allows('abilities')->andReturn([$ability]);
|
||
|
||
return new AbilityRegistrar(new IntegrationRegistry($integration));
|
||
}
|
||
|
||
private function ability(): Mockery\MockInterface
|
||
{
|
||
$ability = Mockery::mock(Ability::class);
|
||
$ability->allows('name')->andReturn('lit/test');
|
||
$ability->allows('label')->andReturn('Test');
|
||
$ability->allows('description')->andReturn('Test ability');
|
||
$ability->allows('inputSchema')->andReturn(['type' => 'object', 'properties' => []]);
|
||
$ability->allows('outputSchema')->andReturn(['type' => 'object']);
|
||
$ability->allows('annotations')->andReturn(['readonly' => true]);
|
||
$ability->allows('isDirect')->andReturn(true);
|
||
|
||
return $ability;
|
||
}
|
||
|
||
public function testArgsExposeAbilityPubliclyToMcpWithAnnotations(): void
|
||
{
|
||
$args = $this->registrarFor($this->ability())->argsFor($this->ability());
|
||
|
||
self::assertSame('Test', $args['label']);
|
||
self::assertSame(AbilityRegistrar::CATEGORY, $args['category']);
|
||
self::assertSame(['type' => 'object'], $args['output_schema']);
|
||
self::assertTrue($args['meta']['mcp']['public']);
|
||
self::assertSame(['readonly' => true], $args['meta']['annotations']);
|
||
self::assertFalse($args['meta']['show_in_rest']);
|
||
}
|
||
|
||
public function testPermissionDeniedWithoutLitCapability(): void
|
||
{
|
||
Functions\when('current_user_can')->justReturn(false);
|
||
$ability = $this->ability();
|
||
$ability->shouldNotReceive('permission');
|
||
|
||
$result = ($this->registrarFor($ability)->argsFor($ability)['permission_callback'])([]);
|
||
|
||
self::assertInstanceOf(WP_Error::class, $result);
|
||
self::assertSame('lit_forbidden', $result->get_error_code());
|
||
}
|
||
|
||
public function testPermissionDelegatesToAbilityWhenGatePasses(): void
|
||
{
|
||
Functions\when('current_user_can')->justReturn(true);
|
||
$ability = $this->ability();
|
||
$ability->expects('permission')->with(['x' => 1])->andReturn(true);
|
||
|
||
$result = ($this->registrarFor($ability)->argsFor($ability)['permission_callback'])(['x' => 1]);
|
||
|
||
self::assertTrue($result);
|
||
}
|
||
|
||
public function testNullInputIsPassedAsEmptyArray(): void
|
||
{
|
||
$ability = $this->ability();
|
||
$ability->expects('execute')->with([])->andReturn(['ok' => true]);
|
||
|
||
$result = ($this->registrarFor($ability)->argsFor($ability)['execute_callback'])(null);
|
||
|
||
self::assertSame(['ok' => true], $result);
|
||
}
|
||
|
||
public function testExceptionBecomesNeutralInternalError(): void
|
||
{
|
||
// error_log() is a PHP internal and cannot be stubbed; redirect it to a temp file instead.
|
||
$log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
|
||
$previous = ini_set('error_log', $log);
|
||
$ability = $this->ability();
|
||
$ability->expects('execute')->andThrow(new RuntimeException('secret path /var/www/x.php'));
|
||
|
||
try {
|
||
$result = ($this->registrarFor($ability)->argsFor($ability)['execute_callback'])([]);
|
||
} finally {
|
||
ini_set('error_log', (string) $previous);
|
||
}
|
||
|
||
self::assertInstanceOf(WP_Error::class, $result);
|
||
self::assertSame('lit_internal_error', $result->get_error_code());
|
||
self::assertStringNotContainsString('/var/www', $result->get_error_message());
|
||
self::assertStringContainsString('lit/test failed', (string) file_get_contents($log));
|
||
unlink($log);
|
||
}
|
||
|
||
public function testRegisterAbilitiesCallsWordPressForEachAbility(): void
|
||
{
|
||
$ability = $this->ability();
|
||
Functions\expect('wp_register_ability')->once()->with('lit/test', Mockery::type('array'));
|
||
|
||
$this->registrarFor($ability)->registerAbilities();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run – muss fehlschlagen**
|
||
|
||
Run: `composer test -- --filter 'AbilityRegistrarTest|IntegrationRegistryTest'`
|
||
Expected: FAIL, Klassen/Interfaces nicht gefunden.
|
||
|
||
- [ ] **Step 3: Implementierung**
|
||
|
||
`src/Abilities/Ability.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Abilities;
|
||
|
||
use WP_Error;
|
||
|
||
/**
|
||
* One capability of the MCP server, registered with the WordPress Abilities API.
|
||
*/
|
||
interface Ability
|
||
{
|
||
/** Ability name, e.g. "lit/guide". */
|
||
public function name(): string;
|
||
|
||
public function label(): string;
|
||
|
||
public function description(): string;
|
||
|
||
/** @return array<string, mixed> JSON Schema of the input. */
|
||
public function inputSchema(): array;
|
||
|
||
/** @return array<string, mixed>|null JSON Schema of the output, null for image results. */
|
||
public function outputSchema(): ?array;
|
||
|
||
/** @return array{readonly?: bool, destructive?: bool, idempotent?: bool} */
|
||
public function annotations(): array;
|
||
|
||
/** Whether the ability is listed as its own MCP tool (otherwise only via meta-tools). */
|
||
public function isDirect(): bool;
|
||
|
||
/** @param array<string, mixed> $input */
|
||
public function permission(array $input): bool|WP_Error;
|
||
|
||
/** @param array<string, mixed> $input */
|
||
public function execute(array $input): mixed;
|
||
}
|
||
```
|
||
|
||
`src/Abilities/Errors.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Abilities;
|
||
|
||
use WP_Error;
|
||
|
||
/**
|
||
* Factory for the error objects abilities return. The MCP adapter turns them
|
||
* into tool results with isError: true.
|
||
*/
|
||
final class Errors
|
||
{
|
||
public static function forbidden(string $message): WP_Error
|
||
{
|
||
return new WP_Error('lit_forbidden', $message, ['status' => 403]);
|
||
}
|
||
|
||
/** @param array<string, string> $fields */
|
||
public static function invalidInput(string $message, array $fields = []): WP_Error
|
||
{
|
||
return new WP_Error('lit_validation_failed', $message, ['status' => 400, 'fields' => $fields]);
|
||
}
|
||
|
||
public static function notFound(string $message): WP_Error
|
||
{
|
||
return new WP_Error('lit_not_found', $message, ['status' => 404]);
|
||
}
|
||
|
||
public static function internal(): WP_Error
|
||
{
|
||
return new WP_Error(
|
||
'lit_internal_error',
|
||
'Interner Fehler bei der Ausführung. Details stehen im PHP-Error-Log der Website.',
|
||
['status' => 500]
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/Integration.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations;
|
||
|
||
use LIT\MCP\Abilities\Ability;
|
||
|
||
/**
|
||
* A group of abilities that is only registered when its theme/plugin is active.
|
||
*/
|
||
interface Integration
|
||
{
|
||
public function id(): string;
|
||
|
||
public function isAvailable(): bool;
|
||
|
||
/** @return list<Ability> */
|
||
public function abilities(): array;
|
||
}
|
||
```
|
||
|
||
`src/Integrations/IntegrationRegistry.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations;
|
||
|
||
use LIT\MCP\Abilities\Ability;
|
||
|
||
final class IntegrationRegistry
|
||
{
|
||
/** @var list<Integration> */
|
||
private array $integrations;
|
||
|
||
public function __construct(Integration ...$integrations)
|
||
{
|
||
$this->integrations = array_values($integrations);
|
||
}
|
||
|
||
/** @return list<Integration> */
|
||
public function available(): array
|
||
{
|
||
return array_values(array_filter(
|
||
$this->integrations,
|
||
static fn (Integration $integration): bool => $integration->isAvailable()
|
||
));
|
||
}
|
||
|
||
/** @return list<Ability> */
|
||
public function abilities(): array
|
||
{
|
||
$abilities = [];
|
||
foreach ($this->available() as $integration) {
|
||
array_push($abilities, ...$integration->abilities());
|
||
}
|
||
|
||
return $abilities;
|
||
}
|
||
|
||
/** @return list<string> */
|
||
public function directAbilityNames(): array
|
||
{
|
||
$names = [];
|
||
foreach ($this->abilities() as $ability) {
|
||
if ($ability->isDirect()) {
|
||
$names[] = $ability->name();
|
||
}
|
||
}
|
||
|
||
return $names;
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Abilities/AbilityRegistrar.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Abilities;
|
||
|
||
use LIT\MCP\Access;
|
||
use LIT\MCP\Integrations\IntegrationRegistry;
|
||
use Throwable;
|
||
use WP_Error;
|
||
|
||
/**
|
||
* Registers all abilities of available integrations with the Abilities API and
|
||
* wraps them with the role gate and neutral error handling.
|
||
*/
|
||
final class AbilityRegistrar
|
||
{
|
||
public const CATEGORY = 'lit-mcp';
|
||
|
||
public function __construct(private IntegrationRegistry $registry)
|
||
{
|
||
}
|
||
|
||
public function register(): void
|
||
{
|
||
add_action('wp_abilities_api_categories_init', [$this, 'registerCategory']);
|
||
add_action('wp_abilities_api_init', [$this, 'registerAbilities']);
|
||
}
|
||
|
||
public function registerCategory(): void
|
||
{
|
||
wp_register_ability_category(self::CATEGORY, [
|
||
'label' => 'LIT MCP',
|
||
'description' => 'Website-Pflege für WordPress, Divi und Pods über MCP.',
|
||
]);
|
||
}
|
||
|
||
public function registerAbilities(): void
|
||
{
|
||
foreach ($this->registry->abilities() as $ability) {
|
||
wp_register_ability($ability->name(), $this->argsFor($ability));
|
||
}
|
||
}
|
||
|
||
/** @return array<string, mixed> */
|
||
public function argsFor(Ability $ability): array
|
||
{
|
||
$args = [
|
||
'label' => $ability->label(),
|
||
'description' => $ability->description(),
|
||
'category' => self::CATEGORY,
|
||
'input_schema' => $ability->inputSchema(),
|
||
'permission_callback' => static function (mixed $input = null) use ($ability): bool|WP_Error {
|
||
if (! Access::currentUserMayUse()) {
|
||
return Errors::forbidden('Dein Benutzerkonto ist für LIT MCP nicht freigegeben.');
|
||
}
|
||
|
||
return $ability->permission(is_array($input) ? $input : []);
|
||
},
|
||
'execute_callback' => static function (mixed $input = null) use ($ability): mixed {
|
||
try {
|
||
return $ability->execute(is_array($input) ? $input : []);
|
||
} catch (Throwable $e) {
|
||
error_log(sprintf('[wp-lit-mcp] %s failed: %s', $ability->name(), $e)); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
|
||
return Errors::internal();
|
||
}
|
||
},
|
||
'meta' => [
|
||
'annotations' => $ability->annotations(),
|
||
'show_in_rest' => false,
|
||
'mcp' => ['public' => true, 'type' => 'tool'],
|
||
],
|
||
];
|
||
|
||
$output = $ability->outputSchema();
|
||
if ($output !== null) {
|
||
$args['output_schema'] = $output;
|
||
}
|
||
|
||
return $args;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run – muss bestehen**
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: alle Tests PASS, phpcs sauber.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/Abilities src/Integrations tests/Unit/Abilities tests/Unit/Integrations
|
||
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: ability and integration infrastructure
|
||
|
||
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Guides und Server-Instructions
|
||
|
||
**Files:**
|
||
- Create: `src/Guides/GuideRepository.php`, `src/Guides/InstructionsBuilder.php`, `src/Integrations/Core/GuideAbility.php`, `docs/guides/overview.md`, `tests/Unit/Guides/GuideRepositoryTest.php`, `tests/Unit/Guides/InstructionsBuilderTest.php`, `tests/Unit/Integrations/Core/GuideAbilityTest.php`, `tests/Fixtures/guides/overview.md`, `tests/Fixtures/guides/divi-shortform.md`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Ability`, `Errors` (Task 3), `IntegrationRegistry::available()` (Task 3).
|
||
- Produces:
|
||
- `GuideRepository::__construct(string $directory)`, `topics(): list<string>` (sortiert), `get(string $topic): ?string`.
|
||
- `InstructionsBuilder::__construct(IntegrationRegistry $registry)`, `build(): string`.
|
||
- `GuideAbility::__construct(GuideRepository $guides)`; Name `lit/guide`; Output `{topic: string, topics: list<string>, content: string}`.
|
||
|
||
- [ ] **Step 1: Fixtures anlegen**
|
||
|
||
`tests/Fixtures/guides/overview.md`:
|
||
```markdown
|
||
# Übersicht
|
||
Testinhalt Übersicht.
|
||
```
|
||
|
||
`tests/Fixtures/guides/divi-shortform.md`:
|
||
```markdown
|
||
# Divi-Kurzform
|
||
Testinhalt Kurzform.
|
||
```
|
||
|
||
- [ ] **Step 2: Failing Tests**
|
||
|
||
`tests/Unit/Guides/GuideRepositoryTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Guides;
|
||
|
||
use LIT\MCP\Guides\GuideRepository;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
|
||
final class GuideRepositoryTest extends TestCase
|
||
{
|
||
private GuideRepository $guides;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
$this->guides = new GuideRepository(dirname(__DIR__, 2) . '/Fixtures/guides');
|
||
}
|
||
|
||
public function testListsTopicsSortedWithoutExtension(): void
|
||
{
|
||
self::assertSame(['divi-shortform', 'overview'], $this->guides->topics());
|
||
}
|
||
|
||
public function testReturnsMarkdownOfKnownTopic(): void
|
||
{
|
||
self::assertStringContainsString('Testinhalt Kurzform', (string) $this->guides->get('divi-shortform'));
|
||
}
|
||
|
||
/**
|
||
* @dataProvider maliciousTopics
|
||
*/
|
||
public function testRejectsAnythingThatIsNotAPlainTopicName(string $topic): void
|
||
{
|
||
self::assertNull($this->guides->get($topic));
|
||
}
|
||
|
||
/** @return array<string, array{string}> */
|
||
public static function maliciousTopics(): array
|
||
{
|
||
return [
|
||
'traversal' => ['../../wp-config'],
|
||
'with extension' => ['overview.md'],
|
||
'empty' => [''],
|
||
'absolute' => ['/etc/passwd'],
|
||
'unknown' => ['does-not-exist'],
|
||
'uppercase' => ['OVERVIEW'],
|
||
];
|
||
}
|
||
|
||
public function testMissingDirectoryYieldsNoTopics(): void
|
||
{
|
||
self::assertSame([], (new GuideRepository('/nonexistent/dir'))->topics());
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Guides/InstructionsBuilderTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Guides;
|
||
|
||
use LIT\MCP\Guides\InstructionsBuilder;
|
||
use LIT\MCP\Integrations\Integration;
|
||
use LIT\MCP\Integrations\IntegrationRegistry;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
|
||
final class InstructionsBuilderTest extends TestCase
|
||
{
|
||
public function testNamesActiveIntegrationsAndCoreRules(): void
|
||
{
|
||
$core = Mockery::mock(Integration::class);
|
||
$core->allows('id')->andReturn('core');
|
||
$core->allows('isAvailable')->andReturn(true);
|
||
|
||
$text = (new InstructionsBuilder(new IntegrationRegistry($core)))->build();
|
||
|
||
self::assertStringContainsString('Aktive Integrationen: core', $text);
|
||
self::assertStringContainsString('lit-guide', $text);
|
||
self::assertStringContainsString('Status', $text);
|
||
self::assertLessThan(2000, strlen($text), 'Instructions must stay short – they are loaded in every conversation.');
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/Core/GuideAbilityTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Core;
|
||
|
||
use LIT\MCP\Guides\GuideRepository;
|
||
use LIT\MCP\Integrations\Core\GuideAbility;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use WP_Error;
|
||
|
||
final class GuideAbilityTest extends TestCase
|
||
{
|
||
private GuideAbility $ability;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
$this->ability = new GuideAbility(new GuideRepository(dirname(__DIR__, 3) . '/Fixtures/guides'));
|
||
}
|
||
|
||
public function testWithoutTopicReturnsOverviewAndTopicList(): void
|
||
{
|
||
$result = $this->ability->execute([]);
|
||
|
||
self::assertSame('overview', $result['topic']);
|
||
self::assertSame(['divi-shortform', 'overview'], $result['topics']);
|
||
self::assertStringContainsString('Testinhalt Übersicht', $result['content']);
|
||
}
|
||
|
||
public function testReturnsRequestedTopic(): void
|
||
{
|
||
$result = $this->ability->execute(['topic' => 'divi-shortform']);
|
||
|
||
self::assertStringContainsString('Testinhalt Kurzform', $result['content']);
|
||
}
|
||
|
||
public function testUnknownTopicIsNotFoundErrorListingTopics(): void
|
||
{
|
||
$result = $this->ability->execute(['topic' => '../../wp-config']);
|
||
|
||
self::assertInstanceOf(WP_Error::class, $result);
|
||
self::assertSame('lit_not_found', $result->get_error_code());
|
||
self::assertStringContainsString('divi-shortform', $result->get_error_message());
|
||
}
|
||
|
||
public function testIsDirectReadOnlyTool(): void
|
||
{
|
||
self::assertSame('lit/guide', $this->ability->name());
|
||
self::assertTrue($this->ability->isDirect());
|
||
self::assertTrue($this->ability->annotations()['readonly']);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Run – muss fehlschlagen**
|
||
|
||
Run: `composer test -- --filter 'GuideRepositoryTest|InstructionsBuilderTest|GuideAbilityTest'`
|
||
Expected: FAIL, Klassen nicht gefunden.
|
||
|
||
- [ ] **Step 4: Implementierung**
|
||
|
||
`src/Guides/GuideRepository.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Guides;
|
||
|
||
/**
|
||
* Reads guide markdown files from one directory. Topic names are restricted to
|
||
* lowercase slugs, so no path outside the directory can ever be read.
|
||
*/
|
||
final class GuideRepository
|
||
{
|
||
private const TOPIC_PATTERN = '/^[a-z0-9]+(?:-[a-z0-9]+)*$/';
|
||
|
||
public function __construct(private string $directory)
|
||
{
|
||
}
|
||
|
||
/** @return list<string> */
|
||
public function topics(): array
|
||
{
|
||
$files = glob($this->directory . '/*.md');
|
||
if ($files === false) {
|
||
return [];
|
||
}
|
||
|
||
$topics = [];
|
||
foreach ($files as $file) {
|
||
$topic = basename($file, '.md');
|
||
if (preg_match(self::TOPIC_PATTERN, $topic) === 1) {
|
||
$topics[] = $topic;
|
||
}
|
||
}
|
||
sort($topics);
|
||
|
||
return $topics;
|
||
}
|
||
|
||
public function get(string $topic): ?string
|
||
{
|
||
if (preg_match(self::TOPIC_PATTERN, $topic) !== 1 || ! in_array($topic, $this->topics(), true)) {
|
||
return null;
|
||
}
|
||
|
||
$content = file_get_contents($this->directory . '/' . $topic . '.md'); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
|
||
|
||
return $content === false ? null : $content;
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Guides/InstructionsBuilder.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Guides;
|
||
|
||
use LIT\MCP\Integrations\Integration;
|
||
use LIT\MCP\Integrations\IntegrationRegistry;
|
||
|
||
/**
|
||
* Builds the short server instructions every MCP client loads on connect.
|
||
* Keep this text short: it occupies context in every conversation.
|
||
*/
|
||
final class InstructionsBuilder
|
||
{
|
||
public function __construct(private IntegrationRegistry $registry)
|
||
{
|
||
}
|
||
|
||
public function build(): string
|
||
{
|
||
$active = implode(', ', array_map(
|
||
static fn (Integration $integration): string => $integration->id(),
|
||
$this->registry->available()
|
||
));
|
||
|
||
return implode("\n", [
|
||
'LIT MCP pflegt diese WordPress-Website (Inhalte, Medien, Divi 5, Pods).',
|
||
'Aktive Integrationen: ' . $active . '.',
|
||
'Regeln:',
|
||
'- Vor dem ersten Arbeitsschritt lit-guide (ohne Topic) aufrufen; Details je Thema über lit-guide mit topic.',
|
||
'- Änderungen erzeugen native Divi-Module; Variablen und Presets vor festen Werten; Code-Modul nur, wenn kein natives Modul passt.',
|
||
'- Den Status (Entwurf/veröffentlicht) nie ändern, außer der Nutzer verlangt es ausdrücklich.',
|
||
'- Schreib-Tools verlangen den aktuellen rev-Wert aus dem letzten Lesevorgang.',
|
||
'- Fehler enthalten einen hint – ihm folgen, statt blind zu wiederholen.',
|
||
]);
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/Core/GuideAbility.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Core;
|
||
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Guides\GuideRepository;
|
||
use WP_Error;
|
||
|
||
final class GuideAbility implements Ability
|
||
{
|
||
private const DEFAULT_TOPIC = 'overview';
|
||
|
||
public function __construct(private GuideRepository $guides)
|
||
{
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'lit/guide';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'Anleitung';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Liefert Anleitungen zur Bedienung dieses MCP-Servers. Ohne topic: Übersicht und Liste aller Themen.';
|
||
}
|
||
|
||
public function inputSchema(): array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'topic' => [
|
||
'type' => 'string',
|
||
'description' => 'Thema, z. B. "overview" oder "divi-shortform".',
|
||
],
|
||
],
|
||
'additionalProperties' => false,
|
||
];
|
||
}
|
||
|
||
public function outputSchema(): ?array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'topic' => ['type' => 'string'],
|
||
'topics' => ['type' => 'array', 'items' => ['type' => 'string']],
|
||
'content' => ['type' => 'string'],
|
||
],
|
||
'required' => ['topic', 'topics', 'content'],
|
||
];
|
||
}
|
||
|
||
public function annotations(): array
|
||
{
|
||
return ['readonly' => true, 'destructive' => false, 'idempotent' => true];
|
||
}
|
||
|
||
public function isDirect(): bool
|
||
{
|
||
return true;
|
||
}
|
||
|
||
public function permission(array $input): bool|WP_Error
|
||
{
|
||
return true;
|
||
}
|
||
|
||
public function execute(array $input): mixed
|
||
{
|
||
$topic = isset($input['topic']) && is_string($input['topic']) && $input['topic'] !== ''
|
||
? $input['topic']
|
||
: self::DEFAULT_TOPIC;
|
||
$topics = $this->guides->topics();
|
||
$content = $this->guides->get($topic);
|
||
|
||
if ($content === null) {
|
||
return Errors::notFound(sprintf(
|
||
'Unbekanntes Thema. Verfügbar: %s',
|
||
implode(', ', $topics)
|
||
));
|
||
}
|
||
|
||
return ['topic' => $topic, 'topics' => $topics, 'content' => $content];
|
||
}
|
||
}
|
||
```
|
||
|
||
`docs/guides/overview.md`:
|
||
```markdown
|
||
# LIT MCP – Übersicht
|
||
|
||
Dieser Server pflegt eine WordPress-Website. Alle Änderungen laufen mit den Rechten
|
||
des verbundenen WordPress-Benutzers.
|
||
|
||
## Grundregeln
|
||
|
||
1. **Divi-nativ:** Inhalte werden als echte Divi-5-Module erzeugt, damit sie im Visual
|
||
Builder weiter bearbeitet werden können. Das Code-Modul nur verwenden, wenn kein
|
||
natives Modul passt.
|
||
2. **Designsystem nutzen:** Globale Farben, Variablen und Presets der Website vor festen
|
||
Werten verwenden.
|
||
3. **Status respektieren:** Entwürfe bleiben Entwürfe, veröffentlichte Inhalte bleiben
|
||
veröffentlicht. Veröffentlichen nur auf ausdrücklichen Wunsch.
|
||
4. **Lesen vor Schreiben:** Vor Änderungen den aktuellen Stand lesen; Schreib-Tools
|
||
verlangen den `rev` des letzten Lesevorgangs.
|
||
|
||
## Verfügbare Werkzeuge (Etappe 0)
|
||
|
||
- `lit-guide` – diese Anleitungen. Weitere Themen: siehe Feld `topics`.
|
||
- `lit-site-status` – Überblick über Website, aktive Integrationen und eigene Rechte.
|
||
|
||
Weitere Werkzeuge kommen mit den nächsten Ausbaustufen hinzu.
|
||
```
|
||
|
||
- [ ] **Step 5: Run – muss bestehen**
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: alle Tests PASS, phpcs sauber.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/Guides src/Integrations/Core/GuideAbility.php docs/guides tests/Unit/Guides tests/Unit/Integrations/Core tests/Fixtures
|
||
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: guide tool and server instructions
|
||
|
||
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Test-Tool `lit/site-status` und Core-Integration
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/Core/EnvironmentProbe.php`, `src/Integrations/Core/SiteStatusAbility.php`, `src/Integrations/Core/CoreIntegration.php`, `tests/Unit/Integrations/Core/SiteStatusAbilityTest.php`, `tests/Unit/Integrations/Core/CoreIntegrationTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Ability`, `Integration` (Task 3), `GuideAbility`, `GuideRepository` (Task 4), `Access` (Task 2).
|
||
- Produces:
|
||
- `EnvironmentProbe::components(): list<array{id: string, active: bool, version: ?string}>` für `divi`, `pods`, `the-events-calendar`.
|
||
- `SiteStatusAbility::__construct(EnvironmentProbe $probe, Access $access)`; Name `lit/site-status`; Output `{site: {name, url}, wordpress: string, user: {id, roles}, allowed_roles: list<string>, components: list<…>}`.
|
||
- `CoreIntegration::__construct(string $guidesDirectory)`; `id()` = `core`; immer verfügbar; liefert `[GuideAbility, SiteStatusAbility]`.
|
||
|
||
- [ ] **Step 1: Failing Tests**
|
||
|
||
`tests/Unit/Integrations/Core/SiteStatusAbilityTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Core;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Access;
|
||
use LIT\MCP\Integrations\Core\EnvironmentProbe;
|
||
use LIT\MCP\Integrations\Core\SiteStatusAbility;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
use WP_User;
|
||
|
||
final class SiteStatusAbilityTest extends TestCase
|
||
{
|
||
public function testReportsSiteUserAndComponents(): void
|
||
{
|
||
Functions\when('get_bloginfo')->alias(static fn (string $key) => ['name' => 'Kunde', 'version' => '7.0.3'][$key]);
|
||
Functions\when('home_url')->justReturn('https://kunde.de');
|
||
Functions\when('wp_get_current_user')->justReturn(new WP_User(3, ['editor']));
|
||
Functions\when('get_option')->justReturn(['administrator', 'editor']);
|
||
|
||
// EnvironmentProbe is deliberately non-final so it can be mocked; Access is final, so use the real one.
|
||
$probe = Mockery::mock(EnvironmentProbe::class);
|
||
$probe->allows('components')->andReturn([['id' => 'divi', 'active' => true, 'version' => '5.2.1']]);
|
||
|
||
$result = (new SiteStatusAbility($probe, new Access()))->execute([]);
|
||
|
||
self::assertSame(['name' => 'Kunde', 'url' => 'https://kunde.de'], $result['site']);
|
||
self::assertSame('7.0.3', $result['wordpress']);
|
||
self::assertSame(['id' => 3, 'roles' => ['editor']], $result['user']);
|
||
self::assertSame(['administrator', 'editor'], $result['allowed_roles']);
|
||
self::assertSame('divi', $result['components'][0]['id']);
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/Core/CoreIntegrationTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Core;
|
||
|
||
use LIT\MCP\Integrations\Core\CoreIntegration;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
|
||
final class CoreIntegrationTest extends TestCase
|
||
{
|
||
public function testIsAlwaysAvailableAndProvidesGuideAndStatus(): void
|
||
{
|
||
$core = new CoreIntegration(dirname(__DIR__, 3) . '/Fixtures/guides');
|
||
|
||
self::assertSame('core', $core->id());
|
||
self::assertTrue($core->isAvailable());
|
||
self::assertSame(
|
||
['lit/guide', 'lit/site-status'],
|
||
array_map(static fn ($a) => $a->name(), $core->abilities())
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run – muss fehlschlagen**
|
||
|
||
Run: `composer test -- --filter 'SiteStatusAbilityTest|CoreIntegrationTest'`
|
||
Expected: FAIL, Klassen nicht gefunden.
|
||
|
||
- [ ] **Step 3: Implementierung**
|
||
|
||
`src/Integrations/Core/EnvironmentProbe.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Core;
|
||
|
||
/**
|
||
* Detects the themes/plugins LIT MCP integrates with.
|
||
*/
|
||
class EnvironmentProbe
|
||
{
|
||
/** @return list<array{id: string, active: bool, version: ?string}> */
|
||
public function components(): array
|
||
{
|
||
$theme = wp_get_theme();
|
||
$isDivi = $theme->get_template() === 'Divi';
|
||
$parent = $theme->parent();
|
||
$diviVersion = $isDivi ? (string) ($parent ? $parent->get('Version') : $theme->get('Version')) : null;
|
||
|
||
$tecActive = class_exists('Tribe__Events__Main');
|
||
|
||
return [
|
||
['id' => 'divi', 'active' => $isDivi, 'version' => $diviVersion],
|
||
['id' => 'pods', 'active' => defined('PODS_VERSION'), 'version' => defined('PODS_VERSION') ? (string) PODS_VERSION : null],
|
||
[
|
||
'id' => 'the-events-calendar',
|
||
'active' => $tecActive,
|
||
'version' => $tecActive ? (string) constant('Tribe__Events__Main::VERSION') : null,
|
||
],
|
||
];
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/Core/SiteStatusAbility.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Core;
|
||
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Access;
|
||
use WP_Error;
|
||
|
||
final class SiteStatusAbility implements Ability
|
||
{
|
||
public function __construct(private EnvironmentProbe $probe, private Access $access)
|
||
{
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'lit/site-status';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'Website-Status';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Überblick: Website, WordPress-Version, eigener Benutzer und Rollen, erkannte Integrationen (Divi, Pods, The Events Calendar).';
|
||
}
|
||
|
||
public function inputSchema(): array
|
||
{
|
||
return ['type' => 'object', 'properties' => [], 'additionalProperties' => false];
|
||
}
|
||
|
||
public function outputSchema(): ?array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'site' => ['type' => 'object'],
|
||
'wordpress' => ['type' => 'string'],
|
||
'user' => ['type' => 'object'],
|
||
'allowed_roles' => ['type' => 'array', 'items' => ['type' => 'string']],
|
||
'components' => ['type' => 'array'],
|
||
],
|
||
'required' => ['site', 'wordpress', 'user', 'allowed_roles', 'components'],
|
||
];
|
||
}
|
||
|
||
public function annotations(): array
|
||
{
|
||
return ['readonly' => true, 'destructive' => false, 'idempotent' => true];
|
||
}
|
||
|
||
public function isDirect(): bool
|
||
{
|
||
return true;
|
||
}
|
||
|
||
public function permission(array $input): bool|WP_Error
|
||
{
|
||
return true;
|
||
}
|
||
|
||
public function execute(array $input): mixed
|
||
{
|
||
$user = wp_get_current_user();
|
||
|
||
return [
|
||
'site' => ['name' => get_bloginfo('name'), 'url' => home_url()],
|
||
'wordpress' => get_bloginfo('version'),
|
||
'user' => ['id' => $user->ID, 'roles' => array_values($user->roles)],
|
||
'allowed_roles' => $this->access->allowedRoles(),
|
||
'components' => $this->probe->components(),
|
||
];
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/Core/CoreIntegration.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Core;
|
||
|
||
use LIT\MCP\Access;
|
||
use LIT\MCP\Guides\GuideRepository;
|
||
use LIT\MCP\Integrations\Integration;
|
||
|
||
final class CoreIntegration implements Integration
|
||
{
|
||
public function __construct(private string $guidesDirectory)
|
||
{
|
||
}
|
||
|
||
public function id(): string
|
||
{
|
||
return 'core';
|
||
}
|
||
|
||
public function isAvailable(): bool
|
||
{
|
||
return true;
|
||
}
|
||
|
||
public function abilities(): array
|
||
{
|
||
return [
|
||
new GuideAbility(new GuideRepository($this->guidesDirectory)),
|
||
new SiteStatusAbility(new EnvironmentProbe(), new Access()),
|
||
];
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run – muss bestehen**
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: alle Tests PASS, phpcs sauber.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Core tests/Unit/Integrations/Core
|
||
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: site-status test tool and core integration
|
||
|
||
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Server-Registrierung und vollständiger Boot
|
||
|
||
**Files:**
|
||
- Create: `src/Server/ServerRegistrar.php`, `tests/Unit/Server/ServerRegistrarTest.php`
|
||
- Modify: `src/Plugin.php` (vollständiger Boot)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `IntegrationRegistry::directAbilityNames()`, `AbilityRegistrar` (Task 3), `InstructionsBuilder::build()` (Task 4), `CoreIntegration` (Task 5), `Access` (Task 2), `Dependencies` (Task 1).
|
||
- Produces:
|
||
- `ServerRegistrar::SERVER_ID = 'mcp-oauth-server'`, `NAMESPACE = 'mcp'`, `ROUTE = 'mcp-oauth-server'`, `META_TOOLS = ['mcp-adapter/discover-abilities', 'mcp-adapter/get-ability-info', 'mcp-adapter/execute-ability']`.
|
||
- `ServerRegistrar::__construct(IntegrationRegistry $registry, InstructionsBuilder $instructions, ?callable $oauthEnabled = null)`, `register(): void`, `serverArguments(): ?array` (null = nicht registrieren), `createServer(): void`.
|
||
|
||
- [ ] **Step 1: Failing Tests**
|
||
|
||
`tests/Unit/Server/ServerRegistrarTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Server;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Guides\InstructionsBuilder;
|
||
use LIT\MCP\Integrations\Integration;
|
||
use LIT\MCP\Integrations\IntegrationRegistry;
|
||
use LIT\MCP\Server\ServerRegistrar;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
|
||
final class ServerRegistrarTest extends TestCase
|
||
{
|
||
private function registry(): IntegrationRegistry
|
||
{
|
||
$direct = Mockery::mock(Ability::class);
|
||
$direct->allows('name')->andReturn('lit/guide');
|
||
$direct->allows('isDirect')->andReturn(true);
|
||
$indirect = Mockery::mock(Ability::class);
|
||
$indirect->allows('name')->andReturn('lit/rare-tool');
|
||
$indirect->allows('isDirect')->andReturn(false);
|
||
|
||
$core = Mockery::mock(Integration::class);
|
||
$core->allows('id')->andReturn('core');
|
||
$core->allows('isAvailable')->andReturn(true);
|
||
$core->allows('abilities')->andReturn([$direct, $indirect]);
|
||
|
||
return new IntegrationRegistry($core);
|
||
}
|
||
|
||
private function instructions(): InstructionsBuilder
|
||
{
|
||
// InstructionsBuilder is final (not mockable) and pure – use the real one.
|
||
return new InstructionsBuilder($this->registry());
|
||
}
|
||
|
||
public function testServerUsesOauthRouteDirectToolsPlusMetaToolsAndInstructions(): void
|
||
{
|
||
$args = (new ServerRegistrar($this->registry(), $this->instructions(), static fn () => true))->serverArguments();
|
||
|
||
self::assertSame('mcp-oauth-server', $args['id']);
|
||
self::assertSame('mcp', $args['namespace']);
|
||
self::assertSame('mcp-oauth-server', $args['route']);
|
||
self::assertStringContainsString('Aktive Integrationen: core', $args['description']);
|
||
self::assertSame(
|
||
['lit/guide', 'mcp-adapter/discover-abilities', 'mcp-adapter/get-ability-info', 'mcp-adapter/execute-ability'],
|
||
$args['tools']
|
||
);
|
||
self::assertNotContains('lit/rare-tool', $args['tools']);
|
||
}
|
||
|
||
public function testNoServerWhenOauthIsDisabled(): void
|
||
{
|
||
$registrar = new ServerRegistrar($this->registry(), $this->instructions(), static fn () => false);
|
||
|
||
self::assertNull($registrar->serverArguments());
|
||
}
|
||
|
||
public function testCreateServerOutsideAdapterInitDoesNothing(): void
|
||
{
|
||
Functions\when('doing_action')->justReturn(false);
|
||
$oauthChecked = false;
|
||
$registrar = new ServerRegistrar(
|
||
$this->registry(),
|
||
$this->instructions(),
|
||
static function () use (&$oauthChecked): bool {
|
||
$oauthChecked = true;
|
||
return true;
|
||
}
|
||
);
|
||
|
||
$registrar->createServer();
|
||
|
||
self::assertFalse($oauthChecked, 'createServer must bail out before touching the adapter');
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run – muss fehlschlagen**
|
||
|
||
Run: `composer test -- --filter ServerRegistrarTest`
|
||
Expected: FAIL, Klasse nicht gefunden.
|
||
|
||
- [ ] **Step 3: Implementierung**
|
||
|
||
`src/Server/ServerRegistrar.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Server;
|
||
|
||
use LIT\MCP\Guides\InstructionsBuilder;
|
||
use LIT\MCP\Integrations\IntegrationRegistry;
|
||
use WP\MCP\Core\McpAdapter;
|
||
use WP\MCP\Infrastructure\ErrorHandling\ErrorLogMcpErrorHandler;
|
||
use WPMedia\MCP\OAuth\Transport\OAuthHttpTransport;
|
||
|
||
/**
|
||
* Registers the OAuth-protected MCP server with our own tool list.
|
||
*
|
||
* wp-media/mcp-oauth hardcodes server id/route (token audience) and only three
|
||
* meta-tools. We register the same server id first (priority 5 < mcp-oauth's 10);
|
||
* mcp-oauth's later attempt fails with "duplicate server id" (logged via
|
||
* _doing_it_wrong in debug mode only) – see spec §4.2.
|
||
*/
|
||
final class ServerRegistrar
|
||
{
|
||
public const SERVER_ID = 'mcp-oauth-server';
|
||
public const NAMESPACE = 'mcp';
|
||
public const ROUTE = 'mcp-oauth-server';
|
||
public const META_TOOLS = [
|
||
'mcp-adapter/discover-abilities',
|
||
'mcp-adapter/get-ability-info',
|
||
'mcp-adapter/execute-ability',
|
||
];
|
||
|
||
/** @var callable(): bool */
|
||
private $oauthEnabled;
|
||
|
||
public function __construct(
|
||
private IntegrationRegistry $registry,
|
||
private InstructionsBuilder $instructions,
|
||
?callable $oauthEnabled = null
|
||
) {
|
||
$this->oauthEnabled = $oauthEnabled ?? static fn (): bool => (bool) apply_filters('wpmedia_mcp_oauth_server_enabled', true);
|
||
}
|
||
|
||
public function register(): void
|
||
{
|
||
add_action('mcp_adapter_init', [$this, 'createServer'], 5);
|
||
}
|
||
|
||
/** @return array<string, mixed>|null */
|
||
public function serverArguments(): ?array
|
||
{
|
||
if (! ($this->oauthEnabled)()) {
|
||
return null;
|
||
}
|
||
|
||
return [
|
||
'id' => self::SERVER_ID,
|
||
'namespace' => self::NAMESPACE,
|
||
'route' => self::ROUTE,
|
||
'name' => 'LIT MCP',
|
||
'description' => $this->instructions->build(),
|
||
'version' => '0.1.0',
|
||
'tools' => array_merge($this->registry->directAbilityNames(), self::META_TOOLS),
|
||
];
|
||
}
|
||
|
||
public function createServer(): void
|
||
{
|
||
if (
|
||
! doing_action('mcp_adapter_init')
|
||
|| ! class_exists(McpAdapter::class)
|
||
|| ! class_exists(OAuthHttpTransport::class)
|
||
) {
|
||
return;
|
||
}
|
||
|
||
$args = $this->serverArguments();
|
||
if ($args === null) {
|
||
return;
|
||
}
|
||
|
||
$result = McpAdapter::instance()->create_server(
|
||
$args['id'],
|
||
$args['namespace'],
|
||
$args['route'],
|
||
$args['name'],
|
||
$args['description'],
|
||
$args['version'],
|
||
[OAuthHttpTransport::class],
|
||
ErrorLogMcpErrorHandler::class,
|
||
null,
|
||
$args['tools']
|
||
);
|
||
|
||
if (is_wp_error($result)) {
|
||
error_log('[wp-lit-mcp] MCP server registration failed: ' . $result->get_error_message()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Plugin.php` – `boot()` vollständig ersetzen durch:
|
||
```php
|
||
public static function boot(string $pluginFile): void
|
||
{
|
||
self::$pluginFile = $pluginFile;
|
||
|
||
$missing = (new Dependencies())->missing();
|
||
if ($missing !== []) {
|
||
self::noticeMissing($missing);
|
||
return;
|
||
}
|
||
|
||
(new Access())->register();
|
||
|
||
$registry = new IntegrationRegistry(
|
||
new CoreIntegration(self::pluginDir() . '/docs/guides')
|
||
);
|
||
|
||
(new AbilityRegistrar($registry))->register();
|
||
(new ServerRegistrar($registry, new InstructionsBuilder($registry)))->register();
|
||
|
||
\WPMedia\MCP\OAuth\Bootstrap::instance();
|
||
}
|
||
```
|
||
und die `use`-Zeilen ergänzen:
|
||
```php
|
||
use LIT\MCP\Abilities\AbilityRegistrar;
|
||
use LIT\MCP\Guides\InstructionsBuilder;
|
||
use LIT\MCP\Integrations\Core\CoreIntegration;
|
||
use LIT\MCP\Integrations\IntegrationRegistry;
|
||
use LIT\MCP\Server\ServerRegistrar;
|
||
```
|
||
|
||
Hinweis: `Plugin::boot()` läuft auf `plugins_loaded` (siehe `wp-lit-mcp.php`) – das ist der von mcp-oauth empfohlene Zeitpunkt (spätestens vor `rest_api_init`@15).
|
||
|
||
- [ ] **Step 4: Run – muss bestehen**
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: alle Tests PASS, phpcs sauber.
|
||
|
||
- [ ] **Step 5: Smoke-Test in WordPress**
|
||
|
||
Run:
|
||
```bash
|
||
cd /home/janniclas/Projekte/WordPress && ddev wp plugin activate WP-LIT-MCP && ddev wp eval '
|
||
do_action("rest_api_init");
|
||
$s = \WP\MCP\Core\McpAdapter::instance()->get_server("mcp-oauth-server");
|
||
echo $s ? "server ok: " . implode(",", array_keys($s->get_tools())) : "NO SERVER";'
|
||
```
|
||
Expected: `server ok: ` gefolgt von Tool-Namen, die `lit-guide` und `lit-site-status` enthalten. Falls `get_server`/`get_tools` in Adapter 0.5 anders heißen: in `vendor/wordpress/mcp-adapter/includes/Core/McpAdapter.php` und `McpServer.php` die Getter nachsehen und den Befehl anpassen – das Ergebnis muss dasselbe zeigen.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/Server src/Plugin.php tests/Unit/Server
|
||
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: register OAuth MCP server with direct tools
|
||
|
||
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Ende-zu-Ende-Test über HTTP (Token, Rollen-Gate, Scope)
|
||
|
||
**Files:**
|
||
- Create: `tests/Integration/mint-token.php`, `tests/Integration/mcp-smoke.sh`
|
||
|
||
**Interfaces:**
|
||
- Consumes: laufendes Plugin (Task 6), mcp-oauth-Klassen `WPMedia\MCP\OAuth\Auth\JWT::encode(array $payload, string $secret): string`, `WPMedia\MCP\OAuth\Auth\SecretManager::get_secret(): string`.
|
||
- Produces: `bash tests/Integration/mcp-smoke.sh` – Exit-Code 0 bei Erfolg; wird in Etappe 1+ wiederverwendet.
|
||
|
||
- [ ] **Step 1: Token-Skript**
|
||
|
||
`tests/Integration/mint-token.php` (läuft über `wp eval-file`, Argument = Benutzer-Login):
|
||
```php
|
||
<?php
|
||
|
||
/**
|
||
* Mints an MCP access token for a user, exactly like mcp-oauth's TokenEndpoint
|
||
* does after a completed OAuth flow. For local integration tests only.
|
||
*
|
||
* Usage: wp eval-file tests/Integration/mint-token.php <user_login>
|
||
*/
|
||
|
||
declare(strict_types=1);
|
||
|
||
use WPMedia\MCP\OAuth\Auth\JWT;
|
||
use WPMedia\MCP\OAuth\Auth\SecretManager;
|
||
|
||
$login = $args[0] ?? '';
|
||
$user = get_user_by('login', $login);
|
||
if (! $user) {
|
||
fwrite(STDERR, "unknown user {$login}\n");
|
||
exit(1);
|
||
}
|
||
|
||
[, $item] = WP_Application_Passwords::create_new_application_password(
|
||
$user->ID,
|
||
['name' => 'lit-mcp-integration-test', 'app_id' => 'lit-mcp-integration-test']
|
||
);
|
||
|
||
$now = time();
|
||
echo JWT::encode([
|
||
'iss' => home_url(),
|
||
'aud' => get_rest_url(null, 'mcp/mcp-oauth-server'),
|
||
'sub' => (string) $user->ID,
|
||
'app_pass_id' => $item['uuid'],
|
||
'client_id' => 'lit-mcp-integration-test',
|
||
'scope' => 'mcp',
|
||
'iat' => $now,
|
||
'exp' => $now + 600,
|
||
], SecretManager::get_secret());
|
||
```
|
||
|
||
- [ ] **Step 2: Smoke-Skript**
|
||
|
||
`tests/Integration/mcp-smoke.sh`:
|
||
```bash
|
||
#!/usr/bin/env bash
|
||
# End-to-end checks of the OAuth-protected MCP endpoint.
|
||
# Runs INSIDE the DDEV web container (wp-cli and curl available there):
|
||
# cd /home/janniclas/Projekte/WordPress && ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/mcp-smoke.sh
|
||
set -euo pipefail
|
||
|
||
PLUGIN=wp-content/plugins/WP-LIT-MCP
|
||
ENDPOINT=http://localhost/wp-json/mcp/mcp-oauth-server
|
||
cd /var/www/html
|
||
|
||
fail() { echo "FAIL: $*"; exit 1; }
|
||
pass() { echo "ok - $*"; }
|
||
|
||
mcp() { # $1 token, $2 json body, $3 session id (optional) -> prints headers+body
|
||
local session_header=()
|
||
[ -n "${3:-}" ] && session_header=(-H "Mcp-Session-Id: $3")
|
||
curl -s -i -X POST "$ENDPOINT" \
|
||
-H "Authorization: Bearer $1" \
|
||
-H 'Content-Type: application/json' \
|
||
-H 'Accept: application/json, text/event-stream' \
|
||
"${session_header[@]}" -d "$2"
|
||
}
|
||
|
||
# 1. Discovery documents
|
||
curl -s http://localhost/.well-known/oauth-protected-resource | grep -q '"resource"' \
|
||
|| fail "protected-resource metadata missing"
|
||
pass "RFC 9728 protected-resource metadata served"
|
||
curl -s http://localhost/.well-known/oauth-authorization-server | grep -q 'client_id_metadata_document_supported' \
|
||
|| fail "authorization-server metadata missing CIMD flag"
|
||
pass "RFC 8414 authorization-server metadata advertises CIMD"
|
||
|
||
# 2. Unauthenticated request -> 401 with WWW-Authenticate
|
||
UNAUTH=$(curl -s -i -X POST "$ENDPOINT" -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}')
|
||
echo "$UNAUTH" | head -1 | grep -q ' 401' || fail "expected 401 without token"
|
||
echo "$UNAUTH" | grep -qi '^www-authenticate: Bearer' || fail "missing WWW-Authenticate header"
|
||
pass "401 + WWW-Authenticate without token"
|
||
|
||
# 3. Administrator: initialize, tools/list, tools/call
|
||
ADMIN_LOGIN=$(wp user list --role=administrator --field=user_login | head -1)
|
||
TOKEN=$(wp eval-file "$PLUGIN/tests/Integration/mint-token.php" "$ADMIN_LOGIN")
|
||
INIT=$(mcp "$TOKEN" '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}')
|
||
SESSION=$(echo "$INIT" | grep -i '^mcp-session-id:' | awk '{print $2}' | tr -d '\r')
|
||
echo "$INIT" | grep -q 'LIT MCP pflegt' || fail "instructions missing in initialize result"
|
||
pass "initialize returns LIT instructions (session: ${SESSION:-none})"
|
||
|
||
LIST=$(mcp "$TOKEN" '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' "$SESSION")
|
||
for tool in lit-guide lit-site-status mcp-adapter-execute-ability; do
|
||
echo "$LIST" | grep -q "\"$tool\"" || fail "tools/list lacks $tool"
|
||
done
|
||
pass "tools/list contains direct tools and meta-tools"
|
||
|
||
CALL=$(mcp "$TOKEN" '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"lit-site-status","arguments":{}}}' "$SESSION")
|
||
echo "$CALL" | grep -q '"isError":false' || fail "lit-site-status failed: $CALL"
|
||
echo "$CALL" | grep -q 'the-events-calendar' || fail "site-status lacks components"
|
||
pass "tools/call lit-site-status works"
|
||
|
||
# 4. Token must not authenticate other REST routes (mcp-oauth issue #62)
|
||
ME=$(curl -s -o /dev/null -w '%{http_code}' http://localhost/wp-json/wp/v2/users/me -H "Authorization: Bearer $TOKEN")
|
||
[ "$ME" = "401" ] || fail "MCP token authenticates /wp/v2/users/me (HTTP $ME) – scope filter needed"
|
||
pass "MCP token is rejected on /wp/v2/users/me"
|
||
|
||
# 5. Role gate: subscriber has a valid token but must not use tools
|
||
wp user create lit-smoke-sub lit-smoke-sub@example.invalid --role=subscriber >/dev/null
|
||
trap 'wp user delete lit-smoke-sub --yes >/dev/null 2>&1 || true' EXIT
|
||
SUB_TOKEN=$(wp eval-file "$PLUGIN/tests/Integration/mint-token.php" lit-smoke-sub)
|
||
SUB_INIT=$(mcp "$SUB_TOKEN" '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}')
|
||
SUB_SESSION=$(echo "$SUB_INIT" | grep -i '^mcp-session-id:' | awk '{print $2}' | tr -d '\r')
|
||
SUB_CALL=$(mcp "$SUB_TOKEN" '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"lit-site-status","arguments":{}}}' "$SUB_SESSION")
|
||
echo "$SUB_CALL" | grep -q '"isError":true\|"error"' || fail "subscriber could call lit-site-status: $SUB_CALL"
|
||
SUB_META=$(mcp "$SUB_TOKEN" '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"mcp-adapter-execute-ability","arguments":{"ability_name":"lit/site-status","parameters":{}}}}' "$SUB_SESSION")
|
||
echo "$SUB_META" | grep -q '"isError":true\|"error"' || fail "subscriber could use execute-ability: $SUB_META"
|
||
pass "role gate blocks subscriber (direct tool and meta-tool)"
|
||
|
||
echo "ALL CHECKS PASSED"
|
||
```
|
||
|
||
- [ ] **Step 3: Ausführen**
|
||
|
||
Run: `cd /home/janniclas/Projekte/WordPress && ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/mcp-smoke.sh`
|
||
Expected: alle Zeilen `ok - …`, am Ende `ALL CHECKS PASSED`.
|
||
|
||
Bei Abweichungen:
|
||
- `.well-known` 404 → `ddev wp rewrite flush` und erneut; bleibt es 404, im Ergebnisprotokoll (Task 9) festhalten.
|
||
- Tool-Namen anders als `lit-guide` → tatsächliche Namen aus der `tools/list`-Antwort übernehmen und Skript sowie `InstructionsBuilder`-Text angleichen (Spec: `lit-<bereich>-<aktion>`).
|
||
- Check 4 schlägt fehl → neue Task vor Task 8 einfügen: Filter auf `rest_authentication_errors`, der Requests mit `Authorization: Bearer` außerhalb der Route `/mcp/mcp-oauth-server` abweist (inkl. Unit-Test) – Spec §4.2.
|
||
- Argumentnamen des Meta-Tools `execute-ability` in Adapter 0.5 anders → aus `vendor/wordpress/mcp-adapter/includes/Abilities/ExecuteAbilityAbility.php` (`input_schema`) übernehmen.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add tests/Integration/mint-token.php tests/Integration/mcp-smoke.sh
|
||
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "test: end-to-end MCP smoke test against DDEV
|
||
|
||
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: The-Events-Calendar-Probe (generisches Anlegen)
|
||
|
||
**Files:**
|
||
- Create: `tests/Integration/tec-probe.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: DDEV-Installation.
|
||
- Produces: Befund „generisch anlegen funktioniert: ja/nein, fehlende abgeleitete Felder: …“ für Task 9.
|
||
|
||
- [ ] **Step 1: Probe-Skript**
|
||
|
||
`tests/Integration/tec-probe.php`:
|
||
```php
|
||
<?php
|
||
|
||
/**
|
||
* Checks whether a The Events Calendar event created only through generic
|
||
* WordPress APIs (wp_insert_post + post meta) is found by TEC's own queries.
|
||
*
|
||
* Usage: wp eval-file tests/Integration/tec-probe.php
|
||
*/
|
||
|
||
declare(strict_types=1);
|
||
|
||
if (! class_exists('Tribe__Events__Main')) {
|
||
fwrite(STDERR, "The Events Calendar is not active\n");
|
||
exit(1);
|
||
}
|
||
|
||
$start = gmdate('Y-m-d', strtotime('+30 days')) . ' 18:00:00';
|
||
$end = gmdate('Y-m-d', strtotime('+30 days')) . ' 20:00:00';
|
||
|
||
$id = wp_insert_post([
|
||
'post_type' => 'tribe_events',
|
||
'post_status' => 'publish',
|
||
'post_title' => 'LIT Probe Event (wegwerfen)',
|
||
]);
|
||
update_post_meta($id, '_EventStartDate', $start);
|
||
update_post_meta($id, '_EventEndDate', $end);
|
||
|
||
$derived = [];
|
||
foreach (['_EventStartDateUTC', '_EventEndDateUTC', '_EventDuration', '_EventTimezone'] as $key) {
|
||
$derived[$key] = get_post_meta($id, $key, true);
|
||
}
|
||
|
||
$found = tribe_get_events([
|
||
'start_date' => gmdate('Y-m-d', strtotime('+29 days')),
|
||
'end_date' => gmdate('Y-m-d', strtotime('+31 days')),
|
||
'fields' => 'ids',
|
||
]);
|
||
|
||
echo wp_json_encode([
|
||
'event_id' => $id,
|
||
'found_by_tribe_get_events' => in_array($id, (array) $found, true),
|
||
'derived_meta' => $derived,
|
||
], JSON_PRETTY_PRINT) . "\n";
|
||
|
||
wp_delete_post($id, true);
|
||
```
|
||
|
||
- [ ] **Step 2: TEC lokal installieren, Probe ausführen, aufräumen**
|
||
|
||
Run:
|
||
```bash
|
||
cd /home/janniclas/Projekte/WordPress \
|
||
&& ddev wp plugin install the-events-calendar --activate \
|
||
&& ddev wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/tec-probe.php \
|
||
; ddev wp plugin deactivate the-events-calendar && ddev wp plugin delete the-events-calendar
|
||
```
|
||
Expected: JSON mit `found_by_tribe_get_events` (true/false) und den abgeleiteten Meta-Werten. Beide Ergebnisse sind gültig – es ist ein Befund, kein Pass/Fail. Ausgabe für Task 9 kopieren.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add tests/Integration/tec-probe.php
|
||
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "test: probe generic event creation for The Events Calendar
|
||
|
||
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Realer claude.ai-Test, MCP Inspector, Entscheidung (manuell, mit Jan-Niclas)
|
||
|
||
**Files:**
|
||
- Create: `docs/superpowers/notes/2026-09-25-etappe-0-ergebnisse.md`
|
||
|
||
**Interfaces:**
|
||
- Consumes: Tasks 1–8.
|
||
- Produces: dokumentierte Entscheidungen für Etappe 1 (Anbindungsschicht; Events-Erweiterungspunkt; ggf. Scope-Filter).
|
||
|
||
Dieser Task erfordert Jan-Niclas (Browser, claude.ai-Konto, öffentliche HTTPS-URL). Der ausführende Agent bereitet vor und dokumentiert, führt aber keine Aktionen im claude.ai-Konto aus.
|
||
|
||
- [ ] **Step 1: Öffentliche HTTPS-URL bereitstellen**
|
||
|
||
Jan-Niclas führt aus: `cd /home/janniclas/Projekte/WordPress && ddev share` (oder nutzt eine Staging-Seite mit installiertem Plugin). Die ausgegebene `https://…`-URL notieren. Hinweis: Bei `ddev share` muss WordPress die öffentliche URL kennen; falls Weiterleitungen auf `wordpress.ddev.site` auftreten, stattdessen Staging verwenden und das im Ergebnisprotokoll vermerken.
|
||
|
||
- [ ] **Step 2: MCP Inspector**
|
||
|
||
Run (lokal, Jan-Niclas im Browser): `npx @modelcontextprotocol/inspector`
|
||
Im Inspector: Transport „Streamable HTTP“, URL `https://<öffentliche-url>/wp-json/mcp/mcp-oauth-server`, OAuth-Flow durchlaufen.
|
||
Prüfen und notieren: Verbindung erfolgreich; ausgehandelte Protokollversion; `lit-guide` und `lit-site-status` sichtbar mit Schema; Aufruf beider Tools erfolgreich; Annotations (`readOnlyHint`) vorhanden.
|
||
|
||
- [ ] **Step 3: claude.ai-Connector**
|
||
|
||
In claude.ai: Einstellungen → Connectors → „Add custom connector“ → URL `https://<öffentliche-url>/wp-json/mcp/mcp-oauth-server`. Anmeldung/Zustimmung auf der WordPress-Seite durchlaufen. In einem Chat: „Rufe lit-site-status auf“ und „Zeig mir die LIT-Anleitung“.
|
||
Notieren: Verbindung ok? Consent-Screen verständlich? Tools werden ohne Umweg über Meta-Tools genutzt? Werden die Server-Instructions befolgt (ruft Claude `lit-guide` von sich aus)?
|
||
|
||
- [ ] **Step 4: Autorisierungs-Details prüfen**
|
||
|
||
Im Browser-Netzwerk-Tab (oder Inspector-Log) beim OAuth-Flow nachsehen:
|
||
- enthält die Weiterleitung nach der Zustimmung einen `iss`-Parameter (RFC 9207)?
|
||
- wird der `resource`-Parameter (RFC 8707) akzeptiert?
|
||
Fehlende Punkte als Upstream-Issue bei `wp-media/mcp-oauth` notieren (nicht selbst öffnen, Jan-Niclas entscheidet).
|
||
|
||
- [ ] **Step 5: Ergebnisprotokoll schreiben**
|
||
|
||
`docs/superpowers/notes/2026-09-25-etappe-0-ergebnisse.md` mit genau diesen Abschnitten füllen:
|
||
```markdown
|
||
# Etappe 0 – Ergebnisse
|
||
|
||
## Automatische Tests
|
||
- Unit: <Anzahl> Tests, Ergebnis
|
||
- mcp-smoke.sh: <Ausgabe der ok/FAIL-Zeilen>
|
||
|
||
## The Events Calendar
|
||
- found_by_tribe_get_events: <true/false>
|
||
- abgeleitete Felder: <Werte>
|
||
- Folgerung: Erweiterungspunkt lit_mcp_after_save mit TEC-Nachberechnung nötig: <ja/nein>
|
||
|
||
## MCP Inspector
|
||
- Protokollversion: …
|
||
- Tools/Schemas/Annotations: …
|
||
|
||
## claude.ai
|
||
- Verbindung: …
|
||
- Consent/Rollen-Gate: …
|
||
- Nutzung direkter Tools / Instructions: …
|
||
|
||
## Autorisierung (RFC 9207 iss / RFC 8707 resource)
|
||
- …
|
||
|
||
## Entscheidungen
|
||
- Anbindungsschicht: mcp-oauth bleibt / Wechsel zu mcp-connect – Begründung
|
||
- Scope-Filter für MCP-Token nötig: ja/nein
|
||
- Offene Punkte für Etappe 1: …
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add docs/superpowers/notes/2026-09-25-etappe-0-ergebnisse.md
|
||
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "docs: stage 0 results and decisions
|
||
|
||
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>"
|
||
```
|