PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.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 2.7.0, at includes/mcp/class-mcp-tools.php

257 lines 7.9 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 * Public because the initialize instructions tell the assistant which
75 * scope this session has, so it can read that as a granted limitation
76 * rather than retrying a tool that will keep refusing (#491).
77 *
78 * @return bool
79 */
80 public static function is_read_only(): bool {
81 if ( null !== self::$read_only_override ) {
82 return self::$read_only_override;
83 }
84 return Mcp_Pairing::is_read_only();
85 }
86
87 /**
88 * The tool list in MCP `tools/list` shape, built from the abilities
89 * registry.
90 *
91 * @return array<int, array{name:string, description:string, inputSchema:array}>
92 */
93 public static function list(): array {
94 $out = [];
95 foreach ( self::abilities() as $ability ) {
96 $schema = $ability->get_input_schema();
97 $out[] = [
98 'name' => self::tool_name( $ability->get_name() ),
99 'description' => $ability->get_description(),
100 'inputSchema' => ! empty( $schema ) ? self::normalize_schema( $schema ) : [
101 'type' => 'object',
102 'properties' => (object) [],
103 ],
104 ];
105 }
106 return $out;
107 }
108
109 /**
110 * Normalize a JSON Schema for MCP clients: an empty PHP `properties` array
111 * JSON-encodes as `[]`, but the schema spec (and the MCP SDK's validator)
112 * requires an OBJECT — `{}`. Recurse first so nested object schemas (e.g.
113 * a no-field `settings` sub-object) are fixed too.
114 *
115 * @param array<string,mixed> $schema JSON Schema node.
116 * @return array<string,mixed>
117 */
118 private static function normalize_schema( array $schema ): array {
119 foreach ( $schema as $key => $value ) {
120 if ( is_array( $value ) ) {
121 $schema[ $key ] = self::normalize_schema( $value );
122 }
123 }
124 if ( isset( $schema['properties'] ) && [] === $schema['properties'] ) {
125 $schema['properties'] = (object) [];
126 }
127 return $schema;
128 }
129
130 /**
131 * Invoke a tool by name with decoded arguments. The ability's own input
132 * validation and permission callback run inside WP_Ability::execute()
133 * (the authenticated credential's user is already set by Mcp_Server).
134 *
135 * @param string $name Tool name (ability name sans `thinkrank/` prefix).
136 * @param array $args Decoded arguments.
137 * @return mixed|\WP_Error Result payload or error.
138 */
139 public static function invoke( string $name, array $args ) {
140 $ability = null;
141 if ( function_exists( 'wp_get_ability' ) ) {
142 foreach ( self::ABILITY_PREFIXES as $prefix ) {
143 $candidate = wp_get_ability( $prefix . $name );
144 if ( $candidate ) {
145 $ability = $candidate;
146 break;
147 }
148 }
149 }
150
151 if ( ! $ability ) {
152 return new \WP_Error(
153 'thinkrank_mcp_unknown_tool',
154 sprintf(
155 /* translators: %s: tool name. */
156 __( 'Unknown tool: %s', 'thinkrank' ),
157 $name
158 ),
159 [ 'status' => 404 ]
160 );
161 }
162
163 // Scope enforcement: a read-only connection cannot invoke a tool that
164 // mutates state.
165 if ( self::is_write_tool( $name ) && self::is_read_only() ) {
166 return new \WP_Error(
167 'thinkrank_mcp_read_only',
168 sprintf(
169 /* translators: %s: tool name. */
170 __( 'This MCP connection is read-only; the "%s" tool changes state and is not permitted. Reconnect with write access to use it.', 'thinkrank' ),
171 $name
172 ),
173 [ 'status' => 403 ]
174 );
175 }
176
177 return $ability->execute( $args );
178 }
179
180 /**
181 * Whether a tool mutates state. Read tools are `get-*` / `list-*`;
182 * everything else is treated as write.
183 *
184 * @param string $name Tool name (sans prefix).
185 * @return bool
186 */
187 public static function is_write_tool( string $name ): bool {
188 foreach ( self::READ_PREFIXES as $prefix ) {
189 if ( 0 === strpos( $name, $prefix ) ) {
190 return false;
191 }
192 }
193 return true;
194 }
195
196 /**
197 * Map an ability name to its MCP tool name (strip the category prefix —
198 * MCP tool names may not contain `/`).
199 *
200 * @param string $ability_name Full ability name, e.g. `thinkrank/get-post-seo`.
201 * @return string
202 */
203 private static function tool_name( string $ability_name ): string {
204 $bare = self::strip_prefix( $ability_name );
205 if ( null !== $bare ) {
206 return $bare;
207 }
208 return str_replace( '/', '-', $ability_name );
209 }
210
211 /**
212 * All registered ThinkRank abilities.
213 *
214 * @return \WP_Ability[]
215 */
216 private static function abilities(): array {
217 if ( ! function_exists( 'wp_get_abilities' ) ) {
218 return [];
219 }
220
221 // Our abilities reach the registry through `wp_abilities_api_init`,
222 // which fires once from whichever Abilities API copy owns the global
223 // functions. When a foreign copy owns them our callback can be missed
224 // entirely, leaving this filter with nothing to match and the client
225 // with a connected-but-empty tool list (#241). This replays the
226 // registration once, and is a no-op on a healthy request.
227 Abilities_Registrar::ensure_registered();
228
229 $out = [];
230 foreach ( wp_get_abilities() as $ability ) {
231 if ( ! is_object( $ability ) || ! method_exists( $ability, 'get_name' ) ) {
232 continue;
233 }
234 if ( null !== self::strip_prefix( $ability->get_name() ) ) {
235 $out[] = $ability;
236 }
237 }
238 return $out;
239 }
240
241 /**
242 * Strip a recognized ThinkRank ability prefix, returning the bare tool name.
243 * Returns null when the ability is not one of ours (so callers can filter).
244 *
245 * @param string $ability_name Full ability name, e.g. `thinkrank-pro/get-redirects`.
246 * @return string|null Bare name (`get-redirects`) or null if not a ThinkRank ability.
247 */
248 private static function strip_prefix( string $ability_name ): ?string {
249 foreach ( self::ABILITY_PREFIXES as $prefix ) {
250 if ( 0 === strpos( $ability_name, $prefix ) ) {
251 return substr( $ability_name, strlen( $prefix ) );
252 }
253 }
254 return null;
255 }
256 }
257