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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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 ) ) {