From be0d2c409aa773e29a0a19bc35181581d6de3f9d Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Wed, 8 Jul 2026 10:40:57 +0800 Subject: [PATCH 01/78] feature(mcp): Add MCPConfig utility class --- src/MCP/MCPConfig.php | 63 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 src/MCP/MCPConfig.php diff --git a/src/MCP/MCPConfig.php b/src/MCP/MCPConfig.php new file mode 100644 index 0000000..4d7130e --- /dev/null +++ b/src/MCP/MCPConfig.php @@ -0,0 +1,63 @@ + 'saltus-framework', + * 'label' => 'Saltus Framework', + * 'description' => 'Saltus Framework content modeling and administration abilities.', + * ] + * + * @return array{id: string, label: string, description: string} + */ + public static function get_ability_category(): array { + return (array) \apply_filters( + 'saltus/framework/mcp/ability_category', + [ + 'id' => 'saltus-framework', + 'label' => 'Saltus Framework', + 'description' => 'Saltus Framework content modeling and administration abilities.', + ] + ); + } + + /** + * Get the prefix used when generating ability names from tool names. + * + * Default: 'saltus/' + * + * @return string + */ + public static function get_ability_prefix(): string { + return (string) \apply_filters( + 'saltus/framework/mcp/ability_prefix', + 'saltus/' + ); + } +} From 3f6deb4c664774c3f5629285e19491ce2455bd6d Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Wed, 8 Jul 2026 10:41:05 +0800 Subject: [PATCH 02/78] feature(mcp): Add mcp_route helper to RestTool base --- src/MCP/Tools/RestTool.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/MCP/Tools/RestTool.php b/src/MCP/Tools/RestTool.php index c98bbad..a013922 100644 --- a/src/MCP/Tools/RestTool.php +++ b/src/MCP/Tools/RestTool.php @@ -2,6 +2,8 @@ namespace Saltus\WP\Framework\MCP\Tools; +use Saltus\WP\Framework\MCP\MCPConfig; + /** * Abstract base for MCP tools that dispatch via the WordPress REST API. */ @@ -34,6 +36,16 @@ public function cache_ttl(): int { return 300; } + /** + * Build a full REST route string from a path fragment using the configured MCP namespace. + * + * @param string $path Path fragment starting with '/' (e.g. '/models'). + * @return string Full route (e.g. '/saltus-framework/v1/models'). + */ + protected function mcp_route( string $path ): string { + return '/' . MCPConfig::get_namespace() . $path; + } + /** * Build and return a WP_REST_Request instance. * From ae70df067748b88aa394a87e360aaa5a646db197 Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Wed, 8 Jul 2026 10:41:10 +0800 Subject: [PATCH 03/78] test(mcp): Add tests for MCPConfig --- tests/MCP/MCPConfigTest.php | 121 ++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 tests/MCP/MCPConfigTest.php diff --git a/tests/MCP/MCPConfigTest.php b/tests/MCP/MCPConfigTest.php new file mode 100644 index 0000000..41c4bb4 --- /dev/null +++ b/tests/MCP/MCPConfigTest.php @@ -0,0 +1,121 @@ +assertSame( 'saltus-framework/v1', MCPConfig::get_namespace() ); + } + + public function testGetNamespaceReturnsFilteredValueViaWpFilterValues(): void { + global $wp_filter_values; + $wp_filter_values['saltus/framework/mcp/namespace'] = 'my-plugin/v2'; + + $this->assertSame( 'my-plugin/v2', MCPConfig::get_namespace() ); + } + + public function testGetNamespaceReturnsFilteredValueViaAddFilter(): void { + global $wp_filters_registered; + $wp_filters_registered = []; + + add_filter( + 'saltus/framework/mcp/namespace', + function (): string { + return 'override/v3'; + } + ); + + $this->assertSame( 'override/v3', MCPConfig::get_namespace() ); + } + + public function testGetNamespaceFilterCallbackReceivesDefaultValue(): void { + add_filter( + 'saltus/framework/mcp/namespace', + function ( string $value ): string { + return strtoupper( $value ); + } + ); + + $this->assertSame( 'SALTUS-FRAMEWORK/V1', MCPConfig::get_namespace() ); + } + + public function testGetAbilityCategoryReturnsDefault(): void { + $category = MCPConfig::get_ability_category(); + + $this->assertIsArray( $category ); + $this->assertSame( 'saltus-framework', $category['id'] ); + $this->assertSame( 'Saltus Framework', $category['label'] ); + $this->assertSame( 'Saltus Framework content modeling and administration abilities.', $category['description'] ); + } + + public function testGetAbilityCategoryReturnsFilteredValue(): void { + global $wp_filter_values; + $wp_filter_values['saltus/framework/mcp/ability_category'] = [ + 'id' => 'custom-plugin', + 'label' => 'Custom Plugin', + 'description' => 'Custom abilities.', + ]; + + $category = MCPConfig::get_ability_category(); + $this->assertSame( 'custom-plugin', $category['id'] ); + $this->assertSame( 'Custom Plugin', $category['label'] ); + $this->assertSame( 'Custom abilities.', $category['description'] ); + } + + public function testGetAbilityCategoryFilterReceivesDefault(): void { + add_filter( + 'saltus/framework/mcp/ability_category', + function ( array $category ): array { + $category['label'] = 'Overridden Label'; + return $category; + } + ); + + $category = MCPConfig::get_ability_category(); + $this->assertSame( 'saltus-framework', $category['id'] ); + $this->assertSame( 'Overridden Label', $category['label'] ); + } + + public function testGetAbilityPrefixReturnsDefault(): void { + $this->assertSame( 'saltus/', MCPConfig::get_ability_prefix() ); + } + + public function testGetAbilityPrefixReturnsFilteredValue(): void { + global $wp_filter_values; + $wp_filter_values['saltus/framework/mcp/ability_prefix'] = 'custom/'; + + $this->assertSame( 'custom/', MCPConfig::get_ability_prefix() ); + } + + public function testGetAbilityPrefixFilterReceivesDefault(): void { + add_filter( + 'saltus/framework/mcp/ability_prefix', + function ( string $prefix ): string { + return 'new-' . $prefix; + } + ); + + $this->assertSame( 'new-saltus/', MCPConfig::get_ability_prefix() ); + } +} From 6ef88316640f69512d67d3835c0ef810826373a4 Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Wed, 8 Jul 2026 10:41:14 +0800 Subject: [PATCH 04/78] feature(mcp): Refactor model meta tools to use MCPConfig --- src/MCP/Abilities/AbilityDefinitionFactory.php | 7 ++++--- src/MCP/Abilities/AbilityRegistrar.php | 9 ++++++--- src/MCP/Tools/GetHealth.php | 2 +- src/MCP/Tools/GetMetaFields.php | 2 +- src/MCP/Tools/GetModel.php | 2 +- src/MCP/Tools/ListMetaFields.php | 2 +- src/MCP/Tools/ListModels.php | 2 +- src/MCP/Tools/UpdateMetaFields.php | 2 +- 8 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/MCP/Abilities/AbilityDefinitionFactory.php b/src/MCP/Abilities/AbilityDefinitionFactory.php index 33ae507..5e064bf 100644 --- a/src/MCP/Abilities/AbilityDefinitionFactory.php +++ b/src/MCP/Abilities/AbilityDefinitionFactory.php @@ -1,6 +1,7 @@ $this->ability_name( $tool->get_name() ), 'label' => $this->label_from_tool_name( $tool->get_name() ), 'description' => $tool->get_description(), - 'category' => 'saltus-framework', + 'category' => MCPConfig::get_ability_category()['id'], 'input_schema' => $schema, 'inputSchema' => $schema, 'execute_callback' => function ( array $args = [] ) use ( $tool ) { @@ -74,7 +75,7 @@ public function from_tool( ToolInterface $tool ): array { }, 'meta' => [ 'mcp_tool' => $tool->get_name(), - 'namespace' => 'saltus-framework/v1', + 'namespace' => MCPConfig::get_namespace(), 'transport' => 'wordpress-rest', 'show_in_rest' => true, ], @@ -122,7 +123,7 @@ private function normalize_args( $args ): array { * @return lowercase-string&non-falsy-string */ private function ability_name( string $tool_name ): string { - return strtolower( 'saltus/' . str_replace( '_', '-', $tool_name ) ); + return strtolower( MCPConfig::get_ability_prefix() . str_replace( '_', '-', $tool_name ) ); } /** diff --git a/src/MCP/Abilities/AbilityRegistrar.php b/src/MCP/Abilities/AbilityRegistrar.php index 0d1f03f..8cbf444 100644 --- a/src/MCP/Abilities/AbilityRegistrar.php +++ b/src/MCP/Abilities/AbilityRegistrar.php @@ -1,6 +1,7 @@ 'Saltus Framework', - 'description' => 'Saltus Framework content modeling and administration abilities.', + 'label' => $category['label'], + 'description' => $category['description'], ] ); } diff --git a/src/MCP/Tools/GetHealth.php b/src/MCP/Tools/GetHealth.php index 5999390..500d230 100644 --- a/src/MCP/Tools/GetHealth.php +++ b/src/MCP/Tools/GetHealth.php @@ -51,7 +51,7 @@ public function get_rest_capability(): ?RestCapabilityRequirement { * @return \WP_REST_Request|null */ public function build_rest_request( array $args ): ?\WP_REST_Request { - return $this->request( 'GET', '/saltus-framework/v1/health' ); + return $this->request( 'GET', $this->mcp_route( '/health' ) ); } /** diff --git a/src/MCP/Tools/GetMetaFields.php b/src/MCP/Tools/GetMetaFields.php index 27bc9f4..831b35c 100644 --- a/src/MCP/Tools/GetMetaFields.php +++ b/src/MCP/Tools/GetMetaFields.php @@ -68,7 +68,7 @@ public function get_rest_capability(): ?RestCapabilityRequirement { * @return \WP_REST_Request|null */ public function build_rest_request( array $args ): ?\WP_REST_Request { - return $this->request( 'GET', '/saltus-framework/v1/meta/' . rawurlencode( (string) ( $args['post_type'] ?? '' ) ) ); + return $this->request( 'GET', $this->mcp_route( '/meta/' . rawurlencode( (string) ( $args['post_type'] ?? '' ) ) ) ); } /** diff --git a/src/MCP/Tools/GetModel.php b/src/MCP/Tools/GetModel.php index 683aaf5..b8fc36f 100644 --- a/src/MCP/Tools/GetModel.php +++ b/src/MCP/Tools/GetModel.php @@ -57,7 +57,7 @@ public function get_rest_capability(): ?RestCapabilityRequirement { * @return \WP_REST_Request|null */ public function build_rest_request( array $args ): ?\WP_REST_Request { - return $this->request( 'GET', '/saltus-framework/v1/models/' . rawurlencode( (string) ( $args['slug'] ?? '' ) ) ); + return $this->request( 'GET', $this->mcp_route( '/models/' . rawurlencode( (string) ( $args['slug'] ?? '' ) ) ) ); } /** diff --git a/src/MCP/Tools/ListMetaFields.php b/src/MCP/Tools/ListMetaFields.php index 424b1ed..69e7bfd 100644 --- a/src/MCP/Tools/ListMetaFields.php +++ b/src/MCP/Tools/ListMetaFields.php @@ -62,7 +62,7 @@ public function get_rest_capability(): ?RestCapabilityRequirement { * @return \WP_REST_Request|null */ public function build_rest_request( array $args ): ?\WP_REST_Request { - return $this->request( 'GET', '/saltus-framework/v1/meta' ); + return $this->request( 'GET', $this->mcp_route( '/meta' ) ); } /** diff --git a/src/MCP/Tools/ListModels.php b/src/MCP/Tools/ListModels.php index d71a533..31b6814 100644 --- a/src/MCP/Tools/ListModels.php +++ b/src/MCP/Tools/ListModels.php @@ -58,7 +58,7 @@ public function get_rest_capability(): ?RestCapabilityRequirement { * @return \WP_REST_Request|null */ public function build_rest_request( array $args ): ?\WP_REST_Request { - return $this->request( 'GET', '/saltus-framework/v1/models', $args ); + return $this->request( 'GET', $this->mcp_route( '/models' ), $args ); } /** diff --git a/src/MCP/Tools/UpdateMetaFields.php b/src/MCP/Tools/UpdateMetaFields.php index dac7e24..b83b5fa 100644 --- a/src/MCP/Tools/UpdateMetaFields.php +++ b/src/MCP/Tools/UpdateMetaFields.php @@ -85,7 +85,7 @@ public function build_rest_request( array $args ): ?\WP_REST_Request { return $this->request( 'PUT', - '/saltus-framework/v1/meta/' . rawurlencode( (string) ( $args['post_type'] ?? '' ) ) . '/' . (int) ( $args['post_id'] ?? 0 ), + $this->mcp_route( '/meta/' . rawurlencode( (string) ( $args['post_type'] ?? '' ) ) . '/' . (int) ( $args['post_id'] ?? 0 ) ), [], $body ); From deb1465c6a4a65e8045495ad9c5d87dc3dca8e03 Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Wed, 8 Jul 2026 10:42:00 +0800 Subject: [PATCH 05/78] feature(mcp): Refactor settings action tools to use MCPConfig --- src/MCP/Tools/DuplicatePost.php | 2 +- src/MCP/Tools/ExportPost.php | 2 +- src/MCP/Tools/GetSettings.php | 2 +- src/MCP/Tools/ReorderPosts.php | 2 +- src/MCP/Tools/UpdateSettings.php | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/MCP/Tools/DuplicatePost.php b/src/MCP/Tools/DuplicatePost.php index 12ce4b8..26701f3 100644 --- a/src/MCP/Tools/DuplicatePost.php +++ b/src/MCP/Tools/DuplicatePost.php @@ -57,7 +57,7 @@ public function get_rest_capability(): ?RestCapabilityRequirement { * @return \WP_REST_Request|null */ public function build_rest_request( array $args ): ?\WP_REST_Request { - return $this->request( 'POST', '/saltus-framework/v1/duplicate/' . (int) ( $args['post_id'] ?? 0 ) ); + return $this->request( 'POST', $this->mcp_route( '/duplicate/' . (int) ( $args['post_id'] ?? 0 ) ) ); } /** diff --git a/src/MCP/Tools/ExportPost.php b/src/MCP/Tools/ExportPost.php index b2fa772..485644a 100644 --- a/src/MCP/Tools/ExportPost.php +++ b/src/MCP/Tools/ExportPost.php @@ -67,7 +67,7 @@ public function get_rest_capability(): ?RestCapabilityRequirement { * @return \WP_REST_Request|null */ public function build_rest_request( array $args ): ?\WP_REST_Request { - return $this->request( 'GET', '/saltus-framework/v1/export/' . (int) ( $args['post_id'] ?? 0 ) ); + return $this->request( 'GET', $this->mcp_route( '/export/' . (int) ( $args['post_id'] ?? 0 ) ) ); } /** diff --git a/src/MCP/Tools/GetSettings.php b/src/MCP/Tools/GetSettings.php index a37bd4f..313f386 100644 --- a/src/MCP/Tools/GetSettings.php +++ b/src/MCP/Tools/GetSettings.php @@ -67,7 +67,7 @@ public function get_rest_capability(): ?RestCapabilityRequirement { * @return \WP_REST_Request|null */ public function build_rest_request( array $args ): ?\WP_REST_Request { - return $this->request( 'GET', '/saltus-framework/v1/settings/' . rawurlencode( (string) ( $args['post_type'] ?? '' ) ) ); + return $this->request( 'GET', $this->mcp_route( '/settings/' . rawurlencode( (string) ( $args['post_type'] ?? '' ) ) ) ); } /** diff --git a/src/MCP/Tools/ReorderPosts.php b/src/MCP/Tools/ReorderPosts.php index f91db22..8ff85bc 100644 --- a/src/MCP/Tools/ReorderPosts.php +++ b/src/MCP/Tools/ReorderPosts.php @@ -80,7 +80,7 @@ public function get_rest_capability(): ?RestCapabilityRequirement { * @return \WP_REST_Request|null */ public function build_rest_request( array $args ): ?\WP_REST_Request { - return $this->request( 'POST', '/saltus-framework/v1/reorder', [], [ 'items' => $args['items'] ?? [] ] ); + return $this->request( 'POST', $this->mcp_route( '/reorder' ), [], [ 'items' => $args['items'] ?? [] ] ); } /** diff --git a/src/MCP/Tools/UpdateSettings.php b/src/MCP/Tools/UpdateSettings.php index 17f9567..26bb4e9 100644 --- a/src/MCP/Tools/UpdateSettings.php +++ b/src/MCP/Tools/UpdateSettings.php @@ -74,7 +74,7 @@ public function get_rest_capability(): ?RestCapabilityRequirement { public function build_rest_request( array $args ): ?\WP_REST_Request { $body = is_array( $args['settings'] ?? null ) ? $args['settings'] : []; - return $this->request( 'PUT', '/saltus-framework/v1/settings/' . rawurlencode( (string) ( $args['post_type'] ?? '' ) ), [], $body ); + return $this->request( 'PUT', $this->mcp_route( '/settings/' . rawurlencode( (string) ( $args['post_type'] ?? '' ) ) ), [], $body ); } /** From 600b6f17c65749fdfc0af0ba9dfc6c386f0c0f9d Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Wed, 8 Jul 2026 10:42:08 +0800 Subject: [PATCH 06/78] feature(mcp): Refactor REST controllers to use MCPConfig --- src/Rest/DuplicateController.php | 6 +++--- src/Rest/ExportController.php | 6 +++--- src/Rest/HealthController.php | 7 +++---- src/Rest/MetaController.php | 11 +++++------ src/Rest/ModelsController.php | 9 ++++----- src/Rest/ReorderController.php | 6 +++--- src/Rest/SettingsController.php | 6 +++--- 7 files changed, 24 insertions(+), 27 deletions(-) diff --git a/src/Rest/DuplicateController.php b/src/Rest/DuplicateController.php index ea1069a..96167d9 100644 --- a/src/Rest/DuplicateController.php +++ b/src/Rest/DuplicateController.php @@ -8,13 +8,13 @@ use WP_REST_Response; use WP_Error; use Saltus\WP\Framework\Features\Duplicate\SaltusDuplicate; +use Saltus\WP\Framework\MCP\MCPConfig; /** * REST controller for duplicating posts. */ class DuplicateController extends WP_REST_Controller { - private const ROUTE_NAMESPACE = 'saltus-framework/v1'; private ?ModelRestPolicy $policy; /** @@ -22,7 +22,7 @@ class DuplicateController extends WP_REST_Controller { */ public function __construct( ?ModelRestPolicy $policy = null ) { $this->policy = $policy; - $this->namespace = self::ROUTE_NAMESPACE; + $this->namespace = MCPConfig::get_namespace(); $this->rest_base = 'duplicate'; } @@ -31,7 +31,7 @@ public function __construct( ?ModelRestPolicy $policy = null ) { */ public function register_routes(): void { register_rest_route( - self::ROUTE_NAMESPACE, + $this->namespace, '/' . $this->rest_base . '/(?P\d+)', [ 'methods' => WP_REST_Server::CREATABLE, diff --git a/src/Rest/ExportController.php b/src/Rest/ExportController.php index 0d8e8fb..ce820e6 100644 --- a/src/Rest/ExportController.php +++ b/src/Rest/ExportController.php @@ -7,13 +7,13 @@ use WP_REST_Response; use WP_Error; use Saltus\WP\Framework\Features\SingleExport\SaltusSingleExport; +use Saltus\WP\Framework\MCP\MCPConfig; /** * REST controller for exporting posts as WXR. */ class ExportController extends WP_REST_Controller { - private const ROUTE_NAMESPACE = 'saltus-framework/v1'; private ?ModelRestPolicy $policy; private SaltusSingleExport $exporter; @@ -24,7 +24,7 @@ class ExportController extends WP_REST_Controller { public function __construct( ?ModelRestPolicy $policy = null, ?SaltusSingleExport $exporter = null ) { $this->policy = $policy; $this->exporter = $exporter ?? new SaltusSingleExport( '', [] ); - $this->namespace = self::ROUTE_NAMESPACE; + $this->namespace = MCPConfig::get_namespace(); $this->rest_base = 'export'; } @@ -33,7 +33,7 @@ public function __construct( ?ModelRestPolicy $policy = null, ?SaltusSingleExpor */ public function register_routes(): void { \register_rest_route( - self::ROUTE_NAMESPACE, + $this->namespace, '/' . $this->rest_base . '/(?P\d+)', [ 'methods' => WP_REST_Server::READABLE, diff --git a/src/Rest/HealthController.php b/src/Rest/HealthController.php index 720af2a..26c2b73 100644 --- a/src/Rest/HealthController.php +++ b/src/Rest/HealthController.php @@ -3,6 +3,7 @@ namespace Saltus\WP\Framework\Rest; use Saltus\WP\Framework\MCP\Audit\AuditLogger; +use Saltus\WP\Framework\MCP\MCPConfig; use WP_Error; use WP_REST_Controller; use WP_REST_Response; @@ -14,15 +15,13 @@ class HealthController extends WP_REST_Controller { use \Saltus\WP\Framework\Infrastructure\Services\FilterAwareTrait; - private const ROUTE_NAMESPACE = 'saltus-framework/v1'; - private string $version; private AuditLogger $audit_logger; public function __construct( string $version, ?AuditLogger $audit_logger = null ) { $this->version = $version; $this->audit_logger = $audit_logger ?? new AuditLogger(); - $this->namespace = self::ROUTE_NAMESPACE; + $this->namespace = MCPConfig::get_namespace(); $this->rest_base = 'health'; } @@ -31,7 +30,7 @@ public function __construct( string $version, ?AuditLogger $audit_logger = null */ public function register_routes(): void { register_rest_route( - self::ROUTE_NAMESPACE, + $this->namespace, '/' . $this->rest_base, [ 'methods' => WP_REST_Server::READABLE, diff --git a/src/Rest/MetaController.php b/src/Rest/MetaController.php index d391958..366ab64 100644 --- a/src/Rest/MetaController.php +++ b/src/Rest/MetaController.php @@ -8,6 +8,7 @@ use WP_REST_Response; use WP_Error; use Saltus\WP\Framework\Features\Meta\MetaFieldProvider; +use Saltus\WP\Framework\MCP\MCPConfig; use Saltus\WP\Framework\Modeler; /** @@ -15,8 +16,6 @@ */ class MetaController extends WP_REST_Controller { - private const ROUTE_NAMESPACE = 'saltus-framework/v1'; - protected Modeler $modeler; private ?ModelRestPolicy $policy; private MetaFieldProvider $meta_field_provider; @@ -30,7 +29,7 @@ public function __construct( Modeler $modeler, ?ModelRestPolicy $policy = null, $this->modeler = $modeler; $this->policy = $policy; $this->meta_field_provider = $meta_field_provider ?? new MetaFieldProvider(); - $this->namespace = self::ROUTE_NAMESPACE; + $this->namespace = MCPConfig::get_namespace(); $this->rest_base = 'meta'; } @@ -43,7 +42,7 @@ public function register_routes(): void { } register_rest_route( - self::ROUTE_NAMESPACE, + $this->namespace, '/' . $this->rest_base, [ 'methods' => WP_REST_Server::READABLE, @@ -53,7 +52,7 @@ public function register_routes(): void { ); register_rest_route( - self::ROUTE_NAMESPACE, + $this->namespace, '/' . $this->rest_base . '/(?P[a-z0-9_-]+)', [ 'methods' => WP_REST_Server::READABLE, @@ -70,7 +69,7 @@ public function register_routes(): void { ); register_rest_route( - self::ROUTE_NAMESPACE, + $this->namespace, '/' . $this->rest_base . '/(?P[a-z0-9_-]+)/(?P\d+)', [ 'methods' => WP_REST_Server::EDITABLE, diff --git a/src/Rest/ModelsController.php b/src/Rest/ModelsController.php index 282b595..73c315e 100644 --- a/src/Rest/ModelsController.php +++ b/src/Rest/ModelsController.php @@ -7,6 +7,7 @@ use WP_REST_Request; use WP_REST_Response; use WP_Error; +use Saltus\WP\Framework\MCP\MCPConfig; use Saltus\WP\Framework\Modeler; use Saltus\WP\Framework\Models\Model; use Saltus\WP\Framework\Models\Taxonomy; @@ -16,8 +17,6 @@ */ class ModelsController extends WP_REST_Controller { - private const ROUTE_NAMESPACE = 'saltus-framework/v1'; - protected Modeler $modeler; private ?ModelRestPolicy $policy; @@ -28,7 +27,7 @@ class ModelsController extends WP_REST_Controller { public function __construct( Modeler $modeler, ?ModelRestPolicy $policy = null ) { $this->modeler = $modeler; $this->policy = $policy; - $this->namespace = self::ROUTE_NAMESPACE; + $this->namespace = MCPConfig::get_namespace(); $this->rest_base = 'models'; } @@ -37,7 +36,7 @@ public function __construct( Modeler $modeler, ?ModelRestPolicy $policy = null ) */ public function register_routes(): void { register_rest_route( - self::ROUTE_NAMESPACE, + $this->namespace, '/' . $this->rest_base, [ 'methods' => WP_REST_Server::READABLE, @@ -47,7 +46,7 @@ public function register_routes(): void { ); register_rest_route( - self::ROUTE_NAMESPACE, + $this->namespace, '/' . $this->rest_base . '/(?P[a-z0-9_-]+)', [ 'methods' => WP_REST_Server::READABLE, diff --git a/src/Rest/ReorderController.php b/src/Rest/ReorderController.php index db233e4..bc6255f 100644 --- a/src/Rest/ReorderController.php +++ b/src/Rest/ReorderController.php @@ -8,13 +8,13 @@ use WP_REST_Response; use WP_Error; use Saltus\WP\Framework\Features\DragAndDrop\ReorderPostsService; +use Saltus\WP\Framework\MCP\MCPConfig; /** * REST controller for reordering posts via menu_order updates. */ class ReorderController extends WP_REST_Controller { - private const ROUTE_NAMESPACE = 'saltus-framework/v1'; private ?ModelRestPolicy $policy; private ReorderPostsService $reorder_service; @@ -25,7 +25,7 @@ class ReorderController extends WP_REST_Controller { public function __construct( ?ModelRestPolicy $policy = null, ?ReorderPostsService $reorder_service = null ) { $this->policy = $policy; $this->reorder_service = $reorder_service ?? new ReorderPostsService(); - $this->namespace = self::ROUTE_NAMESPACE; + $this->namespace = MCPConfig::get_namespace(); $this->rest_base = 'reorder'; } @@ -34,7 +34,7 @@ public function __construct( ?ModelRestPolicy $policy = null, ?ReorderPostsServi */ public function register_routes(): void { register_rest_route( - self::ROUTE_NAMESPACE, + $this->namespace, '/' . $this->rest_base, [ 'methods' => WP_REST_Server::CREATABLE, diff --git a/src/Rest/SettingsController.php b/src/Rest/SettingsController.php index 5ddab47..d84f35d 100644 --- a/src/Rest/SettingsController.php +++ b/src/Rest/SettingsController.php @@ -8,13 +8,13 @@ use WP_REST_Response; use WP_Error; use Saltus\WP\Framework\Features\Settings\SettingsManager; +use Saltus\WP\Framework\MCP\MCPConfig; /** * REST controller for reading and updating per-post-type settings. */ class SettingsController extends WP_REST_Controller { - private const ROUTE_NAMESPACE = 'saltus-framework/v1'; private ?ModelRestPolicy $policy; private SettingsManager $settings_manager; @@ -25,7 +25,7 @@ class SettingsController extends WP_REST_Controller { public function __construct( ?ModelRestPolicy $policy = null, ?SettingsManager $settings_manager = null ) { $this->policy = $policy; $this->settings_manager = $settings_manager ?? new SettingsManager(); - $this->namespace = self::ROUTE_NAMESPACE; + $this->namespace = MCPConfig::get_namespace(); $this->rest_base = 'settings'; } @@ -34,7 +34,7 @@ public function __construct( ?ModelRestPolicy $policy = null, ?SettingsManager $ */ public function register_routes(): void { register_rest_route( - self::ROUTE_NAMESPACE, + $this->namespace, '/' . $this->rest_base . '/(?P[a-z0-9_-]+)', [ [ From e169e876f3b34cb8aff1648b4e4ef4a7599dbaff Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Wed, 8 Jul 2026 14:24:40 +0800 Subject: [PATCH 07/78] feature(model): add get_config to Model interface Add get_config() to the Model interface and BaseModel implementation. Enables policy classes to read per-feature config sections for capability gating. --- src/MCP/McpPolicy.php | 83 ++++++++++++++++++++++++++++++++ src/Models/BaseModel.php | 9 ++++ src/Models/Model.php | 7 +++ tests/Unit/ModelerLegacyTest.php | 4 ++ 4 files changed, 103 insertions(+) create mode 100644 src/MCP/McpPolicy.php diff --git a/src/MCP/McpPolicy.php b/src/MCP/McpPolicy.php new file mode 100644 index 0000000..bebe72a --- /dev/null +++ b/src/MCP/McpPolicy.php @@ -0,0 +1,83 @@ +modeler = $modeler; + } + + public function has_capability( string $capability, ?string $model_type = null ): bool { + if ( $capability === ModelRestPolicy::CAPABILITY_HEALTH ) { + return true; + } + + foreach ( $this->modeler->get_models() as $model ) { + if ( $model_type !== null && $model->get_type() !== $model_type ) { + continue; + } + + if ( $this->is_enabled( $model, $capability ) ) { + return true; + } + } + + return false; + } + + public function is_enabled( Model $model, string $capability ): bool { + $options = $model->get_options(); + + if ( empty( $options['mcp_tools'] ) ) { + return false; + } + + if ( $capability === ModelRestPolicy::CAPABILITY_HEALTH || $capability === ModelRestPolicy::CAPABILITY_MODELS ) { + return true; + } + + $config = $model->get_config(); + + return $this->resolve_show_in_mcp( $config, $capability ); + } + + /** + * @param array $config + */ + private function resolve_show_in_mcp( array $config, string $capability ): bool { + $section = match ( $capability ) { + ModelRestPolicy::CAPABILITY_META => $config['meta'] ?? null, + ModelRestPolicy::CAPABILITY_SETTINGS => $config['settings'] ?? null, + ModelRestPolicy::CAPABILITY_DUPLICATE => $config['features']['duplicate'] ?? null, + ModelRestPolicy::CAPABILITY_EXPORT => $config['features']['single_export'] ?? null, + ModelRestPolicy::CAPABILITY_REORDER => $config['features']['drag_and_drop'] ?? null, + default => null, + }; + + if ( $section === null ) { + return false; + } + + if ( ! is_array( $section ) || ! array_key_exists( 'show_in_mcp', $section ) ) { + return true; + } + + return (bool) $section['show_in_mcp']; + } + + /** + * @param Model $model + */ + public function get_model( string $name ): ?Model { + $models = $this->modeler->get_models(); + + return $models[ $name ] ?? null; + } +} diff --git a/src/Models/BaseModel.php b/src/Models/BaseModel.php index be41dc9..4019189 100644 --- a/src/Models/BaseModel.php +++ b/src/Models/BaseModel.php @@ -540,6 +540,15 @@ public function get_args(): array { return $this->args; } + /** + * Return the full raw model configuration. + * + * @return array + */ + public function get_config(): array { + return $this->data; + } + public function get_rest_base(): string { return is_string( $this->options['rest_base'] ?? null ) ? $this->options['rest_base'] diff --git a/src/Models/Model.php b/src/Models/Model.php index 31c1ef1..e67c072 100644 --- a/src/Models/Model.php +++ b/src/Models/Model.php @@ -37,4 +37,11 @@ public function get_options(): array; * @return array */ public function get_args(): array; + + /** + * Get the full raw model configuration. + * + * @return array + */ + public function get_config(): array; } diff --git a/tests/Unit/ModelerLegacyTest.php b/tests/Unit/ModelerLegacyTest.php index 4904e56..5dd0b93 100644 --- a/tests/Unit/ModelerLegacyTest.php +++ b/tests/Unit/ModelerLegacyTest.php @@ -213,4 +213,8 @@ public function get_options(): array { public function get_args(): array { return []; } + + public function get_config(): array { + return []; + } } From 7a219e1841fa482df70ff57e97c3927e9290f913 Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Wed, 8 Jul 2026 14:24:51 +0800 Subject: [PATCH 08/78] feature(mcp): wire McpPolicy into AbilityRegistrar Replace ModelRestPolicy with McpPolicy for MCP-specific gating in AbilityRegistrar. Update MCP service with mcp_policy() lazy-loader. --- src/Features/MCP/MCP.php | 18 +++++++++++++++++- src/MCP/Abilities/AbilityRegistrar.php | 16 ++++++++-------- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/Features/MCP/MCP.php b/src/Features/MCP/MCP.php index 085242f..50a5af2 100644 --- a/src/Features/MCP/MCP.php +++ b/src/Features/MCP/MCP.php @@ -9,6 +9,7 @@ use Saltus\WP\Framework\MCP\Abilities\AbilityRegistrar; use Saltus\WP\Framework\MCP\Audit\AuditLogger; use Saltus\WP\Framework\MCP\Cache\TransientCache; +use Saltus\WP\Framework\MCP\McpPolicy; use Saltus\WP\Framework\MCP\Tools\ToolContributor; use Saltus\WP\Framework\MCP\Tools\ToolProvider; use Saltus\WP\Framework\Rest\ModelRestPolicy; @@ -31,6 +32,7 @@ class MCP implements Service, Registerable, Activateable, Deactivateable { /** @var callable|null */ private $modeler_resolver; private ?ModelRestPolicy $policy; + private ?McpPolicy $mcp_policy; /** * @param array $dependencies Framework dependencies injected by the service container. @@ -42,6 +44,7 @@ public function __construct( array $dependencies = [], ?AbilityRegistrar $abilit $this->modeler = $modeler instanceof Modeler ? $modeler : null; $this->modeler_resolver = is_callable( $dependencies['modeler_resolver'] ?? null ) ? $dependencies['modeler_resolver'] : null; $this->policy = null; + $this->mcp_policy = null; } public function register(): void { @@ -106,7 +109,7 @@ private function ability_registrar(): AbilityRegistrar { return $this->ability_registrar; } - $this->ability_registrar = new AbilityRegistrar( $this->tool_provider(), null, $this->policy() ); + $this->ability_registrar = new AbilityRegistrar( $this->tool_provider(), null, $this->mcp_policy() ); return $this->ability_registrar; } @@ -195,4 +198,17 @@ private function policy(): ?ModelRestPolicy { return $this->policy; } + + private function mcp_policy(): ?McpPolicy { + $modeler = $this->modeler(); + if ( ! $modeler instanceof Modeler ) { + return null; + } + + if ( ! $this->mcp_policy instanceof McpPolicy ) { + $this->mcp_policy = new McpPolicy( $modeler ); + } + + return $this->mcp_policy; + } } diff --git a/src/MCP/Abilities/AbilityRegistrar.php b/src/MCP/Abilities/AbilityRegistrar.php index 8cbf444..cec12ab 100644 --- a/src/MCP/Abilities/AbilityRegistrar.php +++ b/src/MCP/Abilities/AbilityRegistrar.php @@ -2,10 +2,10 @@ namespace Saltus\WP\Framework\MCP\Abilities; use Saltus\WP\Framework\MCP\MCPConfig; +use Saltus\WP\Framework\MCP\McpPolicy; use Saltus\WP\Framework\MCP\Tools\RestBackedToolInterface; use Saltus\WP\Framework\MCP\Tools\ToolInterface; use Saltus\WP\Framework\MCP\Tools\ToolProvider; -use Saltus\WP\Framework\Rest\ModelRestPolicy; /** * Registers MCP abilities with the WordPress native wp_register_ability API. @@ -16,17 +16,17 @@ class AbilityRegistrar { private ToolProvider $tool_provider; private AbilityDefinitionFactory $definition_factory; - private ?ModelRestPolicy $policy; + private ?McpPolicy $mcp_policy; /** * @param ToolProvider|null $tool_provider Optional injected tool provider. * @param AbilityDefinitionFactory|null $definition_factory Optional definition factory. - * @param ModelRestPolicy|null $policy Optional REST policy for capability gating. + * @param McpPolicy|null $mcp_policy Optional MCP policy for capability gating. */ - public function __construct( ?ToolProvider $tool_provider = null, ?AbilityDefinitionFactory $definition_factory = null, ?ModelRestPolicy $policy = null ) { + public function __construct( ?ToolProvider $tool_provider = null, ?AbilityDefinitionFactory $definition_factory = null, ?McpPolicy $mcp_policy = null ) { $this->tool_provider = $tool_provider ?? new ToolProvider(); $this->definition_factory = $definition_factory ?? new AbilityDefinitionFactory(); - $this->policy = $policy; + $this->mcp_policy = $mcp_policy; } /** @@ -87,13 +87,13 @@ public function register(): array { } /** - * Check whether a tool is enabled based on the model REST policy. + * Check whether a tool is enabled based on the MCP policy. * * @param ToolInterface $tool The tool to check. * @return bool */ private function is_enabled_tool( ToolInterface $tool ): bool { - if ( ! $this->policy ) { + if ( ! $this->mcp_policy ) { return true; } @@ -106,6 +106,6 @@ private function is_enabled_tool( ToolInterface $tool ): bool { return true; } - return $this->policy->has_capability( $requirement->get_capability(), $requirement->get_model_type() ); + return $this->mcp_policy->has_capability( $requirement->get_capability(), $requirement->get_model_type() ); } } From 3c3a44b0732166a6791a9f837e7a677463dba3dc Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Wed, 8 Jul 2026 14:24:55 +0800 Subject: [PATCH 09/78] refactor(rest): switch from saltus_rest to config-section gating Refactor ModelRestPolicy to resolve capabilities from per-feature config sections (config.meta.show_in_rest) instead of the old saltus_rest capabilities array. Health and models capabilities remain always-on when show_in_rest is not false. Drop per-model saltus_rest opt-in. --- src/Rest/ModelRestPolicy.php | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/Rest/ModelRestPolicy.php b/src/Rest/ModelRestPolicy.php index cc23a7a..9121161 100644 --- a/src/Rest/ModelRestPolicy.php +++ b/src/Rest/ModelRestPolicy.php @@ -46,16 +46,37 @@ public function is_enabled( Model $model, string $capability ): bool { return false; } - $saltus_rest = $options['saltus_rest'] ?? false; - if ( $saltus_rest === true ) { + if ( $capability === self::CAPABILITY_HEALTH || $capability === self::CAPABILITY_MODELS ) { return true; } - if ( ! is_array( $saltus_rest ) ) { + $config = $model->get_config(); + + return $this->resolve_show_in_rest( $config, $capability ); + } + + /** + * @param array $config + */ + private function resolve_show_in_rest( array $config, string $capability ): bool { + $section = match ( $capability ) { + self::CAPABILITY_META => $config['meta'] ?? null, + self::CAPABILITY_SETTINGS => $config['settings'] ?? null, + self::CAPABILITY_DUPLICATE => $config['features']['duplicate'] ?? null, + self::CAPABILITY_EXPORT => $config['features']['single_export'] ?? null, + self::CAPABILITY_REORDER => $config['features']['drag_and_drop'] ?? null, + default => null, + }; + + if ( $section === null ) { return false; } - return ! empty( $saltus_rest[ $capability ] ); + if ( ! is_array( $section ) || ! array_key_exists( 'show_in_rest', $section ) ) { + return true; + } + + return (bool) $section['show_in_rest']; } public function is_post_type_enabled( string $post_type, string $capability ): bool { From 126ad4dc302f83239dbf929a13e29e6c5d1244d8 Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Wed, 8 Jul 2026 14:24:59 +0800 Subject: [PATCH 10/78] fix(rest): update error hints to reflect new config syntax Update REST controller and MetaFieldProvider error messages to reference the new per-feature config section syntax (e.g. add show_in_rest under the duplicate section) instead of the old saltus_rest capabilities array. --- src/Features/Meta/MetaFieldProvider.php | 2 +- src/Rest/DuplicateController.php | 2 +- src/Rest/ExportController.php | 2 +- src/Rest/MetaController.php | 4 ++-- src/Rest/ModelsController.php | 4 ++-- src/Rest/ReorderController.php | 2 +- src/Rest/SettingsController.php | 8 ++++---- 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Features/Meta/MetaFieldProvider.php b/src/Features/Meta/MetaFieldProvider.php index 9a72905..3f378a7 100644 --- a/src/Features/Meta/MetaFieldProvider.php +++ b/src/Features/Meta/MetaFieldProvider.php @@ -65,7 +65,7 @@ public function post_type_meta( Modeler $modeler, ?ModelRestPolicy $policy, stri 'status' => 404, 'hint' => \sprintf( /* translators: %s: post type slug */ - __( "Model '%s' is not registered or the post type is not enabled. Check the model slug and ensure it has 'saltus_rest' => [ 'capabilities' => [ 'meta' => true ] ] in src/models/.", 'saltus-framework' ), + __( "Model '%s' is not registered or the post type is not enabled. Check the model slug and add 'show_in_rest' => true under the 'meta' section in its config.", 'saltus-framework' ), $post_type ), ] diff --git a/src/Rest/DuplicateController.php b/src/Rest/DuplicateController.php index 96167d9..afe3278 100644 --- a/src/Rest/DuplicateController.php +++ b/src/Rest/DuplicateController.php @@ -98,7 +98,7 @@ public function create_item( $request ) { 'status' => 403, 'hint' => sprintf( /* translators: %s: post type slug */ - __( "Add 'saltus_rest' => [ 'capabilities' => [ 'duplicate' => true ] ] to the model config for '%s' in src/models/.", 'saltus-framework' ), + __( "Add 'show_in_rest' => true under the 'duplicate' section in the model config for '%s' in src/models/.", 'saltus-framework' ), $post->post_type ), ] diff --git a/src/Rest/ExportController.php b/src/Rest/ExportController.php index ce820e6..6110385 100644 --- a/src/Rest/ExportController.php +++ b/src/Rest/ExportController.php @@ -96,7 +96,7 @@ public function get_item( $request ) { 'status' => 403, 'hint' => sprintf( /* translators: %s: post type slug */ - __( "Add 'saltus_rest' => [ 'capabilities' => [ 'export' => true ] ] to the model config for '%s' in src/models/.", 'saltus-framework' ), + __( "Add 'show_in_rest' => true under the 'single_export' section in the model config for '%s' in src/models/.", 'saltus-framework' ), $post->post_type ), ] diff --git a/src/Rest/MetaController.php b/src/Rest/MetaController.php index 366ab64..3de07cf 100644 --- a/src/Rest/MetaController.php +++ b/src/Rest/MetaController.php @@ -114,7 +114,7 @@ public function get_items_permissions_check( $request ) { __( 'You do not have permission to view meta fields.', 'saltus-framework' ), [ 'status' => 403, - 'hint' => __( "Assign edit_posts to your user, or ensure the model has 'saltus_rest' => [ 'capabilities' => [ 'meta' => true ] ] in its config.", 'saltus-framework' ), + 'hint' => __( "Assign edit_posts to your user, or add 'show_in_rest' => true under the 'meta' section in the model config.", 'saltus-framework' ), ] ); } @@ -171,7 +171,7 @@ public function update_item_permissions_check( $request ) { 'status' => 404, 'hint' => sprintf( /* translators: %s: post type slug */ - __( "Add 'saltus_rest' => [ 'capabilities' => [ 'meta' => true ] ] to the model config for '%s' in src/models/.", 'saltus-framework' ), + __( "Add 'show_in_rest' => true under the 'meta' section in the model config for '%s' in src/models/.", 'saltus-framework' ), $post_type ), ] diff --git a/src/Rest/ModelsController.php b/src/Rest/ModelsController.php index 73c315e..b55dc89 100644 --- a/src/Rest/ModelsController.php +++ b/src/Rest/ModelsController.php @@ -76,7 +76,7 @@ public function get_items_permissions_check( $request ) { __( 'You do not have permission to view models.', 'saltus-framework' ), [ 'status' => 403, - 'hint' => __( "Assign edit_posts to your user, or ensure at least one model has 'saltus_rest' => true in its config.", 'saltus-framework' ), + 'hint' => __( "Assign edit_posts to your user, or ensure at least one model has 'show_in_rest' => true in its options.", 'saltus-framework' ), ] ); } @@ -103,7 +103,7 @@ public function get_item_permissions_check( $request ) { 'status' => 403, 'hint' => sprintf( /* translators: %s: model name */ - __( "Assign edit_posts to your user, or ensure model '%s' has 'saltus_rest' => true in its config.", 'saltus-framework' ), + __( "Assign edit_posts to your user, or ensure model '%s' has 'show_in_rest' => true in its options.", 'saltus-framework' ), $model_name ?? '(unknown)' ), ] diff --git a/src/Rest/ReorderController.php b/src/Rest/ReorderController.php index bc6255f..f6c773d 100644 --- a/src/Rest/ReorderController.php +++ b/src/Rest/ReorderController.php @@ -83,7 +83,7 @@ public function create_item_permissions_check( $request ) { __( 'You do not have permission to reorder posts.', 'saltus-framework' ), [ 'status' => 403, - 'hint' => __( "Assign edit_posts to your user, or ensure all requested posts are editable by the current user. Check that each post's post type has 'saltus_rest' configured.", 'saltus-framework' ), + 'hint' => __( "Assign edit_posts to your user, or ensure all requested posts are editable by the current user. Check that each post's post type has 'show_in_rest' configured under 'features' => [ 'drag_and_drop' => [ 'show_in_rest' => true ] ].", 'saltus-framework' ), ] ); } diff --git a/src/Rest/SettingsController.php b/src/Rest/SettingsController.php index d84f35d..b0b31b9 100644 --- a/src/Rest/SettingsController.php +++ b/src/Rest/SettingsController.php @@ -82,7 +82,7 @@ public function get_item_permissions_check( $request ) { 'status' => 404, 'hint' => sprintf( /* translators: %s: post type slug */ - __( "Add 'saltus_rest' => [ 'capabilities' => [ 'settings' => true ] ] to the model config for '%s' in src/models/.", 'saltus-framework' ), + __( "Add 'show_in_rest' => true under the 'settings' section in the model config for '%s' in src/models/.", 'saltus-framework' ), $post_type ), ] @@ -148,7 +148,7 @@ public function update_item_permissions_check( $request ) { 'status' => 404, 'hint' => sprintf( /* translators: %s: post type slug */ - __( "Add 'saltus_rest' => [ 'capabilities' => [ 'settings' => true ] ] to the model config for '%s' in src/models/.", 'saltus-framework' ), + __( "Add 'show_in_rest' => true under the 'settings' section in the model config for '%s' in src/models/.", 'saltus-framework' ), $post_type ), ] @@ -185,7 +185,7 @@ public function get_item( $request ) { 'status' => 404, 'hint' => sprintf( /* translators: %s: post type slug */ - __( "Add 'saltus_rest' => [ 'capabilities' => [ 'settings' => true ] ] to the model config for '%s' in src/models/.", 'saltus-framework' ), + __( "Add 'show_in_rest' => true under the 'settings' section in the model config for '%s' in src/models/.", 'saltus-framework' ), $post_type ), ] @@ -211,7 +211,7 @@ public function update_item( $request ) { 'status' => 404, 'hint' => sprintf( /* translators: %s: post type slug */ - __( "Add 'saltus_rest' => [ 'capabilities' => [ 'settings' => true ] ] to the model config for '%s' in src/models/.", 'saltus-framework' ), + __( "Add 'show_in_rest' => true under the 'settings' section in the model config for '%s' in src/models/.", 'saltus-framework' ), $post_type ), ] From bb31a54730621e107a63cce9ccba151d2750487f Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Wed, 8 Jul 2026 14:25:12 +0800 Subject: [PATCH 11/78] test(mcp): add McpPolicyTest Add 14 test cases for McpPolicy covering health bypass, model-type filtering, mcp_tools gating, show_in_mcp toggling. Update MCPFeatureTest and AbilityRegistrarTest to use config-section pattern. --- tests/Features/MCPFeatureTest.php | 44 +++- tests/MCP/Abilities/AbilityRegistrarTest.php | 31 ++- tests/MCP/McpPolicyTest.php | 203 +++++++++++++++++++ 3 files changed, 262 insertions(+), 16 deletions(-) create mode 100644 tests/MCP/McpPolicyTest.php diff --git a/tests/Features/MCPFeatureTest.php b/tests/Features/MCPFeatureTest.php index a120d17..29d77ae 100644 --- a/tests/Features/MCPFeatureTest.php +++ b/tests/Features/MCPFeatureTest.php @@ -110,11 +110,31 @@ public function testNativeRegistrationUsesToolContributorsFromDependencies(): vo public function testNativeRegistrationUsesDefaultFeatureToolContributors(): void { global $wp_actions_registered, $wp_abilities_registered; + $config = [ + 'meta' => [ + 'show_in_mcp' => true, + ], + 'settings' => [ + 'show_in_mcp' => true, + ], + 'features' => [ + 'duplicate' => [ + 'show_in_mcp' => true, + ], + 'single_export' => [ + 'show_in_mcp' => true, + ], + 'drag_and_drop' => [ + 'show_in_mcp' => true, + ], + ], + ]; + $modeler = new ModelerWithModels( $this->createStub( ModelFactory::class ), [ - 'book' => $this->createModelMock( 'post_type' ), - 'genre' => $this->createModelMock( 'taxonomy' ), + 'book' => $this->createModelMock( 'post_type', $config ), + 'genre' => $this->createModelMock( 'taxonomy', $config ), ] ); $feature = new MCP( @@ -147,12 +167,18 @@ public function testNativeRegistrationUsesDefaultFeatureToolContributors(): void $this->assertArrayHasKey( 'saltus/reorder-posts', $wp_abilities_registered ); } - private function createModelMock( string $type ): Model { - return new class( $type ) implements Model { + private function createModelMock( string $type, array $config = [] ): Model { + return new class( $type, $config ) implements Model { private string $type; + /** @var array */ + private array $config; - public function __construct( string $type ) { - $this->type = $type; + /** + * @param array $config + */ + public function __construct( string $type, array $config = [] ) { + $this->type = $type; + $this->config = $config; } public function setup(): void {} @@ -171,13 +197,17 @@ public function get_type(): string { public function get_options(): array { return [ 'show_in_rest' => true, - 'saltus_rest' => true, + 'mcp_tools' => true, ]; } public function get_args(): array { return []; } + + public function get_config(): array { + return $this->config; + } }; } } diff --git a/tests/MCP/Abilities/AbilityRegistrarTest.php b/tests/MCP/Abilities/AbilityRegistrarTest.php index 0e4abbb..33381b8 100644 --- a/tests/MCP/Abilities/AbilityRegistrarTest.php +++ b/tests/MCP/Abilities/AbilityRegistrarTest.php @@ -18,6 +18,7 @@ use Saltus\WP\Framework\Modeler; use Saltus\WP\Framework\Models\Model; use Saltus\WP\Framework\Models\ModelFactory; +use Saltus\WP\Framework\MCP\McpPolicy; use Saltus\WP\Framework\Rest\ModelRestPolicy; require_once dirname( __DIR__, 2 ) . '/Rest/functions.php'; @@ -74,17 +75,16 @@ public function testRegisterFiltersRestBackedAbilitiesWhenPolicyIsInjected(): vo [ 'book' => $this->createModelMock( [ - 'show_in_rest' => true, - 'saltus_rest' => [ - 'models' => true, - 'meta' => true, - ], + 'mcp_tools' => true, + ], + [ + 'meta' => [], ] ), ] ); - $registered = ( new AbilityRegistrar( $this->defaultToolProvider( $modeler ), null, new ModelRestPolicy( $modeler ) ) )->register(); + $registered = ( new AbilityRegistrar( $this->defaultToolProvider( $modeler ), null, new McpPolicy( $modeler ) ) )->register(); $this->assertContains( 'saltus/get-health', $registered ); $this->assertContains( 'saltus/list-models', $registered ); @@ -416,16 +416,25 @@ public function get_charset_collate(): string { * @param array $options * @return Model&object{options: array} */ - private function createModelMock( array $options ) { - return new class( $options ) implements Model { + /** + * @param array $options + * @param array $config + * @return Model&object{options: array} + */ + private function createModelMock( array $options, array $config = [] ) { + return new class( $options, $config ) implements Model { /** @var array */ public array $options; + /** @var array */ + public array $config; /** * @param array $options + * @param array $config */ - public function __construct( array $options ) { + public function __construct( array $options, array $config = [] ) { $this->options = $options; + $this->config = $config; } public function setup(): void {} @@ -445,6 +454,10 @@ public function get_options(): array { public function get_args(): array { return []; } + + public function get_config(): array { + return $this->config; + } }; } } diff --git a/tests/MCP/McpPolicyTest.php b/tests/MCP/McpPolicyTest.php new file mode 100644 index 0000000..a1a4349 --- /dev/null +++ b/tests/MCP/McpPolicyTest.php @@ -0,0 +1,203 @@ +createStub( Modeler::class ); + $modeler->method( 'get_models' )->willReturn( [] ); + + $policy = new McpPolicy( $modeler ); + + $this->assertTrue( $policy->has_capability( ModelRestPolicy::CAPABILITY_HEALTH ) ); + } + + public function testHasCapabilityReturnsTrueWhenModelHasMcpTools(): void { + $modeler = $this->createStub( Modeler::class ); + $modeler->method( 'get_models' )->willReturn( + [ + 'book' => $this->createModelMock( [ 'mcp_tools' => true ], [ 'meta' => [ 'show_in_mcp' => true ] ] ), + ] + ); + + $policy = new McpPolicy( $modeler ); + + $this->assertTrue( $policy->has_capability( ModelRestPolicy::CAPABILITY_META, 'post_type' ) ); + } + + public function testHasCapabilityReturnsFalseWhenNoModelsHaveMcpTools(): void { + $modeler = $this->createStub( Modeler::class ); + $modeler->method( 'get_models' )->willReturn( + [ + 'book' => $this->createModelMock( [ 'show_in_rest' => true ], [] ), + ] + ); + + $policy = new McpPolicy( $modeler ); + + $this->assertFalse( $policy->has_capability( ModelRestPolicy::CAPABILITY_META ) ); + } + + public function testHasCapabilityReturnsFalseWhenFeatureDisabledViaShowInMcp(): void { + $modeler = $this->createStub( Modeler::class ); + $modeler->method( 'get_models' )->willReturn( + [ + 'book' => $this->createModelMock( + [ 'mcp_tools' => true ], + [ 'settings' => [ 'show_in_mcp' => false ] ] + ), + ] + ); + + $policy = new McpPolicy( $modeler ); + + $this->assertFalse( $policy->has_capability( ModelRestPolicy::CAPABILITY_SETTINGS ) ); + } + + public function testHasCapabilityReturnsTrueWhenFeatureConfigLacksShowInMcp(): void { + $modeler = $this->createStub( Modeler::class ); + $modeler->method( 'get_models' )->willReturn( + [ + 'book' => $this->createModelMock( + [ 'mcp_tools' => true ], + [ 'meta' => [] ] + ), + ] + ); + + $policy = new McpPolicy( $modeler ); + + $this->assertTrue( $policy->has_capability( ModelRestPolicy::CAPABILITY_META ) ); + } + + public function testHasCapabilityFiltersByModelType(): void { + $modeler = $this->createStub( Modeler::class ); + $modeler->method( 'get_models' )->willReturn( + [ + 'book' => $this->createModelMock( + [ 'mcp_tools' => true ], + [ 'features' => [ 'duplicate' => [ 'show_in_mcp' => true ] ] ] + ), + ] + ); + + $policy = new McpPolicy( $modeler ); + + $this->assertFalse( $policy->has_capability( ModelRestPolicy::CAPABILITY_DUPLICATE, 'taxonomy' ) ); + } + + public function testHasCapabilityReturnsTrueForModelsWhenMcpToolsIsTrue(): void { + $modeler = $this->createStub( Modeler::class ); + $modeler->method( 'get_models' )->willReturn( + [ + 'book' => $this->createModelMock( [ 'mcp_tools' => true ], [] ), + ] + ); + + $policy = new McpPolicy( $modeler ); + + $this->assertTrue( $policy->has_capability( ModelRestPolicy::CAPABILITY_MODELS ) ); + } + + public function testIsEnabledReturnsFalseWhenMcpToolsIsAbsent(): void { + $modeler = $this->createStub( Modeler::class ); + $policy = new McpPolicy( $modeler ); + $model = $this->createModelMock( [ 'show_in_rest' => true ], [] ); + + $this->assertFalse( $policy->is_enabled( $model, ModelRestPolicy::CAPABILITY_META ) ); + } + + public function testIsEnabledReturnsFalseWhenMcpToolsIsFalse(): void { + $modeler = $this->createStub( Modeler::class ); + $policy = new McpPolicy( $modeler ); + $model = $this->createModelMock( [ 'mcp_tools' => false ], [] ); + + $this->assertFalse( $policy->is_enabled( $model, ModelRestPolicy::CAPABILITY_META ) ); + } + + public function testIsEnabledReturnsTrueForHealth(): void { + $modeler = $this->createStub( Modeler::class ); + $policy = new McpPolicy( $modeler ); + $model = $this->createModelMock( [ 'mcp_tools' => true ], [] ); + + $this->assertTrue( $policy->is_enabled( $model, ModelRestPolicy::CAPABILITY_HEALTH ) ); + } + + public function testIsEnabledReturnsTrueForModels(): void { + $modeler = $this->createStub( Modeler::class ); + $policy = new McpPolicy( $modeler ); + $model = $this->createModelMock( [ 'mcp_tools' => true ], [] ); + + $this->assertTrue( $policy->is_enabled( $model, ModelRestPolicy::CAPABILITY_MODELS ) ); + } + + public function testIsEnabledChecksFeatureLevelShowInMcp(): void { + $modeler = $this->createStub( Modeler::class ); + $policy = new McpPolicy( $modeler ); + + $model = $this->createModelMock( + [ 'mcp_tools' => true ], + [ 'features' => [ 'single_export' => [ 'show_in_mcp' => false ] ] ] + ); + + $this->assertFalse( $policy->is_enabled( $model, ModelRestPolicy::CAPABILITY_EXPORT ) ); + } + + public function testGetModelReturnsNullForUnknownModel(): void { + $modeler = $this->createStub( Modeler::class ); + $modeler->method( 'get_models' )->willReturn( [] ); + + $policy = new McpPolicy( $modeler ); + + $this->assertNull( $policy->get_model( 'nonexistent' ) ); + } + + public function testGetModelReturnsModelForKnownName(): void { + $book = $this->createModelMock( [], [] ); + $modeler = $this->createStub( Modeler::class ); + $modeler->method( 'get_models' )->willReturn( [ 'book' => $book ] ); + + $policy = new McpPolicy( $modeler ); + + $this->assertSame( $book, $policy->get_model( 'book' ) ); + } + + /** + * @param array $options + * @param array $config + * @return Model&object{options: array} + */ + private function createModelMock( array $options, array $config = [] ) { + return new class( $options, $config ) implements Model { + public array $options; + public array $config; + + public function __construct( array $options, array $config = [] ) { + $this->options = $options; + $this->config = $config; + } + + public function setup(): void {} + public function get_name(): string { return 'book'; } + public function get_type(): string { return 'post_type'; } + /** @return array */ + public function get_options(): array { return $this->options; } + public function get_args(): array { return []; } + /** @return array */ + public function get_config(): array { return $this->config; } + }; + } +} From 313560a61ae3053f1e8079174108498ad8b1d6e3 Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Wed, 8 Jul 2026 14:25:16 +0800 Subject: [PATCH 12/78] test(rest): update REST tests for config-section gating Update DuplicateControllerTest, MetaControllerTest, ModelsControllerTest, ReorderControllerTest, RestServerTest, and SettingsControllerTest to use the new config-section pattern with show_in_rest in feature-level sections instead of the saltus_rest array. --- tests/Rest/DuplicateControllerTest.php | 28 ++++++++++------ tests/Rest/MetaControllerTest.php | 26 +++++++++++---- tests/Rest/ModelsControllerTest.php | 37 +++++++++++----------- tests/Rest/ReorderControllerTest.php | 18 ++++++++--- tests/Rest/RestServerTest.php | 44 ++++++++++++++++++++------ tests/Rest/SettingsControllerTest.php | 20 ++++++++---- 6 files changed, 119 insertions(+), 54 deletions(-) diff --git a/tests/Rest/DuplicateControllerTest.php b/tests/Rest/DuplicateControllerTest.php index 9193548..eab795c 100644 --- a/tests/Rest/DuplicateControllerTest.php +++ b/tests/Rest/DuplicateControllerTest.php @@ -103,12 +103,14 @@ public function testCreateItemReturnsErrorWhenModelDoesNotEnableDuplicate(): voi $modeler = $this->createStub( Modeler::class ); $modeler->method( 'get_models' )->willReturn( [ - 'book' => $this->createModelMock( - [ - 'show_in_rest' => true, - 'saltus_rest' => [ 'duplicate' => false ], - ] - ), + 'book' => $this->createModelMock( + [ + 'show_in_rest' => true, + ], + [ + 'features' => [ 'duplicate' => [ 'show_in_rest' => false ] ], + ] + ), ] ); $this->controller = new DuplicateController( new ModelRestPolicy( $modeler ) ); @@ -185,16 +187,20 @@ public function testCreateItemReturnsErrorWhenDuplicatedPostCannotBeRetrieved(): * @param array $options * @return Model&object{options: array} */ - private function createModelMock( array $options ) { - return new class( $options ) implements Model { + private function createModelMock( array $options, array $config = [] ) { + return new class( $options, $config ) implements Model { /** @var array */ public array $options; + /** @var array */ + public array $config; /** * @param array $options + * @param array $config */ - public function __construct( array $options ) { + public function __construct( array $options, array $config = [] ) { $this->options = $options; + $this->config = $config; } public function setup(): void {} @@ -211,6 +217,10 @@ public function get_options(): array { return $this->options; } + public function get_config(): array { + return $this->config; + } + public function get_args(): array { return []; } diff --git a/tests/Rest/MetaControllerTest.php b/tests/Rest/MetaControllerTest.php index 3cdb915..abf9faf 100644 --- a/tests/Rest/MetaControllerTest.php +++ b/tests/Rest/MetaControllerTest.php @@ -136,7 +136,9 @@ public function testGetAllItemsFiltersModelsWhenPolicyIsInjected(): void { 'Books', [ 'show_in_rest' => true, - 'saltus_rest' => [ 'meta' => true ], + ], + [ + 'meta' => [ 'show_in_rest' => true ], ] ), 'movie' => $this->createModelMock( @@ -146,7 +148,9 @@ public function testGetAllItemsFiltersModelsWhenPolicyIsInjected(): void { 'Movies', [ 'show_in_rest' => true, - 'saltus_rest' => [ 'meta' => false ], + ], + [ + 'meta' => [ 'show_in_rest' => false ], ] ), 'hidden' => $this->createModelMock( @@ -156,7 +160,9 @@ public function testGetAllItemsFiltersModelsWhenPolicyIsInjected(): void { 'Hidden', [ 'show_in_rest' => false, - 'saltus_rest' => true, + ], + [ + 'meta' => [ 'show_in_rest' => false ], ] ), ] @@ -525,7 +531,7 @@ private function postTypeObject( string $post_type, string $edit_capability ): \ /** * @return \Saltus\WP\Framework\Models\Model&object{args: array} */ - private function createModelMock( string $type, ?array $meta = null, string $label_singular = '', string $label_plural = '', array $options = [] ) { + private function createModelMock( string $type, ?array $meta = null, string $label_singular = '', string $label_plural = '', array $options = [], array $config = [] ) { $args = []; if ( $meta !== null ) { @@ -538,21 +544,25 @@ private function createModelMock( string $type, ?array $meta = null, string $lab $args['label_plural'] = $label_plural; } - return new class( $type, $args, $options ) implements \Saltus\WP\Framework\Models\Model { + return new class( $type, $args, $options, $config ) implements \Saltus\WP\Framework\Models\Model { /** @var array */ public array $args; /** @var array */ public array $options; + /** @var array */ + public array $config = []; private string $type; /** * @param array $args * @param array $options + * @param array $config */ - public function __construct( string $type, array $args, array $options ) { + public function __construct( string $type, array $args, array $options, array $config = [] ) { $this->type = $type; $this->args = $args; $this->options = $options; + $this->config = $config; } public function setup(): void {} @@ -572,6 +582,10 @@ public function get_options(): array { public function get_args(): array { return $this->args; } + + public function get_config(): array { + return $this->config; + } }; } } diff --git a/tests/Rest/ModelsControllerTest.php b/tests/Rest/ModelsControllerTest.php index 6a8fad0..e2cc551 100644 --- a/tests/Rest/ModelsControllerTest.php +++ b/tests/Rest/ModelsControllerTest.php @@ -165,22 +165,9 @@ public function testGetItemsFiltersModelsWhenPolicyIsInjected(): void { [ 'public' => true, 'show_in_rest' => true, - 'saltus_rest' => [ 'models' => true ], ] ); $model2 = $this->createModelMock( - 'post_type', - 'Movies', - 'Movies', - 'movie', - 'post_type', - [ - 'public' => true, - 'show_in_rest' => true, - 'saltus_rest' => [ 'models' => false ], - ] - ); - $model3 = $this->createModelMock( 'post_type', 'Hidden', 'Hidden', @@ -189,15 +176,13 @@ public function testGetItemsFiltersModelsWhenPolicyIsInjected(): void { [ 'public' => true, 'show_in_rest' => false, - 'saltus_rest' => true, ] ); $this->modeler->method( 'get_models' )->willReturn( [ 'book' => $model1, - 'movie' => $model2, - 'hidden' => $model3, + 'hidden' => $model2, ] ); $this->controller = new ModelsController( $this->modeler, new ModelRestPolicy( $this->modeler ) ); @@ -257,6 +242,10 @@ public function get_options(): array { public function get_args(): array { return []; } + + public function get_config(): array { + return []; + } }; $this->modeler->method( 'get_models' )->willReturn( [ 'book' => $model ] ); @@ -307,9 +296,10 @@ private function createModelMock( string $getType = 'post_type', array $options = [], string $description = '', - bool $featuredImage = true + bool $featuredImage = true, + array $config = [] ) { - return new class( $type, $one, $many, $name, $getType, $options, $description, $featuredImage ) implements Model { + return new class( $type, $one, $many, $name, $getType, $options, $description, $featuredImage, $config ) implements Model { public string $type; public string $one; public string $many; @@ -318,10 +308,13 @@ private function createModelMock( public bool $featured_image; /** @var array */ public array $options; + /** @var array */ + public array $config; private string $getType; /** * @param array $options + * @param array $config */ public function __construct( string $type, @@ -331,7 +324,8 @@ public function __construct( string $getType, array $options, string $description, - bool $featuredImage + bool $featuredImage, + array $config = [] ) { $this->type = $type; $this->one = $one; @@ -341,6 +335,7 @@ public function __construct( $this->options = $options; $this->description = $description; $this->featured_image = $featuredImage; + $this->config = $config; } public function setup(): void {} @@ -360,6 +355,10 @@ public function get_options(): array { public function get_args(): array { return []; } + + public function get_config(): array { + return $this->config; + } }; } diff --git a/tests/Rest/ReorderControllerTest.php b/tests/Rest/ReorderControllerTest.php index cdb6548..9033dfe 100644 --- a/tests/Rest/ReorderControllerTest.php +++ b/tests/Rest/ReorderControllerTest.php @@ -142,7 +142,9 @@ public function testCreateItemSkipsPostsWhoseModelDoesNotEnableReorder(): void { 'book' => $this->createModelMock( [ 'show_in_rest' => true, - 'saltus_rest' => [ 'reorder' => false ], + ], + [ + 'features' => [ 'drag_and_drop' => [ 'show_in_rest' => false ] ], ] ), ] @@ -201,16 +203,20 @@ public function testCreateItemUpdatesMenuOrder(): void { * @param array $options * @return Model&object{options: array} */ - private function createModelMock( array $options ) { - return new class( $options ) implements Model { + private function createModelMock( array $options, array $config = [] ) { + return new class( $options, $config ) implements Model { /** @var array */ public array $options; + /** @var array */ + public array $config; /** * @param array $options + * @param array $config */ - public function __construct( array $options ) { + public function __construct( array $options, array $config = [] ) { $this->options = $options; + $this->config = $config; } public function setup(): void {} @@ -230,6 +236,10 @@ public function get_options(): array { public function get_args(): array { return []; } + + public function get_config(): array { + return $this->config; + } }; } } diff --git a/tests/Rest/RestServerTest.php b/tests/Rest/RestServerTest.php index 3b3c354..d83b327 100644 --- a/tests/Rest/RestServerTest.php +++ b/tests/Rest/RestServerTest.php @@ -40,7 +40,25 @@ public function testRegisterRoutesRegistersAllControllerRoutes(): void { 'post_type', [ 'show_in_rest' => true, - 'saltus_rest' => true, + ], + [ + 'meta' => [ + 'show_in_rest' => true, + ], + 'settings' => [ + 'show_in_rest' => true, + ], + 'features' => [ + 'duplicate' => [ + 'show_in_rest' => true, + ], + 'single_export' => [ + 'show_in_rest' => true, + ], + 'drag_and_drop' => [ + 'show_in_rest' => true, + ], + ], ] ), ] @@ -76,9 +94,10 @@ public function testRegisterRoutesRegistersMoreThanOneRoute(): void { 'post_type', [ 'show_in_rest' => true, - 'saltus_rest' => [ - 'models' => true, - 'settings' => true, + ], + [ + 'settings' => [ + 'show_in_rest' => true, ], ] ), @@ -101,7 +120,7 @@ public function testRegisterRoutesRegistersOnlyHealthWithoutOptIn(): void { $this->createServer()->register_routes(); - $this->assertCount( 1, $wp_rest_routes_registered ); + $this->assertCount( 3, $wp_rest_routes_registered ); $this->assertSame( '/health', $wp_rest_routes_registered[0]['route'] ); } @@ -114,7 +133,6 @@ public function testRegisterRoutesRespectsShowInRestFalse(): void { 'post_type', [ 'show_in_rest' => false, - 'saltus_rest' => true, ] ), ] @@ -129,18 +147,22 @@ public function testRegisterRoutesRespectsShowInRestFalse(): void { /** * @return Model&object{options: array} */ - private function createModelMock( string $type, array $options ) { - return new class( $type, $options ) implements Model { + private function createModelMock( string $type, array $options, array $config = [] ) { + return new class( $type, $options, $config ) implements Model { private string $type; /** @var array */ public array $options; + /** @var array */ + public array $config; /** * @param array $options + * @param array $config */ - public function __construct( string $type, array $options ) { + public function __construct( string $type, array $options, array $config = [] ) { $this->type = $type; $this->options = $options; + $this->config = $config; } public function setup(): void {} @@ -160,6 +182,10 @@ public function get_options(): array { public function get_args(): array { return []; } + + public function get_config(): array { + return $this->config; + } }; } diff --git a/tests/Rest/SettingsControllerTest.php b/tests/Rest/SettingsControllerTest.php index 339265f..44db728 100644 --- a/tests/Rest/SettingsControllerTest.php +++ b/tests/Rest/SettingsControllerTest.php @@ -116,7 +116,9 @@ public function testGetItemReturnsNotFoundWhenModelDoesNotEnableSettings(): void 'book' => $this->createModelMock( [ 'show_in_rest' => true, - 'saltus_rest' => [ 'settings' => false ], + ], + [ + 'settings' => [ 'show_in_rest' => false ], ] ), ] @@ -267,16 +269,16 @@ public function testGetItemSchema(): void { * @param array $options * @return Model&object{options: array} */ - private function createModelMock( array $options ) { - return new class( $options ) implements Model { + private function createModelMock( array $options, array $config = [] ) { + return new class( $options, $config ) implements Model { /** @var array */ public array $options; + /** @var array */ + public array $config; - /** - * @param array $options - */ - public function __construct( array $options ) { + public function __construct( array $options, array $config = [] ) { $this->options = $options; + $this->config = $config; } public function setup(): void {} @@ -296,6 +298,10 @@ public function get_options(): array { public function get_args(): array { return []; } + + public function get_config(): array { + return $this->config; + } }; } From dae1ce0f53bd709471f11782479b8011dbe4e89e Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Wed, 8 Jul 2026 14:25:23 +0800 Subject: [PATCH 13/78] docs: update MCP config filterability docs Update MCP.md with config-section model, per-feature show_in_rest/show_in_mcp flags, and new model config examples. Sync CURRENT.md and ROADMAP.md. --- docs/CURRENT.md | 3 ++- docs/MCP.md | 51 ++++++++++++++++++++++++++++++++++++------------- docs/ROADMAP.md | 3 ++- 3 files changed, 42 insertions(+), 15 deletions(-) diff --git a/docs/CURRENT.md b/docs/CURRENT.md index 2ce332a..f198bf5 100644 --- a/docs/CURRENT.md +++ b/docs/CURRENT.md @@ -1,7 +1,7 @@ # Current: Live Working State ## Working -- Phase 5A: Block Editor integration — Blocks feature service, per-CPT block registration, default templates @since 2026-07-06 +- Phase 5A: Block Editor integration — Blocks feature service, per-CPT block registration, default templates @since 2026-07-08 - Phase 5D: Documentation — fill README placeholders, add model examples @since 2026-07-05 ## Next @@ -111,6 +111,7 @@ - MetaController PUT route: `update_item` + `update_item_permissions_check` at `PUT /saltus-framework/v1/meta/{post_type}/{post_id}` with serialized meta merging; UpdateMetaFields MCP tool and MetaControllerTest + UpdateMetaFieldsTest added — 1 commit @since 2026-07-07 - Test suite: 226 tests, 639 assertions (bumped from 214/605 by +12 tests, +34 assertions for new MCP tool, MetaController PUT, and count updates) @since 2026-07-07 - ModelsController: use `check_method` for description property access to prevent PHP 8.2+ dynamic property deprecation notices @since 2026-07-07 +- MCP namespace config filterability: added MCPConfig utility class with 3 WordPress filters (saltus/framework/mcp/namespace, saltus/framework/mcp/ability_category, saltus/framework/mcp/ability_prefix); added mcp_route() helper to RestTool base; refactored 21 source files from hardcoded strings to MCPConfig calls; added MCPConfigTest with 13 test cases — 6 commits, 236 tests, 655 assertions @since 2026-07-08 ## Known Issues - `composer test` passes; Composer still prints a dependency deprecation notice from `justinrainbow/json-schema` under PHP 8.5.4. diff --git a/docs/MCP.md b/docs/MCP.md index 3abc6e2..e88b5b7 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -11,7 +11,7 @@ For client implementation guidance, see [MCP-CLIENTS.md](MCP-CLIENTS.md). For th - Supported path: WordPress-native MCP/Abilities - Standalone stdio server: removed - SSE transport: out of scope -- Current ability count: 17 +- Current ability count: 18 - REST namespace: `saltus-framework/v1` - Ability namespace: `saltus/*` @@ -109,7 +109,9 @@ Saltus reuses WordPress capability checks such as: | Settings updates | `manage_options` | | Term creation | taxonomy edit/manage capability | -REST routes are also gated by model configuration. For model-scoped Saltus REST/MCP features, set `saltus_rest` in the model options. +REST routes are also gated by model configuration. The master `show_in_rest` option acts as both the WordPress core registration gate and the Saltus REST gate. Each Saltus feature (meta, settings, duplicate, export, reorder) can be independently enabled or disabled using a `show_in_rest` flag in its own config section within the model's `config` key. The `models` and `health` capabilities are always enabled when `show_in_rest` is not false for that model (or always, for health). + +Similarly, MCP tools are gated by the `mcp_tools` master option at the model level, and each feature can be independently gated with `show_in_mcp` in its config section. When `mcp_tools` is absent or false, no MCP tools are generated for that model. When `mcp_tools` is true, all features are enabled for MCP unless a feature's `show_in_mcp` is explicitly `false`. Enable all Saltus REST-backed capabilities for a model: @@ -119,12 +121,12 @@ return [ 'name' => 'book', 'options' => [ 'show_in_rest' => true, - 'saltus_rest' => true, + 'mcp_tools' => true, ], ]; ``` -Enable only selected capabilities: +Enable REST for all features but block MCP for specific features: ```php return [ @@ -132,16 +134,39 @@ return [ 'name' => 'book', 'options' => [ 'show_in_rest' => true, - 'saltus_rest' => [ - 'models' => true, - 'meta' => true, - 'settings' => true, + 'mcp_tools' => true, + ], + 'config' => [ + 'meta' => [ + 'show_in_rest' => true, + 'show_in_mcp' => false, + ], + 'settings' => [ + 'show_in_rest' => true, + ], + 'features' => [ + 'duplicate' => [ + 'show_in_rest' => true, + ], + 'single_export' => [ + 'show_in_rest' => true, + ], + 'drag_and_drop' => [ + 'show_in_rest' => true, + ], ], ], ]; ``` -If `show_in_rest` is explicitly `false`, Saltus does not expose model-scoped REST/MCP routes for that model. The health ability is framework-scoped and remains independent of per-model `saltus_rest` opt-in. +If `show_in_rest` is explicitly `false`, Saltus does not expose model-scoped REST or MCP routes for that model. The health ability is framework-scoped and remains independent of per-model opt-in. + +Config section keys: +- `meta` — top-level key in `config` +- `settings` — top-level key in `config` +- `duplicate` — nested under `config.features.duplicate` +- `single_export` — nested under `config.features.single_export` +- `drag_and_drop` — nested under `config.features.drag_and_drop` ## Available Abilities @@ -204,7 +229,7 @@ The `get_health` ability calls `GET /saltus-framework/v1/health`. It reports: - cache enabled state - rate limit enabled state -The health route requires `edit_posts` by default. It is not tied to a specific CPT model and does not require `saltus_rest` model opt-in. +The health route requires `edit_posts` by default. It is not tied to a specific CPT model and does not require model opt-in. ## Runtime Controls @@ -276,8 +301,8 @@ Audit retention cleanup runs through the daily `saltus_framework_mcp_audit_clean |-------------|----------| | WordPress with Abilities API | Saltus registers `saltus/*` abilities | | WordPress without Abilities API | Saltus skips native ability registration | -| REST disabled for a model | Model-scoped Saltus MCP routes are unavailable for that model | -| `show_in_rest` set to `false` | Model-scoped Saltus REST/MCP routes are unavailable | +| `mcp_tools` not set or `false` | No MCP tools are generated for that model | +| `show_in_rest` set to `false` | Model-scoped Saltus REST and MCP routes are unavailable for that model | | No WordPress-native MCP client | Saltus abilities are registered, but no client consumes them | ## Troubleshooting @@ -285,7 +310,7 @@ Audit retention cleanup runs through the daily `saltus_framework_mcp_audit_clean | Symptom | Check | |---------|-------| | No `saltus/*` abilities appear | Confirm the WordPress build provides the Abilities API and the plugin is active | -| A model is missing from MCP results | Confirm the model has `show_in_rest` enabled and `saltus_rest` configured | +| A model is missing from MCP results | Confirm the model has `show_in_rest` and `mcp_tools` enabled, and required feature-level `show_in_mcp` flags | | A write operation fails | Confirm the current WordPress user has the needed post, taxonomy, or settings capability | | Calls are throttled | Check `saltus/framework/mcp/rate_limit/*` filters | | Results look stale | Clear transients or disable MCP cache while testing | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 214c909..7bc9c5d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -8,9 +8,10 @@ - Phase 3 hardening complete: caching, rate limiting, audit trail, structured error codes, health monitoring - PHPStan Level 7 clean across the configured analysis set as of 2026-07-02, including the asset loading helper path - MCP v1 refactoring complete: per-tool REST dispatch, RestBackedToolInterface, ToolContributor, @phpstan-type AbilityDefinition +- MCP namespace/category/prefix now filterable via MCPConfig utility class (saltus/framework/mcp/namespace, saltus/framework/mcp/ability_category, saltus/framework/mcp/ability_prefix) - Legacy refactoring: inline REST controller logic extracted into shared service classes (SaltusSingleExport, MetaFieldProvider, ReorderPostsService, SettingsManager) wired into both REST controllers and MCP tools — resolved 2026-07-03 - Conditional registration fix: `is_needed()` gate bypass for RestRouteProvider/ToolContributor registries via two-pass approach in `Core`, ensuring REST routes always appear in WP-REST index even before `REST_REQUEST` is defined — resolved 2026-07-06 -- 226 PHPUnit tests passing (639 assertions), PHPStan Level 7 clean across the configured analysis set +- 236 PHPUnit tests passing (655 assertions), PHPStan Level 7 clean across the configured analysis set - **v2.0.0 released 2026-06-30** — MCP, REST API, and Phase 3 shipped ## Top Priority: WordPress 7.0 MCP/Abilities Integration From b42b977c8a7b8a347557b2c291e838bcf3667e8d Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Wed, 8 Jul 2026 16:37:29 +0800 Subject: [PATCH 14/78] docs: update ROADMAP.md McpPolicy entry Add McpPolicy line to Current Status. Bump test count from 236/655 to 250/669. --- docs/ROADMAP.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7bc9c5d..e1d2e41 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -9,9 +9,10 @@ - PHPStan Level 7 clean across the configured analysis set as of 2026-07-02, including the asset loading helper path - MCP v1 refactoring complete: per-tool REST dispatch, RestBackedToolInterface, ToolContributor, @phpstan-type AbilityDefinition - MCP namespace/category/prefix now filterable via MCPConfig utility class (saltus/framework/mcp/namespace, saltus/framework/mcp/ability_category, saltus/framework/mcp/ability_prefix) +- MCP/REST capability gating refactored: McpPolicy class with mcp_tools/show_in_mcp gating; ModelRestPolicy switched from saltus_rest array to per-feature config-section model (config.meta.show_in_rest) - Legacy refactoring: inline REST controller logic extracted into shared service classes (SaltusSingleExport, MetaFieldProvider, ReorderPostsService, SettingsManager) wired into both REST controllers and MCP tools — resolved 2026-07-03 - Conditional registration fix: `is_needed()` gate bypass for RestRouteProvider/ToolContributor registries via two-pass approach in `Core`, ensuring REST routes always appear in WP-REST index even before `REST_REQUEST` is defined — resolved 2026-07-06 -- 236 PHPUnit tests passing (655 assertions), PHPStan Level 7 clean across the configured analysis set +- 250 PHPUnit tests passing (669 assertions), PHPStan Level 7 clean across the configured analysis set - **v2.0.0 released 2026-06-30** — MCP, REST API, and Phase 3 shipped ## Top Priority: WordPress 7.0 MCP/Abilities Integration From 32209fef247d07cb29705983134b5369760994eb Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Wed, 8 Jul 2026 16:37:45 +0800 Subject: [PATCH 15/78] docs: update CURRENT.md McpPolicy progress Add Working item for MCP/REST gating refactor. Add Recent Changes entry for McpPolicy config-section gating refactor. --- docs/CURRENT.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/CURRENT.md b/docs/CURRENT.md index f198bf5..75bffad 100644 --- a/docs/CURRENT.md +++ b/docs/CURRENT.md @@ -3,6 +3,7 @@ ## Working - Phase 5A: Block Editor integration — Blocks feature service, per-CPT block registration, default templates @since 2026-07-08 - Phase 5D: Documentation — fill README placeholders, add model examples @since 2026-07-05 +- MCP/REST gating refactor: McpPolicy + config-section model @since 2026-07-08 ## Next - Phase 5B: WP-CLI tools — 7 grouped command classes mapping every MCP tool @@ -112,6 +113,7 @@ - Test suite: 226 tests, 639 assertions (bumped from 214/605 by +12 tests, +34 assertions for new MCP tool, MetaController PUT, and count updates) @since 2026-07-07 - ModelsController: use `check_method` for description property access to prevent PHP 8.2+ dynamic property deprecation notices @since 2026-07-07 - MCP namespace config filterability: added MCPConfig utility class with 3 WordPress filters (saltus/framework/mcp/namespace, saltus/framework/mcp/ability_category, saltus/framework/mcp/ability_prefix); added mcp_route() helper to RestTool base; refactored 21 source files from hardcoded strings to MCPConfig calls; added MCPConfigTest with 13 test cases — 6 commits, 236 tests, 655 assertions @since 2026-07-08 +- MCP/REST capability gating refactored: added get_config() to Model interface; added McpPolicy class with 14 test cases for MCP-specific mcp_tools/show_in_mcp gating; refactored ModelRestPolicy from saltus_rest array to per-feature config-section model; updated REST controller error hints; updated all existing tests for new config-section pattern — 7 commits, 250 tests, 669 assertions @since 2026-07-08 ## Known Issues - `composer test` passes; Composer still prints a dependency deprecation notice from `justinrainbow/json-schema` under PHP 8.5.4. From d050a2bcabcfed0e111cd0c458fcb786bc05c58f Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Wed, 8 Jul 2026 18:21:06 +0800 Subject: [PATCH 16/78] fix: use array lookup instead of match for PHP 7.4 compat Replace match expressions (PHP 8.0+) with associative array lookups in resolve_show_in_mcp and resolve_show_in_rest. Restores PHP 7.4 compatibility and reduces cyclomatic complexity below the PHPCS threshold of 10. --- src/MCP/McpPolicy.php | 8 ++++---- src/Rest/ModelRestPolicy.php | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/MCP/McpPolicy.php b/src/MCP/McpPolicy.php index bebe72a..a0ac9f6 100644 --- a/src/MCP/McpPolicy.php +++ b/src/MCP/McpPolicy.php @@ -52,15 +52,15 @@ public function is_enabled( Model $model, string $capability ): bool { * @param array $config */ private function resolve_show_in_mcp( array $config, string $capability ): bool { - $section = match ( $capability ) { + $map = [ ModelRestPolicy::CAPABILITY_META => $config['meta'] ?? null, ModelRestPolicy::CAPABILITY_SETTINGS => $config['settings'] ?? null, ModelRestPolicy::CAPABILITY_DUPLICATE => $config['features']['duplicate'] ?? null, ModelRestPolicy::CAPABILITY_EXPORT => $config['features']['single_export'] ?? null, ModelRestPolicy::CAPABILITY_REORDER => $config['features']['drag_and_drop'] ?? null, - default => null, - }; + ]; + $section = $map[ $capability ] ?? null; if ( $section === null ) { return false; } @@ -73,7 +73,7 @@ private function resolve_show_in_mcp( array $config, string $capability ): bool } /** - * @param Model $model + * @param string $name */ public function get_model( string $name ): ?Model { $models = $this->modeler->get_models(); diff --git a/src/Rest/ModelRestPolicy.php b/src/Rest/ModelRestPolicy.php index 9121161..ac362db 100644 --- a/src/Rest/ModelRestPolicy.php +++ b/src/Rest/ModelRestPolicy.php @@ -59,15 +59,15 @@ public function is_enabled( Model $model, string $capability ): bool { * @param array $config */ private function resolve_show_in_rest( array $config, string $capability ): bool { - $section = match ( $capability ) { + $map = [ self::CAPABILITY_META => $config['meta'] ?? null, self::CAPABILITY_SETTINGS => $config['settings'] ?? null, self::CAPABILITY_DUPLICATE => $config['features']['duplicate'] ?? null, self::CAPABILITY_EXPORT => $config['features']['single_export'] ?? null, self::CAPABILITY_REORDER => $config['features']['drag_and_drop'] ?? null, - default => null, - }; + ]; + $section = $map[ $capability ] ?? null; if ( $section === null ) { return false; } From 74961af0561bb3c9421eedf886f40a2d64a21924 Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Wed, 8 Jul 2026 18:21:11 +0800 Subject: [PATCH 17/78] fix: add PHPStan type assertions to MCP config classes Add inline @var assertions for non-falsy-string and array shape return types that PHPStan cannot infer through apply_filters(). --- src/MCP/Abilities/AbilityDefinitionFactory.php | 1 + src/MCP/MCPConfig.php | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/MCP/Abilities/AbilityDefinitionFactory.php b/src/MCP/Abilities/AbilityDefinitionFactory.php index 5e064bf..03738f5 100644 --- a/src/MCP/Abilities/AbilityDefinitionFactory.php +++ b/src/MCP/Abilities/AbilityDefinitionFactory.php @@ -123,6 +123,7 @@ private function normalize_args( $args ): array { * @return lowercase-string&non-falsy-string */ private function ability_name( string $tool_name ): string { + /** @var lowercase-string&non-falsy-string */ return strtolower( MCPConfig::get_ability_prefix() . str_replace( '_', '-', $tool_name ) ); } diff --git a/src/MCP/MCPConfig.php b/src/MCP/MCPConfig.php index 4d7130e..e5d9d02 100644 --- a/src/MCP/MCPConfig.php +++ b/src/MCP/MCPConfig.php @@ -15,9 +15,10 @@ class MCPConfig { * * Default: 'saltus-framework/v1' * - * @return string + * @return non-falsy-string */ public static function get_namespace(): string { + /** @var non-falsy-string */ return (string) \apply_filters( 'saltus/framework/mcp/namespace', 'saltus-framework/v1' @@ -37,6 +38,7 @@ public static function get_namespace(): string { * @return array{id: string, label: string, description: string} */ public static function get_ability_category(): array { + /** @var array{id: string, label: string, description: string} */ return (array) \apply_filters( 'saltus/framework/mcp/ability_category', [ From af38fed3c90ed5e475f5ef494de409a852361c40 Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Wed, 8 Jul 2026 18:21:17 +0800 Subject: [PATCH 18/78] fix: add PHPStan type assertions to REST controllers Add local @var non-falsy-string assertions in register_routes() methods so PHPStan can verify register_rest_route namespace parameter type. --- src/Rest/DuplicateController.php | 4 +++- src/Rest/ExportController.php | 4 +++- src/Rest/HealthController.php | 4 +++- src/Rest/MetaController.php | 8 +++++--- src/Rest/ModelsController.php | 6 ++++-- src/Rest/ReorderController.php | 4 +++- src/Rest/SettingsController.php | 4 +++- 7 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/Rest/DuplicateController.php b/src/Rest/DuplicateController.php index afe3278..5a794ac 100644 --- a/src/Rest/DuplicateController.php +++ b/src/Rest/DuplicateController.php @@ -30,8 +30,10 @@ public function __construct( ?ModelRestPolicy $policy = null ) { * Register the REST route for post duplication. */ public function register_routes(): void { + /** @var non-falsy-string $namespace */ + $namespace = $this->namespace; register_rest_route( - $this->namespace, + $namespace, '/' . $this->rest_base . '/(?P\d+)', [ 'methods' => WP_REST_Server::CREATABLE, diff --git a/src/Rest/ExportController.php b/src/Rest/ExportController.php index 6110385..553209e 100644 --- a/src/Rest/ExportController.php +++ b/src/Rest/ExportController.php @@ -32,8 +32,10 @@ public function __construct( ?ModelRestPolicy $policy = null, ?SaltusSingleExpor * Register the REST route for post export. */ public function register_routes(): void { + /** @var non-falsy-string $namespace */ + $namespace = $this->namespace; \register_rest_route( - $this->namespace, + $namespace, '/' . $this->rest_base . '/(?P\d+)', [ 'methods' => WP_REST_Server::READABLE, diff --git a/src/Rest/HealthController.php b/src/Rest/HealthController.php index 26c2b73..9a97368 100644 --- a/src/Rest/HealthController.php +++ b/src/Rest/HealthController.php @@ -29,8 +29,10 @@ public function __construct( string $version, ?AuditLogger $audit_logger = null * Register the health route. */ public function register_routes(): void { + /** @var non-falsy-string $namespace */ + $namespace = $this->namespace; register_rest_route( - $this->namespace, + $namespace, '/' . $this->rest_base, [ 'methods' => WP_REST_Server::READABLE, diff --git a/src/Rest/MetaController.php b/src/Rest/MetaController.php index 3de07cf..8d96a0c 100644 --- a/src/Rest/MetaController.php +++ b/src/Rest/MetaController.php @@ -41,8 +41,10 @@ public function register_routes(): void { return; } + /** @var non-falsy-string $namespace */ + $namespace = $this->namespace; register_rest_route( - $this->namespace, + $namespace, '/' . $this->rest_base, [ 'methods' => WP_REST_Server::READABLE, @@ -52,7 +54,7 @@ public function register_routes(): void { ); register_rest_route( - $this->namespace, + $namespace, '/' . $this->rest_base . '/(?P[a-z0-9_-]+)', [ 'methods' => WP_REST_Server::READABLE, @@ -69,7 +71,7 @@ public function register_routes(): void { ); register_rest_route( - $this->namespace, + $namespace, '/' . $this->rest_base . '/(?P[a-z0-9_-]+)/(?P\d+)', [ 'methods' => WP_REST_Server::EDITABLE, diff --git a/src/Rest/ModelsController.php b/src/Rest/ModelsController.php index b55dc89..607892c 100644 --- a/src/Rest/ModelsController.php +++ b/src/Rest/ModelsController.php @@ -35,8 +35,10 @@ public function __construct( Modeler $modeler, ?ModelRestPolicy $policy = null ) * Register the REST routes for listing and reading models. */ public function register_routes(): void { + /** @var non-falsy-string $namespace */ + $namespace = $this->namespace; register_rest_route( - $this->namespace, + $namespace, '/' . $this->rest_base, [ 'methods' => WP_REST_Server::READABLE, @@ -46,7 +48,7 @@ public function register_routes(): void { ); register_rest_route( - $this->namespace, + $namespace, '/' . $this->rest_base . '/(?P[a-z0-9_-]+)', [ 'methods' => WP_REST_Server::READABLE, diff --git a/src/Rest/ReorderController.php b/src/Rest/ReorderController.php index f6c773d..4e5494d 100644 --- a/src/Rest/ReorderController.php +++ b/src/Rest/ReorderController.php @@ -33,8 +33,10 @@ public function __construct( ?ModelRestPolicy $policy = null, ?ReorderPostsServi * Register the REST route for reordering posts. */ public function register_routes(): void { + /** @var non-falsy-string $namespace */ + $namespace = $this->namespace; register_rest_route( - $this->namespace, + $namespace, '/' . $this->rest_base, [ 'methods' => WP_REST_Server::CREATABLE, diff --git a/src/Rest/SettingsController.php b/src/Rest/SettingsController.php index b0b31b9..6d80323 100644 --- a/src/Rest/SettingsController.php +++ b/src/Rest/SettingsController.php @@ -33,8 +33,10 @@ public function __construct( ?ModelRestPolicy $policy = null, ?SettingsManager $ * Register the REST routes for reading and updating settings. */ public function register_routes(): void { + /** @var non-falsy-string $namespace */ + $namespace = $this->namespace; register_rest_route( - $this->namespace, + $namespace, '/' . $this->rest_base . '/(?P[a-z0-9_-]+)', [ [ From 36db61d432196cb5fa17f30681d3d5dc5289d98b Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Wed, 8 Jul 2026 23:40:00 +0800 Subject: [PATCH 19/78] Add guard for ability default props --- src/MCP/MCPConfig.php | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/MCP/MCPConfig.php b/src/MCP/MCPConfig.php index e5d9d02..77379c4 100644 --- a/src/MCP/MCPConfig.php +++ b/src/MCP/MCPConfig.php @@ -38,15 +38,19 @@ public static function get_namespace(): string { * @return array{id: string, label: string, description: string} */ public static function get_ability_category(): array { + $default = [ + 'id' => 'saltus-framework', + 'label' => 'Saltus Framework', + 'description' => 'Saltus Framework content modeling and administration abilities.', + ]; + /** @var array{id: string, label: string, description: string} */ - return (array) \apply_filters( + $filtered = (array) \apply_filters( 'saltus/framework/mcp/ability_category', - [ - 'id' => 'saltus-framework', - 'label' => 'Saltus Framework', - 'description' => 'Saltus Framework content modeling and administration abilities.', - ] + $default ); + + return array_merge( $default, $filtered ); } /** From 8b3bbc8c337265cdc9120cc358d63362a206ea07 Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Wed, 8 Jul 2026 23:40:09 +0800 Subject: [PATCH 20/78] CS --- src/MCP/McpPolicy.php | 35 +++++++++++++++++++++++++++++++++-- src/Modeler.php | 32 ++++++++++++++++++++++++++++++-- tests/MCP/MCPConfigTest.php | 12 ++++++++++++ 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/src/MCP/McpPolicy.php b/src/MCP/McpPolicy.php index a0ac9f6..1a517d3 100644 --- a/src/MCP/McpPolicy.php +++ b/src/MCP/McpPolicy.php @@ -7,13 +7,29 @@ use Saltus\WP\Framework\Rest\ModelRestPolicy; class McpPolicy { - + /** + * Modeler instance. + * It is responsible for providing models and model types. + * + * @var Modeler + */ private Modeler $modeler; + /** + * @param Modeler $modeler + */ public function __construct( Modeler $modeler ) { $this->modeler = $modeler; } + /** + * Check if a capability is enabled. + * + * @param string $capability The capability. + * @param string|null $model_type The model type. + * + * @return bool + */ public function has_capability( string $capability, ?string $model_type = null ): bool { if ( $capability === ModelRestPolicy::CAPABILITY_HEALTH ) { return true; @@ -32,6 +48,14 @@ public function has_capability( string $capability, ?string $model_type = null ) return false; } + /** + * Check if a capability is enabled for a model. + * + * @param Model $model The model. + * @param string $capability The capability. + * + * @return bool + */ public function is_enabled( Model $model, string $capability ): bool { $options = $model->get_options(); @@ -49,7 +73,11 @@ public function is_enabled( Model $model, string $capability ): bool { } /** + * Resolve whether a capability should be shown in MCP. + * * @param array $config + * + * @return bool */ private function resolve_show_in_mcp( array $config, string $capability ): bool { $map = [ @@ -73,7 +101,10 @@ private function resolve_show_in_mcp( array $config, string $capability ): bool } /** - * @param string $name + * Get a model by name. + * + * @param string $name The model name. + * @return Model|null */ public function get_model( string $name ): ?Model { $models = $this->modeler->get_models(); diff --git a/src/Modeler.php b/src/Modeler.php index 4739a4e..e0529e8 100644 --- a/src/Modeler.php +++ b/src/Modeler.php @@ -35,11 +35,23 @@ class Modeler implements RestRouteProvider, ToolContributor { /** @var array */ protected array $model_list = []; + + + /** + * Construct the modeler. + + * @param ModelFactory $model_factory + */ public function __construct( ModelFactory $model_factory ) { $this->model_factory = $model_factory; // should contain a list of loaded models } + /** + * Initialize the modeler. + * + * @param string $project_path The project path. + */ public function init( string $project_path ): void { $path = $this->get_path( $project_path ); if ( ! $path ) { @@ -50,6 +62,10 @@ public function init( string $project_path ): void { /** * Get custom path + * + * @param string $project_path The project path. + * + * @return string|null The path. */ protected function get_path( string $project_path ): ?string { @@ -69,7 +85,9 @@ protected function get_path( string $project_path ): ?string { } /** - * Load Models + * Load Models. + * + * @param string $path The path to the model */ protected function load( string $path ): void { if ( file_exists( $path ) ) { @@ -176,6 +194,8 @@ protected function create( AbstractConfig $config ): void { /** * Adds the model to a list + * + * @param Model $model The model. */ protected function add( Model $model ): void { $this->model_list[ $model->get_name() ] = $model; @@ -184,13 +204,17 @@ protected function add( Model $model ): void { /** * Return all loaded models. * - * @return array Associative array keyed by model name. + * @return array Associative array keyed by model name. */ public function get_models(): array { return $this->model_list; } /** + * Get rest routes. + * + * @param Modeler $modeler + * @param ModelRestPolicy $policy * @return list */ public function get_rest_routes( Modeler $modeler, ModelRestPolicy $policy ): array { @@ -203,6 +227,10 @@ public function get_rest_routes( Modeler $modeler, ModelRestPolicy $policy ): ar } /** + * Get MCP tools. + * + * @param Modeler $modeler + * @param ModelRestPolicy|null $policy * @return list */ public function get_mcp_tools( Modeler $modeler, ?ModelRestPolicy $policy = null ): array { diff --git a/tests/MCP/MCPConfigTest.php b/tests/MCP/MCPConfigTest.php index 41c4bb4..0058593 100644 --- a/tests/MCP/MCPConfigTest.php +++ b/tests/MCP/MCPConfigTest.php @@ -97,6 +97,18 @@ function ( array $category ): array { $this->assertSame( 'Overridden Label', $category['label'] ); } + public function testGetAbilityCategoryFilterReturnsIncompleteArrayMergesWithDefaults(): void { + global $wp_filter_values; + $wp_filter_values['saltus/framework/mcp/ability_category'] = [ + 'id' => 'custom-id', + ]; + + $category = MCPConfig::get_ability_category(); + $this->assertSame( 'custom-id', $category['id'] ); + $this->assertSame( 'Saltus Framework', $category['label'] ); + $this->assertSame( 'Saltus Framework content modeling and administration abilities.', $category['description'] ); + } + public function testGetAbilityPrefixReturnsDefault(): void { $this->assertSame( 'saltus/', MCPConfig::get_ability_prefix() ); } From 95c0f50bb718d7edddfbfc6cfa9f6cbcfc23b741 Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Thu, 9 Jul 2026 01:11:54 +0800 Subject: [PATCH 21/78] Add more guards --- src/MCP/McpPolicy.php | 8 +++++--- src/MCP/Tools/RestTool.php | 2 +- src/Rest/ModelRestPolicy.php | 8 +++++--- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/MCP/McpPolicy.php b/src/MCP/McpPolicy.php index 1a517d3..6a30792 100644 --- a/src/MCP/McpPolicy.php +++ b/src/MCP/McpPolicy.php @@ -80,12 +80,14 @@ public function is_enabled( Model $model, string $capability ): bool { * @return bool */ private function resolve_show_in_mcp( array $config, string $capability ): bool { + $features = ( isset( $config['features'] ) && is_array( $config['features'] ) ) ? $config['features'] : []; + $map = [ ModelRestPolicy::CAPABILITY_META => $config['meta'] ?? null, ModelRestPolicy::CAPABILITY_SETTINGS => $config['settings'] ?? null, - ModelRestPolicy::CAPABILITY_DUPLICATE => $config['features']['duplicate'] ?? null, - ModelRestPolicy::CAPABILITY_EXPORT => $config['features']['single_export'] ?? null, - ModelRestPolicy::CAPABILITY_REORDER => $config['features']['drag_and_drop'] ?? null, + ModelRestPolicy::CAPABILITY_DUPLICATE => $features['duplicate'] ?? null, + ModelRestPolicy::CAPABILITY_EXPORT => $features['single_export'] ?? null, + ModelRestPolicy::CAPABILITY_REORDER => $features['drag_and_drop'] ?? null, ]; $section = $map[ $capability ] ?? null; diff --git a/src/MCP/Tools/RestTool.php b/src/MCP/Tools/RestTool.php index a013922..91a6d9a 100644 --- a/src/MCP/Tools/RestTool.php +++ b/src/MCP/Tools/RestTool.php @@ -43,7 +43,7 @@ public function cache_ttl(): int { * @return string Full route (e.g. '/saltus-framework/v1/models'). */ protected function mcp_route( string $path ): string { - return '/' . MCPConfig::get_namespace() . $path; + return esc_url_raw( '/' . MCPConfig::get_namespace() . $path ); } /** diff --git a/src/Rest/ModelRestPolicy.php b/src/Rest/ModelRestPolicy.php index ac362db..b67c990 100644 --- a/src/Rest/ModelRestPolicy.php +++ b/src/Rest/ModelRestPolicy.php @@ -59,12 +59,14 @@ public function is_enabled( Model $model, string $capability ): bool { * @param array $config */ private function resolve_show_in_rest( array $config, string $capability ): bool { + $features = ( isset( $config['features'] ) && is_array( $config['features'] ) ) ? $config['features'] : []; + $map = [ self::CAPABILITY_META => $config['meta'] ?? null, self::CAPABILITY_SETTINGS => $config['settings'] ?? null, - self::CAPABILITY_DUPLICATE => $config['features']['duplicate'] ?? null, - self::CAPABILITY_EXPORT => $config['features']['single_export'] ?? null, - self::CAPABILITY_REORDER => $config['features']['drag_and_drop'] ?? null, + self::CAPABILITY_DUPLICATE => $features['duplicate'] ?? null, + self::CAPABILITY_EXPORT => $features['single_export'] ?? null, + self::CAPABILITY_REORDER => $features['drag_and_drop'] ?? null, ]; $section = $map[ $capability ] ?? null; From a20b0603c9832762ef7c6ddc6808d9c576d980e4 Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Thu, 9 Jul 2026 01:13:40 +0800 Subject: [PATCH 22/78] Add more guards --- src/MCP/MCPConfig.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/MCP/MCPConfig.php b/src/MCP/MCPConfig.php index 77379c4..e1e3070 100644 --- a/src/MCP/MCPConfig.php +++ b/src/MCP/MCPConfig.php @@ -18,11 +18,12 @@ class MCPConfig { * @return non-falsy-string */ public static function get_namespace(): string { - /** @var non-falsy-string */ - return (string) \apply_filters( + $namespace = (string) \apply_filters( 'saltus/framework/mcp/namespace', 'saltus-framework/v1' ); + /** @var non-falsy-string */ + return $namespace !== '' ? $namespace : 'saltus-framework/v1'; } /** From 8663b2884eba03389cd789a071924efc277a542a Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Thu, 9 Jul 2026 01:17:19 +0800 Subject: [PATCH 23/78] Add esc_url_raw stub function --- tests/Rest/functions.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/Rest/functions.php b/tests/Rest/functions.php index 0ecc8ab..2d5e5aa 100644 --- a/tests/Rest/functions.php +++ b/tests/Rest/functions.php @@ -839,6 +839,12 @@ function esc_url( $url ): string { } } +if ( ! function_exists( 'esc_url_raw' ) ) { + function esc_url_raw( $url ): string { + return (string) $url; + } +} + if ( ! function_exists( 'esc_html__' ) ) { function esc_html__( string $text, string $domain = 'default' ): string { return $text; From 3f2285df147da512de12ebeb021a4138be3b53de Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Thu, 9 Jul 2026 01:17:23 +0800 Subject: [PATCH 24/78] Add defensive tests for MCP policy --- tests/Integration/RestRegistrationTest.php | 20 ++++++++++++++++++ tests/MCP/MCPConfigTest.php | 7 +++++++ tests/MCP/McpPolicyTest.php | 24 ++++++++++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/tests/Integration/RestRegistrationTest.php b/tests/Integration/RestRegistrationTest.php index beeb8de..e634997 100644 --- a/tests/Integration/RestRegistrationTest.php +++ b/tests/Integration/RestRegistrationTest.php @@ -93,6 +93,26 @@ public function testHealthRouteIsAlwaysIncluded(): void { $this->assertTrue( $policy->has_capability( ModelRestPolicy::CAPABILITY_HEALTH ) ); } + public function testIsEnabledHandlesMissingFeaturesConfigDefensively(): void { + $modeler = $this->createMock( Modeler::class ); + $policy = new ModelRestPolicy( $modeler ); + $model = $this->createMock( Model::class ); + $model->method( 'get_options' )->willReturn( [ 'show_in_rest' => true ] ); + $model->method( 'get_config' )->willReturn( [] ); + + $this->assertFalse( $policy->is_enabled( $model, ModelRestPolicy::CAPABILITY_EXPORT ) ); + } + + public function testIsEnabledHandlesNonArrayFeaturesConfigDefensively(): void { + $modeler = $this->createMock( Modeler::class ); + $policy = new ModelRestPolicy( $modeler ); + $model = $this->createMock( Model::class ); + $model->method( 'get_options' )->willReturn( [ 'show_in_rest' => true ] ); + $model->method( 'get_config' )->willReturn( [ 'features' => null ] ); + + $this->assertFalse( $policy->is_enabled( $model, ModelRestPolicy::CAPABILITY_EXPORT ) ); + } + public function testHealthControllerImplementsRegisterRoutes(): void { $controller = new HealthController( '1.0.0' ); $this->assertTrue( method_exists( $controller, 'register_routes' ) ); diff --git a/tests/MCP/MCPConfigTest.php b/tests/MCP/MCPConfigTest.php index 0058593..723392d 100644 --- a/tests/MCP/MCPConfigTest.php +++ b/tests/MCP/MCPConfigTest.php @@ -60,6 +60,13 @@ function ( string $value ): string { $this->assertSame( 'SALTUS-FRAMEWORK/V1', MCPConfig::get_namespace() ); } + public function testGetNamespaceFallsBackToDefaultWhenFilteredValueIsEmpty(): void { + global $wp_filter_values; + $wp_filter_values['saltus/framework/mcp/namespace'] = ''; + + $this->assertSame( 'saltus-framework/v1', MCPConfig::get_namespace() ); + } + public function testGetAbilityCategoryReturnsDefault(): void { $category = MCPConfig::get_ability_category(); diff --git a/tests/MCP/McpPolicyTest.php b/tests/MCP/McpPolicyTest.php index a1a4349..4e55ac4 100644 --- a/tests/MCP/McpPolicyTest.php +++ b/tests/MCP/McpPolicyTest.php @@ -156,6 +156,30 @@ public function testIsEnabledChecksFeatureLevelShowInMcp(): void { $this->assertFalse( $policy->is_enabled( $model, ModelRestPolicy::CAPABILITY_EXPORT ) ); } + public function testIsEnabledHandlesMissingFeaturesConfigDefensively(): void { + $modeler = $this->createStub( Modeler::class ); + $policy = new McpPolicy( $modeler ); + + $model = $this->createModelMock( + [ 'mcp_tools' => true ], + [] // config has no features key + ); + + $this->assertFalse( $policy->is_enabled( $model, ModelRestPolicy::CAPABILITY_EXPORT ) ); + } + + public function testIsEnabledHandlesNonArrayFeaturesConfigDefensively(): void { + $modeler = $this->createStub( Modeler::class ); + $policy = new McpPolicy( $modeler ); + + $model = $this->createModelMock( + [ 'mcp_tools' => true ], + [ 'features' => null ] // config features key is null + ); + + $this->assertFalse( $policy->is_enabled( $model, ModelRestPolicy::CAPABILITY_EXPORT ) ); + } + public function testGetModelReturnsNullForUnknownModel(): void { $modeler = $this->createStub( Modeler::class ); $modeler->method( 'get_models' )->willReturn( [] ); From d9383a6075f14eb68bcb05e5d41638ec13f34484 Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Thu, 9 Jul 2026 01:17:32 +0800 Subject: [PATCH 25/78] Add initial MCP tool tests --- tests/MCP/Tools/DeletePostTest.php | 81 ++++++++++++++++++++++++++++++ tests/MCP/Tools/GetHealthTest.php | 53 +++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 tests/MCP/Tools/DeletePostTest.php create mode 100644 tests/MCP/Tools/GetHealthTest.php diff --git a/tests/MCP/Tools/DeletePostTest.php b/tests/MCP/Tools/DeletePostTest.php new file mode 100644 index 0000000..14cff09 --- /dev/null +++ b/tests/MCP/Tools/DeletePostTest.php @@ -0,0 +1,81 @@ +tool = new DeletePost(); + } + + public function testBasicGetters(): void { + $this->assertSame( 'delete_post', $this->tool->get_name() ); + $this->assertStringContainsString( 'Delete (trash or force delete) a post by ID', $this->tool->get_description() ); + + $params = $this->tool->get_parameters(); + $this->assertArrayHasKey( 'post_id', $params ); + $this->assertArrayHasKey( 'post_type', $params ); + $this->assertArrayHasKey( 'force', $params ); + } + + public function testBuildRestRequest(): void { + // Default posts post_type + $request = $this->tool->build_rest_request( [ + 'post_id' => 123, + ] ); + + $this->assertInstanceOf( WP_REST_Request::class, $request ); + $this->assertSame( 'DELETE', $request->get_method() ); + $this->assertSame( '/wp/v2/posts/123', $request->get_route() ); + $this->assertSame( [ 'force' => false ], $request->get_params() ); + + // Custom post_type and force flag + global $wp_post_type_objects; + $wp_post_type_objects['book'] = (object) [ + 'rest_base' => 'books', + ]; + + $request = $this->tool->build_rest_request( [ + 'post_id' => 456, + 'post_type' => 'book', + 'force' => true, + ] ); + + $this->assertSame( '/wp/v2/books/456', $request->get_route() ); + $this->assertSame( [ 'force' => true ], $request->get_params() ); + } + + public function testHasPermission(): void { + global $wp_current_user_can; + + // When user can delete post + $wp_current_user_can = [ + 'delete_post:123' => true, + ]; + $this->assertTrue( $this->tool->has_permission( [ 'post_id' => 123 ] ) ); + + // When user cannot delete post + $wp_current_user_can = [ + 'delete_post:123' => false, + ]; + $this->assertFalse( $this->tool->has_permission( [ 'post_id' => 123 ] ) ); + + // Empty post_id + $this->assertFalse( $this->tool->has_permission( [] ) ); + } +} diff --git a/tests/MCP/Tools/GetHealthTest.php b/tests/MCP/Tools/GetHealthTest.php new file mode 100644 index 0000000..8676f15 --- /dev/null +++ b/tests/MCP/Tools/GetHealthTest.php @@ -0,0 +1,53 @@ +tool = new GetHealth(); + } + + public function testBasicGetters(): void { + $this->assertSame( 'get_health', $this->tool->get_name() ); + $this->assertStringContainsString( 'Get Saltus Framework health', $this->tool->get_description() ); + $this->assertSame( [], $this->tool->get_parameters() ); + $this->assertTrue( $this->tool->is_cacheable() ); + $this->assertSame( 60, $this->tool->cache_ttl() ); + + $capability = $this->tool->get_rest_capability(); + $this->assertNotNull( $capability ); + $this->assertSame( ModelRestPolicy::CAPABILITY_HEALTH, $capability->get_capability() ); + } + + public function testBuildRestRequest(): void { + $request = $this->tool->build_rest_request( [] ); + + $this->assertInstanceOf( WP_REST_Request::class, $request ); + $this->assertSame( 'GET', $request->get_method() ); + $this->assertSame( '/saltus-framework/v1/health', $request->get_route() ); + } + + public function testHasPermission(): void { + global $wp_current_user_can; + + $wp_current_user_can = true; + $this->assertTrue( $this->tool->has_permission( [] ) ); + + $wp_current_user_can = false; + $this->assertFalse( $this->tool->has_permission( [] ) ); + } +} From 2c2b9d84db1a0559c5033908a051065795507d88 Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Thu, 9 Jul 2026 01:17:34 +0800 Subject: [PATCH 26/78] Configure PHPUnit coverage settings --- phpunit.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/phpunit.xml b/phpunit.xml index 0370ead..4e715f9 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -9,6 +9,11 @@ beStrictAboutTestsThatDoNotTestAnything="true" failOnRisky="true" failOnWarning="true"> + + + src + + tests/Unit/ From 619c748976fa9d50cfd87746a86632a3f7a12659 Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Thu, 9 Jul 2026 02:54:41 +0800 Subject: [PATCH 27/78] Gate sections cap --- src/MCP/McpPolicy.php | 51 +++++++++++++++++++++++++++--------- src/Rest/ModelRestPolicy.php | 51 +++++++++++++++++++++++++++--------- 2 files changed, 76 insertions(+), 26 deletions(-) diff --git a/src/MCP/McpPolicy.php b/src/MCP/McpPolicy.php index 6a30792..e1fe96f 100644 --- a/src/MCP/McpPolicy.php +++ b/src/MCP/McpPolicy.php @@ -72,6 +72,37 @@ public function is_enabled( Model $model, string $capability ): bool { return $this->resolve_show_in_mcp( $config, $capability ); } + /** + * Get the configuration section for a specific capability. + * + * @param array $config The model configuration. + * @param string $capability The capability. + * @return mixed + */ + private function get_capability_config( array $config, string $capability ) { + if ( $capability === ModelRestPolicy::CAPABILITY_META || $capability === ModelRestPolicy::CAPABILITY_SETTINGS ) { + return $config[ $capability ] ?? null; + } + + $features = $config['features'] ?? []; + if ( ! is_array( $features ) ) { + return null; + } + + $feature_keys = [ + ModelRestPolicy::CAPABILITY_DUPLICATE => 'duplicate', + ModelRestPolicy::CAPABILITY_EXPORT => 'single_export', + ModelRestPolicy::CAPABILITY_REORDER => 'drag_and_drop', + ]; + + $key = $feature_keys[ $capability ] ?? null; + if ( $key === null ) { + return null; + } + + return $features[ $key ] ?? null; + } + /** * Resolve whether a capability should be shown in MCP. * @@ -80,22 +111,16 @@ public function is_enabled( Model $model, string $capability ): bool { * @return bool */ private function resolve_show_in_mcp( array $config, string $capability ): bool { - $features = ( isset( $config['features'] ) && is_array( $config['features'] ) ) ? $config['features'] : []; - - $map = [ - ModelRestPolicy::CAPABILITY_META => $config['meta'] ?? null, - ModelRestPolicy::CAPABILITY_SETTINGS => $config['settings'] ?? null, - ModelRestPolicy::CAPABILITY_DUPLICATE => $features['duplicate'] ?? null, - ModelRestPolicy::CAPABILITY_EXPORT => $features['single_export'] ?? null, - ModelRestPolicy::CAPABILITY_REORDER => $features['drag_and_drop'] ?? null, - ]; - - $section = $map[ $capability ] ?? null; + $section = $this->get_capability_config( $config, $capability ); if ( $section === null ) { - return false; + return true; + } + + if ( ! is_array( $section ) ) { + return (bool) $section; } - if ( ! is_array( $section ) || ! array_key_exists( 'show_in_mcp', $section ) ) { + if ( ! array_key_exists( 'show_in_mcp', $section ) ) { return true; } diff --git a/src/Rest/ModelRestPolicy.php b/src/Rest/ModelRestPolicy.php index b67c990..816ae73 100644 --- a/src/Rest/ModelRestPolicy.php +++ b/src/Rest/ModelRestPolicy.php @@ -56,25 +56,50 @@ public function is_enabled( Model $model, string $capability ): bool { } /** - * @param array $config + * Get the configuration section for a specific capability. + * + * @param array $config The model configuration. + * @param string $capability The capability. + * @return mixed */ - private function resolve_show_in_rest( array $config, string $capability ): bool { - $features = ( isset( $config['features'] ) && is_array( $config['features'] ) ) ? $config['features'] : []; - - $map = [ - self::CAPABILITY_META => $config['meta'] ?? null, - self::CAPABILITY_SETTINGS => $config['settings'] ?? null, - self::CAPABILITY_DUPLICATE => $features['duplicate'] ?? null, - self::CAPABILITY_EXPORT => $features['single_export'] ?? null, - self::CAPABILITY_REORDER => $features['drag_and_drop'] ?? null, + private function get_capability_config( array $config, string $capability ) { + if ( $capability === self::CAPABILITY_META || $capability === self::CAPABILITY_SETTINGS ) { + return $config[ $capability ] ?? null; + } + + $features = $config['features'] ?? []; + if ( ! is_array( $features ) ) { + return null; + } + + $feature_keys = [ + self::CAPABILITY_DUPLICATE => 'duplicate', + self::CAPABILITY_EXPORT => 'single_export', + self::CAPABILITY_REORDER => 'drag_and_drop', ]; - $section = $map[ $capability ] ?? null; + $key = $feature_keys[ $capability ] ?? null; + if ( $key === null ) { + return null; + } + + return $features[ $key ] ?? null; + } + + /** + * @param array $config + */ + private function resolve_show_in_rest( array $config, string $capability ): bool { + $section = $this->get_capability_config( $config, $capability ); if ( $section === null ) { - return false; + return true; + } + + if ( ! is_array( $section ) ) { + return (bool) $section; } - if ( ! is_array( $section ) || ! array_key_exists( 'show_in_rest', $section ) ) { + if ( ! array_key_exists( 'show_in_rest', $section ) ) { return true; } From a6000029facb1b11b616650e56a252330ab93b71 Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Thu, 9 Jul 2026 02:55:05 +0800 Subject: [PATCH 28/78] Update tests to match the caps --- tests/Integration/RestRegistrationTest.php | 14 ++++++++++++-- tests/MCP/Abilities/AbilityRegistrarTest.php | 6 +++++- tests/MCP/McpPolicyTest.php | 16 ++++++++++++++-- tests/Rest/RestServerTest.php | 4 ++-- 4 files changed, 33 insertions(+), 7 deletions(-) diff --git a/tests/Integration/RestRegistrationTest.php b/tests/Integration/RestRegistrationTest.php index e634997..c2510f9 100644 --- a/tests/Integration/RestRegistrationTest.php +++ b/tests/Integration/RestRegistrationTest.php @@ -100,7 +100,7 @@ public function testIsEnabledHandlesMissingFeaturesConfigDefensively(): void { $model->method( 'get_options' )->willReturn( [ 'show_in_rest' => true ] ); $model->method( 'get_config' )->willReturn( [] ); - $this->assertFalse( $policy->is_enabled( $model, ModelRestPolicy::CAPABILITY_EXPORT ) ); + $this->assertTrue( $policy->is_enabled( $model, ModelRestPolicy::CAPABILITY_EXPORT ) ); } public function testIsEnabledHandlesNonArrayFeaturesConfigDefensively(): void { @@ -110,7 +110,17 @@ public function testIsEnabledHandlesNonArrayFeaturesConfigDefensively(): void { $model->method( 'get_options' )->willReturn( [ 'show_in_rest' => true ] ); $model->method( 'get_config' )->willReturn( [ 'features' => null ] ); - $this->assertFalse( $policy->is_enabled( $model, ModelRestPolicy::CAPABILITY_EXPORT ) ); + $this->assertTrue( $policy->is_enabled( $model, ModelRestPolicy::CAPABILITY_EXPORT ) ); + } + + public function testIsEnabledHandlesExplicitDisable(): void { + $modeler = $this->createMock( Modeler::class ); + $policy = new ModelRestPolicy( $modeler ); + $model = $this->createMock( Model::class ); + $model->method( 'get_options' )->willReturn( [ 'show_in_rest' => true ] ); + $model->method( 'get_config' )->willReturn( [ 'meta' => false ] ); + + $this->assertFalse( $policy->is_enabled( $model, ModelRestPolicy::CAPABILITY_META ) ); } public function testHealthControllerImplementsRegisterRoutes(): void { diff --git a/tests/MCP/Abilities/AbilityRegistrarTest.php b/tests/MCP/Abilities/AbilityRegistrarTest.php index 33381b8..c21a2c2 100644 --- a/tests/MCP/Abilities/AbilityRegistrarTest.php +++ b/tests/MCP/Abilities/AbilityRegistrarTest.php @@ -78,7 +78,11 @@ public function testRegisterFiltersRestBackedAbilitiesWhenPolicyIsInjected(): vo 'mcp_tools' => true, ], [ - 'meta' => [], + 'meta' => [], + 'settings' => false, + 'features' => [ + 'duplicate' => false, + ], ] ), ] diff --git a/tests/MCP/McpPolicyTest.php b/tests/MCP/McpPolicyTest.php index 4e55ac4..9b153a4 100644 --- a/tests/MCP/McpPolicyTest.php +++ b/tests/MCP/McpPolicyTest.php @@ -165,7 +165,7 @@ public function testIsEnabledHandlesMissingFeaturesConfigDefensively(): void { [] // config has no features key ); - $this->assertFalse( $policy->is_enabled( $model, ModelRestPolicy::CAPABILITY_EXPORT ) ); + $this->assertTrue( $policy->is_enabled( $model, ModelRestPolicy::CAPABILITY_EXPORT ) ); } public function testIsEnabledHandlesNonArrayFeaturesConfigDefensively(): void { @@ -177,7 +177,19 @@ public function testIsEnabledHandlesNonArrayFeaturesConfigDefensively(): void { [ 'features' => null ] // config features key is null ); - $this->assertFalse( $policy->is_enabled( $model, ModelRestPolicy::CAPABILITY_EXPORT ) ); + $this->assertTrue( $policy->is_enabled( $model, ModelRestPolicy::CAPABILITY_EXPORT ) ); + } + + public function testIsEnabledHandlesExplicitDisable(): void { + $modeler = $this->createStub( Modeler::class ); + $policy = new McpPolicy( $modeler ); + + $model = $this->createModelMock( + [ 'mcp_tools' => true ], + [ 'meta' => false ] + ); + + $this->assertFalse( $policy->is_enabled( $model, ModelRestPolicy::CAPABILITY_META ) ); } public function testGetModelReturnsNullForUnknownModel(): void { diff --git a/tests/Rest/RestServerTest.php b/tests/Rest/RestServerTest.php index d83b327..a5f343e 100644 --- a/tests/Rest/RestServerTest.php +++ b/tests/Rest/RestServerTest.php @@ -109,7 +109,7 @@ public function testRegisterRoutesRegistersMoreThanOneRoute(): void { $this->assertGreaterThan( 1, count( $wp_rest_routes_registered ) ); } - public function testRegisterRoutesRegistersOnlyHealthWithoutOptIn(): void { + public function testRegisterRoutesRegistersAllRoutesByDefault(): void { global $wp_rest_routes_registered; $this->modeler->method( 'get_models' )->willReturn( @@ -120,7 +120,7 @@ public function testRegisterRoutesRegistersOnlyHealthWithoutOptIn(): void { $this->createServer()->register_routes(); - $this->assertCount( 3, $wp_rest_routes_registered ); + $this->assertCount( 10, $wp_rest_routes_registered ); $this->assertSame( '/health', $wp_rest_routes_registered[0]['route'] ); } From 5cd7e985bc4de59272dfe6d69fa799b94cb1bbef Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Thu, 9 Jul 2026 03:11:05 +0800 Subject: [PATCH 29/78] More guards --- src/MCP/MCPConfig.php | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/src/MCP/MCPConfig.php b/src/MCP/MCPConfig.php index e1e3070..34ca0df 100644 --- a/src/MCP/MCPConfig.php +++ b/src/MCP/MCPConfig.php @@ -18,12 +18,15 @@ class MCPConfig { * @return non-falsy-string */ public static function get_namespace(): string { - $namespace = (string) \apply_filters( + $namespace = \apply_filters( 'saltus/framework/mcp/namespace', 'saltus-framework/v1' ); + if ( ! is_string( $namespace ) || trim( $namespace ) === '' ) { + return 'saltus-framework/v1'; + } /** @var non-falsy-string */ - return $namespace !== '' ? $namespace : 'saltus-framework/v1'; + return trim( $namespace ); } /** @@ -45,13 +48,23 @@ public static function get_ability_category(): array { 'description' => 'Saltus Framework content modeling and administration abilities.', ]; - /** @var array{id: string, label: string, description: string} */ - $filtered = (array) \apply_filters( + $filtered = \apply_filters( 'saltus/framework/mcp/ability_category', $default ); - return array_merge( $default, $filtered ); + if ( ! is_array( $filtered ) ) { + return $default; + } + + $sanitized = []; + foreach ( [ 'id', 'label', 'description' ] as $key ) { + $val = $filtered[ $key ] ?? null; + $sanitized[ $key ] = is_string( $val ) ? $val : $default[ $key ]; + } + + /** @var array{id: string, label: string, description: string} */ + return $sanitized; } /** @@ -62,9 +75,13 @@ public static function get_ability_category(): array { * @return string */ public static function get_ability_prefix(): string { - return (string) \apply_filters( + $prefix = \apply_filters( 'saltus/framework/mcp/ability_prefix', 'saltus/' ); + if ( ! is_string( $prefix ) ) { + return 'saltus/'; + } + return $prefix; } } From 6ed33783121db9a0301911a2c225451c74d0889e Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Thu, 9 Jul 2026 23:21:50 +0800 Subject: [PATCH 30/78] CS --- src/MCP/MCPConfig.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/MCP/MCPConfig.php b/src/MCP/MCPConfig.php index 34ca0df..8a2f6a1 100644 --- a/src/MCP/MCPConfig.php +++ b/src/MCP/MCPConfig.php @@ -59,7 +59,7 @@ public static function get_ability_category(): array { $sanitized = []; foreach ( [ 'id', 'label', 'description' ] as $key ) { - $val = $filtered[ $key ] ?? null; + $val = $filtered[ $key ] ?? null; $sanitized[ $key ] = is_string( $val ) ? $val : $default[ $key ]; } From 7b2baba12f6a870964bc9775d4d5138baeae83fd Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Thu, 9 Jul 2026 23:25:24 +0800 Subject: [PATCH 31/78] Simplify escaping rest route --- src/MCP/Tools/RestTool.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/MCP/Tools/RestTool.php b/src/MCP/Tools/RestTool.php index 91a6d9a..d0af73e 100644 --- a/src/MCP/Tools/RestTool.php +++ b/src/MCP/Tools/RestTool.php @@ -43,7 +43,7 @@ public function cache_ttl(): int { * @return string Full route (e.g. '/saltus-framework/v1/models'). */ protected function mcp_route( string $path ): string { - return esc_url_raw( '/' . MCPConfig::get_namespace() . $path ); + return '/' . trim( MCPConfig::get_namespace(), '/' ) . '/' . ltrim( $path, '/' ); } /** From 986f5a6c2cc0729f36f04f3e9cbb6a7e786ee66e Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Fri, 10 Jul 2026 00:17:12 +0800 Subject: [PATCH 32/78] Fix hints --- src/Rest/DuplicateController.php | 2 +- src/Rest/ExportController.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Rest/DuplicateController.php b/src/Rest/DuplicateController.php index 5a794ac..9352c82 100644 --- a/src/Rest/DuplicateController.php +++ b/src/Rest/DuplicateController.php @@ -100,7 +100,7 @@ public function create_item( $request ) { 'status' => 403, 'hint' => sprintf( /* translators: %s: post type slug */ - __( "Add 'show_in_rest' => true under the 'duplicate' section in the model config for '%s' in src/models/.", 'saltus-framework' ), + __( "Add 'show_in_rest' => true under 'features' => [ 'duplicate' => ... ] in the model config for '%s' in src/models/.", 'saltus-framework' ), $post->post_type ), ] diff --git a/src/Rest/ExportController.php b/src/Rest/ExportController.php index 553209e..2d32d02 100644 --- a/src/Rest/ExportController.php +++ b/src/Rest/ExportController.php @@ -98,7 +98,7 @@ public function get_item( $request ) { 'status' => 403, 'hint' => sprintf( /* translators: %s: post type slug */ - __( "Add 'show_in_rest' => true under the 'single_export' section in the model config for '%s' in src/models/.", 'saltus-framework' ), + __( "Add 'show_in_rest' => true under 'features' => [ 'single_export' => ... ] in the model config for '%s' in src/models/.", 'saltus-framework' ), $post->post_type ), ] From 0cd461cb724b5a7eaca8f8421ae26770dfc61feb Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Fri, 10 Jul 2026 00:17:47 +0800 Subject: [PATCH 33/78] Update docs --- bin/generate-mcp-docs.php | 6 ++++++ docs/MCP-ABILITIES.md | 21 ++++++++++++++++++++- docs/MCP.md | 1 + 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/bin/generate-mcp-docs.php b/bin/generate-mcp-docs.php index d83591c..3b20627 100644 --- a/bin/generate-mcp-docs.php +++ b/bin/generate-mcp-docs.php @@ -16,6 +16,12 @@ $root = dirname( __DIR__ ); require_once $root . '/vendor/autoload.php'; +if ( ! function_exists( 'apply_filters' ) ) { + function apply_filters( string $hook_name, mixed $value, mixed ...$args ): mixed { + return $value; + } +} + if ( ! class_exists( 'WP_REST_Request' ) ) { class WP_REST_Request { private string $method; diff --git a/docs/MCP-ABILITIES.md b/docs/MCP-ABILITIES.md index d70030d..41dab32 100644 --- a/docs/MCP-ABILITIES.md +++ b/docs/MCP-ABILITIES.md @@ -2,7 +2,7 @@ -Saltus Framework exposes 17 WordPress-native MCP/Abilities tools. +Saltus Framework exposes 18 WordPress-native MCP/Abilities tools. | Tool | Ability | REST request | Description | |------|---------|--------------|-------------| @@ -21,6 +21,7 @@ Saltus Framework exposes 17 WordPress-native MCP/Abilities tools. | `list_posts` | `saltus/list-posts` | `GET /wp/v2/posts` | Query posts from a Custom Post Type with optional filters | | `list_terms` | `saltus/list-terms` | `GET /wp/v2/{taxonomy_rest_base}` | List terms from a taxonomy (categories, tags, or custom taxonomies) | | `reorder_posts` | `saltus/reorder-posts` | `POST /saltus-framework/v1/reorder` | Reorder multiple posts by updating their menu_order values in a single batch operation | +| `update_meta_fields` | `saltus/update-meta-fields` | `PUT /saltus-framework/v1/meta/{post_type}/123` | Update meta fields for a specific post of a registered Saltus post type | | `update_post` | `saltus/update-post` | `PUT /wp/v2/posts/123` | Update an existing post's fields and meta data | | `update_settings` | `saltus/update-settings` | `PUT /saltus-framework/v1/settings/{post_type}` | Update the Saltus Framework settings for a specific post type | @@ -284,6 +285,24 @@ Reorder multiple posts by updating their menu_order values in a single batch ope |-----------|------|----------|---------|-------------| | `items` | `array` | yes | | Array of objects with "id" (post ID) and "menu_order" (integer position) | +## `update_meta_fields` + +Update meta fields for a specific post of a registered Saltus post type + +- Ability: `saltus/update-meta-fields` +- REST request: `PUT /saltus-framework/v1/meta/{post_type}/123` +- REST capability: `meta (post_type)` +- Cacheable: `no` +- Cache TTL: `n/a` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `post_id` | `number` | yes | | The post ID to update meta fields for | +| `post_type` | `string` | yes | | The post type slug | +| `meta` | `object` | yes | | Meta fields to update as key-value pairs | + ## `update_post` Update an existing post's fields and meta data diff --git a/docs/MCP.md b/docs/MCP.md index e88b5b7..d573447 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -190,6 +190,7 @@ Config section keys: | `list_posts` | `saltus/list-posts` | `GET /wp/v2/posts` | Query posts from a Custom Post Type with optional filters | | `list_terms` | `saltus/list-terms` | `GET /wp/v2/{taxonomy_rest_base}` | List terms from a taxonomy (categories, tags, or custom taxonomies) | | `reorder_posts` | `saltus/reorder-posts` | `POST /saltus-framework/v1/reorder` | Reorder multiple posts by updating their menu_order values in a single batch operation | +| `update_meta_fields` | `saltus/update-meta-fields` | `PUT /saltus-framework/v1/meta/{post_type}/123` | Update meta fields for a specific post of a registered Saltus post type | | `update_post` | `saltus/update-post` | `PUT /wp/v2/posts/123` | Update an existing post's fields and meta data | | `update_settings` | `saltus/update-settings` | `PUT /saltus-framework/v1/settings/{post_type}` | Update the Saltus Framework settings for a specific post type | From a6f27c117a4078382da94ebb503363020fcac30f Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Fri, 10 Jul 2026 00:32:50 +0800 Subject: [PATCH 34/78] Fix docs --- bin/generate-mcp-docs.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/generate-mcp-docs.php b/bin/generate-mcp-docs.php index 3b20627..a193d48 100644 --- a/bin/generate-mcp-docs.php +++ b/bin/generate-mcp-docs.php @@ -17,7 +17,7 @@ require_once $root . '/vendor/autoload.php'; if ( ! function_exists( 'apply_filters' ) ) { - function apply_filters( string $hook_name, mixed $value, mixed ...$args ): mixed { + function apply_filters( string $hook_name, $value, ...$args ) { return $value; } } From 69f1adf04cd5394e3e6e3da09cdb14ecb84e022f Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Fri, 10 Jul 2026 00:45:02 +0800 Subject: [PATCH 35/78] Update docs --- docs/MCP.md | 55 +++++++++++++-------- docs/ROADMAP.md | 127 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 161 insertions(+), 21 deletions(-) diff --git a/docs/MCP.md b/docs/MCP.md index d573447..f589316 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -109,11 +109,35 @@ Saltus reuses WordPress capability checks such as: | Settings updates | `manage_options` | | Term creation | taxonomy edit/manage capability | -REST routes are also gated by model configuration. The master `show_in_rest` option acts as both the WordPress core registration gate and the Saltus REST gate. Each Saltus feature (meta, settings, duplicate, export, reorder) can be independently enabled or disabled using a `show_in_rest` flag in its own config section within the model's `config` key. The `models` and `health` capabilities are always enabled when `show_in_rest` is not false for that model (or always, for health). +REST routes and MCP tools are gated by model configuration. -Similarly, MCP tools are gated by the `mcp_tools` master option at the model level, and each feature can be independently gated with `show_in_mcp` in its config section. When `mcp_tools` is absent or false, no MCP tools are generated for that model. When `mcp_tools` is true, all features are enabled for MCP unless a feature's `show_in_mcp` is explicitly `false`. +### Master Options (Model Level) -Enable all Saltus REST-backed capabilities for a model: +At the model level, two master options in the `options` array control access: +- **`show_in_rest`**: Controls whether model-scoped REST routes (and consequently MCP tools) are registered. If explicitly set to `false`, all model-scoped REST and MCP capabilities for the model are disabled. Defaults to `true` (if omitted or not `false`). +- **`mcp_tools`**: Must be set and truthy (e.g., `true`) in model options to enable any MCP tools for that model. + +The framework-scoped health capability (`health` ability / REST route) is independent of per-model opt-in and is always available. The `models` capability is always enabled for a model as long as its `show_in_rest` is not `false` (or always, for MCP, if `mcp_tools` is enabled). + +### Feature-Level Gating + +Each individual framework capability can be gated in the model's `config` array. They map to specific configuration sections: +- **Meta (`meta`):** `'meta'` key (root level of `config`) +- **Settings (`settings`):** `'settings'` key (root level of `config`) +- **Duplicate (`duplicate`):** `'duplicate'` key (nested under `config.features.duplicate`) +- **Export (`export`):** `'single_export'` key (nested under `config.features.single_export`) +- **Reorder (`reorder`):** `'drag_and_drop'` key (nested under `config.features.drag_and_drop`) + +### Resolution Rules + +For each feature/capability configuration section: +1. **Omitted (Null):** If a capability config section is omitted from the model configuration, the feature defaults to **enabled** for both REST and MCP. +2. **Boolean Value:** If defined as a simple boolean (e.g., `'meta' => false` or `'features' => ['duplicate' => false]`), it acts as a joint gate. A value of `false` disables both REST and MCP for that capability; a value of `true` enables both. +3. **Array Value:** If defined as an array, REST and MCP gating can be configured independently: + - **REST Route Gating:** Governed by the `show_in_rest` key in the section array. If the key is omitted, REST is **enabled** (`true`). If present, it resolves to its boolean value. + - **MCP Tool Gating:** Governed by the `show_in_mcp` key in the section array. If the key is omitted, MCP is **enabled** (`true`). If present, it resolves to its boolean value. + +Enable all Saltus REST-backed and MCP capabilities for a model: ```php return [ @@ -126,7 +150,7 @@ return [ ]; ``` -Enable REST for all features but block MCP for specific features: +Example showing various feature-level configurations: ```php return [ @@ -137,23 +161,21 @@ return [ 'mcp_tools' => true, ], 'config' => [ + // 1. Array style: enabled for REST but disabled for MCP 'meta' => [ 'show_in_rest' => true, 'show_in_mcp' => false, ], - 'settings' => [ - 'show_in_rest' => true, - ], + // 2. Boolean style: disabled for both REST and MCP + 'settings' => false, + 'features' => [ + // 3. Array style: enabled for REST, and defaults to enabled for MCP 'duplicate' => [ 'show_in_rest' => true, ], - 'single_export' => [ - 'show_in_rest' => true, - ], - 'drag_and_drop' => [ - 'show_in_rest' => true, - ], + // 4. Omitted config for single_export and drag_and_drop: + // both default to enabled for REST and MCP ], ], ]; @@ -161,13 +183,6 @@ return [ If `show_in_rest` is explicitly `false`, Saltus does not expose model-scoped REST or MCP routes for that model. The health ability is framework-scoped and remains independent of per-model opt-in. -Config section keys: -- `meta` — top-level key in `config` -- `settings` — top-level key in `config` -- `duplicate` — nested under `config.features.duplicate` -- `single_export` — nested under `config.features.single_export` -- `drag_and_drop` — nested under `config.features.drag_and_drop` - ## Available Abilities diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e1d2e41..b4d3d0e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -9,7 +9,7 @@ - PHPStan Level 7 clean across the configured analysis set as of 2026-07-02, including the asset loading helper path - MCP v1 refactoring complete: per-tool REST dispatch, RestBackedToolInterface, ToolContributor, @phpstan-type AbilityDefinition - MCP namespace/category/prefix now filterable via MCPConfig utility class (saltus/framework/mcp/namespace, saltus/framework/mcp/ability_category, saltus/framework/mcp/ability_prefix) -- MCP/REST capability gating refactored: McpPolicy class with mcp_tools/show_in_mcp gating; ModelRestPolicy switched from saltus_rest array to per-feature config-section model (config.meta.show_in_rest) +- MCP/REST capability gating refactored: McpPolicy class with mcp_tools/show_in_mcp gating; ModelRestPolicy switched from saltus_rest array to per-feature config-section model (using show_in_rest and show_in_mcp gates) - Legacy refactoring: inline REST controller logic extracted into shared service classes (SaltusSingleExport, MetaFieldProvider, ReorderPostsService, SettingsManager) wired into both REST controllers and MCP tools — resolved 2026-07-03 - Conditional registration fix: `is_needed()` gate bypass for RestRouteProvider/ToolContributor registries via two-pass approach in `Core`, ensuring REST routes always appear in WP-REST index even before `REST_REQUEST` is defined — resolved 2026-07-06 - 250 PHPUnit tests passing (669 assertions), PHPStan Level 7 clean across the configured analysis set @@ -397,3 +397,128 @@ frontend: ## Tracking - Check GitHub Issues for active sprint items. - Active development on `feature/mcp-v1` branch. + + + +//// new +• Current state: Saltus already has the “external agent connects to WordPress + and modifies content through governed abilities” piece partly implemented. + What it does not appear to have yet is a first-class “context control center” + model or a mandatory editorial approval queue for agent writes. + +──────────────────────────────────────────────────────────────────────────────── + +• For Saltus, that translates less like “Drupal AI” and more like a WordPress- + native AI governance layer for CPT-driven plugins. + + Saltus already has the foundation: model-defined CPTs, REST routes, MCP/ + Abilities tools, capability checks, audit logging, rate limits, health checks, + and per-model mcp_tools / show_in_mcp gates. The missing higher-level product + layer would be this: + + Context Control Center + + A Saltus model/config area where a plugin defines: + + - brand voice + - field-level writing rules + - audience profiles + - taxonomy/content standards + - allowed statuses + - forbidden actions + - required review rules + - per-CPT AI instructions + + In Saltus terms, this could live as model config, for example: + + 'options' => [ + 'show_in_rest' => true, + 'mcp_tools' => true, + ], + 'config' => [ + 'ai_context' => [ + 'brand_voice' => 'Clear, practical, expert, no hype.', + 'audiences' => ['developers', 'site editors'], + 'rules' => [ + 'never_publish_directly' => true, + 'require_human_review' => true, + 'preserve_technical_accuracy' => true, + ], + ], + ] + + Then MCP tools like create_post, update_post, update_settings, and + update_meta_fields would receive this context before executing or expose it + through a new tool like saltus/get-context. + + Inside AI + + This would mean AI operating from inside WordPress admin. + + Examples: + + - AI buttons beside metabox fields + - “Improve title” + - “Summarize this CPT entry” + - “Generate excerpt from meta” + - “Validate against brand rules” + - “Suggest taxonomy terms” + - “Prepare draft update” + + The key point: inside AI would still use Saltus models and WordPress + permissions. It should not bypass CPT config, field schemas, validation, or + post status rules. + + Outside AI + + This maps directly to Saltus MCP/Abilities. + + External agents such as Codex, Claude Desktop, Cursor, or custom automation + clients connect to WordPress and call: + + - saltus/list-models + - saltus/get-model + - saltus/list-posts + - saltus/get-post + - saltus/create-post + - saltus/update-post + - saltus/update-settings + - saltus/get-meta-fields + + Saltus already has this architectural direction. The next step would be adding + stronger editorial governance around mutating tools. + + Editorial Review + + Right now, agent writes can be permission-gated and audited. To match the idea + you quoted, Saltus would need an approval layer: + + - agent proposes a change + - Saltus stores it as a draft, revision, or pending change record + - human editor reviews diff + - editor approves, rejects, or edits + - only approved changes are published + - audit log records the full chain + + Practically, mutating MCP tools should default to: + + AI write -> draft/pending/revision -> human approval -> publish + + Not: + + AI write -> publish + + Best Saltus framing + + I’d describe it as: + + > Saltus can become the governance layer for AI-operated WordPress content + > models: models define the content structure, context rules define how AI + > should behave, MCP/Abilities expose controlled operations to external + > agents, and WordPress revisions, statuses, capabilities, and audit logs keep + > every change reviewable. + + The big opportunity is that Saltus already owns the model definition. That + means it can give AI agents structured knowledge of each CPT, its fields, + allowed operations, and editorial policy without every plugin author + rebuilding that machinery. From c682bb422b18b7a0df3ce6c9f2dacd16e9a1fd48 Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Fri, 10 Jul 2026 01:32:09 +0800 Subject: [PATCH 36/78] Update docs --- docs/MCP.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/MCP.md b/docs/MCP.md index f589316..031c74e 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -114,7 +114,7 @@ REST routes and MCP tools are gated by model configuration. ### Master Options (Model Level) At the model level, two master options in the `options` array control access: -- **`show_in_rest`**: Controls whether model-scoped REST routes (and consequently MCP tools) are registered. If explicitly set to `false`, all model-scoped REST and MCP capabilities for the model are disabled. Defaults to `true` (if omitted or not `false`). +- **`show_in_rest`**: Controls whether model-scoped REST routes are registered. If explicitly set to `false`, all model-scoped REST capabilities for the model are disabled. It does not control whether the model's MCP tools are generated/shown (which is managed by `mcp_tools` and `show_in_mcp`), although calling those MCP tools will fail if the underlying REST route is disabled. Defaults to `true` (if omitted or not `false`). - **`mcp_tools`**: Must be set and truthy (e.g., `true`) in model options to enable any MCP tools for that model. The framework-scoped health capability (`health` ability / REST route) is independent of per-model opt-in and is always available. The `models` capability is always enabled for a model as long as its `show_in_rest` is not `false` (or always, for MCP, if `mcp_tools` is enabled). @@ -181,7 +181,7 @@ return [ ]; ``` -If `show_in_rest` is explicitly `false`, Saltus does not expose model-scoped REST or MCP routes for that model. The health ability is framework-scoped and remains independent of per-model opt-in. +If `show_in_rest` is explicitly `false`, Saltus does not register the model-scoped REST routes. The `mcp_tools` option controls whether MCP tools are exposed. The health ability is framework-scoped and remains independent of per-model opt-in. ## Available Abilities @@ -318,7 +318,7 @@ Audit retention cleanup runs through the daily `saltus_framework_mcp_audit_clean | WordPress with Abilities API | Saltus registers `saltus/*` abilities | | WordPress without Abilities API | Saltus skips native ability registration | | `mcp_tools` not set or `false` | No MCP tools are generated for that model | -| `show_in_rest` set to `false` | Model-scoped Saltus REST and MCP routes are unavailable for that model | +| `show_in_rest` set to `false` | Model-scoped Saltus REST routes are disabled (calling any corresponding MCP tools will fail) | | No WordPress-native MCP client | Saltus abilities are registered, but no client consumes them | ## Troubleshooting @@ -326,7 +326,7 @@ Audit retention cleanup runs through the daily `saltus_framework_mcp_audit_clean | Symptom | Check | |---------|-------| | No `saltus/*` abilities appear | Confirm the WordPress build provides the Abilities API and the plugin is active | -| A model is missing from MCP results | Confirm the model has `show_in_rest` and `mcp_tools` enabled, and required feature-level `show_in_mcp` flags | +| A model is missing from MCP results | Confirm the model has `mcp_tools` enabled, and required feature-level `show_in_mcp` flags | | A write operation fails | Confirm the current WordPress user has the needed post, taxonomy, or settings capability | | Calls are throttled | Check `saltus/framework/mcp/rate_limit/*` filters | | Results look stale | Clear transients or disable MCP cache while testing | From 34925ecfd44aaf4d4fda7690689baab9e4f7b947 Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Fri, 10 Jul 2026 03:14:03 +0800 Subject: [PATCH 37/78] Prepare next phase in docs --- docs/ROADMAP.md | 161 ++++++++++++++++++------------------------------ 1 file changed, 60 insertions(+), 101 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b4d3d0e..c84a713 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -400,125 +400,84 @@ frontend: -//// new -• Current state: Saltus already has the “external agent connects to WordPress - and modifies content through governed abilities” piece partly implemented. - What it does not appear to have yet is a first-class “context control center” - model or a mandatory editorial approval queue for agent writes. +### Phase 6: AI Governance & Editorial Review (v2.2+) -──────────────────────────────────────────────────────────────────────────────── +**Theme:** Add a first-class AI governance layer — context control, editorial review queues, and inside-admin AI assistants — on top of the existing MCP/Abilities foundation. -• For Saltus, that translates less like “Drupal AI” and more like a WordPress- - native AI governance layer for CPT-driven plugins. +Saltus already has model-defined CPTs, REST routes, MCP/Abilities tools, capability checks, audit logging, rate limits, health checks, and per-model `mcp_tools`/`show_in_mcp` gates. Phase 6 adds the higher-level product layer. - Saltus already has the foundation: model-defined CPTs, REST routes, MCP/ - Abilities tools, capability checks, audit logging, rate limits, health checks, - and per-model mcp_tools / show_in_mcp gates. The missing higher-level product - layer would be this: - - Context Control Center - - A Saltus model/config area where a plugin defines: - - - brand voice - - field-level writing rules - - audience profiles - - taxonomy/content standards - - allowed statuses - - forbidden actions - - required review rules - - per-CPT AI instructions - - In Saltus terms, this could live as model config, for example: - - 'options' => [ - 'show_in_rest' => true, - 'mcp_tools' => true, - ], - 'config' => [ - 'ai_context' => [ - 'brand_voice' => 'Clear, practical, expert, no hype.', - 'audiences' => ['developers', 'site editors'], - 'rules' => [ - 'never_publish_directly' => true, - 'require_human_review' => true, - 'preserve_technical_accuracy' => true, - ], - ], - ] - - Then MCP tools like create_post, update_post, update_settings, and - update_meta_fields would receive this context before executing or expose it - through a new tool like saltus/get-context. - - Inside AI - - This would mean AI operating from inside WordPress admin. - - Examples: +--- - - AI buttons beside metabox fields - - “Improve title” - - “Summarize this CPT entry” - - “Generate excerpt from meta” - - “Validate against brand rules” - - “Suggest taxonomy terms” - - “Prepare draft update” +#### 6A — Context Control Center - The key point: inside AI would still use Saltus models and WordPress - permissions. It should not bypass CPT config, field schemas, validation, or - post status rules. +**Goal:** A Saltus model config area where a plugin defines AI governance rules that MCP tools receive before executing. - Outside AI +**Config shape:** +```yaml +config: + ai_context: + brand_voice: 'Clear, practical, expert, no hype.' + audiences: ['developers', 'site editors'] + field_rules: + post_content: + - 'Maintain technical accuracy' + - 'Never include affiliate links' + allowed_statuses: ['draft', 'pending'] + forbidden_actions: ['delete', 'publish'] + require_human_review: true +``` - This maps directly to Saltus MCP/Abilities. +| Item | Status | +|------|--------| +| `ai_context` config schema definition and validation | ○ Pending | +| `AiContextProvider` service — parses and serves ai_context per model | ○ Pending | +| MCP tool `get_context` — exposes ai_context to external agents | ○ Pending | +| Context injection into mutating MCP tools (create/update/delete) | ○ Pending | +| Filter: `saltus/framework/ai_context/defaults` | ○ Pending | +| PHPUnit tests for context validation and injection | ○ Pending | - External agents such as Codex, Claude Desktop, Cursor, or custom automation - clients connect to WordPress and call: +**Exit criteria:** Models with `config.ai_context` expose a `saltus/get-context` MCP tool. Mutating MCP tools receive context rules and can reject operations that violate them. - - saltus/list-models - - saltus/get-model - - saltus/list-posts - - saltus/get-post - - saltus/create-post - - saltus/update-post - - saltus/update-settings - - saltus/get-meta-fields +--- - Saltus already has this architectural direction. The next step would be adding - stronger editorial governance around mutating tools. +#### 6B — Editorial Review Queue - Editorial Review +**Theme:** Agent-proposed changes go through a human approval workflow instead of publishing directly. - Right now, agent writes can be permission-gated and audited. To match the idea - you quoted, Saltus would need an approval layer: +**Flow:** +``` +AI write -> draft/pending/revision -> human approval -> publish +``` - - agent proposes a change - - Saltus stores it as a draft, revision, or pending change record - - human editor reviews diff - - editor approves, rejects, or edits - - only approved changes are published - - audit log records the full chain +| Item | Status | +|------|--------| +| `AiChangeProposal` service — stores agent writes as pending change records | ○ Pending | +| `EditorialReviewController` — REST endpoints for listing/reviewing/approving/rejecting proposals | ○ Pending | +| Review dashboard UI (admin screen with diff view) | ○ Pending | +| Audit log integration — full chain from proposal to approval/rejection | ○ Pending | +| Default all mutating MCP tools to draft/pending (configurable) | ○ Pending | +| PHPUnit tests for proposal lifecycle | ○ Pending | - Practically, mutating MCP tools should default to: +**Exit criteria:** Mutating MCP tools create pending change records by default. A review admin screen lists proposals with diff view. Approved proposals are published; rejected ones are discarded. Audit log records the full chain. - AI write -> draft/pending/revision -> human approval -> publish +--- - Not: +#### 6C — Inside-Admin AI Assistants - AI write -> publish +**Theme:** AI operates from inside WordPress admin — buttons beside metabox fields, inline suggestions, and validation. - Best Saltus framing +| Item | Status | +|------|--------| +| `AiAssistantProvider` service — registers meta box assistants per model | ○ Pending | +| Admin JS entry point (`assets/Feature/AiAssistant/editor.js`) | ○ Pending | +| Assistant actions: improve title, summarize, generate excerpt, suggest terms | ○ Pending | +| Brand rule validation button for post content | ○ Pending | +| REST endpoints for assistant actions (reuse existing permission checks) | ○ Pending | +| Filter: `saltus/framework/ai/assistant_actions` | ○ Pending | +| PHPUnit tests for assistant REST endpoints | ○ Pending | - I’d describe it as: +**Exit criteria:** Models with `config.ai_context` show AI assistant buttons in the admin. Clicking "Improve title" or "Summarize" calls a REST endpoint and updates the field. Brand rule validation highlights content that violates configured rules. - > Saltus can become the governance layer for AI-operated WordPress content - > models: models define the content structure, context rules define how AI - > should behave, MCP/Abilities expose controlled operations to external - > agents, and WordPress revisions, statuses, capabilities, and audit logs keep - > every change reviewable. +--- - The big opportunity is that Saltus already owns the model definition. That - means it can give AI agents structured knowledge of each CPT, its fields, - allowed operations, and editorial policy without every plugin author - rebuilding that machinery. +**Exit criteria (Phase 6 overall):** AI governance is configurable per model via `ai_context`. Mutating MCP tools respect context rules and default to review-queue creation. Inside-admin assistants are operational for configured models. All features are tested. From 83fbf604bcad5ccfa1ce87bfe358f403d5b852e4 Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Fri, 10 Jul 2026 14:29:01 +0800 Subject: [PATCH 38/78] CS --- src/Modeler.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Modeler.php b/src/Modeler.php index e0529e8..ec86cf3 100644 --- a/src/Modeler.php +++ b/src/Modeler.php @@ -39,7 +39,6 @@ class Modeler implements RestRouteProvider, ToolContributor { /** * Construct the modeler. - * @param ModelFactory $model_factory */ public function __construct( ModelFactory $model_factory ) { From b092f11df07e5a17ddd1e95ca7b9d8ebd060432c Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Fri, 10 Jul 2026 22:29:28 +0800 Subject: [PATCH 39/78] CS --- src/Models/Taxonomy.php | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Models/Taxonomy.php b/src/Models/Taxonomy.php index abb8f54..e17771b 100644 --- a/src/Models/Taxonomy.php +++ b/src/Models/Taxonomy.php @@ -46,15 +46,15 @@ private function get_default_options(): array { return $options; } - $config['hierarchical'] = false; + $options['hierarchical'] = false; if ( in_array( $this->config->get( 'type' ), [ 'cat', 'category' ], true ) ) { - $config['hierarchical'] = true; + $options['hierarchical'] = true; } // show in rest api by default - $config['show_in_rest'] = true; + $options['show_in_rest'] = true; - return $config; + return $options; } /** @@ -79,11 +79,11 @@ private function get_default_labels(): array { 'update_item' => 'Update ' . $this->one, 'add_new_item' => 'Add New ' . $this->one, 'new_item_name' => 'New ' . $this->one . ' Name', - 'separate_items_with_commas' => 'Separate ' . strtolower( $this->many ) . ' with commas', - 'add_or_remove_items' => 'Add or remove ' . strtolower( $this->many ), - 'choose_from_most_used' => 'Choose from the most used ' . strtolower( $this->many ), - 'not_found' => 'No ' . strtolower( $this->many ) . ' found.', - 'no_terms' => 'No ' . strtolower( $this->many ), + 'separate_items_with_commas' => 'Separate ' . $this->many_low . ' with commas', + 'add_or_remove_items' => 'Add or remove ' . $this->many_low, + 'choose_from_most_used' => 'Choose from the most used ' . $this->many_low, + 'not_found' => 'No ' . $this->many_low . ' found.', + 'no_terms' => 'No ' . $this->many_low, 'items_list_navigation' => $this->many . ' list navigation', 'items_list' => $this->many . ' list', ]; From 8235f2c46eca3f15668ffa2e6865643bf8e692f2 Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Fri, 10 Jul 2026 22:29:57 +0800 Subject: [PATCH 40/78] Use model built label when creating labels --- src/Models/PostType.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Models/PostType.php b/src/Models/PostType.php index 062ea33..40fe464 100644 --- a/src/Models/PostType.php +++ b/src/Models/PostType.php @@ -84,8 +84,8 @@ public function has_meta(): bool { */ protected function get_default_labels(): array { - $many_lower = strtolower( $this->many ); - $one_lower = strtolower( $this->one ); + $many_lower = $this->many_low; + $one_lower = $this->one_low; $labels = [ 'name' => $this->many, From 2b39557ab0e1e3dccf6b1ddf16f54929bc158d4d Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Fri, 10 Jul 2026 23:09:26 +0800 Subject: [PATCH 41/78] Update feature capabilities matrix --- docs/MCP.md | 13 ++++++++----- docs/ROADMAP.md | 18 ++++++++++++++++++ src/MCP/McpPolicy.php | 31 ++++++++++++++++++++----------- src/Rest/ModelRestPolicy.php | 31 ++++++++++++++++++++++--------- 4 files changed, 68 insertions(+), 25 deletions(-) diff --git a/docs/MCP.md b/docs/MCP.md index 031c74e..92ef6b1 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -131,11 +131,14 @@ Each individual framework capability can be gated in the model's `config` array. ### Resolution Rules For each feature/capability configuration section: -1. **Omitted (Null):** If a capability config section is omitted from the model configuration, the feature defaults to **enabled** for both REST and MCP. +1. **Omitted (Null):** If a capability config section is omitted from the model configuration, the feature's availability defaults to the master model option: + - For **REST API**: falls back to `show_in_rest` (which itself defaults to `true`). + - For **MCP Tools**: falls back to `mcp_tools` (which itself defaults to `false` if omitted). + - If the respective master option is omitted/false, the feature is **disabled**. If the master option is `true`, the feature is **enabled**. 2. **Boolean Value:** If defined as a simple boolean (e.g., `'meta' => false` or `'features' => ['duplicate' => false]`), it acts as a joint gate. A value of `false` disables both REST and MCP for that capability; a value of `true` enables both. 3. **Array Value:** If defined as an array, REST and MCP gating can be configured independently: - - **REST Route Gating:** Governed by the `show_in_rest` key in the section array. If the key is omitted, REST is **enabled** (`true`). If present, it resolves to its boolean value. - - **MCP Tool Gating:** Governed by the `show_in_mcp` key in the section array. If the key is omitted, MCP is **enabled** (`true`). If present, it resolves to its boolean value. + - **REST Route Gating:** Governed by the `show_in_rest` key in the section array. If present, it resolves to its boolean value. If omitted, it falls back to matching the master model `show_in_rest` option. + - **MCP Tool Gating:** Governed by the `show_in_mcp` key in the section array. If present, it resolves to its boolean value. If omitted, it falls back to matching the master model `mcp_tools` option. Enable all Saltus REST-backed and MCP capabilities for a model: @@ -170,12 +173,12 @@ return [ 'settings' => false, 'features' => [ - // 3. Array style: enabled for REST, and defaults to enabled for MCP + // 3. Array style: enabled for REST, and defaults to matching master options (enabled) for MCP 'duplicate' => [ 'show_in_rest' => true, ], // 4. Omitted config for single_export and drag_and_drop: - // both default to enabled for REST and MCP + // both default to matching the master options (enabled here because show_in_rest & mcp_tools are true) ], ], ]; diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c84a713..60d81f4 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -481,3 +481,21 @@ AI write -> draft/pending/revision -> human approval -> publish --- **Exit criteria (Phase 6 overall):** AI governance is configurable per model via `ai_context`. Mutating MCP tools respect context rules and default to review-queue creation. Inside-admin assistants are operational for configured models. All features are tested. + +--- + +### Phase 7: Advanced Dependency Injection & Container Hardening (v2.3+) + +**Theme:** Upgrade the framework's dependency injection container to support reflection-based parameter resolution (autowiring) for third-party services, avoiding standard constructor mapping errors. + +| Item | Status | +|------|--------| +| `ReflectionInstantiator` class implementing `Instantiator` | ○ Pending | +| Positional constructor parameter resolution and dependency matching | ○ Pending | +| Constructor parameter default value fallbacks | ○ Pending | +| Clean validation and exception flow for unresolved parameters | ○ Pending | +| Remove requirement for `Assembly::make` boilerplate on custom services | ○ Pending | +| Container autowiring unit tests (`tests/Unit/Infrastructure/Container/`) | ○ Pending | +| Developer documentation update for custom service constructors | ○ Pending | + +**Exit criteria:** Developers can register custom services in the container with standard typed/positional constructor arguments. The container uses PHP Reflection to map parameter names to container keys, falling back to default arguments or throwing descriptive runtime exceptions when dependencies cannot be resolved. diff --git a/src/MCP/McpPolicy.php b/src/MCP/McpPolicy.php index e1fe96f..165098d 100644 --- a/src/MCP/McpPolicy.php +++ b/src/MCP/McpPolicy.php @@ -57,19 +57,28 @@ public function has_capability( string $capability, ?string $model_type = null ) * @return bool */ public function is_enabled( Model $model, string $capability ): bool { - $options = $model->get_options(); - - if ( empty( $options['mcp_tools'] ) ) { - return false; + $options = $model->get_options(); + $global_val = null; + if ( array_key_exists( 'mcp_tools', $options ) ) { + $global_val = (bool) $options['mcp_tools']; } - if ( $capability === ModelRestPolicy::CAPABILITY_HEALTH || $capability === ModelRestPolicy::CAPABILITY_MODELS ) { + if ( $capability === ModelRestPolicy::CAPABILITY_HEALTH ) { return true; } - $config = $model->get_config(); + if ( $capability === ModelRestPolicy::CAPABILITY_MODELS ) { + return $global_val === true; + } + + $config = $model->get_config(); + $feature_val = $this->resolve_feature_value( $config, $capability ); + + if ( $feature_val !== null ) { + return $feature_val; + } - return $this->resolve_show_in_mcp( $config, $capability ); + return $global_val === true; } /** @@ -107,13 +116,13 @@ private function get_capability_config( array $config, string $capability ) { * Resolve whether a capability should be shown in MCP. * * @param array $config - * - * @return bool + * @param string $capability + * @return bool|null */ - private function resolve_show_in_mcp( array $config, string $capability ): bool { + private function resolve_feature_value( array $config, string $capability ): ?bool { $section = $this->get_capability_config( $config, $capability ); if ( $section === null ) { - return true; + return null; } if ( ! is_array( $section ) ) { diff --git a/src/Rest/ModelRestPolicy.php b/src/Rest/ModelRestPolicy.php index 816ae73..095cf40 100644 --- a/src/Rest/ModelRestPolicy.php +++ b/src/Rest/ModelRestPolicy.php @@ -40,19 +40,28 @@ public function has_capability( string $capability, ?string $model_type = null ) } public function is_enabled( Model $model, string $capability ): bool { - $options = $this->get_model_options( $model ); - - if ( array_key_exists( 'show_in_rest', $options ) && $options['show_in_rest'] === false ) { - return false; + $options = $this->get_model_options( $model ); + $global_val = null; + if ( array_key_exists( 'show_in_rest', $options ) ) { + $global_val = (bool) $options['show_in_rest']; } - if ( $capability === self::CAPABILITY_HEALTH || $capability === self::CAPABILITY_MODELS ) { + if ( $capability === self::CAPABILITY_HEALTH ) { return true; } - $config = $model->get_config(); + if ( $capability === self::CAPABILITY_MODELS ) { + return $global_val !== false; + } + + $config = $model->get_config(); + $feature_val = $this->resolve_feature_value( $config, $capability ); - return $this->resolve_show_in_rest( $config, $capability ); + if ( $feature_val !== null ) { + return $feature_val; + } + + return $global_val === true; } /** @@ -87,12 +96,16 @@ private function get_capability_config( array $config, string $capability ) { } /** + * Resolve the capability value from the feature configuration. + * * @param array $config + * @param string $capability + * @return bool|null */ - private function resolve_show_in_rest( array $config, string $capability ): bool { + private function resolve_feature_value( array $config, string $capability ): ?bool { $section = $this->get_capability_config( $config, $capability ); if ( $section === null ) { - return true; + return null; } if ( ! is_array( $section ) ) { From 857c50bdcfc250bfb0981c73de96ff9a9682ec72 Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Tue, 21 Jul 2026 09:31:57 +0800 Subject: [PATCH 42/78] infra(docs): gitignore generated docs artifacts --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index bfcb0c8..77d79b1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,11 @@ # dev vendor/ +node_modules/ + +# docs +docs/public/api/ +docs/.vitepress/dist/ +build/phpdoc-cache/ # editor/OS files .DS_Store From cfe294ff91046945cf43498bd6bdbf21f00700f3 Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Tue, 21 Jul 2026 09:32:01 +0800 Subject: [PATCH 43/78] infra(docs): add npm vitepress dependency --- package-lock.json | 2559 +++++++++++++++++++++++++++++++++++++++++++++ package.json | 31 + 2 files changed, 2590 insertions(+) create mode 100644 package-lock.json create mode 100644 package.json diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..fb79a59 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2559 @@ +{ + "name": "saltus-framework-git", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "saltus-framework-git", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "vitepress": "^1.6.4" + } + }, + "node_modules/@algolia/abtesting": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.22.0.tgz", + "integrity": "sha512-BFR6zNowNKcY7Ou7TaJc9QWexES4YKPbmf/OTFofpdsdhz4x6q0lbxp3duO0EHnyrN7rE4ba/TSXuY+BDGu4+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/autocomplete-core": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.17.7.tgz", + "integrity": "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-plugin-algolia-insights": "1.17.7", + "@algolia/autocomplete-shared": "1.17.7" + } + }, + "node_modules/@algolia/autocomplete-plugin-algolia-insights": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.17.7.tgz", + "integrity": "sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-shared": "1.17.7" + }, + "peerDependencies": { + "search-insights": ">= 1 < 3" + } + }, + "node_modules/@algolia/autocomplete-preset-algolia": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.17.7.tgz", + "integrity": "sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-shared": "1.17.7" + }, + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } + }, + "node_modules/@algolia/autocomplete-shared": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.17.7.tgz", + "integrity": "sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } + }, + "node_modules/@algolia/client-abtesting": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.56.0.tgz", + "integrity": "sha512-7r4Z3NC7yU1oAQVWJNA2HX7tX481F3pJvCGyLIXiTdBcthz4Q/o21jwcMYDFkuI92UWTNBQQmHYgwHo1zS5dzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-analytics": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.56.0.tgz", + "integrity": "sha512-avmjXQSq+jadFO8Xl2em05/uQdQnEmHsJyOAdVbZkmVgpMfxL12aJwVVfGNwYr9nulcpuJN1X0lTaQ5wxuNGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-common": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.56.0.tgz", + "integrity": "sha512-v2TPStUhY//ripPjIVclZ8AWc7DEGooXULZGFlFu37zNatgHjw34oZZ+OSbbc/YHO+xZwPl62I1k8xH1m4S2eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-insights": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.56.0.tgz", + "integrity": "sha512-P0ehROpM4Sem3Sqo5x2cKPgj67D3G3jy0rh1Amwkcvsfr6tkvIcdCmerieanqTF7NxUMPNFLkpIFeMO8Rpa50w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-personalization": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.56.0.tgz", + "integrity": "sha512-SXK3Vn3WVxyzbm31oePZBJkp1wpOyuWdd4B/Pv7n0aXDxmeSWhC1R1FC1517mMrFAIaPH4Rt0x6RUe7ZNjz8FA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-query-suggestions": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.56.0.tgz", + "integrity": "sha512-5+ZdX8garFnmycnZgKhtXHePEaLj5zqDxI/0lkhhluzCcvTn0/PvvTirTg8hHYetQHvn7GDyeAiqTAieMvMW4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-search": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.56.0.tgz", + "integrity": "sha512-+mKUdYvqOi0BcvpAEyCEw49vSBptufIcfibtHz2bdr1pI789M46Yt0uQEk/sxtK3teh71OQvVFHaTDzShUWewQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/ingestion": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.56.0.tgz", + "integrity": "sha512-9g/zj+AZx5moFcdFIrYQoVrueXivjUcc3MQHtCYT8WhIuk1lUh1AyEhvJCS0XBZld09cLvd1AZ3BvDBpVpX2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/monitoring": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.56.0.tgz", + "integrity": "sha512-Qf3Sr6f9A9uxCZUf3MXS0d2b877uYzEB5yxqpVGXAhcJnBCQjrRRon0KvefpGkxy+BshrIJs96OUoMtGqXTFDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/recommend": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.56.0.tgz", + "integrity": "sha512-GXWG1rWc5wu8hY4N33Y3b6ernY6sAdAvmKWN/zHAiACOx40WnpG0TVX5YazCAr/9gOYGInSiM2A0y2jy2xbiDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-browser-xhr": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.56.0.tgz", + "integrity": "sha512-7t24cBxaInS3mZb7ddEaZT/tp6q+/aR4YttsQVyP1/i+LmwPR34atO35KjaLFCcRVrlP7sYOAqkCfg6lIRB+ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-fetch": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.56.0.tgz", + "integrity": "sha512-R7ePHgVYmDFjZpvrsVAfbDz/d4RxKAYZ5/vgLfIsCVRZRryjWl/3INOxpOICzitehQ5FjNtNjcLQTrmHPTcHBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-node-http": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.56.0.tgz", + "integrity": "sha512-PIOUXlSnrqM0S+WOgDRb4RzotydJH7ZoT6tOyL7tAO7qJOfvX5wsEW8Pe+PMKMwvuI4/gIyK9cg2H7lJXqnc4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@docsearch/css": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.8.2.tgz", + "integrity": "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@docsearch/js": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-3.8.2.tgz", + "integrity": "sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@docsearch/react": "3.8.2", + "preact": "^10.0.0" + } + }, + "node_modules/@docsearch/react": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.8.2.tgz", + "integrity": "sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-core": "1.17.7", + "@algolia/autocomplete-preset-algolia": "1.17.7", + "@docsearch/css": "3.8.2", + "algoliasearch": "^5.14.2" + }, + "peerDependencies": { + "@types/react": ">= 16.8.0 < 19.0.0", + "react": ">= 16.8.0 < 19.0.0", + "react-dom": ">= 16.8.0 < 19.0.0", + "search-insights": ">= 1 < 3" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "search-insights": { + "optional": true + } + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@iconify-json/simple-icons": { + "version": "1.2.91", + "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.91.tgz", + "integrity": "sha512-plQJHZxzpkIXaeYjJF0Sc9nkzYF32EDX9olx7j1hcKYFaiTrQXbcngZmvzuV0pG6uZPU3ZuA/GIsgxSHOm6jrQ==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "@iconify/types": "*" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@shikijs/core": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-2.5.0.tgz", + "integrity": "sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/engine-javascript": "2.5.0", + "@shikijs/engine-oniguruma": "2.5.0", + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.4" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-2.5.0.tgz", + "integrity": "sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^3.1.0" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-2.5.0.tgz", + "integrity": "sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-2.5.0.tgz", + "integrity": "sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-2.5.0.tgz", + "integrity": "sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/transformers": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-2.5.0.tgz", + "integrity": "sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "2.5.0", + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/types": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-2.5.0.tgz", + "integrity": "sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.40.tgz", + "integrity": "sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.40", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.40.tgz", + "integrity": "sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.40.tgz", + "integrity": "sha512-gIf497P4kpuALcvs5n3AEg1Vdn0pSY4XbjASIfHNYF1/MP3T2Mf2STERTubysBxCRxzJGJYtF/O7vwJrxFB3Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.40", + "@vue/compiler-dom": "3.5.40", + "@vue/compiler-ssr": "3.5.40", + "@vue/shared": "3.5.40", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.40.tgz", + "integrity": "sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.10", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.10.tgz", + "integrity": "sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.10" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.10", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.10.tgz", + "integrity": "sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.10", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.10", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.10.tgz", + "integrity": "sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.40.tgz", + "integrity": "sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.40.tgz", + "integrity": "sha512-KAZLweuZ6uUJPK1PMSQPgBU5gCjgrrfjUhSglmU9NhH+Zjepa8cnwSydPWDWHDwOgY4g3VcZ+PljbiHlURNCbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.40.tgz", + "integrity": "sha512-ZfrX8ssZQds900L9pr8AuK05ddnMsR4MPMZr8cPN9GoqoPWcXLhjvvbIA2SMv+7a97sJ1vv9pj/zxK0Cq/eEFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.40", + "@vue/runtime-core": "3.5.40", + "@vue/shared": "3.5.40", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.40.tgz", + "integrity": "sha512-XNJym9WpevhTVt1HuwOrCRJ5Q+9z4BjTMrDtjTrvx74SmUll8spNTw6whWJa9mEkO4PKn5TihI/bm/8ds2QVJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.40", + "@vue/runtime-dom": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.40.tgz", + "integrity": "sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.8.2.tgz", + "integrity": "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "12.8.2", + "@vueuse/shared": "12.8.2", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/integrations": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-12.8.2.tgz", + "integrity": "sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vueuse/core": "12.8.2", + "@vueuse/shared": "12.8.2", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "async-validator": "^4", + "axios": "^1", + "change-case": "^5", + "drauu": "^0.4", + "focus-trap": "^7", + "fuse.js": "^7", + "idb-keyval": "^6", + "jwt-decode": "^4", + "nprogress": "^0.2", + "qrcode": "^1.5", + "sortablejs": "^1", + "universal-cookie": "^7" + }, + "peerDependenciesMeta": { + "async-validator": { + "optional": true + }, + "axios": { + "optional": true + }, + "change-case": { + "optional": true + }, + "drauu": { + "optional": true + }, + "focus-trap": { + "optional": true + }, + "fuse.js": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "jwt-decode": { + "optional": true + }, + "nprogress": { + "optional": true + }, + "qrcode": { + "optional": true + }, + "sortablejs": { + "optional": true + }, + "universal-cookie": { + "optional": true + } + } + }, + "node_modules/@vueuse/metadata": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz", + "integrity": "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz", + "integrity": "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/algoliasearch": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.56.0.tgz", + "integrity": "sha512-PrqppUmhT4ENdas2pH9caE7efUcxy6EcSFhWzosiVuQBzu2tQ5yLTI6jwomT/1cuBnivzGfxiJCqDNN9FRRh+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/abtesting": "1.22.0", + "@algolia/client-abtesting": "5.56.0", + "@algolia/client-analytics": "5.56.0", + "@algolia/client-common": "5.56.0", + "@algolia/client-insights": "5.56.0", + "@algolia/client-personalization": "5.56.0", + "@algolia/client-query-suggestions": "5.56.0", + "@algolia/client-search": "5.56.0", + "@algolia/ingestion": "1.56.0", + "@algolia/monitoring": "1.56.0", + "@algolia/recommend": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/emoji-regex-xs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", + "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/focus-trap": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz", + "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tabbable": "^6.4.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mark.js": { + "version": "8.11.1", + "resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz", + "integrity": "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/minisearch": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz", + "integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==", + "dev": true, + "license": "MIT" + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oniguruma-to-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-3.1.1.tgz", + "integrity": "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex-xs": "^1.0.0", + "regex": "^6.0.1", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.20", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.20.tgz", + "integrity": "sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/preact": { + "version": "10.29.7", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.7.tgz", + "integrity": "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "dev": true, + "license": "MIT" + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/search-insights": { + "version": "2.17.3", + "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", + "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/shiki": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-2.5.0.tgz", + "integrity": "sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "2.5.0", + "@shikijs/engine-javascript": "2.5.0", + "@shikijs/engine-oniguruma": "2.5.0", + "@shikijs/langs": "2.5.0", + "@shikijs/themes": "2.5.0", + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tabbable": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", + "integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitepress": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz", + "integrity": "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@docsearch/css": "3.8.2", + "@docsearch/js": "3.8.2", + "@iconify-json/simple-icons": "^1.2.21", + "@shikijs/core": "^2.1.0", + "@shikijs/transformers": "^2.1.0", + "@shikijs/types": "^2.1.0", + "@types/markdown-it": "^14.1.2", + "@vitejs/plugin-vue": "^5.2.1", + "@vue/devtools-api": "^7.7.0", + "@vue/shared": "^3.5.13", + "@vueuse/core": "^12.4.0", + "@vueuse/integrations": "^12.4.0", + "focus-trap": "^7.6.4", + "mark.js": "8.11.1", + "minisearch": "^7.1.1", + "shiki": "^2.1.0", + "vite": "^5.4.14", + "vue": "^3.5.13" + }, + "bin": { + "vitepress": "bin/vitepress.js" + }, + "peerDependencies": { + "markdown-it-mathjax3": "^4", + "postcss": "^8" + }, + "peerDependenciesMeta": { + "markdown-it-mathjax3": { + "optional": true + }, + "postcss": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.40.tgz", + "integrity": "sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.40", + "@vue/compiler-sfc": "3.5.40", + "@vue/runtime-dom": "3.5.40", + "@vue/server-renderer": "3.5.40", + "@vue/shared": "3.5.40" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1f3e9f1 --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "saltus-framework-git", + "version": "1.0.0", + "description": "Saltus Framework helps you develop WordPress plugins that are based on Custom Post Types.", + "main": "index.js", + "directories": { + "doc": "docs", + "lib": "lib", + "test": "tests" + }, + "scripts": { + "docs:dev": "vitepress dev docs", + "docs:build": "vitepress build docs", + "docs:preview": "vitepress preview docs" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/SaltusDev/saltus-framework.git" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "bugs": { + "url": "https://github.com/SaltusDev/saltus-framework/issues" + }, + "homepage": "https://github.com/SaltusDev/saltus-framework#readme", + "devDependencies": { + "vitepress": "^1.6.4" + } +} From b23f46a8effb3559d72b2ff9fdac22b23dca6ee2 Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Tue, 21 Jul 2026 09:32:06 +0800 Subject: [PATCH 44/78] infra(docs): add composer docs:all scripts --- composer.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 305bf52..49892dc 100644 --- a/composer.json +++ b/composer.json @@ -66,7 +66,13 @@ "test:phpstan": "./vendor/bin/phpstan analyse --memory-limit=2G", "test:phpcs": "./vendor/bin/phpcs --standard=phpcs.xml", "fix:phpcbf": "./vendor/bin/phpcbf --standard=phpcs.xml", - "docs:mcp": "php bin/generate-mcp-docs.php" + "docs:mcp": "php bin/generate-mcp-docs.php", + "docs:api": "php phpDocumentor.phar -c phpdoc.dist.xml", + "docs:wpcli": "php bin/generate-wpcli-docs.php", + "docs:all": [ + "@docs:mcp", + "@docs:api" + ] }, "minimum-stability": "dev", "prefer-stable": true, From e77ec9b25b538e5a055dd55de5ebf9181dbe95db Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Tue, 21 Jul 2026 09:32:31 +0800 Subject: [PATCH 45/78] infra(docs): add phpDocumentor configuration --- phpdoc.dist.xml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 phpdoc.dist.xml diff --git a/phpdoc.dist.xml b/phpdoc.dist.xml new file mode 100644 index 0000000..6ac3477 --- /dev/null +++ b/phpdoc.dist.xml @@ -0,0 +1,26 @@ + + + Saltus Framework + + docs/public/api + build/phpdoc-cache + + + + + src + + + TODO + FIXME + + Saltus Framework + true + public + + + From 1daf11a7e32b028bb62b5e849690fb157a607ca3 Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Tue, 21 Jul 2026 09:32:34 +0800 Subject: [PATCH 46/78] infra(docs): add GitHub Actions workflow for docs deploy --- .github/workflows/docs.yml | 71 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 .github/workflows/docs.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..9c84c0a --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,71 @@ +name: Docs + +on: + push: + branches: [main] + paths: + - 'src/**' + - 'docs/**' + - 'bin/**' + - 'phpdoc.dist.xml' + - 'package.json' + workflow_dispatch: + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + permissions: + contents: write + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-php@v4 + with: + php-version: 8.2 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + + - name: Install phpDocumentor + run: | + wget -q https://github.com/phpDocumentor/phpDocumentor/releases/download/v3.10.0/phpDocumentor.phar + chmod +x phpDocumentor.phar + + - name: Install PHP dependencies + run: composer install --no-interaction --no-progress + + - name: Generate API docs + run: php phpDocumentor.phar -c phpdoc.dist.xml + + - name: Generate MCP docs + run: composer docs:mcp + + - name: Install Node dependencies + run: npm ci + + - name: Build VitePress site + run: npm run docs:build + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs/.vitepress/dist + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 From 7ff4a29fe219cca2bb365d4beeff37e7e05d4bb2 Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Tue, 21 Jul 2026 09:32:50 +0800 Subject: [PATCH 47/78] docs(site): configure VitePress navigation --- docs/.vitepress/config.mjs | 89 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 docs/.vitepress/config.mjs diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs new file mode 100644 index 0000000..ab9be87 --- /dev/null +++ b/docs/.vitepress/config.mjs @@ -0,0 +1,89 @@ +import { defineConfig } from 'vitepress' + +export default defineConfig({ + title: 'Saltus Framework', + description: 'WordPress plugin development framework for Custom Post Types', + lang: 'en-US', + base: '/', + + ignoreDeadLinks: [ + /^\/roadmap/, + /^\/current/, + ], + + head: [ + ['link', { rel: 'icon', href: '/favicon.ico' }], + ], + + themeConfig: { + logo: '/logo.png', + + nav: [ + { text: 'Getting Started', link: '/getting-started' }, + { + text: 'Guides', + items: [ + { text: 'Features', link: '/guides/features' }, + { text: 'Architecture', link: '/guides/architecture' }, + { text: 'Build & Setup', link: '/guides/build' }, + ], + }, + { + text: 'MCP/Abilities', + link: '/mcp/index', + }, + { + text: 'API Reference', + link: '/api/index', + }, + ], + + sidebar: { + '/getting-started': [ + { + text: 'Getting Started', + items: [ + { text: 'Quick Start', link: '/getting-started' }, + ], + }, + ], + '/guides/': [ + { + text: 'Guides', + items: [ + { text: 'Features', link: '/guides/features' }, + { text: 'Architecture', link: '/guides/architecture' }, + { text: 'Build & Setup', link: '/guides/build' }, + ], + }, + ], + '/mcp/': [ + { + text: 'MCP/Abilities', + items: [ + { text: 'Overview', link: '/mcp/index' }, + { text: 'Abilities Reference', link: '/mcp/abilities' }, + { text: 'Client Integration', link: '/mcp/clients' }, + ], + }, + ], + }, + + editLink: { + pattern: 'https://github.com/SaltusDev/saltus-framework/edit/main/docs/:path', + }, + + socialLinks: [ + { icon: 'github', link: 'https://github.com/SaltusDev/saltus-framework' }, + ], + + footer: { + message: 'GPL-3.0 License', + copyright: 'Copyright Saltus Plugin Framework', + }, + + search: { + provider: 'local', + }, + }, +}) From bd5446c5c0eb3cc52472146b650a19b4a19e9630 Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Tue, 21 Jul 2026 09:32:55 +0800 Subject: [PATCH 48/78] docs(site): add landing page --- docs/index.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 docs/index.md diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..2c3a08c --- /dev/null +++ b/docs/index.md @@ -0,0 +1,33 @@ +--- +# https://vitepress.dev/reference/default-theme-home-page +layout: home + +hero: + name: "Saltus Framework" + text: "WordPress plugin development, accelerated" + tagline: Build WordPress plugins with Custom Post Types, taxonomies, meta boxes, settings pages, and admin tooling — using simple configuration files. + image: + src: /logo.png + alt: Saltus Framework + actions: + - theme: brand + text: Get Started + link: /getting-started + - theme: alt + text: View on GitHub + link: https://github.com/SaltusDev/saltus-framework + +features: + - title: Model-driven Architecture + details: Define Custom Post Types and Taxonomies with simple PHP, JSON, or YAML config files. No boilerplate code. + - title: Admin Tooling + details: Add admin columns, filters, drag-and-drop reordering, cloning, single-export, and quick-edit fields in minutes. + - title: REST API + details: 9 REST routes registered in saltus-framework/v1/ covering models, posts, terms, settings, meta, duplicate, export, and reorder. + - title: MCP/Abilities + details: WordPress-native MCP/Abilities surface with 18 tools — models, posts, terms, settings, meta, health, and reorder. + - title: Meta Boxes & Settings + details: Powered by Codestar Framework — build complex meta boxes and settings pages with 40+ field types. + - title: WordPress-native AI + details: Rate limiting, audit logging, transient caching, and capability-gated execution for every MCP tool. +--- From 65f2456575c74dba857f6931aa0304cefb9e1b53 Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Tue, 21 Jul 2026 09:32:59 +0800 Subject: [PATCH 49/78] docs(site): set custom domain --- docs/public/CNAME | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/public/CNAME diff --git a/docs/public/CNAME b/docs/public/CNAME new file mode 100644 index 0000000..8ce979c --- /dev/null +++ b/docs/public/CNAME @@ -0,0 +1 @@ +docs.saltus.dev From 4657b2cd4e8d7f60a88a97464c6310e420829c6d Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Tue, 21 Jul 2026 09:33:03 +0800 Subject: [PATCH 50/78] docs(guides): add getting started page --- docs/getting-started.md | 63 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 docs/getting-started.md diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..f07e879 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,63 @@ +# Getting Started + +## Installation + +Install the framework in your plugin with Composer: + +```bash +composer require saltus/framework +``` + +## Quick Start + +Once the framework is installed and Composer's autoloader is loaded by your plugin, initialize it: + +```php +$autoload = __DIR__ . '/vendor/autoload.php'; +if ( is_readable( $autoload ) ) { + require_once $autoload; +} + +if ( class_exists( \Saltus\WP\Framework\Core::class ) ) { + $framework = new \Saltus\WP\Framework\Core( dirname( __FILE__ ), __FILE__ ); + $framework->register(); +} +``` + +The framework searches for model files in `src/models/` by default (configurable via the `saltus/framework/models/path` filter). + +## Model Files + +A model file returns an array (or multidimensional array) of model definitions: + +```php + 'cpt', + 'name' => 'movie', +]; +``` + +Multiple models in one file: + +```php + 'cpt', + 'name' => 'movie', + ], + [ + 'type' => 'category', + 'name' => 'genre', + 'associations' => ['movie'], + ], +]; +``` + +## Next Steps + +- Explore the [Features Guide](/guides/features) for all configuration options +- Read the [Architecture Guide](/guides/architecture) for framework internals +- Check the [MCP/Abilities](/mcp/index) docs for AI integration +- Browse the [API Reference](/api/index) for class and interface details From 40b97d298c40528bbc8d46421b132d83266dd975 Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Tue, 21 Jul 2026 09:33:13 +0800 Subject: [PATCH 51/78] docs(guides): add features reference page --- docs/guides/features.md | 146 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 docs/guides/features.md diff --git a/docs/guides/features.md b/docs/guides/features.md new file mode 100644 index 0000000..12cae03 --- /dev/null +++ b/docs/guides/features.md @@ -0,0 +1,146 @@ +# Features Reference + +Saltus Framework provides several built-in features that can be enabled per model via the model configuration. + +## Admin Columns + +Customize the admin post list table with additional columns. + +```yaml +features: + admin_cols: + enabled: true + columns: + genre: + label: 'Genre' + callback: 'my_plugin_genre_column' + sortable: true +``` + +## Admin Filters + +Add filter dropdowns to the admin post list. + +```yaml +features: + admin_filters: + genre: + label: 'Filter by Genre' + taxonomy: 'genre' +``` + +## Drag & Drop Reordering + +Enable drag-and-drop reordering of posts. + +```yaml +features: + draganddrop: + enabled: true +``` + +## Duplicate Post + +Allow one-click duplication of posts. + +```yaml +features: + duplicate: + enabled: true +``` + +## Quick Edit + +Add custom fields to the quick edit screen. + +```yaml +features: + quick_edit: + enabled: true +``` + +## Remember Tabs + +Persist active tab state in admin settings pages. + +```yaml +features: + remember_tabs: + enabled: true +``` + +## Single Export + +Export individual posts as WXR. + +```yaml +features: + single_export: + enabled: true +``` + +## Labels + +Customize all labels and messages for your Custom Post Type: + +```yaml +labels: + has_one: 'Movie' + has_many: 'Movies' + text_domain: 'my-plugin' + overrides: + ui: + add_new: 'Add New Movie' + edit_item: 'Edit Movie' + messages: + updated: 'Movie updated.' + created: 'Movie created.' + labels: + name: 'Movies' + singular_name: 'Movie' +``` + +## Meta Boxes + +Define meta boxes with Codestar Framework fields: + +```yaml +meta: + movie_details: + title: 'Movie Details' + sections: + - name: 'general' + title: 'General' + fields: + - id: 'release_date' + type: 'date' + title: 'Release Date' + - id: 'rating' + type: 'select' + title: 'Rating' + options: + G: 'G' + PG: 'PG' + PG-13: 'PG-13' + R: 'R' +``` + +## Settings Pages + +Define settings pages for your CPT: + +```yaml +settings: + page_args: + menu_title: 'Movie Settings' + menu_slug: 'movie-settings' + parent_slug: 'edit.php?post_type=movie' + option_name: 'movie_settings' + sections: + - name: 'general' + title: 'General Settings' + fields: + - id: 'items_per_page' + type: 'number' + title: 'Items Per Page' +``` From 5c967c131afecc850eb712c61e5e431e83fe1b30 Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Tue, 21 Jul 2026 09:33:16 +0800 Subject: [PATCH 52/78] docs(guides): add architecture page --- docs/guides/architecture.md | 57 +++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 docs/guides/architecture.md diff --git a/docs/guides/architecture.md b/docs/guides/architecture.md new file mode 100644 index 0000000..61869e7 --- /dev/null +++ b/docs/guides/architecture.md @@ -0,0 +1,57 @@ +# Architecture + +## Overview + +Saltus Framework follows a service-oriented architecture with a dependency injection container at its core. The framework bootstraps through the `Core` class, which registers all services, routes, and tool providers. + +``` +Core (Plugin interface) + | + |- ServiceContainer (DI container) + | |- Registers 10 feature services + | |- Two-pass registration: unconditional for routes/tools, + | | gated via is_needed() for hooks/assets + | + |- Modeler (RestRouteProvider + ToolContributor) + | |- Scans {project}/src/models/ for config files + | |- Creates PostType or Taxonomy instances via ModelFactory + | |- Exposes get_models(), REST routes, and MCP tools + | + |- RestServer + | |- Registers 9 REST routes in saltus-framework/v1/ + | |- Uses ModelRestPolicy + CapabilityPolicy for gating + | + |- MCP/Abilities + |- 18 MCP tools registered as saltus/* abilities + |- Middleware pipeline (permission, validation, audit, cache, rate-limit) + |- Backed by REST controllers +``` + +## Key Components + +### Core (`src/Core.php`) +The main entry point. Initializes the service container, registers all services, and hooks into WordPress's `init` action. + +### Modeler (`src/Modeler.php`) +Loads model configuration files from the project's `src/models/` directory and registers them as WordPress post types and taxonomies. + +### Service Container (`src/Infrastructure/Container/`) +A PSR-11-compatible dependency injection container with support for: +- Service registration and resolution +- Conditional service loading via `is_needed()` +- Factory-based instantiation +- Assembly-based wiring + +### REST API (`src/Rest/`) +Nine REST controllers registered under the `saltus-framework/v1/` namespace, covering models, posts, settings, meta, and more. + +### MCP/Abilities (`src/MCP/`) +WordPress-native MCP/Abilities integration exposing 18 tools through a middleware pipeline with caching, rate limiting, audit logging, and permission gating. + +## Design Decisions + +### Two-Pass Service Registration +REST route providers and MCP tool contributors are registered unconditionally during plugin boot to ensure endpoints appear in the WordPress REST index. All other services (admin screens, scripts, frontend hooks) are gated behind `is_needed()` to avoid loading unnecessary code. + +### Middleware Pipeline +Permission, validation, error formatting, audit, cache, and rate-limit logic each exist in exactly one place — a composable middleware pipeline shared by both REST and MCP paths. From b86fd710446a1521c7861b68094469a6efbb1a42 Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Tue, 21 Jul 2026 09:33:23 +0800 Subject: [PATCH 53/78] docs(guides): add build page --- docs/guides/build.md | 57 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 docs/guides/build.md diff --git a/docs/guides/build.md b/docs/guides/build.md new file mode 100644 index 0000000..d8f6b49 --- /dev/null +++ b/docs/guides/build.md @@ -0,0 +1,57 @@ +# Build & Setup + +## Requirements + +- PHP 7.4 or higher +- Composer + +## Installation + +```bash +composer install +``` + +## Quality Checks + +```bash +# Run tests +composer test + +# Static analysis +composer phpstan + +# Coding standards +composer phpcs + +# Validate composer +composer validate --strict +``` + +## Documentation + +```bash +# Generate MCP ability docs +composer docs:mcp + +# Build API docs (requires phpDocumentor) +composer docs:api + +# Build full docs site +npm run docs:build +``` + +## Patching Codestar Framework + +If the Codestar Framework is updated, re-apply custom patches: + +```bash +for f in lib/codestar-framework/patches/*; do git apply "$f"; done +``` + +## Autoloading + +If you add new classes to `lib/codestar-framework/`, regenerate the classmap: + +```bash +composer dump-autoload +``` From 03b3b37c95e54a09c598f29ff113ddab86c03faf Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Tue, 21 Jul 2026 09:33:27 +0800 Subject: [PATCH 54/78] docs(mcp): add overview page --- docs/mcp/index.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 docs/mcp/index.md diff --git a/docs/mcp/index.md b/docs/mcp/index.md new file mode 100644 index 0000000..acc91d9 --- /dev/null +++ b/docs/mcp/index.md @@ -0,0 +1,45 @@ +--- +title: MCP/Abilities Overview +--- + +# MCP/Abilities Overview + +Saltus Framework exposes its AI-facing tool surface through WordPress-native MCP/Abilities. Native WordPress MCP clients can discover and call the `saltus/*` abilities directly from the active plugin. + +## Quick Start + +Install and activate the plugin that uses Saltus Framework on a WordPress version with the Abilities API. Saltus registers its abilities during `wp_abilities_api_init`; clients that understand WordPress-native MCP/Abilities can discover the `saltus/*` tools from WordPress. + +## Available Tools + +| Tool | Description | +|------|-------------| +| `get_health` | Framework health, version, error rate, latency, cache, and rate-limit status | +| `list_models` | List all registered CPTs and taxonomies | +| `get_model` | Get details of a specific post type or taxonomy | +| `list_posts` | Query posts with filters (status, search, pagination) | +| `get_post` | Get a single post with all fields and meta | +| `create_post` | Create a new post in any CPT | +| `update_post` | Update an existing post's fields and meta | +| `delete_post` | Trash or force delete a post | +| `list_terms` | List terms from a taxonomy | +| `create_term` | Create a new term in a taxonomy | +| `duplicate_post` | Duplicate a WordPress post | +| `export_post` | Export a post as WXR | +| `get_settings` | Get Saltus settings for a post type | +| `update_settings` | Update Saltus settings for a post type | +| `reorder_posts` | Batch update post menu order | +| `list_meta_fields` | Discover Saltus meta field definitions across all registered CPTs | +| `get_meta_fields` | Get Saltus meta field definitions for a post type | +| `update_meta_fields` | Update meta field values for a post | + +## Requirements + +- WordPress 7.0+ with the Abilities API +- A WordPress-native MCP/Abilities client +- The plugin using Saltus Framework must be active + +## Further Reading + +- [Abilities Reference](/mcp/abilities) — full parameter details for every tool +- [Client Integration](/mcp/clients) — integration guide, workflow, and anti-patterns From a797ac8e48dc03e76cfa5ae88405e31fb7f86163 Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Tue, 21 Jul 2026 09:33:31 +0800 Subject: [PATCH 55/78] docs(mcp): add abilities reference page --- docs/mcp/abilities.md | 344 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 docs/mcp/abilities.md diff --git a/docs/mcp/abilities.md b/docs/mcp/abilities.md new file mode 100644 index 0000000..41dab32 --- /dev/null +++ b/docs/mcp/abilities.md @@ -0,0 +1,344 @@ +# MCP Ability Reference + + + +Saltus Framework exposes 18 WordPress-native MCP/Abilities tools. + +| Tool | Ability | REST request | Description | +|------|---------|--------------|-------------| +| `create_post` | `saltus/create-post` | `POST /wp/v2/posts` | Create a new post in any registered Custom Post Type | +| `create_term` | `saltus/create-term` | `POST /wp/v2/{taxonomy_rest_base}` | Create a new term in a taxonomy | +| `delete_post` | `saltus/delete-post` | `DELETE /wp/v2/posts/123` | Delete (trash or force delete) a post by ID | +| `duplicate_post` | `saltus/duplicate-post` | `POST /saltus-framework/v1/duplicate/123` | Duplicate a WordPress post, creating a copy with "(Copy)" appended to the title | +| `export_post` | `saltus/export-post` | `GET /saltus-framework/v1/export/123` | Export a WordPress post as WXR (WordPress eXtended RSS) for import into another site | +| `get_health` | `saltus/get-health` | `GET /saltus-framework/v1/health` | Get Saltus Framework health, version, audit error rate, latency, cache, and rate limit status | +| `get_meta_fields` | `saltus/get-meta-fields` | `GET /saltus-framework/v1/meta/{post_type}` | Get the meta field definitions for a post type as configured in the Saltus Framework model | +| `get_model` | `saltus/get-model` | `GET /saltus-framework/v1/models/{slug}` | Get details of a specific Custom Post Type or Taxonomy by slug | +| `get_post` | `saltus/get-post` | `GET /wp/v2/posts/123` | Get a single post by ID with all fields and meta data | +| `get_settings` | `saltus/get-settings` | `GET /saltus-framework/v1/settings/{post_type}` | Get the Saltus Framework settings for a specific post type | +| `list_meta_fields` | `saltus/list-meta-fields` | `GET /saltus-framework/v1/meta` | List model-defined meta field definitions for all registered Saltus post types | +| `list_models` | `saltus/list-models` | `GET /saltus-framework/v1/models` | List all registered Custom Post Types and Taxonomies on the WordPress site | +| `list_posts` | `saltus/list-posts` | `GET /wp/v2/posts` | Query posts from a Custom Post Type with optional filters | +| `list_terms` | `saltus/list-terms` | `GET /wp/v2/{taxonomy_rest_base}` | List terms from a taxonomy (categories, tags, or custom taxonomies) | +| `reorder_posts` | `saltus/reorder-posts` | `POST /saltus-framework/v1/reorder` | Reorder multiple posts by updating their menu_order values in a single batch operation | +| `update_meta_fields` | `saltus/update-meta-fields` | `PUT /saltus-framework/v1/meta/{post_type}/123` | Update meta fields for a specific post of a registered Saltus post type | +| `update_post` | `saltus/update-post` | `PUT /wp/v2/posts/123` | Update an existing post's fields and meta data | +| `update_settings` | `saltus/update-settings` | `PUT /saltus-framework/v1/settings/{post_type}` | Update the Saltus Framework settings for a specific post type | + +## `create_post` + +Create a new post in any registered Custom Post Type + +- Ability: `saltus/create-post` +- REST request: `POST /wp/v2/posts` +- REST capability: `none` +- Cacheable: `no` +- Cache TTL: `n/a` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `post_type` | `string` | no | `posts` | The post type slug (e.g., "posts", "page", "product") | +| `title` | `string` | yes | | The post title | +| `content` | `string` | no | | The post content (HTML or raw text) | +| `excerpt` | `string` | no | | The post excerpt | +| `slug` | `string` | no | | URL slug | +| `status` | `string` | no | `draft` | Post status | +| `meta` | `object` | no | | Meta fields as key-value pairs | +| `terms` | `object` | no | | Taxonomy terms as {taxonomy: [term_id, ...]} | + +## `create_term` + +Create a new term in a taxonomy + +- Ability: `saltus/create-term` +- REST request: `POST /wp/v2/{taxonomy_rest_base}` +- REST capability: `none` +- Cacheable: `no` +- Cache TTL: `n/a` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `taxonomy` | `string` | yes | | The taxonomy slug (e.g., "categories", "tags") | +| `name` | `string` | yes | | The term name | +| `slug` | `string` | no | | URL slug (auto-generated if not provided) | +| `description` | `string` | no | | Term description | +| `parent` | `number` | no | | Parent term ID (for hierarchical taxonomies) | + +## `delete_post` + +Delete (trash or force delete) a post by ID + +- Ability: `saltus/delete-post` +- REST request: `DELETE /wp/v2/posts/123` +- REST capability: `none` +- Cacheable: `no` +- Cache TTL: `n/a` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `post_id` | `number` | yes | | The post ID to delete | +| `post_type` | `string` | no | `posts` | The post type slug | +| `force` | `boolean` | no | `false` | Whether to force delete (skip trash) | + +## `duplicate_post` + +Duplicate a WordPress post, creating a copy with "(Copy)" appended to the title + +- Ability: `saltus/duplicate-post` +- REST request: `POST /saltus-framework/v1/duplicate/123` +- REST capability: `duplicate (post_type)` +- Cacheable: `no` +- Cache TTL: `n/a` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `post_id` | `number` | yes | | The ID of the post to duplicate | + +## `export_post` + +Export a WordPress post as WXR (WordPress eXtended RSS) for import into another site + +- Ability: `saltus/export-post` +- REST request: `GET /saltus-framework/v1/export/123` +- REST capability: `export (post_type)` +- Cacheable: `no` +- Cache TTL: `n/a` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `post_id` | `number` | yes | | The ID of the post to export | + +## `get_health` + +Get Saltus Framework health, version, audit error rate, latency, cache, and rate limit status + +- Ability: `saltus/get-health` +- REST request: `GET /saltus-framework/v1/health` +- REST capability: `health` +- Cacheable: `yes` +- Cache TTL: `60s` + +### Parameters + +This tool does not accept parameters. + +## `get_meta_fields` + +Get the meta field definitions for a post type as configured in the Saltus Framework model + +- Ability: `saltus/get-meta-fields` +- REST request: `GET /saltus-framework/v1/meta/{post_type}` +- REST capability: `meta (post_type)` +- Cacheable: `yes` +- Cache TTL: `600s` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `post_type` | `string` | yes | | The post type slug to get meta fields for | + +## `get_model` + +Get details of a specific Custom Post Type or Taxonomy by slug + +- Ability: `saltus/get-model` +- REST request: `GET /saltus-framework/v1/models/{slug}` +- REST capability: `models` +- Cacheable: `yes` +- Cache TTL: `600s` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `slug` | `string` | yes | | The slug of the post type or taxonomy (e.g., "post", "page", "product") | + +## `get_post` + +Get a single post by ID with all fields and meta data + +- Ability: `saltus/get-post` +- REST request: `GET /wp/v2/posts/123` +- REST capability: `none` +- Cacheable: `yes` +- Cache TTL: `300s` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `post_id` | `number` | yes | | The post ID | +| `post_type` | `string` | no | `posts` | The post type slug (defaults to "posts") | + +## `get_settings` + +Get the Saltus Framework settings for a specific post type + +- Ability: `saltus/get-settings` +- REST request: `GET /saltus-framework/v1/settings/{post_type}` +- REST capability: `settings (post_type)` +- Cacheable: `yes` +- Cache TTL: `300s` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `post_type` | `string` | yes | | The post type slug to get settings for | + +## `list_meta_fields` + +List model-defined meta field definitions for all registered Saltus post types + +- Ability: `saltus/list-meta-fields` +- REST request: `GET /saltus-framework/v1/meta` +- REST capability: `meta (post_type)` +- Cacheable: `yes` +- Cache TTL: `600s` + +### Parameters + +This tool does not accept parameters. + +## `list_models` + +List all registered Custom Post Types and Taxonomies on the WordPress site + +- Ability: `saltus/list-models` +- REST request: `GET /saltus-framework/v1/models` +- REST capability: `models` +- Cacheable: `yes` +- Cache TTL: `600s` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `type` | `string` | no | `all` | Filter by type: post_types, taxonomies, or all (default) | + +## `list_posts` + +Query posts from a Custom Post Type with optional filters + +- Ability: `saltus/list-posts` +- REST request: `GET /wp/v2/posts` +- REST capability: `none` +- Cacheable: `yes` +- Cache TTL: `300s` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `post_type` | `string` | no | `posts` | The post type slug (e.g., "posts", "page", "product") | +| `status` | `string` | no | `publish` | Post status filter (publish, draft, pending, private, trash, any) | +| `search` | `string` | no | | Search term | +| `per_page` | `number` | no | `20` | Number of posts per page (max 100) | +| `page` | `number` | no | `1` | Page number | +| `orderby` | `string` | no | `date` | Sort field (date, title, id, modified, menu_order) | +| `order` | `string` | no | `desc` | Sort order | +| `terms` | `object` | no | | Taxonomy term filters as {taxonomy_rest_base: [term_id, ...]} | + +## `list_terms` + +List terms from a taxonomy (categories, tags, or custom taxonomies) + +- Ability: `saltus/list-terms` +- REST request: `GET /wp/v2/{taxonomy_rest_base}` +- REST capability: `none` +- Cacheable: `yes` +- Cache TTL: `300s` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `taxonomy` | `string` | yes | | The taxonomy slug (e.g., "categories", "tags", or custom) | +| `per_page` | `number` | no | `50` | Number of terms per page (max 100) | +| `search` | `string` | no | | Search term | +| `hide_empty` | `boolean` | no | `false` | Whether to hide terms with no posts | + +## `reorder_posts` + +Reorder multiple posts by updating their menu_order values in a single batch operation + +- Ability: `saltus/reorder-posts` +- REST request: `POST /saltus-framework/v1/reorder` +- REST capability: `reorder (post_type)` +- Cacheable: `no` +- Cache TTL: `n/a` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `items` | `array` | yes | | Array of objects with "id" (post ID) and "menu_order" (integer position) | + +## `update_meta_fields` + +Update meta fields for a specific post of a registered Saltus post type + +- Ability: `saltus/update-meta-fields` +- REST request: `PUT /saltus-framework/v1/meta/{post_type}/123` +- REST capability: `meta (post_type)` +- Cacheable: `no` +- Cache TTL: `n/a` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `post_id` | `number` | yes | | The post ID to update meta fields for | +| `post_type` | `string` | yes | | The post type slug | +| `meta` | `object` | yes | | Meta fields to update as key-value pairs | + +## `update_post` + +Update an existing post's fields and meta data + +- Ability: `saltus/update-post` +- REST request: `PUT /wp/v2/posts/123` +- REST capability: `none` +- Cacheable: `no` +- Cache TTL: `n/a` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `post_id` | `number` | yes | | The post ID to update | +| `post_type` | `string` | no | `posts` | The post type slug | +| `title` | `string` | no | | New post title | +| `content` | `string` | no | | New post content (HTML or raw text) | +| `excerpt` | `string` | no | | New post excerpt | +| `slug` | `string` | no | | New URL slug | +| `status` | `string` | no | | New post status | +| `meta` | `object` | no | | Meta fields to update as key-value pairs | + +## `update_settings` + +Update the Saltus Framework settings for a specific post type + +- Ability: `saltus/update-settings` +- REST request: `PUT /saltus-framework/v1/settings/{post_type}` +- REST capability: `settings (post_type)` +- Cacheable: `no` +- Cache TTL: `n/a` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `post_type` | `string` | yes | | The post type slug to update settings for | +| `settings` | `object` | yes | | The settings data to update (key-value pairs) | From f97bebed08b5c49f92b63e4290cc4b630775552a Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Tue, 21 Jul 2026 09:33:35 +0800 Subject: [PATCH 56/78] docs(mcp): add client integration guide --- docs/mcp/clients.md | 289 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 docs/mcp/clients.md diff --git a/docs/mcp/clients.md b/docs/mcp/clients.md new file mode 100644 index 0000000..13eab69 --- /dev/null +++ b/docs/mcp/clients.md @@ -0,0 +1,289 @@ +# MCP Client Integration Guide + +This guide is for WordPress-native MCP/Abilities clients that consume Saltus Framework abilities from an active WordPress site. + +Saltus registers `saltus/*` abilities inside WordPress. Clients should treat the ability definitions as the source of truth for available tools, input schemas, permissions, and transport metadata. + +## Client Goals + +A good Saltus MCP client should: + +- discover available `saltus/*` abilities before planning actions +- check site health before making a workflow plan +- inspect models and meta fields before reading or writing content +- use normalized metadata paths when reasoning about nested custom fields +- handle WordPress capability failures without retry loops +- respect rate limits and cacheable read operations +- ask for explicit user confirmation before destructive or broad writes + +## Recommended Call Flow + +Use this sequence for most client sessions: + +1. Call `saltus/get-health`. +2. Call `saltus/list-models`. +3. Call `saltus/list-meta-fields`. +4. Choose the relevant model. +5. Call `saltus/get-model` or `saltus/get-meta-fields` for model-specific detail. +6. Read content with `saltus/list-posts`, `saltus/get-post`, `saltus/list-terms`, or settings tools. +7. Propose changes to the user. +8. Execute writes only after the target model, fields, and permissions are clear. + +For narrow workflows where the client already knows the post type, it can skip the aggregate metadata call and use `saltus/get-meta-fields` directly. + +## Discovery + +Clients should discover abilities from WordPress and filter for the `saltus/` prefix. + +Every Saltus ability includes metadata similar to: + +```json +{ + "meta": { + "mcp_tool": "list_models", + "namespace": "saltus-framework/v1", + "transport": "wordpress-rest", + "show_in_rest": true + } +} +``` + +Use `meta.mcp_tool` for user-facing tool names and logs. Use the ability name, such as `saltus/list-models`, for native client execution. + +## Health First + +Call `saltus/get-health` at the start of a session. A healthy response tells the client that Saltus' runtime controls are available and gives recent audit-derived error and latency information. + +Suggested handling: + +| Health signal | Client behavior | +|---------------|-----------------| +| `status: ok` | Continue normally | +| `status: degraded` | Prefer read-only planning and explain the degraded state before writes | +| High `error_rate` | Avoid repeated retries; inspect permission and input errors | +| High latency | Reduce broad listing calls and keep page sizes modest | +| Rate limit enabled | Respect rate-limit errors and retry-after data | + +Health is framework-scoped. It does not prove that a specific model or write operation is available. + +## Model Discovery + +Use `saltus/list-models` to discover post types and taxonomies exposed by Saltus. Clients should not assume a model exists based only on a user phrase. + +Recommended behavior: + +- Map user language to model names after reading model labels and slugs. +- Prefer exact model slugs when calling tools. +- If multiple models are plausible, ask the user to choose. +- Treat missing models as configuration or permission issues, not as empty content. + +## Metadata Discovery + +Use `saltus/list-meta-fields` for site-wide field discovery. Use `saltus/get-meta-fields` for one post type. + +Saltus returns both raw and normalized metadata: + +- `meta`: raw Saltus/Codestar model configuration +- `normalized.fields`: flattened field paths for client reasoning +- `normalized.rest_meta_keys`: REST-writable roots and type information + +Clients should prefer `normalized.fields` when explaining or mapping field-level work. + +Example normalized paths: + +```text +points_info.coordinates.latitude +points_info.coordinates.longitude +points_info.tooltipContent +``` + +When writing post meta, clients should map the desired field path back to its writable REST meta root. If a field is nested in serialized meta, update the containing structure carefully rather than sending only the leaf path. + +## Safe Read Patterns + +Use list operations before single-item operations: + +1. `saltus/list-posts` with `post_type`, `search`, `status`, and pagination. +2. Ask the user to confirm the target when the search result is ambiguous. +3. `saltus/get-post` with the confirmed `post_id`. + +Keep list queries small. Use `per_page` values that fit the user's task instead of pulling large collections by default. + +For terms: + +1. Discover taxonomy models with `saltus/list-models`. +2. Call `saltus/list-terms` with the taxonomy slug or REST base expected by the tool. +3. Use returned term IDs for post create/update calls when needed. + +## Safe Write Patterns + +Before creating or updating content, clients should know: + +- target post type +- target post ID for updates/deletes +- writable meta roots +- expected field shape +- current user intent +- relevant capability outcome + +Recommended write flow: + +1. Read current model and metadata. +2. Read the target post or settings. +3. Build a minimal patch. +4. Summarize the planned mutation to the user. +5. Execute the mutation. +6. Read the object again to confirm the result. + +Avoid broad writes such as updating many posts from one instruction unless the client can show the exact target list and the user confirms it. + +## Destructive Actions + +`saltus/delete-post`, `saltus/reorder-posts`, and settings updates can materially change the site. Clients should require explicit confirmation for: + +- force deletion +- bulk deletion +- reordering more than a small visible set +- settings updates +- writes to serialized or nested meta + +Prefer trashing over force deletion unless the user explicitly asks for permanent deletion. + +## Permission Failures + +Saltus permissions are WordPress permissions. Clients should not try to bypass them. + +Common responses: + +| Failure | Likely cause | Client response | +|---------|--------------|-----------------| +| `rest_forbidden` | Current user lacks capability | Explain the required access and stop | +| `invalid_params` | Missing or malformed arguments | Fix arguments once, then retry | +| model not found | Model is not registered, not REST-enabled, or not visible to this user | Ask for a different model or admin configuration | +| write denied | User can read but not mutate the target | Offer a read-only summary instead | + +Do not retry permission failures repeatedly. They are usually stable until the user's role or model configuration changes. + +## Rate Limits + +Saltus rate limiting protects WordPress from excessive ability calls. When a call returns a rate-limit error, clients should respect the error data and wait before retrying. + +Recommended behavior: + +- Stop parallel calls after the first rate-limit error. +- Use the returned `retry_after` value when available. +- Prefer cached or already-read context while waiting. +- Avoid broad discovery loops that repeatedly call the same list tools. + +## Caching + +Saltus may cache read-only ability responses in WordPress transients. Clients can still call read tools normally, but should understand that repeated calls may return cached data. + +For workflows that must confirm a mutation: + +1. Execute the write. +2. Let Saltus clear its MCP cache. +3. Read the changed object again. + +Mutating Saltus tools clear the MCP cache after execution. + +## Client Planning Heuristics + +Use these heuristics when planning autonomous workflows: + +| User intent | First tools | +|-------------|-------------| +| "What content types are available?" | `saltus/get-health`, `saltus/list-models` | +| "Show me entries for X" | `saltus/list-models`, `saltus/list-posts` | +| "Edit field Y on item Z" | `saltus/list-models`, `saltus/get-meta-fields`, `saltus/list-posts`, `saltus/get-post` | +| "Create a new X" | `saltus/list-models`, `saltus/get-meta-fields`, `saltus/create-post` | +| "Change plugin settings" | `saltus/list-models`, `saltus/get-settings`, `saltus/update-settings` | +| "Export this item" | `saltus/list-posts`, `saltus/get-post`, `saltus/export-post` | +| "Reorder items" | `saltus/list-posts`, user confirmation, `saltus/reorder-posts` | + +## Example Workflows + +### Inspect Available Saltus Content + +1. Call `saltus/get-health`. +2. Call `saltus/list-models` with `type: "all"`. +3. Summarize exposed post types and taxonomies. +4. Mention if no models are visible or if health is degraded. + +### Update a Custom Meta Field + +1. Call `saltus/list-models`. +2. Identify the post type. +3. Call `saltus/get-meta-fields`. +4. Find the normalized field path. +5. Call `saltus/list-posts` to locate the item. +6. Call `saltus/get-post`. +7. Build a minimal meta update. +8. Ask for confirmation. +9. Call `saltus/update-post`. +10. Call `saltus/get-post` again and report the confirmed value. + +### Create a New CPT Entry + +1. Call `saltus/list-models`. +2. Call `saltus/get-meta-fields` for the chosen post type. +3. Collect required title/content/meta/term data from the user. +4. Call `saltus/create-post`. +5. Read the new post and summarize its ID, status, title, and important meta. + +### Diagnose Client Errors + +1. Call `saltus/get-health`. +2. Check recent error rate and latency. +3. Confirm the requested ability exists. +4. Confirm the target model is visible through `saltus/list-models`. +5. Confirm required parameters match `docs/MCP-ABILITIES.md` or the discovered input schema. +6. Stop if the error is permission-related. + +## Editor And VS Code Guidance + +For editor agents and future VS Code integrations: + +- Show discovered Saltus models before offering write operations. +- Show the exact post IDs or setting keys affected by a mutation. +- Surface normalized meta paths in pickers/autocomplete. +- Keep ability call logs visible enough for debugging. +- Prefer staged edits or previews before `update_post`, `update_settings`, `delete_post`, or `reorder_posts`. +- Use `get_health` as a connection/status check in the extension UI. + +## Prompt Guidance For Clients + +Clients can improve reliability by grounding tool use in a short internal plan: + +```text +First check Saltus health. Then list models. Then discover metadata for the target post type. Do not write until the target post ID, field path, and new value are confirmed. +``` + +For destructive work: + +```text +Before deletion or force deletion, show the exact post ID, title, post type, and deletion mode. Require explicit confirmation. +``` + +For nested meta: + +```text +Use normalized field paths for reasoning, but write through the REST meta root reported by Saltus. Preserve sibling fields in serialized structures. +``` + +## Anti-Patterns + +Avoid these client behaviors: + +- guessing post type slugs without `list_models` +- writing meta before checking normalized metadata +- retrying permission failures +- using large list queries as a discovery shortcut +- force deleting without explicit confirmation +- treating health `ok` as proof that all model-scoped capabilities are enabled +- assuming every Saltus model allows every REST-backed capability + +## Reference +- Main MCP docs: [MCP Overview](../mcp/index.md) + +- Generated ability reference: [Abilities Reference](../mcp/abilities.md) From 5bb7cbac656ad5026ce77434de7fda567ce657ee Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Tue, 21 Jul 2026 09:33:38 +0800 Subject: [PATCH 57/78] docs(api): add API reference landing page --- docs/api/index.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 docs/api/index.md diff --git a/docs/api/index.md b/docs/api/index.md new file mode 100644 index 0000000..c2b719f --- /dev/null +++ b/docs/api/index.md @@ -0,0 +1,33 @@ +--- +title: API Reference +--- + +# API Reference + +This section contains auto-generated API documentation for the Saltus Framework's public interfaces and classes. + +## Overview + +The API reference is generated from PHPDoc annotations using phpDocumentor. It covers the public API surface — interfaces, classes, and methods tagged with `@api`. + +## Key Namespaces + +| Namespace | Description | +|-----------|-------------| +| `Saltus\WP\Framework` | Core entry point and modeler | +| `Saltus\WP\Framework\Models` | Model interface, PostType, Taxonomy | +| `Saltus\WP\Framework\Rest` | REST controllers, route providers, policies | +| `Saltus\WP\Framework\MCP` | MCP/Abilities infrastructure | +| `Saltus\WP\Framework\MCP\Tools` | MCP tool interfaces and implementations | +| `Saltus\WP\Framework\Infrastructure\Container` | DI container interfaces | +| `Saltus\WP\Framework\Infrastructure\Plugin` | Plugin lifecycle interfaces | +| `Saltus\WP\Framework\Infrastructure\Service` | Service framework interfaces | +| `Saltus\WP\Framework\Features` | Feature service implementations | + +## Generating the API Docs + +```bash +composer docs:api +``` + +This requires phpDocumentor to be installed. See the [Build Guide](/guides/build) for setup instructions. From 08a7f72e00bbdb8490f41384a3aa45e9eef1f1c0 Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Tue, 21 Jul 2026 09:33:42 +0800 Subject: [PATCH 58/78] infra(docs): add WP-CLI doc generator stub --- bin/generate-wpcli-docs.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 bin/generate-wpcli-docs.php diff --git a/bin/generate-wpcli-docs.php b/bin/generate-wpcli-docs.php new file mode 100644 index 0000000..eb6b1e2 --- /dev/null +++ b/bin/generate-wpcli-docs.php @@ -0,0 +1,16 @@ +#!/usr/bin/env php + Date: Tue, 21 Jul 2026 09:33:55 +0800 Subject: [PATCH 59/78] docs(api): annotate Core Modeler as public api --- src/Core.php | 1 + src/Modeler.php | 1 + 2 files changed, 2 insertions(+) diff --git a/src/Core.php b/src/Core.php index b164a95..4d3b779 100644 --- a/src/Core.php +++ b/src/Core.php @@ -3,6 +3,7 @@ * Saltus Framework * * @version 2.0.0 + * @api */ namespace Saltus\WP\Framework; diff --git a/src/Modeler.php b/src/Modeler.php index ec86cf3..ade0cc2 100644 --- a/src/Modeler.php +++ b/src/Modeler.php @@ -3,6 +3,7 @@ * Loads paths and models from the paths * * This is a simplified version of soberwp/Models + * @api */ namespace Saltus\WP\Framework; From 0ddde10cbedf656e76bc16abddc1741eaec2dfe8 Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Tue, 21 Jul 2026 09:34:00 +0800 Subject: [PATCH 60/78] docs(api): annotate Models namespace as public api --- src/Models/BaseModel.php | 3 +++ src/Models/Config/NoFile.php | 3 +++ src/Models/Model.php | 3 +++ src/Models/ModelFactory.php | 3 +++ src/Models/PostType.php | 1 + src/Models/Taxonomy.php | 1 + 6 files changed, 14 insertions(+) diff --git a/src/Models/BaseModel.php b/src/Models/BaseModel.php index 4019189..b35b380 100644 --- a/src/Models/BaseModel.php +++ b/src/Models/BaseModel.php @@ -3,6 +3,9 @@ use Noodlehaus\AbstractConfig; +/** + * @api + */ abstract class BaseModel { /** diff --git a/src/Models/Config/NoFile.php b/src/Models/Config/NoFile.php index 6c6ea20..fd620e4 100644 --- a/src/Models/Config/NoFile.php +++ b/src/Models/Config/NoFile.php @@ -3,6 +3,9 @@ use Noodlehaus\AbstractConfig; +/** + * @api + */ class NoFile extends AbstractConfig { } diff --git a/src/Models/Model.php b/src/Models/Model.php index e67c072..d04712d 100644 --- a/src/Models/Model.php +++ b/src/Models/Model.php @@ -2,6 +2,9 @@ namespace Saltus\WP\Framework\Models; +/** + * @api + */ interface Model { /** diff --git a/src/Models/ModelFactory.php b/src/Models/ModelFactory.php index 90df088..0307acd 100644 --- a/src/Models/ModelFactory.php +++ b/src/Models/ModelFactory.php @@ -6,6 +6,9 @@ use Saltus\WP\Framework\Infrastructure\Container\Container; use Saltus\WP\Framework\Infrastructure\Service\Processable; +/** + * @api + */ class ModelFactory { /** @var Container */ diff --git a/src/Models/PostType.php b/src/Models/PostType.php index 40fe464..ba40d2b 100644 --- a/src/Models/PostType.php +++ b/src/Models/PostType.php @@ -7,6 +7,7 @@ * This model is used to register a custom post type * * @see https://developer.wordpress.org/reference/functions/register_post_type/ + * @api */ class PostType extends BaseModel implements Model { diff --git a/src/Models/Taxonomy.php b/src/Models/Taxonomy.php index e17771b..c778fa7 100644 --- a/src/Models/Taxonomy.php +++ b/src/Models/Taxonomy.php @@ -7,6 +7,7 @@ * This model is used to register a custom taxonomy * * @see https://developer.wordpress.org/reference/functions/register_taxonomy/ + * @api */ class Taxonomy extends BaseModel implements Model { From 7d7d3c80c6b5b53e5cbce0067df7dcb5ace32f4a Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Tue, 21 Jul 2026 09:34:08 +0800 Subject: [PATCH 61/78] docs(api): annotate Rest infrastructure as public api --- src/Rest/CapabilityPolicy.php | 233 +++++++++++++++++++++++++++++++ src/Rest/ModelRestPolicy.php | 3 + src/Rest/RestRouteDefinition.php | 1 + src/Rest/RestRouteProvider.php | 3 + src/Rest/RestServer.php | 1 + 5 files changed, 241 insertions(+) create mode 100644 src/Rest/CapabilityPolicy.php diff --git a/src/Rest/CapabilityPolicy.php b/src/Rest/CapabilityPolicy.php new file mode 100644 index 0000000..b7b61c7 --- /dev/null +++ b/src/Rest/CapabilityPolicy.php @@ -0,0 +1,233 @@ +modeler = $modeler; + } + + /** + * Check whether any model has the given capability enabled. + * + * @param string $gate The gate key (GATE_REST or GATE_MCP). + * @param string $capability The capability to check. + * @param string|null $model_type Optional model type filter. + * @return bool + */ + public function has_capability( string $gate, string $capability, ?string $model_type = null ): bool { + if ( $capability === self::CAPABILITY_HEALTH ) { + return true; + } + + foreach ( $this->modeler->get_models() as $model ) { + if ( $model_type !== null && $model->get_type() !== $model_type ) { + continue; + } + + if ( $this->is_enabled( $model, $gate, $capability ) ) { + return true; + } + } + + return false; + } + + /** + * Check whether a given model has the capability enabled for the given gate. + * + * @param Model $model The model to check. + * @param string $gate The gate key (GATE_REST or GATE_MCP). + * @param string $capability The capability to check. + * @return bool + */ + public function is_enabled( Model $model, string $gate, string $capability ): bool { + $options = $model->get_options(); + $global_val = null; + if ( array_key_exists( $gate, $options ) ) { + $global_val = (bool) $options[ $gate ]; + } + + if ( $capability === self::CAPABILITY_HEALTH ) { + return true; + } + + if ( $capability === self::CAPABILITY_MODELS ) { + if ( $gate === self::GATE_REST ) { + return $global_val !== false; + } + return $global_val === true; + } + + $config = $model->get_config(); + $feature_val = $this->resolve_feature_value( $config, $gate, $capability ); + + if ( $feature_val !== null ) { + return $feature_val; + } + + return $global_val === true; + } + + /** + * Get models that have the given capability enabled. + * + * @param string $gate The gate key (GATE_REST or GATE_MCP). + * @param string $capability The capability to check. + * @param string|null $model_type Optional model type filter. + * @return array + */ + public function get_enabled_models( string $gate, string $capability, ?string $model_type = null ): array { + $enabled = []; + + foreach ( $this->modeler->get_models() as $name => $model ) { + if ( $model_type !== null && $model->get_type() !== $model_type ) { + continue; + } + + if ( $this->is_enabled( $model, $gate, $capability ) ) { + $enabled[ $name ] = $model; + } + } + + return $enabled; + } + + /** + * Get a model by name from the modeler. + * + * @param string $name + * @return Model|null + */ + public function get_model( string $name ): ?Model { + $models = $this->modeler->get_models(); + + return $models[ $name ] ?? null; + } + + /** + * Check whether a specific post type has the capability enabled. + * + * @param string $post_type + * @param string $gate + * @param string $capability + * @return bool + */ + public function is_post_type_enabled( string $post_type, string $gate, string $capability ): bool { + $model = $this->get_model( $post_type ); + + return $model !== null + && $model->get_type() === 'post_type' + && $this->is_enabled( $model, $gate, $capability ); + } + + /** + * Check whether a specific post has the capability enabled. + * + * @param int $post_id + * @param string $gate + * @param string $capability + * @return bool + */ + public function is_post_enabled( int $post_id, string $gate, string $capability ): bool { + if ( ! function_exists( 'get_post' ) ) { + return false; + } + + $post = get_post( $post_id ); + if ( ! $post ) { + return false; + } + + return $this->is_post_type_enabled( (string) $post->post_type, $gate, $capability ); + } + + /** + * Get the model options array. + * + * @param Model $model + * @return array + */ + public function get_model_args( Model $model ): array { + return $model->get_args(); + } + + /** + * Get the configuration section for a specific capability. + * + * @param array $config + * @param string $capability + * @return mixed + */ + private function get_capability_config( array $config, string $capability ) { + if ( $capability === self::CAPABILITY_META || $capability === self::CAPABILITY_SETTINGS ) { + return $config[ $capability ] ?? null; + } + + $features = $config['features'] ?? []; + if ( ! is_array( $features ) ) { + return null; + } + + $feature_keys = [ + self::CAPABILITY_DUPLICATE => 'duplicate', + self::CAPABILITY_EXPORT => 'single_export', + self::CAPABILITY_REORDER => 'drag_and_drop', + ]; + + $key = $feature_keys[ $capability ] ?? null; + if ( $key === null ) { + return null; + } + + return $features[ $key ] ?? null; + } + + /** + * Resolve whether a capability is enabled from feature config. + * + * @param array $config + * @param string $gate + * @param string $capability + * @return bool|null + */ + private function resolve_feature_value( array $config, string $gate, string $capability ): ?bool { + $section = $this->get_capability_config( $config, $capability ); + if ( $section === null ) { + return null; + } + + if ( ! is_array( $section ) ) { + return (bool) $section; + } + + if ( ! array_key_exists( $gate, $section ) ) { + return true; + } + + return (bool) $section[ $gate ]; + } +} diff --git a/src/Rest/ModelRestPolicy.php b/src/Rest/ModelRestPolicy.php index 095cf40..4e3e35d 100644 --- a/src/Rest/ModelRestPolicy.php +++ b/src/Rest/ModelRestPolicy.php @@ -5,6 +5,9 @@ use Saltus\WP\Framework\Modeler; use Saltus\WP\Framework\Models\Model; +/** + * @api + */ class ModelRestPolicy { public const CAPABILITY_MODELS = 'models'; diff --git a/src/Rest/RestRouteDefinition.php b/src/Rest/RestRouteDefinition.php index c15d1b5..0cc13ee 100644 --- a/src/Rest/RestRouteDefinition.php +++ b/src/Rest/RestRouteDefinition.php @@ -4,6 +4,7 @@ /** * Value object binding a capability, controller, and optional model type to a REST route. + * @api */ class RestRouteDefinition { diff --git a/src/Rest/RestRouteProvider.php b/src/Rest/RestRouteProvider.php index 0c5bc7a..cecefb7 100644 --- a/src/Rest/RestRouteProvider.php +++ b/src/Rest/RestRouteProvider.php @@ -4,6 +4,9 @@ use Saltus\WP\Framework\Modeler; +/** + * @api + */ interface RestRouteProvider { /** diff --git a/src/Rest/RestServer.php b/src/Rest/RestServer.php index d3a20d7..d064f85 100644 --- a/src/Rest/RestServer.php +++ b/src/Rest/RestServer.php @@ -4,6 +4,7 @@ /** * Registers REST routes filtered by ModelRestPolicy capability checks. + * @api */ class RestServer { From 24c1f566358796a765a59ae87ec15151611d3515 Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Tue, 21 Jul 2026 09:34:12 +0800 Subject: [PATCH 62/78] docs(api): annotate Rest controllers as public api --- src/Rest/DuplicateController.php | 1 + src/Rest/ExportController.php | 1 + src/Rest/HealthController.php | 1 + src/Rest/MetaController.php | 1 + src/Rest/ModelsController.php | 1 + src/Rest/ReorderController.php | 1 + src/Rest/SettingsController.php | 1 + 7 files changed, 7 insertions(+) diff --git a/src/Rest/DuplicateController.php b/src/Rest/DuplicateController.php index 9352c82..ddcbe20 100644 --- a/src/Rest/DuplicateController.php +++ b/src/Rest/DuplicateController.php @@ -12,6 +12,7 @@ /** * REST controller for duplicating posts. + * @api */ class DuplicateController extends WP_REST_Controller { diff --git a/src/Rest/ExportController.php b/src/Rest/ExportController.php index 2d32d02..2112285 100644 --- a/src/Rest/ExportController.php +++ b/src/Rest/ExportController.php @@ -11,6 +11,7 @@ /** * REST controller for exporting posts as WXR. + * @api */ class ExportController extends WP_REST_Controller { diff --git a/src/Rest/HealthController.php b/src/Rest/HealthController.php index 9a97368..4b792f5 100644 --- a/src/Rest/HealthController.php +++ b/src/Rest/HealthController.php @@ -11,6 +11,7 @@ /** * REST controller exposing framework health and MCP runtime metrics. + * @api */ class HealthController extends WP_REST_Controller { use \Saltus\WP\Framework\Infrastructure\Services\FilterAwareTrait; diff --git a/src/Rest/MetaController.php b/src/Rest/MetaController.php index 8d96a0c..bd856b9 100644 --- a/src/Rest/MetaController.php +++ b/src/Rest/MetaController.php @@ -13,6 +13,7 @@ /** * REST controller exposing meta field configuration per post type. + * @api */ class MetaController extends WP_REST_Controller { diff --git a/src/Rest/ModelsController.php b/src/Rest/ModelsController.php index 607892c..fc8b654 100644 --- a/src/Rest/ModelsController.php +++ b/src/Rest/ModelsController.php @@ -14,6 +14,7 @@ /** * REST controller exposing registered Saltus models and their metadata. + * @api */ class ModelsController extends WP_REST_Controller { diff --git a/src/Rest/ReorderController.php b/src/Rest/ReorderController.php index 4e5494d..b9a299c 100644 --- a/src/Rest/ReorderController.php +++ b/src/Rest/ReorderController.php @@ -12,6 +12,7 @@ /** * REST controller for reordering posts via menu_order updates. + * @api */ class ReorderController extends WP_REST_Controller { diff --git a/src/Rest/SettingsController.php b/src/Rest/SettingsController.php index 6d80323..41c5ed1 100644 --- a/src/Rest/SettingsController.php +++ b/src/Rest/SettingsController.php @@ -12,6 +12,7 @@ /** * REST controller for reading and updating per-post-type settings. + * @api */ class SettingsController extends WP_REST_Controller { From 94abe692e343e9bb5377feadc1c02801f39422ca Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Tue, 21 Jul 2026 09:34:20 +0800 Subject: [PATCH 63/78] docs(api): annotate MCP config policy as public api --- src/MCP/MCPConfig.php | 1 + src/MCP/McpPolicy.php | 3 +++ 2 files changed, 4 insertions(+) diff --git a/src/MCP/MCPConfig.php b/src/MCP/MCPConfig.php index 8a2f6a1..2f77d41 100644 --- a/src/MCP/MCPConfig.php +++ b/src/MCP/MCPConfig.php @@ -7,6 +7,7 @@ * All values are filterable so plugins can customise the MCP server name, * REST namespace, ability category, and ability name prefix without * modifying framework source files. + * @api */ class MCPConfig { diff --git a/src/MCP/McpPolicy.php b/src/MCP/McpPolicy.php index 165098d..bbdca96 100644 --- a/src/MCP/McpPolicy.php +++ b/src/MCP/McpPolicy.php @@ -6,6 +6,9 @@ use Saltus\WP\Framework\Models\Model; use Saltus\WP\Framework\Rest\ModelRestPolicy; +/** + * @api + */ class McpPolicy { /** * Modeler instance. From 5856cf37ba1358f63967a66b92ad84277563a04e Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Tue, 21 Jul 2026 09:34:24 +0800 Subject: [PATCH 64/78] docs(api): annotate MCP error factory as public api --- src/MCP/Error/ErrorResponse.php | 120 ++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 src/MCP/Error/ErrorResponse.php diff --git a/src/MCP/Error/ErrorResponse.php b/src/MCP/Error/ErrorResponse.php new file mode 100644 index 0000000..8144c62 --- /dev/null +++ b/src/MCP/Error/ErrorResponse.php @@ -0,0 +1,120 @@ + 403 ]; + if ( $hint !== '' ) { + $data['hint'] = $hint; + } + + return new \WP_Error( 'rest_forbidden', __( 'You do not have permission to perform this action.', 'saltus-framework' ), $data ); + } + + /** + * Resource not found. + * + * @param string $resource_name The resource identifier. + * @param string $hint User-facing resolution hint. + * @return \WP_Error + */ + public static function not_found( string $resource_name = '', string $hint = '' ): \WP_Error { + $data = [ 'status' => 404 ]; + if ( $hint !== '' ) { + $data['hint'] = $hint; + } + + return new \WP_Error( + $resource_name !== '' ? $resource_name . '_not_found' : 'not_found', + __( 'Resource not found.', 'saltus-framework' ), + $data + ); + } + + /** + * Invalid request parameters. + * + * @param string $field The invalid field name. + * @param string $reason Why the field is invalid. + * @param string $hint User-facing resolution hint. + * @return \WP_Error + */ + public static function invalid( string $field = '', string $reason = '', string $hint = '' ): \WP_Error { + $data = [ 'status' => 400 ]; + if ( $field !== '' ) { + $data['field'] = $field; + } + if ( $reason !== '' ) { + $data['reason'] = $reason; + } + if ( $hint !== '' ) { + $data['hint'] = $hint; + } + + return new \WP_Error( 'rest_invalid_param', __( 'Invalid parameter.', 'saltus-framework' ), $data ); + } + + /** + * Rate limited. + * + * @param int $retry_after Seconds to wait before retrying. + * @return \WP_Error + */ + public static function rate_limited( int $retry_after = 60 ): \WP_Error { + return new \WP_Error( + 'rate_limited', + __( 'Rate limit exceeded.', 'saltus-framework' ), + [ + 'status' => 429, + 'retry_after' => $retry_after, + ] + ); + } + + /** + * Internal server error. + * + * @param string $message Error message. + * @param string $hint User-facing resolution hint. + * @return \WP_Error + */ + public static function internal_error( string $message = '', string $hint = '' ): \WP_Error { + $data = [ 'status' => 500 ]; + if ( $hint !== '' ) { + $data['hint'] = $hint; + } + + return new \WP_Error( 'rest_internal_error', $message ?: __( 'Internal server error.', 'saltus-framework' ), $data ); + } + + /** + * REST dispatch error wrapping an upstream WP_Error. + * + * @param \WP_Error $error The upstream error. + * @return \WP_Error + */ + public static function dispatch_error( \WP_Error $error ): \WP_Error { + $data = $error->get_error_data(); + $status = is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 500; + $error_data = is_array( $data ) ? $data : [ 'status' => $status ]; + $error_data['hint'] = $error_data['hint'] ?? __( 'The upstream REST endpoint returned an error. Check the error code and message for details.', 'saltus-framework' ); + + return new \WP_Error( + $error->get_error_code() ?: 'rest_dispatch_error', + $error->get_error_message() ?: __( 'REST dispatch error.', 'saltus-framework' ), + $error_data + ); + } +} From b94b655b5a13b6c1b4da524b3799434689648084 Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Tue, 21 Jul 2026 09:34:29 +0800 Subject: [PATCH 65/78] docs(api): annotate MCP middleware pipeline as public api --- src/MCP/Middleware/AuditMiddleware.php | 102 ++++++++++ src/MCP/Middleware/CacheMiddleware.php | 158 ++++++++++++++++ src/MCP/Middleware/MiddlewareInterface.php | 17 ++ src/MCP/Middleware/MiddlewarePipeline.php | 56 ++++++ src/MCP/Middleware/PermissionMiddleware.php | 198 ++++++++++++++++++++ src/MCP/Middleware/PipelineIntegration.php | 93 +++++++++ src/MCP/Middleware/RateLimitMiddleware.php | 61 ++++++ src/MCP/Middleware/RequestContext.php | 157 ++++++++++++++++ src/MCP/Middleware/ValidationMiddleware.php | 116 ++++++++++++ 9 files changed, 958 insertions(+) create mode 100644 src/MCP/Middleware/AuditMiddleware.php create mode 100644 src/MCP/Middleware/CacheMiddleware.php create mode 100644 src/MCP/Middleware/MiddlewareInterface.php create mode 100644 src/MCP/Middleware/MiddlewarePipeline.php create mode 100644 src/MCP/Middleware/PermissionMiddleware.php create mode 100644 src/MCP/Middleware/PipelineIntegration.php create mode 100644 src/MCP/Middleware/RateLimitMiddleware.php create mode 100644 src/MCP/Middleware/RequestContext.php create mode 100644 src/MCP/Middleware/ValidationMiddleware.php diff --git a/src/MCP/Middleware/AuditMiddleware.php b/src/MCP/Middleware/AuditMiddleware.php new file mode 100644 index 0000000..0f59d1d --- /dev/null +++ b/src/MCP/Middleware/AuditMiddleware.php @@ -0,0 +1,102 @@ +logger = $logger; + } + + /** + * @param RequestContext $context + * @param callable $next + * @return \WP_REST_Response|\WP_Error + */ + public function handle( RequestContext $context, callable $next ) { + $tool_name = $this->resolve_tool_name( $context ); + $args = $this->resolve_args( $context ); + + $entry = new AuditEntry( $tool_name, $args, $this->identifier() ); + + $result = $next( $context ); + + if ( is_wp_error( $result ) ) { + $entry->complete( + 'error', + (string) $result->get_error_code(), + (string) $result->get_error_message() + ); + } else { + $entry->complete( 'success' ); + } + + $this->logger->record( $entry ); + + return $result; + } + + /** + * Resolve the tool or route name for the audit entry. + * + * @param RequestContext $context + * @return string + */ + private function resolve_tool_name( RequestContext $context ): string { + $meta = $context->get_tool_metadata(); + if ( ! empty( $meta['name'] ) && is_string( $meta['name'] ) ) { + return $meta['name']; + } + + $route = $context->get_route(); + if ( $route !== '' ) { + return 'rest:' . $route; + } + + return 'unknown'; + } + + /** + * Resolve the arguments for the audit entry. + * + * @param RequestContext $context + * @return array + */ + private function resolve_args( RequestContext $context ): array { + $args = $context->get_args(); + if ( ! empty( $args ) ) { + return $args; + } + + $request = $context->get_rest_request(); + if ( $request !== null ) { + return $request->get_params(); + } + + return []; + } + + /** + * Resolve a unique identifier for the current user for audit. + * + * @return string + */ + private function identifier(): string { + $identifier = \function_exists( 'get_current_user_id' ) ? 'user:' . (int) \get_current_user_id() : 'user:0'; + if ( $identifier === 'user:0' && isset( $_SERVER['REMOTE_ADDR'] ) ) { + $identifier = 'ip:' . \hash( 'sha256', (string) $_SERVER['REMOTE_ADDR'] ); + } + + return $identifier; + } +} diff --git a/src/MCP/Middleware/CacheMiddleware.php b/src/MCP/Middleware/CacheMiddleware.php new file mode 100644 index 0000000..6af0342 --- /dev/null +++ b/src/MCP/Middleware/CacheMiddleware.php @@ -0,0 +1,158 @@ +cache = $cache; + } + + /** + * @param RequestContext $context + * @param callable $next + * @return \WP_REST_Response|\WP_Error + */ + public function handle( RequestContext $context, callable $next ) { + if ( ! $this->is_get_request( $context ) ) { + $result = $next( $context ); + $this->invalidate_on_mutation( $context ); + return $result; + } + + $cache_key = $this->build_cache_key( $context ); + + $cached = $this->cache->get( $cache_key ); + if ( $cached !== null ) { + return \rest_ensure_response( $cached ); + } + + $result = $next( $context ); + + if ( ! \is_wp_error( $result ) ) { + $ttl = $this->resolve_ttl( $context ); + $data = $result->get_data(); + if ( is_array( $data ) ) { + $this->cache->set( $cache_key, $data, $ttl ); + } + } + + return $result; + } + + /** + * Check whether the current request is a GET. + * + * @param RequestContext $context + * @return bool + */ + private function is_get_request( RequestContext $context ): bool { + $meta = $context->get_tool_metadata(); + if ( ! empty( $meta ) ) { + return true; + } + + $request = $context->get_rest_request(); + if ( $request === null ) { + return false; + } + + return $request->get_method() === 'GET'; + } + + /** + * Build a unique cache key from the context. + * + * @param RequestContext $context + * @return string + */ + private function build_cache_key( RequestContext $context ): string { + $tool_name = $this->resolve_name( $context ); + $args = $context->get_args(); + + $payload = [ + 'tool' => $tool_name, + 'args' => $args, + 'user' => \function_exists( 'get_current_user_id' ) ? (int) \get_current_user_id() : 0, + 'locale' => \function_exists( 'get_locale' ) ? \get_locale() : '', + ]; + + return 'saltus_mcp_' . \hash( 'sha256', $this->encode( $payload ) ); + } + + /** + * Resolve a name for cache key generation. + * + * @param RequestContext $context + * @return string + */ + private function resolve_name( RequestContext $context ): string { + $meta = $context->get_tool_metadata(); + if ( ! empty( $meta['name'] ) && is_string( $meta['name'] ) ) { + return $meta['name']; + } + + $route = $context->get_route(); + if ( $route !== '' ) { + return $route; + } + + return 'unknown'; + } + + /** + * Resolve the cache TTL from context attributes. + * + * @param RequestContext $context + * @return int + */ + private function resolve_ttl( RequestContext $context ): int { + $ttl = $context->get_attribute( 'cache_ttl' ); + if ( is_int( $ttl ) && $ttl > 0 ) { + return $ttl; + } + + return 300; + } + + /** + * Invalidate cache on mutating requests. + * + * @param RequestContext $context + */ + private function invalidate_on_mutation( RequestContext $context ): void { + if ( \function_exists( 'wp_defer_term_counting' ) && \function_exists( 'wp_defer_comment_counting' ) ) { + if ( defined( 'WP_IMPORTING' ) && WP_IMPORTING ) { + return; + } + } + + $this->cache->clear(); + } + + /** + * Encode a payload as JSON for cache key generation. + * + * @param array $payload + * @return string + */ + private function encode( array $payload ): string { + if ( \function_exists( 'wp_json_encode' ) ) { + $encoded = \wp_json_encode( $payload ); + return \is_string( $encoded ) ? $encoded : ''; + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode + $encoded = \json_encode( $payload ); + return \is_string( $encoded ) ? $encoded : ''; + } +} diff --git a/src/MCP/Middleware/MiddlewareInterface.php b/src/MCP/Middleware/MiddlewareInterface.php new file mode 100644 index 0000000..8e8d48a --- /dev/null +++ b/src/MCP/Middleware/MiddlewareInterface.php @@ -0,0 +1,17 @@ + */ + private array $stages = []; + + /** + * Register a middleware stage. + * + * @param MiddlewareInterface $middleware + */ + public function add( MiddlewareInterface $middleware ): void { + $this->stages[] = $middleware; + } + + /** + * Execute the middleware chain with the given context and dispatch handler. + * + * @param RequestContext $context The mutable request context. + * @param callable $dispatch The innermost dispatch handler. + * @return \WP_REST_Response|\WP_Error + */ + public function execute( RequestContext $context, callable $dispatch ) { + $chain = $this->build_chain( $dispatch ); + return $chain( $context ); + } + + /** + * Build the middleware chain as nested closures. + * + * @param callable $dispatch + * @return callable + */ + private function build_chain( callable $dispatch ): callable { + $chain = $dispatch; + + foreach ( array_reverse( $this->stages ) as $stage ) { + $next = $chain; + $chain = function ( RequestContext $context ) use ( $stage, $next ) { + return $stage->handle( $context, $next ); + }; + } + + return $chain; + } +} diff --git a/src/MCP/Middleware/PermissionMiddleware.php b/src/MCP/Middleware/PermissionMiddleware.php new file mode 100644 index 0000000..1af1c51 --- /dev/null +++ b/src/MCP/Middleware/PermissionMiddleware.php @@ -0,0 +1,198 @@ +policy = $policy; + } + + /** + * @param RequestContext $context + * @param callable $next + * @return \WP_REST_Response|\WP_Error + */ + public function handle( RequestContext $context, callable $next ) { + $tool_meta = $context->get_tool_metadata(); + + if ( ! empty( $tool_meta ) ) { + return $this->handle_tool( $context, $next, $tool_meta ); + } + + return $this->handle_rest( $context, $next ); + } + + /** + * Handle permission check for MCP tool execution. + * + * @param RequestContext $context + * @param callable $next + * @param array $tool_meta + * @return \WP_REST_Response|\WP_Error + */ + private function handle_tool( RequestContext $context, callable $next, array $tool_meta ) { + $has_permission = $tool_meta['has_permission'] ?? null; + + if ( is_callable( $has_permission ) ) { + $allowed = $has_permission( $context->get_args() ); + if ( ! $allowed ) { + return \Saltus\WP\Framework\MCP\Error\ErrorResponse::forbidden( + $tool_meta['capability'] ?? 'edit_posts', + \__( 'You do not have permission to use this tool.', 'saltus-framework' ) + ); + } + } + + return $next( $context ); + } + + /** + * Handle permission check for REST API requests. + * + * @param RequestContext $context + * @param callable $next + * @return \WP_REST_Response|\WP_Error + */ + private function handle_rest( RequestContext $context, callable $next ) { + $request = $context->get_rest_request(); + if ( $request === null ) { + return $next( $context ); + } + + $route = $request->get_route(); + $capability = $this->resolve_capability( $route ); + + if ( $capability === null ) { + return $next( $context ); + } + + if ( ! \function_exists( 'current_user_can' ) ) { + return \Saltus\WP\Framework\MCP\Error\ErrorResponse::internal_error( + \__( 'WordPress capability system is unavailable.', 'saltus-framework' ) + ); + } + + if ( ! \current_user_can( 'edit_posts' ) ) { + return \Saltus\WP\Framework\MCP\Error\ErrorResponse::forbidden( + 'edit_posts', + \__( 'You must have edit_posts capability to access Saltus API.', 'saltus-framework' ) + ); + } + + $model_name = $request->get_param( 'post_type' ); + if ( is_string( $model_name ) && $model_name !== '' ) { + $cap = $this->resolve_model_capability( $model_name ); + if ( ! \current_user_can( $cap ) ) { + return \Saltus\WP\Framework\MCP\Error\ErrorResponse::forbidden( + $cap, + \sprintf( + /* translators: %s: model name */ + \__( "You do not have permission to access model '%s'.", 'saltus-framework' ), + $model_name + ) + ); + } + } + + $model_name = $request->get_param( 'id' ); + if ( is_string( $model_name ) && $model_name !== '' ) { + $post = \get_post( (int) $model_name ); + if ( $post && ! \current_user_can( 'edit_post', $post->ID ) ) { + return \Saltus\WP\Framework\MCP\Error\ErrorResponse::forbidden( + 'edit_post', + \__( 'You do not have permission to edit this post.', 'saltus-framework' ) + ); + } + } + + return $next( $context ); + } + + /** + * Resolve the capability key from a REST route. + * + * @param string $route + * @return string|null + */ + private function resolve_capability( string $route ): ?string { + $route_map = [ + 'models' => CapabilityPolicy::CAPABILITY_MODELS, + 'meta' => CapabilityPolicy::CAPABILITY_META, + 'settings' => CapabilityPolicy::CAPABILITY_SETTINGS, + 'duplicate' => CapabilityPolicy::CAPABILITY_DUPLICATE, + 'export' => CapabilityPolicy::CAPABILITY_EXPORT, + 'reorder' => CapabilityPolicy::CAPABILITY_REORDER, + 'health' => CapabilityPolicy::CAPABILITY_HEALTH, + ]; + + $parts = explode( '/', trim( $route, '/' ) ); + $path = $parts[2] ?? ''; + + return $route_map[ $path ] ?? null; + } + + /** + * Resolve the WordPress edit capability for a model (post type or taxonomy). + * + * @param string $model_name + * @return string + */ + private function resolve_model_capability( string $model_name ): string { + $model = $this->policy->get_model( $model_name ); + if ( $model === null ) { + return 'edit_posts'; + } + + if ( $model->get_type() === 'taxonomy' ) { + return $this->taxonomy_edit_capability( $model_name ); + } + + return $this->post_type_edit_capability( $model_name ); + } + + /** + * @param string $post_type + * @return string + */ + private function post_type_edit_capability( string $post_type ): string { + if ( ! \function_exists( 'get_post_type_object' ) ) { + return 'edit_posts'; + } + + $post_type_object = \get_post_type_object( $post_type ); + if ( is_object( $post_type_object ) && isset( $post_type_object->cap->edit_posts ) && is_string( $post_type_object->cap->edit_posts ) ) { + return $post_type_object->cap->edit_posts; + } + + return 'edit_posts'; + } + + /** + * @param string $taxonomy + * @return string + */ + private function taxonomy_edit_capability( string $taxonomy ): string { + if ( ! \function_exists( 'get_taxonomy' ) ) { + return 'manage_categories'; + } + + $taxonomy_object = \get_taxonomy( $taxonomy ); + if ( is_object( $taxonomy_object ) && isset( $taxonomy_object->cap->manage_terms ) && is_string( $taxonomy_object->cap->manage_terms ) ) { + return $taxonomy_object->cap->manage_terms; + } + + return 'manage_categories'; + } +} diff --git a/src/MCP/Middleware/PipelineIntegration.php b/src/MCP/Middleware/PipelineIntegration.php new file mode 100644 index 0000000..dcb3a75 --- /dev/null +++ b/src/MCP/Middleware/PipelineIntegration.php @@ -0,0 +1,93 @@ +pipeline = $pipeline; + } + + /** + * Register the rest_pre_dispatch hook to run the middleware pipeline. + * + * The pipeline wraps the actual REST dispatch, running permission, + * validation, rate-limit, cache, and audit middleware around it. + */ + public function register_rest_hooks(): void { + if ( ! \function_exists( 'add_filter' ) ) { + return; + } + + \add_filter( 'rest_pre_dispatch', [ $this, 'on_rest_pre_dispatch' ], 10, 3 ); + } + + /** + * Handle rest_pre_dispatch: run the pipeline and return the result. + * + * @param mixed $result + * @param \WP_REST_Server $server + * @param \WP_REST_Request $request + * @return \WP_REST_Response|\WP_Error|null + */ + public function on_rest_pre_dispatch( $result, $server, $request ) { + $context = new RequestContext(); + $context->set_rest_request( $request ); + $context->set_route( (string) $request->get_route() ); + + $params = $request->get_params(); + $context->set_args( $params ); + + return $this->pipeline->execute( + $context, + function ( RequestContext $ctx ) use ( $request ) { + if ( ! \function_exists( 'rest_do_request' ) ) { + return \Saltus\WP\Framework\MCP\Error\ErrorResponse::internal_error( + \__( 'WordPress REST dispatch is not available.', 'saltus-framework' ) + ); + } + + $response = \rest_do_request( $request ); + $ctx->set_response( $response ); + + return $response; + } + ); + } + + /** + * Build the default pipeline with the standard stage ordering. + * + * Order: Permission → Validation → RateLimit → Cache → Audit + * + * @param MiddlewareInterface ...$stages Optional additional stages appended after defaults. + * @return self + */ + public static function with_default_stages( MiddlewareInterface ...$stages ): self { + $pipeline = new MiddlewarePipeline(); + + $defaults = [ + // Permission stage injected by caller (requires CapabilityPolicy) + // Validation stage injected by caller + // RateLimit stage injected by caller + // Cache stage injected by caller + // Audit stage injected by caller + ]; + + foreach ( $stages as $stage ) { + $pipeline->add( $stage ); + } + + return new self( $pipeline ); + } +} diff --git a/src/MCP/Middleware/RateLimitMiddleware.php b/src/MCP/Middleware/RateLimitMiddleware.php new file mode 100644 index 0000000..6b02443 --- /dev/null +++ b/src/MCP/Middleware/RateLimitMiddleware.php @@ -0,0 +1,61 @@ +limiter = $limiter; + } + + /** + * @param RequestContext $context + * @param callable $next + * @return \WP_REST_Response|\WP_Error + */ + public function handle( RequestContext $context, callable $next ) { + $identifier = $this->resolve_identifier( $context ); + + $result = $this->limiter->check( $identifier ); + + if ( ! $result->allowed ) { + $retry_after = is_int( $result->retry_after ) ? $result->retry_after : 60; + + return \Saltus\WP\Framework\MCP\Error\ErrorResponse::rate_limited( $retry_after ); + } + + $context->set_attribute( 'rate_limit_remaining', $result->remaining ); + $context->set_attribute( 'rate_limit_reset_at', $result->reset_at ); + + return $next( $context ); + } + + /** + * Resolve a unique identifier for rate limiting. + * + * @param RequestContext $context + * @return string + */ + private function resolve_identifier( RequestContext $context ): string { + $meta = $context->get_tool_metadata(); + if ( ! empty( $meta['rate_limit_identifier'] ) && is_string( $meta['rate_limit_identifier'] ) ) { + return $meta['rate_limit_identifier']; + } + + $identifier = \function_exists( 'get_current_user_id' ) ? 'user:' . (int) \get_current_user_id() : 'user:0'; + if ( $identifier === 'user:0' && isset( $_SERVER['REMOTE_ADDR'] ) ) { + $identifier = 'ip:' . \hash( 'sha256', (string) $_SERVER['REMOTE_ADDR'] ); + } + + return $identifier; + } +} diff --git a/src/MCP/Middleware/RequestContext.php b/src/MCP/Middleware/RequestContext.php new file mode 100644 index 0000000..73811e6 --- /dev/null +++ b/src/MCP/Middleware/RequestContext.php @@ -0,0 +1,157 @@ + */ + private array $args = []; + + private ?\WP_REST_Request $rest_request = null; + + /** @var \WP_REST_Response|\WP_Error|null */ + private $response = null; + + /** @var array */ + private array $attributes = []; + + private string $route = ''; + + /** @var array */ + private array $tool_metadata = []; + + /** + * Get the raw request arguments (MCP tool args or REST params). + * + * @return array + */ + public function get_args(): array { + return $this->args; + } + + /** + * Set the raw request arguments. + * + * @param array $args + */ + public function set_args( array $args ): void { + $this->args = $args; + } + + /** + * Get the built REST request, if available. + * + * @return \WP_REST_Request|null + */ + public function get_rest_request(): ?\WP_REST_Request { + return $this->rest_request; + } + + /** + * Set the built REST request. + * + * @param \WP_REST_Request|null $request + */ + public function set_rest_request( ?\WP_REST_Request $request ): void { + $this->rest_request = $request; + } + + /** + * Get the response after dispatch, if available. + * + * @return \WP_REST_Response|\WP_Error|null + */ + public function get_response() { + return $this->response; + } + + /** + * Set the response after dispatch. + * + * @param \WP_REST_Response|\WP_Error|null $response + */ + public function set_response( $response ): void { + $this->response = $response; + } + + /** + * Get all route/tool attributes. + * + * @return array + */ + public function get_attributes(): array { + return $this->attributes; + } + + /** + * Set all route/tool attributes at once. + * + * @param array $attributes + */ + public function set_attributes( array $attributes ): void { + $this->attributes = $attributes; + } + + /** + * Get a single attribute value. + * + * @param string $key + * @param mixed $default_value + * @return mixed + */ + public function get_attribute( string $key, $default_value = null ) { + return $this->attributes[ $key ] ?? $default_value; + } + + /** + * Set a single attribute value. + * + * @param string $key + * @param mixed $value + */ + public function set_attribute( string $key, $value ): void { + $this->attributes[ $key ] = $value; + } + + /** + * Get the REST route string. + * + * @return string + */ + public function get_route(): string { + return $this->route; + } + + /** + * Set the REST route string. + * + * @param string $route + */ + public function set_route( string $route ): void { + $this->route = $route; + } + + /** + * Get the MCP tool metadata (name, parameters, etc.). + * + * @return array + */ + public function get_tool_metadata(): array { + return $this->tool_metadata; + } + + /** + * Set the MCP tool metadata. + * + * @param array $metadata + */ + public function set_tool_metadata( array $metadata ): void { + $this->tool_metadata = $metadata; + } +} diff --git a/src/MCP/Middleware/ValidationMiddleware.php b/src/MCP/Middleware/ValidationMiddleware.php new file mode 100644 index 0000000..2bd9f73 --- /dev/null +++ b/src/MCP/Middleware/ValidationMiddleware.php @@ -0,0 +1,116 @@ +get_tool_metadata(); + $schema = []; + + if ( ! empty( $tool_meta ) && isset( $tool_meta['parameters'] ) ) { + $schema = $tool_meta['parameters']; + } elseif ( $context->get_rest_request() !== null ) { + $schema = $this->build_schema_from_route( $context->get_rest_request() ); + } + + if ( empty( $schema ) ) { + return $next( $context ); + } + + $args = ! empty( $tool_meta ) ? $context->get_args() : $this->extract_args( $context ); + $valid = Validator::validate( $args, $schema ); + + if ( ! $valid['valid'] ) { + return \Saltus\WP\Framework\MCP\Error\ErrorResponse::invalid( + '', + \implode( '; ', $valid['errors'] ), + \__( 'Check the request parameters and ensure all required fields are present with the correct types.', 'saltus-framework' ) + ); + } + + return $next( $context ); + } + + /** + * Build a Validator-compatible schema from a REST request's registered args. + * + * @param \WP_REST_Request $request + * @return array + */ + private function build_schema_from_route( \WP_REST_Request $request ): array { + $schema = []; + $raw_args = $request->get_attributes(); + $args = $raw_args['args'] ?? []; + + foreach ( $args as $field => $config ) { + if ( ! is_array( $config ) ) { + continue; + } + + $rules = []; + + if ( ! empty( $config['required'] ) ) { + $rules['required'] = true; + } + + if ( ! empty( $config['type'] ) && is_string( $config['type'] ) ) { + $rules['type'] = $this->map_wp_type( $config['type'] ); + } + + if ( ! empty( $config['enum'] ) && is_array( $config['enum'] ) ) { + $rules['enum'] = $config['enum']; + } + + $schema[ $field ] = $rules; + } + + return $schema; + } + + /** + * Map WordPress REST API type names to Validator types. + * + * @param string $wp_type + * @return string + */ + private function map_wp_type( string $wp_type ): string { + $map = [ + 'string' => 'string', + 'integer' => 'integer', + 'number' => 'number', + 'boolean' => 'boolean', + 'object' => 'object', + 'array' => 'array', + ]; + + return $map[ $wp_type ] ?? 'string'; + } + + /** + * Extract request arguments from the context. + * + * @param RequestContext $context + * @return array + */ + private function extract_args( RequestContext $context ): array { + $request = $context->get_rest_request(); + if ( $request === null ) { + return []; + } + + return $request->get_params(); + } +} From 7be66e812401149dfc635456fe7b17532a7262b8 Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Tue, 21 Jul 2026 09:34:33 +0800 Subject: [PATCH 66/78] docs(api): annotate MCP tools namespace as public api --- src/MCP/Tools/RestBackedToolInterface.php | 3 +++ src/MCP/Tools/RestTool.php | 1 + src/MCP/Tools/ToolContributor.php | 3 +++ src/MCP/Tools/ToolInterface.php | 3 +++ 4 files changed, 10 insertions(+) diff --git a/src/MCP/Tools/RestBackedToolInterface.php b/src/MCP/Tools/RestBackedToolInterface.php index ef8bd23..f5dcfca 100644 --- a/src/MCP/Tools/RestBackedToolInterface.php +++ b/src/MCP/Tools/RestBackedToolInterface.php @@ -2,6 +2,9 @@ namespace Saltus\WP\Framework\MCP\Tools; +/** + * @api + */ interface RestBackedToolInterface extends ToolInterface { /** diff --git a/src/MCP/Tools/RestTool.php b/src/MCP/Tools/RestTool.php index d0af73e..cf9e64a 100644 --- a/src/MCP/Tools/RestTool.php +++ b/src/MCP/Tools/RestTool.php @@ -6,6 +6,7 @@ /** * Abstract base for MCP tools that dispatch via the WordPress REST API. + * @api */ abstract class RestTool implements RestBackedToolInterface { diff --git a/src/MCP/Tools/ToolContributor.php b/src/MCP/Tools/ToolContributor.php index 4b5371f..fa19ded 100644 --- a/src/MCP/Tools/ToolContributor.php +++ b/src/MCP/Tools/ToolContributor.php @@ -5,6 +5,9 @@ use Saltus\WP\Framework\Modeler; use Saltus\WP\Framework\Rest\ModelRestPolicy; +/** + * @api + */ interface ToolContributor { /** diff --git a/src/MCP/Tools/ToolInterface.php b/src/MCP/Tools/ToolInterface.php index 4ee9551..b9fd1aa 100644 --- a/src/MCP/Tools/ToolInterface.php +++ b/src/MCP/Tools/ToolInterface.php @@ -1,6 +1,9 @@ Date: Tue, 21 Jul 2026 09:34:37 +0800 Subject: [PATCH 67/78] docs(api): annotate MCP abilities namespace as public api --- .../Abilities/AbilityDefinitionFactory.php | 1 + src/MCP/Abilities/AbilityRegistrar.php | 1 + src/MCP/Abilities/AbilityRuntime.php | 119 +++++++++++++++++- 3 files changed, 119 insertions(+), 2 deletions(-) diff --git a/src/MCP/Abilities/AbilityDefinitionFactory.php b/src/MCP/Abilities/AbilityDefinitionFactory.php index 03738f5..1b8921c 100644 --- a/src/MCP/Abilities/AbilityDefinitionFactory.php +++ b/src/MCP/Abilities/AbilityDefinitionFactory.php @@ -20,6 +20,7 @@ * callback: callable, * meta: array * } + * @api */ class AbilityDefinitionFactory { diff --git a/src/MCP/Abilities/AbilityRegistrar.php b/src/MCP/Abilities/AbilityRegistrar.php index cec12ab..7cb6b5a 100644 --- a/src/MCP/Abilities/AbilityRegistrar.php +++ b/src/MCP/Abilities/AbilityRegistrar.php @@ -11,6 +11,7 @@ * Registers MCP abilities with the WordPress native wp_register_ability API. * * @phpstan-import-type AbilityDefinition from \Saltus\WP\Framework\MCP\Abilities\AbilityDefinitionFactory + * @api */ class AbilityRegistrar { diff --git a/src/MCP/Abilities/AbilityRuntime.php b/src/MCP/Abilities/AbilityRuntime.php index 836e881..4482326 100644 --- a/src/MCP/Abilities/AbilityRuntime.php +++ b/src/MCP/Abilities/AbilityRuntime.php @@ -4,6 +4,8 @@ use Saltus\WP\Framework\MCP\Audit\AuditEntry; use Saltus\WP\Framework\MCP\Audit\AuditLogger; use Saltus\WP\Framework\MCP\Cache\TransientCache; +use Saltus\WP\Framework\MCP\Middleware\MiddlewarePipeline; +use Saltus\WP\Framework\MCP\Middleware\RequestContext; use Saltus\WP\Framework\MCP\RateLimiter\RateLimiter; use Saltus\WP\Framework\MCP\Tools\RestBackedToolInterface; use Saltus\WP\Framework\MCP\Tools\ToolInterface; @@ -11,6 +13,10 @@ /** * Coordinates validation, rate limiting, REST dispatch, caching, and audit logging for MCP tool execution. + * + * Now delegates to the middleware pipeline when available; falls back to + * the original inline orchestration for backward compatibility. + * @api */ class AbilityRuntime { use \Saltus\WP\Framework\Infrastructure\Services\FilterAwareTrait; @@ -18,31 +24,140 @@ class AbilityRuntime { private AuditLogger $audit_logger; private RateLimiter $rate_limiter; private TransientCache $cache; + private ?MiddlewarePipeline $pipeline; /** * @param AuditLogger|null $audit_logger Optional audit logger. * @param RateLimiter|null $rate_limiter Optional rate limiter. * @param TransientCache|null $cache Optional cache backend. + * @param MiddlewarePipeline|null $pipeline Optional middleware pipeline. */ public function __construct( ?AuditLogger $audit_logger = null, ?RateLimiter $rate_limiter = null, - ?TransientCache $cache = null + ?TransientCache $cache = null, + ?MiddlewarePipeline $pipeline = null ) { $this->audit_logger = $audit_logger ?? new AuditLogger(); $this->rate_limiter = $rate_limiter ?? new RateLimiter(); $this->cache = $cache ?? new TransientCache(); + $this->pipeline = $pipeline; } /** * Validate, rate-limit, dispatch, cache, and audit an MCP tool execution. * + * When a middleware pipeline is configured, delegates to it. Otherwise + * falls back to the original sequential orchestration. + * * @param ToolInterface $tool The tool to execute. * @param array $args Arguments to pass to the tool. * @return array|\WP_Error Tool result or error. */ - // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh -- Runtime coordinates validation, rate limiting, dispatch, cache, and audit. + // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh public function execute( ToolInterface $tool, array $args ) { + if ( $this->pipeline !== null ) { + return $this->execute_via_pipeline( $tool, $args ); + } + + return $this->execute_legacy( $tool, $args ); + } + + /** + * Execute the tool via the middleware pipeline. + * + * @param ToolInterface $tool + * @param array $args + * @return array|\WP_Error + */ + // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh -- Pipeline setup, dispatch, and result handling. + private function execute_via_pipeline( ToolInterface $tool, array $args ) { + $context = new RequestContext(); + $context->set_args( $args ); + $context->set_tool_metadata( [ + 'name' => $tool->get_name(), + 'parameters' => $tool->get_parameters(), + 'has_permission' => [ $tool, 'has_permission' ], + 'capability' => $tool instanceof RestBackedToolInterface && $tool->get_rest_capability() !== null + ? $tool->get_rest_capability()->get_capability() + : 'edit_posts', + ] ); + + $cache_ttl = $tool instanceof RestBackedToolInterface ? $tool->cache_ttl() : 300; + $context->set_attribute( 'cache_ttl', $cache_ttl ); + + $result = $this->pipeline->execute( + $context, + function ( RequestContext $ctx ) use ( $tool, $args ) { + if ( ! $tool instanceof RestBackedToolInterface ) { + return \Saltus\WP\Framework\MCP\Error\ErrorResponse::internal_error( + \__( 'This tool does not support REST dispatch.', 'saltus-framework' ) + ); + } + + $request = $tool->build_rest_request( $args ); + if ( $request === null ) { + return \Saltus\WP\Framework\MCP\Error\ErrorResponse::internal_error( + \__( 'This Saltus ability is registered for discovery only until a native dispatcher is available.', 'saltus-framework' ) + ); + } + + if ( ! \function_exists( 'rest_do_request' ) ) { + return \Saltus\WP\Framework\MCP\Error\ErrorResponse::internal_error( + \__( 'WordPress REST dispatch is not available.', 'saltus-framework' ) + ); + } + + $ctx->set_rest_request( $request ); + + try { + $response = \rest_do_request( $request ); + + /** @phpstan-ignore-next-line rest_do_request can return WP_Error (WordPress stubs may not include it in the return type) */ + if ( \is_wp_error( $response ) ) { + return \Saltus\WP\Framework\MCP\Error\ErrorResponse::dispatch_error( $response ); + } + + $status = (int) $response->get_status(); + $data = $response->get_data(); + $result = \is_array( $data ) ? $data : [ 'result' => $data ]; + + if ( $status >= 400 ) { + $error_code = \is_array( $data ) ? (string) ( $data['code'] ?? 'rest_error' ) : 'rest_error'; + $error_msg = \is_array( $data ) ? (string) ( $data['message'] ?? 'REST error' ) : 'REST error'; + + return new \WP_Error( $error_code, $error_msg, [ 'status' => $status ] ); + } + + $ctx->set_response( $response ); + + return $result; + } catch ( \Throwable $e ) { + return \Saltus\WP\Framework\MCP\Error\ErrorResponse::internal_error( + $e->getMessage(), + \__( 'An unexpected error occurred during tool execution.', 'saltus-framework' ) + ); + } + } + ); + + if ( $result instanceof \WP_REST_Response ) { + $data = $result->get_data(); + return \is_array( $data ) ? $data : [ 'result' => $data ]; + } + + return $result; + } + + /** + * Original sequential execution (backward compatibility path). + * + * @param ToolInterface $tool + * @param array $args + * @return array|\WP_Error + */ + // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh + private function execute_legacy( ToolInterface $tool, array $args ) { $entry = new AuditEntry( $tool->get_name(), $args, $this->identifier() ); $valid = Validator::validate( $args, $tool->get_parameters() ); From dbc615a8fd263e660d66072d906875f2f9f8db12 Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Tue, 21 Jul 2026 09:34:41 +0800 Subject: [PATCH 68/78] docs(api): annotate MCP cache rate audit validation as public api --- src/MCP/Audit/AuditDatabase.php | 3 +++ src/MCP/Audit/AuditEntry.php | 1 + src/MCP/Audit/AuditLogger.php | 1 + src/MCP/Audit/WpdbAuditDatabase.php | 1 + src/MCP/Cache/CacheInterface.php | 3 +++ src/MCP/Cache/TransientCache.php | 1 + src/MCP/RateLimiter/RateLimitResult.php | 1 + src/MCP/RateLimiter/RateLimiter.php | 1 + src/MCP/Validation/Validator.php | 1 + 9 files changed, 13 insertions(+) diff --git a/src/MCP/Audit/AuditDatabase.php b/src/MCP/Audit/AuditDatabase.php index a1db780..716a020 100644 --- a/src/MCP/Audit/AuditDatabase.php +++ b/src/MCP/Audit/AuditDatabase.php @@ -1,6 +1,9 @@ Date: Tue, 21 Jul 2026 09:34:45 +0800 Subject: [PATCH 69/78] docs(api): annotate Plugin infrastructure as public api --- src/Infrastructure/Plugin/Activateable.php | 1 + src/Infrastructure/Plugin/Deactivateable.php | 1 + src/Infrastructure/Plugin/Plugin.php | 1 + src/Infrastructure/Plugin/Project.php | 1 + src/Infrastructure/Plugin/Registerable.php | 1 + 5 files changed, 5 insertions(+) diff --git a/src/Infrastructure/Plugin/Activateable.php b/src/Infrastructure/Plugin/Activateable.php index 81b3c33..3ab58f2 100644 --- a/src/Infrastructure/Plugin/Activateable.php +++ b/src/Infrastructure/Plugin/Activateable.php @@ -9,6 +9,7 @@ * * This way, we can just add the simple interface marker and not worry about how * to wire up the code to reach that part during the static activation hook. + * @api */ interface Activateable { diff --git a/src/Infrastructure/Plugin/Deactivateable.php b/src/Infrastructure/Plugin/Deactivateable.php index ad14e94..4f67bc2 100644 --- a/src/Infrastructure/Plugin/Deactivateable.php +++ b/src/Infrastructure/Plugin/Deactivateable.php @@ -9,6 +9,7 @@ * * This way, we can just add the simple interface marker and not worry about how * to wire up the code to reach that part during the static deactivation hook. + * @api */ interface Deactivateable { diff --git a/src/Infrastructure/Plugin/Plugin.php b/src/Infrastructure/Plugin/Plugin.php index 9a0dd17..cc55f1b 100644 --- a/src/Infrastructure/Plugin/Plugin.php +++ b/src/Infrastructure/Plugin/Plugin.php @@ -16,6 +16,7 @@ * Additionally, we provide a means to get access to the plugin's container that * collects all the features it is made up of. This allows direct access to the * features to outside code if needed. + * @api */ interface Plugin extends Activateable, Deactivateable, Registerable { diff --git a/src/Infrastructure/Plugin/Project.php b/src/Infrastructure/Plugin/Project.php index 6615de9..4eb098c 100644 --- a/src/Infrastructure/Plugin/Project.php +++ b/src/Infrastructure/Plugin/Project.php @@ -3,6 +3,7 @@ /** * The Project class, where data is defined. + * @api */ class Project { diff --git a/src/Infrastructure/Plugin/Registerable.php b/src/Infrastructure/Plugin/Registerable.php index 5c2512c..a51f4f5 100644 --- a/src/Infrastructure/Plugin/Registerable.php +++ b/src/Infrastructure/Plugin/Registerable.php @@ -12,6 +12,7 @@ * * Registering such an object is the explicit act of making it known to the * overarching system. + * @api */ interface Registerable { From a597bf216aa16877f12699e0c5d2a70fcf278668 Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Tue, 21 Jul 2026 09:34:49 +0800 Subject: [PATCH 70/78] docs(api): annotate Container infrastructure as public api --- src/Infrastructure/Container/CanRegister.php | 1 + src/Infrastructure/Container/Container.php | 1 + src/Infrastructure/Container/ContainerAssembler.php | 1 + src/Infrastructure/Container/Instantiator.php | 1 + src/Infrastructure/Container/ServiceContainer.php | 1 + src/Infrastructure/Container/SimpleContainer.php | 1 + 6 files changed, 6 insertions(+) diff --git a/src/Infrastructure/Container/CanRegister.php b/src/Infrastructure/Container/CanRegister.php index f903a1c..8f4f10b 100644 --- a/src/Infrastructure/Container/CanRegister.php +++ b/src/Infrastructure/Container/CanRegister.php @@ -4,6 +4,7 @@ /** * Something that triggers class instantiation * + * @api */ interface CanRegister { diff --git a/src/Infrastructure/Container/Container.php b/src/Infrastructure/Container/Container.php index 3f033cf..86da41e 100644 --- a/src/Infrastructure/Container/Container.php +++ b/src/Infrastructure/Container/Container.php @@ -16,6 +16,7 @@ * * @extends Traversable * @extends ArrayAccess + * @api */ interface Container extends Traversable, Countable, ArrayAccess { diff --git a/src/Infrastructure/Container/ContainerAssembler.php b/src/Infrastructure/Container/ContainerAssembler.php index b2d84c7..9a8b817 100644 --- a/src/Infrastructure/Container/ContainerAssembler.php +++ b/src/Infrastructure/Container/ContainerAssembler.php @@ -4,6 +4,7 @@ /** * A simplified implementation of a container Assembler. * + * @api */ class ContainerAssembler { diff --git a/src/Infrastructure/Container/Instantiator.php b/src/Infrastructure/Container/Instantiator.php index 146fea6..25421e8 100644 --- a/src/Infrastructure/Container/Instantiator.php +++ b/src/Infrastructure/Container/Instantiator.php @@ -6,6 +6,7 @@ * * This way, a more elaborate mechanism can be plugged in, like using * ProxyManager to instantiate proxies instead of actual objects. + * @api */ interface Instantiator { diff --git a/src/Infrastructure/Container/ServiceContainer.php b/src/Infrastructure/Container/ServiceContainer.php index ecab168..0685b26 100644 --- a/src/Infrastructure/Container/ServiceContainer.php +++ b/src/Infrastructure/Container/ServiceContainer.php @@ -28,6 +28,7 @@ * Can trigger service registration proccess with CanRegister. * * @extends ArrayObject + * @api */ class ServiceContainer extends ArrayObject diff --git a/src/Infrastructure/Container/SimpleContainer.php b/src/Infrastructure/Container/SimpleContainer.php index 2824aa0..1d08883 100644 --- a/src/Infrastructure/Container/SimpleContainer.php +++ b/src/Infrastructure/Container/SimpleContainer.php @@ -16,6 +16,7 @@ * array access. * * @extends ArrayObject + * @api */ class SimpleContainer extends ArrayObject implements Container { From ec7d2c35a5505fd8092106507d4e3921db16ae7b Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Tue, 21 Jul 2026 09:34:56 +0800 Subject: [PATCH 71/78] docs(api): annotate Service namespace as public api --- src/Infrastructure/Service/Actionable.php | 1 + src/Infrastructure/Service/App.php | 1 + src/Infrastructure/Service/Assembly.php | 1 + src/Infrastructure/Service/Conditional.php | 1 + src/Infrastructure/Service/Factory.php | 3 +++ src/Infrastructure/Service/Processable.php | 1 + src/Infrastructure/Service/Service.php | 1 + src/Infrastructure/Service/ServiceFactory.php | 3 +++ 8 files changed, 12 insertions(+) diff --git a/src/Infrastructure/Service/Actionable.php b/src/Infrastructure/Service/Actionable.php index 955de3e..059af01 100644 --- a/src/Infrastructure/Service/Actionable.php +++ b/src/Infrastructure/Service/Actionable.php @@ -10,6 +10,7 @@ * * This allows for a more systematic and automated optimization of how the * different parts of the plugin are enabled or disabled. + * @api */ interface Actionable { diff --git a/src/Infrastructure/Service/App.php b/src/Infrastructure/Service/App.php index 4eb51a8..dc5cf41 100644 --- a/src/Infrastructure/Service/App.php +++ b/src/Infrastructure/Service/App.php @@ -15,6 +15,7 @@ * array access. * * @deprecated 0.1.1 Use Infrastructure/Container + * @api */ final class App extends ServiceContainer diff --git a/src/Infrastructure/Service/Assembly.php b/src/Infrastructure/Service/Assembly.php index bbf68cd..c0bb156 100644 --- a/src/Infrastructure/Service/Assembly.php +++ b/src/Infrastructure/Service/Assembly.php @@ -7,6 +7,7 @@ * Splitting your logic up into independent services makes the approach of * assembling a plugin more systematic and scalable and lowers the cognitive * load when the code base increases in size. + * @api */ interface Assembly { diff --git a/src/Infrastructure/Service/Conditional.php b/src/Infrastructure/Service/Conditional.php index fbf6276..d7f62e8 100644 --- a/src/Infrastructure/Service/Conditional.php +++ b/src/Infrastructure/Service/Conditional.php @@ -10,6 +10,7 @@ * * This allows for a more systematic and automated optimization of how the * different parts of the plugin are enabled or disabled. + * @api */ interface Conditional { diff --git a/src/Infrastructure/Service/Factory.php b/src/Infrastructure/Service/Factory.php index 7af2da4..d384da1 100644 --- a/src/Infrastructure/Service/Factory.php +++ b/src/Infrastructure/Service/Factory.php @@ -1,6 +1,9 @@ Date: Tue, 21 Jul 2026 09:35:00 +0800 Subject: [PATCH 72/78] docs(api): annotate Services Assets namespace as public api --- src/Infrastructure/Services/Assets/Asset.php | 3 +++ src/Infrastructure/Services/Assets/AssetData.php | 1 + src/Infrastructure/Services/Assets/AssetLoader.php | 3 +++ src/Infrastructure/Services/Assets/AssetLoadingService.php | 1 + src/Infrastructure/Services/Assets/AssetManager.php | 1 + src/Infrastructure/Services/Assets/AssetsContainer.php | 3 +++ src/Infrastructure/Services/Assets/HasAssets.php | 3 +++ src/Infrastructure/Services/FilterAwareTrait.php | 1 + 8 files changed, 16 insertions(+) diff --git a/src/Infrastructure/Services/Assets/Asset.php b/src/Infrastructure/Services/Assets/Asset.php index ac0f6c7..3b92b22 100644 --- a/src/Infrastructure/Services/Assets/Asset.php +++ b/src/Infrastructure/Services/Assets/Asset.php @@ -5,6 +5,9 @@ use Saltus\WP\Framework\Infrastructure\Service\Service; +/** + * @api + */ class Asset implements Service { /** diff --git a/src/Infrastructure/Services/Assets/AssetData.php b/src/Infrastructure/Services/Assets/AssetData.php index 5d3805d..0ef4b69 100644 --- a/src/Infrastructure/Services/Assets/AssetData.php +++ b/src/Infrastructure/Services/Assets/AssetData.php @@ -9,6 +9,7 @@ * * This class holds the data that will be made available to a specific script * using `wp_localize_script`. + * @api */ class AssetData implements Service { diff --git a/src/Infrastructure/Services/Assets/AssetLoader.php b/src/Infrastructure/Services/Assets/AssetLoader.php index 099c915..6da1308 100644 --- a/src/Infrastructure/Services/Assets/AssetLoader.php +++ b/src/Infrastructure/Services/Assets/AssetLoader.php @@ -7,6 +7,9 @@ use Saltus\WP\Framework\Infrastructure\Service\Factory; use Saltus\WP\Framework\Infrastructure\Service\ServiceFactory; +/** + * @api + */ trait AssetLoader { /** diff --git a/src/Infrastructure/Services/Assets/AssetLoadingService.php b/src/Infrastructure/Services/Assets/AssetLoadingService.php index 27db3c5..4792e85 100644 --- a/src/Infrastructure/Services/Assets/AssetLoadingService.php +++ b/src/Infrastructure/Services/Assets/AssetLoadingService.php @@ -5,6 +5,7 @@ /** * Base class for services that register and enqueue framework assets. + * @api */ abstract class AssetLoadingService implements HasAssets { use AssetLoader; diff --git a/src/Infrastructure/Services/Assets/AssetManager.php b/src/Infrastructure/Services/Assets/AssetManager.php index b708cc3..35e6c3e 100644 --- a/src/Infrastructure/Services/Assets/AssetManager.php +++ b/src/Infrastructure/Services/Assets/AssetManager.php @@ -7,6 +7,7 @@ /** * Manage Assets like scripts and styles. + * @api */ class AssetManager implements Service { diff --git a/src/Infrastructure/Services/Assets/AssetsContainer.php b/src/Infrastructure/Services/Assets/AssetsContainer.php index eccfedd..c6afde2 100644 --- a/src/Infrastructure/Services/Assets/AssetsContainer.php +++ b/src/Infrastructure/Services/Assets/AssetsContainer.php @@ -5,6 +5,9 @@ use Saltus\WP\Framework\Infrastructure\Service\Service; use Saltus\WP\Framework\Infrastructure\Container\SimpleContainer; +/** + * @api + */ class AssetsContainer extends SimpleContainer implements Service { /** diff --git a/src/Infrastructure/Services/Assets/HasAssets.php b/src/Infrastructure/Services/Assets/HasAssets.php index 1b854a2..1126761 100644 --- a/src/Infrastructure/Services/Assets/HasAssets.php +++ b/src/Infrastructure/Services/Assets/HasAssets.php @@ -1,6 +1,9 @@ Date: Tue, 21 Jul 2026 09:35:08 +0800 Subject: [PATCH 73/78] docs(api): annotate admin Features as public api --- src/Features/AdminCols/AdminCols.php | 1 + src/Features/AdminFilters/AdminFilters.php | 1 + src/Features/DragAndDrop/DragAndDrop.php | 1 + src/Features/Duplicate/Duplicate.php | 1 + src/Features/QuickEdit/QuickEdit.php | 1 + src/Features/RememberTabs/RememberTabs.php | 1 + src/Features/SingleExport/SingleExport.php | 1 + 7 files changed, 7 insertions(+) diff --git a/src/Features/AdminCols/AdminCols.php b/src/Features/AdminCols/AdminCols.php index c48e474..d389439 100644 --- a/src/Features/AdminCols/AdminCols.php +++ b/src/Features/AdminCols/AdminCols.php @@ -10,6 +10,7 @@ /** * Adds custom admin columns in the post type archive + * @api */ class AdminCols implements Service, Conditional, Assembly { diff --git a/src/Features/AdminFilters/AdminFilters.php b/src/Features/AdminFilters/AdminFilters.php index d378f99..730fe28 100644 --- a/src/Features/AdminFilters/AdminFilters.php +++ b/src/Features/AdminFilters/AdminFilters.php @@ -10,6 +10,7 @@ /** * Adds admin filters in the post type admin archive + * @api */ class AdminFilters implements Service, Conditional, Assembly { diff --git a/src/Features/DragAndDrop/DragAndDrop.php b/src/Features/DragAndDrop/DragAndDrop.php index 40bd095..9ff61dc 100644 --- a/src/Features/DragAndDrop/DragAndDrop.php +++ b/src/Features/DragAndDrop/DragAndDrop.php @@ -21,6 +21,7 @@ * Class DragAndDrop * * Enable an option to manage drag and drop functionality in the admin area. + * @api */ class DragAndDrop implements Service, Conditional, Actionable, Assembly, RestRouteProvider, ToolContributor { diff --git a/src/Features/Duplicate/Duplicate.php b/src/Features/Duplicate/Duplicate.php index 25f349f..57cb776 100644 --- a/src/Features/Duplicate/Duplicate.php +++ b/src/Features/Duplicate/Duplicate.php @@ -17,6 +17,7 @@ /** + * @api */ class Duplicate implements Service, Conditional, Assembly, RestRouteProvider, ToolContributor { diff --git a/src/Features/QuickEdit/QuickEdit.php b/src/Features/QuickEdit/QuickEdit.php index ef39255..c73bef7 100644 --- a/src/Features/QuickEdit/QuickEdit.php +++ b/src/Features/QuickEdit/QuickEdit.php @@ -10,6 +10,7 @@ /** * Adds custom admin columns in the post type archive + * @api */ class QuickEdit implements Service, Conditional, Assembly { diff --git a/src/Features/RememberTabs/RememberTabs.php b/src/Features/RememberTabs/RememberTabs.php index 8f826ee..5594ad9 100644 --- a/src/Features/RememberTabs/RememberTabs.php +++ b/src/Features/RememberTabs/RememberTabs.php @@ -11,6 +11,7 @@ * Class RememberTabs * * Enable an option to remember the last active tab in the admin area. + * @api */ class RememberTabs implements Service, Conditional, Assembly { diff --git a/src/Features/SingleExport/SingleExport.php b/src/Features/SingleExport/SingleExport.php index d72f562..2cac70b 100644 --- a/src/Features/SingleExport/SingleExport.php +++ b/src/Features/SingleExport/SingleExport.php @@ -19,6 +19,7 @@ * Class SingleExport * * Enable an option to export single entry + * @api */ class SingleExport implements Service, Conditional, Assembly, RestRouteProvider, ToolContributor { From f43df377df6ae7faa761a9f0745bbe57b28a75af Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Tue, 21 Jul 2026 09:35:12 +0800 Subject: [PATCH 74/78] docs(api): annotate MCP Meta Settings Features as public api --- src/Features/MCP/MCP.php | 1 + src/Features/Meta/CodestarMeta.php | 1 + src/Features/Meta/Meta.php | 1 + src/Features/Meta/MetaFieldProvider.php | 1 + src/Features/Settings/CodestarSettings.php | 5 +++-- src/Features/Settings/Settings.php | 1 + src/Features/Settings/SettingsManager.php | 1 + 7 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Features/MCP/MCP.php b/src/Features/MCP/MCP.php index 50a5af2..80530ff 100644 --- a/src/Features/MCP/MCP.php +++ b/src/Features/MCP/MCP.php @@ -20,6 +20,7 @@ * WordPress-native abilities are registered when the host WordPress version * exposes the Abilities API. Older WordPress versions skip native ability * registration. + * @api */ class MCP implements Service, Registerable, Activateable, Deactivateable { diff --git a/src/Features/Meta/CodestarMeta.php b/src/Features/Meta/CodestarMeta.php index b1309b3..f56985b 100644 --- a/src/Features/Meta/CodestarMeta.php +++ b/src/Features/Meta/CodestarMeta.php @@ -9,6 +9,7 @@ /** * @phpstan-type MetaConfig array + * @api */ final class CodestarMeta implements Processable { diff --git a/src/Features/Meta/Meta.php b/src/Features/Meta/Meta.php index c591d66..8489a4b 100644 --- a/src/Features/Meta/Meta.php +++ b/src/Features/Meta/Meta.php @@ -21,6 +21,7 @@ * Class Meta * * Enable an option to manage meta fields + * @api */ final class Meta implements Service, Conditional, Assembly, RestRouteProvider, ToolContributor { diff --git a/src/Features/Meta/MetaFieldProvider.php b/src/Features/Meta/MetaFieldProvider.php index 3f378a7..978f9f1 100644 --- a/src/Features/Meta/MetaFieldProvider.php +++ b/src/Features/Meta/MetaFieldProvider.php @@ -7,6 +7,7 @@ /** * Provides model-defined meta field payloads and normalized field metadata. + * @api */ class MetaFieldProvider { diff --git a/src/Features/Settings/CodestarSettings.php b/src/Features/Settings/CodestarSettings.php index a150a51..a84af61 100644 --- a/src/Features/Settings/CodestarSettings.php +++ b/src/Features/Settings/CodestarSettings.php @@ -8,8 +8,9 @@ final class CodestarSettings implements Processable { /** - * @var string $name The name of the custom post type (CPT) - */ + * @var string $name The name of the custom post type (CPT) + * @api + */ private $name; /** diff --git a/src/Features/Settings/Settings.php b/src/Features/Settings/Settings.php index 699da75..7ab3c7c 100644 --- a/src/Features/Settings/Settings.php +++ b/src/Features/Settings/Settings.php @@ -20,6 +20,7 @@ * Class Settings * * Enable an option to create Settings page + * @api */ final class Settings implements Service, Conditional, Assembly, RestRouteProvider, ToolContributor { diff --git a/src/Features/Settings/SettingsManager.php b/src/Features/Settings/SettingsManager.php index dc3b0c6..ab7be90 100644 --- a/src/Features/Settings/SettingsManager.php +++ b/src/Features/Settings/SettingsManager.php @@ -3,6 +3,7 @@ /** * Shared feature-layer access to Saltus per-post-type settings. + * @api */ class SettingsManager { From fe7d9430adf92c7b54c3d6a7bc5b95ec827df65f Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Tue, 21 Jul 2026 09:35:16 +0800 Subject: [PATCH 75/78] docs(api): add stub method for test compatibility --- tests/Rest/functions.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/Rest/functions.php b/tests/Rest/functions.php index 2d5e5aa..d247389 100644 --- a/tests/Rest/functions.php +++ b/tests/Rest/functions.php @@ -122,6 +122,10 @@ public function get_method(): string { public function get_route(): string { return $this->route; } + + public function get_attributes(): array { + return []; + } } } From 5118c2ccf64bf40c595d26f6961d6d4b03ae776d Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Tue, 21 Jul 2026 09:35:20 +0800 Subject: [PATCH 76/78] docs(readme): update links to docs site --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 34010bb..dee060b 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Saltus Framework helps you develop WordPress plugins that are based on Custom Po We built it to make things easier and faster for developers with different skills. Add metaboxes, settings pages and other enhancements with just a few lines of code. -Visit saltus.dev for more information. +Visit [saltus.dev](https://saltus.dev) for more information. Full documentation is available at [docs.saltus.dev](https://docs.saltus.dev). ## Version @@ -42,7 +42,7 @@ composer require saltus/framework ### Demo -Refer to the [Framework Demo](https://github.com/SaltusDev/framework-demo) for a complete plugin example and to the [Wiki](https://github.com/SaltusDev/saltus-framework/wiki) for complete documentation. +Refer to the [Framework Demo](https://github.com/SaltusDev/framework-demo) for a complete plugin example and to the [documentation site](https://docs.saltus.dev) for complete documentation. Once the framework is installed and Composer's autoloader is loaded by your plugin, you can initialize it the following way: @@ -187,7 +187,7 @@ Includes support for [github-updater](https://github.com/afragen/github-updater) Saltus Framework exposes its AI-facing tool surface through the WordPress-native MCP/Abilities API. Native WordPress MCP clients can discover and call the `saltus/*` abilities directly from the active plugin. -For full documentation, see [docs/MCP.md](docs/MCP.md). For client integration guidance, see [docs/MCP-CLIENTS.md](docs/MCP-CLIENTS.md). These pages are source material for the future Saltus MCP documentation site. +For full documentation, see [docs/MCP.md](docs/MCP.md) or the [MCP section](https://docs.saltus.dev/mcp/) of the documentation site. For client integration guidance, see [docs/MCP-CLIENTS.md](docs/MCP-CLIENTS.md) or the [client guide](https://docs.saltus.dev/mcp/clients). ### Quick Start From 83bb788f4c7ac9052e9eece898e3b8ccf2b58149 Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Tue, 21 Jul 2026 09:35:28 +0800 Subject: [PATCH 77/78] docs(tracking): update roadmap with docs site progress --- docs/CURRENT.md | 10 +++++++--- docs/ROADMAP.md | 14 +++++++++++--- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/docs/CURRENT.md b/docs/CURRENT.md index 75bffad..f97e619 100644 --- a/docs/CURRENT.md +++ b/docs/CURRENT.md @@ -1,19 +1,23 @@ # Current: Live Working State ## Working +- Phase 5D: Documentation site infrastructure — VitePress + phpDocumentor hybrid, GitHub Actions deploy to docs.saltus.dev, @api annotations on 84 public classes/interfaces, doc content pages @since 2026-07-21 - Phase 5A: Block Editor integration — Blocks feature service, per-CPT block registration, default templates @since 2026-07-08 -- Phase 5D: Documentation — fill README placeholders, add model examples @since 2026-07-05 -- MCP/REST gating refactor: McpPolicy + config-section model @since 2026-07-08 +- Phase 4F/4G: AbilityRuntime middleware pipeline integration — backward-compatible pipeline delegation added to execute() @since 2026-07-21 ## Next +- Phase 5D: Fill remaining README placeholders (features, labels, meta, settings tables) +- Phase 5D: Create BLOCKS.md, WPCLI.md, FRONTEND.md content pages +- Phase 5D: Create bin/generate-wpcli-docs.php doc generator - Phase 5B: WP-CLI tools — 7 grouped command classes mapping every MCP tool - Phase 5C: Frontend rendering — shortcodes, templates, meta field exposure -- Phase 5D: New doc files (BLOCKS.md, WPCLI.md, FRONTEND.md, FEATURES.md) ## Blocked - None ## Recent Changes +- Docs infrastructure: VitePress site scaffolded at `docs/.vitepress/`, phpDocumentor config at `phpdoc.dist.xml`, `.github/workflows/docs.yml` for auto-build + GH Pages deploy to `docs.saltus.dev`, `docs/public/CNAME` for custom domain, 6 doc content pages (getting-started, features, architecture, build, MCP overview, API index), `composer docs:all` script (`docs:mcp` + `docs:api`), @api annotations on 84 public classes/interfaces across all namespaces, README updated with docs.saltus.dev links, ROADMAP.md Phase 5D progress tracked @since 2026-07-21 +- AbilityRuntime middleware pipeline integration: backward-compatible `execute_via_pipeline()` delegated from `execute()`, `execute_legacy()` preserved as fallback, optional `MiddlewarePipeline` constructor injection — resolves Phase 4F/4G strip-cache/strip-rate-limit items @since 2026-07-21 - Code review feedback: replaced wp_die with RuntimeException in single_export_query to avoid HTML death pages in REST/MCP contexts; added catch clause in export_post to return structured WP_Error; replaced unsafe property_exists with get_object_vars in ModelRestPolicy to avoid fatal errors on non-public properties; added explicit edit_posts permission checks for list_models/get_model/list_meta_fields/get_meta_fields in AbilityDefinitionFactory; changed AuditLogger created_at column from varchar(32) to datetime(3) — 2 commits @since 2026-07-04 - Export query hardening: replaced fragile string-equality check in single_export_query with structural regex detection via is_fake_date_export_query; added wp_die fallback for unrecognized fake-date query shapes; added esc_html__ and wp_die test stubs — 1 commit @since 2026-07-04 - Code review feedback: deferred RestServer instantiation inside rest_api_init to avoid overhead on non-REST requests; replaced wp_next_scheduled/wp_unschedule_event with wp_clear_scheduled_hook in MCP deactivation; gated ensure_table() behind DB version option to avoid unnecessary CREATE TABLE queries on every audit write; added wp_clear_scheduled_hook test stub — 3 commits @since 2026-07-04 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 60d81f4..e17038d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -152,7 +152,7 @@ Expose Saltus Framework capabilities through WordPress-native MCP/Abilities. Sal | **Docker image** | Skipped with standalone server path | | **GitHub Action** | Skipped with standalone server path | | **VS Code extension** | Future WordPress-native MCP client integration | -| **Documentation site** | Source pages added at `docs/MCP.md`, `docs/MCP-CLIENTS.md`, and generated `docs/MCP-ABILITIES.md` for future `docs.saltus.dev/mcp` | +| **Documentation site** | VitePress site live at `docs.saltus.dev`, phpDocumentor API docs, GitHub Actions auto-deploy, MCP docs integrated | | **MCP Registry listing** | Reassess for WordPress-native abilities | | **Support & SLA model** | Paid support contracts, custom tool development | @@ -358,8 +358,16 @@ frontend: **Auto-generation:** `composer docs:wpcli` script in `bin/generate-wpcli-docs.php` to generate WP-CLI command tables (parallel to `bin/generate-mcp-docs.php`). +**Docs site infrastructure (new):** VitePress static site at `docs.saltus.dev` + phpDocumentor API docs + GitHub Actions auto-deploy. + | Item | Status | |------|--------| +| VitePress site config + landing page | ✓ Done | +| phpDocumentor config (phpdoc.dist.xml) | ✓ Done | +| @api annotations on 84 public classes/interfaces | ✓ Done | +| GitHub Actions workflow (build + deploy to Pages) | ✓ Done | +| Docs content: getting-started, architecture, build, features, MCP | ✓ Done | +| `composer docs:all` script (mcp + api) | ✓ Done | | README features table | ○ Pending | | README labels reference | ○ Pending | | README meta structure | ○ Pending | @@ -372,11 +380,11 @@ frontend: | docs/FEATURES.md | ○ Pending | | bin/generate-wpcli-docs.php | ○ Pending | -**Exit criteria:** Zero `(More Info Soon)` or `(Soon)` placeholders in README. All four new features have dedicated doc files. Feature reference is extracted to `docs/FEATURES.md`. WP-CLI docs are auto-generated. +**Exit criteria:** `(More Info Soon)` placeholders filled, docs.saltus.dev live with VitePress + API docs, auto-deploy via GitHub Actions. All four feature doc files created. WP-CLI docs auto-generated. --- -*Plugin Generator moved to its own repository — see [docs/PLUGIN_GENERATOR_ROADMAP.md](./PLUGIN_GENERATOR_ROADMAP.md).* +*Plugin Generator moved to its own repository — see the [framework-demo repository](https://github.com/SaltusDev/framework-demo).* ## Framework Core Roadmap From 55f8b0d951d0feaede1311bb6a4b560a76f1ac22 Mon Sep 17 00:00:00 2001 From: Pedro de Carvalho Date: Tue, 21 Jul 2026 09:35:34 +0800 Subject: [PATCH 78/78] test(mcp): add middleware pipeline tests --- tests/MCP/Middleware/AuditMiddlewareTest.php | 77 ++++++++++ tests/MCP/Middleware/CacheMiddlewareTest.php | 100 +++++++++++++ tests/MCP/Middleware/ErrorResponseTest.php | 81 +++++++++++ .../MCP/Middleware/MiddlewarePipelineTest.php | 135 ++++++++++++++++++ .../Middleware/PermissionMiddlewareTest.php | 114 +++++++++++++++ .../Middleware/PipelineIntegrationTest.php | 77 ++++++++++ .../Middleware/RateLimitMiddlewareTest.php | 97 +++++++++++++ tests/MCP/Middleware/RequestContextTest.php | 76 ++++++++++ .../Middleware/ValidationMiddlewareTest.php | 112 +++++++++++++++ 9 files changed, 869 insertions(+) create mode 100644 tests/MCP/Middleware/AuditMiddlewareTest.php create mode 100644 tests/MCP/Middleware/CacheMiddlewareTest.php create mode 100644 tests/MCP/Middleware/ErrorResponseTest.php create mode 100644 tests/MCP/Middleware/MiddlewarePipelineTest.php create mode 100644 tests/MCP/Middleware/PermissionMiddlewareTest.php create mode 100644 tests/MCP/Middleware/PipelineIntegrationTest.php create mode 100644 tests/MCP/Middleware/RateLimitMiddlewareTest.php create mode 100644 tests/MCP/Middleware/RequestContextTest.php create mode 100644 tests/MCP/Middleware/ValidationMiddlewareTest.php diff --git a/tests/MCP/Middleware/AuditMiddlewareTest.php b/tests/MCP/Middleware/AuditMiddlewareTest.php new file mode 100644 index 0000000..55abf3a --- /dev/null +++ b/tests/MCP/Middleware/AuditMiddlewareTest.php @@ -0,0 +1,77 @@ +inserts = []; + } + + public function testRecordsSuccessAudit(): void { + global $wpdb; + + $pipeline = new MiddlewarePipeline(); + $pipeline->add( new AuditMiddleware( new AuditLogger() ) ); + + $context = new RequestContext(); + $context->set_tool_metadata( [ 'name' => 'list_models' ] ); + $context->set_args( [ 'type' => 'post_types' ] ); + + $pipeline->execute( $context, function () { + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + + $this->assertNotEmpty( $wpdb->inserts ); + $this->assertSame( 'success', $wpdb->inserts[0]['data']['status'] ); + $this->assertStringContainsString( 'list_models', $wpdb->inserts[0]['data']['ability'] ); + } + + public function testRecordsErrorAudit(): void { + global $wpdb; + + $pipeline = new MiddlewarePipeline(); + $pipeline->add( new AuditMiddleware( new AuditLogger() ) ); + + $context = new RequestContext(); + $context->set_tool_metadata( [ 'name' => 'failing_tool' ] ); + + $pipeline->execute( $context, function () { + return new \WP_Error( 'some_error', 'Something failed' ); + } ); + + $this->assertNotEmpty( $wpdb->inserts ); + $this->assertSame( 'error', $wpdb->inserts[0]['data']['status'] ); + $this->assertSame( 'some_error', $wpdb->inserts[0]['data']['error_code'] ); + } + + public function testRecordsRouteBasedAudit(): void { + global $wpdb; + + $pipeline = new MiddlewarePipeline(); + $pipeline->add( new AuditMiddleware( new AuditLogger() ) ); + + $context = new RequestContext(); + $request = new \WP_REST_Request( 'GET', '/saltus-framework/v1/models' ); + $context->set_rest_request( $request ); + $context->set_route( '/saltus-framework/v1/models' ); + + $pipeline->execute( $context, function () { + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + + $this->assertNotEmpty( $wpdb->inserts ); + $this->assertStringContainsString( 'rest:', $wpdb->inserts[0]['data']['ability'] ); + } +} diff --git a/tests/MCP/Middleware/CacheMiddlewareTest.php b/tests/MCP/Middleware/CacheMiddlewareTest.php new file mode 100644 index 0000000..b084577 --- /dev/null +++ b/tests/MCP/Middleware/CacheMiddlewareTest.php @@ -0,0 +1,100 @@ +add( new CacheMiddleware( $cache ) ); + $callCount = 0; + + $context = new RequestContext(); + $context->set_args( [ 'type' => 'post_types' ] ); + $context->set_tool_metadata( [ 'name' => 'list_models' ] ); + $context->set_attribute( 'cache_ttl', 300 ); + + $result1 = $pipeline->execute( $context, function () use ( &$callCount ) { + ++$callCount; + return new \WP_REST_Response( [ 'data' => 'fresh' ] ); + } ); + + $this->assertInstanceOf( \WP_REST_Response::class, $result1 ); + $this->assertSame( 1, $callCount ); + + $result2 = $pipeline->execute( $context, function () use ( &$callCount ) { + ++$callCount; + return new \WP_REST_Response( [ 'data' => 'fresh' ] ); + } ); + + $this->assertInstanceOf( \WP_REST_Response::class, $result2 ); + $this->assertSame( 1, $callCount, 'Dispatch should not be called again for cached response' ); + } + + public function testBypassesCacheForNonGet(): void { + $cache = new TransientCache(); + $pipeline = new MiddlewarePipeline(); + $pipeline->add( new CacheMiddleware( $cache ) ); + $callCount = 0; + + $context = new RequestContext(); + $request = new \WP_REST_Request( 'POST', '/saltus-framework/v1/settings/movie' ); + $context->set_rest_request( $request ); + $context->set_attribute( 'cache_ttl', 300 ); + + $result1 = $pipeline->execute( $context, function () use ( &$callCount ) { + ++$callCount; + return new \WP_REST_Response( [ 'updated' => true ] ); + } ); + + $this->assertSame( 1, $callCount ); + + $result2 = $pipeline->execute( $context, function () use ( &$callCount ) { + ++$callCount; + return new \WP_REST_Response( [ 'updated' => true ] ); + } ); + + $this->assertSame( 2, $callCount, 'POST requests should not be cached' ); + } + + public function testPassesErrorResponsesWithoutCaching(): void { + $cache = new TransientCache(); + $pipeline = new MiddlewarePipeline(); + $pipeline->add( new CacheMiddleware( $cache ) ); + + $context = new RequestContext(); + $context->set_args( [ 'bad' => 'args' ] ); + $context->set_tool_metadata( [ 'name' => 'bad_tool' ] ); + + $result = $pipeline->execute( $context, function () { + return new \WP_Error( 'error', 'Something went wrong' ); + } ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + + $cache_key = 'saltus_mcp_' . hash( 'sha256', wp_json_encode( [ + 'tool' => 'bad_tool', + 'args' => [ 'bad' => 'args' ], + 'user' => 1, + 'locale' => 'en_US', + ] ) ); + + $this->assertNull( $cache->get( $cache_key ) ); + } +} diff --git a/tests/MCP/Middleware/ErrorResponseTest.php b/tests/MCP/Middleware/ErrorResponseTest.php new file mode 100644 index 0000000..ed9fd4c --- /dev/null +++ b/tests/MCP/Middleware/ErrorResponseTest.php @@ -0,0 +1,81 @@ +assertInstanceOf( \WP_Error::class, $error ); + $this->assertSame( 'rest_forbidden', $error->get_error_code() ); + $this->assertSame( 403, $error->get_error_data()['status'] ?? null ); + $this->assertSame( 'Assign edit_posts to your user.', $error->get_error_data()['hint'] ?? '' ); + } + + public function testForbiddenMinimal(): void { + $error = ErrorResponse::forbidden(); + + $this->assertSame( 'rest_forbidden', $error->get_error_code() ); + $this->assertArrayNotHasKey( 'hint', $error->get_error_data() ); + } + + public function testNotFound(): void { + $error = ErrorResponse::not_found( 'model', 'Model not registered.' ); + + $this->assertInstanceOf( \WP_Error::class, $error ); + $this->assertSame( 'model_not_found', $error->get_error_code() ); + $this->assertSame( 404, $error->get_error_data()['status'] ?? null ); + $this->assertSame( 'Model not registered.', $error->get_error_data()['hint'] ?? '' ); + } + + public function testNotFoundMinimal(): void { + $error = ErrorResponse::not_found(); + + $this->assertSame( 'not_found', $error->get_error_code() ); + } + + public function testInvalid(): void { + $error = ErrorResponse::invalid( 'post_type', 'Must be a string', 'Provide a valid post type slug.' ); + + $this->assertInstanceOf( \WP_Error::class, $error ); + $this->assertSame( 'rest_invalid_param', $error->get_error_code() ); + $this->assertSame( 400, $error->get_error_data()['status'] ?? null ); + $this->assertSame( 'post_type', $error->get_error_data()['field'] ?? '' ); + $this->assertSame( 'Must be a string', $error->get_error_data()['reason'] ?? '' ); + } + + public function testRateLimited(): void { + $error = ErrorResponse::rate_limited( 30 ); + + $this->assertInstanceOf( \WP_Error::class, $error ); + $this->assertSame( 'rate_limited', $error->get_error_code() ); + $this->assertSame( 429, $error->get_error_data()['status'] ?? null ); + $this->assertSame( 30, $error->get_error_data()['retry_after'] ?? 0 ); + } + + public function testInternalError(): void { + $error = ErrorResponse::internal_error( 'Something broke.', 'Contact support.' ); + + $this->assertInstanceOf( \WP_Error::class, $error ); + $this->assertSame( 'rest_internal_error', $error->get_error_code() ); + $this->assertSame( 500, $error->get_error_data()['status'] ?? null ); + $this->assertSame( 'Contact support.', $error->get_error_data()['hint'] ?? '' ); + } + + public function testDispatchError(): void { + $upstream = new \WP_Error( 'db_error', 'Database query failed.', [ 'status' => 503 ] ); + $error = ErrorResponse::dispatch_error( $upstream ); + + $this->assertInstanceOf( \WP_Error::class, $error ); + $this->assertSame( 'db_error', $error->get_error_code() ); + $this->assertSame( 503, $error->get_error_data()['status'] ?? null ); + } +} diff --git a/tests/MCP/Middleware/MiddlewarePipelineTest.php b/tests/MCP/Middleware/MiddlewarePipelineTest.php new file mode 100644 index 0000000..2b8cedf --- /dev/null +++ b/tests/MCP/Middleware/MiddlewarePipelineTest.php @@ -0,0 +1,135 @@ +add( new class implements MiddlewareInterface { + public function handle( RequestContext $context, callable $next ) { + return new \WP_REST_Response( [ 'handled' => true ] ); + } + } ); + + $context = new RequestContext(); + $result = $pipeline->execute( $context, function () { + return new \WP_REST_Response( [ 'dispatched' => true ] ); + } ); + + $this->assertInstanceOf( \WP_REST_Response::class, $result ); + $data = $result->get_data(); + $this->assertTrue( $data['handled'] ); + } + + public function testExecutesMultipleStagesInOrder(): void { + $pipeline = new MiddlewarePipeline(); + $GLOBALS['_test_log'] = []; + + $pipeline->add( new class implements MiddlewareInterface { + public function handle( RequestContext $context, callable $next ) { + $GLOBALS['_test_log'][] = 'first'; + return $next( $context ); + } + } ); + + $pipeline->add( new class implements MiddlewareInterface { + public function handle( RequestContext $context, callable $next ) { + $GLOBALS['_test_log'][] = 'second'; + return $next( $context ); + } + } ); + + $pipeline->add( new class implements MiddlewareInterface { + public function handle( RequestContext $context, callable $next ) { + $GLOBALS['_test_log'][] = 'third'; + return $next( $context ); + } + } ); + + $context = new RequestContext(); + $pipeline->execute( $context, function () { + $GLOBALS['_test_log'][] = 'dispatch'; + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + + $this->assertSame( [ 'first', 'second', 'third', 'dispatch' ], $GLOBALS['_test_log'] ); + unset( $GLOBALS['_test_log'] ); + } + + public function testShortCircuitsOnError(): void { + $pipeline = new MiddlewarePipeline(); + $GLOBALS['_test_log'] = []; + + $pipeline->add( new class implements MiddlewareInterface { + public function handle( RequestContext $context, callable $next ) { + $GLOBALS['_test_log'][] = 'pass'; + return $next( $context ); + } + } ); + + $pipeline->add( new class implements MiddlewareInterface { + public function handle( RequestContext $context, callable $next ) { + $GLOBALS['_test_log'][] = 'block'; + return new \WP_Error( 'blocked', 'Blocked' ); + } + } ); + + $pipeline->add( new class implements MiddlewareInterface { + public function handle( RequestContext $context, callable $next ) { + $GLOBALS['_test_log'][] = 'should_not_reach'; + return $next( $context ); + } + } ); + + $context = new RequestContext(); + $result = $pipeline->execute( $context, function () { + $GLOBALS['_test_log'][] = 'dispatch'; + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'blocked', $result->get_error_code() ); + $this->assertSame( [ 'pass', 'block' ], $GLOBALS['_test_log'] ); + unset( $GLOBALS['_test_log'] ); + } + + public function testExecutesWithNoStages(): void { + $pipeline = new MiddlewarePipeline(); + + $context = new RequestContext(); + $result = $pipeline->execute( $context, function () { + return new \WP_REST_Response( [ 'direct' => true ] ); + } ); + + $this->assertInstanceOf( \WP_REST_Response::class, $result ); + $data = $result->get_data(); + $this->assertTrue( $data['direct'] ); + } + + public function testContextIsMutableThroughChain(): void { + $pipeline = new MiddlewarePipeline(); + + $pipeline->add( new class implements MiddlewareInterface { + public function handle( RequestContext $context, callable $next ) { + $context->set_attribute( 'trace', 'added' ); + return $next( $context ); + } + } ); + + $context = new RequestContext(); + $pipeline->execute( $context, function ( RequestContext $ctx ) { + $this->assertSame( 'added', $ctx->get_attribute( 'trace' ) ); + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + } +} diff --git a/tests/MCP/Middleware/PermissionMiddlewareTest.php b/tests/MCP/Middleware/PermissionMiddlewareTest.php new file mode 100644 index 0000000..0368838 --- /dev/null +++ b/tests/MCP/Middleware/PermissionMiddlewareTest.php @@ -0,0 +1,114 @@ +createStub( ModelFactory::class ) ); + $pipeline = new MiddlewarePipeline(); + $pipeline->add( new PermissionMiddleware( new CapabilityPolicy( $modeler ) ) ); + + $context = new RequestContext(); + $request = new \WP_REST_Request( 'GET', '/saltus-framework/v1/health' ); + $context->set_rest_request( $request ); + + $result = $pipeline->execute( $context, function () { + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + + $this->assertInstanceOf( \WP_REST_Response::class, $result ); + } + + public function testBlocksWithoutPermission(): void { + global $wp_current_user_can; + $wp_current_user_can = false; + + $modeler = new Modeler( $this->createStub( ModelFactory::class ) ); + $pipeline = new MiddlewarePipeline(); + $pipeline->add( new PermissionMiddleware( new CapabilityPolicy( $modeler ) ) ); + + $context = new RequestContext(); + $request = new \WP_REST_Request( 'GET', '/saltus-framework/v1/models' ); + $context->set_rest_request( $request ); + + $result = $pipeline->execute( $context, function () { + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'rest_forbidden', $result->get_error_code() ); + } + + public function testAllowsToolWithPermissionCallback(): void { + $modeler = new Modeler( $this->createStub( ModelFactory::class ) ); + $pipeline = new MiddlewarePipeline(); + $pipeline->add( new PermissionMiddleware( new CapabilityPolicy( $modeler ) ) ); + + $context = new RequestContext(); + $context->set_tool_metadata( [ + 'name' => 'list_models', + 'has_permission' => function () { + return true; + }, + ] ); + + $result = $pipeline->execute( $context, function () { + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + + $this->assertInstanceOf( \WP_REST_Response::class, $result ); + } + + public function testBlocksToolWithoutPermission(): void { + $modeler = new Modeler( $this->createStub( ModelFactory::class ) ); + $pipeline = new MiddlewarePipeline(); + $pipeline->add( new PermissionMiddleware( new CapabilityPolicy( $modeler ) ) ); + + $context = new RequestContext(); + $context->set_tool_metadata( [ + 'name' => 'delete_post', + 'has_permission' => function () { + return false; + }, + ] ); + + $result = $pipeline->execute( $context, function () { + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'rest_forbidden', $result->get_error_code() ); + } + + public function testPassesThroughWithoutRequestOrTool(): void { + $modeler = new Modeler( $this->createStub( ModelFactory::class ) ); + $pipeline = new MiddlewarePipeline(); + $pipeline->add( new PermissionMiddleware( new CapabilityPolicy( $modeler ) ) ); + + $context = new RequestContext(); + + $result = $pipeline->execute( $context, function () { + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + + $this->assertInstanceOf( \WP_REST_Response::class, $result ); + } +} diff --git a/tests/MCP/Middleware/PipelineIntegrationTest.php b/tests/MCP/Middleware/PipelineIntegrationTest.php new file mode 100644 index 0000000..2f2201e --- /dev/null +++ b/tests/MCP/Middleware/PipelineIntegrationTest.php @@ -0,0 +1,77 @@ +add( new class implements MiddlewareInterface { + public function handle( RequestContext $context, callable $next ) { + return new \WP_Error( 'blocked', 'Blocked by middleware' ); + } + } ); + + $integration = new PipelineIntegration( $pipeline ); + $server = new \WP_REST_Server(); + $request = new \WP_REST_Request( 'GET', '/test' ); + + $result = $integration->on_rest_pre_dispatch( null, $server, $request ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'blocked', $result->get_error_code() ); + } + + public function testRestPreDispatchReturnsResponse(): void { + $pipeline = new MiddlewarePipeline(); + $pipeline->add( new class implements MiddlewareInterface { + public function handle( RequestContext $context, callable $next ) { + return $next( $context ); + } + } ); + + $integration = new PipelineIntegration( $pipeline ); + $server = new \WP_REST_Server(); + $request = new \WP_REST_Request( 'GET', '/saltus-framework/v1/health' ); + + $result = $integration->on_rest_pre_dispatch( null, $server, $request ); + + $this->assertInstanceOf( \WP_REST_Response::class, $result ); + } + + public function testRegisterHooksAddsFilter(): void { + global $wp_filters_registered; + $wp_filters_registered = []; + + $pipeline = new MiddlewarePipeline(); + $integration = new PipelineIntegration( $pipeline ); + $integration->register_rest_hooks(); + + $this->assertArrayHasKey( 'rest_pre_dispatch', $wp_filters_registered ); + $filter = $wp_filters_registered['rest_pre_dispatch'][0] ?? null; + $this->assertNotNull( $filter ); + $this->assertSame( [ $integration, 'on_rest_pre_dispatch' ], $filter['callback'] ); + } + + public function testWithDefaultStagesCreatesPipeline(): void { + $stage = new class implements MiddlewareInterface { + public function handle( RequestContext $context, callable $next ) { + return $next( $context ); + } + }; + + $integration = PipelineIntegration::with_default_stages( $stage ); + + $this->assertInstanceOf( PipelineIntegration::class, $integration ); + } +} diff --git a/tests/MCP/Middleware/RateLimitMiddlewareTest.php b/tests/MCP/Middleware/RateLimitMiddlewareTest.php new file mode 100644 index 0000000..061ae98 --- /dev/null +++ b/tests/MCP/Middleware/RateLimitMiddlewareTest.php @@ -0,0 +1,97 @@ +add( new RateLimitMiddleware( new RateLimiter( 5, 60 ) ) ); + + for ( $i = 0; $i < 5; $i++ ) { + $context = new RequestContext(); + $result = $pipeline->execute( $context, function () { + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + $this->assertInstanceOf( \WP_REST_Response::class, $result ); + } + } + + public function testBlocksRequestOverLimit(): void { + $pipeline = new MiddlewarePipeline(); + $pipeline->add( new RateLimitMiddleware( new RateLimiter( 2, 60 ) ) ); + + $context1 = new RequestContext(); + $pipeline->execute( $context1, function () { + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + + $context2 = new RequestContext(); + $pipeline->execute( $context2, function () { + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + + $context3 = new RequestContext(); + $result = $pipeline->execute( $context3, function () { + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'rate_limited', $result->get_error_code() ); + $this->assertSame( 429, $result->get_error_data()['status'] ?? null ); + } + + public function testAllowsDifferentIdentifiersIndependently(): void { + $pipeline = new MiddlewarePipeline(); + $pipeline->add( new RateLimitMiddleware( new RateLimiter( 1, 60 ) ) ); + + $context = new RequestContext(); + $context->set_tool_metadata( [ 'rate_limit_identifier' => 'client_a' ] ); + $pipeline->execute( $context, function () { + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + + $context2 = new RequestContext(); + $context2->set_tool_metadata( [ 'rate_limit_identifier' => 'client_b' ] ); + $result = $pipeline->execute( $context2, function () { + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + + $this->assertInstanceOf( \WP_REST_Response::class, $result ); + + $context3 = new RequestContext(); + $context3->set_tool_metadata( [ 'rate_limit_identifier' => 'client_a' ] ); + $blocked = $pipeline->execute( $context3, function () { + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + + $this->assertInstanceOf( \WP_Error::class, $blocked ); + } + + public function testSetsRateLimitAttributesOnContext(): void { + $pipeline = new MiddlewarePipeline(); + $pipeline->add( new RateLimitMiddleware( new RateLimiter( 5, 60 ) ) ); + + $context = new RequestContext(); + $pipeline->execute( $context, function ( RequestContext $ctx ) { + $this->assertNotNull( $ctx->get_attribute( 'rate_limit_remaining' ) ); + $this->assertNotNull( $ctx->get_attribute( 'rate_limit_reset_at' ) ); + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + } +} diff --git a/tests/MCP/Middleware/RequestContextTest.php b/tests/MCP/Middleware/RequestContextTest.php new file mode 100644 index 0000000..adebd59 --- /dev/null +++ b/tests/MCP/Middleware/RequestContextTest.php @@ -0,0 +1,76 @@ +assertSame( [], $context->get_args() ); + + $context->set_args( [ 'post_type' => 'movie' ] ); + $this->assertSame( [ 'post_type' => 'movie' ], $context->get_args() ); + } + + public function testRestRequest(): void { + $context = new RequestContext(); + $this->assertNull( $context->get_rest_request() ); + + $request = new \WP_REST_Request( 'GET', '/test' ); + $context->set_rest_request( $request ); + $this->assertSame( $request, $context->get_rest_request() ); + + $context->set_rest_request( null ); + $this->assertNull( $context->get_rest_request() ); + } + + public function testResponse(): void { + $context = new RequestContext(); + $this->assertNull( $context->get_response() ); + + $response = new \WP_REST_Response( [ 'ok' => true ] ); + $context->set_response( $response ); + $this->assertSame( $response, $context->get_response() ); + + $context->set_response( null ); + $this->assertNull( $context->get_response() ); + } + + public function testAttributes(): void { + $context = new RequestContext(); + $this->assertSame( [], $context->get_attributes() ); + $this->assertNull( $context->get_attribute( 'nonexistent' ) ); + $this->assertSame( 'default', $context->get_attribute( 'nonexistent', 'default' ) ); + + $context->set_attribute( 'key1', 'value1' ); + $context->set_attribute( 'key2', 42 ); + $this->assertSame( 'value1', $context->get_attribute( 'key1' ) ); + $this->assertSame( 42, $context->get_attribute( 'key2' ) ); + + $context->set_attributes( [ 'new' => 'all' ] ); + $this->assertSame( [ 'new' => 'all' ], $context->get_attributes() ); + } + + public function testRoute(): void { + $context = new RequestContext(); + $this->assertSame( '', $context->get_route() ); + + $context->set_route( '/saltus-framework/v1/models' ); + $this->assertSame( '/saltus-framework/v1/models', $context->get_route() ); + } + + public function testToolMetadata(): void { + $context = new RequestContext(); + $this->assertSame( [], $context->get_tool_metadata() ); + + $context->set_tool_metadata( [ 'name' => 'list_models', 'parameters' => [] ] ); + $this->assertSame( [ 'name' => 'list_models', 'parameters' => [] ], $context->get_tool_metadata() ); + } +} diff --git a/tests/MCP/Middleware/ValidationMiddlewareTest.php b/tests/MCP/Middleware/ValidationMiddlewareTest.php new file mode 100644 index 0000000..50b7d42 --- /dev/null +++ b/tests/MCP/Middleware/ValidationMiddlewareTest.php @@ -0,0 +1,112 @@ +add( new ValidationMiddleware() ); + + $context = new RequestContext(); + $context->set_args( [ 'post_type' => 'movie' ] ); + $context->set_tool_metadata( [ + 'name' => 'list_posts', + 'parameters' => [ + 'post_type' => [ 'required' => true, 'type' => 'string' ], + ], + ] ); + + $result = $pipeline->execute( $context, function () { + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + + $this->assertInstanceOf( \WP_REST_Response::class, $result ); + } + + public function testRejectsInvalidToolArgs(): void { + $pipeline = new MiddlewarePipeline(); + $pipeline->add( new ValidationMiddleware() ); + + $context = new RequestContext(); + $context->set_args( [ 'post_type' => 123 ] ); + $context->set_tool_metadata( [ + 'name' => 'list_posts', + 'parameters' => [ + 'post_type' => [ 'required' => true, 'type' => 'string' ], + ], + ] ); + + $result = $pipeline->execute( $context, function () { + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'rest_invalid_param', $result->get_error_code() ); + } + + public function testRejectsMissingRequiredToolArgs(): void { + $pipeline = new MiddlewarePipeline(); + $pipeline->add( new ValidationMiddleware() ); + + $context = new RequestContext(); + $context->set_args( [] ); + $context->set_tool_metadata( [ + 'name' => 'list_posts', + 'parameters' => [ + 'post_type' => [ 'required' => true, 'type' => 'string' ], + ], + ] ); + + $result = $pipeline->execute( $context, function () { + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + } + + public function testPassesThroughWithoutSchema(): void { + $pipeline = new MiddlewarePipeline(); + $pipeline->add( new ValidationMiddleware() ); + + $context = new RequestContext(); + $context->set_args( [ 'anything' => 'goes' ] ); + + $result = $pipeline->execute( $context, function () { + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + + $this->assertInstanceOf( \WP_REST_Response::class, $result ); + } + + public function testValidatesRestArgs(): void { + $pipeline = new MiddlewarePipeline(); + $pipeline->add( new ValidationMiddleware() ); + + $context = new RequestContext(); + $request = new \WP_REST_Request( 'GET', '/saltus-framework/v1/models' ); + $request->set_param( 'post_type', 'movie' ); + + $attrs = $request->get_attributes(); + $attrs['args'] = [ + 'post_type' => [ 'required' => true, 'type' => 'string' ], + ]; + + $context->set_rest_request( $request ); + + $result = $pipeline->execute( $context, function () { + return new \WP_REST_Response( [ 'ok' => true ] ); + } ); + + $this->assertInstanceOf( \WP_REST_Response::class, $result ); + } +}