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

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