Skip to content

Commit 10f2e19

Browse files
jdevalkjanw-medavidmosterd
authored
Merge feature/create post into main (#28)
* Rough create post command * intermediate commit * Move functions * Works? * Clean up * loop on methods * Added a smaller whitelist * stuff * remove param * DI * Better schema * whitespace * extract tool, use sanitize_key * naming, comments and scoping * First workign prototype * merge * WIP * allowed list naming * make the routes a list that is configuratble open/closed * Add required * small markup * Added some user routes * small todo * Make named variables in REST requests work * Added better debug output handling. * Moved to separate method * clean * make it work with multiple named variables * multiple matches in routes * Remove unneeded function * Ignore vscode folder * return * Dynamicly created tool descriptions. * Ignore the home route. * Housekeeping * Route information * Add a temporary limit on the number of tools * Add debug around skipping tools for registration * Better response output. * route information * cleanup --------- Co-authored-by: Jan-Willem Oostendorp <[email protected]> Co-authored-by: David Mosterd <[email protected]>
1 parent 3268738 commit 10f2e19

File tree

6 files changed

+339
-2
lines changed

6 files changed

+339
-2
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ composer.lock
1111
phpunit.xml
1212
phpcs.xml
1313
.phpcs.xml
14+
.vscode/

src/AiCommand.php

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
* Servers provide context, tools, and prompts to clients
2121
*/
2222
class AiCommand extends WP_CLI_Command {
23+
2324
/**
2425
* Greets the world.
2526
*
@@ -86,6 +87,47 @@ private function register_tools($server, $client) {
8687
]
8788
);
8889

90+
$map_rest_to_mcp = new MapRESTtoMCP();
91+
$map_rest_to_mcp->map_rest_to_mcp( $server );
92+
93+
$server->register_tool(
94+
[
95+
'name' => 'create_post',
96+
'description' => 'Creates a post.',
97+
'inputSchema' => [
98+
'type' => 'object',
99+
'properties' => [
100+
'title' => [
101+
'type' => 'string',
102+
'description' => 'The title of the post.',
103+
],
104+
'content' => [
105+
'type' => 'string',
106+
'description' => 'The content of the post.',
107+
],
108+
'category' => [
109+
'type' => 'string',
110+
'description' => 'The category of the post.',
111+
],
112+
],
113+
'required' => [ 'title', 'content' ],
114+
],
115+
'callable' => function ( $params ) {
116+
$request = new \WP_REST_Request( 'POST', '/wp/v2/posts' );
117+
$request->set_body_params( [
118+
'title' => $params['title'],
119+
'content' => $params['content'],
120+
'categories' => [ $params['category'] ],
121+
'status' => 'publish',
122+
] );
123+
$controller = new \WP_REST_Posts_Controller( 'post' );
124+
$response = $controller->create_item( $request );
125+
$data = $response->get_data();
126+
return $data;
127+
},
128+
]
129+
);
130+
89131
$server->register_tool(
90132
[
91133
'name' => 'calculate_total',
@@ -272,7 +314,6 @@ private function register_tools($server, $client) {
272314
},
273315
]
274316
);
275-
276317
}
277318

278319
// Register resources for AI access

src/MCP/Client.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ static function () {
128128
}
129129

130130
public function call_ai_service_with_prompt( string $prompt ) {
131+
\WP_CLI::debug( "Prompt: {$prompt}", 'mcp_server' );
131132
$parts = new Parts();
132133
$parts->add_text_part( $prompt );
133134
$content = new Content( Content_Role::USER, $parts );

src/MCP/Server.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace WP_CLI\AiCommand\MCP;
44

55
use Exception;
6+
use WP_CLI;
67
use InvalidArgumentException;
78

89
class Server {
@@ -49,6 +50,12 @@ public function register_tool( array $tool_definition ): void {
4950
$description = $tool_definition['description'] ?? null;
5051
$input_schema = $tool_definition['inputSchema'] ?? null;
5152

53+
// TODO: This is a temporary limit.
54+
if ( count( $this->tools ) >= 128 ) {
55+
WP_CLI::debug( 'Too many tools, max is 128', 'tools' );
56+
return;
57+
}
58+
5259
$this->tools[ $name ] = [
5360
'name' => $name,
5461
'callable' => $callable,
@@ -163,7 +170,7 @@ public function handle_request( string $request_data ): false|string {
163170
}
164171
}
165172

166-
private function list_resources() {
173+
public function list_resources() {
167174
$result = [];
168175
foreach ( $this->resources as $resource ) {
169176
$result[] = [

src/MapRESTtoMCP.php

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
<?php
2+
3+
namespace WP_CLI\AiCommand;
4+
5+
use WP_CLI;
6+
use WP_CLI\AiCommand\MCP\Server;
7+
use WP_REST_Request;
8+
9+
10+
class MapRESTtoMCP {
11+
12+
public function args_to_schema( $args = [] ) {
13+
$schema = [];
14+
$required = [];
15+
16+
if ( empty( $args ) ) {
17+
return [];
18+
}
19+
20+
foreach ( $args as $title => $arg ) {
21+
$description = $arg['description'] ?? $title;
22+
$type = $this->sanitize_type( $arg['type'] ?? 'string' );
23+
24+
$schema[ $title ] = [
25+
'type' => $type,
26+
'description' => $description,
27+
];
28+
if ( isset( $arg['required'] ) && $arg['required'] ) {
29+
$required[] = $title;
30+
}
31+
}
32+
33+
return [
34+
'type' => 'object',
35+
'properties' => $schema,
36+
'required' => $required,
37+
];
38+
}
39+
40+
protected function sanitize_type( $type) {
41+
42+
$mapping = array(
43+
'string' => 'string',
44+
'integer' => 'integer',
45+
'number' => 'integer',
46+
'boolean' => 'boolean',
47+
);
48+
49+
// Validated types:
50+
if ( !\is_array($type) && isset($mapping[ $type ]) ) {
51+
return $mapping[ $type ];
52+
}
53+
54+
if ( $type === 'array' || $type === 'object' ) {
55+
return 'string'; // TODO, better solution.
56+
}
57+
if (empty( $type ) || $type === 'null' ) {
58+
return 'string';
59+
}
60+
61+
if ( !\is_array( $type ) ) {
62+
throw new \Exception( 'Invalid type: ' . $type );
63+
return 'string';
64+
}
65+
66+
// Find valid values in array.
67+
if ( \in_array( 'string', $type ) ) {
68+
return 'string';
69+
}
70+
if ( \in_array( 'integer', $type ) ) {
71+
return 'integer';
72+
}
73+
// TODO, better types handling.
74+
return 'string';
75+
76+
}
77+
78+
public function map_rest_to_mcp( Server $mcp_server ) {
79+
$server = rest_get_server();
80+
$routes = $server->get_routes();
81+
82+
foreach ( $routes as $route => $endpoints ) {
83+
foreach ( $endpoints as $endpoint ) {
84+
foreach( $endpoint['methods'] as $method_name => $enabled ) {
85+
$information = new RouteInformation(
86+
$route,
87+
$method_name,
88+
$endpoint['callback'],
89+
);
90+
91+
if ( ! $information->is_wp_rest_controller() ) {
92+
continue;
93+
}
94+
95+
$tool = [
96+
'name' => $information->get_sanitized_route_name(),
97+
'description' => $this->generate_description( $information ),
98+
'inputSchema' => $this->args_to_schema( $endpoint['args'] ),
99+
'callable' => function ( $inputs ) use ( $route, $method_name, $server ){
100+
return $this->rest_callable( $inputs, $route, $method_name, $server );
101+
},
102+
];
103+
104+
$mcp_server->register_tool($tool);
105+
}
106+
}
107+
}
108+
}
109+
110+
/**
111+
* Create description based on route and method.
112+
*
113+
*
114+
* Get a list of posts GET /wp/v2/posts
115+
* Get post with id GET /wp/v2/posts/(?P<id>[\d]+)
116+
*/
117+
protected function generate_description( RouteInformation $information ) : string {
118+
119+
$verb = match ($information->get_method()) {
120+
'GET' => 'Get',
121+
'POST' => 'Create',
122+
'PUT', 'PATCH' => 'Update',
123+
'DELETE' => 'Delete',
124+
};
125+
126+
$schema = $information->get_wp_rest_controller()->get_public_item_schema();
127+
$title = $schema['title'];
128+
129+
$determiner = $information->is_singular()
130+
? 'a'
131+
: 'list of';
132+
133+
return $verb . ' ' . $determiner . ' ' . $title;
134+
}
135+
136+
protected function rest_callable( $inputs, $route, $method_name, \WP_REST_Server $server ) {
137+
preg_match_all( '/\(?P<(\w+)>/', $route, $matches );
138+
139+
foreach( $matches[1] as $match ) {
140+
if ( array_key_exists( $match, $inputs ) ) {
141+
$route = preg_replace( '/(\(\?P<'.$match.'>.*?\))/', $inputs[$match], $route, 1 );
142+
}
143+
}
144+
145+
WP_CLI::debug( 'Rest Route: ' . $route . ' ' . $method_name, 'mcp_server' );
146+
147+
foreach( $inputs as $key => $value ) {
148+
WP_CLI::debug( ' param->' . $key . ' : ' . $value, 'mcp_server' );
149+
}
150+
151+
$request = new WP_REST_Request( $method_name, $route );
152+
$request->set_body_params( $inputs );
153+
154+
/**
155+
* @var WP_REST_Response $response
156+
*/
157+
$response = $server->dispatch( $request );
158+
159+
$data = $server->response_to_data( $response, false );
160+
161+
if( isset( $data[0]['slug'] ) ) {
162+
$debug_data = 'Result List: ';
163+
foreach ( $data as $item ) {
164+
$debug_data .= $item['id'] . '=>' . $item['slug'] . ', ';
165+
}
166+
} elseif( isset( $data['slug'] ) ) {
167+
$debug_data = 'Result: ' . $data['id'] . ' ' . $data['slug'];
168+
} else {
169+
$debug_data = 'Unknown format';
170+
}
171+
WP_CLI::debug( $debug_data, 'mcp_server' );
172+
173+
return $data;
174+
}
175+
}

src/RouteInformation.php

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace WP_CLI\AiCommand;
6+
7+
use BadMethodCallException;
8+
use WP_REST_Controller;
9+
use WP_REST_Posts_Controller;
10+
use WP_REST_Taxonomies_Controller;
11+
use WP_REST_Users_Controller;
12+
13+
class RouteInformation
14+
{
15+
16+
public function __construct(
17+
private string $route,
18+
private string $method,
19+
private $callback,
20+
) {
21+
}
22+
23+
public function get_sanitized_route_name(): string
24+
{
25+
$route = $this->route;
26+
27+
preg_match_all('/\(?P<(\w+)>/', $this->route, $matches);
28+
29+
foreach ($matches[1] as $match) {
30+
$route = preg_replace('/(\(\?P<' . $match . '>.*\))/', 'p_' . $match, $route, 1);
31+
}
32+
33+
return $this->method . '_' . sanitize_title($route);
34+
}
35+
36+
public function get_method(): string
37+
{
38+
return $this->method;
39+
}
40+
41+
public function is_create(): bool
42+
{
43+
return $this->method === 'POST';
44+
}
45+
46+
public function is_update(): bool
47+
{
48+
return $this->method === 'PUT' || $this->method === 'PATCH';
49+
}
50+
51+
public function is_delete(): bool
52+
{
53+
return $this->method === 'DELETE';
54+
}
55+
56+
public function is_get(): bool
57+
{
58+
return $this->method === 'GET';
59+
}
60+
61+
public function is_singular(): bool
62+
{
63+
// Always true
64+
if (str_ends_with($this->route, '(?P<id>[\d]+)')) {
65+
return true;
66+
}
67+
68+
// Never true
69+
if ( ! str_contains($this->route, '?P<id>')) {
70+
return false;
71+
}
72+
73+
return false;
74+
}
75+
76+
public function is_list(): bool
77+
{
78+
return ! $this->is_singular();
79+
}
80+
81+
public function is_wp_rest_controller(): bool
82+
{
83+
// The callback form for a WP_REST_Controller is [ WP_REST_Controller, method ]
84+
if ( ! is_array( $this->callback ) ) {
85+
return false;
86+
}
87+
88+
$allowed = [
89+
WP_REST_Posts_Controller::class,
90+
WP_REST_Users_Controller::class,
91+
WP_REST_Taxonomies_Controller::class,
92+
];
93+
94+
foreach ($allowed as $controller) {
95+
if ($this->callback[0] instanceof $controller) {
96+
return true;
97+
}
98+
}
99+
100+
return false;
101+
}
102+
103+
public function get_wp_rest_controller(): WP_REST_Controller
104+
{
105+
if ( ! $this->is_wp_rest_controller()) {
106+
throw new BadMethodCallException('The callback needs to be a WP_Rest_Controller');
107+
}
108+
109+
return $this->callback[0];
110+
}
111+
112+
}

0 commit comments

Comments
 (0)