feat: content-types tool and WordPress integration

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-25 22:21:29 +02:00
co-authored by Claude Opus 5.5
parent 4a652240a3
commit 98a910f056
7 changed files with 296 additions and 7 deletions
+19 -5
View File
@@ -13,8 +13,12 @@ use LIT\MCP\Integrations\IntegrationRegistry;
*/ */
final class InstructionsBuilder final class InstructionsBuilder
{ {
public function __construct(private IntegrationRegistry $registry) /** @var (callable(): list<string>)|null */
private $postTypeNames;
public function __construct(private IntegrationRegistry $registry, ?callable $postTypeNames = null)
{ {
$this->postTypeNames = $postTypeNames;
} }
public function build(): string public function build(): string
@@ -24,17 +28,27 @@ final class InstructionsBuilder
$this->registry->available() $this->registry->available()
)); ));
return implode("\n", [ $lines = [
'LIT MCP pflegt diese WordPress-Website (Inhalte, Medien, Divi 5, Pods).', 'LIT MCP pflegt diese WordPress-Website (Inhalte, Medien, Divi 5, Pods).',
'Aktive Integrationen: ' . $active . '.', 'Aktive Integrationen: ' . $active . '.',
];
if ($this->postTypeNames !== null) {
$lines[] = 'Inhaltstypen: ' . implode(', ', ($this->postTypeNames)())
. ' (Felder und Taxonomien: lit-content-types).';
}
return implode("\n", array_merge($lines, [
'Regeln:', 'Regeln:',
'- Vor dem ersten Arbeitsschritt lit-guide (ohne Topic) aufrufen; Details je Thema über lit-guide ' '- Vor dem ersten Arbeitsschritt lit-guide (ohne Topic) aufrufen; Details je Thema über lit-guide '
. 'mit topic.', . 'mit topic.',
'- Änderungen erzeugen native Divi-Module; Variablen und Presets vor festen Werten; Code-Modul nur, ' '- Änderungen erzeugen native Divi-Module; Variablen und Presets vor festen Werten; Code-Modul nur, '
. 'wenn kein natives Modul passt.', . 'wenn kein natives Modul passt.',
'- Den Status (Entwurf/veröffentlicht) nie ändern, außer der Nutzer verlangt es ausdrücklich.', '- 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.', . 'neue Inhalte entstehen als Entwurf.',
'- Beim Bearbeiten immer den rev-Wert aus dem letzten Lesevorgang mitgeben (Pflicht).',
'- Löschen und Ersetzen erst nach Rückfrage beim Nutzer (confirm: true).',
'- Fehler enthalten einen hint – ihm folgen, statt blind zu wiederholen.', '- Fehler enthalten einen hint – ihm folgen, statt blind zu wiederholen.',
]); ]));
} }
} }
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Content;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
final class ContentTypesAbility implements Ability
{
public function __construct(private ContentSchema $schema)
{
}
public function name(): string
{
return 'lit/content-types';
}
public function label(): string
{
return 'Inhaltstypen';
}
public function description(): string
{
return 'Listet die Inhaltstypen (Post-Typen) dieser Website mit Taxonomien, Anzahl und Divi-Fähigkeit. '
. 'Mit post_type zusätzlich alle Felder (Pods, WordPress-Meta) mit Typ, Pflicht, Auswahlwerten, '
. 'Beziehungsziel und Schreibstatus (writable/readonly/unknown).';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'post_type' => [
'type' => 'string',
'description' => 'Optional: nur diesen Inhaltstyp, dafür mit allen Feldern.',
],
],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return [
'type' => 'object',
'properties' => [
'post_types' => ['type' => 'array', 'items' => ['type' => 'object']],
],
'required' => ['post_types'],
];
}
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 current_user_can('edit_posts')
? true
: Errors::forbidden('Keine Berechtigung, Inhalte zu bearbeiten.');
}
public function execute(array $input): mixed
{
$postType = isset($input['post_type']) && is_string($input['post_type']) ? $input['post_type'] : '';
if ($postType === '') {
return ['post_types' => $this->schema->describePostTypes()];
}
$description = $this->schema->describe($postType, true);
if ($description === null) {
return Errors::notFound(
sprintf('Inhaltstyp "%s" ist nicht verfügbar.', $postType),
'Verfügbare Inhaltstypen: ' . implode(', ', $this->schema->postTypeNames()) . '.'
);
}
return ['post_types' => [$description]];
}
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Integration;
use LIT\MCP\Integrations\WordPress\Content\ContentTypesAbility;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
/**
* WordPress basics (spec §6): content, status, copies, revisions, menus, settings.
* Always available.
*/
final class WordPressIntegration implements Integration
{
public function __construct(private ContentSchema $schema, private ChangeLog $log)
{
}
public function id(): string
{
return 'wordpress';
}
public function isAvailable(): bool
{
return true;
}
public function abilities(): array
{
return [
new ContentTypesAbility($this->schema),
];
}
}
+22 -2
View File
@@ -11,6 +11,11 @@ use LIT\MCP\ChangeLog\ChangeLogInstaller;
use LIT\MCP\Guides\InstructionsBuilder; use LIT\MCP\Guides\InstructionsBuilder;
use LIT\MCP\Integrations\Core\CoreIntegration; use LIT\MCP\Integrations\Core\CoreIntegration;
use LIT\MCP\Integrations\IntegrationRegistry; use LIT\MCP\Integrations\IntegrationRegistry;
use LIT\MCP\Integrations\Pods\PodsFieldSource;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Schema\RegisteredMetaSource;
use LIT\MCP\Integrations\WordPress\Schema\SampledMetaSource;
use LIT\MCP\Integrations\WordPress\WordPressIntegration;
use LIT\MCP\Server\RequestContext; use LIT\MCP\Server\RequestContext;
use LIT\MCP\Server\ServerRegistrar; use LIT\MCP\Server\ServerRegistrar;
@@ -40,12 +45,17 @@ final class Plugin
(new ChangeLogInstaller($changeLog))->maybeInstall(); (new ChangeLogInstaller($changeLog))->maybeInstall();
(new ChangeLogCleanup($changeLog, new Limits()))->register(); (new ChangeLogCleanup($changeLog, new Limits()))->register();
$schema = self::contentSchema();
$registry = new IntegrationRegistry( $registry = new IntegrationRegistry(
new CoreIntegration(self::pluginDir() . '/docs/guides') new CoreIntegration(self::pluginDir() . '/docs/guides'),
new WordPressIntegration($schema, $changeLog)
); );
(new AbilityRegistrar($registry))->register(); (new AbilityRegistrar($registry))->register();
(new ServerRegistrar($registry, new InstructionsBuilder($registry)))->register(); (new ServerRegistrar(
$registry,
new InstructionsBuilder($registry, static fn (): array => $schema->postTypeNames())
))->register();
\WPMedia\MCP\OAuth\Bootstrap::instance(); \WPMedia\MCP\OAuth\Bootstrap::instance();
} }
@@ -71,4 +81,14 @@ final class Plugin
); );
}); });
} }
private static function contentSchema(): ContentSchema
{
$sources = [new RegisteredMetaSource(), new SampledMetaSource()];
if (PodsFieldSource::isAvailable()) {
array_unshift($sources, new PodsFieldSource());
}
return new ContentSchema($sources);
}
} }
@@ -25,4 +25,17 @@ final class InstructionsBuilderTest extends TestCase
self::assertStringContainsString('Status', $text); self::assertStringContainsString('Status', $text);
self::assertLessThan(2000, strlen($text), 'Instructions must stay short – they are loaded in every conversation.'); self::assertLessThan(2000, strlen($text), 'Instructions must stay short – they are loaded in every conversation.');
} }
public function testListsPostTypesWhenAProviderIsGiven(): void
{
$core = Mockery::mock(Integration::class);
$core->allows('id')->andReturn('core');
$core->allows('isAvailable')->andReturn(true);
$text = (new InstructionsBuilder(new IntegrationRegistry($core), static fn (): array => ['page', 'post']))->build();
self::assertStringContainsString('Inhaltstypen: page, post', $text);
self::assertStringContainsString('lit-content-types', $text);
self::assertStringContainsString('confirm: true', $text);
}
} }
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Content;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\WordPress\Content\ContentTypesAbility;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Error;
final class ContentTypesAbilityTest extends TestCase
{
public function testListsAllTypesWithoutFields(): void
{
$schema = Mockery::mock(ContentSchema::class);
$schema->expects('describePostTypes')->andReturn([['name' => 'page'], ['name' => 'post']]);
$result = (new ContentTypesAbility($schema))->execute([]);
self::assertSame(['post_types' => [['name' => 'page'], ['name' => 'post']]], $result);
}
public function testSingleTypeIncludesFields(): void
{
$schema = Mockery::mock(ContentSchema::class);
$schema->expects('describe')->with('lit_person', true)->andReturn(['name' => 'lit_person', 'fields' => []]);
$result = (new ContentTypesAbility($schema))->execute(['post_type' => 'lit_person']);
self::assertSame([['name' => 'lit_person', 'fields' => []]], $result['post_types']);
}
public function testUnknownTypeIsNotFoundWithTheAvailableTypes(): void
{
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('describe')->andReturn(null);
$schema->allows('postTypeNames')->andReturn(['page', 'post']);
$result = (new ContentTypesAbility($schema))->execute(['post_type' => 'nope']);
self::assertInstanceOf(WP_Error::class, $result);
self::assertSame('lit_not_found', $result->get_error_code());
self::assertStringContainsString('page, post', $result->get_error_message());
}
public function testPermissionRequiresEditPosts(): void
{
Functions\when('current_user_can')->justReturn(false);
$ability = new ContentTypesAbility(Mockery::mock(ContentSchema::class));
self::assertSame('lit_forbidden', $ability->permission([])->get_error_code());
}
public function testMetadata(): void
{
$ability = new ContentTypesAbility(Mockery::mock(ContentSchema::class));
self::assertSame('lit/content-types', $ability->name());
self::assertTrue($ability->isDirect());
self::assertSame(['readonly' => true, 'destructive' => false, 'idempotent' => true], $ability->annotations());
self::assertFalse($ability->inputSchema()['additionalProperties']);
}
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\WordPressIntegration;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class WordPressIntegrationTest extends TestCase
{
/** Grows with every task that adds abilities – keep in the order of abilities(). */
private const EXPECTED = [
'lit/content-types',
];
public function testProvidesTheWordPressAbilitiesInOrder(): void
{
$integration = new WordPressIntegration(Mockery::mock(ContentSchema::class), Mockery::mock(ChangeLog::class));
self::assertSame('wordpress', $integration->id());
self::assertTrue($integration->isAvailable());
self::assertSame(
self::EXPECTED,
array_map(static fn (Ability $ability): string => $ability->name(), $integration->abilities())
);
}
public function testEveryAbilityHasAnObjectInputSchemaThatRejectsUnknownKeys(): void
{
$integration = new WordPressIntegration(Mockery::mock(ContentSchema::class), Mockery::mock(ChangeLog::class));
foreach ($integration->abilities() as $ability) {
$schema = $ability->inputSchema();
self::assertSame('object', $schema['type'], $ability->name());
self::assertFalse($schema['additionalProperties'], $ability->name());
self::assertMatchesRegularExpression('#^lit/[a-z]+(-[a-z]+)+$#', $ability->name());
}
}
}