Skip to content

Commit 214b298

Browse files
committed
Derive REST method enums from registered routes dynamically
Replace the hardcoded GET/POST/PATCH/DELETE allowlist with methods extracted from live route data via new get_rest_api_methods_from_routes() helper. - Tool schemas for api-function-list, api-function-details-get, and api-function-run now use lazy closures so enums reflect actual registered routes (e.g. PUT appears when any route supports it). - Sanitize the methods filter input with strtoupper/strval normalization. - Add mock rest_get_server() fixture in tests/run.php and assertions covering schema enum content, PUT filtering, and MCP route exclusion. - Add README note clarifying that method schemas are derived at discovery time.
1 parent 2b4378e commit 214b298

4 files changed

Lines changed: 208 additions & 30 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,8 @@ WP-CLI execution is disabled by default. To enable it, define `WP_FORGE_MCP_ENAB
152152
| `wp-forge-api-function-details-get` | Get detailed metadata for a specific REST API route and HTTP method |
153153
| `wp-forge-api-function-run` | Execute a REST API request by route, method, and parameters |
154154

155+
REST method schemas are derived from the registered WordPress REST routes at discovery time, so methods such as `PUT` appear when the underlying API supports them.
156+
155157
## Copy-Paste MCP Configuration
156158

157159
Replace `https://example.com` with your site URL. Create a WordPress Application Password from your user profile, then replace the username and password placeholders below.

src/Abilities.php

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2356,8 +2356,18 @@ private function list_api_functions( $params ) {
23562356
return Response::error( 'This ability requires a WordPress runtime.', 500 );
23572357
}
23582358

2359-
$routes = rest_get_server()->get_routes();
2360-
$items = array();
2359+
$routes = rest_get_server()->get_routes();
2360+
$allowed_methods = $this->get_rest_api_methods_from_routes( $routes );
2361+
$method_filter = array();
2362+
$items = array();
2363+
2364+
if ( ! empty( $params['methods'] ) && is_array( $params['methods'] ) ) {
2365+
$method_filter = array_values(
2366+
array_unique(
2367+
array_map( 'strtoupper', array_map( 'strval', $params['methods'] ) )
2368+
)
2369+
);
2370+
}
23612371
foreach ( $routes as $route => $handlers ) {
23622372
if ( '/mcp/wp-forge' === $route ) {
23632373
continue;
@@ -2377,10 +2387,10 @@ private function list_api_functions( $params ) {
23772387
}
23782388
foreach ( array_keys( $handler['methods'] ) as $method ) {
23792389
$method = strtoupper( $method );
2380-
if ( ! in_array( $method, array( 'GET', 'POST', 'PUT', 'PATCH', 'DELETE' ), true ) ) {
2390+
if ( ! in_array( $method, $allowed_methods, true ) ) {
23812391
continue;
23822392
}
2383-
if ( ! empty( $params['methods'] ) && ! in_array( $method, $params['methods'], true ) ) {
2393+
if ( $method_filter && ! in_array( $method, $method_filter, true ) ) {
23842394
continue;
23852395
}
23862396
$items[] = array(
@@ -2395,6 +2405,53 @@ private function list_api_functions( $params ) {
23952405
return $items;
23962406
}
23972407

2408+
/**
2409+
* Get supported REST methods from registered route handlers.
2410+
*
2411+
* @return array<int,string>
2412+
*/
2413+
private function get_rest_api_methods() {
2414+
if ( ! function_exists( 'rest_get_server' ) ) {
2415+
return array();
2416+
}
2417+
2418+
return $this->get_rest_api_methods_from_routes( rest_get_server()->get_routes() );
2419+
}
2420+
2421+
/**
2422+
* Get supported REST methods from route data.
2423+
*
2424+
* @param array<string,mixed> $routes REST routes.
2425+
* @return array<int,string>
2426+
*/
2427+
private function get_rest_api_methods_from_routes( $routes ) {
2428+
$methods = array();
2429+
2430+
foreach ( $routes as $route => $handlers ) {
2431+
if ( '/mcp/wp-forge' === $route || ! is_array( $handlers ) ) {
2432+
continue;
2433+
}
2434+
2435+
foreach ( $handlers as $handler ) {
2436+
if ( empty( $handler['methods'] ) || ! is_array( $handler['methods'] ) ) {
2437+
continue;
2438+
}
2439+
2440+
foreach ( array_keys( $handler['methods'] ) as $method ) {
2441+
$method = strtoupper( trim( (string) $method ) );
2442+
if ( '' !== $method ) {
2443+
$methods[] = $method;
2444+
}
2445+
}
2446+
}
2447+
}
2448+
2449+
$methods = array_values( array_unique( $methods ) );
2450+
sort( $methods );
2451+
2452+
return $methods;
2453+
}
2454+
23982455
/**
23992456
* Get REST API function details.
24002457
*

src/Tools/RestCatalogTools.php

Lines changed: 38 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -21,36 +21,48 @@ trait RestCatalogTools {
2121
* @return void
2222
*/
2323
private function add_rest_catalog_abilities() {
24-
$this->add_ability( self::INTERNAL_PREFIX . 'api-function-list', 'List API Functions', 'List available WordPress REST API endpoints that support CRUD', $this->schema(
25-
array(
26-
'namespace' => $this->string_prop( 'REST namespace, such as wp/v2.' ),
27-
'methods' => array(
28-
'type' => 'array',
29-
'description' => 'HTTP methods to include.',
30-
'items' => array( 'type' => 'string', 'enum' => array( 'GET', 'POST', 'PATCH', 'DELETE' ) ),
31-
),
32-
'search' => $this->string_prop( 'Route search term.' ),
33-
)
34-
), function ( $params ) {
24+
$method_prop = function () {
25+
return $this->enum_string_prop( 'HTTP method.', $this->get_rest_api_methods() );
26+
};
27+
$list_schema = function () use ( $method_prop ) {
28+
return $this->schema(
29+
array(
30+
'namespace' => $this->string_prop( 'REST namespace, such as wp/v2.' ),
31+
'methods' => array(
32+
'type' => 'array',
33+
'description' => 'HTTP methods to include.',
34+
'items' => $method_prop(),
35+
),
36+
'search' => $this->string_prop( 'Route search term.' ),
37+
)
38+
);
39+
};
40+
$this->add_ability( self::INTERNAL_PREFIX . 'api-function-list', 'List API Functions', 'List available WordPress REST API endpoints that support CRUD', $list_schema, function ( $params ) {
3541
return $this->list_api_functions( $params );
3642
} );
37-
$this->add_ability( self::INTERNAL_PREFIX . 'api-function-details-get', 'Get Function Details', 'Get detailed metadata for a specific REST API route and HTTP method', $this->schema(
38-
array(
39-
'route' => $this->string_prop( 'REST route.' ),
40-
'method' => $this->string_prop( 'HTTP method.' ),
41-
),
42-
array( 'route', 'method' )
43-
), function ( $params ) {
43+
$details_schema = function () use ( $method_prop ) {
44+
return $this->schema(
45+
array(
46+
'route' => $this->string_prop( 'REST route.' ),
47+
'method' => $method_prop(),
48+
),
49+
array( 'route', 'method' )
50+
);
51+
};
52+
$this->add_ability( self::INTERNAL_PREFIX . 'api-function-details-get', 'Get Function Details', 'Get detailed metadata for a specific REST API route and HTTP method', $details_schema, function ( $params ) {
4453
return $this->get_function_details( $params['route'], $params['method'] );
4554
} );
46-
$this->add_ability( self::INTERNAL_PREFIX . 'api-function-run', 'Run API Function', 'Execute a REST API request by route, method, and parameters', $this->schema(
47-
array(
48-
'route' => $this->string_prop( 'REST route.' ),
49-
'method' => $this->string_prop( 'HTTP method.' ),
50-
'parameters' => array( 'type' => 'object', 'description' => 'Request parameters.' ),
51-
),
52-
array( 'route', 'method' )
53-
), function ( $params ) {
55+
$run_schema = function () use ( $method_prop ) {
56+
return $this->schema(
57+
array(
58+
'route' => $this->string_prop( 'REST route.' ),
59+
'method' => $method_prop(),
60+
'parameters' => array( 'type' => 'object', 'description' => 'Request parameters.' ),
61+
),
62+
array( 'route', 'method' )
63+
);
64+
};
65+
$this->add_ability( self::INTERNAL_PREFIX . 'api-function-run', 'Run API Function', 'Execute a REST API request by route, method, and parameters', $run_schema, function ( $params ) {
5466
return $this->run_api_function( $params['route'], $params['method'], isset( $params['parameters'] ) ? $params['parameters'] : array() );
5567
}, false, 'read', array(), array( $this, 'can_run_api_function_request' ) );
5668
}

tests/run.php

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,38 @@ function wp_json_encode( $value, $flags = 0 ) {
6161
'rest_base' => 'mcp-genres',
6262
),
6363
);
64+
$test_rest_routes = array(
65+
'/wp/v2/types' => array(
66+
array(
67+
'methods' => array(
68+
'GET' => true,
69+
),
70+
'args' => array(),
71+
),
72+
),
73+
'/wp/v2/posts/(?P<id>[\d]+)' => array(
74+
array(
75+
'methods' => array(
76+
'GET' => true,
77+
'POST' => true,
78+
'PUT' => true,
79+
'PATCH' => true,
80+
'DELETE' => true,
81+
),
82+
'args' => array(
83+
'id' => array( 'type' => 'integer' ),
84+
),
85+
),
86+
),
87+
'/mcp/wp-forge' => array(
88+
array(
89+
'methods' => array(
90+
'POST' => true,
91+
),
92+
'args' => array(),
93+
),
94+
),
95+
);
6496
$test_post_types = array(
6597
'post' => array(
6698
'name' => 'post',
@@ -254,6 +286,24 @@ function get_allowed_mime_types() {
254286
}
255287
}
256288

289+
if ( ! function_exists( 'rest_get_server' ) ) {
290+
function rest_get_server() {
291+
global $test_rest_routes;
292+
293+
return new class( $test_rest_routes ) {
294+
private $routes;
295+
296+
public function __construct( $routes ) {
297+
$this->routes = $routes;
298+
}
299+
300+
public function get_routes() {
301+
return $this->routes;
302+
}
303+
};
304+
}
305+
}
306+
257307
if ( ! function_exists( 'plugin_basename' ) ) {
258308
function plugin_basename( $file ) {
259309
return basename( $file );
@@ -395,6 +445,25 @@ function assert_same( $expected, $actual, $message ) {
395445
$comment_schema = $abilities->get_schema( 'wp-forge-comment-save' );
396446
assert_true( ! isset( $comment_schema['input_schema']['properties']['status']['enum'] ), 'Comment status schema should stay flexible when custom statuses cannot be reliably discovered.' );
397447

448+
$api_list_schema = $abilities->get_schema( 'wp-forge-api-function-list' );
449+
$api_methods = $api_list_schema['input_schema']['properties']['methods']['items']['enum'];
450+
assert_true( in_array( 'PUT', $api_methods, true ), 'REST list schema should include PUT when a REST route supports it.' );
451+
assert_true( ! in_array( 'OPTIONS', $api_methods, true ), 'REST list schema should be derived from registered route methods.' );
452+
453+
$api_details_schema = $abilities->get_schema( 'wp-forge-api-function-details-get' );
454+
assert_same(
455+
$api_methods,
456+
$api_details_schema['input_schema']['properties']['method']['enum'],
457+
'REST details schema should use the same route-derived methods as list.'
458+
);
459+
460+
$api_run_schema = $abilities->get_schema( 'wp-forge-api-function-run' );
461+
assert_same(
462+
$api_methods,
463+
$api_run_schema['input_schema']['properties']['method']['enum'],
464+
'REST runner schema should use the same route-derived methods as list.'
465+
);
466+
398467
$invalid_search_status = $abilities->call( 'wp-forge-content-search', array( 'post_type' => 'post', 'status' => 'mcp-missing-status' ) );
399468
assert_same( 'error', $invalid_search_status['status'], 'Search content should reject unknown post statuses before querying.' );
400469
assert_same( 400, $invalid_search_status['statusCode'], 'Unknown query post statuses should be a client error.' );
@@ -468,6 +537,39 @@ function assert_same( $expected, $actual, $message ) {
468537
$public_hierarchical_post_types = $abilities->call( 'wp-forge-post-type-list', array( 'public' => true, 'hierarchical' => true ) );
469538
assert_same( array( 'page' ), array_column( $public_hierarchical_post_types['message']['post_types'], 'slug' ), 'Post type list should pass filters through to get_post_types().' );
470539

540+
$put_api_functions = $abilities->call(
541+
'wp-forge-api-function-list',
542+
array(
543+
'namespace' => 'wp/v2',
544+
'methods' => array( 'PUT' ),
545+
)
546+
);
547+
assert_same( 'success', $put_api_functions['status'], 'REST function list should accept route-derived PUT filters.' );
548+
assert_same(
549+
array( 'PUT' ),
550+
array_values( array_unique( array_column( $put_api_functions['message'], 'method' ) ) ),
551+
'REST function list should return PUT routes when supported.'
552+
);
553+
554+
$put_api_details = $abilities->call(
555+
'wp-forge-api-function-details-get',
556+
array(
557+
'route' => '/wp/v2/posts/(?P<id>[\d]+)',
558+
'method' => 'PUT',
559+
)
560+
);
561+
assert_same( 'success', $put_api_details['status'], 'REST function details should accept PUT when the route supports it.' );
562+
assert_same( 'PUT', $put_api_details['message']['method'], 'REST function details should preserve the supported PUT method.' );
563+
564+
$mcp_api_functions = $abilities->call(
565+
'wp-forge-api-function-list',
566+
array(
567+
'methods' => array( 'POST' ),
568+
'search' => '/mcp/wp-forge',
569+
)
570+
);
571+
assert_same( array(), $mcp_api_functions['message'], 'REST function list should not expose the MCP transport route.' );
572+
471573
$wp_ability_names = $abilities->get_wordpress_ability_names();
472574
assert_same( 53, count( $wp_ability_names ), 'Expected all abilities to be available for the MCP adapter.' );
473575
assert_true( in_array( 'wp-forge/content-search', $wp_ability_names, true ), 'Adapter ability list should use WordPress ability names.' );
@@ -489,6 +591,11 @@ function assert_same( $expected, $actual, $message ) {
489591
assert_same( false, $registered_abilities['wp-forge/plugin-activate']['meta']['annotations']['destructive'], 'Activation should not be marked destructive.' );
490592
assert_same( true, $registered_abilities['wp-forge/plugin-activate']['meta']['annotations']['idempotent'], 'Activation should be marked idempotent.' );
491593
assert_same( true, $registered_abilities['wp-forge/plugin-uninstall']['meta']['annotations']['destructive'], 'Uninstall should be marked destructive.' );
594+
assert_same(
595+
$api_methods,
596+
$registered_abilities['wp-forge/api-function-run']['input_schema']['properties']['method']['enum'],
597+
'Registered WordPress REST runner ability should use route-derived methods.'
598+
);
492599
assert_same( true, $registered_abilities['wp-forge/content-search']['permission_callback'](), 'Permission callback should allow users with the ability capability.' );
493600

494601
$registered_result = $registered_abilities['wp-forge/content-search']['execute_callback']( array( 'post_type' => 'post' ) );

0 commit comments

Comments
 (0)