PluginProbe
NotificationX – FOMO, Live Sales Notification, WooCommerce Sales Popup, GDPR, Social Proof, Announcement Banner & Floating Notification Bar / 3.3.1
NotificationX – FOMO, Live Sales Notification, WooCommerce Sales Popup, GDPR, Social Proof, Announcement Banner & Floating Notification Bar v3.3.1
3.3.1 3.3.0 3.2.14 3.2.13 3.2.12 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 trunk 0.2.5.5 0.2.5.6 0.2.5.7 1.0.0 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.2.0 1.2.1 All 156 releases
notificationx / includes / MCP / Server.php

Server.php in NotificationX – FOMO, Live Sales Notification, WooCommerce Sales Popup, GDPR, Social Proof, Announcement Banner & Floating Notification Bar 3.3.1, at includes/MCP/Server.php

341 lines 11.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * The NotificationX MCP server — JSON-RPC 2.0 over HTTP.
4 *
5 * Handles the MCP method set (initialize, ping, tools/list, tools/call,
6 * notifications) and authenticates every call via a static pairing token or an
7 * OAuth 2.1 access token. On success it impersonates the granting admin so each
8 * ability's own capability checks run as that user; on failure it returns an
9 * RFC 9728 Bearer challenge so a client can discover how to authorize.
10 *
11 * @package NotificationX\MCP
12 */
13
14 namespace NotificationX\MCP;
15
16 use NotificationX\GetInstance;
17
18 if ( ! defined( 'ABSPATH' ) ) {
19 exit;
20 }
21
22 /**
23 * @method static Server get_instance( $args = null )
24 */
25 class Server {
26
27 use GetInstance;
28
29 const PROTOCOL_VERSION = '2025-06-18';
30
31 // JSON-RPC error codes.
32 const PARSE_ERROR = -32700;
33 const INVALID_REQUEST = -32600;
34 const METHOD_NOT_FOUND = -32601;
35 const INVALID_PARAMS = -32602;
36 const INTERNAL_ERROR = -32603;
37 const UNAUTHORIZED = -32001;
38
39 /**
40 * Handle an MCP request. Returns a WP_REST_Response so both the REST route
41 * and the pretty endpoint can emit it consistently.
42 *
43 * @param \WP_REST_Request $request Incoming request.
44 * @return \WP_REST_Response
45 */
46 public function handle( $request ) {
47 if ( ! Manager::get_instance()->is_enabled() ) {
48 return $this->response( array(), 403 );
49 }
50
51 $limiter = RateLimiter::get_instance();
52 if ( $limiter->is_locked() ) {
53 $resp = $this->response(
54 $this->error_body( null, self::UNAUTHORIZED, __( 'Too many failed attempts. Try again later.', 'notificationx' ) ),
55 429
56 );
57 $resp->header( 'Retry-After', (string) $limiter->retry_after() );
58 return $this->with_challenge( $resp );
59 }
60
61 $token = $this->bearer_token( $request );
62
63 // No credential: normal OAuth opening probe. Challenge, do not penalise.
64 if ( '' === $token ) {
65 return $this->with_challenge(
66 $this->response(
67 $this->error_body( null, self::UNAUTHORIZED, __( 'Authentication required.', 'notificationx' ) ),
68 401
69 )
70 );
71 }
72
73 $auth = $this->authenticate( $token );
74 if ( is_wp_error( $auth ) ) {
75 $limiter->record_failure();
76 return $this->with_challenge(
77 $this->response(
78 $this->error_body( null, self::UNAUTHORIZED, $auth->get_error_message() ),
79 401
80 )
81 );
82 }
83 $limiter->clear();
84
85 // Scope → read/write gating for this request.
86 Tools::get_instance()->set_read_only( ! empty( $auth['read_only'] ) );
87
88 $raw = $request->get_body();
89 $decoded = json_decode( $raw, true );
90
91 if ( null === $decoded && JSON_ERROR_NONE !== json_last_error() ) {
92 return $this->response( $this->error_body( null, self::PARSE_ERROR, __( 'Parse error.', 'notificationx' ) ), 400 );
93 }
94
95 // Batch vs single.
96 if ( $this->is_batch( $decoded ) ) {
97 $out = array();
98 foreach ( $decoded as $message ) {
99 $result = $this->dispatch( $message );
100 if ( null !== $result ) {
101 $out[] = $result;
102 }
103 }
104 return empty( $out ) ? $this->response( null, 202 ) : $this->response( $out, 200 );
105 }
106
107 $result = $this->dispatch( is_array( $decoded ) ? $decoded : array() );
108 return null === $result ? $this->response( null, 202 ) : $this->response( $result, 200 );
109 }
110
111 /**
112 * Dispatch a single JSON-RPC message.
113 *
114 * @param array $message Decoded message.
115 * @return array|null JSON-RPC response, or null for notifications.
116 */
117 protected function dispatch( $message ) {
118 $method = isset( $message['method'] ) ? $message['method'] : '';
119 $id = isset( $message['id'] ) ? $message['id'] : null;
120 $params = isset( $message['params'] ) && is_array( $message['params'] ) ? $message['params'] : array();
121
122 // Notifications (no id) are acknowledged without a body.
123 if ( null === $id && 0 === strpos( (string) $method, 'notifications/' ) ) {
124 return null;
125 }
126
127 switch ( $method ) {
128 case 'initialize':
129 return $this->result( $id, array(
130 'protocolVersion' => self::PROTOCOL_VERSION,
131 'capabilities' => array(
132 'tools' => array( 'listChanged' => false ),
133 ),
134 'serverInfo' => array(
135 'name' => 'notificationx',
136 'version' => defined( 'NOTIFICATIONX_VERSION' ) ? NOTIFICATIONX_VERSION : '1.0.0',
137 ),
138 ) );
139
140 case 'ping':
141 return $this->result( $id, (object) array() );
142
143 case 'tools/list':
144 return $this->result( $id, array( 'tools' => Tools::get_instance()->list_tools() ) );
145
146 case 'tools/call':
147 return $this->call_tool( $id, $params );
148
149 default:
150 if ( null === $id ) {
151 return null;
152 }
153 return $this->error_body( $id, self::METHOD_NOT_FOUND, __( 'Method not found.', 'notificationx' ) );
154 }
155 }
156
157 /**
158 * Handle tools/call.
159 *
160 * @param mixed $id JSON-RPC id.
161 * @param array $params Params (name + arguments).
162 * @return array
163 */
164 protected function call_tool( $id, $params ) {
165 $name = isset( $params['name'] ) ? $params['name'] : '';
166 $args = isset( $params['arguments'] ) && is_array( $params['arguments'] ) ? $params['arguments'] : array();
167
168 if ( '' === $name ) {
169 return $this->error_body( $id, self::INVALID_PARAMS, __( 'Missing tool name.', 'notificationx' ) );
170 }
171
172 $result = Tools::get_instance()->invoke( $name, $args );
173
174 // A tool-level error is returned as a *successful* JSON-RPC result with
175 // isError=true, so the assistant can read and react to the message.
176 if ( is_wp_error( $result ) ) {
177 return $this->result( $id, array(
178 'content' => array(
179 array( 'type' => 'text', 'text' => $result->get_error_message() ),
180 ),
181 'isError' => true,
182 ) );
183 }
184
185 return $this->result( $id, array(
186 'content' => array(
187 array( 'type' => 'text', 'text' => wp_json_encode( $result ) ),
188 ),
189 'structuredContent' => $result,
190 'isError' => false,
191 ) );
192 }
193
194 /* --------------------------------------------------------------------- */
195 /* Authentication */
196 /* --------------------------------------------------------------------- */
197
198 /**
199 * Authenticate a bearer token via pairing token or OAuth, then impersonate
200 * the granting admin.
201 *
202 * @param string $token Bearer token.
203 * @return array|\WP_Error { user_id, read_only } or error.
204 */
205 protected function authenticate( $token ) {
206 $pairing = Pairing::get_instance();
207 if ( $pairing->is_connected() && $pairing->verify( $token ) ) {
208 $user = $this->impersonate( $pairing->user_id() );
209 if ( is_wp_error( $user ) ) {
210 return $user;
211 }
212 $pairing->touch_last_used();
213 return array(
214 'user_id' => $pairing->user_id(),
215 'read_only' => $pairing->is_read_only(),
216 );
217 }
218
219 $grant = OAuth::get_instance()->validate_token( $token );
220 if ( is_array( $grant ) ) {
221 $user = $this->impersonate( $grant['user_id'] );
222 if ( is_wp_error( $user ) ) {
223 return $user;
224 }
225 return array(
226 'user_id' => $grant['user_id'],
227 'read_only' => OAuth::get_instance()->scope_is_read_only( isset( $grant['scope'] ) ? $grant['scope'] : '' ),
228 );
229 }
230
231 return new \WP_Error( 'nx_mcp_unauthorized', __( 'Invalid or expired credentials.', 'notificationx' ) );
232 }
233
234 /**
235 * Become the granting user for the rest of the request. Refuses anyone who
236 * is not an administrator (a demoted/deleted admin's grants stop working).
237 *
238 * @param int $user_id User id.
239 * @return \WP_User|\WP_Error
240 */
241 protected function impersonate( $user_id ) {
242 $user = get_user_by( 'id', (int) $user_id );
243 if ( ! $user || ! user_can( $user, 'manage_options' ) ) {
244 return new \WP_Error( 'nx_mcp_unauthorized', __( 'The account behind this connection can no longer manage NotificationX.', 'notificationx' ) );
245 }
246 wp_set_current_user( $user->ID );
247 return $user;
248 }
249
250 /**
251 * Extract the bearer token from the Authorization header.
252 *
253 * @param \WP_REST_Request $request Request.
254 * @return string
255 */
256 protected function bearer_token( $request ) {
257 $header = $request->get_header( 'authorization' );
258 if ( ! $header ) {
259 $header = $request->get_header( 'Authorization' );
260 }
261 if ( $header && preg_match( '/Bearer\s+(.+)/i', $header, $m ) ) {
262 return trim( $m[1] );
263 }
264 return '';
265 }
266
267 /* --------------------------------------------------------------------- */
268 /* Response helpers */
269 /* --------------------------------------------------------------------- */
270
271 /**
272 * Build a JSON-RPC success result.
273 *
274 * @param mixed $id Request id.
275 * @param mixed $result Result payload.
276 * @return array
277 */
278 protected function result( $id, $result ) {
279 return array(
280 'jsonrpc' => '2.0',
281 'id' => $id,
282 'result' => $result,
283 );
284 }
285
286 /**
287 * Build a JSON-RPC error object.
288 *
289 * @param mixed $id Request id.
290 * @param int $code Error code.
291 * @param string $message Error message.
292 * @return array
293 */
294 protected function error_body( $id, $code, $message ) {
295 return array(
296 'jsonrpc' => '2.0',
297 'id' => $id,
298 'error' => array(
299 'code' => $code,
300 'message' => $message,
301 ),
302 );
303 }
304
305 /**
306 * Wrap a body in a WP_REST_Response and stamp the protocol header.
307 *
308 * @param mixed $body Response body.
309 * @param int $status HTTP status.
310 * @return \WP_REST_Response
311 */
312 protected function response( $body, $status = 200 ) {
313 $resp = new \WP_REST_Response( $body, $status );
314 $resp->header( 'MCP-Protocol-Version', self::PROTOCOL_VERSION );
315 $resp->header( 'Cache-Control', 'no-store' );
316 return $resp;
317 }
318
319 /**
320 * Add the RFC 9728 Bearer challenge header pointing at resource metadata.
321 *
322 * @param \WP_REST_Response $resp Response.
323 * @return \WP_REST_Response
324 */
325 protected function with_challenge( $resp ) {
326 $metadata_url = home_url( '/.well-known/oauth-protected-resource' );
327 $resp->header( 'WWW-Authenticate', sprintf( 'Bearer resource_metadata="%s"', $metadata_url ) );
328 return $resp;
329 }
330
331 /**
332 * Whether a decoded payload is a JSON-RPC batch (a list of messages).
333 *
334 * @param mixed $decoded Decoded payload.
335 * @return bool
336 */
337 protected function is_batch( $decoded ) {
338 return is_array( $decoded ) && array() !== $decoded && array_keys( $decoded ) === range( 0, count( $decoded ) - 1 );
339 }
340 }
341