PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.30.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.30.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / mcp / class-mcp-tools.php

class-mcp-tools.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 1.30.0, at includes/mcp/class-mcp-tools.php

253 lines 7.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MCP tool registry — exposes ThinkRank's registered abilities as MCP tools.
4 *
5 * Unlike a hand-written catalog, the tool surface here is the WordPress
6 * Abilities API registry: every `thinkrank/*` (and Pro `thinkrank-pro/*`)
7 * ability becomes one MCP tool (name, description, JSON Schema inputSchema).
8 * Consumed by Mcp_Server for both tools/list and tools/call, so the ability
9 * registry and the MCP surface can never drift.
10 *
11 * Tool naming: MCP tool names may not contain `/`, so the category prefix
12 * (`thinkrank/` or `thinkrank-pro/`) is stripped — the ability
13 * `thinkrank/get-post-seo` is the tool `get-post-seo`. Free and Pro bare
14 * names do not collide, so a bare tool name resolves back to exactly one
15 * ability (see invoke()).
16 *
17 * Scope model: a read-only credential may only invoke read tools. An ability
18 * is read-only when its name (sans prefix) starts with `get-` or `list-`;
19 * everything else (update-*, submit-*, generate-*) mutates state.
20 *
21 * @package ThinkRank\Mcp
22 */
23
24 declare(strict_types=1);
25
26 namespace ThinkRank\Mcp;
27
28 use ThinkRank\Abilities\Abilities_Registrar;
29
30 if ( ! defined( 'ABSPATH' ) ) {
31 exit; // Exit if accessed directly.
32 }
33
34 /**
35 * Bridges the Abilities API registry to the MCP tool surface.
36 */
37 final class Mcp_Tools {
38
39 /**
40 * Ability name prefixes that mark a ThinkRank ability exposed over MCP.
41 * The free plugin owns `thinkrank/`; ThinkRank Pro registers its abilities
42 * under `thinkrank-pro/` via the `thinkrank_register_abilities` filter.
43 */
44 private const ABILITY_PREFIXES = [ 'thinkrank/', 'thinkrank-pro/' ];
45
46 /**
47 * Tool-name prefixes that identify read-only (non-mutating) abilities.
48 */
49 private const READ_PREFIXES = [ 'get-', 'list-' ];
50
51 /**
52 * Per-call read-only override. Null means "defer to the pairing token's
53 * scope". true/false is set by Mcp_Server when an OAuth access token
54 * (with its own scope) authorized the request.
55 *
56 * @var bool|null
57 */
58 private static $read_only_override = null;
59
60 /**
61 * Set the active credential's read-only state for the current request.
62 * Passing null clears the override (back to the pairing-token default).
63 *
64 * @param bool|null $read_only Whether the active credential is read-only.
65 * @return void
66 */
67 public static function set_read_only_override( ?bool $read_only ): void {
68 self::$read_only_override = $read_only;
69 }
70
71 /**
72 * Whether the active MCP credential is limited to read-only tools.
73 *
74 * @return bool
75 */
76 private static function is_read_only(): bool {
77 if ( null !== self::$read_only_override ) {
78 return self::$read_only_override;
79 }
80 return Mcp_Pairing::is_read_only();
81 }
82
83 /**
84 * The tool list in MCP `tools/list` shape, built from the abilities
85 * registry.
86 *
87 * @return array<int, array{name:string, description:string, inputSchema:array}>
88 */
89 public static function list(): array {
90 $out = [];
91 foreach ( self::abilities() as $ability ) {
92 $schema = $ability->get_input_schema();
93 $out[] = [
94 'name' => self::tool_name( $ability->get_name() ),
95 'description' => $ability->get_description(),
96 'inputSchema' => ! empty( $schema ) ? self::normalize_schema( $schema ) : [
97 'type' => 'object',
98 'properties' => (object) [],
99 ],
100 ];
101 }
102 return $out;
103 }
104
105 /**
106 * Normalize a JSON Schema for MCP clients: an empty PHP `properties` array
107 * JSON-encodes as `[]`, but the schema spec (and the MCP SDK's validator)
108 * requires an OBJECT — `{}`. Recurse first so nested object schemas (e.g.
109 * a no-field `settings` sub-object) are fixed too.
110 *
111 * @param array<string,mixed> $schema JSON Schema node.
112 * @return array<string,mixed>
113 */
114 private static function normalize_schema( array $schema ): array {
115 foreach ( $schema as $key => $value ) {
116 if ( is_array( $value ) ) {
117 $schema[ $key ] = self::normalize_schema( $value );
118 }
119 }
120 if ( isset( $schema['properties'] ) && [] === $schema['properties'] ) {
121 $schema['properties'] = (object) [];
122 }
123 return $schema;
124 }
125
126 /**
127 * Invoke a tool by name with decoded arguments. The ability's own input
128 * validation and permission callback run inside WP_Ability::execute()
129 * (the authenticated credential's user is already set by Mcp_Server).
130 *
131 * @param string $name Tool name (ability name sans `thinkrank/` prefix).
132 * @param array $args Decoded arguments.
133 * @return mixed|\WP_Error Result payload or error.
134 */
135 public static function invoke( string $name, array $args ) {
136 $ability = null;
137 if ( function_exists( 'wp_get_ability' ) ) {
138 foreach ( self::ABILITY_PREFIXES as $prefix ) {
139 $candidate = wp_get_ability( $prefix . $name );
140 if ( $candidate ) {
141 $ability = $candidate;
142 break;
143 }
144 }
145 }
146
147 if ( ! $ability ) {
148 return new \WP_Error(
149 'thinkrank_mcp_unknown_tool',
150 sprintf(
151 /* translators: %s: tool name. */
152 __( 'Unknown tool: %s', 'thinkrank' ),
153 $name
154 ),
155 [ 'status' => 404 ]
156 );
157 }
158
159 // Scope enforcement: a read-only connection cannot invoke a tool that
160 // mutates state.
161 if ( self::is_write_tool( $name ) && self::is_read_only() ) {
162 return new \WP_Error(
163 'thinkrank_mcp_read_only',
164 sprintf(
165 /* translators: %s: tool name. */
166 __( 'This MCP connection is read-only; the "%s" tool changes state and is not permitted. Reconnect with write access to use it.', 'thinkrank' ),
167 $name
168 ),
169 [ 'status' => 403 ]
170 );
171 }
172
173 return $ability->execute( $args );
174 }
175
176 /**
177 * Whether a tool mutates state. Read tools are `get-*` / `list-*`;
178 * everything else is treated as write.
179 *
180 * @param string $name Tool name (sans prefix).
181 * @return bool
182 */
183 public static function is_write_tool( string $name ): bool {
184 foreach ( self::READ_PREFIXES as $prefix ) {
185 if ( 0 === strpos( $name, $prefix ) ) {
186 return false;
187 }
188 }
189 return true;
190 }
191
192 /**
193 * Map an ability name to its MCP tool name (strip the category prefix —
194 * MCP tool names may not contain `/`).
195 *
196 * @param string $ability_name Full ability name, e.g. `thinkrank/get-post-seo`.
197 * @return string
198 */
199 private static function tool_name( string $ability_name ): string {
200 $bare = self::strip_prefix( $ability_name );
201 if ( null !== $bare ) {
202 return $bare;
203 }
204 return str_replace( '/', '-', $ability_name );
205 }
206
207 /**
208 * All registered ThinkRank abilities.
209 *
210 * @return \WP_Ability[]
211 */
212 private static function abilities(): array {
213 if ( ! function_exists( 'wp_get_abilities' ) ) {
214 return [];
215 }
216
217 // Our abilities reach the registry through `wp_abilities_api_init`,
218 // which fires once from whichever Abilities API copy owns the global
219 // functions. When a foreign copy owns them our callback can be missed
220 // entirely, leaving this filter with nothing to match and the client
221 // with a connected-but-empty tool list (#241). This replays the
222 // registration once, and is a no-op on a healthy request.
223 Abilities_Registrar::ensure_registered();
224
225 $out = [];
226 foreach ( wp_get_abilities() as $ability ) {
227 if ( ! is_object( $ability ) || ! method_exists( $ability, 'get_name' ) ) {
228 continue;
229 }
230 if ( null !== self::strip_prefix( $ability->get_name() ) ) {
231 $out[] = $ability;
232 }
233 }
234 return $out;
235 }
236
237 /**
238 * Strip a recognized ThinkRank ability prefix, returning the bare tool name.
239 * Returns null when the ability is not one of ours (so callers can filter).
240 *
241 * @param string $ability_name Full ability name, e.g. `thinkrank-pro/get-redirects`.
242 * @return string|null Bare name (`get-redirects`) or null if not a ThinkRank ability.
243 */
244 private static function strip_prefix( string $ability_name ): ?string {
245 foreach ( self::ABILITY_PREFIXES as $prefix ) {
246 if ( 0 === strpos( $ability_name, $prefix ) ) {
247 return substr( $ability_name, strlen( $prefix ) );
248 }
249 }
250 return null;
251 }
252 }
253