Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ Don't name specific third-party block namespaces as "legacy" in comments, error
1. Add the tool def to the right `*_TOOLS` array in `src/tools/*.ts` (`name`, `description`, `inputSchema`).
2. Add the `case` to the module's `handle*Tool()`; call a `WordPressBlockClient` method; enrich.
3. `npm run build`.
4. **Wire it into the WordPress Abilities API too** — `buildManifest()` in `scripts/export-abilities-manifest.mjs` only exports tool groups it explicitly imports/spreads; a new `*_TOOLS` array (a whole new module) needs its own `import` + spread there, or it never reaches `tools.manifest.json` and is invisible to `Abilities_Registry`/the MCP Adapter regardless of step 3. Map the new tool(s) to a `permission` key in `permissionFor()` — reuse an existing bucket (`read`, `edit_post`, `create_post`, `upload_files`, `manage_options`) when an existing REST permission callback already enforces the right check, or add a new bucket (see `TEMPLATE_EDIT`) plus a matching `case` in `Abilities_Registry::check_tool_permission()` that calls the *same* `REST_Controller` permission-callback method the REST route uses — never re-implement the gate logic in the ability's permission check, or the two surfaces can drift out of parity. Then add the dispatch `case` + `execute_*()` method to `Tool_Executor::execute()` (delegates to the controller via `call_controller()`) — a manifest entry with no `Tool_Executor` case registers as an ability but 400s "Unknown Block MCP tool" the moment anything calls it. Run `node scripts/export-abilities-manifest.mjs` (regenerates the manifest) and `npm run build` (bakes the bundle); commit both.

### Add a mutation operation
Add to the `MutationOp` union (`src/types.ts`), the `enum` in `REST_Controller` + the `edit_block_tree` `inputSchema` + `VALID_OPS` (`src/tools/mutate.ts`), and a `case` in `Block_Mutator::mutate()`. Maintain the `innerContent` null-placeholder invariant when child count changes.
Expand Down
14 changes: 14 additions & 0 deletions scripts/export-abilities-manifest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,19 @@ const UPLOAD = new Set(['upload_media']);
/** @type {ReadonlySet<string>} */
const CREATE = new Set(['create_post']);

/**
* Template writes, gated by the site's own toggle (Template_Manager::edits_enabled(),
* option gk_block_api_template_edits + filter gk/block-mcp/templates/allow-edits)
* in addition to a capability check. Mapped to their own permission key so
* Abilities_Registry::check_tool_permission() can route them to
* check_template_edit_permissions() instead of the ungated default 'edit_post'
* branch — the same gate their REST twins (POST /template, POST /template/reset)
* enforce.
*
* @type {ReadonlySet<string>}
*/
const TEMPLATE_EDIT = new Set(['update_template', 'reset_template']);

/** @type {ReadonlySet<string>} */
const READ = new Set([
'list_block_types',
Expand Down Expand Up @@ -67,6 +80,7 @@ function permissionFor(name, annotations) {
if (MANAGE_OPTIONS.has(name)) return 'manage_options';
if (UPLOAD.has(name)) return 'upload_files';
if (CREATE.has(name)) return 'create_post';
if (TEMPLATE_EDIT.has(name)) return 'template_edit';
if (PER_POST_READ.has(name)) return 'edit_post';
if (READ.has(name) || annotations?.readOnlyHint === true) return 'read';
return 'edit_post';
Expand Down
27 changes: 27 additions & 0 deletions tests/abilities-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,31 @@ describe('tools.manifest.json matches the current npm tool definitions', () => {
'yoast_get_seo returns single-post SEO data; its REST twin (Yoast_Bridge::check_permissions) requires edit_post on the target post, not only the global read permission',
).toBe('edit_post');
});

it('includes the templates tool group', () => {
const generated = buildManifest();
const names = generated.tools.map((t) => t.name);
expect(names).toEqual(
expect.arrayContaining(['list_templates', 'get_template', 'update_template', 'reset_template']),
);
});

it('scopes list_templates and get_template to the read permission', () => {
const generated = buildManifest();
for (const name of ['list_templates', 'get_template']) {
const tool = generated.tools.find((t) => t.name === name);
expect(tool?.permission, `${name} should use the blanket read permission`).toBe('read');
}
});

it('scopes update_template and reset_template to a gated template_edit permission, not plain edit_post', () => {
const generated = buildManifest();
for (const name of ['update_template', 'reset_template']) {
const tool = generated.tools.find((t) => t.name === name);
expect(
tool?.permission,
`${name}'s REST twin is gated by the gk_block_api_template_edits toggle (Template_Manager::edits_enabled()) in addition to a capability check — the manifest permission must route Abilities_Registry::check_tool_permission() to that gate, not the ungated default 'edit_post' branch`,
).toBe('template_edit');
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2166,7 +2166,7 @@
"output_schema": {
"type": "object"
},
"permission": "edit_post",
"permission": "template_edit",
"annotations": {
"readonly": false,
"destructive": true,
Expand Down Expand Up @@ -2201,7 +2201,7 @@
"output_schema": {
"type": "object"
},
"permission": "edit_post",
"permission": "template_edit",
"annotations": {
"readonly": false,
"destructive": true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,14 @@ private function check_tool_permission( string $permission, array $input ) {
);
case 'create_post':
return $this->controller_check( array( $this->controller, 'check_edit_permissions' ) );
case 'template_edit':
// Delegates to the exact same permission callback the REST
// routes use (POST /template, POST /template/reset) so the
// Abilities surface can never bypass the toggle
// (Template_Manager::edits_enabled()) or its capability half
// (edit_posts or edit_theme_options) — no gate logic is
// re-implemented here.
return $this->controller_check( array( $this->controller, 'check_template_edit_permissions' ) );
case 'edit_post':
default:
$base = $this->controller_check( array( $this->controller, 'check_edit_permissions' ) );
Expand Down
115 changes: 115 additions & 0 deletions wordpress-plugin/gk-block-mcp/includes/class-tool-executor.php
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,14 @@ public function execute( string $tool_name, array $input ) {
return $this->execute_yoast_update_seo( $input );
case 'yoast_bulk_update_seo':
return $this->execute_yoast_bulk_update_seo( $input );
case 'list_templates':
return $this->execute_list_templates( $input );
case 'get_template':
return $this->execute_get_template( $input );
case 'update_template':
return $this->execute_update_template( $input );
case 'reset_template':
return $this->execute_reset_template( $input );
default:
return new \WP_Error(
'unknown_tool',
Expand Down Expand Up @@ -971,6 +979,113 @@ private function execute_yoast_bulk_update_seo( array $input ) {
);
}

/**
* List a block theme's templates/template parts via the templates REST handler.
*
* @since 2.2.0
*
* @param array<string, mixed> $input Tool input.
* @return array<string, mixed>|\WP_Error
*/
private function execute_list_templates( array $input ) {
$params = array(
'type' => isset( $input['type'] ) ? (string) $input['type'] : null,
'area' => isset( $input['area'] ) ? (string) $input['area'] : null,
'post_type' => isset( $input['post_type'] ) ? (string) $input['post_type'] : null,
'slug' => isset( $input['slug'] ) ? (string) $input['slug'] : null,
'source' => isset( $input['source'] ) ? (string) $input['source'] : null,
);

return $this->call_controller(
array( $this->controller, 'get_templates' ),
new \WP_REST_Request( 'GET', '/' . REST_Controller::NAMESPACE . '/templates' ),
$params
);
}

/**
* Fetch a single template's metadata, raw content, and parsed blocks via
* the template REST handler.
*
* @since 2.2.0
*
* @param array<string, mixed> $input Tool input.
* @return array<string, mixed>|\WP_Error
*/
private function execute_get_template( array $input ) {
$id = isset( $input['id'] ) ? (string) $input['id'] : '';
if ( '' === $id ) {
return new \WP_Error( 'missing_id', __( 'id is required.', 'gk-block-mcp' ), array( 'status' => 400 ) );
}

$params = array(
'id' => $id,
'type' => isset( $input['type'] ) ? (string) $input['type'] : null,
);

$request = new \WP_REST_Request( 'GET', '/' . REST_Controller::NAMESPACE . '/template' );
return $this->call_controller( array( $this->controller, 'get_template' ), $request, $params );
}

/**
* Replace a template's entire content via the gated template-update REST
* handler. Permission (the gk_block_api_template_edits toggle plus
* edit_posts/edit_theme_options) is enforced by
* Abilities_Registry::check_tool_permission()'s 'template_edit' branch
* before this method ever runs.
*
* @since 2.2.0
*
* @param array<string, mixed> $input Tool input.
* @return array<string, mixed>|\WP_Error
*/
private function execute_update_template( array $input ) {
$id = isset( $input['id'] ) ? (string) $input['id'] : '';
if ( '' === $id ) {
return new \WP_Error( 'missing_id', __( 'id is required.', 'gk-block-mcp' ), array( 'status' => 400 ) );
}

$body = array();
if ( isset( $input['content'] ) && is_string( $input['content'] ) ) {
$body['content'] = $input['content'];
}
if ( isset( $input['blocks'] ) && is_array( $input['blocks'] ) ) {
$body['blocks'] = $input['blocks'];
}

$params = array(
'id' => $id,
'type' => isset( $input['type'] ) ? (string) $input['type'] : null,
);

$request = new \WP_REST_Request( 'POST', '/' . REST_Controller::NAMESPACE . '/template' );
return $this->call_controller( array( $this->controller, 'update_template' ), $request, $params, $body );
}

/**
* Delete a template's database override via the gated template-reset REST
* handler. Gated the same way as execute_update_template().
*
* @since 2.2.0
*
* @param array<string, mixed> $input Tool input.
* @return array<string, mixed>|\WP_Error
*/
private function execute_reset_template( array $input ) {
$id = isset( $input['id'] ) ? (string) $input['id'] : '';
if ( '' === $id ) {
return new \WP_Error( 'missing_id', __( 'id is required.', 'gk-block-mcp' ), array( 'status' => 400 ) );
}

$params = array(
'id' => $id,
'type' => isset( $input['type'] ) ? (string) $input['type'] : null,
);

$request = new \WP_REST_Request( 'POST', '/' . REST_Controller::NAMESPACE . '/template/reset' );
return $this->call_controller( array( $this->controller, 'reset_template' ), $request, $params );
}

/**
* Invoke a REST controller handler and normalize its response payload.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,11 @@ public function set_up(): void {
/**
* Abilities_Registry::load_manifest() must parse the checked-in
* tools.manifest.json and return the full, current 32-tool set, matching
* the npm MCP server's tool list one-for-one.
* the npm MCP server's tool list one-for-one (this includes the four
* templates tools — manifest registration is data-driven and doesn't
* depend on Tool_Executor actually knowing how to run a tool; see
* TemplateAbilitiesTest for coverage that the templates abilities also
* execute correctly and honor their write gate).
*
* The manifest (generated by scripts/export-abilities-manifest.mjs) is
* the only thing register_abilities() reads: if it fails to parse — or
Expand All @@ -63,6 +67,10 @@ public function test_manifest_lists_all_block_mcp_tools() {
$this->assertContains( 'get_page_blocks', $names );
$this->assertContains( 'edit_block_tree', $names );
$this->assertContains( 'site_editor_context', $names );
$this->assertContains( 'list_templates', $names );
$this->assertContains( 'get_template', $names );
$this->assertContains( 'update_template', $names );
$this->assertContains( 'reset_template', $names );
}

/**
Expand Down Expand Up @@ -149,6 +157,10 @@ public function test_ability_ids_use_expected_namespace() {
$this->assertContains( 'gk-block-mcp/get-page-blocks', $ids );
$this->assertContains( 'gk-block-mcp/edit-block-tree', $ids );
$this->assertContains( 'gk-block-mcp/site-editor-context', $ids );
$this->assertContains( 'gk-block-mcp/list-templates', $ids );
$this->assertContains( 'gk-block-mcp/get-template', $ids );
$this->assertContains( 'gk-block-mcp/update-template', $ids );
$this->assertContains( 'gk-block-mcp/reset-template', $ids );
$this->assertNotContains( 'gk-block-mcp/yoast-get-seo', $ids );
}

Expand Down Expand Up @@ -580,10 +592,14 @@ public function test_read_abilities_are_annotated_readonly() {
$get = wp_get_ability( 'gk-block-mcp/get-page-blocks' )->get_meta();
$list = wp_get_ability( 'gk-block-mcp/list-block-types' )->get_meta();
$update = wp_get_ability( 'gk-block-mcp/update-block' )->get_meta();
$templates = wp_get_ability( 'gk-block-mcp/list-templates' )->get_meta();
$template = wp_get_ability( 'gk-block-mcp/get-template' )->get_meta();

$this->assertTrue( $get['annotations']['readonly'] );
$this->assertTrue( $list['annotations']['readonly'] );
$this->assertFalse( $update['annotations']['readonly'] );
$this->assertTrue( $templates['annotations']['readonly'] );
$this->assertTrue( $template['annotations']['readonly'] );
}

/**
Expand All @@ -592,20 +608,26 @@ public function test_read_abilities_are_annotated_readonly() {
* treats as destructive). update-block and update-blocks overwrite
* existing block content — both TRUE, the manifest's current
* destructiveHint semantics; insert-blocks and create-post only add
* content — FALSE; delete-block removes blocks — TRUE.
* content — FALSE; delete-block removes blocks — TRUE. update-template
* replaces a whole template and reset-template deletes an override —
* both TRUE, matching src/tools/templates.ts's WRITE_ANNOT.
*/
public function test_write_abilities_declare_destructive_annotation() {
$update = wp_get_ability( 'gk-block-mcp/update-block' )->get_meta()['annotations'];
$updates = wp_get_ability( 'gk-block-mcp/update-blocks' )->get_meta()['annotations'];
$insert = wp_get_ability( 'gk-block-mcp/insert-blocks' )->get_meta()['annotations'];
$create = wp_get_ability( 'gk-block-mcp/create-post' )->get_meta()['annotations'];
$delete = wp_get_ability( 'gk-block-mcp/delete-block' )->get_meta()['annotations'];
$update_template = wp_get_ability( 'gk-block-mcp/update-template' )->get_meta()['annotations'];
$reset_template = wp_get_ability( 'gk-block-mcp/reset-template' )->get_meta()['annotations'];

$this->assertTrue( $update['destructive'], 'update-block overwrites existing block content' );
$this->assertTrue( $updates['destructive'], 'update-blocks overwrites existing block content' );
$this->assertFalse( $insert['destructive'], 'insert-blocks only adds blocks' );
$this->assertFalse( $create['destructive'], 'create-post only adds a post' );
$this->assertTrue( $delete['destructive'], 'delete-block removes blocks' );
$this->assertTrue( $update_template['destructive'], 'update-template replaces a whole template' );
$this->assertTrue( $reset_template['destructive'], 'reset-template deletes an override' );
}

/**
Expand Down
Loading
Loading