[], 'code' => [], 'a' => ['href' => [], 'target' => [], 'rel' => []]]; // phpcs:enable PSR12.Properties.ConstantVisibility.NotFound /** * Settings > MCP connects an outside assistant; our own beside it reads as one of them. * * @return boolean */ public static function isOwnScreen() { if (!\is_admin() || !function_exists('get_current_screen')) { return false; } $screen = \get_current_screen(); return $screen && $screen->id === 'settings_page_' . self::PAGE; } /** * @return void */ public static function register() { \add_action('admin_menu', [self::class, 'registerPage']); \add_action('edit_user_profile', [self::class, 'render']); \add_action('admin_init', [self::class, 'handleAction']); \add_action('rest_api_init', [self::class, 'registerRoute']); } /** * @return void */ public static function registerPage() { if (!Availability::offered()) { return; } $hook = \add_options_page( /* translators: MCP is a protocol name; keep it in English. */ \__('MCP', 'extendify-local'), /* translators: MCP is a protocol name; keep it in English. */ \__('MCP', 'extendify-local'), 'manage_options', self::PAGE, [self::class, 'renderPage'] ); if ($hook) { \add_action('load-' . $hook, [self::class, 'quietNotices']); } } /** * Update nags and other plugins' notices print over the band and break the layout. * Removed just before they print, so one hooked after the page loads goes too. * * @return void */ public static function quietNotices() { \add_action('in_admin_header', function () { foreach (['admin_notices', 'all_admin_notices', 'user_admin_notices', 'network_admin_notices'] as $hook) { \remove_all_actions($hook); } }, 1000); } /** * @return void */ public static function registerRoute() { \register_rest_route(Config::$slug . '/' . Config::$apiVersion, '/mcp/connections', [ 'methods' => 'GET', 'callback' => [self::class, 'connections'], 'permission_callback' => [self::class, 'mayList'], 'show_in_index' => false, ]); } /** * Anyone else asking would learn when an administrator authorized an assistant. * * @return boolean */ public static function mayList() { return Availability::offered() && \current_user_can('manage_options'); } /** * @return \WP_REST_Response */ public static function connections() { return new \WP_REST_Response(Connections::state(\get_current_user_id())); } /** * @return void */ public static function renderPage() { if (!Availability::offered() || !\current_user_can('manage_options')) { return; } echo '
'; /* translators: MCP is a protocol name; keep it in English. */ self::renderBand(\__('MCP', 'extendify-local'), self::intro(true), 'extendify-mcp'); echo '
'; self::renderSection(\get_current_user_id(), false); echo '
'; } /** * @param string $title - The page's heading. * @param string $intro - A line under the heading, or none. * @param string $id - The heading's id, or none. * @return void */ public static function renderBand($title, $intro = '', $id = '') { echo '
'; if (PartnerData::$logo) { printf( '%2$s', \esc_url(PartnerData::$logo), \esc_attr(PartnerData::$name) ); } echo '
' . \esc_html($title) . ''; if ($intro !== '') { echo '

' . \esc_html($intro) . '

'; } echo '
'; } /** * @return void */ public static function handleAction() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $action = \sanitize_key(\wp_unslash($_GET['extendify_mcp_action'] ?? '')); if (!in_array($action, ['revoke', 'turn_off', 'turn_on'], true)) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended $userId = (int) \sanitize_text_field(\wp_unslash($_GET['user_id'] ?? '')) ?: \get_current_user_id(); // Without the nonce, a link on any page could revoke or switch off connections as the signed-in administrator. \check_admin_referer('extendify_mcp_' . $action . '_' . $userId); if ($action === 'revoke') { self::revokeConnection($userId, \sanitize_key(\wp_unslash($_GET['connection'] ?? ''))); } if ($action === 'turn_off') { self::turnSiteOff(); } if ($action === 'turn_on') { self::turnSiteOn(); } \wp_safe_redirect(self::screenUrl($userId) . '#extendify-mcp'); exit; } /** * @return void */ public static function turnSiteOff() { if (!\current_user_can('manage_options')) { return; } Availability::turnOff(\get_current_user_id()); } /** * @return void */ public static function turnSiteOn() { if (!\current_user_can('manage_options')) { return; } Availability::turnOn(); } /** * @param integer $userId - The user the connection belongs to. * @param string $id - The connection's id. * @return void */ public static function revokeConnection($userId, $id) { // Without this, any signed-in user could end another administrator's connections. if (!\current_user_can('edit_user', $userId)) { return; } Connections::revoke($userId, $id); } /** * @param \WP_User $user - The user whose profile is on screen. * @return void */ public static function render($user) { if (!\current_user_can('edit_user', $user->ID) || !Availability::offered()) { return; } if (!\user_can($user->ID, 'manage_options')) { return; } /* translators: heading over the assistants another user has authorized. */ echo '

' . \esc_html__('AI assistant connections', 'extendify-local') . '

'; self::renderSection($user->ID); } /** * @param integer $userId - The user whose connections to show. * @param boolean $withIntro - Whether to print the intro, which the settings screen puts in its heading. * @return void */ private static function renderSection($userId, $withIntro = true) { self::enqueueStyles(); if (Availability::turnedOff()) { self::renderTurnedOff($userId); return; } $isSelf = (int) $userId === \get_current_user_id(); if ($withIntro) { echo '

' . \esc_html(self::intro($isSelf)) . '

'; } if (!$isSelf) { self::renderList($userId); return; } \wp_register_script('extendify-mcp-profile', false, [], false, true); \wp_enqueue_script('extendify-mcp-profile'); \wp_add_inline_script('extendify-mcp-profile', self::script(\get_current_user_id())); /* translators: heading over the steps that connect the person's AI assistant to this site; not a sign-in. */ self::openSection(\__('Connect your AI assistant', 'extendify-local'), 'extendify-mcp'); self::renderReachability(); self::renderPicker(); echo ''; /* translators: heading over the assistants this person has authorized. */ self::openSection(\__('Your authorized assistants', 'extendify-local')); self::renderList($userId); echo ''; } /** * @param string $title - The heading that collapses the section. * @param string $class - An extra class for the section's body. * @return void */ private static function openSection($title, $class = '') { printf( '
%1$s' . '
', \esc_html($title), \esc_attr(trim('extendify-mcp-section-body ' . $class)) ); } /** * @return void */ private static function enqueueStyles() { \wp_register_style('extendify-mcp-profile', false, [], false); \wp_enqueue_style('extendify-mcp-profile'); \wp_add_inline_style('extendify-mcp-profile', self::styles()); } /** * @param boolean $isSelf - Whether this is the screen the viewer connects from. * @return string */ private static function intro($isSelf) { if (!$isSelf) { /* translators: shown to an administrator looking at someone else's profile. */ return \__( 'AI assistants this user has authorized. Only they can add new ones, but you can revoke any of them.', 'extendify-local' ); } if (!Allowed::writable()) { /* translators: the assistant acts with this person's permissions but cannot change anything. */ return \__( 'Connect an AI assistant to your site so it can look things up for you. It cannot change anything.', 'extendify-local' ); } /* translators: the assistant acts with this person's own permissions. */ return \__('Connect an AI assistant to work on your site.', 'extendify-local'); } /** * @return void */ private static function renderReachability() { $obstacle = Reachability::obstacle(); if (!$obstacle) { return; } $reasons = [ /* translators: shown when the site is reachable only inside its own network. */ 'local' => \__( 'This site needs to be publicly accessible for an AI assistant to connect to it.', 'extendify-local' ), /* translators: HTTPS is a protocol name; keep it in English. */ 'http' => \__( 'This site needs to be served over HTTPS for an AI assistant to connect to it.', 'extendify-local' ), /* translators: shown when the whole site sits behind a password prompt. */ 'auth' => \__( 'Remove the site\'s password protection so an AI assistant can connect to it.', 'extendify-local' ), /* translators: names the likely causes when requests from outside never arrive. */ 'blocked' => \__( 'Outside requests can\'t reach this site. A coming-soon page or security plugin may be blocking them.', 'extendify-local' ), ]; printf('

%s

', \esc_html($reasons[$obstacle])); } /** * @return void */ private static function renderPicker() { $clients = Clients::all(); $selected = self::selected($clients); echo '

' /* translators: heading over a picker of AI assistants. */ . \esc_html__('Which assistant are you using?', 'extendify-local') . '

' /* translators: under the heading of the AI assistant picker. */ . '

' . \esc_html__( 'Pick your AI assistant to see how to connect it.', 'extendify-local' ) . '

'; echo ''; foreach ($clients as $client) { self::renderPanel($client, $client['id'] === $selected); } echo '
'; } /** * @param array $client - One of the Clients entries. * @param boolean $open - Whether this is the one the picker is showing. * @return void */ private static function renderPanel(array $client, $open) { printf( '
', \esc_attr($client['id']), $open ? '' : ' hidden' ); if (isset($client['gate'])) { self::renderGate($client['gate']); } self::renderSteps($client); echo '
'; } /** * @param array $gate - Its tone and its line. * @return void */ private static function renderGate(array $gate) { printf( '

%2$s

', \esc_attr($gate['tone']), \wp_kses($gate['text'], self::STEP_MARKUP) ); } /** * @param array $links - The client's buttons, each opening the assistant in a new tab. * @return void */ private static function renderLinks(array $links) { echo '

'; foreach ($links as $link) { printf( '%3$s' . ' %4$s', $link['primary'] ? 'button-primary' : '', \esc_url($link['url']), \esc_html($link['label']), /* translators: screen-reader text appended to a link that opens a new tab. */ \esc_html__('(opens in a new tab)', 'extendify-local') ); } echo '

'; } /** * @param array $client - One of the Clients entries. * @return void */ private static function renderSteps(array $client) { echo '
    '; foreach ($client['steps'] as $step) { echo '
  1. ' . \esc_html($step['title']) . '

    '; self::renderInstructions($step, $client['id']); echo '
  2. '; } echo '
'; } /** * @param array $block - A step, or a part of one, with optional text, substeps, links, fields and asides. * @param string $clientId - The client the fields are for. * @return void */ private static function renderInstructions(array $block, $clientId) { foreach ((array) ($block['text'] ?? []) as $paragraph) { echo '

' . \wp_kses($paragraph, self::STEP_MARKUP) . '

'; } if (isset($block['substeps'])) { echo '
    '; foreach ($block['substeps'] as $substep) { $substep = is_array($substep) ? $substep : ['text' => $substep]; echo '
  1. ' . \wp_kses($substep['text'], self::STEP_MARKUP); self::renderFields($clientId, $substep['fields'] ?? []); echo '
  2. '; } echo '
'; } if (isset($block['links'])) { self::renderLinks($block['links']); } self::renderFields($clientId, $block['fields'] ?? []); foreach ($block['asides'] ?? [] as $aside) { printf('
%s', \esc_html($aside['summary'])); self::renderInstructions($aside, $clientId); echo '
'; } } /** * @param string $clientId - The client the fields are for. * @param array $fields - Labels keyed by the value they carry: name, url or prompt. * @return void */ private static function renderFields($clientId, array $fields) { $values = [ 'url' => Metadata::resource(), 'prompt' => Clients::prompt(), ]; foreach ($fields as $field => $label) { if ($label !== '') { echo '

' . \esc_html($label) . '

'; } if ($field === 'name') { printf( '

%s

', sprintf( /* translators: %s: this site's name, offered as an example name for the connection. */ \esc_html__('Anything you\'ll recognize, like %s.', 'extendify-local'), '' . \esc_html(Clients::siteName()) . '' ) ); continue; } self::renderCopy('extendify-mcp-' . $field . '-' . $clientId, $values[$field], $field === 'prompt'); } } /** * @param string $id - The id the copy button reads the text from. * @param string $text - The text to copy. * @param boolean $multiline - Whether the text is a prompt that wraps rather than a one-line value. * @return void */ private static function renderCopy($id, $text, $multiline = false) { printf( $multiline ? '
%2$s
' : '

%2$s', \esc_attr($id), \esc_html($text) ); printf( '', \esc_attr($id), /* translators: button label for two seconds after the text was copied. */ \esc_attr__('Copied', 'extendify-local'), /* translators: button that copies the text beside it. */ \esc_html__('Copy', 'extendify-local') ); echo $multiline ? '

' : '

'; } /** * @param array $clients - The clients the picker lists. * @return string */ private static function selected(array $clients) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $asked = \sanitize_key(\wp_unslash($_GET['assistant'] ?? '')); return in_array($asked, array_column($clients, 'id'), true) ? $asked : $clients[0]['id']; } /** * @param integer $userId - The user whose profile screen this is. * @return void */ private static function renderTurnedOff($userId) { $off = Availability::turnedOff(); $who = \get_userdata($off['by']); $when = self::when($off['at']); $notice = $who ? sprintf( /* translators: 1: a person's name. 2: a date and time. No assistant works until this is undone. */ \__( '%1$s turned connections off for the whole site on %2$s. None work until they\'re turned back on.', 'extendify-local' ), $who->display_name, $when ) : sprintf( /* translators: %s: a date and time. No assistant works until this is undone. */ \__( 'Connections were turned off for the whole site on %s. None work until they\'re turned back on.', 'extendify-local' ), $when ); printf('

%s

', \esc_html($notice)); if (!\current_user_can('manage_options')) { return; } printf( '

%2$s

', \esc_url(self::actionUrl('turn_on', $userId)), /* translators: button that re-enables connections for the whole site. */ \esc_html__('Turn connections back on', 'extendify-local') ); } /** * @param integer $userId - The user whose connections to list. * @return void */ private static function renderList($userId) { $connections = Connections::all($userId); if (!$connections) { echo '

' /* translators: empty state under that heading. */ . \esc_html__('No assistants have been authorized yet.', 'extendify-local') . '

'; return; } echo '
'; foreach ($connections as $connection) { self::renderConnection($userId, $connection); } echo '
'; } /** * @param integer $userId - The user the connection belongs to. * @param array $connection - The connection being listed. * @return void */ private static function renderConnection($userId, array $connection) { /* translators: fallback when an assistant gave no name. */ $name = $connection['label'] ?: \__('Unnamed assistant', 'extendify-local'); $stale = !empty($connection['invalidated']); $calls = Log::recent($userId, $connection['id']); printf('
', $stale ? ' is-stale' : ''); printf( '
%1$s' . '%2$s
', // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped as it is built. self::assistantName($name, $connection['client'] ?? ''), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped as it is built. implode(' · ', self::summary($connection, (bool) $calls)) ); printf( '
%2$s' . '%5$s
', $stale ? ' is-stale' : '', \esc_html($stale /* translators: badge on a connection that no longer works. */ ? \__('Needs reconnecting', 'extendify-local') : Grants::label($connection['grants'])), \esc_url(self::actionUrl('revoke', $userId, $connection['id'])), /* translators: %s: the assistant's name. */ \esc_attr(sprintf(\__('Revoke "%s"', 'extendify-local'), $name)), /* translators: button that ends an assistant's access. */ \esc_html__('Revoke', 'extendify-local') ); if ($stale) { echo '

' . \esc_html__( /* translators: why a connection stopped working, and the fix. Revoke is the button beside this message; use its label. */ '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 'extendify-local' ) . '

'; } self::renderActivity($connection, $calls); echo '
'; } /** * @param array $connection - The connection being listed. * @param array $calls - Its newest calls, newest first. * @return void */ private static function renderActivity(array $connection, array $calls) { if (!$calls) { return; } $used = $connection['lastUsed'] ?: strtotime($calls[0]['created_at'] . ' UTC'); printf( '
%2$s
    ', \esc_attr(self::exactly($used)), /* translators: %s: how long ago, such as "2 hours". */ \esc_html(sprintf(\__('Last used %s ago', 'extendify-local'), \human_time_diff($used))) ); foreach ($calls as $call) { $at = strtotime($call['created_at'] . ' UTC'); printf( '
  • %2$s%3$s%4$s
  • ', \esc_attr(self::exactly($at)), /* translators: %s: how long ago, such as "2 hours". */ \esc_html(sprintf(\__('%s ago', 'extendify-local'), \human_time_diff($at))), \esc_html($call['tool']), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped as it is built. self::refusal($call) ); } echo '
'; } /** * @param array $call - One row of the log. * @return string */ private static function refusal(array $call) { if ($call['outcome'] === 'ok') { return ''; } $why = $call['error'] ? $call['error'] : ($call['outcome'] === 'refused' /* translators: a tool call this site would not allow. */ ? \__('Refused', 'extendify-local') /* translators: a tool call that ended in an error. */ : \__('Failed', 'extendify-local')); return '' . \esc_html($why) . ''; } /** * @param array $connection - The connection being listed. * @param boolean $expandable - Whether a toggle already carries the last-used date. * @return array - The metadata line, already escaped, in order. */ private static function summary(array $connection, $expandable) { $parts = [ sprintf( /* translators: %s: a date. */ \esc_html__('Authorized %s', 'extendify-local'), \esc_html(self::when($connection['created'])) ), ]; if ($expandable) { return $parts; } $parts[] = $connection['lastUsed'] ? sprintf( '%2$s', \esc_attr(self::exactly($connection['lastUsed'])), sprintf( /* translators: %s: how long ago, such as "2 hours". */ \esc_html__('Last used %s ago', 'extendify-local'), \esc_html(\human_time_diff($connection['lastUsed'])) ) ) /* translators: shown when an assistant has never called this site. */ : \esc_html__('Never used', 'extendify-local'); return $parts; } /** * Two connections a person has named the same are told apart by where the client is. * * @param string $name - The label the client gave itself. * @param string $client - The client id, which is a URL when the client published one. * @return string */ private static function assistantName($name, $client) { if (\wp_parse_url((string) $client, PHP_URL_SCHEME) !== 'https') { return \esc_html($name); } return sprintf( '%2$s', \esc_url($client), \esc_html($name) ); } /** * @param integer $timestamp - A UTC timestamp. * @return string */ private static function when($timestamp) { return (string) \wp_date(\get_option('date_format'), (int) $timestamp); } /** * Two connections used on the same day read as one without the time. * * @param integer $timestamp - A UTC timestamp. * @return string */ private static function exactly($timestamp) { $format = \get_option('date_format') . ' ' . \get_option('time_format'); return (string) \wp_date($format, (int) $timestamp); } /** * @param string $action - One of the handleAction actions. * @param integer $userId - The user the connection belongs to. * @param string $connection - The connection's id. * @return string */ private static function actionUrl($action, $userId, $connection = '') { $args = ['extendify_mcp_action' => $action, 'user_id' => (int) $userId]; if ($connection) { $args['connection'] = $connection; } return \wp_nonce_url( \add_query_arg($args, self::screenUrl($userId)), 'extendify_mcp_' . $action . '_' . (int) $userId ); } /** * @param integer $userId - The user whose profile screen to point at. * @return string */ private static function screenUrl($userId) { return (int) $userId === \get_current_user_id() ? \admin_url('options-general.php?page=' . self::PAGE) : \add_query_arg('user_id', (int) $userId, \admin_url('user-edit.php')); } /** * @param string $screen - The admin screen's hook suffix, which WordPress puts on the body. * @return string */ public static function frameStyles($screen) { $page = 'body.' . $screen . ' '; // Core has no token for the ground its own stage sits on. return $page . '{ background: #1e1e1e; } ' . $page . '#wpcontent { padding-left: 0; } ' . $page . '#wpbody-content { padding-bottom: 0; } ' . $page . '#wpfooter { display: none; } .extendify-mcp-wrap { margin: 0 8px 8px 0; overflow: hidden; background: var(--wpds-color-background-surface-neutral-strong, #fff); border-radius: var(--wpds-border-radius-xl, 12px); min-height: calc(100vh - var(--wp-admin--admin-bar--height, 32px) - 8px); } /* Partner logos are drawn for their banner colour, and a light one vanishes on white. */ .extendify-mcp-band { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 24px; padding: var(--wpds-dimension-padding-lg, 16px) var(--wpds-dimension-padding-2xl, 24px); background: var(--ext-banner-main, transparent); color: var(--ext-banner-text, var(--wpds-color-foreground-content-neutral, #1e1e1e)); border-bottom: 1px solid var(--wpds-color-stroke-surface-neutral-weak, #f0f0f1); } .extendify-mcp-partner { display: block; flex: none; width: auto; min-width: 156px; max-width: min(208px, 100%); height: 40px; object-fit: contain; object-position: left center; } @media (min-width: 768px) { .extendify-mcp-partner { max-width: min(288px, 100%); } } .extendify-mcp-band h1 { margin: 0; padding: 0; font-size: var(--wpds-typography-font-size-lg, 15px); font-weight: var(--wpds-typography-font-weight-emphasis, 600); line-height: var(--wpds-typography-line-height-sm, 20px); color: inherit; } .extendify-mcp-band p { margin: var(--wpds-dimension-gap-xs, 4px) 0 0; max-width: 62ch; font-size: var(--wpds-typography-font-size-md, 13px); color: inherit; opacity: .8; } .extendify-mcp-band .notice { margin: var(--wpds-dimension-gap-md, 12px) 0 0; }'; } /** * @return string */ private static function styles() { return self::frameStyles('settings_page_' . self::PAGE) . ' .extendify-mcp-page { box-sizing: border-box; max-width: 680px; margin: 0 auto; padding: var(--wpds-dimension-padding-2xl, 24px); padding-bottom: 200px; } .extendify-mcp-section { background: var(--wpds-color-background-surface-neutral-strong, #fff); border: 1px solid var(--wpds-color-stroke-surface-neutral, #dcdcde); border-radius: var(--wpds-border-radius-lg, 8px); margin: 0 0 16px; } .extendify-mcp-section > summary { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 20px 24px; cursor: pointer; list-style: none; font-size: 14px; font-weight: 600; color: #1e1e1e; } .extendify-mcp-section > summary::-webkit-details-marker { display: none; } .extendify-mcp-section > summary::after { content: ""; flex: none; width: 7px; height: 7px; margin: -4px 4px 0 0; border: solid currentColor; border-width: 0 1.5px 1.5px 0; transform: rotate(45deg); } .extendify-mcp-section[open] > summary::after { margin-top: 4px; transform: rotate(-135deg); } .extendify-mcp-section > summary:focus-visible { outline: 2px solid var(--wp-admin-theme-color, #3858e9); outline-offset: -2px; border-radius: var(--wpds-border-radius-lg, 8px); } .extendify-mcp-section-body { padding: 0 24px 24px; } .extendify-mcp-card { margin: 0 0 20px; } .extendify-mcp-card h2 { font-size: var(--wpds-typography-font-size-md, 14px); font-weight: var(--wpds-typography-font-weight-emphasis, 600); margin: 0; } .extendify-mcp-card > .description { margin: var(--wpds-dimension-gap-xs, 4px) 0 var(--wpds-dimension-gap-md, 12px); } .extendify-mcp-address { display: flex; margin: 0; } .extendify-mcp-address code { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 6px 10px; border: 1px solid #dcdcde; border-right: 0; border-radius: 4px 0 0 4px; background: #f6f7f7; } .extendify-mcp-address .button { border-radius: 0 4px 4px 0; } .extendify-mcp-tiles { display: flex; flex-wrap: wrap; gap: 4px; padding: 4px; background: var(--wpds-color-background-surface-neutral-weak, #f0f0f1); border-radius: var(--wpds-border-radius-lg, 8px); } .extendify-mcp-tile { display: flex; flex: 1 1 124px; box-sizing: border-box; align-items: center; justify-content: center; gap: 8px; padding: 8px 12px; border-radius: 6px; color: #50575e; text-decoration: none; font-size: 13px; font-weight: 500; white-space: nowrap; } .extendify-mcp-tile:hover, .extendify-mcp-tile:focus { background: rgba(255, 255, 255, .6); color: #1e1e1e; } /* Core sets border-radius: 2px on a:focus, which squares the ring off the tile. */ .extendify-mcp-tile:focus { border-radius: 6px; } .extendify-mcp-tile[aria-pressed="true"] { background: #fff; color: var(--wp-admin-theme-color, #3858e9); font-weight: 600; box-shadow: 0 1px 2px rgba(0, 0, 0, .08), 0 0 0 1px rgba(0, 0, 0, .04); } .extendify-mcp-logo { display: inline-flex; flex: none; width: 18px; height: 18px; color: #1e1e1e; } .extendify-mcp-logo svg { width: 100%; height: 100%; } .extendify-mcp-panel { margin-top: 16px; } .extendify-mcp-gate { margin: 0; } .extendify-mcp-gate p { margin: 0; } .extendify-mcp-gate.is-ok { position: relative; padding-left: 22px; color: #1e1e1e; } .extendify-mcp-gate.is-ok::before { content: ""; position: absolute; left: 4px; top: 3px; width: 5px; height: 9px; border: solid #008a20; border-width: 0 2px 2px 0; transform: rotate(45deg); } .extendify-mcp-gate.is-warn { border-radius: 6px; padding: 8px 10px; background: var(--wpds-color-background-surface-caution-weak, #fcf5e6); border: 1px solid var(--wpds-color-stroke-surface-caution, #e3cf9a); color: var(--wpds-color-foreground-content-caution-weak, #7a5600); } .extendify-mcp-actions { display: flex; flex-wrap: wrap; gap: var(--wpds-dimension-gap-sm, 8px); margin: var(--wpds-dimension-gap-md, 12px) 0 0; } .extendify-mcp-copyable { display: flex; gap: var(--wpds-dimension-gap-sm, 8px); align-items: flex-start; margin-top: var(--wpds-dimension-padding-md, 10px); } .extendify-mcp-copyable pre { flex: 1 1 auto; min-width: 0; margin: 0; overflow-x: auto; background: var(--wpds-color-background-surface-neutral-strong, #fff); border: 1px solid var(--wpds-color-stroke-surface-neutral, #dcdcde); border-radius: var(--wpds-border-radius-md, 6px); padding: var(--wpds-dimension-gap-sm, 8px) var(--wpds-dimension-padding-md, 10px); font-family: var(--wpds-typography-font-family-mono, monospace); white-space: pre-wrap; word-break: break-word; } .extendify-mcp-aside { border-top: 1px solid var(--wpds-color-stroke-surface-neutral, #dcdcde); margin-top: var(--wpds-dimension-gap-md, 12px); padding-top: var(--wpds-dimension-padding-md, 10px); } .extendify-mcp-aside summary, .extendify-mcp-manual summary { cursor: var(--wpds-cursor-control, pointer); color: var(--wpds-color-foreground-interactive-brand, #2271b1); } .extendify-mcp-steps { list-style: none; margin: 16px 0 0; padding: 0; counter-reset: extendify-mcp-step; } .extendify-mcp-steps > li { position: relative; margin: 0; padding: 16px 0 16px 32px; border-top: 1px solid #f0f0f1; counter-increment: extendify-mcp-step; } .extendify-mcp-steps > li:first-child { padding-top: 0; border-top: 0; } .extendify-mcp-steps > li::before { content: counter(extendify-mcp-step); position: absolute; left: 0; top: 16px; width: 22px; height: 22px; border-radius: 50%; background: #f0f0f1; color: #1e1e1e; font-size: 12px; font-weight: 600; line-height: 22px; text-align: center; } .extendify-mcp-steps > li:first-child::before { top: 0; } .extendify-mcp-steps h4 { margin: 2px 0 4px; font-size: 14px; font-weight: 600; } .extendify-mcp-steps p { margin: 4px 0 0; } .extendify-mcp-manual { margin-top: 10px; } .extendify-mcp-manual ol, .extendify-mcp-substeps { margin: 8px 0 0 18px; } .extendify-mcp-substeps li { margin-bottom: 6px; } .extendify-mcp-steps .extendify-mcp-field { margin: 12px 0 4px; font-size: 12px; font-weight: 500; color: #50575e; } .extendify-mcp-connections { display: flex; flex-direction: column; gap: var(--wpds-dimension-gap-sm, 8px); } .extendify-mcp-connection { display: flex; flex-wrap: wrap; gap: var(--wpds-dimension-gap-sm, 8px) var(--wpds-dimension-gap-lg, 14px); align-items: center; justify-content: space-between; background: var(--wpds-color-background-surface-neutral-strong, #fff); border: 1px solid var(--wpds-color-stroke-surface-neutral, #dcdcde); border-radius: var(--wpds-border-radius-lg, 8px); padding: var(--wpds-dimension-padding-md, 12px) var(--wpds-dimension-padding-lg, 16px); } .extendify-mcp-connection.is-stale { border-color: var(--wpds-color-stroke-surface-caution, #e3cf9a); } .extendify-mcp-who { flex: 1 1 320px; min-width: 0; } .extendify-mcp-name { display: block; font-weight: var(--wpds-typography-font-weight-emphasis, 600); } .extendify-mcp-meta { display: block; color: var(--wpds-color-foreground-content-neutral-weak, #646970); } .extendify-mcp-status { display: flex; align-items: center; gap: var(--wpds-dimension-padding-md, 10px); } .extendify-mcp-pill { background: var(--wpds-color-background-surface-brand, #eef4fa); color: var(--wpds-color-foreground-interactive-brand, #135e96); border-radius: 999px; padding: 3px var(--wpds-dimension-padding-md, 10px); font-size: var(--wpds-typography-font-size-sm, 12px); white-space: nowrap; } .extendify-mcp-pill.is-stale { background: var(--wpds-color-background-surface-caution-weak, #fcf5e6); color: var(--wpds-color-foreground-content-caution-weak, #7a5600); } .extendify-mcp-revoke { color: var(--wpds-color-foreground-interactive-error, #b32d2e); } .extendify-mcp-why { flex: 1 1 100%; margin: 0; color: var(--wpds-color-foreground-content-caution-weak, #7a5600); } .extendify-mcp-activity { flex: 1 1 100%; border-top-color: var(--wpds-color-stroke-surface-neutral-weak, #f0f0f1); margin-top: var(--wpds-dimension-padding-md, 10px); padding-top: var(--wpds-dimension-gap-sm, 8px); } .extendify-mcp-activity ul { margin: var(--wpds-dimension-gap-sm, 8px) 0 0; } .extendify-mcp-activity li { display: flex; flex-wrap: wrap; gap: var(--wpds-dimension-gap-xs, 4px) var(--wpds-dimension-padding-md, 10px); align-items: baseline; padding: var(--wpds-dimension-gap-xs, 4px) 0; border-top: 1px solid var(--wpds-color-stroke-surface-neutral-weak, #f0f0f1); } .extendify-mcp-activity li > span:first-child { flex: 0 0 auto; min-width: 92px; color: var(--wpds-color-foreground-content-neutral-weak, #646970); } .extendify-mcp-refused { min-width: 0; color: var(--wpds-color-foreground-interactive-error, #b32d2e); } .extendify-mcp-empty { color: var(--wpds-color-foreground-content-neutral-weak, #646970); } /* Arabic letters render unjoined in the mono font. */ .rtl .extendify-mcp-steps code, .rtl .extendify-mcp-copyable pre { font-family: inherit; }'; } /** * Without this, a user who approved on the assistant comes back to an empty list. * * @param integer $userId - The user whose connections the screen is showing. * @return string */ private static function script($userId) { $state = array_merge(Connections::state($userId), [ 'url' => \rest_url(Config::$slug . '/' . Config::$apiVersion . '/mcp/connections'), 'nonce' => \wp_create_nonce('wp_rest'), ]); $state['watching'] = $state['count'] === 0; return 'window.extendifyMcpWatch = ' . \wp_json_encode($state) . "; var watch = window.extendifyMcpWatch; var deadline = 0; var timer = null; var stopWatching = function () { if (timer) { clearInterval(timer); timer = null; } }; var check = function () { if (Date.now() > deadline) { stopWatching(); return; } if (document.hidden) { return; } fetch(watch.url, { headers: { 'X-WP-Nonce': watch.nonce }, credentials: 'same-origin' }) .then(function (response) { return response.ok ? response.json() : null; }) .then(function (state) { if (!state || (state.count === watch.count && state.newest === watch.newest)) { return; } stopWatching(); window.location.reload(); }) .catch(function () {}); }; var armed = false; var startWatching = function () { armed = true; deadline = Date.now() + 120000; if (!timer) { timer = setInterval(check, 3000); } }; if (watch.watching) { startWatching(); } document.addEventListener('visibilitychange', function () { if (document.hidden || !armed) { return; } startWatching(); check(); }); var copyFrom = function (button) { var source = document.getElementById(button.dataset.copy); if (!source) { return; } var range = document.createRange(); range.selectNodeContents(source); window.getSelection().removeAllRanges(); window.getSelection().addRange(range); if (navigator.clipboard) { navigator.clipboard.writeText(source.textContent); } else { document.execCommand('copy'); } var label = button.textContent; button.textContent = button.dataset.copied; setTimeout(function () { button.textContent = label; }, 2000); startWatching(); }; var show = function (id) { var tiles = document.querySelectorAll('.extendify-mcp-tile'); for (var t = 0; t < tiles.length; t++) { tiles[t].setAttribute('aria-pressed', String(tiles[t].dataset.client === id)); } var panels = document.querySelectorAll('.extendify-mcp-panel'); for (var p = 0; p < panels.length; p++) { panels[p].hidden = panels[p].dataset.client !== id; } }; document.addEventListener('click', function (event) { var copy = event.target.closest('[data-copy]'); if (copy) { copyFrom(copy); return; } var tile = event.target.closest('.extendify-mcp-tile'); if (tile) { event.preventDefault(); show(tile.dataset.client); window.history.replaceState({}, '', tile.href); } });"; } }