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:
@@ -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
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user