PluginProbe
Extendify / 3.2.2
Extendify v3.2.2
3.2.2 3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 All 128 releases
extendify / app / Mcp / Profile.php

Profile.php in Extendify 3.2.2, at app/Mcp/Profile.php

1,140 lines 43.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * The MCP settings screen, and the same section on another user's profile.
5 */
6
7 namespace Extendify\Mcp;
8
9 defined('ABSPATH') || die('No direct access.');
10
11 use Extendify\Config;
12 use Extendify\Mcp\OAuth\Metadata;
13 use Extendify\PartnerData;
14
15 /**
16 * Core's profile form wraps this section, so a nested form is not an option.
17 */
18 class Profile
19 {
20 // phpcs:disable PSR12.Properties.ConstantVisibility.NotFound
21 const PAGE = 'extendify-mcp';
22
23 const STEP_MARKUP = ['strong' => [], 'code' => [], 'a' => ['href' => [], 'target' => [], 'rel' => []]];
24 // phpcs:enable PSR12.Properties.ConstantVisibility.NotFound
25
26 /**
27 * Settings > MCP connects an outside assistant; our own beside it reads as one of them.
28 *
29 * @return boolean
30 */
31 public static function isOwnScreen()
32 {
33 if (!\is_admin() || !function_exists('get_current_screen')) {
34 return false;
35 }
36
37 $screen = \get_current_screen();
38
39 return $screen && $screen->id === 'settings_page_' . self::PAGE;
40 }
41
42 /**
43 * @return void
44 */
45 public static function register()
46 {
47 \add_action('admin_menu', [self::class, 'registerPage']);
48 \add_action('edit_user_profile', [self::class, 'render']);
49 \add_action('admin_init', [self::class, 'handleAction']);
50 \add_action('rest_api_init', [self::class, 'registerRoute']);
51 }
52
53 /**
54 * @return void
55 */
56 public static function registerPage()
57 {
58 if (!Availability::offered()) {
59 return;
60 }
61
62 $hook = \add_options_page(
63 /* translators: MCP is a protocol name; keep it in English. */
64 \__('MCP', 'extendify-local'),
65 /* translators: MCP is a protocol name; keep it in English. */
66 \__('MCP', 'extendify-local'),
67 'manage_options',
68 self::PAGE,
69 [self::class, 'renderPage']
70 );
71 if ($hook) {
72 \add_action('load-' . $hook, [self::class, 'quietNotices']);
73 }
74 }
75
76 /**
77 * Update nags and other plugins' notices print over the band and break the layout.
78 * Removed just before they print, so one hooked after the page loads goes too.
79 *
80 * @return void
81 */
82 public static function quietNotices()
83 {
84 \add_action('in_admin_header', function () {
85 foreach (['admin_notices', 'all_admin_notices', 'user_admin_notices', 'network_admin_notices'] as $hook) {
86 \remove_all_actions($hook);
87 }
88 }, 1000);
89 }
90
91 /**
92 * @return void
93 */
94 public static function registerRoute()
95 {
96 \register_rest_route(Config::$slug . '/' . Config::$apiVersion, '/mcp/connections', [
97 'methods' => 'GET',
98 'callback' => [self::class, 'connections'],
99 'permission_callback' => [self::class, 'mayList'],
100 'show_in_index' => false,
101 ]);
102 }
103
104 /**
105 * Anyone else asking would learn when an administrator authorized an assistant.
106 *
107 * @return boolean
108 */
109 public static function mayList()
110 {
111 return Availability::offered() && \current_user_can('manage_options');
112 }
113
114 /**
115 * @return \WP_REST_Response
116 */
117 public static function connections()
118 {
119 return new \WP_REST_Response(Connections::state(\get_current_user_id()));
120 }
121
122 /**
123 * @return void
124 */
125 public static function renderPage()
126 {
127 if (!Availability::offered() || !\current_user_can('manage_options')) {
128 return;
129 }
130
131 echo '<div class="wrap extendify-mcp-wrap">';
132 /* translators: MCP is a protocol name; keep it in English. */
133 self::renderBand(\__('MCP', 'extendify-local'), self::intro(true), 'extendify-mcp');
134 echo '<div class="extendify-mcp-page">';
135 self::renderSection(\get_current_user_id(), false);
136 echo '</div></div>';
137 }
138
139 /**
140 * @param string $title - The page's heading.
141 * @param string $intro - A line under the heading, or none.
142 * @param string $id - The heading's id, or none.
143 * @return void
144 */
145 public static function renderBand($title, $intro = '', $id = '')
146 {
147 echo '<div class="extendify-mcp-band">';
148 if (PartnerData::$logo) {
149 printf(
150 '<img class="extendify-mcp-partner" src="%1$s" alt="%2$s">',
151 \esc_url(PartnerData::$logo),
152 \esc_attr(PartnerData::$name)
153 );
154 }
155
156 echo '<div><h1' . ($id !== '' ? ' id="' . \esc_attr($id) . '"' : '') . '>' . \esc_html($title) . '</h1>';
157 if ($intro !== '') {
158 echo '<p>' . \esc_html($intro) . '</p>';
159 }
160
161 echo '</div></div>';
162 }
163
164 /**
165 * @return void
166 */
167 public static function handleAction()
168 {
169 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
170 $action = \sanitize_key(\wp_unslash($_GET['extendify_mcp_action'] ?? ''));
171 if (!in_array($action, ['revoke', 'turn_off', 'turn_on'], true)) {
172 return;
173 }
174
175 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
176 $userId = (int) \sanitize_text_field(\wp_unslash($_GET['user_id'] ?? '')) ?: \get_current_user_id();
177 // Without the nonce, a link on any page could revoke or switch off connections as the signed-in administrator.
178 \check_admin_referer('extendify_mcp_' . $action . '_' . $userId);
179
180 if ($action === 'revoke') {
181 self::revokeConnection($userId, \sanitize_key(\wp_unslash($_GET['connection'] ?? '')));
182 }
183
184 if ($action === 'turn_off') {
185 self::turnSiteOff();
186 }
187
188 if ($action === 'turn_on') {
189 self::turnSiteOn();
190 }
191
192 \wp_safe_redirect(self::screenUrl($userId) . '#extendify-mcp');
193 exit;
194 }
195
196 /**
197 * @return void
198 */
199 public static function turnSiteOff()
200 {
201 if (!\current_user_can('manage_options')) {
202 return;
203 }
204
205 Availability::turnOff(\get_current_user_id());
206 }
207
208 /**
209 * @return void
210 */
211 public static function turnSiteOn()
212 {
213 if (!\current_user_can('manage_options')) {
214 return;
215 }
216
217 Availability::turnOn();
218 }
219
220 /**
221 * @param integer $userId - The user the connection belongs to.
222 * @param string $id - The connection's id.
223 * @return void
224 */
225 public static function revokeConnection($userId, $id)
226 {
227 // Without this, any signed-in user could end another administrator's connections.
228 if (!\current_user_can('edit_user', $userId)) {
229 return;
230 }
231
232 Connections::revoke($userId, $id);
233 }
234
235 /**
236 * @param \WP_User $user - The user whose profile is on screen.
237 * @return void
238 */
239 public static function render($user)
240 {
241 if (!\current_user_can('edit_user', $user->ID) || !Availability::offered()) {
242 return;
243 }
244
245 if (!\user_can($user->ID, 'manage_options')) {
246 return;
247 }
248
249 /* translators: heading over the assistants another user has authorized. */
250 echo '<h2 id="extendify-mcp">' . \esc_html__('AI assistant connections', 'extendify-local') . '</h2>';
251 self::renderSection($user->ID);
252 }
253
254 /**
255 * @param integer $userId - The user whose connections to show.
256 * @param boolean $withIntro - Whether to print the intro, which the settings screen puts in its heading.
257 * @return void
258 */
259 private static function renderSection($userId, $withIntro = true)
260 {
261 self::enqueueStyles();
262
263 if (Availability::turnedOff()) {
264 self::renderTurnedOff($userId);
265 return;
266 }
267
268 $isSelf = (int) $userId === \get_current_user_id();
269
270 if ($withIntro) {
271 echo '<p class="description">' . \esc_html(self::intro($isSelf)) . '</p>';
272 }
273
274 if (!$isSelf) {
275 self::renderList($userId);
276 return;
277 }
278
279 \wp_register_script('extendify-mcp-profile', false, [], false, true);
280 \wp_enqueue_script('extendify-mcp-profile');
281 \wp_add_inline_script('extendify-mcp-profile', self::script(\get_current_user_id()));
282
283 /* translators: heading over the steps that connect the person's AI assistant to this site; not a sign-in. */
284 self::openSection(\__('Connect your AI assistant', 'extendify-local'), 'extendify-mcp');
285 self::renderReachability();
286 self::renderPicker();
287 echo '</div></details>';
288
289 /* translators: heading over the assistants this person has authorized. */
290 self::openSection(\__('Your authorized assistants', 'extendify-local'));
291 self::renderList($userId);
292 echo '</div></details>';
293 }
294
295 /**
296 * @param string $title - The heading that collapses the section.
297 * @param string $class - An extra class for the section's body.
298 * @return void
299 */
300 private static function openSection($title, $class = '')
301 {
302 printf(
303 '<details class="extendify-mcp-section" open><summary>%1$s</summary>'
304 . '<div class="%2$s">',
305 \esc_html($title),
306 \esc_attr(trim('extendify-mcp-section-body ' . $class))
307 );
308 }
309
310 /**
311 * @return void
312 */
313 private static function enqueueStyles()
314 {
315 \wp_register_style('extendify-mcp-profile', false, [], false);
316 \wp_enqueue_style('extendify-mcp-profile');
317 \wp_add_inline_style('extendify-mcp-profile', self::styles());
318 }
319
320 /**
321 * @param boolean $isSelf - Whether this is the screen the viewer connects from.
322 * @return string
323 */
324 private static function intro($isSelf)
325 {
326 if (!$isSelf) {
327 /* translators: shown to an administrator looking at someone else's profile. */
328 return \__(
329 'AI assistants this user has authorized. Only they can add new ones, but you can revoke any of them.',
330 'extendify-local'
331 );
332 }
333
334 if (!Allowed::writable()) {
335 /* translators: the assistant acts with this person's permissions but cannot change anything. */
336 return \__(
337 'Connect an AI assistant to your site so it can look things up for you. It cannot change anything.',
338 'extendify-local'
339 );
340 }
341
342 /* translators: the assistant acts with this person's own permissions. */
343 return \__('Connect an AI assistant to work on your site.', 'extendify-local');
344 }
345
346 /**
347 * @return void
348 */
349 private static function renderReachability()
350 {
351 $obstacle = Reachability::obstacle();
352 if (!$obstacle) {
353 return;
354 }
355
356 $reasons = [
357 /* translators: shown when the site is reachable only inside its own network. */
358 'local' => \__(
359 'This site needs to be publicly accessible for an AI assistant to connect to it.',
360 'extendify-local'
361 ),
362 /* translators: HTTPS is a protocol name; keep it in English. */
363 'http' => \__(
364 'This site needs to be served over HTTPS for an AI assistant to connect to it.',
365 'extendify-local'
366 ),
367 /* translators: shown when the whole site sits behind a password prompt. */
368 'auth' => \__(
369 'Remove the site\'s password protection so an AI assistant can connect to it.',
370 'extendify-local'
371 ),
372 /* translators: names the likely causes when requests from outside never arrive. */
373 'blocked' => \__(
374 'Outside requests can\'t reach this site. A coming-soon page or security plugin may be blocking them.',
375 'extendify-local'
376 ),
377 ];
378
379 printf('<div class="notice notice-warning inline"><p>%s</p></div>', \esc_html($reasons[$obstacle]));
380 }
381
382 /**
383 * @return void
384 */
385 private static function renderPicker()
386 {
387 $clients = Clients::all();
388 $selected = self::selected($clients);
389
390 echo '<div class="extendify-mcp-card"><h2>'
391 /* translators: heading over a picker of AI assistants. */
392 . \esc_html__('Which assistant are you using?', 'extendify-local') . '</h2>'
393 /* translators: under the heading of the AI assistant picker. */
394 . '<p class="description">' . \esc_html__(
395 'Pick your AI assistant to see how to connect it.',
396 'extendify-local'
397 ) . '</p>';
398
399 echo '<div class="extendify-mcp-tiles">';
400 foreach ($clients as $client) {
401 printf(
402 '<a class="extendify-mcp-tile" href="%1$s" data-client="%2$s" aria-pressed="%3$s">'
403 . '<span class="extendify-mcp-logo" aria-hidden="true">',
404 \esc_url(\add_query_arg('assistant', $client['id'], self::screenUrl(\get_current_user_id()))),
405 \esc_attr($client['id']),
406 $client['id'] === $selected ? 'true' : 'false'
407 );
408 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- fixed SVG markup, no input.
409 echo Clients::logo($client);
410 echo '</span><span>' . \esc_html($client['name']) . '</span></a>';
411 }
412
413 echo '</div>';
414
415 foreach ($clients as $client) {
416 self::renderPanel($client, $client['id'] === $selected);
417 }
418
419 echo '</div>';
420 }
421
422 /**
423 * @param array $client - One of the Clients entries.
424 * @param boolean $open - Whether this is the one the picker is showing.
425 * @return void
426 */
427 private static function renderPanel(array $client, $open)
428 {
429 printf(
430 '<div class="extendify-mcp-panel" id="extendify-mcp-panel-%1$s" data-client="%1$s"%2$s>',
431 \esc_attr($client['id']),
432 $open ? '' : ' hidden'
433 );
434
435 if (isset($client['gate'])) {
436 self::renderGate($client['gate']);
437 }
438
439 self::renderSteps($client);
440 echo '</div>';
441 }
442
443 /**
444 * @param array $gate - Its tone and its line.
445 * @return void
446 */
447 private static function renderGate(array $gate)
448 {
449 printf(
450 '<div class="extendify-mcp-gate is-%1$s"><p>%2$s</p></div>',
451 \esc_attr($gate['tone']),
452 \wp_kses($gate['text'], self::STEP_MARKUP)
453 );
454 }
455
456 /**
457 * @param array $links - The client's buttons, each opening the assistant in a new tab.
458 * @return void
459 */
460 private static function renderLinks(array $links)
461 {
462 echo '<p class="extendify-mcp-actions">';
463 foreach ($links as $link) {
464 printf(
465 '<a class="button button-compact %1$s" href="%2$s" target="_blank" rel="noopener noreferrer">%3$s'
466 . '<span class="screen-reader-text"> %4$s</span></a>',
467 $link['primary'] ? 'button-primary' : '',
468 \esc_url($link['url']),
469 \esc_html($link['label']),
470 /* translators: screen-reader text appended to a link that opens a new tab. */
471 \esc_html__('(opens in a new tab)', 'extendify-local')
472 );
473 }
474
475 echo '</p>';
476 }
477
478 /**
479 * @param array $client - One of the Clients entries.
480 * @return void
481 */
482 private static function renderSteps(array $client)
483 {
484 echo '<ol class="extendify-mcp-steps">';
485 foreach ($client['steps'] as $step) {
486 echo '<li><h4>' . \esc_html($step['title']) . '</h4>';
487 self::renderInstructions($step, $client['id']);
488 echo '</li>';
489 }
490
491 echo '</ol>';
492 }
493
494 /**
495 * @param array $block - A step, or a part of one, with optional text, substeps, links, fields and asides.
496 * @param string $clientId - The client the fields are for.
497 * @return void
498 */
499 private static function renderInstructions(array $block, $clientId)
500 {
501 foreach ((array) ($block['text'] ?? []) as $paragraph) {
502 echo '<p class="description">' . \wp_kses($paragraph, self::STEP_MARKUP) . '</p>';
503 }
504
505 if (isset($block['substeps'])) {
506 echo '<ol class="extendify-mcp-substeps">';
507 foreach ($block['substeps'] as $substep) {
508 $substep = is_array($substep) ? $substep : ['text' => $substep];
509 echo '<li>' . \wp_kses($substep['text'], self::STEP_MARKUP);
510 self::renderFields($clientId, $substep['fields'] ?? []);
511 echo '</li>';
512 }
513
514 echo '</ol>';
515 }
516
517 if (isset($block['links'])) {
518 self::renderLinks($block['links']);
519 }
520
521 self::renderFields($clientId, $block['fields'] ?? []);
522
523 foreach ($block['asides'] ?? [] as $aside) {
524 printf('<details class="extendify-mcp-manual"><summary>%s</summary>', \esc_html($aside['summary']));
525 self::renderInstructions($aside, $clientId);
526 echo '</details>';
527 }
528 }
529
530 /**
531 * @param string $clientId - The client the fields are for.
532 * @param array $fields - Labels keyed by the value they carry: name, url or prompt.
533 * @return void
534 */
535 private static function renderFields($clientId, array $fields)
536 {
537 $values = [
538 'url' => Metadata::resource(),
539 'prompt' => Clients::prompt(),
540 ];
541
542 foreach ($fields as $field => $label) {
543 if ($label !== '') {
544 echo '<p class="extendify-mcp-field">' . \esc_html($label) . '</p>';
545 }
546
547 if ($field === 'name') {
548 printf(
549 '<p class="description">%s</p>',
550 sprintf(
551 /* translators: %s: this site's name, offered as an example name for the connection. */
552 \esc_html__('Anything you\'ll recognize, like %s.', 'extendify-local'),
553 '<code>' . \esc_html(Clients::siteName()) . '</code>'
554 )
555 );
556 continue;
557 }
558
559 self::renderCopy('extendify-mcp-' . $field . '-' . $clientId, $values[$field], $field === 'prompt');
560 }
561 }
562
563 /**
564 * @param string $id - The id the copy button reads the text from.
565 * @param string $text - The text to copy.
566 * @param boolean $multiline - Whether the text is a prompt that wraps rather than a one-line value.
567 * @return void
568 */
569 private static function renderCopy($id, $text, $multiline = false)
570 {
571 printf(
572 $multiline
573 ? '<div class="extendify-mcp-copyable"><pre id="%1$s">%2$s</pre>'
574 : '<p class="extendify-mcp-address"><code id="%1$s">%2$s</code>',
575 \esc_attr($id),
576 \esc_html($text)
577 );
578 printf(
579 '<button type="button" class="button button-compact" data-copy="%1$s" data-copied="%2$s">%3$s</button>',
580 \esc_attr($id),
581 /* translators: button label for two seconds after the text was copied. */
582 \esc_attr__('Copied', 'extendify-local'),
583 /* translators: button that copies the text beside it. */
584 \esc_html__('Copy', 'extendify-local')
585 );
586 echo $multiline ? '</div>' : '</p>';
587 }
588
589 /**
590 * @param array $clients - The clients the picker lists.
591 * @return string
592 */
593 private static function selected(array $clients)
594 {
595 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
596 $asked = \sanitize_key(\wp_unslash($_GET['assistant'] ?? ''));
597
598 return in_array($asked, array_column($clients, 'id'), true) ? $asked : $clients[0]['id'];
599 }
600
601 /**
602 * @param integer $userId - The user whose profile screen this is.
603 * @return void
604 */
605 private static function renderTurnedOff($userId)
606 {
607 $off = Availability::turnedOff();
608 $who = \get_userdata($off['by']);
609 $when = self::when($off['at']);
610
611 $notice = $who
612 ? sprintf(
613 /* translators: 1: a person's name. 2: a date and time. No assistant works until this is undone. */
614 \__(
615 '%1$s turned connections off for the whole site on %2$s. None work until they\'re turned back on.',
616 'extendify-local'
617 ),
618 $who->display_name,
619 $when
620 )
621 : sprintf(
622 /* translators: %s: a date and time. No assistant works until this is undone. */
623 \__(
624 'Connections were turned off for the whole site on %s. None work until they\'re turned back on.',
625 'extendify-local'
626 ),
627 $when
628 );
629
630 printf('<p class="description">%s</p>', \esc_html($notice));
631
632 if (!\current_user_can('manage_options')) {
633 return;
634 }
635
636 printf(
637 '<p><a class="button" href="%1$s">%2$s</a></p>',
638 \esc_url(self::actionUrl('turn_on', $userId)),
639 /* translators: button that re-enables connections for the whole site. */
640 \esc_html__('Turn connections back on', 'extendify-local')
641 );
642 }
643
644 /**
645 * @param integer $userId - The user whose connections to list.
646 * @return void
647 */
648 private static function renderList($userId)
649 {
650 $connections = Connections::all($userId);
651 if (!$connections) {
652 echo '<p class="extendify-mcp-empty">'
653 /* translators: empty state under that heading. */
654 . \esc_html__('No assistants have been authorized yet.', 'extendify-local') . '</p>';
655 return;
656 }
657
658 echo '<div class="extendify-mcp-connections">';
659 foreach ($connections as $connection) {
660 self::renderConnection($userId, $connection);
661 }
662
663 echo '</div>';
664 }
665
666 /**
667 * @param integer $userId - The user the connection belongs to.
668 * @param array $connection - The connection being listed.
669 * @return void
670 */
671 private static function renderConnection($userId, array $connection)
672 {
673 /* translators: fallback when an assistant gave no name. */
674 $name = $connection['label'] ?: \__('Unnamed assistant', 'extendify-local');
675 $stale = !empty($connection['invalidated']);
676 $calls = Log::recent($userId, $connection['id']);
677
678 printf('<div class="extendify-mcp-connection%s">', $stale ? ' is-stale' : '');
679 printf(
680 '<div class="extendify-mcp-who"><span class="extendify-mcp-name">%1$s</span>'
681 . '<span class="extendify-mcp-meta">%2$s</span></div>',
682 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped as it is built.
683 self::assistantName($name, $connection['client'] ?? ''),
684 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped as it is built.
685 implode(' &middot; ', self::summary($connection, (bool) $calls))
686 );
687
688 printf(
689 '<div class="extendify-mcp-status"><span class="extendify-mcp-pill%1$s">%2$s</span>'
690 . '<a class="button button-compact extendify-mcp-revoke" href="%3$s" aria-label="%4$s">%5$s</a></div>',
691 $stale ? ' is-stale' : '',
692 \esc_html($stale
693 /* translators: badge on a connection that no longer works. */
694 ? \__('Needs reconnecting', 'extendify-local')
695 : Grants::label($connection['grants'])),
696 \esc_url(self::actionUrl('revoke', $userId, $connection['id'])),
697 /* translators: %s: the assistant's name. */
698 \esc_attr(sprintf(\__('Revoke "%s"', 'extendify-local'), $name)),
699 /* translators: button that ends an assistant's access. */
700 \esc_html__('Revoke', 'extendify-local')
701 );
702
703 if ($stale) {
704 echo '<p class="extendify-mcp-why">' . \esc_html__(
705 /* translators: why a connection stopped working, and the fix.
706 Revoke is the button beside this message; use its label. */
707 'This site\'s address or security keys changed, so this connection stopped working. Revoke it and connect the assistant again.', // phpcs:ignore Generic.Files.LineLength.TooLong
708 'extendify-local'
709 ) . '</p>';
710 }
711
712 self::renderActivity($connection, $calls);
713
714 echo '</div>';
715 }
716
717 /**
718 * @param array $connection - The connection being listed.
719 * @param array $calls - Its newest calls, newest first.
720 * @return void
721 */
722 private static function renderActivity(array $connection, array $calls)
723 {
724 if (!$calls) {
725 return;
726 }
727
728 $used = $connection['lastUsed'] ?: strtotime($calls[0]['created_at'] . ' UTC');
729 printf(
730 '<details class="extendify-mcp-aside extendify-mcp-activity"><summary title="%1$s">%2$s</summary><ul>',
731 \esc_attr(self::exactly($used)),
732 /* translators: %s: how long ago, such as "2 hours". */
733 \esc_html(sprintf(\__('Last used %s ago', 'extendify-local'), \human_time_diff($used)))
734 );
735
736 foreach ($calls as $call) {
737 $at = strtotime($call['created_at'] . ' UTC');
738 printf(
739 '<li><span title="%1$s">%2$s</span><code>%3$s</code>%4$s</li>',
740 \esc_attr(self::exactly($at)),
741 /* translators: %s: how long ago, such as "2 hours". */
742 \esc_html(sprintf(\__('%s ago', 'extendify-local'), \human_time_diff($at))),
743 \esc_html($call['tool']),
744 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped as it is built.
745 self::refusal($call)
746 );
747 }
748
749 echo '</ul></details>';
750 }
751
752 /**
753 * @param array $call - One row of the log.
754 * @return string
755 */
756 private static function refusal(array $call)
757 {
758 if ($call['outcome'] === 'ok') {
759 return '';
760 }
761
762 $why = $call['error']
763 ? $call['error']
764 : ($call['outcome'] === 'refused'
765 /* translators: a tool call this site would not allow. */
766 ? \__('Refused', 'extendify-local')
767 /* translators: a tool call that ended in an error. */
768 : \__('Failed', 'extendify-local'));
769
770 return '<span class="extendify-mcp-refused">' . \esc_html($why) . '</span>';
771 }
772
773 /**
774 * @param array $connection - The connection being listed.
775 * @param boolean $expandable - Whether a toggle already carries the last-used date.
776 * @return array - The metadata line, already escaped, in order.
777 */
778 private static function summary(array $connection, $expandable)
779 {
780 $parts = [
781 sprintf(
782 /* translators: %s: a date. */
783 \esc_html__('Authorized %s', 'extendify-local'),
784 \esc_html(self::when($connection['created']))
785 ),
786 ];
787
788 if ($expandable) {
789 return $parts;
790 }
791
792 $parts[] = $connection['lastUsed']
793 ? sprintf(
794 '<span title="%1$s">%2$s</span>',
795 \esc_attr(self::exactly($connection['lastUsed'])),
796 sprintf(
797 /* translators: %s: how long ago, such as "2 hours". */
798 \esc_html__('Last used %s ago', 'extendify-local'),
799 \esc_html(\human_time_diff($connection['lastUsed']))
800 )
801 )
802 /* translators: shown when an assistant has never called this site. */
803 : \esc_html__('Never used', 'extendify-local');
804
805 return $parts;
806 }
807
808 /**
809 * Two connections a person has named the same are told apart by where the client is.
810 *
811 * @param string $name - The label the client gave itself.
812 * @param string $client - The client id, which is a URL when the client published one.
813 * @return string
814 */
815 private static function assistantName($name, $client)
816 {
817 if (\wp_parse_url((string) $client, PHP_URL_SCHEME) !== 'https') {
818 return \esc_html($name);
819 }
820
821 return sprintf(
822 '<a href="%1$s" target="_blank" rel="noopener noreferrer">%2$s</a>',
823 \esc_url($client),
824 \esc_html($name)
825 );
826 }
827
828 /**
829 * @param integer $timestamp - A UTC timestamp.
830 * @return string
831 */
832 private static function when($timestamp)
833 {
834 return (string) \wp_date(\get_option('date_format'), (int) $timestamp);
835 }
836
837 /**
838 * Two connections used on the same day read as one without the time.
839 *
840 * @param integer $timestamp - A UTC timestamp.
841 * @return string
842 */
843 private static function exactly($timestamp)
844 {
845 $format = \get_option('date_format') . ' ' . \get_option('time_format');
846
847 return (string) \wp_date($format, (int) $timestamp);
848 }
849
850 /**
851 * @param string $action - One of the handleAction actions.
852 * @param integer $userId - The user the connection belongs to.
853 * @param string $connection - The connection's id.
854 * @return string
855 */
856 private static function actionUrl($action, $userId, $connection = '')
857 {
858 $args = ['extendify_mcp_action' => $action, 'user_id' => (int) $userId];
859 if ($connection) {
860 $args['connection'] = $connection;
861 }
862
863 return \wp_nonce_url(
864 \add_query_arg($args, self::screenUrl($userId)),
865 'extendify_mcp_' . $action . '_' . (int) $userId
866 );
867 }
868
869 /**
870 * @param integer $userId - The user whose profile screen to point at.
871 * @return string
872 */
873 private static function screenUrl($userId)
874 {
875 return (int) $userId === \get_current_user_id()
876 ? \admin_url('options-general.php?page=' . self::PAGE)
877 : \add_query_arg('user_id', (int) $userId, \admin_url('user-edit.php'));
878 }
879
880 /**
881 * @param string $screen - The admin screen's hook suffix, which WordPress puts on the body.
882 * @return string
883 */
884 public static function frameStyles($screen)
885 {
886 $page = 'body.' . $screen . ' ';
887
888 // Core has no token for the ground its own stage sits on.
889 return $page . '{ background: #1e1e1e; }
890 ' . $page . '#wpcontent { padding-left: 0; }
891 ' . $page . '#wpbody-content { padding-bottom: 0; }
892 ' . $page . '#wpfooter { display: none; }
893 .extendify-mcp-wrap { margin: 0 8px 8px 0; overflow: hidden;
894 background: var(--wpds-color-background-surface-neutral-strong, #fff);
895 border-radius: var(--wpds-border-radius-xl, 12px);
896 min-height: calc(100vh - var(--wp-admin--admin-bar--height, 32px) - 8px); }
897 /* Partner logos are drawn for their banner colour, and a light one vanishes on white. */
898 .extendify-mcp-band { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 24px;
899 padding: var(--wpds-dimension-padding-lg, 16px) var(--wpds-dimension-padding-2xl, 24px);
900 background: var(--ext-banner-main, transparent);
901 color: var(--ext-banner-text, var(--wpds-color-foreground-content-neutral, #1e1e1e));
902 border-bottom: 1px solid var(--wpds-color-stroke-surface-neutral-weak, #f0f0f1); }
903 .extendify-mcp-partner { display: block; flex: none; width: auto; min-width: 156px; max-width: min(208px, 100%);
904 height: 40px; object-fit: contain; object-position: left center; }
905 @media (min-width: 768px) { .extendify-mcp-partner { max-width: min(288px, 100%); } }
906 .extendify-mcp-band h1 { margin: 0; padding: 0; font-size: var(--wpds-typography-font-size-lg, 15px);
907 font-weight: var(--wpds-typography-font-weight-emphasis, 600);
908 line-height: var(--wpds-typography-line-height-sm, 20px); color: inherit; }
909 .extendify-mcp-band p { margin: var(--wpds-dimension-gap-xs, 4px) 0 0; max-width: 62ch;
910 font-size: var(--wpds-typography-font-size-md, 13px); color: inherit; opacity: .8; }
911 .extendify-mcp-band .notice { margin: var(--wpds-dimension-gap-md, 12px) 0 0; }';
912 }
913
914 /**
915 * @return string
916 */
917 private static function styles()
918 {
919 return self::frameStyles('settings_page_' . self::PAGE) . '
920 .extendify-mcp-page { box-sizing: border-box; max-width: 680px; margin: 0 auto;
921 padding: var(--wpds-dimension-padding-2xl, 24px); padding-bottom: 200px; }
922 .extendify-mcp-section { background: var(--wpds-color-background-surface-neutral-strong, #fff);
923 border: 1px solid var(--wpds-color-stroke-surface-neutral, #dcdcde);
924 border-radius: var(--wpds-border-radius-lg, 8px); margin: 0 0 16px; }
925 .extendify-mcp-section > summary { display: flex; align-items: center; justify-content: space-between; gap: 12px;
926 padding: 20px 24px; cursor: pointer; list-style: none; font-size: 14px; font-weight: 600; color: #1e1e1e; }
927 .extendify-mcp-section > summary::-webkit-details-marker { display: none; }
928 .extendify-mcp-section > summary::after { content: ""; flex: none; width: 7px; height: 7px; margin: -4px 4px 0 0;
929 border: solid currentColor; border-width: 0 1.5px 1.5px 0; transform: rotate(45deg); }
930 .extendify-mcp-section[open] > summary::after { margin-top: 4px; transform: rotate(-135deg); }
931 .extendify-mcp-section > summary:focus-visible { outline: 2px solid var(--wp-admin-theme-color, #3858e9);
932 outline-offset: -2px; border-radius: var(--wpds-border-radius-lg, 8px); }
933 .extendify-mcp-section-body { padding: 0 24px 24px; }
934 .extendify-mcp-card { margin: 0 0 20px; }
935 .extendify-mcp-card h2 { font-size: var(--wpds-typography-font-size-md, 14px);
936 font-weight: var(--wpds-typography-font-weight-emphasis, 600); margin: 0; }
937 .extendify-mcp-card > .description { margin: var(--wpds-dimension-gap-xs, 4px) 0 var(--wpds-dimension-gap-md, 12px); }
938 .extendify-mcp-address { display: flex; margin: 0; }
939 .extendify-mcp-address code { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis;
940 white-space: nowrap; padding: 6px 10px; border: 1px solid #dcdcde; border-right: 0;
941 border-radius: 4px 0 0 4px; background: #f6f7f7; }
942 .extendify-mcp-address .button { border-radius: 0 4px 4px 0; }
943 .extendify-mcp-tiles { display: flex; flex-wrap: wrap; gap: 4px;
944 padding: 4px; background: var(--wpds-color-background-surface-neutral-weak, #f0f0f1);
945 border-radius: var(--wpds-border-radius-lg, 8px); }
946 .extendify-mcp-tile { display: flex; flex: 1 1 124px; box-sizing: border-box; align-items: center;
947 justify-content: center; gap: 8px; padding: 8px 12px; border-radius: 6px; color: #50575e;
948 text-decoration: none; font-size: 13px; font-weight: 500; white-space: nowrap; }
949 .extendify-mcp-tile:hover, .extendify-mcp-tile:focus { background: rgba(255, 255, 255, .6); color: #1e1e1e; }
950 /* Core sets border-radius: 2px on a:focus, which squares the ring off the tile. */
951 .extendify-mcp-tile:focus { border-radius: 6px; }
952 .extendify-mcp-tile[aria-pressed="true"] { background: #fff; color: var(--wp-admin-theme-color, #3858e9);
953 font-weight: 600; box-shadow: 0 1px 2px rgba(0, 0, 0, .08), 0 0 0 1px rgba(0, 0, 0, .04); }
954 .extendify-mcp-logo { display: inline-flex; flex: none; width: 18px; height: 18px; color: #1e1e1e; }
955 .extendify-mcp-logo svg { width: 100%; height: 100%; }
956 .extendify-mcp-panel { margin-top: 16px; }
957 .extendify-mcp-gate { margin: 0; }
958 .extendify-mcp-gate p { margin: 0; }
959 .extendify-mcp-gate.is-ok { position: relative; padding-left: 22px; color: #1e1e1e; }
960 .extendify-mcp-gate.is-ok::before { content: ""; position: absolute; left: 4px; top: 3px; width: 5px; height: 9px;
961 border: solid #008a20; border-width: 0 2px 2px 0; transform: rotate(45deg); }
962 .extendify-mcp-gate.is-warn { border-radius: 6px; padding: 8px 10px;
963 background: var(--wpds-color-background-surface-caution-weak, #fcf5e6);
964 border: 1px solid var(--wpds-color-stroke-surface-caution, #e3cf9a);
965 color: var(--wpds-color-foreground-content-caution-weak, #7a5600); }
966 .extendify-mcp-actions { display: flex; flex-wrap: wrap; gap: var(--wpds-dimension-gap-sm, 8px);
967 margin: var(--wpds-dimension-gap-md, 12px) 0 0; }
968 .extendify-mcp-copyable { display: flex; gap: var(--wpds-dimension-gap-sm, 8px); align-items: flex-start;
969 margin-top: var(--wpds-dimension-padding-md, 10px); }
970 .extendify-mcp-copyable pre { flex: 1 1 auto; min-width: 0; margin: 0; overflow-x: auto;
971 background: var(--wpds-color-background-surface-neutral-strong, #fff);
972 border: 1px solid var(--wpds-color-stroke-surface-neutral, #dcdcde);
973 border-radius: var(--wpds-border-radius-md, 6px);
974 padding: var(--wpds-dimension-gap-sm, 8px) var(--wpds-dimension-padding-md, 10px);
975 font-family: var(--wpds-typography-font-family-mono, monospace); white-space: pre-wrap; word-break: break-word; }
976 .extendify-mcp-aside { border-top: 1px solid var(--wpds-color-stroke-surface-neutral, #dcdcde);
977 margin-top: var(--wpds-dimension-gap-md, 12px); padding-top: var(--wpds-dimension-padding-md, 10px); }
978 .extendify-mcp-aside summary, .extendify-mcp-manual summary { cursor: var(--wpds-cursor-control, pointer);
979 color: var(--wpds-color-foreground-interactive-brand, #2271b1); }
980 .extendify-mcp-steps { list-style: none; margin: 16px 0 0; padding: 0; counter-reset: extendify-mcp-step; }
981 .extendify-mcp-steps > li { position: relative; margin: 0; padding: 16px 0 16px 32px;
982 border-top: 1px solid #f0f0f1; counter-increment: extendify-mcp-step; }
983 .extendify-mcp-steps > li:first-child { padding-top: 0; border-top: 0; }
984 .extendify-mcp-steps > li::before { content: counter(extendify-mcp-step); position: absolute; left: 0; top: 16px;
985 width: 22px; height: 22px; border-radius: 50%; background: #f0f0f1; color: #1e1e1e;
986 font-size: 12px; font-weight: 600; line-height: 22px; text-align: center; }
987 .extendify-mcp-steps > li:first-child::before { top: 0; }
988 .extendify-mcp-steps h4 { margin: 2px 0 4px; font-size: 14px; font-weight: 600; }
989 .extendify-mcp-steps p { margin: 4px 0 0; }
990 .extendify-mcp-manual { margin-top: 10px; }
991 .extendify-mcp-manual ol, .extendify-mcp-substeps { margin: 8px 0 0 18px; }
992 .extendify-mcp-substeps li { margin-bottom: 6px; }
993 .extendify-mcp-steps .extendify-mcp-field { margin: 12px 0 4px; font-size: 12px; font-weight: 500; color: #50575e; }
994 .extendify-mcp-connections { display: flex; flex-direction: column; gap: var(--wpds-dimension-gap-sm, 8px); }
995 .extendify-mcp-connection { display: flex; flex-wrap: wrap;
996 gap: var(--wpds-dimension-gap-sm, 8px) var(--wpds-dimension-gap-lg, 14px); align-items: center;
997 justify-content: space-between; background: var(--wpds-color-background-surface-neutral-strong, #fff);
998 border: 1px solid var(--wpds-color-stroke-surface-neutral, #dcdcde);
999 border-radius: var(--wpds-border-radius-lg, 8px);
1000 padding: var(--wpds-dimension-padding-md, 12px) var(--wpds-dimension-padding-lg, 16px); }
1001 .extendify-mcp-connection.is-stale { border-color: var(--wpds-color-stroke-surface-caution, #e3cf9a); }
1002 .extendify-mcp-who { flex: 1 1 320px; min-width: 0; }
1003 .extendify-mcp-name { display: block; font-weight: var(--wpds-typography-font-weight-emphasis, 600); }
1004 .extendify-mcp-meta { display: block; color: var(--wpds-color-foreground-content-neutral-weak, #646970); }
1005 .extendify-mcp-status { display: flex; align-items: center; gap: var(--wpds-dimension-padding-md, 10px); }
1006 .extendify-mcp-pill { background: var(--wpds-color-background-surface-brand, #eef4fa);
1007 color: var(--wpds-color-foreground-interactive-brand, #135e96); border-radius: 999px;
1008 padding: 3px var(--wpds-dimension-padding-md, 10px);
1009 font-size: var(--wpds-typography-font-size-sm, 12px); white-space: nowrap; }
1010 .extendify-mcp-pill.is-stale { background: var(--wpds-color-background-surface-caution-weak, #fcf5e6);
1011 color: var(--wpds-color-foreground-content-caution-weak, #7a5600); }
1012 .extendify-mcp-revoke { color: var(--wpds-color-foreground-interactive-error, #b32d2e); }
1013 .extendify-mcp-why { flex: 1 1 100%; margin: 0;
1014 color: var(--wpds-color-foreground-content-caution-weak, #7a5600); }
1015 .extendify-mcp-activity { flex: 1 1 100%;
1016 border-top-color: var(--wpds-color-stroke-surface-neutral-weak, #f0f0f1);
1017 margin-top: var(--wpds-dimension-padding-md, 10px); padding-top: var(--wpds-dimension-gap-sm, 8px); }
1018 .extendify-mcp-activity ul { margin: var(--wpds-dimension-gap-sm, 8px) 0 0; }
1019 .extendify-mcp-activity li { display: flex; flex-wrap: wrap;
1020 gap: var(--wpds-dimension-gap-xs, 4px) var(--wpds-dimension-padding-md, 10px); align-items: baseline;
1021 padding: var(--wpds-dimension-gap-xs, 4px) 0;
1022 border-top: 1px solid var(--wpds-color-stroke-surface-neutral-weak, #f0f0f1); }
1023 .extendify-mcp-activity li > span:first-child { flex: 0 0 auto; min-width: 92px;
1024 color: var(--wpds-color-foreground-content-neutral-weak, #646970); }
1025 .extendify-mcp-refused { min-width: 0; color: var(--wpds-color-foreground-interactive-error, #b32d2e); }
1026 .extendify-mcp-empty { color: var(--wpds-color-foreground-content-neutral-weak, #646970); }
1027 /* Arabic letters render unjoined in the mono font. */
1028 .rtl .extendify-mcp-steps code, .rtl .extendify-mcp-copyable pre { font-family: inherit; }';
1029 }
1030
1031 /**
1032 * Without this, a user who approved on the assistant comes back to an empty list.
1033 *
1034 * @param integer $userId - The user whose connections the screen is showing.
1035 * @return string
1036 */
1037 private static function script($userId)
1038 {
1039 $state = array_merge(Connections::state($userId), [
1040 'url' => \rest_url(Config::$slug . '/' . Config::$apiVersion . '/mcp/connections'),
1041 'nonce' => \wp_create_nonce('wp_rest'),
1042 ]);
1043 $state['watching'] = $state['count'] === 0;
1044
1045 return 'window.extendifyMcpWatch = ' . \wp_json_encode($state) . ";
1046 var watch = window.extendifyMcpWatch;
1047 var deadline = 0;
1048 var timer = null;
1049 var stopWatching = function () {
1050 if (timer) {
1051 clearInterval(timer);
1052 timer = null;
1053 }
1054 };
1055 var check = function () {
1056 if (Date.now() > deadline) {
1057 stopWatching();
1058 return;
1059 }
1060 if (document.hidden) {
1061 return;
1062 }
1063 fetch(watch.url, { headers: { 'X-WP-Nonce': watch.nonce }, credentials: 'same-origin' })
1064 .then(function (response) {
1065 return response.ok ? response.json() : null;
1066 })
1067 .then(function (state) {
1068 if (!state || (state.count === watch.count && state.newest === watch.newest)) {
1069 return;
1070 }
1071 stopWatching();
1072 window.location.reload();
1073 })
1074 .catch(function () {});
1075 };
1076 var armed = false;
1077 var startWatching = function () {
1078 armed = true;
1079 deadline = Date.now() + 120000;
1080 if (!timer) {
1081 timer = setInterval(check, 3000);
1082 }
1083 };
1084 if (watch.watching) {
1085 startWatching();
1086 }
1087 document.addEventListener('visibilitychange', function () {
1088 if (document.hidden || !armed) {
1089 return;
1090 }
1091 startWatching();
1092 check();
1093 });
1094 var copyFrom = function (button) {
1095 var source = document.getElementById(button.dataset.copy);
1096 if (!source) {
1097 return;
1098 }
1099 var range = document.createRange();
1100 range.selectNodeContents(source);
1101 window.getSelection().removeAllRanges();
1102 window.getSelection().addRange(range);
1103 if (navigator.clipboard) {
1104 navigator.clipboard.writeText(source.textContent);
1105 } else {
1106 document.execCommand('copy');
1107 }
1108 var label = button.textContent;
1109 button.textContent = button.dataset.copied;
1110 setTimeout(function () {
1111 button.textContent = label;
1112 }, 2000);
1113 startWatching();
1114 };
1115 var show = function (id) {
1116 var tiles = document.querySelectorAll('.extendify-mcp-tile');
1117 for (var t = 0; t < tiles.length; t++) {
1118 tiles[t].setAttribute('aria-pressed', String(tiles[t].dataset.client === id));
1119 }
1120 var panels = document.querySelectorAll('.extendify-mcp-panel');
1121 for (var p = 0; p < panels.length; p++) {
1122 panels[p].hidden = panels[p].dataset.client !== id;
1123 }
1124 };
1125 document.addEventListener('click', function (event) {
1126 var copy = event.target.closest('[data-copy]');
1127 if (copy) {
1128 copyFrom(copy);
1129 return;
1130 }
1131 var tile = event.target.closest('.extendify-mcp-tile');
1132 if (tile) {
1133 event.preventDefault();
1134 show(tile.dataset.client);
1135 window.history.replaceState({}, '', tile.href);
1136 }
1137 });";
1138 }
1139 }
1140