diff --git a/bin/generate-mcp-docs.php b/bin/generate-mcp-docs.php index d83591cf..a193d485 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, $value, ...$args ) { + return $value; + } +} + if ( ! class_exists( 'WP_REST_Request' ) ) { class WP_REST_Request { private string $method; diff --git a/docs/CURRENT.md b/docs/CURRENT.md index 2ce332a9..75bffad4 100644 --- a/docs/CURRENT.md +++ b/docs/CURRENT.md @@ -1,8 +1,9 @@ # 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 +- 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 @@ -111,6 +112,8 @@ - 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 +- 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. diff --git a/docs/MCP-ABILITIES.md b/docs/MCP-ABILITIES.md index d70030d5..41dab324 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 3abc6e2e..92ef6b13 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,9 +109,38 @@ 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 and MCP tools are gated by model configuration. -Enable all Saltus REST-backed capabilities for a model: +### 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 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). + +### 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'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 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: ```php return [ @@ -119,12 +148,12 @@ return [ 'name' => 'book', 'options' => [ 'show_in_rest' => true, - 'saltus_rest' => true, + 'mcp_tools' => true, ], ]; ``` -Enable only selected capabilities: +Example showing various feature-level configurations: ```php return [ @@ -132,16 +161,30 @@ return [ 'name' => 'book', 'options' => [ 'show_in_rest' => true, - 'saltus_rest' => [ - 'models' => true, - 'meta' => true, - 'settings' => true, + 'mcp_tools' => true, + ], + 'config' => [ + // 1. Array style: enabled for REST but disabled for MCP + 'meta' => [ + 'show_in_rest' => true, + 'show_in_mcp' => false, + ], + // 2. Boolean style: disabled for both REST and MCP + 'settings' => false, + + 'features' => [ + // 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 matching the master options (enabled here because show_in_rest & mcp_tools are 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 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 @@ -165,6 +208,7 @@ If `show_in_rest` is explicitly `false`, Saltus does not expose model-scoped RES | `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 | @@ -204,7 +248,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 +320,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 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 @@ -285,7 +329,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 `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 214c9092..60d81f43 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -8,9 +8,11 @@ - 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) +- 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 -- 226 PHPUnit tests passing (639 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 @@ -395,3 +397,105 @@ frontend: ## Tracking - Check GitHub Issues for active sprint items. - Active development on `feature/mcp-v1` branch. + + + +### 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. + +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. + +--- + +#### 6A — Context Control Center + +**Goal:** A Saltus model config area where a plugin defines AI governance rules that MCP tools receive before executing. + +**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 +``` + +| 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 | + +**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. + +--- + +#### 6B — Editorial Review Queue + +**Theme:** Agent-proposed changes go through a human approval workflow instead of publishing directly. + +**Flow:** +``` +AI write -> draft/pending/revision -> human approval -> publish +``` + +| 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 | + +**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. + +--- + +#### 6C — Inside-Admin AI Assistants + +**Theme:** AI operates from inside WordPress admin — buttons beside metabox fields, inline suggestions, and validation. + +| 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 | + +**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. + +--- + +**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/phpunit.xml b/phpunit.xml index 0370eadc..4e715f93 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -9,6 +9,11 @@ beStrictAboutTestsThatDoNotTestAnything="true" failOnRisky="true" failOnWarning="true"> + + + src + + tests/Unit/ diff --git a/src/Features/MCP/MCP.php b/src/Features/MCP/MCP.php index 085242f3..50a5af2a 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/Features/Meta/MetaFieldProvider.php b/src/Features/Meta/MetaFieldProvider.php index 9a729051..3f378a73 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/MCP/Abilities/AbilityDefinitionFactory.php b/src/MCP/Abilities/AbilityDefinitionFactory.php index 33ae5072..03738f5f 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,8 @@ 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 ) ); + /** @var lowercase-string&non-falsy-string */ + 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 0d1f03f6..cec12ab1 100644 --- a/src/MCP/Abilities/AbilityRegistrar.php +++ b/src/MCP/Abilities/AbilityRegistrar.php @@ -1,10 +1,11 @@ tool_provider = $tool_provider ?? new ToolProvider(); $this->definition_factory = $definition_factory ?? new AbilityDefinitionFactory(); - $this->policy = $policy; + $this->mcp_policy = $mcp_policy; } /** @@ -45,11 +46,13 @@ public function register_category(): void { return; } + $category = MCPConfig::get_ability_category(); + \wp_register_ability_category( - 'saltus-framework', + $category['id'], [ - 'label' => 'Saltus Framework', - 'description' => 'Saltus Framework content modeling and administration abilities.', + 'label' => $category['label'], + 'description' => $category['description'], ] ); } @@ -84,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; } @@ -103,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() ); } } diff --git a/src/MCP/MCPConfig.php b/src/MCP/MCPConfig.php new file mode 100644 index 00000000..8a2f6a14 --- /dev/null +++ b/src/MCP/MCPConfig.php @@ -0,0 +1,87 @@ + '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 { + $default = [ + 'id' => 'saltus-framework', + 'label' => 'Saltus Framework', + 'description' => 'Saltus Framework content modeling and administration abilities.', + ]; + + $filtered = \apply_filters( + 'saltus/framework/mcp/ability_category', + $default + ); + + 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; + } + + /** + * Get the prefix used when generating ability names from tool names. + * + * Default: 'saltus/' + * + * @return string + */ + public static function get_ability_prefix(): string { + $prefix = \apply_filters( + 'saltus/framework/mcp/ability_prefix', + 'saltus/' + ); + if ( ! is_string( $prefix ) ) { + return 'saltus/'; + } + return $prefix; + } +} diff --git a/src/MCP/McpPolicy.php b/src/MCP/McpPolicy.php new file mode 100644 index 00000000..165098de --- /dev/null +++ b/src/MCP/McpPolicy.php @@ -0,0 +1,150 @@ +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; + } + + 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; + } + + /** + * 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(); + $global_val = null; + if ( array_key_exists( 'mcp_tools', $options ) ) { + $global_val = (bool) $options['mcp_tools']; + } + + if ( $capability === ModelRestPolicy::CAPABILITY_HEALTH ) { + return true; + } + + 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 $global_val === true; + } + + /** + * 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. + * + * @param array $config + * @param string $capability + * @return bool|null + */ + private function resolve_feature_value( array $config, string $capability ): ?bool { + $section = $this->get_capability_config( $config, $capability ); + if ( $section === null ) { + return null; + } + + if ( ! is_array( $section ) ) { + return (bool) $section; + } + + if ( ! array_key_exists( 'show_in_mcp', $section ) ) { + return true; + } + + return (bool) $section['show_in_mcp']; + } + + /** + * 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(); + + return $models[ $name ] ?? null; + } +} diff --git a/src/MCP/Tools/DuplicatePost.php b/src/MCP/Tools/DuplicatePost.php index 12ce4b89..26701f37 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 b2fa772a..485644a9 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/GetHealth.php b/src/MCP/Tools/GetHealth.php index 5999390e..500d2301 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 27bc9f45..831b35cc 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 683aaf54..b8fc36f0 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/GetSettings.php b/src/MCP/Tools/GetSettings.php index a37bd4fc..313f3864 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/ListMetaFields.php b/src/MCP/Tools/ListMetaFields.php index 424b1ed1..69e7bfd1 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 d71a533e..31b68141 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/ReorderPosts.php b/src/MCP/Tools/ReorderPosts.php index f91db221..8ff85bca 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/RestTool.php b/src/MCP/Tools/RestTool.php index c98bbad9..d0af73e1 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 '/' . trim( MCPConfig::get_namespace(), '/' ) . '/' . ltrim( $path, '/' ); + } + /** * Build and return a WP_REST_Request instance. * diff --git a/src/MCP/Tools/UpdateMetaFields.php b/src/MCP/Tools/UpdateMetaFields.php index dac7e241..b83b5fa5 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 ); diff --git a/src/MCP/Tools/UpdateSettings.php b/src/MCP/Tools/UpdateSettings.php index 17f95673..26bb4e98 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 ); } /** diff --git a/src/Modeler.php b/src/Modeler.php index 4739a4e4..ec86cf30 100644 --- a/src/Modeler.php +++ b/src/Modeler.php @@ -35,11 +35,22 @@ 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 +61,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 +84,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 +193,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 +203,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 +226,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/src/Models/BaseModel.php b/src/Models/BaseModel.php index be41dc98..4019189e 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 31c1ef13..e67c072a 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/src/Models/PostType.php b/src/Models/PostType.php index 062ea33d..40fe4647 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, diff --git a/src/Models/Taxonomy.php b/src/Models/Taxonomy.php index abb8f542..e17771b4 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', ]; diff --git a/src/Rest/DuplicateController.php b/src/Rest/DuplicateController.php index ea1069a7..9352c82a 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'; } @@ -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( - self::ROUTE_NAMESPACE, + $namespace, '/' . $this->rest_base . '/(?P\d+)', [ 'methods' => WP_REST_Server::CREATABLE, @@ -98,7 +100,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 '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 0d8e8fb7..2d32d029 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'; } @@ -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( - self::ROUTE_NAMESPACE, + $namespace, '/' . $this->rest_base . '/(?P\d+)', [ 'methods' => WP_REST_Server::READABLE, @@ -96,7 +98,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 'features' => [ 'single_export' => ... ] in the model config for '%s' in src/models/.", 'saltus-framework' ), $post->post_type ), ] diff --git a/src/Rest/HealthController.php b/src/Rest/HealthController.php index 720af2a0..9a973689 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'; } @@ -30,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( - self::ROUTE_NAMESPACE, + $namespace, '/' . $this->rest_base, [ 'methods' => WP_REST_Server::READABLE, diff --git a/src/Rest/MetaController.php b/src/Rest/MetaController.php index d3919587..8d96a0cb 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'; } @@ -42,8 +41,10 @@ public function register_routes(): void { return; } + /** @var non-falsy-string $namespace */ + $namespace = $this->namespace; register_rest_route( - self::ROUTE_NAMESPACE, + $namespace, '/' . $this->rest_base, [ 'methods' => WP_REST_Server::READABLE, @@ -53,7 +54,7 @@ public function register_routes(): void { ); register_rest_route( - self::ROUTE_NAMESPACE, + $namespace, '/' . $this->rest_base . '/(?P[a-z0-9_-]+)', [ 'methods' => WP_REST_Server::READABLE, @@ -70,7 +71,7 @@ public function register_routes(): void { ); register_rest_route( - self::ROUTE_NAMESPACE, + $namespace, '/' . $this->rest_base . '/(?P[a-z0-9_-]+)/(?P\d+)', [ 'methods' => WP_REST_Server::EDITABLE, @@ -115,7 +116,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' ), ] ); } @@ -172,7 +173,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/ModelRestPolicy.php b/src/Rest/ModelRestPolicy.php index cc23a7aa..095cf402 100644 --- a/src/Rest/ModelRestPolicy.php +++ b/src/Rest/ModelRestPolicy.php @@ -40,22 +40,83 @@ 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']; } - $saltus_rest = $options['saltus_rest'] ?? false; - if ( $saltus_rest === true ) { + if ( $capability === self::CAPABILITY_HEALTH ) { return true; } - if ( ! is_array( $saltus_rest ) ) { - return false; + if ( $capability === self::CAPABILITY_MODELS ) { + return $global_val !== false; + } + + $config = $model->get_config(); + $feature_val = $this->resolve_feature_value( $config, $capability ); + + if ( $feature_val !== null ) { + return $feature_val; + } + + return $global_val === true; + } + + /** + * 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 === self::CAPABILITY_META || $capability === self::CAPABILITY_SETTINGS ) { + return $config[ $capability ] ?? null; + } + + $features = $config['features'] ?? []; + if ( ! is_array( $features ) ) { + return null; + } + + $feature_keys = [ + self::CAPABILITY_DUPLICATE => 'duplicate', + self::CAPABILITY_EXPORT => 'single_export', + self::CAPABILITY_REORDER => 'drag_and_drop', + ]; + + $key = $feature_keys[ $capability ] ?? null; + if ( $key === null ) { + return null; + } + + return $features[ $key ] ?? null; + } + + /** + * Resolve the capability value from the feature configuration. + * + * @param array $config + * @param string $capability + * @return bool|null + */ + private function resolve_feature_value( array $config, string $capability ): ?bool { + $section = $this->get_capability_config( $config, $capability ); + if ( $section === null ) { + return null; + } + + if ( ! is_array( $section ) ) { + return (bool) $section; + } + + if ( ! array_key_exists( 'show_in_rest', $section ) ) { + return true; } - return ! empty( $saltus_rest[ $capability ] ); + return (bool) $section['show_in_rest']; } public function is_post_type_enabled( string $post_type, string $capability ): bool { diff --git a/src/Rest/ModelsController.php b/src/Rest/ModelsController.php index 282b5958..607892c4 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'; } @@ -36,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( - self::ROUTE_NAMESPACE, + $namespace, '/' . $this->rest_base, [ 'methods' => WP_REST_Server::READABLE, @@ -47,7 +48,7 @@ public function register_routes(): void { ); register_rest_route( - self::ROUTE_NAMESPACE, + $namespace, '/' . $this->rest_base . '/(?P[a-z0-9_-]+)', [ 'methods' => WP_REST_Server::READABLE, @@ -77,7 +78,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' ), ] ); } @@ -104,7 +105,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 db233e4f..4e5494d2 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'; } @@ -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( - self::ROUTE_NAMESPACE, + $namespace, '/' . $this->rest_base, [ 'methods' => WP_REST_Server::CREATABLE, @@ -83,7 +85,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 5ddab472..6d80323e 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'; } @@ -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( - self::ROUTE_NAMESPACE, + $namespace, '/' . $this->rest_base . '/(?P[a-z0-9_-]+)', [ [ @@ -82,7 +84,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 +150,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 +187,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 +213,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 ), ] diff --git a/tests/Features/MCPFeatureTest.php b/tests/Features/MCPFeatureTest.php index a120d17c..29d77aef 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/Integration/RestRegistrationTest.php b/tests/Integration/RestRegistrationTest.php index beeb8de4..c2510f9a 100644 --- a/tests/Integration/RestRegistrationTest.php +++ b/tests/Integration/RestRegistrationTest.php @@ -93,6 +93,36 @@ 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->assertTrue( $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->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 { $controller = new HealthController( '1.0.0' ); $this->assertTrue( method_exists( $controller, 'register_routes' ) ); diff --git a/tests/MCP/Abilities/AbilityRegistrarTest.php b/tests/MCP/Abilities/AbilityRegistrarTest.php index 0e4abbb3..c21a2c2f 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,20 @@ public function testRegisterFiltersRestBackedAbilitiesWhenPolicyIsInjected(): vo [ 'book' => $this->createModelMock( [ - 'show_in_rest' => true, - 'saltus_rest' => [ - 'models' => true, - 'meta' => true, + 'mcp_tools' => true, + ], + [ + 'meta' => [], + 'settings' => false, + 'features' => [ + 'duplicate' => false, ], ] ), ] ); - $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 +420,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 +458,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/MCPConfigTest.php b/tests/MCP/MCPConfigTest.php new file mode 100644 index 00000000..723392d2 --- /dev/null +++ b/tests/MCP/MCPConfigTest.php @@ -0,0 +1,140 @@ +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 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(); + + $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 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() ); + } + + 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() ); + } +} diff --git a/tests/MCP/McpPolicyTest.php b/tests/MCP/McpPolicyTest.php new file mode 100644 index 00000000..9b153a4f --- /dev/null +++ b/tests/MCP/McpPolicyTest.php @@ -0,0 +1,239 @@ +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 testIsEnabledHandlesMissingFeaturesConfigDefensively(): void { + $modeler = $this->createStub( Modeler::class ); + $policy = new McpPolicy( $modeler ); + + $model = $this->createModelMock( + [ 'mcp_tools' => true ], + [] // config has no features key + ); + + $this->assertTrue( $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->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 { + $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; } + }; + } +} diff --git a/tests/MCP/Tools/DeletePostTest.php b/tests/MCP/Tools/DeletePostTest.php new file mode 100644 index 00000000..14cff093 --- /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 00000000..8676f15a --- /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( [] ) ); + } +} diff --git a/tests/Rest/DuplicateControllerTest.php b/tests/Rest/DuplicateControllerTest.php index 91935480..eab795c3 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 3cdb9158..abf9fafd 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 6a8fad05..e2cc5516 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 cdb65481..9033dfe5 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 3b3c3545..a5f343ed 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, ], ] ), @@ -90,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( @@ -101,7 +120,7 @@ public function testRegisterRoutesRegistersOnlyHealthWithoutOptIn(): void { $this->createServer()->register_routes(); - $this->assertCount( 1, $wp_rest_routes_registered ); + $this->assertCount( 10, $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 339265fe..44db7284 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; + } }; } diff --git a/tests/Rest/functions.php b/tests/Rest/functions.php index 0ecc8ab1..2d5e5aa6 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; diff --git a/tests/Unit/ModelerLegacyTest.php b/tests/Unit/ModelerLegacyTest.php index 4904e567..5dd0b93d 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 []; + } }