PluginProbe
Extendify / 3.2.3
Extendify v3.2.3
3.2.3 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 All 129 releases
extendify / app / Mcp / Profile.php

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

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