PluginProbe
Kit (formerly ConvertKit) – Email Newsletter, Email Marketing, Membership, Subscribers and Landing Pages / 3.4.3
Kit (formerly ConvertKit) – Email Newsletter, Email Marketing, Membership, Subscribers and Landing Pages v3.4.3
3.4.3 3.4.2 3.4.1 3.4.0 3.3.9 3.3.8 3.3.7 3.3.6 3.3.5 3.3.4 3.3.3 3.3.2 3.3.1 2.2.0 2.2.1 2.2.2 2.2.3 2.2.4 2.2.5 2.2.6 2.2.7 2.2.8 2.2.9 2.3.0 2.3.1 All 196 releases
convertkit / vendor / wordpress / mcp-adapter / includes / Transport / Infrastructure / SessionManager.php

SessionManager.php in Kit (formerly ConvertKit) – Email Newsletter, Email Marketing, Membership, Subscribers and Landing Pages 3.4.3, at vendor/wordpress/mcp-adapter/includes/Transport/Infrastructure/SessionManager.php

460 lines 13.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MCP Session Manager using User Meta
4 *
5 *
6 * @package McpAdapter
7 */
8
9 declare( strict_types=1 );
10
11 namespace WP\MCP\Transport\Infrastructure;
12
13 use WP\MCP\Infrastructure\ErrorHandling\Contracts\McpErrorHandlerInterface;
14 use WP\MCP\Infrastructure\ErrorHandling\ErrorLogMcpErrorHandler;
15 use WP_Error;
16
17 /**
18 * MCP Session Manager
19 *
20 * Handles session creation, validation, and cleanup using user meta storage.
21 * Sessions are tied to authenticated users to prevent anonymous session flooding.
22 * MCP protocol revisions that do not negotiate session IDs should bypass this
23 * compatibility storage entirely.
24 * Established session maps use compare-and-swap semantics. An older
25 * unconditional writer can still overwrite a concurrent newer writer.
26 */
27 final class SessionManager {
28
29 /**
30 * Base user meta key for storing sessions.
31 *
32 * On multisite the stored key is blog-scoped via {@see session_meta_key()}
33 * because user meta is network-global. Without a blog suffix, two site-local
34 * MCP connectors for the same user share one session map. Single-site keeps
35 * the unsuffixed legacy key for upgrade compatibility.
36 *
37 * @var string
38 */
39 private const SESSION_META_KEY = 'mcp_adapter_sessions';
40
41 /**
42 * User meta key for the current site's session map.
43 *
44 * Single-site keeps the unsuffixed legacy key so in-flight sessions survive
45 * upgrade. On multisite, user meta is network-global, so the key is suffixed
46 * with the current blog ID. When no positive blog ID is available yet, fall
47 * back to the unsuffixed legacy key so reads/writes do not land under an
48 * orphaned `mcp_adapter_sessions_0` row (see session_meta_key_for_blog()).
49 *
50 * @since 0.6.0
51 */
52 private static function session_meta_key(): string {
53 if ( ! is_multisite() ) {
54 return self::SESSION_META_KEY;
55 }
56
57 return self::session_meta_key_for_blog( (int) get_current_blog_id() );
58 }
59
60 /**
61 * Resolve the session meta key for a blog ID (multisite).
62 *
63 * @since 0.6.0
64 *
65 * @param int $blog_id Blog ID; values below 1 use the unsuffixed legacy key.
66 */
67 private static function session_meta_key_for_blog( int $blog_id ): string {
68 if ( $blog_id < 1 ) {
69 return self::SESSION_META_KEY;
70 }
71
72 return self::SESSION_META_KEY . '_' . $blog_id;
73 }
74
75 /**
76 * Maximum sessions per user on the current site.
77 *
78 * @var int
79 */
80 private const DEFAULT_MAX_SESSIONS = 32;
81
82 /**
83 * Session inactivity timeout in seconds (24 hours).
84 *
85 * @var int
86 */
87 private const DEFAULT_INACTIVITY_TIMEOUT = DAY_IN_SECONDS;
88
89 /**
90 * Minimum interval between last_activity writes in seconds.
91 *
92 * @var int
93 */
94 private const DEFAULT_ACTIVITY_UPDATE_INTERVAL = 60;
95
96 /**
97 * Maximum attempts for a concurrent session mutation.
98 *
99 * @since 0.6.0
100 *
101 * @var int
102 */
103 private const MAX_UPDATE_ATTEMPTS = 5;
104
105 /**
106 * Create a new session for a user
107 *
108 * @since 0.6.0 Added the optional error handler.
109 *
110 * @param int $user_id The user ID.
111 * @param array $params Client parameters from initialize request.
112 * @param \WP\MCP\Infrastructure\ErrorHandling\Contracts\McpErrorHandlerInterface|null $error_handler Error handler for reporting storage failures. Defaults to the standard error-log handler.
113 *
114 * @return string|false The session ID on success, false on failure.
115 */
116 public static function create_session( int $user_id, array $params = array(), ?McpErrorHandlerInterface $error_handler = null ) {
117 if ( ! $user_id || ! get_user_by( 'id', $user_id ) ) {
118 return false;
119 }
120
121 $session_id = wp_generate_uuid4();
122 $now = time();
123 $config = self::get_config();
124
125 $created = self::mutate_sessions(
126 $user_id,
127 static function ( array $sessions ) use ( $config, $now, $params, $session_id ): array {
128 foreach ( $sessions as $stored_session_id => $session ) {
129 if ( $session['last_activity'] + $config['inactivity_timeout'] >= $now ) {
130 continue;
131 }
132
133 unset( $sessions[ $stored_session_id ] );
134 }
135
136 if ( count( $sessions ) >= $config['max_sessions'] ) {
137 uasort(
138 $sessions,
139 static function ( $a, $b ) {
140 return $a['created_at'] <=> $b['created_at'];
141 }
142 );
143
144 array_shift( $sessions );
145 }
146
147 $sessions[ $session_id ] = array(
148 'created_at' => $now,
149 'last_activity' => $now,
150 'client_params' => $params,
151 );
152
153 return $sessions;
154 },
155 $error_handler
156 );
157
158 if ( ! $created ) {
159 return false;
160 }
161
162 return $session_id;
163 }
164
165 /**
166 * Apply a session mutation without overwriting an established session map.
167 *
168 * WordPress ignores an empty $prev_value. Concurrent first connections may
169 * therefore still overwrite each other, but subsequent writes retry when the
170 * previously read non-empty map has changed.
171 *
172 * @since 0.6.0
173 *
174 * @param int $user_id The user ID.
175 * @param callable $mutation Receives the latest sessions and returns the updated sessions.
176 * @param \WP\MCP\Infrastructure\ErrorHandling\Contracts\McpErrorHandlerInterface|null $error_handler Error handler for reporting storage failures. Defaults to the standard error-log handler.
177 * @return bool True when the mutation was stored, false after repeated conflicts.
178 */
179 private static function mutate_sessions( int $user_id, callable $mutation, ?McpErrorHandlerInterface $error_handler = null ): bool {
180 for ( $attempt = 0; $attempt < self::MAX_UPDATE_ATTEMPTS; ++$attempt ) {
181 wp_cache_delete( $user_id, 'user_meta' );
182 $previous_sessions = self::get_all_user_sessions( $user_id );
183 $updated_sessions = $mutation( $previous_sessions );
184
185 if ( $updated_sessions === $previous_sessions ) {
186 return true;
187 }
188
189 $updated = update_user_meta( $user_id, self::session_meta_key(), $updated_sessions, $previous_sessions );
190 if ( false !== $updated ) {
191 return true;
192 }
193 }
194
195 $error_handler = $error_handler ?? new ErrorLogMcpErrorHandler();
196 $error_handler->log(
197 'Failed to persist MCP sessions after exhausting update retries.',
198 array(
199 'component' => self::class,
200 'method' => 'mutate_sessions',
201 'user_id' => $user_id,
202 'attempts' => self::MAX_UPDATE_ATTEMPTS,
203 )
204 );
205
206 return false;
207 }
208
209 /**
210 * Cleanup inactive sessions for a user
211 *
212 * @param int $user_id The user ID.
213 *
214 * @return int Number of sessions removed.
215 */
216 public static function cleanup_expired_sessions( int $user_id ): int {
217 if ( ! $user_id ) {
218 return 0;
219 }
220
221 $now = time();
222 $removed = 0;
223 $config = self::get_config();
224 $inactivity_timeout = $config['inactivity_timeout'];
225
226 $stored = self::mutate_sessions(
227 $user_id,
228 static function ( array $sessions ) use ( $inactivity_timeout, $now, &$removed ): array {
229 $removed = 0;
230
231 foreach ( $sessions as $session_id => $session ) {
232 if ( $session['last_activity'] + $inactivity_timeout >= $now ) {
233 continue;
234 }
235
236 unset( $sessions[ $session_id ] );
237 ++$removed;
238 }
239
240 return $sessions;
241 }
242 );
243
244 return $stored ? $removed : 0;
245 }
246
247 /**
248 * Get all sessions for a user on the current site.
249 *
250 * @param int $user_id The user ID.
251 *
252 * @return array Array of sessions.
253 */
254 public static function get_all_user_sessions( int $user_id ): array {
255 if ( ! $user_id ) {
256 return array();
257 }
258
259 $sessions = get_user_meta( $user_id, self::session_meta_key(), true );
260
261 if ( ! is_array( $sessions ) ) {
262 return array();
263 }
264
265 return $sessions;
266 }
267
268 /**
269 * Get configuration values.
270 *
271 * @return array{max_sessions: int, inactivity_timeout: int, activity_update_interval: int} Configuration array.
272 */
273 private static function get_config(): array {
274 /**
275 * Filters the maximum number of MCP sessions allowed per user on the current site.
276 *
277 * When a user exceeds this limit on the current site, the oldest inactive
278 * session is automatically removed to make room for new sessions.
279 *
280 * @since 0.3.0
281 *
282 * @param int $max_sessions Maximum sessions per user on the current site. Default 32.
283 */
284 $max_sessions = (int) apply_filters( 'mcp_adapter_session_max_per_user', self::DEFAULT_MAX_SESSIONS );
285
286 /**
287 * Filters the session inactivity timeout in seconds.
288 *
289 * Sessions that have been inactive longer than this duration are
290 * considered expired and may be cleaned up automatically.
291 *
292 * @since 0.3.0
293 *
294 * @param int $timeout Inactivity timeout in seconds. Default DAY_IN_SECONDS (86400 / 24 hours).
295 */
296 $inactivity_timeout = (int) apply_filters( 'mcp_adapter_session_inactivity_timeout', self::DEFAULT_INACTIVITY_TIMEOUT );
297
298 /**
299 * Filters the minimum interval between session last_activity writes.
300 *
301 * To reduce write amplification, the session manager only updates
302 * `last_activity` if at least this many seconds have elapsed since
303 * the last write.
304 *
305 * @since 0.5.0
306 *
307 * @param int $interval Minimum seconds between writes. Default 60.
308 */
309 $activity_update_interval = (int) apply_filters( 'mcp_adapter_session_activity_update_interval', self::DEFAULT_ACTIVITY_UPDATE_INTERVAL );
310
311 // Clamp: interval must be less than inactivity timeout to prevent
312 // sessions from expiring despite active use.
313 if ( $activity_update_interval >= $inactivity_timeout ) {
314 $activity_update_interval = (int) ( $inactivity_timeout / 2 );
315 }
316
317 return array(
318 'max_sessions' => $max_sessions,
319 'inactivity_timeout' => $inactivity_timeout,
320 'activity_update_interval' => max( 0, $activity_update_interval ),
321 );
322 }
323
324 /**
325 * Get a specific session for a user
326 *
327 * @param int $user_id The user ID.
328 * @param string $session_id The session ID.
329 *
330 * @return array|\WP_Error|false Session data on success, WP_Error on invalid input, false if not found or inactive.
331 */
332 public static function get_session( int $user_id, string $session_id ) {
333 if ( ! $user_id || ! $session_id ) {
334 return new WP_Error( 'mcp_session_invalid_input', 'Invalid user ID or session ID.' );
335 }
336
337 $sessions = self::get_all_user_sessions( $user_id );
338
339 if ( ! isset( $sessions[ $session_id ] ) ) {
340 return false;
341 }
342
343 $session = $sessions[ $session_id ];
344
345 // Check inactivity timeout
346 $config = self::get_config();
347 $inactivity_timeout = $config['inactivity_timeout'];
348 if ( $session['last_activity'] + $inactivity_timeout < time() ) {
349 self::clear_session( $user_id, $session_id );
350
351 return false;
352 }
353
354 return $session;
355 }
356
357 /**
358 * Clear an inactive session (internal cleanup).
359 *
360 * @param int $user_id The user ID.
361 * @param string $session_id The session ID to clear.
362 *
363 * @return void
364 */
365 private static function clear_session( int $user_id, string $session_id ): void {
366 self::mutate_sessions(
367 $user_id,
368 static function ( array $sessions ) use ( $session_id ): array {
369 unset( $sessions[ $session_id ] );
370
371 return $sessions;
372 }
373 );
374 }
375
376 /**
377 * Validate a session and update last activity
378 *
379 * @since 0.6.0 Added the optional error handler.
380 *
381 * @param int $user_id The user ID.
382 * @param string $session_id The session ID.
383 * @param \WP\MCP\Infrastructure\ErrorHandling\Contracts\McpErrorHandlerInterface|null $error_handler Error handler for reporting storage failures. Defaults to the standard error-log handler.
384 *
385 * @return bool True if valid, false otherwise.
386 */
387 public static function validate_session( int $user_id, string $session_id, ?McpErrorHandlerInterface $error_handler = null ): bool {
388 if ( ! $user_id || ! $session_id ) {
389 return false;
390 }
391
392 $config = self::get_config();
393 $now = time();
394 $is_valid = false;
395
396 $stored = self::mutate_sessions(
397 $user_id,
398 static function ( array $sessions ) use ( $config, $now, $session_id, &$is_valid ): array {
399 if ( ! isset( $sessions[ $session_id ] ) ) {
400 $is_valid = false;
401 return $sessions;
402 }
403
404 if ( $sessions[ $session_id ]['last_activity'] + $config['inactivity_timeout'] < $now ) {
405 $is_valid = false;
406 unset( $sessions[ $session_id ] );
407
408 return $sessions;
409 }
410
411 $is_valid = true;
412 if ( $now - $sessions[ $session_id ]['last_activity'] >= $config['activity_update_interval'] ) {
413 $sessions[ $session_id ]['last_activity'] = $now;
414 }
415
416 return $sessions;
417 },
418 $error_handler
419 );
420
421 return $stored && $is_valid;
422 }
423
424 /**
425 * Delete a specific session
426 *
427 * @since 0.6.0 Added the optional error handler.
428 *
429 * @param int $user_id The user ID.
430 * @param string $session_id The session ID.
431 * @param \WP\MCP\Infrastructure\ErrorHandling\Contracts\McpErrorHandlerInterface|null $error_handler Error handler for reporting storage failures. Defaults to the standard error-log handler.
432 *
433 * @return bool True on success, false on failure.
434 */
435 public static function delete_session( int $user_id, string $session_id, ?McpErrorHandlerInterface $error_handler = null ): bool {
436 if ( ! $user_id || ! $session_id ) {
437 return false;
438 }
439
440 $session_found = false;
441 $stored = self::mutate_sessions(
442 $user_id,
443 static function ( array $sessions ) use ( $session_id, &$session_found ): array {
444 if ( ! isset( $sessions[ $session_id ] ) ) {
445 $session_found = false;
446 return $sessions;
447 }
448
449 $session_found = true;
450 unset( $sessions[ $session_id ] );
451
452 return $sessions;
453 },
454 $error_handler
455 );
456
457 return $stored && $session_found;
458 }
459 }
460