Skip to content

Commit 63279b9

Browse files
committed
Expose _all_ CLI commands as MCP tools
1 parent a8df854 commit 63279b9

File tree

1 file changed

+95
-179
lines changed

1 file changed

+95
-179
lines changed

src/MCP/Servers/WP_CLI/Tools/CliCommands.php

Lines changed: 95 additions & 179 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use Psr\Log\LoggerInterface;
77
use WP_CLI;
88
use WP_CLI\SynopsisParser;
9+
use function WP_CLI\Dispatcher\get_path;
910

1011
/**
1112
* @phpstan-import-type ToolDefinition from Server
@@ -14,220 +15,135 @@
1415
public function __construct( private LoggerInterface $logger ) {
1516
}
1617

18+
/**
19+
* @param WP_CLI\Dispatcher\CompositeCommand $command Command instance.
20+
* @return array<WP_CLI\Dispatcher\CompositeCommand>
21+
*/
22+
private function get_commands( WP_CLI\Dispatcher\CompositeCommand $command ): array {
23+
if ( WP_CLI::get_runner()->is_command_disabled( $command ) ) {
24+
return [];
25+
}
26+
27+
// Value is different if it's a RootCommand instance.
28+
// @phpstan-ignore booleanNot.alwaysFalse
29+
if ( ! $command->can_have_subcommands() ) {
30+
return [ $command ];
31+
}
32+
33+
$commands = [];
34+
35+
/**
36+
* @var WP_CLI\Dispatcher\CompositeCommand $subcommand
37+
*/
38+
foreach ( $command->get_subcommands() as $subcommand ) {
39+
array_push( $commands, ...$this->get_commands( $subcommand ) );
40+
}
41+
42+
return $commands;
43+
}
44+
1745
/**
1846
* Returns a list of tools.
1947
*
2048
* @return array<int, ToolDefinition> Tools.
2149
*/
2250
public function get_tools(): array {
23-
// Expose WP-CLI commands as tools
24-
$commands = [
25-
'cache',
26-
'config',
27-
'core',
28-
'maintenance-mode',
29-
'profile',
30-
'rewrite',
31-
];
51+
$commands = $this->get_commands( WP_CLI::get_root_command() );
3252

3353
$tools = [];
3454

55+
/**
56+
* Command class.
57+
*
58+
* @var WP_CLI\Dispatcher\RootCommand|WP_CLI\Dispatcher\Subcommand $command
59+
*/
3560
foreach ( $commands as $command ) {
36-
$result = WP_CLI::get_runner()->find_command_to_run( [ $command ] );
61+
$command_name = implode( ' ', get_path( $command ) );
3762

38-
// Command not found/installed.
39-
if ( is_string( $result ) ) {
40-
continue;
41-
}
42-
43-
[ $command ] = $result;
63+
$command_desc = $command->get_shortdesc();
64+
$command_synopsis = $command->get_synopsis();
4465

4566
/**
46-
* Command class.
67+
* Parsed synopsys.
4768
*
48-
* @var WP_CLI\Dispatcher\RootCommand|WP_CLI\Dispatcher\Subcommand $command
69+
* @var array<int, array{optional?: bool, type: string, repeating: bool, name: string}> $synopsis_spec
4970
*/
50-
$command_name = $command->get_name();
71+
$synopsis_spec = SynopsisParser::parse( $command_synopsis );
5172

52-
if ( ! $command->can_have_subcommands() ) {
73+
$properties = [];
74+
$required = [];
5375

54-
$command_desc = $command->get_shortdesc();
55-
$command_synopsis = $command->get_synopsis();
76+
$this->logger->debug( "Synopsis for command: \"$command_name\"" . ' - ' . print_r( $command_synopsis, true ) );
5677

57-
/**
58-
* Parsed synopsys.
59-
*
60-
* @var array<int, array{optional?: bool, type: string, repeating: bool, name: string}> $synopsis_spec
61-
*/
62-
$synopsis_spec = SynopsisParser::parse( $command_synopsis );
78+
foreach ( $synopsis_spec as $arg ) {
79+
if ( 'positional' === $arg['type'] || 'assoc' === $arg['type'] ) {
80+
$prop_name = str_replace( '-', '_', $arg['name'] );
81+
$properties[ $prop_name ] = [
82+
'type' => 'string',
83+
'description' => "Parameter {$arg['name']}",
84+
];
6385

64-
$properties = [];
65-
$required = [];
86+
if ( ! isset( $arg['optional'] ) || ! $arg['optional'] ) {
87+
$required[] = $prop_name;
88+
}
89+
}
90+
}
6691

92+
if ( empty( $properties ) ) {
93+
// Some commands such as "wp cache flush" don't take any parameters,
94+
// but the MCP SDK doesn't seem to like empty $properties.
6795
$properties['dummy'] = [
6896
'type' => 'string',
6997
'description' => 'Dummy parameter',
7098
];
99+
}
71100

72-
$this->logger->debug( 'Synopsis for command: ' . $command_name . ' - ' . print_r( $command_synopsis, true ) );
73-
74-
foreach ( $synopsis_spec as $arg ) {
75-
if ( 'positional' === $arg['type'] || 'assoc' === $arg['type'] ) {
76-
$prop_name = str_replace( '-', '_', $arg['name'] );
77-
$properties[ $prop_name ] = [
78-
'type' => 'string',
79-
'description' => "Parameter {$arg['name']}",
80-
];
81-
82-
if ( ! isset( $arg['optional'] ) || ! $arg['optional'] ) {
83-
$required[] = $prop_name;
84-
}
85-
}
86-
}
87-
88-
$tool = [
89-
'name' => 'wp_cli_' . str_replace( ' ', '_', $command_name ),
90-
'description' => $command_desc,
91-
'inputSchema' => [
92-
'type' => 'object',
93-
'properties' => $properties,
94-
'required' => $required,
95-
],
96-
'callable' => function ( $params ) use ( $command_name, $synopsis_spec ) {
97-
$args = [];
98-
$assoc_args = [];
99-
100-
// Process positional arguments first
101-
foreach ( $synopsis_spec as $arg ) {
102-
if ( 'positional' === $arg['type'] ) {
103-
$prop_name = str_replace( '-', '_', $arg['name'] );
104-
if ( isset( $params[ $prop_name ] ) ) {
105-
$args[] = $params[ $prop_name ];
106-
}
107-
}
108-
}
109-
110-
// Process associative arguments and flags
111-
foreach ( $params as $key => $value ) {
112-
// Skip positional args and dummy param
113-
if ( 'dummy' === $key ) {
114-
continue;
115-
}
116-
117-
// Check if this is an associative argument
118-
foreach ( $synopsis_spec as $arg ) {
119-
if ( ( 'assoc' === $arg['type'] || 'flag' === $arg['type'] ) &&
120-
str_replace( '-', '_', $arg['name'] ) === $key ) {
121-
$assoc_args[ str_replace( '_', '-', $key ) ] = $value;
122-
break;
123-
}
101+
$tool = [
102+
'name' => 'wp_cli_' . str_replace( ' ', '_', $command_name ),
103+
'description' => $command_desc,
104+
'inputSchema' => [
105+
'type' => 'object',
106+
'properties' => $properties,
107+
'required' => $required,
108+
],
109+
'callable' => function ( $params ) use ( $command_name, $synopsis_spec ) {
110+
$args = [];
111+
$assoc_args = [];
112+
113+
// Process positional arguments first
114+
foreach ( $synopsis_spec as $arg ) {
115+
if ( 'positional' === $arg['type'] ) {
116+
$prop_name = str_replace( '-', '_', $arg['name'] );
117+
if ( isset( $params[ $prop_name ] ) ) {
118+
$args[] = $params[ $prop_name ];
124119
}
125120
}
126-
127-
ob_start();
128-
WP_CLI::run_command( array_merge( explode( ' ', $command_name ), $args ), $assoc_args );
129-
return ob_get_clean();
130-
},
131-
];
132-
133-
$tools[] = $tool;
134-
135-
} else {
136-
137-
$this->logger->debug( $command_name . ' subcommands: ' . print_r( $command->get_subcommands(), true ) );
138-
139-
foreach ( $command->get_subcommands() as $subcommand ) {
140-
141-
if ( WP_CLI::get_runner()->is_command_disabled( $subcommand ) ) {
142-
continue;
143121
}
144122

145-
$subcommand_name = $subcommand->get_name();
146-
$subcommand_desc = $subcommand->get_shortdesc() ?? "Runs WP-CLI command: $subcommand_name";
147-
$subcommand_synopsis = $subcommand->get_synopsis();
148-
149-
/**
150-
* Parsed synopsys.
151-
*
152-
* @var array<int, array{optional?: bool, type: string, repeating: bool, name: string}> $synopsis_spec
153-
*/
154-
$synopsis_spec = SynopsisParser::parse( $subcommand_synopsis );
155-
156-
$properties = [];
157-
$required = [];
158-
159-
$properties['dummy'] = [
160-
'type' => 'string',
161-
'description' => 'Dummy parameter',
162-
];
163-
164-
foreach ( $synopsis_spec as $arg ) {
165-
$prop_name = str_replace( '-', '_', $arg['name'] );
166-
167-
if ( 'positional' === $arg['type'] || 'assoc' === $arg['type'] ) {
168-
$properties[ $prop_name ] = [
169-
'type' => 'string',
170-
'description' => "Parameter {$arg['name']}",
171-
];
123+
// Process associative arguments and flags
124+
foreach ( $params as $key => $value ) {
125+
// Skip positional args and dummy param
126+
if ( 'dummy' === $key ) {
127+
continue;
172128
}
173129

174-
// TODO: Handle flag type parameters (boolean)
175-
176-
if ( ! isset( $arg['optional'] ) || ! $arg['optional'] ) {
177-
$required[] = $prop_name;
130+
// Check if this is an associative argument
131+
foreach ( $synopsis_spec as $arg ) {
132+
if ( ( 'assoc' === $arg['type'] || 'flag' === $arg['type'] ) &&
133+
str_replace( '-', '_', $arg['name'] ) === $key ) {
134+
$assoc_args[ str_replace( '_', '-', $key ) ] = $value;
135+
break;
136+
}
178137
}
179138
}
180-
$tool = [
181-
'name' => 'wp_cli_' . str_replace( ' ', '_', $command_name ) . '_' . str_replace( ' ', '_', $subcommand_name ),
182-
'description' => $subcommand_desc,
183-
'inputSchema' => [
184-
'type' => 'object',
185-
'properties' => $properties,
186-
'required' => $required,
187-
],
188-
'callable' => function ( $params ) use ( $command_name, $subcommand_name, $synopsis_spec ) {
189-
190-
$this->logger->debug( 'Subcommand: ' . $subcommand_name . ' - Received params: ' . print_r( $params, true ) );
191-
192-
$args = [];
193-
$assoc_args = [];
194-
195-
// Process positional arguments first
196-
foreach ( $synopsis_spec as $arg ) {
197-
if ( 'positional' === $arg['type'] ) {
198-
$prop_name = str_replace( '-', '_', $arg['name'] );
199-
if ( isset( $params[ $prop_name ] ) ) {
200-
$args[] = $params[ $prop_name ];
201-
}
202-
}
203-
}
204139

205-
// Process associative arguments and flags
206-
foreach ( $params as $key => $value ) {
207-
// Skip positional args and dummy param
208-
if ( 'dummy' === $key ) {
209-
continue;
210-
}
211-
212-
// Check if this is an associative argument
213-
foreach ( $synopsis_spec as $arg ) {
214-
if ( ( 'assoc' === $arg['type'] || 'flag' === $arg['type'] ) &&
215-
str_replace( '-', '_', $arg['name'] ) === $key ) {
216-
$assoc_args[ str_replace( '_', '-', $key ) ] = $value;
217-
break;
218-
}
219-
}
220-
}
140+
ob_start();
141+
WP_CLI::run_command( array_merge( explode( ' ', $command_name ), $args ), $assoc_args );
142+
return ob_get_clean();
143+
},
144+
];
221145

222-
ob_start();
223-
WP_CLI::run_command( array_merge( [ $command_name, $subcommand_name ], $args ), $assoc_args );
224-
return ob_get_clean();
225-
},
226-
];
227-
228-
$tools[] = $tool;
229-
}
230-
}
146+
$tools[] = $tool;
231147
}
232148

233149
return $tools;

0 commit comments

Comments
 (0)