From f8c55a103d7370b84fcab188ea0805f37bd1cf47 Mon Sep 17 00:00:00 2001 From: Eoin Gallagher Date: Fri, 10 Apr 2026 21:24:56 +0100 Subject: [PATCH 01/39] feat(ai): add MCP settings admin page for Jetpack AI Standalone React admin page at wp-admin/?page=jetpack-ai that mirrors the WordPress.com dashboard MCP settings, with hub/read/write/setup views and per-tool and per-category enable toggles. Co-Authored-By: Claude Sonnet 4.6 --- .../plugins/jetpack/_inc/client/ai-admin.js | 32 +++ .../plugins/jetpack/_inc/client/ai/main.jsx | 139 +++++++++ .../jetpack/_inc/client/ai/mcp/categories.js | 142 +++++++++ .../jetpack/_inc/client/ai/mcp/index.jsx | 225 +++++++++++++++ .../jetpack/_inc/client/ai/mcp/read.jsx | 262 +++++++++++++++++ .../jetpack/_inc/client/ai/mcp/setup.jsx | 270 ++++++++++++++++++ .../jetpack/_inc/client/ai/mcp/style.scss | 89 ++++++ .../_inc/client/ai/mcp/use-mcp-settings.js | 81 ++++++ .../jetpack/_inc/client/ai/mcp/utils.js | 86 ++++++ .../jetpack/_inc/client/ai/mcp/write.jsx | 265 +++++++++++++++++ .../plugins/jetpack/_inc/client/ai/style.scss | 26 ++ .../lib/admin-pages/class-jetpack-ai-page.php | 114 ++++++++ ...pcom-rest-api-v2-endpoint-mcp-settings.php | 159 +++++++++++ .../aiint-357-jetpack-ai-mcp-settings | 4 + .../plugins/jetpack/class.jetpack-admin.php | 4 + .../plugins/jetpack/tools/webpack.config.js | 17 ++ 16 files changed, 1915 insertions(+) create mode 100644 projects/plugins/jetpack/_inc/client/ai-admin.js create mode 100644 projects/plugins/jetpack/_inc/client/ai/main.jsx create mode 100644 projects/plugins/jetpack/_inc/client/ai/mcp/categories.js create mode 100644 projects/plugins/jetpack/_inc/client/ai/mcp/index.jsx create mode 100644 projects/plugins/jetpack/_inc/client/ai/mcp/read.jsx create mode 100644 projects/plugins/jetpack/_inc/client/ai/mcp/setup.jsx create mode 100644 projects/plugins/jetpack/_inc/client/ai/mcp/style.scss create mode 100644 projects/plugins/jetpack/_inc/client/ai/mcp/use-mcp-settings.js create mode 100644 projects/plugins/jetpack/_inc/client/ai/mcp/utils.js create mode 100644 projects/plugins/jetpack/_inc/client/ai/mcp/write.jsx create mode 100644 projects/plugins/jetpack/_inc/client/ai/style.scss create mode 100644 projects/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-ai-page.php create mode 100644 projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php create mode 100644 projects/plugins/jetpack/changelog/aiint-357-jetpack-ai-mcp-settings diff --git a/projects/plugins/jetpack/_inc/client/ai-admin.js b/projects/plugins/jetpack/_inc/client/ai-admin.js new file mode 100644 index 000000000000..2356f4462d5c --- /dev/null +++ b/projects/plugins/jetpack/_inc/client/ai-admin.js @@ -0,0 +1,32 @@ +/** + * Entry point for the Jetpack AI admin page. + * + * Mounts the React app into the #jetpack-ai-root div rendered by Jetpack_AI_Page::page_render(). + */ + +import apiFetch from '@wordpress/api-fetch'; +import * as WPElement from '@wordpress/element'; +import App from './ai/main'; +import './ai/style.scss'; + +const { apiRoot, apiNonce } = window?.jetpackAiSettings ?? {}; + +if ( apiRoot ) { + apiFetch.use( apiFetch.createRootURLMiddleware( apiRoot ) ); +} +if ( apiNonce ) { + apiFetch.use( apiFetch.createNonceMiddleware( apiNonce ) ); +} + +/** + * Mount the React app into the page root element. + */ +function render() { + const container = document.getElementById( 'jetpack-ai-root' ); + if ( ! container ) { + return; + } + WPElement.createRoot( container ).render( ); +} + +render(); diff --git a/projects/plugins/jetpack/_inc/client/ai/main.jsx b/projects/plugins/jetpack/_inc/client/ai/main.jsx new file mode 100644 index 000000000000..28ab067210a9 --- /dev/null +++ b/projects/plugins/jetpack/_inc/client/ai/main.jsx @@ -0,0 +1,139 @@ +/** + * Root component for the Jetpack AI admin page. + * + * Manages the view stack (hub → read | write | setup) and owns the MCP settings state. + */ + +import { + Button, + Notice, + Spinner, + __experimentalText as Text, // eslint-disable-line @wordpress/no-unsafe-wp-apis + __experimentalVStack as VStack, // eslint-disable-line @wordpress/no-unsafe-wp-apis +} from '@wordpress/components'; +import { useCallback, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { arrowLeft } from '@wordpress/icons'; +import McpHub from './mcp/index'; +import McpRead from './mcp/read'; +import McpSetup from './mcp/setup'; +import { useMcpSettings } from './mcp/use-mcp-settings'; +import McpWrite from './mcp/write'; + +const { blogId } = window?.jetpackAiSettings ?? {}; + +const VIEW_TITLES = { + hub: __( 'AI settings', 'jetpack' ), + read: __( 'Read', 'jetpack' ), + write: __( 'Write', 'jetpack' ), + setup: __( 'Connect external AI agent', 'jetpack' ), +}; + +const VIEW_DESCRIPTIONS = { + hub: __( 'Control how external AI agents can access this site via MCP.', 'jetpack' ), + read: __( 'View your site\u2019s content.', 'jetpack' ), + write: __( 'Create, update, and manage content on your site.', 'jetpack' ), + setup: __( 'Get instructions for connecting your external AI assistant.', 'jetpack' ), +}; + +/** + * Root App component for the Jetpack AI admin page. + * + * @return {object} Component markup. + */ +export default function App() { + const [ view, setView ] = useState( 'hub' ); + const [ saveError, setSaveError ] = useState( null ); + const { isLoading, isSaving, mcpAbilities, error, updateMcpAbilities } = useMcpSettings(); + + const handleUpdate = useCallback( + update => { + setSaveError( null ); + return updateMcpAbilities( update ).catch( () => { + setSaveError( __( 'Failed to save MCP settings. Please try again.', 'jetpack' ) ); + } ); + }, + [ updateMcpAbilities ] + ); + + const dismissSaveError = useCallback( () => setSaveError( null ), [] ); + const navigateBack = useCallback( () => setView( 'hub' ), [] ); + + const isSubView = view !== 'hub'; + + return ( +
+
+ { isSubView && ( + + ) } + + + { VIEW_TITLES[ view ] } + + { VIEW_DESCRIPTIONS[ view ] && ( + { VIEW_DESCRIPTIONS[ view ] } + ) } + +
+ +
+ { isLoading && ( +
+ +
+ ) } + + { ! isLoading && error && ( + + { error } + + ) } + + { ! isLoading && saveError && ( + + { saveError } + + ) } + + { ! isLoading && ! error && ( + + { view === 'hub' && ( + + ) } + { view === 'read' && ( + + ) } + { view === 'write' && ( + + ) } + { view === 'setup' && } + + ) } +
+
+ ); +} diff --git a/projects/plugins/jetpack/_inc/client/ai/mcp/categories.js b/projects/plugins/jetpack/_inc/client/ai/mcp/categories.js new file mode 100644 index 000000000000..8d78bcf01d2c --- /dev/null +++ b/projects/plugins/jetpack/_inc/client/ai/mcp/categories.js @@ -0,0 +1,142 @@ +/** + * MCP Tools Category Mapping. + * + * Ported from client/dashboard/me/mcp/categories.ts in wp-calypso. + * Maps API category values to display categories and sub-categories for the MCP settings page. + */ + +import { __ } from '@wordpress/i18n'; + +export const DISPLAY_CATEGORIES = { + POSTS: __( 'Posts', 'jetpack' ), + PAGES: __( 'Pages', 'jetpack' ), + DESIGN: __( 'Design', 'jetpack' ), + SITES: __( 'Sites', 'jetpack' ), + ACCOUNT: __( 'Account', 'jetpack' ), + DOMAINS: __( 'Domains', 'jetpack' ), + DEVELOPER_TESTING: __( 'Developer & testing', 'jetpack' ), + UNCATEGORIZED: __( 'Uncategorized', 'jetpack' ), +}; + +export const CATEGORY_ORDER = [ + DISPLAY_CATEGORIES.SITES, + DISPLAY_CATEGORIES.POSTS, + DISPLAY_CATEGORIES.PAGES, + DISPLAY_CATEGORIES.DESIGN, + DISPLAY_CATEGORIES.DOMAINS, + DISPLAY_CATEGORIES.ACCOUNT, + DISPLAY_CATEGORIES.DEVELOPER_TESTING, + DISPLAY_CATEGORIES.UNCATEGORIZED, +]; + +const SUB_CATEGORIES = { + POSTS: __( 'Posts', 'jetpack' ), + COMMENTS: __( 'Comments', 'jetpack' ), + CATEGORIES_TAGS: __( 'Categories & tags', 'jetpack' ), + SITES: __( 'Sites', 'jetpack' ), + MEDIA: __( 'Media', 'jetpack' ), + SITE_SETTINGS: __( 'Site settings', 'jetpack' ), + ANALYTICS: __( 'Analytics', 'jetpack' ), + ACCOUNT: __( 'Account', 'jetpack' ), + NOTIFICATIONS: __( 'Notifications', 'jetpack' ), +}; + +export const SUB_CATEGORY_ORDER = { + [ DISPLAY_CATEGORIES.POSTS ]: [ + SUB_CATEGORIES.POSTS, + SUB_CATEGORIES.COMMENTS, + SUB_CATEGORIES.CATEGORIES_TAGS, + ], + [ DISPLAY_CATEGORIES.SITES ]: [ + SUB_CATEGORIES.SITES, + SUB_CATEGORIES.SITE_SETTINGS, + SUB_CATEGORIES.MEDIA, + SUB_CATEGORIES.ANALYTICS, + ], + [ DISPLAY_CATEGORIES.ACCOUNT ]: [ SUB_CATEGORIES.ACCOUNT, SUB_CATEGORIES.NOTIFICATIONS ], +}; + +const API_CATEGORY_TO_DISPLAY = { + posts: DISPLAY_CATEGORIES.POSTS, + comments: DISPLAY_CATEGORIES.POSTS, + 'categories-tags': DISPLAY_CATEGORIES.POSTS, + pages: DISPLAY_CATEGORIES.PAGES, + design: DISPLAY_CATEGORIES.DESIGN, + sites: DISPLAY_CATEGORIES.SITES, + media: DISPLAY_CATEGORIES.SITES, + users: DISPLAY_CATEGORIES.SITES, + plugins: DISPLAY_CATEGORIES.SITES, + 'site-settings': DISPLAY_CATEGORIES.SITES, + analytics: DISPLAY_CATEGORIES.SITES, + account: DISPLAY_CATEGORIES.ACCOUNT, + notifications: DISPLAY_CATEGORIES.ACCOUNT, + billing: DISPLAY_CATEGORIES.ACCOUNT, + domains: DISPLAY_CATEGORIES.DOMAINS, + 'developer-testing': DISPLAY_CATEGORIES.DEVELOPER_TESTING, +}; + +const API_CATEGORY_TO_SUB_CATEGORY = { + posts: SUB_CATEGORIES.POSTS, + comments: SUB_CATEGORIES.COMMENTS, + 'categories-tags': SUB_CATEGORIES.CATEGORIES_TAGS, + sites: SUB_CATEGORIES.SITES, + media: SUB_CATEGORIES.MEDIA, + users: SUB_CATEGORIES.SITE_SETTINGS, + plugins: SUB_CATEGORIES.SITE_SETTINGS, + 'site-settings': SUB_CATEGORIES.SITE_SETTINGS, + analytics: SUB_CATEGORIES.ANALYTICS, + account: SUB_CATEGORIES.ACCOUNT, + notifications: SUB_CATEGORIES.NOTIFICATIONS, + billing: SUB_CATEGORIES.ACCOUNT, +}; + +/** + * Get the display sub-category name for a tool. + * + * @param {string} toolId - Tool identifier. + * @param {object} ability - Tool descriptor from the API. + * @return {string | undefined} Sub-category display name, or undefined if none. + */ +export function getSubCategory( toolId, ability ) { + const apiCategory = ability?.category; + if ( apiCategory ) { + return API_CATEGORY_TO_SUB_CATEGORY[ apiCategory ]; + } + return undefined; +} + +/** + * Check whether a tool is a write (non-readonly) tool. + * + * @param {string} toolId - Tool identifier. + * @param {object} ability - Tool descriptor from the API. + * @return {boolean} True if the tool is a write tool. + */ +export function isWriteTool( toolId, ability ) { + return ability?.readonly === false; +} + +/** + * Get the display category name for a tool. + * + * @param {string} toolId - Tool identifier. + * @param {object} ability - Tool descriptor from the API. + * @return {string} Display category name, falling back to Uncategorized. + */ +export function getDisplayCategory( toolId, ability ) { + const apiCategory = ability?.category; + if ( apiCategory && API_CATEGORY_TO_DISPLAY[ apiCategory ] ) { + return API_CATEGORY_TO_DISPLAY[ apiCategory ]; + } + return DISPLAY_CATEGORIES.UNCATEGORIZED; +} + +/** + * Pass-through sort — preserved for interface compatibility. + * + * @param {Array} tools - Tool entries to sort. + * @return {Array} The same tool entries, unchanged. + */ +export function sortTools( tools ) { + return tools; +} diff --git a/projects/plugins/jetpack/_inc/client/ai/mcp/index.jsx b/projects/plugins/jetpack/_inc/client/ai/mcp/index.jsx new file mode 100644 index 000000000000..305768047a2d --- /dev/null +++ b/projects/plugins/jetpack/_inc/client/ai/mcp/index.jsx @@ -0,0 +1,225 @@ +/** + * MCP Settings hub — main view shown at wp-admin/admin.php?page=jetpack-ai. + * Shows the enable/disable toggle and navigation to Read, Write, and Setup sub-views. + */ + +import { + Button, + Card, + CardBody, + CardDivider, + Icon, + ToggleControl, + __experimentalHStack as HStack, // eslint-disable-line @wordpress/no-unsafe-wp-apis + __experimentalText as Text, // eslint-disable-line @wordpress/no-unsafe-wp-apis + __experimentalVStack as VStack, // eslint-disable-line @wordpress/no-unsafe-wp-apis +} from '@wordpress/components'; +import { useCallback } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { seen, pencil, connection, chevronRight } from '@wordpress/icons'; +import { isWriteTool } from './categories'; +import { + getAccountMcpAbilities, + getSiteContextToolIds, + getSiteLevelEnabled, + getSiteMcpAbilities, + mergeSiteMcpAbilities, +} from './utils'; + +import './style.scss'; + +/** + * Compute a badge text + intent for a set of tools. + * + * @param {Array} tools - Tool entries to evaluate. + * @param {boolean} defaultEnabled - Fallback enabled state when there are no overrides. + * @return {{ text: string, intent?: string }} Badge descriptor. + */ +function computeBadge( tools, defaultEnabled ) { + if ( tools.length === 0 ) { + return defaultEnabled + ? { text: __( 'All enabled', 'jetpack' ), intent: 'success' } + : { text: __( 'Disabled', 'jetpack' ) }; + } + const enabledCount = tools.filter( ( [ , t ] ) => t.enabled ).length; + if ( enabledCount === tools.length ) { + return { text: __( 'All enabled', 'jetpack' ), intent: 'success' }; + } + if ( enabledCount === 0 ) { + return { text: __( 'Disabled', 'jetpack' ) }; + } + return { + /* translators: %1$d: enabled count, %2$d: total count */ + text: sprintf( __( '%1$d of %2$d enabled', 'jetpack' ), enabledCount, tools.length ), + intent: 'info', + }; +} + +/** + * Minimal sprintf supporting positional %N$d / %N$s placeholders. + * + * @param {string} format - Format string. + * @param {...number} args - Replacement values. + * @return {string} Formatted string. + */ +function sprintf( format, ...args ) { + let i = 0; + return format.replace( /%\d+\$[ds]/g, () => args[ i++ ] ); +} + +/** + * A tappable row that navigates to a sub-view, visually similar to calypso's + * RouterLinkSummaryButton. + * + * @param {object} props - Component props. + * @param {*} props.icon - WordPress icon. + * @param {string} props.title - Row label. + * @param {{ text: string, intent?: string }} props.badge - Optional badge. + * @param {Function} props.onClick - Click handler. + * @return {object} Component markup. + */ +function SummaryRow( { icon, title, badge, onClick } ) { + return ( + + ); +} + +/** + * MCP hub component. + * + * @param {object} props - Component props. + * @param {object} props.mcpAbilities - Full mcp_abilities object from API. + * @param {number} props.blogId - Current site's blog ID. + * @param {boolean} props.isSaving - Whether a save is in progress. + * @param {Function} props.onNavigate - Called with 'read' | 'write' | 'setup'. + * @param {Function} props.onUpdate - Called with partial mcp_abilities update. + * @return {object} Component markup. + */ +export default function McpHub( { mcpAbilities, blogId, isSaving, onNavigate, onUpdate } ) { + const accountAbilities = getAccountMcpAbilities( mcpAbilities ?? {} ); + const siteContextToolIds = getSiteContextToolIds( mcpAbilities ?? {} ); + const siteAbilities = getSiteMcpAbilities( mcpAbilities ?? {}, blogId ); + const siteAccountAbilities = siteContextToolIds.size + ? Object.fromEntries( + Object.entries( accountAbilities ).filter( ( [ id ] ) => siteContextToolIds.has( id ) ) + ) + : accountAbilities; + const merged = mergeSiteMcpAbilities( siteAccountAbilities, siteAbilities ); + + const isMcpEnabled = getSiteLevelEnabled( mcpAbilities ?? {}, blogId ); + const hasSiteAbilityOverrides = Object.keys( siteAbilities ).length > 0; + const defaultToolEnabled = mcpAbilities?.site_level_enabled_default ?? false; + + const availableTools = Object.entries( merged ).filter( ( [ , t ] ) => t.visible !== false ); + const readTools = availableTools.filter( ( [ id, t ] ) => ! isWriteTool( id, t ) ); + const writeTools = availableTools.filter( ( [ id, t ] ) => isWriteTool( id, t ) ); + + const defaultBadge = defaultToolEnabled + ? { text: __( 'All enabled', 'jetpack' ), intent: 'success' } + : { text: __( 'Disabled', 'jetpack' ) }; + const readBadge = hasSiteAbilityOverrides + ? computeBadge( readTools, defaultToolEnabled ) + : defaultBadge; + const writeBadge = hasSiteAbilityOverrides + ? computeBadge( writeTools, defaultToolEnabled ) + : defaultBadge; + + const handleMcpToggle = useCallback( + enabled => { + const abilities = {}; + if ( enabled ) { + readTools.forEach( ( [ toolId ] ) => { + abilities[ toolId ] = true; + } ); + } + onUpdate( { + sites: [ + { + blog_id: blogId, + site_level_enabled: enabled, + abilities, + }, + ], + } ); + }, + [ blogId, onUpdate, readTools ] + ); + + const navigateToRead = useCallback( () => onNavigate( 'read' ), [ onNavigate ] ); + const navigateToWrite = useCallback( () => onNavigate( 'write' ), [ onNavigate ] ); + const navigateToSetup = useCallback( () => onNavigate( 'setup' ), [ onNavigate ] ); + + return ( + <> + + + + + + { __( 'External AI agent access', 'jetpack' ) } + + + { __( 'Allow external AI agents to access this site via MCP.', 'jetpack' ) } + + + + + + + { isMcpEnabled && ( + <> + + + + + + ) } + + + { isMcpEnabled && ( + + + + ) } + + ); +} diff --git a/projects/plugins/jetpack/_inc/client/ai/mcp/read.jsx b/projects/plugins/jetpack/_inc/client/ai/mcp/read.jsx new file mode 100644 index 000000000000..30cd343fbae5 --- /dev/null +++ b/projects/plugins/jetpack/_inc/client/ai/mcp/read.jsx @@ -0,0 +1,262 @@ +/** + * MCP Read tools view. + * + * Lists read-only tools grouped by category, with per-tool and per-category toggles. + */ + +import { + Card, + CardBody, + CardDivider, + CardHeader, + ToggleControl, + __experimentalHStack as HStack, // eslint-disable-line @wordpress/no-unsafe-wp-apis + __experimentalText as Text, // eslint-disable-line @wordpress/no-unsafe-wp-apis + __experimentalVStack as VStack, // eslint-disable-line @wordpress/no-unsafe-wp-apis +} from '@wordpress/components'; +import { Fragment, useCallback } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { + CATEGORY_ORDER, + SUB_CATEGORY_ORDER, + getDisplayCategory, + getSubCategory, + isWriteTool, + sortTools, +} from './categories'; +import { + getAccountMcpAbilities, + getSiteContextToolIds, + getSiteMcpAbilities, + mergeSiteMcpAbilities, +} from './utils'; + +/** + * A single tool toggle row. + * + * @param {object} props - Component props. + * @param {string} props.toolId - Tool identifier. + * @param {object} props.tool - Tool descriptor from the API. + * @param {boolean} props.isSaving - Whether a save is pending. + * @param {Function} props.onToggle - Called with (toolId, enabled). + * @return {object} Component markup. + */ +function ToolToggle( { toolId, tool, isSaving, onToggle } ) { + const handleChange = useCallback( checked => onToggle( toolId, checked ), [ toolId, onToggle ] ); + return ( + + ); +} + +/** + * Category card header with an "Enable all" toggle. + * + * @param {object} props - Component props. + * @param {string} props.categoryName - Display name of the category. + * @param {boolean} props.allEnabled - Whether all tools in the category are enabled. + * @param {boolean} props.isSaving - Whether a save is pending. + * @param {Array} props.categoryTools - Tools in the category. + * @param {Function} props.onEnableAll - Called with (categoryTools, enabled). + * @return {object} Component markup. + */ +function CategoryHeader( { categoryName, allEnabled, isSaving, categoryTools, onEnableAll } ) { + const handleChange = useCallback( + checked => onEnableAll( categoryTools, checked ), + [ categoryTools, onEnableAll ] + ); + return ( + + + + { categoryName } + + + + + ); +} + +/** + * MCP Read tools view. + * + * @param {object} props - Component props. + * @param {object} props.mcpAbilities - Full mcp_abilities object from the API. + * @param {number} props.blogId - Current site's blog ID. + * @param {boolean} props.isSaving - Whether a save is in progress. + * @param {Function} props.onUpdate - Called with partial mcp_abilities update. + * @return {object} Component markup. + */ +export default function McpRead( { mcpAbilities, blogId, isSaving, onUpdate } ) { + const accountAbilities = getAccountMcpAbilities( mcpAbilities ?? {} ); + const siteContextToolIds = getSiteContextToolIds( mcpAbilities ?? {} ); + const siteAbilities = getSiteMcpAbilities( mcpAbilities ?? {}, blogId ); + const siteAccountAbilities = siteContextToolIds.size + ? Object.fromEntries( + Object.entries( accountAbilities ).filter( ( [ id ] ) => siteContextToolIds.has( id ) ) + ) + : accountAbilities; + const mergedAbilities = mergeSiteMcpAbilities( siteAccountAbilities, siteAbilities ); + + const hasSiteAbilityOverrides = Object.keys( siteAbilities ).length > 0; + const defaultToolEnabled = mcpAbilities?.site_level_enabled_default ?? false; + + const effectiveAbilities = hasSiteAbilityOverrides + ? mergedAbilities + : Object.fromEntries( + Object.entries( mergedAbilities ).map( ( [ id, tool ] ) => [ + id, + { ...tool, enabled: defaultToolEnabled }, + ] ) + ); + + const allTools = Object.entries( effectiveAbilities ).filter( + ( [ , t ] ) => t.visible !== false + ); + const readTools = allTools.filter( ( [ id, t ] ) => ! isWriteTool( id, t ) ); + + const buildAbilities = useCallback( + overrides => { + if ( hasSiteAbilityOverrides ) { + return overrides; + } + const defaults = {}; + allTools.forEach( ( [ id ] ) => { + defaults[ id ] = defaultToolEnabled; + } ); + return { ...defaults, ...overrides }; + }, + [ allTools, defaultToolEnabled, hasSiteAbilityOverrides ] + ); + + const handleToolChange = useCallback( + ( toolId, enabled ) => { + onUpdate( { + sites: [ + { + blog_id: blogId, + abilities: buildAbilities( { [ toolId ]: enabled } ), + }, + ], + } ); + }, + [ blogId, buildAbilities, onUpdate ] + ); + + const handleEnableAll = useCallback( + ( categoryTools, enabled ) => { + const overrides = {}; + categoryTools.forEach( ( [ toolId ] ) => { + overrides[ toolId ] = enabled; + } ); + onUpdate( { + sites: [ + { + blog_id: blogId, + abilities: buildAbilities( overrides ), + }, + ], + } ); + }, + [ blogId, buildAbilities, onUpdate ] + ); + + // Group by display category + const grouped = {}; + readTools.forEach( ( [ toolId, tool ] ) => { + const category = getDisplayCategory( toolId, tool ); + if ( ! grouped[ category ] ) { + grouped[ category ] = []; + } + grouped[ category ].push( [ toolId, tool ] ); + } ); + + const renderToolToggles = tools => + tools.map( ( [ toolId, tool ] ) => ( + + ) ); + + const renderSubGroupedTools = ( categoryTools, categoryName ) => { + const subGrouped = {}; + categoryTools.forEach( ( [ toolId, tool ] ) => { + const sub = getSubCategory( toolId, tool ) ?? ''; + if ( ! subGrouped[ sub ] ) { + subGrouped[ sub ] = []; + } + subGrouped[ sub ].push( [ toolId, tool ] ); + } ); + + const order = SUB_CATEGORY_ORDER[ categoryName ] ?? []; + const subGroups = order.filter( sub => subGrouped[ sub ]?.length > 0 ); + + return subGroups.map( ( subName, index ) => ( + + { index > 0 && } + + { renderToolToggles( sortTools( subGrouped[ subName ] ) ) } + + + ) ); + }; + + if ( readTools.length === 0 ) { + return ( + + + { __( 'No read tools available.', 'jetpack' ) } + + + ); + } + + return ( + + { CATEGORY_ORDER.map( categoryName => { + const categoryTools = grouped[ categoryName ]; + if ( ! categoryTools?.length ) { + return null; + } + + const allEnabled = categoryTools.every( ( [ , t ] ) => t.enabled ); + const subOrder = SUB_CATEGORY_ORDER[ categoryName ]; + + return ( + + + { subOrder ? ( + renderSubGroupedTools( categoryTools, categoryName ) + ) : ( + + { renderToolToggles( categoryTools ) } + + ) } + + ); + } ) } + + ); +} diff --git a/projects/plugins/jetpack/_inc/client/ai/mcp/setup.jsx b/projects/plugins/jetpack/_inc/client/ai/mcp/setup.jsx new file mode 100644 index 000000000000..c2d42ea4ffcf --- /dev/null +++ b/projects/plugins/jetpack/_inc/client/ai/mcp/setup.jsx @@ -0,0 +1,270 @@ +/** + * MCP Agent Setup view. + * + * Shows per-agent quick-setup instructions and a manual config textarea. + * Ported from client/dashboard/me/mcp/setup/index.tsx in wp-calypso. + */ + +import { + Button, + Card, + CardBody, + ExternalLink, + SelectControl, + TextareaControl, + __experimentalHStack as HStack, // eslint-disable-line @wordpress/no-unsafe-wp-apis + __experimentalText as Text, // eslint-disable-line @wordpress/no-unsafe-wp-apis + __experimentalVStack as VStack, // eslint-disable-line @wordpress/no-unsafe-wp-apis +} from '@wordpress/components'; +import { createInterpolateElement, useCallback, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { check, copy } from '@wordpress/icons'; + +const MCP_SERVER_NAME = 'wpcom-mcp'; +const MCP_SERVER_URL = 'https://public-api.wordpress.com/wpcom/v2/mcp/v1'; + +const CLIENT_OPTIONS = [ + { label: 'Claude', value: 'claude' }, + { label: 'Claude Code', value: 'claude-code' }, + { label: 'Cursor', value: 'cursor' }, + { label: 'VS Code', value: 'vscode' }, + { label: 'Continue', value: 'continue' }, + { label: __( 'Other MCP client', 'jetpack' ), value: 'default' }, +]; + +const CLIENT_DOCS = { + claude: 'https://docs.claude.com/en/docs/mcp', + 'claude-code': 'https://code.claude.com/docs/en/mcp', + vscode: 'https://code.visualstudio.com/docs/copilot/customization/mcp-servers', + cursor: 'https://docs.cursor.com/en/context/mcp', + continue: 'https://docs.continue.dev/customize/deep-dives/mcp', + default: 'https://modelcontextprotocol.io/docs/develop/connect-local-servers', +}; + +const CLIENT_DOCS_LABELS = { + claude: __( 'Claude documentation', 'jetpack' ), + 'claude-code': __( 'Claude Code documentation', 'jetpack' ), + vscode: __( 'VS Code documentation', 'jetpack' ), + cursor: __( 'Cursor documentation', 'jetpack' ), + continue: __( 'Continue documentation', 'jetpack' ), + default: __( 'MCP documentation', 'jetpack' ), +}; + +/** + * Generate an MCP client configuration JSON object for the given client. + * + * @param {string} client - MCP client identifier. + * @return {object} Configuration object to serialize and copy. + */ +function generateMcpConfig( client ) { + const baseConfig = { url: MCP_SERVER_URL }; + switch ( client ) { + case 'claude': + return { mcpServers: { [ MCP_SERVER_NAME ]: baseConfig } }; + case 'claude-code': + return { + mcpServers: { [ MCP_SERVER_NAME ]: { type: 'http', url: MCP_SERVER_URL } }, + }; + case 'vscode': + return { servers: { [ MCP_SERVER_NAME ]: baseConfig } }; + case 'cursor': + return { mcpServers: { [ MCP_SERVER_NAME ]: baseConfig } }; + case 'continue': + return { mcpServers: [ { name: MCP_SERVER_NAME, ...baseConfig } ] }; + default: + return { mcpServers: { [ MCP_SERVER_NAME ]: baseConfig } }; + } +} + +/** + * MCP Agent Setup view. + * + * @return {object} Component markup. + */ +export default function McpSetup() { + const [ selectedClient, setSelectedClient ] = useState( 'claude' ); + const [ copyStatus, setCopyStatus ] = useState( 'idle' ); + + const configText = JSON.stringify( generateMcpConfig( selectedClient ), null, 2 ); + + // Read-only textarea requires a no-op onChange to satisfy the controlled input contract. + const handleConfigChange = useCallback( () => {}, [] ); + + const copyToClipboard = useCallback( async () => { + try { + await navigator.clipboard.writeText( configText ); + setCopyStatus( 'success' ); + setTimeout( () => setCopyStatus( 'idle' ), 2000 ); + } catch { + // Clipboard may be blocked — silently fail. + } + }, [ configText ] ); + + const quickSetupClients = [ 'claude', 'claude-code', 'cursor' ]; + const showQuickSetup = quickSetupClients.includes( selectedClient ); + + return ( + + + + + + + + { showQuickSetup && ( + + + + + { __( 'Quick setup', 'jetpack' ) } + + + { selectedClient === 'claude' && ( +
    +
  1. + + { createInterpolateElement( __( 'Open .', 'jetpack' ), { + ClaudeSettings: ( + + { __( 'Claude settings', 'jetpack' ) } + + ), + } ) } + +
  2. +
  3. + + { __( 'Click "Browse connectors" and search for WordPress.com.', 'jetpack' ) } + +
  4. +
  5. + + { __( 'Select WordPress.com and follow the prompts.', 'jetpack' ) } + +
  6. +
+ ) } + + { selectedClient === 'claude-code' && ( + + + { __( + 'Claude Code uses a different config format with type: "http". Use the CLI or copy the configuration below.', + 'jetpack' + ) } + +
    +
  1. + + { createInterpolateElement( + __( 'Run in your terminal: ', 'jetpack' ), + { + code: ( + + { `claude mcp add --transport http ${ MCP_SERVER_NAME } ${ MCP_SERVER_URL }` } + + ), + } + ) } + +
  2. +
  3. + + { createInterpolateElement( + __( + 'Or copy the configuration below and add it to your or file.', + 'jetpack' + ), + { + mcpJson: .mcp.json, + claudeJson: ( + ~/.claude.json + ), + } + ) } + +
  4. +
  5. + + { createInterpolateElement( + __( + 'In Claude Code, run to authenticate with your WordPress.com account.', + 'jetpack' + ), + { + code: /mcp, + } + ) } + +
  6. +
+
+ ) } + + { selectedClient === 'cursor' && ( + + + { __( + 'For Cursor users, use the one-click install to add the WordPress.com MCP app.', + 'jetpack' + ) } + + + + ) } +
+
+
+ ) } + + + + + + + { __( 'Manual setup', 'jetpack' ) } + + + ); +} + /** * MCP hub component. * @@ -185,7 +209,7 @@ export default function McpHub( { mcpAbilities, blogId, isSaving, onNavigate, on __nextHasNoMarginBottom checked={ isMcpEnabled } disabled={ isSaving } - label={ __( 'Enable MCP access for this site', 'jetpack' ) } + label={ __( 'Enable MCP access', 'jetpack' ) } onChange={ handleMcpToggle } /> @@ -213,9 +237,12 @@ export default function McpHub( { mcpAbilities, blogId, isSaving, onNavigate, on { isMcpEnabled && ( - diff --git a/projects/plugins/jetpack/_inc/client/ai/mcp/style.scss b/projects/plugins/jetpack/_inc/client/ai/mcp/style.scss index 1d6f0020bbdf..30cadcb583e0 100644 --- a/projects/plugins/jetpack/_inc/client/ai/mcp/style.scss +++ b/projects/plugins/jetpack/_inc/client/ai/mcp/style.scss @@ -3,7 +3,7 @@ &__summary-row { display: block; width: 100%; - padding: 12px 16px; + padding: 16px 24px; height: auto; text-align: left; border-radius: 0; @@ -19,16 +19,68 @@ } } + &__connect-row { + display: flex; + gap: 16px; + align-items: flex-start; + padding: 24px; + cursor: pointer; + width: 100%; + background: none; + border: none; + text-align: left; + border-radius: 0; + + &:hover { + background: var(--color-surface-backdrop, #f0f0f1); + } + } + + &__connect-row-icon { + flex-shrink: 0; + margin-top: 2px; + } + + &__connect-row-text { + flex: 1; + min-width: 0; + } + + &__connect-row-title { + font-size: 15px; + font-weight: 600; + color: var(--color-neutral-100, #1e1e1e); + line-height: 24px; + margin: 0; + } + + &__connect-row-description { + font-size: 13px; + color: var(--color-neutral-60, #50575e); + line-height: 20px; + margin: 0; + } + + &__connect-row-chevron { + flex-shrink: 0; + margin-top: 2px; + color: var(--color-neutral-60, #50575e); + } + &__badge { - display: inline-block; + display: inline-flex; + align-items: center; + gap: 2px; padding: 2px 8px; border-radius: 2px; font-size: 12px; - font-weight: 500; + font-weight: 400; + line-height: 16px; &--success { - background: var(--color-success-10, #d8f5e3); - color: var(--color-success-60, #007a33); + background: #eff8f0; + color: #345b37; + padding-left: 4px; } &--info { @@ -38,7 +90,7 @@ &--neutral { background: var(--color-neutral-5, #f0f0f1); - color: var(--color-neutral-60, #50575e); + color: var(--color-neutral-100, #1e1e1e); } &--warning { diff --git a/projects/plugins/jetpack/_inc/client/ai/mcp/utils.js b/projects/plugins/jetpack/_inc/client/ai/mcp/utils.js index 6afd95602203..0ea76fe34ed0 100644 --- a/projects/plugins/jetpack/_inc/client/ai/mcp/utils.js +++ b/projects/plugins/jetpack/_inc/client/ai/mcp/utils.js @@ -32,7 +32,8 @@ export function getAccountMcpAbilities( userSettings ) { * @return {Set} Set of tool IDs available in site context. */ export function getSiteContextToolIds( userSettings ) { - const siteTools = userSettings?.mcp_abilities?.site || {}; + // Support both flat format (userSettings.site) and nested (userSettings.mcp_abilities.site). + const siteTools = userSettings?.site || userSettings?.mcp_abilities?.site || {}; return new Set( Object.keys( siteTools ) ); } @@ -44,7 +45,8 @@ export function getSiteContextToolIds( userSettings ) { * @return {Record} Site-level ability overrides keyed by tool ID. */ export function getSiteMcpAbilities( userSettings, siteId ) { - const mcpSites = userSettings?.mcp_abilities?.sites || []; + // Support both flat format (userSettings.sites) and nested (userSettings.mcp_abilities.sites). + const mcpSites = userSettings?.sites || userSettings?.mcp_abilities?.sites || []; const siteEntry = mcpSites.find( site => site.blog_id === parseInt( siteId ) ); return siteEntry?.abilities || {}; } @@ -76,11 +78,14 @@ export function mergeSiteMcpAbilities( accountAbilities, siteAbilities ) { * @return {boolean} Whether site-level MCP access is enabled. */ export function getSiteLevelEnabled( userSettings, siteId ) { - const mcpAbilities = userSettings?.mcp_abilities; - const mcpSites = mcpAbilities?.sites || []; + // Support both flat format (userSettings.sites) and nested (userSettings.mcp_abilities.sites). + const mcpSites = userSettings?.sites || userSettings?.mcp_abilities?.sites || []; const siteEntry = mcpSites.find( site => site.blog_id === parseInt( siteId ) ); if ( siteEntry ) { return siteEntry.site_level_enabled === true; } - return mcpAbilities?.site_level_enabled_default === true; + return ( + ( userSettings?.site_level_enabled_default ?? + userSettings?.mcp_abilities?.site_level_enabled_default ) === true + ); } From bfa02ee081ab6b853541197dae73dc8d814c7b0e Mon Sep 17 00:00:00 2001 From: Eoin Gallagher Date: Sat, 11 Apr 2026 20:05:59 +0100 Subject: [PATCH 03/39] fix(ai): address Copilot review feedback on MCP settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use @wordpress/i18n sprintf instead of local implementation - Wrap fallback error strings in __() for i18n - Merge partial mcp_abilities updates on IS_WPCOM instead of overwriting - Register endpoint via wpcom_rest_api_v2_load_plugin() - Add additional_styles() to load wrapper styles - Fix changelog type: added → enhancement Co-Authored-By: Claude Sonnet 4.6 --- .../jetpack/_inc/client/ai/mcp/index.jsx | 14 +------------ .../_inc/client/ai/mcp/use-mcp-settings.js | 5 +++-- .../lib/admin-pages/class-jetpack-ai-page.php | 7 +++++++ ...pcom-rest-api-v2-endpoint-mcp-settings.php | 20 +++++++++++++++++-- .../aiint-357-jetpack-ai-mcp-settings | 2 +- 5 files changed, 30 insertions(+), 18 deletions(-) diff --git a/projects/plugins/jetpack/_inc/client/ai/mcp/index.jsx b/projects/plugins/jetpack/_inc/client/ai/mcp/index.jsx index 78c72a4afbd8..419a8b17f497 100644 --- a/projects/plugins/jetpack/_inc/client/ai/mcp/index.jsx +++ b/projects/plugins/jetpack/_inc/client/ai/mcp/index.jsx @@ -15,7 +15,7 @@ import { __experimentalVStack as VStack, // eslint-disable-line @wordpress/no-unsafe-wp-apis } from '@wordpress/components'; import { useCallback } from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; +import { __, sprintf } from '@wordpress/i18n'; import { seen, pencil, connection, chevronRight, check } from '@wordpress/icons'; import { isWriteTool } from './categories'; import { @@ -55,18 +55,6 @@ function computeBadge( tools, defaultEnabled ) { }; } -/** - * Minimal sprintf supporting positional %N$d / %N$s placeholders. - * - * @param {string} format - Format string. - * @param {...number} args - Replacement values. - * @return {string} Formatted string. - */ -function sprintf( format, ...args ) { - let i = 0; - return format.replace( /%\d+\$[ds]/g, () => args[ i++ ] ); -} - /** * A tappable row that navigates to a sub-view, visually similar to calypso's * RouterLinkSummaryButton. diff --git a/projects/plugins/jetpack/_inc/client/ai/mcp/use-mcp-settings.js b/projects/plugins/jetpack/_inc/client/ai/mcp/use-mcp-settings.js index cc5bae61d8be..f7aca198cf0e 100644 --- a/projects/plugins/jetpack/_inc/client/ai/mcp/use-mcp-settings.js +++ b/projects/plugins/jetpack/_inc/client/ai/mcp/use-mcp-settings.js @@ -8,6 +8,7 @@ import apiFetch from '@wordpress/api-fetch'; import { useCallback, useEffect, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; const ENDPOINT = '/wpcom/v2/jetpack-ai/mcp-settings'; @@ -34,7 +35,7 @@ export function useMcpSettings() { } ) .catch( err => { if ( ! cancelled ) { - setError( err?.message ?? 'Failed to load MCP settings.' ); + setError( err?.message ?? __( 'Failed to load MCP settings.', 'jetpack' ) ); } } ) .finally( () => { @@ -67,7 +68,7 @@ export function useMcpSettings() { setError( null ); } ) .catch( err => { - setError( err?.message ?? 'Failed to save MCP settings.' ); + setError( err?.message ?? __( 'Failed to save MCP settings.', 'jetpack' ) ); throw err; } ) .finally( () => { diff --git a/projects/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-ai-page.php b/projects/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-ai-page.php index 4ad5ddf76322..ddedf828b957 100644 --- a/projects/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-ai-page.php +++ b/projects/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-ai-page.php @@ -54,6 +54,13 @@ public function add_page_actions( $hook ) { // phpcs:ignore VariableAnalysis.Cod // Nothing extra needed beyond the common hooks in Jetpack_Admin_Page::add_actions(). } + /** + * Load shared wrapper styles used by the base admin page renderer. + */ + public function additional_styles() { + Jetpack_Admin_Page::load_wrapper_styles(); + } + /** * Enqueue scripts and styles for the AI admin page. */ diff --git a/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php b/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php index 2e2c2641c7b0..0decc1e116cf 100644 --- a/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php +++ b/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php @@ -127,7 +127,23 @@ public function update_mcp_settings( $request ) { $mcp_abilities = $request->get_param( 'mcp_abilities' ); if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) { - // On WordPress.com, write directly. + // On WordPress.com, merge partial updates into the stored value. + $existing_mcp_abilities = get_user_option( 'mcp_abilities', get_current_user_id() ); + + if ( is_object( $existing_mcp_abilities ) ) { + $existing_mcp_abilities = get_object_vars( $existing_mcp_abilities ); + } elseif ( ! is_array( $existing_mcp_abilities ) ) { + $existing_mcp_abilities = array(); + } + + if ( is_object( $mcp_abilities ) ) { + $mcp_abilities = get_object_vars( $mcp_abilities ); + } elseif ( ! is_array( $mcp_abilities ) ) { + $mcp_abilities = array(); + } + + $mcp_abilities = array_replace( $existing_mcp_abilities, $mcp_abilities ); + update_user_option( get_current_user_id(), 'mcp_abilities', $mcp_abilities ); return rest_ensure_response( array( 'mcp_abilities' => $mcp_abilities ) ); } @@ -156,4 +172,4 @@ public function update_mcp_settings( $request ) { } } -new WPCOM_REST_API_V2_Endpoint_MCP_Settings(); +wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_MCP_Settings' ); diff --git a/projects/plugins/jetpack/changelog/aiint-357-jetpack-ai-mcp-settings b/projects/plugins/jetpack/changelog/aiint-357-jetpack-ai-mcp-settings index 2216f8474018..4507d8958a32 100644 --- a/projects/plugins/jetpack/changelog/aiint-357-jetpack-ai-mcp-settings +++ b/projects/plugins/jetpack/changelog/aiint-357-jetpack-ai-mcp-settings @@ -1,4 +1,4 @@ Significance: minor -Type: added +Type: enhancement AI: Add Jetpack AI admin page with MCP settings. Registers an "AI" submenu under Jetpack and provides a React-based interface for configuring external AI agent access (MCP) for the site, including per-tool read/write toggles and agent setup instructions. From e24932680abff40b1ea5a73dd35bb9c6e1340721 Mon Sep 17 00:00:00 2001 From: Eoin Gallagher Date: Sat, 11 Apr 2026 20:30:02 +0100 Subject: [PATCH 04/39] fix(ai): address second round of Copilot review feedback on MCP settings - Deep-merge mcp_abilities sites entries by blog_id (and abilities by tool ID) in the IS_WPCOM branch to avoid overwriting unrelated sites - Show a warning notice and block editing when blogId is 0 (site not connected to WordPress.com) Co-Authored-By: Claude Sonnet 4.6 --- .../plugins/jetpack/_inc/client/ai/main.jsx | 11 +++++++- ...pcom-rest-api-v2-endpoint-mcp-settings.php | 27 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/projects/plugins/jetpack/_inc/client/ai/main.jsx b/projects/plugins/jetpack/_inc/client/ai/main.jsx index 28ab067210a9..962535e55b4c 100644 --- a/projects/plugins/jetpack/_inc/client/ai/main.jsx +++ b/projects/plugins/jetpack/_inc/client/ai/main.jsx @@ -103,7 +103,16 @@ export default function App() { ) } - { ! isLoading && ! error && ( + { ! isLoading && ! error && ! blogId && ( + + { __( + 'This site is not connected to WordPress.com. Please connect Jetpack to manage MCP settings.', + 'jetpack' + ) } + + ) } + + { ! isLoading && ! error && !! blogId && ( { view === 'hub' && ( $mcp_abilities ) ); } From b8e82c297322b8802ad913245277e5d895acffd6 Mon Sep 17 00:00:00 2001 From: Eoin Gallagher Date: Tue, 14 Apr 2026 16:22:14 +0100 Subject: [PATCH 05/39] fix(ai): proxy MCP settings reads/writes to wpcom/v2/sites/{id}/mcp-abilities Switch GET to call wpcom/v2/sites/{blog_id}/mcp-abilities with user token auth so WPCOM can resolve the requesting user's account-level MCP state and return site_level_enabled correctly. Reshapes the array-of-abilities response into the { account, site, sites, site_level_enabled_default } structure the JS client utilities expect, with readonly fallback for older WPCOM builds. Switch POST to proxy to the new WPCOM POST /sites/{blog_id}/mcp-abilities endpoint (AIINT-359) which persists site_level_enabled and per-tool ability overrides to user settings via SettingsHelper, keeping Jetpack and Calypso in sync. Removes the previous local mcp_abilities site option storage. Co-Authored-By: Claude Sonnet 4.6 --- ...pcom-rest-api-v2-endpoint-mcp-settings.php | 215 ++++++++++++------ 1 file changed, 140 insertions(+), 75 deletions(-) diff --git a/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php b/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php index 4180e26f7e98..cfe1a365a33a 100644 --- a/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php +++ b/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php @@ -2,7 +2,13 @@ /** * REST API endpoint for Jetpack AI MCP settings. * - * Proxies GET/POST requests to /rest/v1.1/me/settings for the mcp_abilities field. + * GET — proxies to WPCOM wpcom/v2/sites/{blog_id}/mcp-abilities (wpcom#205108) using + * user token auth so that WPCOM can resolve the requesting user's account-level + * MCP state. Reshapes the response into the { account, site, sites, + * site_level_enabled_default } structure the client utilities expect. + * POST — proxies to WPCOM POST /sites/{blog_id}/mcp-abilities (user token auth) which + * persists { site_level_enabled, abilities } to user settings via SettingsHelper, + * keeping Jetpack and Calypso in sync. * * @package automattic/jetpack */ @@ -84,24 +90,33 @@ public function permissions_check() { } /** - * Get MCP settings from WordPress.com. + * Fetch MCP abilities from the WPCOM wpcom/v2/sites/{blog_id}/mcp-abilities endpoint. * - * @return array|WP_Error + * The WPCOM endpoint accepts user token auth and returns an array of abilities. + * The response is shaped to match the format the client utilities expect: + * { account: { tool-id: {...} }, site: { tool-id: {...} }, + * sites: [{ blog_id, site_level_enabled, abilities }], + * site_level_enabled_default: bool } + * + * All state is owned by WPCOM and persisted via POST /sites/{blog_id}/mcp-abilities. + * + * @return WP_REST_Response|WP_Error */ public function get_mcp_settings() { - if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) { - // On WordPress.com, read directly from user meta. - $user_settings = get_user_option( 'mcp_abilities', get_current_user_id() ); - return rest_ensure_response( array( 'mcp_abilities' => $user_settings ? $user_settings : new stdClass() ) ); + $blog_id = defined( 'IS_WPCOM' ) && IS_WPCOM + ? get_current_blog_id() + : \Jetpack_Options::get_option( 'id' ); + + if ( ! $blog_id ) { + return rest_ensure_response( array( 'mcp_abilities' => new stdClass() ) ); } $response = Client::wpcom_json_api_request_as_user( - '/me/settings', - '1.1', - array( - 'method' => 'GET', - 'headers' => array( 'Content-Type' => 'application/json' ), - ) + sprintf( '/sites/%d/mcp-abilities', (int) $blog_id ), + '2', + array( 'method' => 'GET' ), + null, + 'wpcom' ); if ( is_wp_error( $response ) ) { @@ -110,92 +125,142 @@ public function get_mcp_settings() { $body = json_decode( wp_remote_retrieve_body( $response ), true ); + // Transform [ {name, title, readonly, site_context, enabled, …}, … ] → { name: {…}, … }. + // readonly and site_context are now provided by the WPCOM endpoint (wpcom#205108). + // Fall back to name-suffix heuristic for readonly only if the field is absent, so + // this endpoint remains functional against older WPCOM builds. + $account_abilities = new stdClass(); + $site_abilities = array(); + if ( ! empty( $body['abilities'] ) && is_array( $body['abilities'] ) ) { + $account_abilities = array(); + foreach ( $body['abilities'] as $ability ) { + if ( empty( $ability['name'] ) ) { + continue; + } + if ( ! array_key_exists( 'readonly', $ability ) ) { + $ability['readonly'] = ! (bool) preg_match( '/-(create|update|delete)$/i', $ability['name'] ); + } + $account_abilities[ $ability['name'] ] = $ability; + + // `site` subset: tools marked site_context=true by WPCOM are the only ones + // relevant to the site-level settings UI. getSiteContextToolIds() in JS uses + // this to filter out account/notifications/billing/domains tools. + if ( ! empty( $ability['site_context'] ) ) { + $site_abilities[ $ability['name'] ] = $ability; + } + } + } + + // site_level_enabled comes directly from WPCOM — it is the authoritative effective + // state for this site (account default applied, site exceptions factored in). + // Fall back to the enabled-abilities heuristic only if the field is absent. + $site_level_enabled = isset( $body['site_level_enabled'] ) + ? (bool) $body['site_level_enabled'] + : ! empty( array_filter( (array) $account_abilities, fn( $a ) => ! empty( $a['enabled'] ) ) ); + + // site_level_enabled_default mirrors Calypso: same value as site_level_enabled + // when derived from WPCOM (no per-site override concept at this layer). + $site_level_enabled_default = $site_level_enabled; + + // Build per-site abilities from the effective enabled state of each site-context + // tool returned by WPCOM. The WPCOM endpoint merges account defaults with any + // per-site user overrides saved via POST, so these values reflect what was last + // saved and drive the per-tool toggle states in the Read/Write views. + $site_tool_abilities = array(); + foreach ( $site_abilities as $name => $ability ) { + $site_tool_abilities[ $name ] = ! empty( $ability['enabled'] ); + } + return rest_ensure_response( array( - 'mcp_abilities' => $body['mcp_abilities'] ?? new stdClass(), + 'mcp_abilities' => array( + 'account' => $account_abilities, + 'site' => $site_abilities, + 'sites' => array( + array( + 'blog_id' => (int) $blog_id, + 'site_level_enabled' => $site_level_enabled, + 'abilities' => (object) $site_tool_abilities, + ), + ), + 'site_level_enabled_default' => $site_level_enabled_default, + ), ) ); } /** - * Update MCP settings on WordPress.com. + * Proxy mcp_abilities update to WPCOM POST /sites/{blog_id}/mcp-abilities. + * + * Accepts the sites[] format used by the client: + * { sites: [{ blog_id, site_level_enabled?, abilities?: { tool_id: bool } }] } + * + * Extracts site_level_enabled and abilities from sites[0] and forwards them + * to the WPCOM endpoint which persists them to user settings via SettingsHelper. + * This keeps Jetpack and Calypso in sync — both read/write the same store. * * @param WP_REST_Request $request Full details about the request. - * @return array|WP_Error + * @return WP_REST_Response|WP_Error */ public function update_mcp_settings( $request ) { - $mcp_abilities = $request->get_param( 'mcp_abilities' ); + $blog_id = \Jetpack_Options::get_option( 'id' ); + $incoming = $request->get_param( 'mcp_abilities' ); - if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) { - // On WordPress.com, merge partial updates into the stored value. - $existing_mcp_abilities = get_user_option( 'mcp_abilities', get_current_user_id() ); + if ( is_object( $incoming ) ) { + $incoming = get_object_vars( $incoming ); + } elseif ( ! is_array( $incoming ) ) { + $incoming = array(); + } + + // Unpack sites[0] into the flat format WPCOM POST /sites/{id}/mcp-abilities expects. + $wpcom_body = array(); - if ( is_object( $existing_mcp_abilities ) ) { - $existing_mcp_abilities = get_object_vars( $existing_mcp_abilities ); - } elseif ( ! is_array( $existing_mcp_abilities ) ) { - $existing_mcp_abilities = array(); + if ( ! empty( $incoming['sites'] ) && is_array( $incoming['sites'] ) ) { + $site_update = $incoming['sites'][0]; + if ( is_object( $site_update ) ) { + $site_update = get_object_vars( $site_update ); } - if ( is_object( $mcp_abilities ) ) { - $mcp_abilities = get_object_vars( $mcp_abilities ); - } elseif ( ! is_array( $mcp_abilities ) ) { - $mcp_abilities = array(); + if ( isset( $site_update['site_level_enabled'] ) ) { + $wpcom_body['site_level_enabled'] = (bool) $site_update['site_level_enabled']; } - // Shallow-merge top-level keys first. - $mcp_abilities = array_replace( $existing_mcp_abilities, $mcp_abilities ); - - // Deep-merge `sites` entries by blog_id, and nested `abilities` by tool ID. - if ( isset( $mcp_abilities['sites'] ) && is_array( $mcp_abilities['sites'] ) && - isset( $existing_mcp_abilities['sites'] ) && is_array( $existing_mcp_abilities['sites'] ) ) { - $existing_sites = array(); - foreach ( $existing_mcp_abilities['sites'] as $entry ) { - if ( isset( $entry['blog_id'] ) ) { - $existing_sites[ $entry['blog_id'] ] = $entry; - } - } - foreach ( $mcp_abilities['sites'] as $entry ) { - if ( ! isset( $entry['blog_id'] ) ) { - continue; - } - $blog_id = $entry['blog_id']; - if ( isset( $existing_sites[ $blog_id ] ) ) { - $existing_abilities = isset( $existing_sites[ $blog_id ]['abilities'] ) && is_array( $existing_sites[ $blog_id ]['abilities'] ) ? $existing_sites[ $blog_id ]['abilities'] : array(); - $new_abilities = isset( $entry['abilities'] ) && is_array( $entry['abilities'] ) ? $entry['abilities'] : array(); - $entry['abilities'] = array_replace( $existing_abilities, $new_abilities ); - $existing_sites[ $blog_id ] = array_replace( $existing_sites[ $blog_id ], $entry ); - } else { - $existing_sites[ $blog_id ] = $entry; - } + if ( isset( $site_update['abilities'] ) ) { + $abilities = is_object( $site_update['abilities'] ) + ? get_object_vars( $site_update['abilities'] ) + : (array) $site_update['abilities']; + $normalised = array(); + foreach ( $abilities as $name => $value ) { + $normalised[ $name ] = (bool) $value; } - $mcp_abilities['sites'] = array_values( $existing_sites ); + $wpcom_body['abilities'] = $normalised; } - - update_user_option( get_current_user_id(), 'mcp_abilities', $mcp_abilities ); - return rest_ensure_response( array( 'mcp_abilities' => $mcp_abilities ) ); } - $response = Client::wpcom_json_api_request_as_user( - '/me/settings', - '1.1', - array( - 'method' => 'POST', - 'headers' => array( 'Content-Type' => 'application/json' ), - 'body' => wp_json_encode( array( 'mcp_abilities' => $mcp_abilities ), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ), - ) - ); + if ( ! empty( $wpcom_body ) ) { + $response = Client::wpcom_json_api_request_as_user( + sprintf( '/sites/%d/mcp-abilities', (int) $blog_id ), + '2', + array( 'method' => 'POST' ), + $wpcom_body, + 'wpcom' + ); - if ( is_wp_error( $response ) ) { - return $response; - } + if ( is_wp_error( $response ) ) { + return $response; + } - $body = json_decode( wp_remote_retrieve_body( $response ), true ); + $status = wp_remote_retrieve_response_code( $response ); + if ( $status < 200 || $status >= 300 ) { + return new WP_Error( + 'wpcom_update_failed', + __( 'Failed to save MCP settings on WordPress.com.', 'jetpack' ), + array( 'status' => 502 ) + ); + } + } - return rest_ensure_response( - array( - 'mcp_abilities' => $body['mcp_abilities'] ?? $mcp_abilities, - ) - ); + return $this->get_mcp_settings(); } } From 92efbd07b903b081060f0d6dedf9bb0209a274b6 Mon Sep 17 00:00:00 2001 From: Eoin Gallagher Date: Tue, 14 Apr 2026 16:37:47 +0100 Subject: [PATCH 06/39] fix(ai): fix PHP 7.3 compat and Phan type error in MCP settings endpoint Replace arrow function syntax (PHP 7.4+) with a traditional closure and cast the array key to string to satisfy Phan type checker. Co-Authored-By: Claude Sonnet 4.6 --- .../class-wpcom-rest-api-v2-endpoint-mcp-settings.php | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php b/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php index cfe1a365a33a..906b53f92b4d 100644 --- a/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php +++ b/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php @@ -140,7 +140,7 @@ public function get_mcp_settings() { if ( ! array_key_exists( 'readonly', $ability ) ) { $ability['readonly'] = ! (bool) preg_match( '/-(create|update|delete)$/i', $ability['name'] ); } - $account_abilities[ $ability['name'] ] = $ability; + $account_abilities[ (string) $ability['name'] ] = $ability; // `site` subset: tools marked site_context=true by WPCOM are the only ones // relevant to the site-level settings UI. getSiteContextToolIds() in JS uses @@ -156,7 +156,14 @@ public function get_mcp_settings() { // Fall back to the enabled-abilities heuristic only if the field is absent. $site_level_enabled = isset( $body['site_level_enabled'] ) ? (bool) $body['site_level_enabled'] - : ! empty( array_filter( (array) $account_abilities, fn( $a ) => ! empty( $a['enabled'] ) ) ); + : ! empty( + array_filter( + (array) $account_abilities, + function ( $a ) { + return ! empty( $a['enabled'] ); + } + ) + ); // site_level_enabled_default mirrors Calypso: same value as site_level_enabled // when derived from WPCOM (no per-site override concept at this layer). From abfe039a78c30d7534bbf3c00ab5b9d215bb0c0b Mon Sep 17 00:00:00 2001 From: Eoin Gallagher Date: Wed, 15 Apr 2026 12:26:47 +0100 Subject: [PATCH 07/39] fix(ai): use WPCOM user_overrides for per-tool abilities, fix stale default mismatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GET /sites/{id}/mcp-abilities endpoint returns computed effective states (account defaults merged with overrides). Using these for sites[0].abilities caused the Jetpack UI to show most tools as disabled even when site_level_enabled was true — because WPCOM account defaults are false for most tools. WPCOM now returns a user_overrides.abilities field containing only the raw explicitly stored overrides (wpcom#205108 / AIINT-359). Jetpack now uses those for sites[0].abilities. The JS merge falls back to site_level_enabled (not the account default) for any tool without an explicit override, matching Calypso's display behaviour. Co-Authored-By: Claude Sonnet 4.6 --- .../jetpack/_inc/client/ai/mcp/index.jsx | 4 +- .../jetpack/_inc/client/ai/mcp/read.jsx | 38 +++++-------------- .../jetpack/_inc/client/ai/mcp/utils.js | 7 ++-- .../jetpack/_inc/client/ai/mcp/write.jsx | 38 +++++-------------- ...pcom-rest-api-v2-endpoint-mcp-settings.php | 16 +++++--- 5 files changed, 34 insertions(+), 69 deletions(-) diff --git a/projects/plugins/jetpack/_inc/client/ai/mcp/index.jsx b/projects/plugins/jetpack/_inc/client/ai/mcp/index.jsx index 419a8b17f497..b9afa059b1aa 100644 --- a/projects/plugins/jetpack/_inc/client/ai/mcp/index.jsx +++ b/projects/plugins/jetpack/_inc/client/ai/mcp/index.jsx @@ -135,9 +135,9 @@ export default function McpHub( { mcpAbilities, blogId, isSaving, onNavigate, on Object.entries( accountAbilities ).filter( ( [ id ] ) => siteContextToolIds.has( id ) ) ) : accountAbilities; - const merged = mergeSiteMcpAbilities( siteAccountAbilities, siteAbilities ); - const isMcpEnabled = getSiteLevelEnabled( mcpAbilities ?? {}, blogId ); + const merged = mergeSiteMcpAbilities( siteAccountAbilities, siteAbilities, isMcpEnabled ); + const hasSiteAbilityOverrides = Object.keys( siteAbilities ).length > 0; const defaultToolEnabled = mcpAbilities?.site_level_enabled_default ?? false; diff --git a/projects/plugins/jetpack/_inc/client/ai/mcp/read.jsx b/projects/plugins/jetpack/_inc/client/ai/mcp/read.jsx index 30cd343fbae5..98e96860a702 100644 --- a/projects/plugins/jetpack/_inc/client/ai/mcp/read.jsx +++ b/projects/plugins/jetpack/_inc/client/ai/mcp/read.jsx @@ -27,6 +27,7 @@ import { import { getAccountMcpAbilities, getSiteContextToolIds, + getSiteLevelEnabled, getSiteMcpAbilities, mergeSiteMcpAbilities, } from './utils'; @@ -108,38 +109,17 @@ export default function McpRead( { mcpAbilities, blogId, isSaving, onUpdate } ) Object.entries( accountAbilities ).filter( ( [ id ] ) => siteContextToolIds.has( id ) ) ) : accountAbilities; - const mergedAbilities = mergeSiteMcpAbilities( siteAccountAbilities, siteAbilities ); - - const hasSiteAbilityOverrides = Object.keys( siteAbilities ).length > 0; - const defaultToolEnabled = mcpAbilities?.site_level_enabled_default ?? false; - - const effectiveAbilities = hasSiteAbilityOverrides - ? mergedAbilities - : Object.fromEntries( - Object.entries( mergedAbilities ).map( ( [ id, tool ] ) => [ - id, - { ...tool, enabled: defaultToolEnabled }, - ] ) - ); - - const allTools = Object.entries( effectiveAbilities ).filter( - ( [ , t ] ) => t.visible !== false + const isMcpEnabled = getSiteLevelEnabled( mcpAbilities ?? {}, blogId ); + const mergedAbilities = mergeSiteMcpAbilities( + siteAccountAbilities, + siteAbilities, + isMcpEnabled ); + + const allTools = Object.entries( mergedAbilities ).filter( ( [ , t ] ) => t.visible !== false ); const readTools = allTools.filter( ( [ id, t ] ) => ! isWriteTool( id, t ) ); - const buildAbilities = useCallback( - overrides => { - if ( hasSiteAbilityOverrides ) { - return overrides; - } - const defaults = {}; - allTools.forEach( ( [ id ] ) => { - defaults[ id ] = defaultToolEnabled; - } ); - return { ...defaults, ...overrides }; - }, - [ allTools, defaultToolEnabled, hasSiteAbilityOverrides ] - ); + const buildAbilities = useCallback( overrides => overrides, [] ); const handleToolChange = useCallback( ( toolId, enabled ) => { diff --git a/projects/plugins/jetpack/_inc/client/ai/mcp/utils.js b/projects/plugins/jetpack/_inc/client/ai/mcp/utils.js index 0ea76fe34ed0..9c9417ba01bc 100644 --- a/projects/plugins/jetpack/_inc/client/ai/mcp/utils.js +++ b/projects/plugins/jetpack/_inc/client/ai/mcp/utils.js @@ -55,16 +55,17 @@ export function getSiteMcpAbilities( userSettings, siteId ) { * Merge account-level abilities with site-level overrides. * * @param {Record} accountAbilities - Account-level tool definitions. - * @param {Record} siteAbilities - Site-level enabled overrides by tool ID. + * @param {Record} siteAbilities - Explicit per-site overrides by tool ID (only tools the user has explicitly set). + * @param {boolean|null} defaultEnabled - Fallback enabled state for tools not in siteAbilities (typically site_level_enabled). When null, falls back to the account-level tool.enabled value. * @return {Record} Merged abilities with site overrides applied. */ -export function mergeSiteMcpAbilities( accountAbilities, siteAbilities ) { +export function mergeSiteMcpAbilities( accountAbilities, siteAbilities, defaultEnabled = null ) { return Object.fromEntries( Object.entries( accountAbilities ).map( ( [ toolId, tool ] ) => [ toolId, { ...tool, - enabled: toolId in siteAbilities ? siteAbilities[ toolId ] : tool.enabled, + enabled: toolId in siteAbilities ? siteAbilities[ toolId ] : defaultEnabled ?? tool.enabled, }, ] ) ); diff --git a/projects/plugins/jetpack/_inc/client/ai/mcp/write.jsx b/projects/plugins/jetpack/_inc/client/ai/mcp/write.jsx index 9edf548bb01f..61ad3873d5b1 100644 --- a/projects/plugins/jetpack/_inc/client/ai/mcp/write.jsx +++ b/projects/plugins/jetpack/_inc/client/ai/mcp/write.jsx @@ -28,6 +28,7 @@ import { import { getAccountMcpAbilities, getSiteContextToolIds, + getSiteLevelEnabled, getSiteMcpAbilities, mergeSiteMcpAbilities, } from './utils'; @@ -109,38 +110,17 @@ export default function McpWrite( { mcpAbilities, blogId, isSaving, onUpdate } ) Object.entries( accountAbilities ).filter( ( [ id ] ) => siteContextToolIds.has( id ) ) ) : accountAbilities; - const mergedAbilities = mergeSiteMcpAbilities( siteAccountAbilities, siteAbilities ); - - const hasSiteAbilityOverrides = Object.keys( siteAbilities ).length > 0; - const defaultToolEnabled = mcpAbilities?.site_level_enabled_default ?? false; - - const effectiveAbilities = hasSiteAbilityOverrides - ? mergedAbilities - : Object.fromEntries( - Object.entries( mergedAbilities ).map( ( [ id, tool ] ) => [ - id, - { ...tool, enabled: defaultToolEnabled }, - ] ) - ); - - const allTools = Object.entries( effectiveAbilities ).filter( - ( [ , t ] ) => t.visible !== false + const isMcpEnabled = getSiteLevelEnabled( mcpAbilities ?? {}, blogId ); + const mergedAbilities = mergeSiteMcpAbilities( + siteAccountAbilities, + siteAbilities, + isMcpEnabled ); + + const allTools = Object.entries( mergedAbilities ).filter( ( [ , t ] ) => t.visible !== false ); const writeTools = allTools.filter( ( [ id, t ] ) => isWriteTool( id, t ) ); - const buildAbilities = useCallback( - overrides => { - if ( hasSiteAbilityOverrides ) { - return overrides; - } - const defaults = {}; - allTools.forEach( ( [ id ] ) => { - defaults[ id ] = defaultToolEnabled; - } ); - return { ...defaults, ...overrides }; - }, - [ allTools, defaultToolEnabled, hasSiteAbilityOverrides ] - ); + const buildAbilities = useCallback( overrides => overrides, [] ); const handleToolChange = useCallback( ( toolId, enabled ) => { diff --git a/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php b/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php index 906b53f92b4d..fa4f203b8762 100644 --- a/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php +++ b/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php @@ -169,13 +169,17 @@ function ( $a ) { // when derived from WPCOM (no per-site override concept at this layer). $site_level_enabled_default = $site_level_enabled; - // Build per-site abilities from the effective enabled state of each site-context - // tool returned by WPCOM. The WPCOM endpoint merges account defaults with any - // per-site user overrides saved via POST, so these values reflect what was last - // saved and drive the per-tool toggle states in the Read/Write views. + // Use only the explicit per-site user overrides returned by WPCOM in user_overrides.abilities. + // These are the raw values stored via SettingsHelper — not the computed effective states + // (account defaults merged with overrides). Keeping only explicit overrides here lets the + // JS fall back to site_level_enabled as the default for any tool not yet overridden, + // matching Calypso's display behaviour (all tools on when site_level_enabled:true and no + // per-tool overrides exist). $site_tool_abilities = array(); - foreach ( $site_abilities as $name => $ability ) { - $site_tool_abilities[ $name ] = ! empty( $ability['enabled'] ); + if ( isset( $body['user_overrides']['abilities'] ) && is_array( $body['user_overrides']['abilities'] ) ) { + foreach ( $body['user_overrides']['abilities'] as $name => $value ) { + $site_tool_abilities[ (string) $name ] = (bool) $value; + } } return rest_ensure_response( From d7a660fa9f892c5d15a71b1a21665d456ddf0c1f Mon Sep 17 00:00:00 2001 From: Eoin Gallagher Date: Wed, 15 Apr 2026 13:48:47 +0100 Subject: [PATCH 08/39] fix(ai): center AI admin page content using standard Jetpack .wrap layout page_render() was placing the React root directly inside #jp-plugin-container with no .wrap wrapper, so the shared layout rule (#jp-plugin-container .wrap: max-width 45rem, margin 0 auto, 1.5rem horizontal padding) never applied. Content sat flush against the left edge of the content area. Wrapping in .wrap picks up the standard centering and horizontal padding. The redundant max-width: 640px on .jetpack-ai-admin is removed since .wrap already constrains to 45rem. Co-Authored-By: Claude Sonnet 4.6 --- projects/plugins/jetpack/_inc/client/ai/style.scss | 1 - .../jetpack/_inc/lib/admin-pages/class-jetpack-ai-page.php | 7 ++++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/projects/plugins/jetpack/_inc/client/ai/style.scss b/projects/plugins/jetpack/_inc/client/ai/style.scss index 63788f1ad1d5..69a4d15ef856 100644 --- a/projects/plugins/jetpack/_inc/client/ai/style.scss +++ b/projects/plugins/jetpack/_inc/client/ai/style.scss @@ -1,5 +1,4 @@ .jetpack-ai-admin { - max-width: 640px; padding: 24px 0; &__header { diff --git a/projects/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-ai-page.php b/projects/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-ai-page.php index ddedf828b957..845a5ca8c498 100644 --- a/projects/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-ai-page.php +++ b/projects/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-ai-page.php @@ -112,10 +112,15 @@ public function page_admin_scripts() { /** * Render the page container. The React app mounts into this div. + * + * The .wrap class activates the shared #jp-plugin-container .wrap layout rules: + * max-width, horizontal padding, and margin: 0 auto centering. */ public function page_render() { ?> -
+
+
+
Date: Wed, 15 Apr 2026 18:11:30 +0100 Subject: [PATCH 09/39] fix(ai): use AdminPage component for consistent layout and footer Switch from the old wrap_ui/custom-header approach to the shared AdminPage component from @automattic/jetpack-components. This gives: - Proper full-width header with Jetpack bolt icon, matching Figma design - Title and subtitle rendered in the standard admin-ui Page header bar - JetpackFooter pinned to the bottom (not floating mid-page on short content) - White page background consistent with other Jetpack admin pages (Newsletter etc.) Also update hub title to 'AI' and subtitle to match Figma copy. Co-Authored-By: Claude Sonnet 4.6 --- .../plugins/jetpack/_inc/client/ai/main.jsx | 144 +++++++++--------- .../plugins/jetpack/_inc/client/ai/style.scss | 8 +- .../lib/admin-pages/class-jetpack-ai-page.php | 8 +- 3 files changed, 73 insertions(+), 87 deletions(-) diff --git a/projects/plugins/jetpack/_inc/client/ai/main.jsx b/projects/plugins/jetpack/_inc/client/ai/main.jsx index 962535e55b4c..bb9b200e02b8 100644 --- a/projects/plugins/jetpack/_inc/client/ai/main.jsx +++ b/projects/plugins/jetpack/_inc/client/ai/main.jsx @@ -4,13 +4,8 @@ * Manages the view stack (hub → read | write | setup) and owns the MCP settings state. */ -import { - Button, - Notice, - Spinner, - __experimentalText as Text, // eslint-disable-line @wordpress/no-unsafe-wp-apis - __experimentalVStack as VStack, // eslint-disable-line @wordpress/no-unsafe-wp-apis -} from '@wordpress/components'; +import { AdminPage } from '@automattic/jetpack-components'; +import { Button, Notice, Spinner, __experimentalVStack as VStack } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis import { useCallback, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { arrowLeft } from '@wordpress/icons'; @@ -20,17 +15,17 @@ import McpSetup from './mcp/setup'; import { useMcpSettings } from './mcp/use-mcp-settings'; import McpWrite from './mcp/write'; -const { blogId } = window?.jetpackAiSettings ?? {}; +const { blogId, apiRoot, apiNonce } = window?.jetpackAiSettings ?? {}; const VIEW_TITLES = { - hub: __( 'AI settings', 'jetpack' ), + hub: __( 'AI', 'jetpack' ), read: __( 'Read', 'jetpack' ), write: __( 'Write', 'jetpack' ), setup: __( 'Connect external AI agent', 'jetpack' ), }; const VIEW_DESCRIPTIONS = { - hub: __( 'Control how external AI agents can access this site via MCP.', 'jetpack' ), + hub: __( 'Control how AI agents interact with your site.', 'jetpack' ), read: __( 'View your site\u2019s content.', 'jetpack' ), write: __( 'Create, update, and manage content on your site.', 'jetpack' ), setup: __( 'Get instructions for connecting your external AI assistant.', 'jetpack' ), @@ -62,8 +57,13 @@ export default function App() { const isSubView = view !== 'hub'; return ( -
-
+ +
{ isSubView && (
-
- { isLoading && ( -
- -
- ) } +
+ { isLoading && ( +
+ +
+ ) } - { ! isLoading && error && ( - - { error } - - ) } + { ! isLoading && error && ( + + { error } + + ) } - { ! isLoading && saveError && ( - - { saveError } - - ) } + { ! isLoading && saveError && ( + + { saveError } + + ) } - { ! isLoading && ! error && ! blogId && ( - - { __( - 'This site is not connected to WordPress.com. Please connect Jetpack to manage MCP settings.', - 'jetpack' - ) } - - ) } + { ! isLoading && ! error && ! blogId && ( + + { __( + 'This site is not connected to WordPress.com. Please connect Jetpack to manage MCP settings.', + 'jetpack' + ) } + + ) } - { ! isLoading && ! error && !! blogId && ( - - { view === 'hub' && ( - - ) } - { view === 'read' && ( - - ) } - { view === 'write' && ( - - ) } - { view === 'setup' && } - - ) } + { ! isLoading && ! error && !! blogId && ( + + { view === 'hub' && ( + + ) } + { view === 'read' && ( + + ) } + { view === 'write' && ( + + ) } + { view === 'setup' && } + + ) } +
-
+ ); } diff --git a/projects/plugins/jetpack/_inc/client/ai/style.scss b/projects/plugins/jetpack/_inc/client/ai/style.scss index 69a4d15ef856..faa87841db73 100644 --- a/projects/plugins/jetpack/_inc/client/ai/style.scss +++ b/projects/plugins/jetpack/_inc/client/ai/style.scss @@ -1,12 +1,8 @@ .jetpack-ai-admin { - padding: 24px 0; - - &__header { - margin-bottom: 24px; - } + padding: 16px 24px 24px; &__back { - margin-bottom: 12px; + margin-bottom: 8px; .components-button.is-tertiary { padding-left: 0; diff --git a/projects/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-ai-page.php b/projects/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-ai-page.php index 845a5ca8c498..269ae64b5d16 100644 --- a/projects/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-ai-page.php +++ b/projects/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-ai-page.php @@ -113,14 +113,12 @@ public function page_admin_scripts() { /** * Render the page container. The React app mounts into this div. * - * The .wrap class activates the shared #jp-plugin-container .wrap layout rules: - * max-width, horizontal padding, and margin: 0 auto centering. + * AdminPage from @automattic/jetpack-components handles the full-page layout + * (header, footer, background) so no wrapper is needed here. */ public function page_render() { ?> -
-
-
+
Date: Wed, 15 Apr 2026 18:25:27 +0100 Subject: [PATCH 10/39] fix(ai): remove double header/footer by skipping wrap_ui wrap_ui renders the Jetpack masthead header and static footer. Combined with AdminPage (which also renders a header and footer) this produced two headers and two footers on the page. Override render() to call page_render() directly, skipping wrap_ui. Pass showFooter=false to AdminPage so only the standard WP admin footer shows. AdminPage now owns the full page layout (single header + content only). Co-Authored-By: Claude Sonnet 4.6 --- projects/plugins/jetpack/_inc/client/ai/main.jsx | 1 + .../_inc/lib/admin-pages/class-jetpack-ai-page.php | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/projects/plugins/jetpack/_inc/client/ai/main.jsx b/projects/plugins/jetpack/_inc/client/ai/main.jsx index bb9b200e02b8..e83468190ee6 100644 --- a/projects/plugins/jetpack/_inc/client/ai/main.jsx +++ b/projects/plugins/jetpack/_inc/client/ai/main.jsx @@ -62,6 +62,7 @@ export default function App() { subTitle={ VIEW_DESCRIPTIONS[ view ] } apiRoot={ apiRoot } apiNonce={ apiNonce } + showFooter={ false } >
{ isSubView && ( diff --git a/projects/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-ai-page.php b/projects/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-ai-page.php index 269ae64b5d16..83c06db850c4 100644 --- a/projects/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-ai-page.php +++ b/projects/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-ai-page.php @@ -110,11 +110,21 @@ public function page_admin_scripts() { ); } + /** + * Override the base render() to skip wrap_ui entirely. + * + * Wrap_ui renders the Jetpack masthead header and static footer, which + * duplicate the header/footer that AdminPage (React) already provides. + * Calling page_render() directly lets AdminPage own the full layout. + */ + public function render() { + $this->page_render(); + } + /** * Render the page container. The React app mounts into this div. * - * AdminPage from @automattic/jetpack-components handles the full-page layout - * (header, footer, background) so no wrapper is needed here. + * AdminPage from @automattic/jetpack-components handles the full-page layout. */ public function page_render() { ?> From d3f627d825e949feb659399ff9a220d6a5590d04 Mon Sep 17 00:00:00 2001 From: Eoin Gallagher Date: Wed, 15 Apr 2026 18:35:26 +0100 Subject: [PATCH 11/39] fix(ai): fix footer bleed, missing footer, and card width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues addressed: 1. Footer bleeding into sidebar: load_wrapper_styles() loaded admin.css which sets #wpcontent padding-left: 0. AdminPage's margin-left: -20px then had nothing to compensate for, pulling the footer under the sidebar. Fixed by making additional_styles() a no-op — AdminPage doesn't need the wrap_ui stylesheet bundle. 2. Footer missing: restored AdminPage showFooter default (true) now that the bleed is fixed. 3. Cards too wide: AdminPage renders a fluid full-width container. Wrapped content in Container/Col (same pattern as newsletter) to constrain width. Co-Authored-By: Claude Sonnet 4.6 --- .../plugins/jetpack/_inc/client/ai/main.jsx | 31 +++++++++---------- .../plugins/jetpack/_inc/client/ai/style.scss | 5 --- .../lib/admin-pages/class-jetpack-ai-page.php | 9 +++--- 3 files changed, 20 insertions(+), 25 deletions(-) diff --git a/projects/plugins/jetpack/_inc/client/ai/main.jsx b/projects/plugins/jetpack/_inc/client/ai/main.jsx index e83468190ee6..df3d0be286fc 100644 --- a/projects/plugins/jetpack/_inc/client/ai/main.jsx +++ b/projects/plugins/jetpack/_inc/client/ai/main.jsx @@ -4,7 +4,7 @@ * Manages the view stack (hub → read | write | setup) and owns the MCP settings state. */ -import { AdminPage } from '@automattic/jetpack-components'; +import { AdminPage, Col, Container } from '@automattic/jetpack-components'; import { Button, Notice, Spinner, __experimentalVStack as VStack } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis import { useCallback, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; @@ -62,21 +62,20 @@ export default function App() { subTitle={ VIEW_DESCRIPTIONS[ view ] } apiRoot={ apiRoot } apiNonce={ apiNonce } - showFooter={ false } > -
- { isSubView && ( - - ) } + + + { isSubView && ( + + ) } -
{ isLoading && (
@@ -134,8 +133,8 @@ export default function App() { { view === 'setup' && } ) } -
-
+ +
); } diff --git a/projects/plugins/jetpack/_inc/client/ai/style.scss b/projects/plugins/jetpack/_inc/client/ai/style.scss index faa87841db73..7905d8c13b0c 100644 --- a/projects/plugins/jetpack/_inc/client/ai/style.scss +++ b/projects/plugins/jetpack/_inc/client/ai/style.scss @@ -1,5 +1,4 @@ .jetpack-ai-admin { - padding: 16px 24px 24px; &__back { margin-bottom: 8px; @@ -9,10 +8,6 @@ } } - &__content { - // Ensure notices stack correctly - } - &__loading { display: flex; justify-content: center; diff --git a/projects/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-ai-page.php b/projects/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-ai-page.php index 83c06db850c4..8a394cdb9f59 100644 --- a/projects/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-ai-page.php +++ b/projects/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-ai-page.php @@ -55,11 +55,12 @@ public function add_page_actions( $hook ) { // phpcs:ignore VariableAnalysis.Cod } /** - * Load shared wrapper styles used by the base admin page renderer. + * No additional styles needed: AdminPage from @automattic/jetpack-components + * owns the full layout and does not need the wrap_ui admin.css / style.min.css + * bundle (which zeroes out #wpcontent padding and conflicts with AdminPage's + * margin-left compensation). */ - public function additional_styles() { - Jetpack_Admin_Page::load_wrapper_styles(); - } + public function additional_styles() {} /** * Enqueue scripts and styles for the AI admin page. From 02b74ae211b7f73cc0db2a8359bda6dfe3c3cdd6 Mon Sep 17 00:00:00 2001 From: Eoin Gallagher Date: Wed, 15 Apr 2026 18:46:25 +0100 Subject: [PATCH 12/39] fix(ai): clip horizontal overflow caused by AdminPage margin-left compensation Co-Authored-By: Claude Sonnet 4.6 --- projects/plugins/jetpack/_inc/client/ai/style.scss | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/projects/plugins/jetpack/_inc/client/ai/style.scss b/projects/plugins/jetpack/_inc/client/ai/style.scss index 7905d8c13b0c..0742e10438ed 100644 --- a/projects/plugins/jetpack/_inc/client/ai/style.scss +++ b/projects/plugins/jetpack/_inc/client/ai/style.scss @@ -1,3 +1,10 @@ +// AdminPage applies margin-left: -20px to compensate for #wpcontent padding. +// On self-hosted WP this can overflow the viewport; clip it. +// phpcs:ignore -- SCSS selector, not a PHP file +.jetpack_page_jetpack-ai #wpbody-content { + overflow-x: hidden; +} + .jetpack-ai-admin { &__back { From 231c1df63da2b8a8d6a25db7f8b7cfc02b2cc47e Mon Sep 17 00:00:00 2001 From: Eoin Gallagher Date: Wed, 15 Apr 2026 19:13:39 +0100 Subject: [PATCH 13/39] fix(ai): fix content padding asymmetry caused by AdminPage margin-left offset AdminPage uses margin-left: -20px to extend full-width, which gets clipped by overflow-x: hidden on #wpbody-content. This caused the non-fluid Container (max-width: 1040px content + 48px padding = 1088px total) to overflow the viewport, clipping the right side of cards. Replace Container+Col with a simple div using box-sizing: border-box and 44px left padding (24px standard + 20px to compensate for the 20px left clip), giving symmetric 24px visible margins on both sides. Co-Authored-By: Claude Sonnet 4.6 --- .../plugins/jetpack/_inc/client/ai/main.jsx | 134 +++++++++--------- .../plugins/jetpack/_inc/client/ai/style.scss | 15 ++ 2 files changed, 81 insertions(+), 68 deletions(-) diff --git a/projects/plugins/jetpack/_inc/client/ai/main.jsx b/projects/plugins/jetpack/_inc/client/ai/main.jsx index df3d0be286fc..11c1678d8153 100644 --- a/projects/plugins/jetpack/_inc/client/ai/main.jsx +++ b/projects/plugins/jetpack/_inc/client/ai/main.jsx @@ -4,7 +4,7 @@ * Manages the view stack (hub → read | write | setup) and owns the MCP settings state. */ -import { AdminPage, Col, Container } from '@automattic/jetpack-components'; +import { AdminPage } from '@automattic/jetpack-components'; import { Button, Notice, Spinner, __experimentalVStack as VStack } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis import { useCallback, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; @@ -63,78 +63,76 @@ export default function App() { apiRoot={ apiRoot } apiNonce={ apiNonce } > - - - { isSubView && ( - - ) } +
+ { isSubView && ( + + ) } - { isLoading && ( -
- -
- ) } + { isLoading && ( +
+ +
+ ) } - { ! isLoading && error && ( - - { error } - - ) } + { ! isLoading && error && ( + + { error } + + ) } - { ! isLoading && saveError && ( - - { saveError } - - ) } + { ! isLoading && saveError && ( + + { saveError } + + ) } - { ! isLoading && ! error && ! blogId && ( - - { __( - 'This site is not connected to WordPress.com. Please connect Jetpack to manage MCP settings.', - 'jetpack' - ) } - - ) } + { ! isLoading && ! error && ! blogId && ( + + { __( + 'This site is not connected to WordPress.com. Please connect Jetpack to manage MCP settings.', + 'jetpack' + ) } + + ) } - { ! isLoading && ! error && !! blogId && ( - - { view === 'hub' && ( - - ) } - { view === 'read' && ( - - ) } - { view === 'write' && ( - - ) } - { view === 'setup' && } - - ) } - - + { ! isLoading && ! error && !! blogId && ( + + { view === 'hub' && ( + + ) } + { view === 'read' && ( + + ) } + { view === 'write' && ( + + ) } + { view === 'setup' && } + + ) } +
); } diff --git a/projects/plugins/jetpack/_inc/client/ai/style.scss b/projects/plugins/jetpack/_inc/client/ai/style.scss index 0742e10438ed..48c125e9a136 100644 --- a/projects/plugins/jetpack/_inc/client/ai/style.scss +++ b/projects/plugins/jetpack/_inc/client/ai/style.scss @@ -7,6 +7,21 @@ .jetpack-ai-admin { + // Content wrapper inside AdminPage. + // AdminPage's margin-left: -20px shifts the page 20px left, so #wpbody-content + // clips the left 20px. Add 20px extra left padding (44px total) to keep + // content symmetrically inset. Use border-box so padding is included in width. + &__content { + box-sizing: border-box; + width: 100%; + padding: 24px 24px 24px 44px; + + @media (max-width: 782px) { + // Narrower screens use -10px AdminPage margin-left compensation. + padding-left: 34px; + } + } + &__back { margin-bottom: 8px; From 3f31969f1346078f44b348437193c81c0e3110a0 Mon Sep 17 00:00:00 2001 From: Eoin Gallagher Date: Wed, 15 Apr 2026 23:07:36 +0100 Subject: [PATCH 14/39] fix(ai): remove overflow-x hack, use border-box to prevent card overflow The overflow-x: hidden approach clipped AdminPage's margin-left: -20px white background, creating a grey gap beside the sidebar. Removing it restores the white background flush to the sidebar edge. The actual overflow is prevented by box-sizing: border-box on the content wrapper, constraining the 24px padding within the 1040px available width. Co-Authored-By: Claude Sonnet 4.6 --- .../plugins/jetpack/_inc/client/ai/style.scss | 21 ++++--------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/projects/plugins/jetpack/_inc/client/ai/style.scss b/projects/plugins/jetpack/_inc/client/ai/style.scss index 48c125e9a136..b547097406fa 100644 --- a/projects/plugins/jetpack/_inc/client/ai/style.scss +++ b/projects/plugins/jetpack/_inc/client/ai/style.scss @@ -1,25 +1,12 @@ -// AdminPage applies margin-left: -20px to compensate for #wpcontent padding. -// On self-hosted WP this can overflow the viewport; clip it. -// phpcs:ignore -- SCSS selector, not a PHP file -.jetpack_page_jetpack-ai #wpbody-content { - overflow-x: hidden; -} - .jetpack-ai-admin { - // Content wrapper inside AdminPage. - // AdminPage's margin-left: -20px shifts the page 20px left, so #wpbody-content - // clips the left 20px. Add 20px extra left padding (44px total) to keep - // content symmetrically inset. Use border-box so padding is included in width. + // Content wrapper inside AdminPage's fluid container. + // Use border-box so the 24px padding is included in the 100% width, + // preventing horizontal overflow from content-box sizing. &__content { box-sizing: border-box; width: 100%; - padding: 24px 24px 24px 44px; - - @media (max-width: 782px) { - // Narrower screens use -10px AdminPage margin-left compensation. - padding-left: 34px; - } + padding: 24px; } &__back { From e9576ec160a0739f5962b6b79588c68c9a0b7710 Mon Sep 17 00:00:00 2001 From: Eoin Gallagher Date: Wed, 15 Apr 2026 23:20:48 +0100 Subject: [PATCH 15/39] fix(ai): white background full height, 960px max-width for content - Add min-height: 100vh to .admin-ui-page so white background fills the viewport instead of stopping at content height - Add max-width: 960px to content wrapper per Figma design constraint Co-Authored-By: Claude Sonnet 4.6 --- projects/plugins/jetpack/_inc/client/ai/style.scss | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/projects/plugins/jetpack/_inc/client/ai/style.scss b/projects/plugins/jetpack/_inc/client/ai/style.scss index b547097406fa..9b58980a406b 100644 --- a/projects/plugins/jetpack/_inc/client/ai/style.scss +++ b/projects/plugins/jetpack/_inc/client/ai/style.scss @@ -1,11 +1,19 @@ +// phpcs:ignore -- SCSS selector, not a PHP file +.jetpack_page_jetpack-ai .admin-ui-page { + // Extend white background to fill the full viewport height. + min-height: 100vh; +} + .jetpack-ai-admin { // Content wrapper inside AdminPage's fluid container. // Use border-box so the 24px padding is included in the 100% width, // preventing horizontal overflow from content-box sizing. + // max-width matches the Figma design constraint. &__content { box-sizing: border-box; width: 100%; + max-width: 960px; padding: 24px; } From b23040e77713ae226a10f1a9e52f868cb13ec881 Mon Sep 17 00:00:00 2001 From: Eoin Gallagher Date: Wed, 15 Apr 2026 23:24:30 +0100 Subject: [PATCH 16/39] fix(ai): center content within 960px max-width Co-Authored-By: Claude Sonnet 4.6 --- projects/plugins/jetpack/_inc/client/ai/style.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/projects/plugins/jetpack/_inc/client/ai/style.scss b/projects/plugins/jetpack/_inc/client/ai/style.scss index 9b58980a406b..31cba7be4990 100644 --- a/projects/plugins/jetpack/_inc/client/ai/style.scss +++ b/projects/plugins/jetpack/_inc/client/ai/style.scss @@ -14,6 +14,7 @@ box-sizing: border-box; width: 100%; max-width: 960px; + margin: 0 auto; padding: 24px; } From fe13d5db4ef25a30e8c9a2e4da723e9dc9f7b9c5 Mon Sep 17 00:00:00 2001 From: Eoin Gallagher Date: Thu, 16 Apr 2026 11:58:42 +0100 Subject: [PATCH 17/39] fix(ai): fix footer position and align badge icon colors with Calypso - Use min-height: calc(100vh - 32px) and flex:1 on the fluid container so the Jetpack footer sits at the bottom of the viewport in production - Add intent-based icons to hub badges (info/published/caution) matching Calypso's @automattic/ui Badge component behaviour - Scope row decoration icon fill to neutral-70 (matching Calypso's .mcp-hub__summary-icon without bleeding into badge icon fill Co-Authored-By: Claude Sonnet 4.6 EOF ) --- projects/plugins/jetpack/_inc/client/ai/mcp/index.jsx | 8 +++++--- .../plugins/jetpack/_inc/client/ai/mcp/style.scss | 5 +++++ projects/plugins/jetpack/_inc/client/ai/style.scss | 11 +++++++++-- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/projects/plugins/jetpack/_inc/client/ai/mcp/index.jsx b/projects/plugins/jetpack/_inc/client/ai/mcp/index.jsx index b9afa059b1aa..1fe5d9a0cc73 100644 --- a/projects/plugins/jetpack/_inc/client/ai/mcp/index.jsx +++ b/projects/plugins/jetpack/_inc/client/ai/mcp/index.jsx @@ -16,7 +16,7 @@ import { } from '@wordpress/components'; import { useCallback } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; -import { seen, pencil, connection, chevronRight, check } from '@wordpress/icons'; +import { seen, pencil, connection, chevronRight, info, published, caution } from '@wordpress/icons'; import { isWriteTool } from './categories'; import { getAccountMcpAbilities, @@ -72,13 +72,15 @@ function SummaryRow( { icon, title, badge, onClick } ) { + ) } +
+ +
+ + + ); +} diff --git a/projects/plugins/jetpack/_inc/client/ai/mcp/use-mcp-settings.js b/projects/plugins/jetpack/_inc/client/ai/mcp/use-mcp-settings.js index f7aca198cf0e..78ee71dc7b1f 100644 --- a/projects/plugins/jetpack/_inc/client/ai/mcp/use-mcp-settings.js +++ b/projects/plugins/jetpack/_inc/client/ai/mcp/use-mcp-settings.js @@ -21,6 +21,7 @@ export function useMcpSettings() { const [ isLoading, setIsLoading ] = useState( true ); const [ isSaving, setIsSaving ] = useState( false ); const [ mcpAbilities, setMcpAbilities ] = useState( null ); + const [ hasMcpAccess, setHasMcpAccess ] = useState( null ); const [ error, setError ] = useState( null ); useEffect( () => { @@ -30,6 +31,12 @@ export function useMcpSettings() { .then( data => { if ( ! cancelled ) { setMcpAbilities( data?.mcp_abilities ?? {} ); + // has_mcp_access is explicitly set by the PHP proxy. + // Fall back to checking whether any account tools were returned. + setHasMcpAccess( + data?.has_mcp_access !== false && + Object.keys( data?.mcp_abilities?.account ?? {} ).length > 0 + ); setError( null ); } } ) @@ -78,5 +85,5 @@ export function useMcpSettings() { [ mcpAbilities ] ); - return { isLoading, isSaving, mcpAbilities, error, updateMcpAbilities }; + return { isLoading, isSaving, mcpAbilities, hasMcpAccess, error, updateMcpAbilities }; } diff --git a/projects/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-ai-page.php b/projects/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-ai-page.php index 8a394cdb9f59..6a3e7fd6694e 100644 --- a/projects/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-ai-page.php +++ b/projects/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-ai-page.php @@ -97,6 +97,7 @@ public function page_admin_scripts() { 'apiRoot' => esc_url_raw( rest_url() ), 'apiNonce' => wp_create_nonce( 'wp_rest' ), 'pluginUrl' => plugins_url( '', JETPACK__PLUGIN_FILE ), + 'upgradeUrl' => 'https://wordpress.com/plans/' . rawurlencode( wp_parse_url( home_url(), PHP_URL_HOST ) ?? '' ), ), JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ';', diff --git a/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php b/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php index fa4f203b8762..5fa45dcb4230 100644 --- a/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php +++ b/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php @@ -123,6 +123,19 @@ public function get_mcp_settings() { return $response; } + $http_status = wp_remote_retrieve_response_code( $response ); + // 402 / 403 from WPCOM means the site does not have an MCP-capable plan. + // Return a clean "no access" payload so the client can show the upsell + // instead of a generic error notice. + if ( in_array( $http_status, array( 402, 403 ), true ) ) { + return rest_ensure_response( + array( + 'has_mcp_access' => false, + 'mcp_abilities' => array(), + ) + ); + } + $body = json_decode( wp_remote_retrieve_body( $response ), true ); // Transform [ {name, title, readonly, site_context, enabled, …}, … ] → { name: {…}, … }. @@ -184,7 +197,8 @@ function ( $a ) { return rest_ensure_response( array( - 'mcp_abilities' => array( + 'has_mcp_access' => true, + 'mcp_abilities' => array( 'account' => $account_abilities, 'site' => $site_abilities, 'sites' => array( @@ -214,7 +228,9 @@ function ( $a ) { * @return WP_REST_Response|WP_Error */ public function update_mcp_settings( $request ) { - $blog_id = \Jetpack_Options::get_option( 'id' ); + $blog_id = defined( 'IS_WPCOM' ) && IS_WPCOM + ? get_current_blog_id() + : \Jetpack_Options::get_option( 'id' ); $incoming = $request->get_param( 'mcp_abilities' ); if ( is_object( $incoming ) ) { From d89353b7f172498a4ecbce1f1c0dc4332dabad08 Mon Sep 17 00:00:00 2001 From: Eoin Gallagher Date: Thu, 16 Apr 2026 16:03:03 +0100 Subject: [PATCH 19/39] fix(ai): use has_mcp_plan field from wpcom for upsell detection The wpcom mcp-abilities endpoint now returns an explicit has_mcp_plan boolean (via PaidPlanMiddleware::site_has_paid_plan). Read that field directly instead of inferring plan access from 402/403 status codes. Keep 402/403 fallback for older wpcom builds that predate the field. Co-Authored-By: Claude Sonnet 4.6 --- ...-wpcom-rest-api-v2-endpoint-mcp-settings.php | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php b/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php index 5fa45dcb4230..4f1689a06ec8 100644 --- a/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php +++ b/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php @@ -123,11 +123,16 @@ public function get_mcp_settings() { return $response; } - $http_status = wp_remote_retrieve_response_code( $response ); - // 402 / 403 from WPCOM means the site does not have an MCP-capable plan. - // Return a clean "no access" payload so the client can show the upsell - // instead of a generic error notice. - if ( in_array( $http_status, array( 402, 403 ), true ) ) { + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + + // has_mcp_plan is set explicitly by the WPCOM endpoint using PaidPlanMiddleware. + // Fall back to 402/403 status handling for older WPCOM builds that predate the field. + $http_status = wp_remote_retrieve_response_code( $response ); + $has_mcp_plan = isset( $body['has_mcp_plan'] ) + ? (bool) $body['has_mcp_plan'] + : ! in_array( $http_status, array( 402, 403 ), true ); + + if ( ! $has_mcp_plan ) { return rest_ensure_response( array( 'has_mcp_access' => false, @@ -136,8 +141,6 @@ public function get_mcp_settings() { ); } - $body = json_decode( wp_remote_retrieve_body( $response ), true ); - // Transform [ {name, title, readonly, site_context, enabled, …}, … ] → { name: {…}, … }. // readonly and site_context are now provided by the WPCOM endpoint (wpcom#205108). // Fall back to name-suffix heuristic for readonly only if the field is absent, so From 497f6ea84cfa9c8309dc6161eb1df0f3d63747f2 Mon Sep 17 00:00:00 2001 From: Eoin Gallagher Date: Thu, 16 Apr 2026 23:25:36 +0100 Subject: [PATCH 20/39] feat(jetpack-mu-wpcom): load Jetpack AI MCP settings page and endpoint on wpcom - Copy class-wpcom-rest-api-v2-endpoint-mcp-settings.php into the mu-plugin wpcom-endpoints folder so it is auto-loaded on both Atomic and Simple wpcom via load_wpcom_rest_api_endpoints() - Register the Jetpack AI admin page (jetpack-ai slug) inside wpcom_add_jetpack_submenu() on Atomic sites where JETPACK__PLUGIN_DIR is defined; Simple site support requires moving the React app to a package and is left as a follow-up - Add jetpack-ai to the submenu reorder list Co-Authored-By: Claude Sonnet 4.6 --- .../aiint-357-jetpack-ai-mcp-settings | 4 + .../wpcom-admin-menu/wpcom-admin-menu.php | 15 + ...pcom-rest-api-v2-endpoint-mcp-settings.php | 297 ++++++++++++++++++ 3 files changed, 316 insertions(+) create mode 100644 projects/packages/jetpack-mu-wpcom/changelog/aiint-357-jetpack-ai-mcp-settings create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php diff --git a/projects/packages/jetpack-mu-wpcom/changelog/aiint-357-jetpack-ai-mcp-settings b/projects/packages/jetpack-mu-wpcom/changelog/aiint-357-jetpack-ai-mcp-settings new file mode 100644 index 000000000000..5fcefbfac0d8 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/changelog/aiint-357-jetpack-ai-mcp-settings @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add Jetpack AI MCP settings admin page and REST endpoint for wpcom diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-admin-menu/wpcom-admin-menu.php b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-admin-menu/wpcom-admin-menu.php index aed36460d337..b40f4ccb54a0 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-admin-menu/wpcom-admin-menu.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-admin-menu/wpcom-admin-menu.php @@ -442,6 +442,20 @@ function () { null // @phan-suppress-current-line PhanTypeMismatchArgumentProbablyReal -- Core should ideally document null for no-callback arg. https://core.trac.wordpress.org/ticket/52539. ); + // Jetpack > AI + // On Atomic, JETPACK__PLUGIN_DIR is defined and the plugin's admin page class + // can be loaded directly. Simple sites need the React app in a package first + // and are not yet supported here. + $jetpack_ai_page_file = defined( 'JETPACK__PLUGIN_DIR' ) + ? JETPACK__PLUGIN_DIR . '_inc/lib/admin-pages/class-jetpack-ai-page.php' + : ''; + if ( $jetpack_ai_page_file && file_exists( $jetpack_ai_page_file ) ) { + require_once JETPACK__PLUGIN_DIR . '_inc/lib/admin-pages/class.jetpack-admin-page.php'; + require_once $jetpack_ai_page_file; + $jetpack_ai = new Jetpack_AI_Page(); + $jetpack_ai->add_actions(); + } + wpcom_reorder_submenu( 'jetpack', array( @@ -449,6 +463,7 @@ function () { 'stats', 'boost', 'social', + 'jetpack-ai', 'akismet-key-config', 'activity-log', 'scan', diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php new file mode 100644 index 000000000000..1052942b92fb --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php @@ -0,0 +1,297 @@ +namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_mcp_settings' ), + 'permission_callback' => array( $this, 'permissions_check' ), + ), + array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'update_mcp_settings' ), + 'permission_callback' => array( $this, 'permissions_check' ), + 'args' => array( + 'mcp_abilities' => array( + 'type' => 'object', + 'required' => true, + ), + ), + ), + ) + ); + } + + /** + * Check permissions. + * + * @return bool|WP_Error + */ + public function permissions_check() { + if ( ! current_user_can( 'manage_options' ) ) { + return new WP_Error( + 'rest_forbidden', + __( 'You do not have permission to manage MCP settings.', 'jetpack-mu-wpcom' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Fetch MCP abilities from the WPCOM wpcom/v2/sites/{blog_id}/mcp-abilities endpoint. + * + * The WPCOM endpoint accepts user token auth and returns an array of abilities. + * The response is shaped to match the format the client utilities expect: + * { account: { tool-id: {...} }, site: { tool-id: {...} }, + * sites: [{ blog_id, site_level_enabled, abilities }], + * site_level_enabled_default: bool } + * + * All state is owned by WPCOM and persisted via POST /sites/{blog_id}/mcp-abilities. + * + * @return WP_REST_Response|WP_Error + */ + public function get_mcp_settings() { + $blog_id = defined( 'IS_WPCOM' ) && IS_WPCOM + ? get_current_blog_id() + : \Jetpack_Options::get_option( 'id' ); + + if ( ! $blog_id ) { + return rest_ensure_response( array( 'mcp_abilities' => new stdClass() ) ); + } + + $response = Client::wpcom_json_api_request_as_user( + sprintf( '/sites/%d/mcp-abilities', (int) $blog_id ), + '2', + array( 'method' => 'GET' ), + null, + 'wpcom' + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + + // has_mcp_plan is set explicitly by the WPCOM endpoint using PaidPlanMiddleware. + // Fall back to 402/403 status handling for older WPCOM builds that predate the field. + $http_status = wp_remote_retrieve_response_code( $response ); + $has_mcp_plan = isset( $body['has_mcp_plan'] ) + ? (bool) $body['has_mcp_plan'] + : ! in_array( $http_status, array( 402, 403 ), true ); + + if ( ! $has_mcp_plan ) { + return rest_ensure_response( + array( + 'has_mcp_access' => false, + 'mcp_abilities' => array(), + ) + ); + } + + // Transform [ {name, title, readonly, site_context, enabled, …}, … ] → { name: {…}, … }. + // readonly and site_context are now provided by the WPCOM endpoint (wpcom#205108). + // Fall back to name-suffix heuristic for readonly only if the field is absent, so + // this endpoint remains functional against older WPCOM builds. + $account_abilities = new stdClass(); + $site_abilities = array(); + if ( ! empty( $body['abilities'] ) && is_array( $body['abilities'] ) ) { + $account_abilities = array(); + foreach ( $body['abilities'] as $ability ) { + if ( empty( $ability['name'] ) ) { + continue; + } + if ( ! array_key_exists( 'readonly', $ability ) ) { + $ability['readonly'] = ! (bool) preg_match( '/-(create|update|delete)$/i', $ability['name'] ); + } + $account_abilities[ (string) $ability['name'] ] = $ability; + + // `site` subset: tools marked site_context=true by WPCOM are the only ones + // relevant to the site-level settings UI. getSiteContextToolIds() in JS uses + // this to filter out account/notifications/billing/domains tools. + if ( ! empty( $ability['site_context'] ) ) { + $site_abilities[ $ability['name'] ] = $ability; + } + } + } + + // site_level_enabled comes directly from WPCOM — it is the authoritative effective + // state for this site (account default applied, site exceptions factored in). + // Fall back to the enabled-abilities heuristic only if the field is absent. + $site_level_enabled = isset( $body['site_level_enabled'] ) + ? (bool) $body['site_level_enabled'] + : ! empty( + array_filter( + (array) $account_abilities, + function ( $a ) { + return ! empty( $a['enabled'] ); + } + ) + ); + + // site_level_enabled_default mirrors Calypso: same value as site_level_enabled + // when derived from WPCOM (no per-site override concept at this layer). + $site_level_enabled_default = $site_level_enabled; + + // Use only the explicit per-site user overrides returned by WPCOM in user_overrides.abilities. + // These are the raw values stored via SettingsHelper — not the computed effective states + // (account defaults merged with overrides). Keeping only explicit overrides here lets the + // JS fall back to site_level_enabled as the default for any tool not yet overridden, + // matching Calypso's display behaviour (all tools on when site_level_enabled:true and no + // per-tool overrides exist). + $site_tool_abilities = array(); + if ( isset( $body['user_overrides']['abilities'] ) && is_array( $body['user_overrides']['abilities'] ) ) { + foreach ( $body['user_overrides']['abilities'] as $name => $value ) { + $site_tool_abilities[ (string) $name ] = (bool) $value; + } + } + + return rest_ensure_response( + array( + 'has_mcp_access' => true, + 'mcp_abilities' => array( + 'account' => $account_abilities, + 'site' => $site_abilities, + 'sites' => array( + array( + 'blog_id' => (int) $blog_id, + 'site_level_enabled' => $site_level_enabled, + 'abilities' => (object) $site_tool_abilities, + ), + ), + 'site_level_enabled_default' => $site_level_enabled_default, + ), + ) + ); + } + + /** + * Proxy mcp_abilities update to WPCOM POST /sites/{blog_id}/mcp-abilities. + * + * Accepts the sites[] format used by the client: + * { sites: [{ blog_id, site_level_enabled?, abilities?: { tool_id: bool } }] } + * + * Extracts site_level_enabled and abilities from sites[0] and forwards them + * to the WPCOM endpoint which persists them to user settings via SettingsHelper. + * This keeps Jetpack and Calypso in sync — both read/write the same store. + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error + */ + public function update_mcp_settings( $request ) { + $blog_id = defined( 'IS_WPCOM' ) && IS_WPCOM + ? get_current_blog_id() + : \Jetpack_Options::get_option( 'id' ); + $incoming = $request->get_param( 'mcp_abilities' ); + + if ( is_object( $incoming ) ) { + $incoming = get_object_vars( $incoming ); + } elseif ( ! is_array( $incoming ) ) { + $incoming = array(); + } + + // Unpack sites[0] into the flat format WPCOM POST /sites/{id}/mcp-abilities expects. + $wpcom_body = array(); + + if ( ! empty( $incoming['sites'] ) && is_array( $incoming['sites'] ) ) { + $site_update = $incoming['sites'][0]; + if ( is_object( $site_update ) ) { + $site_update = get_object_vars( $site_update ); + } + + if ( isset( $site_update['site_level_enabled'] ) ) { + $wpcom_body['site_level_enabled'] = (bool) $site_update['site_level_enabled']; + } + + if ( isset( $site_update['abilities'] ) ) { + $abilities = is_object( $site_update['abilities'] ) + ? get_object_vars( $site_update['abilities'] ) + : (array) $site_update['abilities']; + $normalised = array(); + foreach ( $abilities as $name => $value ) { + $normalised[ $name ] = (bool) $value; + } + $wpcom_body['abilities'] = $normalised; + } + } + + if ( ! empty( $wpcom_body ) ) { + $response = Client::wpcom_json_api_request_as_user( + sprintf( '/sites/%d/mcp-abilities', (int) $blog_id ), + '2', + array( 'method' => 'POST' ), + $wpcom_body, + 'wpcom' + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $status = wp_remote_retrieve_response_code( $response ); + if ( $status < 200 || $status >= 300 ) { + return new WP_Error( + 'wpcom_update_failed', + __( 'Failed to save MCP settings on WordPress.com.', 'jetpack-mu-wpcom' ), + array( 'status' => 502 ) + ); + } + } + + return $this->get_mcp_settings(); + } +} + +wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_MCP_Settings' ); From 001f6256b88416814a6a5d0ed2f1f156c6e139b4 Mon Sep 17 00:00:00 2001 From: Eoin Gallagher Date: Fri, 17 Apr 2026 09:18:09 +0100 Subject: [PATCH 21/39] fix(ai): remove duplicate MCP endpoint and double menu registration from Jetpack plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP settings endpoint is wpcom-only and is already provided by jetpack-mu-wpcom's features/wpcom-endpoints/ for both Atomic and Simple sites. Keeping a copy in the plugin's wpcom-endpoints/ caused a PHP fatal (Cannot redeclare class) on Atomic where both loaders run. Also remove the Jetpack_AI_Page registration added to class.jetpack-admin.php — that path fires on Atomic alongside wpcom-admin-menu.php's registration, resulting in two add_submenu_page() calls for the same slug. The wpcom-admin-menu.php path in jetpack-mu-wpcom is the only correct registration for this wpcom-only page. Fixes both issues flagged in PR review (Automattic/jetpack#48048). Co-Authored-By: Claude Sonnet 4.6 --- ...pcom-rest-api-v2-endpoint-mcp-settings.php | 297 ------------------ .../aiint-357-jetpack-ai-mcp-settings | 4 - .../plugins/jetpack/class.jetpack-admin.php | 4 - 3 files changed, 305 deletions(-) delete mode 100644 projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php delete mode 100644 projects/plugins/jetpack/changelog/aiint-357-jetpack-ai-mcp-settings diff --git a/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php b/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php deleted file mode 100644 index 4f1689a06ec8..000000000000 --- a/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php +++ /dev/null @@ -1,297 +0,0 @@ -namespace, - '/' . $this->rest_base, - array( - array( - 'methods' => WP_REST_Server::READABLE, - 'callback' => array( $this, 'get_mcp_settings' ), - 'permission_callback' => array( $this, 'permissions_check' ), - ), - array( - 'methods' => WP_REST_Server::EDITABLE, - 'callback' => array( $this, 'update_mcp_settings' ), - 'permission_callback' => array( $this, 'permissions_check' ), - 'args' => array( - 'mcp_abilities' => array( - 'type' => 'object', - 'required' => true, - ), - ), - ), - ) - ); - } - - /** - * Check permissions. - * - * @return bool|WP_Error - */ - public function permissions_check() { - if ( ! current_user_can( 'manage_options' ) ) { - return new WP_Error( - 'rest_forbidden', - __( 'You do not have permission to manage MCP settings.', 'jetpack' ), - array( 'status' => rest_authorization_required_code() ) - ); - } - - return true; - } - - /** - * Fetch MCP abilities from the WPCOM wpcom/v2/sites/{blog_id}/mcp-abilities endpoint. - * - * The WPCOM endpoint accepts user token auth and returns an array of abilities. - * The response is shaped to match the format the client utilities expect: - * { account: { tool-id: {...} }, site: { tool-id: {...} }, - * sites: [{ blog_id, site_level_enabled, abilities }], - * site_level_enabled_default: bool } - * - * All state is owned by WPCOM and persisted via POST /sites/{blog_id}/mcp-abilities. - * - * @return WP_REST_Response|WP_Error - */ - public function get_mcp_settings() { - $blog_id = defined( 'IS_WPCOM' ) && IS_WPCOM - ? get_current_blog_id() - : \Jetpack_Options::get_option( 'id' ); - - if ( ! $blog_id ) { - return rest_ensure_response( array( 'mcp_abilities' => new stdClass() ) ); - } - - $response = Client::wpcom_json_api_request_as_user( - sprintf( '/sites/%d/mcp-abilities', (int) $blog_id ), - '2', - array( 'method' => 'GET' ), - null, - 'wpcom' - ); - - if ( is_wp_error( $response ) ) { - return $response; - } - - $body = json_decode( wp_remote_retrieve_body( $response ), true ); - - // has_mcp_plan is set explicitly by the WPCOM endpoint using PaidPlanMiddleware. - // Fall back to 402/403 status handling for older WPCOM builds that predate the field. - $http_status = wp_remote_retrieve_response_code( $response ); - $has_mcp_plan = isset( $body['has_mcp_plan'] ) - ? (bool) $body['has_mcp_plan'] - : ! in_array( $http_status, array( 402, 403 ), true ); - - if ( ! $has_mcp_plan ) { - return rest_ensure_response( - array( - 'has_mcp_access' => false, - 'mcp_abilities' => array(), - ) - ); - } - - // Transform [ {name, title, readonly, site_context, enabled, …}, … ] → { name: {…}, … }. - // readonly and site_context are now provided by the WPCOM endpoint (wpcom#205108). - // Fall back to name-suffix heuristic for readonly only if the field is absent, so - // this endpoint remains functional against older WPCOM builds. - $account_abilities = new stdClass(); - $site_abilities = array(); - if ( ! empty( $body['abilities'] ) && is_array( $body['abilities'] ) ) { - $account_abilities = array(); - foreach ( $body['abilities'] as $ability ) { - if ( empty( $ability['name'] ) ) { - continue; - } - if ( ! array_key_exists( 'readonly', $ability ) ) { - $ability['readonly'] = ! (bool) preg_match( '/-(create|update|delete)$/i', $ability['name'] ); - } - $account_abilities[ (string) $ability['name'] ] = $ability; - - // `site` subset: tools marked site_context=true by WPCOM are the only ones - // relevant to the site-level settings UI. getSiteContextToolIds() in JS uses - // this to filter out account/notifications/billing/domains tools. - if ( ! empty( $ability['site_context'] ) ) { - $site_abilities[ $ability['name'] ] = $ability; - } - } - } - - // site_level_enabled comes directly from WPCOM — it is the authoritative effective - // state for this site (account default applied, site exceptions factored in). - // Fall back to the enabled-abilities heuristic only if the field is absent. - $site_level_enabled = isset( $body['site_level_enabled'] ) - ? (bool) $body['site_level_enabled'] - : ! empty( - array_filter( - (array) $account_abilities, - function ( $a ) { - return ! empty( $a['enabled'] ); - } - ) - ); - - // site_level_enabled_default mirrors Calypso: same value as site_level_enabled - // when derived from WPCOM (no per-site override concept at this layer). - $site_level_enabled_default = $site_level_enabled; - - // Use only the explicit per-site user overrides returned by WPCOM in user_overrides.abilities. - // These are the raw values stored via SettingsHelper — not the computed effective states - // (account defaults merged with overrides). Keeping only explicit overrides here lets the - // JS fall back to site_level_enabled as the default for any tool not yet overridden, - // matching Calypso's display behaviour (all tools on when site_level_enabled:true and no - // per-tool overrides exist). - $site_tool_abilities = array(); - if ( isset( $body['user_overrides']['abilities'] ) && is_array( $body['user_overrides']['abilities'] ) ) { - foreach ( $body['user_overrides']['abilities'] as $name => $value ) { - $site_tool_abilities[ (string) $name ] = (bool) $value; - } - } - - return rest_ensure_response( - array( - 'has_mcp_access' => true, - 'mcp_abilities' => array( - 'account' => $account_abilities, - 'site' => $site_abilities, - 'sites' => array( - array( - 'blog_id' => (int) $blog_id, - 'site_level_enabled' => $site_level_enabled, - 'abilities' => (object) $site_tool_abilities, - ), - ), - 'site_level_enabled_default' => $site_level_enabled_default, - ), - ) - ); - } - - /** - * Proxy mcp_abilities update to WPCOM POST /sites/{blog_id}/mcp-abilities. - * - * Accepts the sites[] format used by the client: - * { sites: [{ blog_id, site_level_enabled?, abilities?: { tool_id: bool } }] } - * - * Extracts site_level_enabled and abilities from sites[0] and forwards them - * to the WPCOM endpoint which persists them to user settings via SettingsHelper. - * This keeps Jetpack and Calypso in sync — both read/write the same store. - * - * @param WP_REST_Request $request Full details about the request. - * @return WP_REST_Response|WP_Error - */ - public function update_mcp_settings( $request ) { - $blog_id = defined( 'IS_WPCOM' ) && IS_WPCOM - ? get_current_blog_id() - : \Jetpack_Options::get_option( 'id' ); - $incoming = $request->get_param( 'mcp_abilities' ); - - if ( is_object( $incoming ) ) { - $incoming = get_object_vars( $incoming ); - } elseif ( ! is_array( $incoming ) ) { - $incoming = array(); - } - - // Unpack sites[0] into the flat format WPCOM POST /sites/{id}/mcp-abilities expects. - $wpcom_body = array(); - - if ( ! empty( $incoming['sites'] ) && is_array( $incoming['sites'] ) ) { - $site_update = $incoming['sites'][0]; - if ( is_object( $site_update ) ) { - $site_update = get_object_vars( $site_update ); - } - - if ( isset( $site_update['site_level_enabled'] ) ) { - $wpcom_body['site_level_enabled'] = (bool) $site_update['site_level_enabled']; - } - - if ( isset( $site_update['abilities'] ) ) { - $abilities = is_object( $site_update['abilities'] ) - ? get_object_vars( $site_update['abilities'] ) - : (array) $site_update['abilities']; - $normalised = array(); - foreach ( $abilities as $name => $value ) { - $normalised[ $name ] = (bool) $value; - } - $wpcom_body['abilities'] = $normalised; - } - } - - if ( ! empty( $wpcom_body ) ) { - $response = Client::wpcom_json_api_request_as_user( - sprintf( '/sites/%d/mcp-abilities', (int) $blog_id ), - '2', - array( 'method' => 'POST' ), - $wpcom_body, - 'wpcom' - ); - - if ( is_wp_error( $response ) ) { - return $response; - } - - $status = wp_remote_retrieve_response_code( $response ); - if ( $status < 200 || $status >= 300 ) { - return new WP_Error( - 'wpcom_update_failed', - __( 'Failed to save MCP settings on WordPress.com.', 'jetpack' ), - array( 'status' => 502 ) - ); - } - } - - return $this->get_mcp_settings(); - } -} - -wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_MCP_Settings' ); diff --git a/projects/plugins/jetpack/changelog/aiint-357-jetpack-ai-mcp-settings b/projects/plugins/jetpack/changelog/aiint-357-jetpack-ai-mcp-settings deleted file mode 100644 index 4507d8958a32..000000000000 --- a/projects/plugins/jetpack/changelog/aiint-357-jetpack-ai-mcp-settings +++ /dev/null @@ -1,4 +0,0 @@ -Significance: minor -Type: enhancement - -AI: Add Jetpack AI admin page with MCP settings. Registers an "AI" submenu under Jetpack and provides a React-based interface for configuring external AI agent access (MCP) for the site, including per-tool read/write toggles and agent setup instructions. diff --git a/projects/plugins/jetpack/class.jetpack-admin.php b/projects/plugins/jetpack/class.jetpack-admin.php index 3c2f8bb3224d..88f11fa321e7 100644 --- a/projects/plugins/jetpack/class.jetpack-admin.php +++ b/projects/plugins/jetpack/class.jetpack-admin.php @@ -63,9 +63,6 @@ private function __construct() { require_once JETPACK__PLUGIN_DIR . '_inc/lib/admin-pages/class-jetpack-about-page.php'; $jetpack_about = new Jetpack_About_Page(); - require_once JETPACK__PLUGIN_DIR . '_inc/lib/admin-pages/class-jetpack-ai-page.php'; - $jetpack_ai = new Jetpack_AI_Page(); - add_action( 'admin_init', array( $jetpack_react, 'react_redirects' ), 0 ); add_action( 'admin_menu', array( $jetpack_react, 'add_actions' ), 998 ); add_action( 'admin_menu', array( $jetpack_react, 'remove_jetpack_menu' ), 2000 ); @@ -73,7 +70,6 @@ private function __construct() { add_action( 'jetpack_admin_menu', array( $this, 'admin_menu_debugger' ) ); add_action( 'jetpack_admin_menu', array( $fallback_page, 'add_actions' ) ); add_action( 'jetpack_admin_menu', array( $jetpack_about, 'add_actions' ) ); - add_action( 'jetpack_admin_menu', array( $jetpack_ai, 'add_actions' ) ); // Add redirect to current page for activation/deactivation of modules. add_action( 'jetpack_pre_activate_module', array( $this, 'fix_redirect' ), 10, 2 ); From 2feea9b44d313af76fa44ef7a2bd595a84208b6f Mon Sep 17 00:00:00 2001 From: Eoin Gallagher Date: Fri, 17 Apr 2026 09:18:49 +0100 Subject: [PATCH 22/39] fix(ai): add changelog entry for class.jetpack-admin.php cleanup Co-Authored-By: Claude Sonnet 4.6 --- .../jetpack/changelog/aiint-357-jetpack-ai-mcp-settings | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 projects/plugins/jetpack/changelog/aiint-357-jetpack-ai-mcp-settings diff --git a/projects/plugins/jetpack/changelog/aiint-357-jetpack-ai-mcp-settings b/projects/plugins/jetpack/changelog/aiint-357-jetpack-ai-mcp-settings new file mode 100644 index 000000000000..f5352a55bdba --- /dev/null +++ b/projects/plugins/jetpack/changelog/aiint-357-jetpack-ai-mcp-settings @@ -0,0 +1,4 @@ +Significance: patch +Type: other + +Remove erroneous Jetpack_AI_Page registration from class.jetpack-admin.php; the AI admin page is wpcom-only and is registered exclusively via jetpack-mu-wpcom. From 987efa9ed5cc84952d255c50788e79e4a57e0fe6 Mon Sep 17 00:00:00 2001 From: Eoin Gallagher Date: Fri, 17 Apr 2026 09:21:50 +0100 Subject: [PATCH 23/39] fix(ai): suppress Phan undeclared-class warnings for runtime-loaded Jetpack_AI_Page Phan analyses the mu-plugin in isolation and cannot see the Jetpack plugin's class files loaded via runtime require_once. Suppress the two false-positive PhanUndeclaredClassMethod errors on the instantiation and add_actions() lines. Co-Authored-By: Claude Sonnet 4.6 --- .../src/features/wpcom-admin-menu/wpcom-admin-menu.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-admin-menu/wpcom-admin-menu.php b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-admin-menu/wpcom-admin-menu.php index b40f4ccb54a0..6bfa5cb3282b 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-admin-menu/wpcom-admin-menu.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-admin-menu/wpcom-admin-menu.php @@ -452,8 +452,8 @@ function () { if ( $jetpack_ai_page_file && file_exists( $jetpack_ai_page_file ) ) { require_once JETPACK__PLUGIN_DIR . '_inc/lib/admin-pages/class.jetpack-admin-page.php'; require_once $jetpack_ai_page_file; - $jetpack_ai = new Jetpack_AI_Page(); - $jetpack_ai->add_actions(); + $jetpack_ai = new Jetpack_AI_Page(); // @phan-suppress-current-line PhanUndeclaredClassMethod -- class loaded via runtime require_once above. + $jetpack_ai->add_actions(); // @phan-suppress-current-line PhanUndeclaredClassMethod } wpcom_reorder_submenu( From 4177f46fc716aa83949b8988aad87b7745c6f01e Mon Sep 17 00:00:00 2001 From: sergeymitr Date: Sat, 18 Apr 2026 21:04:30 -0400 Subject: [PATCH 24/39] Fix submenu item initialization. --- .../wpcom-admin-menu/wpcom-admin-menu.php | 36 +++++++++++-------- ...pcom-rest-api-v2-endpoint-mcp-settings.php | 18 +++++----- 2 files changed, 32 insertions(+), 22 deletions(-) diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-admin-menu/wpcom-admin-menu.php b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-admin-menu/wpcom-admin-menu.php index 6bfa5cb3282b..c65471490576 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-admin-menu/wpcom-admin-menu.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-admin-menu/wpcom-admin-menu.php @@ -442,20 +442,6 @@ function () { null // @phan-suppress-current-line PhanTypeMismatchArgumentProbablyReal -- Core should ideally document null for no-callback arg. https://core.trac.wordpress.org/ticket/52539. ); - // Jetpack > AI - // On Atomic, JETPACK__PLUGIN_DIR is defined and the plugin's admin page class - // can be loaded directly. Simple sites need the React app in a package first - // and are not yet supported here. - $jetpack_ai_page_file = defined( 'JETPACK__PLUGIN_DIR' ) - ? JETPACK__PLUGIN_DIR . '_inc/lib/admin-pages/class-jetpack-ai-page.php' - : ''; - if ( $jetpack_ai_page_file && file_exists( $jetpack_ai_page_file ) ) { - require_once JETPACK__PLUGIN_DIR . '_inc/lib/admin-pages/class.jetpack-admin-page.php'; - require_once $jetpack_ai_page_file; - $jetpack_ai = new Jetpack_AI_Page(); // @phan-suppress-current-line PhanUndeclaredClassMethod -- class loaded via runtime require_once above. - $jetpack_ai->add_actions(); // @phan-suppress-current-line PhanUndeclaredClassMethod - } - wpcom_reorder_submenu( 'jetpack', array( @@ -781,6 +767,28 @@ function wpcom_add_settings_menu() { } add_action( 'admin_menu', 'wpcom_add_settings_menu', 999999 ); +/** + * Add the Jetpack AI submenu item. + * + * @return void + */ +function wpcom_add_jetpack_ai_submenu() { + // Jetpack > AI + // On Atomic, JETPACK__PLUGIN_DIR is defined and the plugin's admin page class + // can be loaded directly. Simple sites need the React app in a package first + // and are not yet supported here. + $jetpack_ai_page_file = defined( 'JETPACK__PLUGIN_DIR' ) + ? JETPACK__PLUGIN_DIR . '_inc/lib/admin-pages/class-jetpack-ai-page.php' + : ''; + if ( $jetpack_ai_page_file && file_exists( $jetpack_ai_page_file ) ) { + require_once JETPACK__PLUGIN_DIR . '_inc/lib/admin-pages/class.jetpack-admin-page.php'; + require_once $jetpack_ai_page_file; + $jetpack_ai = new Jetpack_AI_Page(); // @phan-suppress-current-line PhanUndeclaredClassMethod -- class loaded via runtime require_once above. + $jetpack_ai->add_actions(); // @phan-suppress-current-line PhanUndeclaredClassMethod + } +} +add_action( 'admin_menu', 'wpcom_add_jetpack_ai_submenu' ); + if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) { require_once __DIR__ . '/p2-admin-menu.php'; } diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php index 1052942b92fb..fcfe6a5e93fa 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php @@ -14,6 +14,7 @@ */ use Automattic\Jetpack\Connection\Client; +use Automattic\Jetpack\Connection\Manager; if ( ! defined( 'ABSPATH' ) ) { exit( 0 ); @@ -103,12 +104,10 @@ public function permissions_check() { * @return WP_REST_Response|WP_Error */ public function get_mcp_settings() { - $blog_id = defined( 'IS_WPCOM' ) && IS_WPCOM - ? get_current_blog_id() - : \Jetpack_Options::get_option( 'id' ); + $blog_id = Manager::get_site_id(); - if ( ! $blog_id ) { - return rest_ensure_response( array( 'mcp_abilities' => new stdClass() ) ); + if ( is_wp_error( $blog_id ) ) { + return rest_ensure_response( $blog_id ); } $response = Client::wpcom_json_api_request_as_user( @@ -231,9 +230,12 @@ function ( $a ) { * @return WP_REST_Response|WP_Error */ public function update_mcp_settings( $request ) { - $blog_id = defined( 'IS_WPCOM' ) && IS_WPCOM - ? get_current_blog_id() - : \Jetpack_Options::get_option( 'id' ); + $blog_id = Manager::get_site_id(); + + if ( is_wp_error( $blog_id ) ) { + return rest_ensure_response( $blog_id ); + } + $incoming = $request->get_param( 'mcp_abilities' ); if ( is_object( $incoming ) ) { From 09e00bd5333da9865ad9ba7a8baa18948d460ef0 Mon Sep 17 00:00:00 2001 From: Eoin Gallagher Date: Tue, 21 Apr 2026 13:53:20 +0100 Subject: [PATCH 25/39] fix(ai): address PR review feedback on MCP settings UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace @wordpress/components Notice with Notice from @wordpress/ui (compound component API: Notice.Root/Title/Description/CloseIcon) - Replace experimental VStack in main.jsx with Stack from @wordpress/ui - Replace custom badge implementation with Badge from @wordpress/ui; map intents: success→stable, info→informational, warning→medium, neutral→draft - Remove now-unused custom badge SCSS (Badge brings its own styles) - Route external docs links in setup.jsx through getRedirectUrl so they can be updated server-side without a code deploy - Switch docs ExternalLink to Link from @wordpress/ui with openInNewTab - Add @wordpress/ui 0.10.0 dependency to Jetpack plugin package.json - Update changelog type to enhancement / significance to minor Co-Authored-By: Claude Sonnet 4.6 --- .../plugins/jetpack/_inc/client/ai/main.jsx | 34 +++++++++++-------- .../jetpack/_inc/client/ai/mcp/index.jsx | 19 ++++++----- .../jetpack/_inc/client/ai/mcp/setup.jsx | 26 ++++++++------ .../jetpack/_inc/client/ai/mcp/style.scss | 32 ----------------- .../aiint-357-jetpack-ai-mcp-settings | 4 +-- 5 files changed, 47 insertions(+), 68 deletions(-) diff --git a/projects/plugins/jetpack/_inc/client/ai/main.jsx b/projects/plugins/jetpack/_inc/client/ai/main.jsx index d827d639c7fd..8ba45bb02446 100644 --- a/projects/plugins/jetpack/_inc/client/ai/main.jsx +++ b/projects/plugins/jetpack/_inc/client/ai/main.jsx @@ -5,10 +5,11 @@ */ import { AdminPage } from '@automattic/jetpack-components'; -import { Button, Notice, Spinner, __experimentalVStack as VStack } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis +import { Button, Spinner } from '@wordpress/components'; import { useCallback, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { arrowLeft } from '@wordpress/icons'; +import { Notice, Stack } from '@wordpress/ui'; import McpHub from './mcp/index'; import McpRead from './mcp/read'; import McpSetup from './mcp/setup'; @@ -84,30 +85,33 @@ export default function App() { ) } { ! isLoading && error && ( - - { error } - + + { error } + ) } { ! isLoading && saveError && ( - - { saveError } - + + { saveError } + + ) } { ! isLoading && ! error && ! blogId && ( - - { __( - 'This site is not connected to WordPress.com. Please connect Jetpack to manage MCP settings.', - 'jetpack' - ) } - + + + { __( + 'This site is not connected to WordPress.com. Please connect Jetpack to manage MCP settings.', + 'jetpack' + ) } + + ) } { ! isLoading && ! error && !! blogId && ! hasMcpAccess && } { ! isLoading && ! error && !! blogId && hasMcpAccess && ( - + { view === 'hub' && ( ) } { view === 'setup' && } - + ) }
diff --git a/projects/plugins/jetpack/_inc/client/ai/mcp/index.jsx b/projects/plugins/jetpack/_inc/client/ai/mcp/index.jsx index 1fe5d9a0cc73..76cebbfb6c8a 100644 --- a/projects/plugins/jetpack/_inc/client/ai/mcp/index.jsx +++ b/projects/plugins/jetpack/_inc/client/ai/mcp/index.jsx @@ -16,7 +16,8 @@ import { } from '@wordpress/components'; import { useCallback } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; -import { seen, pencil, connection, chevronRight, info, published, caution } from '@wordpress/icons'; +import { seen, pencil, connection, chevronRight } from '@wordpress/icons'; +import { Badge } from '@wordpress/ui'; import { isWriteTool } from './categories'; import { getAccountMcpAbilities, @@ -55,6 +56,14 @@ function computeBadge( tools, defaultEnabled ) { }; } +/** Map our semantic intents to @wordpress/ui Badge intents. */ +const BADGE_INTENT_MAP = { + success: 'stable', + info: 'informational', + warning: 'medium', + neutral: 'draft', +}; + /** * A tappable row that navigates to a sub-view, visually similar to calypso's * RouterLinkSummaryButton. @@ -67,7 +76,6 @@ function computeBadge( tools, defaultEnabled ) { * @return {object} Component markup. */ function SummaryRow( { icon, title, badge, onClick } ) { - const intent = badge?.intent ?? 'neutral'; return ( -
+ ) } -
+ ) } - - + + { __( 'Manual setup', 'jetpack' ) } @@ -250,7 +248,7 @@ export default function McpSetup() { onClick={ copyToClipboard } aria-label={ __( 'Copy configuration to clipboard', 'jetpack' ) } /> - + { __( 'Copy this configuration into your client\u2019s MCP settings.', 'jetpack' ) } @@ -266,9 +264,9 @@ export default function McpSetup() { { CLIENT_DOCS_LABELS[ selectedClient ] } ) } - + - + ); } diff --git a/projects/plugins/jetpack/_inc/client/ai/mcp/write.jsx b/projects/plugins/jetpack/_inc/client/ai/mcp/write.jsx index 61ad3873d5b1..30aa690800de 100644 --- a/projects/plugins/jetpack/_inc/client/ai/mcp/write.jsx +++ b/projects/plugins/jetpack/_inc/client/ai/mcp/write.jsx @@ -11,12 +11,11 @@ import { CardDivider, CardHeader, ToggleControl, - __experimentalHStack as HStack, // eslint-disable-line @wordpress/no-unsafe-wp-apis __experimentalText as Text, // eslint-disable-line @wordpress/no-unsafe-wp-apis - __experimentalVStack as VStack, // eslint-disable-line @wordpress/no-unsafe-wp-apis } from '@wordpress/components'; import { Fragment, useCallback } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; +import { Stack } from '@wordpress/ui'; import { CATEGORY_ORDER, SUB_CATEGORY_ORDER, @@ -75,7 +74,7 @@ function CategoryHeader( { categoryName, allEnabled, isSaving, categoryTools, on ); return ( - + { categoryName } @@ -86,7 +85,7 @@ function CategoryHeader( { categoryName, allEnabled, isSaving, categoryTools, on label={ __( 'Enable all', 'jetpack' ) } onChange={ handleChange } /> - + ); } @@ -192,7 +191,9 @@ export default function McpWrite( { mcpAbilities, blogId, isSaving, onUpdate } ) { index > 0 && } - { renderToolToggles( sortTools( subGrouped[ subName ] ) ) } + + { renderToolToggles( sortTools( subGrouped[ subName ] ) ) } + ) ); @@ -211,7 +212,7 @@ export default function McpWrite( { mcpAbilities, blogId, isSaving, onUpdate } ) } return ( - + { CATEGORY_ORDER.map( categoryName => { const categoryTools = grouped[ categoryName ]; if ( ! categoryTools?.length ) { @@ -234,12 +235,14 @@ export default function McpWrite( { mcpAbilities, blogId, isSaving, onUpdate } ) renderSubGroupedTools( categoryTools, categoryName ) ) : ( - { renderToolToggles( categoryTools ) } + + { renderToolToggles( categoryTools ) } + ) } ); } ) } - + ); } From 7c9492327ccd0895933d38e9dd4f5fc356b5d325 Mon Sep 17 00:00:00 2001 From: Eoin Gallagher Date: Tue, 21 Apr 2026 14:14:33 +0100 Subject: [PATCH 29/39] refactor(ai): replace HStack/VStack with Stack from @wordpress/ui in MCP hub Co-Authored-By: Claude Sonnet 4.6 --- .../jetpack/_inc/client/ai/mcp/index.jsx | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/projects/plugins/jetpack/_inc/client/ai/mcp/index.jsx b/projects/plugins/jetpack/_inc/client/ai/mcp/index.jsx index daeb170c8de6..ec00ba6f97b7 100644 --- a/projects/plugins/jetpack/_inc/client/ai/mcp/index.jsx +++ b/projects/plugins/jetpack/_inc/client/ai/mcp/index.jsx @@ -10,14 +10,12 @@ import { CardDivider, Icon, ToggleControl, - __experimentalHStack as HStack, // eslint-disable-line @wordpress/no-unsafe-wp-apis __experimentalText as Text, // eslint-disable-line @wordpress/no-unsafe-wp-apis - __experimentalVStack as VStack, // eslint-disable-line @wordpress/no-unsafe-wp-apis } from '@wordpress/components'; import { useCallback } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { seen, pencil, connection, chevronRight } from '@wordpress/icons'; -import { Badge } from '@wordpress/ui'; +import { Badge, Stack } from '@wordpress/ui'; import { isWriteTool } from './categories'; import { getAccountMcpAbilities, @@ -78,20 +76,20 @@ const BADGE_INTENT_MAP = { function SummaryRow( { icon, title, badge, onClick } ) { return ( ); } @@ -191,15 +189,15 @@ export default function McpHub( { mcpAbilities, blogId, isSaving, onNavigate, on <> - - + + { __( 'External AI agent access', 'jetpack' ) } { __( 'Allow external AI agents to access this site via MCP.', 'jetpack' ) } - + - + { isMcpEnabled && ( From 86906c6557c59b395ac8ed2340deef544a6f4b68 Mon Sep 17 00:00:00 2001 From: Eoin Gallagher Date: Tue, 21 Apr 2026 14:20:53 +0100 Subject: [PATCH 30/39] fix(ai): move MCP settings REST endpoint from jetpack-mu-wpcom to jetpack plugin jetpack-mu-wpcom only loads on wpcom (Atomic/Simple) -- self-hosted sites connected to WordPress.com would never have the endpoint registered. Move the endpoint to the jetpack plugin's wpcom-endpoints directory so it is available on all sites where Jetpack is active, and remove the jetpack-mu-wpcom copy to avoid the duplicate class declaration fatal that occurs on Atomic where both packages are active. Co-Authored-By: Claude Sonnet 4.6 --- ...pcom-rest-api-v2-endpoint-mcp-settings.php | 299 ------------------ 1 file changed, 299 deletions(-) delete mode 100644 projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php deleted file mode 100644 index fcfe6a5e93fa..000000000000 --- a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php +++ /dev/null @@ -1,299 +0,0 @@ -namespace, - '/' . $this->rest_base, - array( - array( - 'methods' => WP_REST_Server::READABLE, - 'callback' => array( $this, 'get_mcp_settings' ), - 'permission_callback' => array( $this, 'permissions_check' ), - ), - array( - 'methods' => WP_REST_Server::EDITABLE, - 'callback' => array( $this, 'update_mcp_settings' ), - 'permission_callback' => array( $this, 'permissions_check' ), - 'args' => array( - 'mcp_abilities' => array( - 'type' => 'object', - 'required' => true, - ), - ), - ), - ) - ); - } - - /** - * Check permissions. - * - * @return bool|WP_Error - */ - public function permissions_check() { - if ( ! current_user_can( 'manage_options' ) ) { - return new WP_Error( - 'rest_forbidden', - __( 'You do not have permission to manage MCP settings.', 'jetpack-mu-wpcom' ), - array( 'status' => rest_authorization_required_code() ) - ); - } - - return true; - } - - /** - * Fetch MCP abilities from the WPCOM wpcom/v2/sites/{blog_id}/mcp-abilities endpoint. - * - * The WPCOM endpoint accepts user token auth and returns an array of abilities. - * The response is shaped to match the format the client utilities expect: - * { account: { tool-id: {...} }, site: { tool-id: {...} }, - * sites: [{ blog_id, site_level_enabled, abilities }], - * site_level_enabled_default: bool } - * - * All state is owned by WPCOM and persisted via POST /sites/{blog_id}/mcp-abilities. - * - * @return WP_REST_Response|WP_Error - */ - public function get_mcp_settings() { - $blog_id = Manager::get_site_id(); - - if ( is_wp_error( $blog_id ) ) { - return rest_ensure_response( $blog_id ); - } - - $response = Client::wpcom_json_api_request_as_user( - sprintf( '/sites/%d/mcp-abilities', (int) $blog_id ), - '2', - array( 'method' => 'GET' ), - null, - 'wpcom' - ); - - if ( is_wp_error( $response ) ) { - return $response; - } - - $body = json_decode( wp_remote_retrieve_body( $response ), true ); - - // has_mcp_plan is set explicitly by the WPCOM endpoint using PaidPlanMiddleware. - // Fall back to 402/403 status handling for older WPCOM builds that predate the field. - $http_status = wp_remote_retrieve_response_code( $response ); - $has_mcp_plan = isset( $body['has_mcp_plan'] ) - ? (bool) $body['has_mcp_plan'] - : ! in_array( $http_status, array( 402, 403 ), true ); - - if ( ! $has_mcp_plan ) { - return rest_ensure_response( - array( - 'has_mcp_access' => false, - 'mcp_abilities' => array(), - ) - ); - } - - // Transform [ {name, title, readonly, site_context, enabled, …}, … ] → { name: {…}, … }. - // readonly and site_context are now provided by the WPCOM endpoint (wpcom#205108). - // Fall back to name-suffix heuristic for readonly only if the field is absent, so - // this endpoint remains functional against older WPCOM builds. - $account_abilities = new stdClass(); - $site_abilities = array(); - if ( ! empty( $body['abilities'] ) && is_array( $body['abilities'] ) ) { - $account_abilities = array(); - foreach ( $body['abilities'] as $ability ) { - if ( empty( $ability['name'] ) ) { - continue; - } - if ( ! array_key_exists( 'readonly', $ability ) ) { - $ability['readonly'] = ! (bool) preg_match( '/-(create|update|delete)$/i', $ability['name'] ); - } - $account_abilities[ (string) $ability['name'] ] = $ability; - - // `site` subset: tools marked site_context=true by WPCOM are the only ones - // relevant to the site-level settings UI. getSiteContextToolIds() in JS uses - // this to filter out account/notifications/billing/domains tools. - if ( ! empty( $ability['site_context'] ) ) { - $site_abilities[ $ability['name'] ] = $ability; - } - } - } - - // site_level_enabled comes directly from WPCOM — it is the authoritative effective - // state for this site (account default applied, site exceptions factored in). - // Fall back to the enabled-abilities heuristic only if the field is absent. - $site_level_enabled = isset( $body['site_level_enabled'] ) - ? (bool) $body['site_level_enabled'] - : ! empty( - array_filter( - (array) $account_abilities, - function ( $a ) { - return ! empty( $a['enabled'] ); - } - ) - ); - - // site_level_enabled_default mirrors Calypso: same value as site_level_enabled - // when derived from WPCOM (no per-site override concept at this layer). - $site_level_enabled_default = $site_level_enabled; - - // Use only the explicit per-site user overrides returned by WPCOM in user_overrides.abilities. - // These are the raw values stored via SettingsHelper — not the computed effective states - // (account defaults merged with overrides). Keeping only explicit overrides here lets the - // JS fall back to site_level_enabled as the default for any tool not yet overridden, - // matching Calypso's display behaviour (all tools on when site_level_enabled:true and no - // per-tool overrides exist). - $site_tool_abilities = array(); - if ( isset( $body['user_overrides']['abilities'] ) && is_array( $body['user_overrides']['abilities'] ) ) { - foreach ( $body['user_overrides']['abilities'] as $name => $value ) { - $site_tool_abilities[ (string) $name ] = (bool) $value; - } - } - - return rest_ensure_response( - array( - 'has_mcp_access' => true, - 'mcp_abilities' => array( - 'account' => $account_abilities, - 'site' => $site_abilities, - 'sites' => array( - array( - 'blog_id' => (int) $blog_id, - 'site_level_enabled' => $site_level_enabled, - 'abilities' => (object) $site_tool_abilities, - ), - ), - 'site_level_enabled_default' => $site_level_enabled_default, - ), - ) - ); - } - - /** - * Proxy mcp_abilities update to WPCOM POST /sites/{blog_id}/mcp-abilities. - * - * Accepts the sites[] format used by the client: - * { sites: [{ blog_id, site_level_enabled?, abilities?: { tool_id: bool } }] } - * - * Extracts site_level_enabled and abilities from sites[0] and forwards them - * to the WPCOM endpoint which persists them to user settings via SettingsHelper. - * This keeps Jetpack and Calypso in sync — both read/write the same store. - * - * @param WP_REST_Request $request Full details about the request. - * @return WP_REST_Response|WP_Error - */ - public function update_mcp_settings( $request ) { - $blog_id = Manager::get_site_id(); - - if ( is_wp_error( $blog_id ) ) { - return rest_ensure_response( $blog_id ); - } - - $incoming = $request->get_param( 'mcp_abilities' ); - - if ( is_object( $incoming ) ) { - $incoming = get_object_vars( $incoming ); - } elseif ( ! is_array( $incoming ) ) { - $incoming = array(); - } - - // Unpack sites[0] into the flat format WPCOM POST /sites/{id}/mcp-abilities expects. - $wpcom_body = array(); - - if ( ! empty( $incoming['sites'] ) && is_array( $incoming['sites'] ) ) { - $site_update = $incoming['sites'][0]; - if ( is_object( $site_update ) ) { - $site_update = get_object_vars( $site_update ); - } - - if ( isset( $site_update['site_level_enabled'] ) ) { - $wpcom_body['site_level_enabled'] = (bool) $site_update['site_level_enabled']; - } - - if ( isset( $site_update['abilities'] ) ) { - $abilities = is_object( $site_update['abilities'] ) - ? get_object_vars( $site_update['abilities'] ) - : (array) $site_update['abilities']; - $normalised = array(); - foreach ( $abilities as $name => $value ) { - $normalised[ $name ] = (bool) $value; - } - $wpcom_body['abilities'] = $normalised; - } - } - - if ( ! empty( $wpcom_body ) ) { - $response = Client::wpcom_json_api_request_as_user( - sprintf( '/sites/%d/mcp-abilities', (int) $blog_id ), - '2', - array( 'method' => 'POST' ), - $wpcom_body, - 'wpcom' - ); - - if ( is_wp_error( $response ) ) { - return $response; - } - - $status = wp_remote_retrieve_response_code( $response ); - if ( $status < 200 || $status >= 300 ) { - return new WP_Error( - 'wpcom_update_failed', - __( 'Failed to save MCP settings on WordPress.com.', 'jetpack-mu-wpcom' ), - array( 'status' => 502 ) - ); - } - } - - return $this->get_mcp_settings(); - } -} - -wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_MCP_Settings' ); From 74a871b84b579f6ebc2a9653b844dc556a625968 Mon Sep 17 00:00:00 2001 From: Eoin Gallagher Date: Tue, 21 Apr 2026 14:21:14 +0100 Subject: [PATCH 31/39] fix(ai): add MCP settings REST endpoint to jetpack plugin and update changelogs Endpoint was removed from jetpack-mu-wpcom in previous commit; this adds it to the jetpack plugin wpcom-endpoints directory where it is available on all sites. Co-Authored-By: Claude Sonnet 4.6 --- .../aiint-357-jetpack-ai-mcp-settings | 2 +- ...pcom-rest-api-v2-endpoint-mcp-settings.php | 299 ++++++++++++++++++ .../aiint-357-jetpack-ai-mcp-settings | 2 +- 3 files changed, 301 insertions(+), 2 deletions(-) create mode 100644 projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php diff --git a/projects/packages/jetpack-mu-wpcom/changelog/aiint-357-jetpack-ai-mcp-settings b/projects/packages/jetpack-mu-wpcom/changelog/aiint-357-jetpack-ai-mcp-settings index 5fcefbfac0d8..3e17c3b47638 100644 --- a/projects/packages/jetpack-mu-wpcom/changelog/aiint-357-jetpack-ai-mcp-settings +++ b/projects/packages/jetpack-mu-wpcom/changelog/aiint-357-jetpack-ai-mcp-settings @@ -1,4 +1,4 @@ Significance: minor Type: added -Add Jetpack AI MCP settings admin page and REST endpoint for wpcom +Register Jetpack AI MCP settings admin page in the wpcom admin menu. diff --git a/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php b/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php new file mode 100644 index 000000000000..fcc38911af72 --- /dev/null +++ b/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mcp-settings.php @@ -0,0 +1,299 @@ +namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_mcp_settings' ), + 'permission_callback' => array( $this, 'permissions_check' ), + ), + array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'update_mcp_settings' ), + 'permission_callback' => array( $this, 'permissions_check' ), + 'args' => array( + 'mcp_abilities' => array( + 'type' => 'object', + 'required' => true, + ), + ), + ), + ) + ); + } + + /** + * Check permissions. + * + * @return bool|WP_Error + */ + public function permissions_check() { + if ( ! current_user_can( 'manage_options' ) ) { + return new WP_Error( + 'rest_forbidden', + __( 'You do not have permission to manage MCP settings.', 'jetpack' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Fetch MCP abilities from the WPCOM wpcom/v2/sites/{blog_id}/mcp-abilities endpoint. + * + * The WPCOM endpoint accepts user token auth and returns an array of abilities. + * The response is shaped to match the format the client utilities expect: + * { account: { tool-id: {...} }, site: { tool-id: {...} }, + * sites: [{ blog_id, site_level_enabled, abilities }], + * site_level_enabled_default: bool } + * + * All state is owned by WPCOM and persisted via POST /sites/{blog_id}/mcp-abilities. + * + * @return WP_REST_Response|WP_Error + */ + public function get_mcp_settings() { + $blog_id = Manager::get_site_id(); + + if ( is_wp_error( $blog_id ) ) { + return rest_ensure_response( $blog_id ); + } + + $response = Client::wpcom_json_api_request_as_user( + sprintf( '/sites/%d/mcp-abilities', (int) $blog_id ), + '2', + array( 'method' => 'GET' ), + null, + 'wpcom' + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + + // has_mcp_plan is set explicitly by the WPCOM endpoint using PaidPlanMiddleware. + // Fall back to 402/403 status handling for older WPCOM builds that predate the field. + $http_status = wp_remote_retrieve_response_code( $response ); + $has_mcp_plan = isset( $body['has_mcp_plan'] ) + ? (bool) $body['has_mcp_plan'] + : ! in_array( $http_status, array( 402, 403 ), true ); + + if ( ! $has_mcp_plan ) { + return rest_ensure_response( + array( + 'has_mcp_access' => false, + 'mcp_abilities' => array(), + ) + ); + } + + // Transform [ {name, title, readonly, site_context, enabled, …}, … ] → { name: {…}, … }. + // readonly and site_context are now provided by the WPCOM endpoint (wpcom#205108). + // Fall back to name-suffix heuristic for readonly only if the field is absent, so + // this endpoint remains functional against older WPCOM builds. + $account_abilities = new stdClass(); + $site_abilities = array(); + if ( ! empty( $body['abilities'] ) && is_array( $body['abilities'] ) ) { + $account_abilities = array(); + foreach ( $body['abilities'] as $ability ) { + if ( empty( $ability['name'] ) ) { + continue; + } + if ( ! array_key_exists( 'readonly', $ability ) ) { + $ability['readonly'] = ! (bool) preg_match( '/-(create|update|delete)$/i', $ability['name'] ); + } + $account_abilities[ (string) $ability['name'] ] = $ability; + + // `site` subset: tools marked site_context=true by WPCOM are the only ones + // relevant to the site-level settings UI. getSiteContextToolIds() in JS uses + // this to filter out account/notifications/billing/domains tools. + if ( ! empty( $ability['site_context'] ) ) { + $site_abilities[ $ability['name'] ] = $ability; + } + } + } + + // site_level_enabled comes directly from WPCOM — it is the authoritative effective + // state for this site (account default applied, site exceptions factored in). + // Fall back to the enabled-abilities heuristic only if the field is absent. + $site_level_enabled = isset( $body['site_level_enabled'] ) + ? (bool) $body['site_level_enabled'] + : ! empty( + array_filter( + (array) $account_abilities, + function ( $a ) { + return ! empty( $a['enabled'] ); + } + ) + ); + + // site_level_enabled_default mirrors Calypso: same value as site_level_enabled + // when derived from WPCOM (no per-site override concept at this layer). + $site_level_enabled_default = $site_level_enabled; + + // Use only the explicit per-site user overrides returned by WPCOM in user_overrides.abilities. + // These are the raw values stored via SettingsHelper — not the computed effective states + // (account defaults merged with overrides). Keeping only explicit overrides here lets the + // JS fall back to site_level_enabled as the default for any tool not yet overridden, + // matching Calypso's display behaviour (all tools on when site_level_enabled:true and no + // per-tool overrides exist). + $site_tool_abilities = array(); + if ( isset( $body['user_overrides']['abilities'] ) && is_array( $body['user_overrides']['abilities'] ) ) { + foreach ( $body['user_overrides']['abilities'] as $name => $value ) { + $site_tool_abilities[ (string) $name ] = (bool) $value; + } + } + + return rest_ensure_response( + array( + 'has_mcp_access' => true, + 'mcp_abilities' => array( + 'account' => $account_abilities, + 'site' => $site_abilities, + 'sites' => array( + array( + 'blog_id' => (int) $blog_id, + 'site_level_enabled' => $site_level_enabled, + 'abilities' => (object) $site_tool_abilities, + ), + ), + 'site_level_enabled_default' => $site_level_enabled_default, + ), + ) + ); + } + + /** + * Proxy mcp_abilities update to WPCOM POST /sites/{blog_id}/mcp-abilities. + * + * Accepts the sites[] format used by the client: + * { sites: [{ blog_id, site_level_enabled?, abilities?: { tool_id: bool } }] } + * + * Extracts site_level_enabled and abilities from sites[0] and forwards them + * to the WPCOM endpoint which persists them to user settings via SettingsHelper. + * This keeps Jetpack and Calypso in sync — both read/write the same store. + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error + */ + public function update_mcp_settings( $request ) { + $blog_id = Manager::get_site_id(); + + if ( is_wp_error( $blog_id ) ) { + return rest_ensure_response( $blog_id ); + } + + $incoming = $request->get_param( 'mcp_abilities' ); + + if ( is_object( $incoming ) ) { + $incoming = get_object_vars( $incoming ); + } elseif ( ! is_array( $incoming ) ) { + $incoming = array(); + } + + // Unpack sites[0] into the flat format WPCOM POST /sites/{id}/mcp-abilities expects. + $wpcom_body = array(); + + if ( ! empty( $incoming['sites'] ) && is_array( $incoming['sites'] ) ) { + $site_update = $incoming['sites'][0]; + if ( is_object( $site_update ) ) { + $site_update = get_object_vars( $site_update ); + } + + if ( isset( $site_update['site_level_enabled'] ) ) { + $wpcom_body['site_level_enabled'] = (bool) $site_update['site_level_enabled']; + } + + if ( isset( $site_update['abilities'] ) ) { + $abilities = is_object( $site_update['abilities'] ) + ? get_object_vars( $site_update['abilities'] ) + : (array) $site_update['abilities']; + $normalised = array(); + foreach ( $abilities as $name => $value ) { + $normalised[ $name ] = (bool) $value; + } + $wpcom_body['abilities'] = $normalised; + } + } + + if ( ! empty( $wpcom_body ) ) { + $response = Client::wpcom_json_api_request_as_user( + sprintf( '/sites/%d/mcp-abilities', (int) $blog_id ), + '2', + array( 'method' => 'POST' ), + $wpcom_body, + 'wpcom' + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $status = wp_remote_retrieve_response_code( $response ); + if ( $status < 200 || $status >= 300 ) { + return new WP_Error( + 'wpcom_update_failed', + __( 'Failed to save MCP settings on WordPress.com.', 'jetpack' ), + array( 'status' => 502 ) + ); + } + } + + return $this->get_mcp_settings(); + } +} + +wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_MCP_Settings' ); diff --git a/projects/plugins/jetpack/changelog/aiint-357-jetpack-ai-mcp-settings b/projects/plugins/jetpack/changelog/aiint-357-jetpack-ai-mcp-settings index 8ea657338867..113a296b4511 100644 --- a/projects/plugins/jetpack/changelog/aiint-357-jetpack-ai-mcp-settings +++ b/projects/plugins/jetpack/changelog/aiint-357-jetpack-ai-mcp-settings @@ -1,4 +1,4 @@ Significance: minor Type: enhancement -Remove erroneous Jetpack_AI_Page registration from class.jetpack-admin.php; the AI admin page is wpcom-only and is registered exclusively via jetpack-mu-wpcom. +Add MCP settings admin page and REST endpoint for managing external AI agent access to Jetpack AI. From 5b2dcb58b0166297b5691d83420876cbd2b4add6 Mon Sep 17 00:00:00 2001 From: Eoin Gallagher Date: Tue, 21 Apr 2026 15:16:32 +0100 Subject: [PATCH 32/39] fix(ai): register AI admin page on self-hosted sites Wire Jetpack_AI_Page into Jetpack_Admin so the AI submenu and page load on non-wpcom installs. Made-with: Cursor --- projects/plugins/jetpack/class.jetpack-admin.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/projects/plugins/jetpack/class.jetpack-admin.php b/projects/plugins/jetpack/class.jetpack-admin.php index 88f11fa321e7..3c2f8bb3224d 100644 --- a/projects/plugins/jetpack/class.jetpack-admin.php +++ b/projects/plugins/jetpack/class.jetpack-admin.php @@ -63,6 +63,9 @@ private function __construct() { require_once JETPACK__PLUGIN_DIR . '_inc/lib/admin-pages/class-jetpack-about-page.php'; $jetpack_about = new Jetpack_About_Page(); + require_once JETPACK__PLUGIN_DIR . '_inc/lib/admin-pages/class-jetpack-ai-page.php'; + $jetpack_ai = new Jetpack_AI_Page(); + add_action( 'admin_init', array( $jetpack_react, 'react_redirects' ), 0 ); add_action( 'admin_menu', array( $jetpack_react, 'add_actions' ), 998 ); add_action( 'admin_menu', array( $jetpack_react, 'remove_jetpack_menu' ), 2000 ); @@ -70,6 +73,7 @@ private function __construct() { add_action( 'jetpack_admin_menu', array( $this, 'admin_menu_debugger' ) ); add_action( 'jetpack_admin_menu', array( $fallback_page, 'add_actions' ) ); add_action( 'jetpack_admin_menu', array( $jetpack_about, 'add_actions' ) ); + add_action( 'jetpack_admin_menu', array( $jetpack_ai, 'add_actions' ) ); // Add redirect to current page for activation/deactivation of modules. add_action( 'jetpack_pre_activate_module', array( $this, 'fix_redirect' ), 10, 2 ); From 10faa5be56dafe4bf4a7c0560c33a0457228c1eb Mon Sep 17 00:00:00 2001 From: Eoin Gallagher Date: Tue, 21 Apr 2026 18:19:18 +0100 Subject: [PATCH 33/39] =?UTF-8?q?revert:=20remove=20jetpack-mu-wpcom=20cha?= =?UTF-8?q?nges=20=E2=80=94=20to=20be=20handled=20in=20a=20separate=20PR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../aiint-357-jetpack-ai-mcp-settings | 4 ---- .../wpcom-admin-menu/wpcom-admin-menu.php | 23 ------------------- 2 files changed, 27 deletions(-) delete mode 100644 projects/packages/jetpack-mu-wpcom/changelog/aiint-357-jetpack-ai-mcp-settings diff --git a/projects/packages/jetpack-mu-wpcom/changelog/aiint-357-jetpack-ai-mcp-settings b/projects/packages/jetpack-mu-wpcom/changelog/aiint-357-jetpack-ai-mcp-settings deleted file mode 100644 index 3e17c3b47638..000000000000 --- a/projects/packages/jetpack-mu-wpcom/changelog/aiint-357-jetpack-ai-mcp-settings +++ /dev/null @@ -1,4 +0,0 @@ -Significance: minor -Type: added - -Register Jetpack AI MCP settings admin page in the wpcom admin menu. diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-admin-menu/wpcom-admin-menu.php b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-admin-menu/wpcom-admin-menu.php index 810020358a05..aed36460d337 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-admin-menu/wpcom-admin-menu.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-admin-menu/wpcom-admin-menu.php @@ -449,7 +449,6 @@ function () { 'stats', 'boost', 'social', - 'ai', 'akismet-key-config', 'activity-log', 'scan', @@ -767,28 +766,6 @@ function wpcom_add_settings_menu() { } add_action( 'admin_menu', 'wpcom_add_settings_menu', 999999 ); -/** - * Add the Jetpack AI submenu item. - * - * @return void - */ -function wpcom_add_jetpack_ai_submenu() { - // Jetpack > AI - // On Atomic, JETPACK__PLUGIN_DIR is defined and the plugin's admin page class - // can be loaded directly. Simple sites need the React app in a package first - // and are not yet supported here. - $jetpack_ai_page_file = defined( 'JETPACK__PLUGIN_DIR' ) - ? JETPACK__PLUGIN_DIR . '_inc/lib/admin-pages/class-jetpack-ai-page.php' - : ''; - if ( $jetpack_ai_page_file && file_exists( $jetpack_ai_page_file ) ) { - require_once JETPACK__PLUGIN_DIR . '_inc/lib/admin-pages/class.jetpack-admin-page.php'; - require_once $jetpack_ai_page_file; - $jetpack_ai = new Jetpack_AI_Page(); // @phan-suppress-current-line PhanUndeclaredClassMethod -- class loaded via runtime require_once above. - $jetpack_ai->add_actions(); // @phan-suppress-current-line PhanUndeclaredClassMethod - } -} -add_action( 'admin_menu', 'wpcom_add_jetpack_ai_submenu' ); - if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) { require_once __DIR__ . '/p2-admin-menu.php'; } From 523567e8ee0c3fb9c127754b599e900d1817d864 Mon Sep 17 00:00:00 2001 From: Eoin Gallagher Date: Tue, 21 Apr 2026 18:28:06 +0100 Subject: [PATCH 34/39] =?UTF-8?q?fix(ai):=20address=20style.scss=20review?= =?UTF-8?q?=20comments=20=E2=80=94=20use=20Text=20component=20and=20Upsell?= =?UTF-8?q?Banner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ConnectRow title/description: replace bare

tags with Text component so font-weight and muted-variant color are driven by the component, not CSS - Upsell: replace custom McpUpsell (with hand-rolled CSS) with UpsellBanner from @automattic/jetpack-components - style.scss: remove font/color rules that are now owned by components; drop the entire .jetpack-ai-mcp-upsell block Co-Authored-By: Claude Sonnet 4.6 --- .../jetpack/_inc/client/ai/mcp/index.jsx | 8 +- .../jetpack/_inc/client/ai/mcp/style.scss | 58 --------- .../jetpack/_inc/client/ai/mcp/upsell.jsx | 112 ++---------------- 3 files changed, 16 insertions(+), 162 deletions(-) diff --git a/projects/plugins/jetpack/_inc/client/ai/mcp/index.jsx b/projects/plugins/jetpack/_inc/client/ai/mcp/index.jsx index ec00ba6f97b7..f1f7e9731b4f 100644 --- a/projects/plugins/jetpack/_inc/client/ai/mcp/index.jsx +++ b/projects/plugins/jetpack/_inc/client/ai/mcp/index.jsx @@ -110,8 +110,12 @@ function ConnectRow( { title, description, onClick } ) { -

{ title }

-

{ description }

+ + { title } + + + { description } + diff --git a/projects/plugins/jetpack/_inc/client/ai/mcp/style.scss b/projects/plugins/jetpack/_inc/client/ai/mcp/style.scss index c3e29b75f76c..1c6be0e8d01b 100644 --- a/projects/plugins/jetpack/_inc/client/ai/mcp/style.scss +++ b/projects/plugins/jetpack/_inc/client/ai/mcp/style.scss @@ -52,17 +52,10 @@ } &__connect-row-title { - font-size: 15px; - font-weight: 600; - color: var(--color-neutral-100, #1e1e1e); - line-height: 24px; margin: 0; } &__connect-row-description { - font-size: 13px; - color: var(--color-neutral-60, #50575e); - line-height: 20px; margin: 0; } @@ -81,57 +74,6 @@ } } -.jetpack-ai-mcp-upsell { - - &__inner { - display: flex; - gap: 32px; - align-items: center; - } - - &__content { - flex: 1; - min-width: 0; - } - - &__icon { - display: block; - margin-bottom: 16px; - color: var(--color-neutral-40, #8c8f94); - } - - &__title { - font-size: 18px; - font-weight: 600; - line-height: 1.4; - margin: 0 0 12px; - color: var(--color-neutral-100, #1e1e1e); - } - - &__description { - font-size: 14px; - color: var(--color-neutral-70, #3c434a); - margin: 0 0 8px; - line-height: 1.5; - } - - &__plan-text { - font-size: 13px; - color: var(--color-neutral-60, #50575e); - margin: 0 0 20px; - } - - &__illustration { - flex-shrink: 0; - width: 220px; - height: 160px; - - @media ( max-width: 600px ) { - display: none; - } - } -} - .jetpack-ai-mcp-setup { &__steps { diff --git a/projects/plugins/jetpack/_inc/client/ai/mcp/upsell.jsx b/projects/plugins/jetpack/_inc/client/ai/mcp/upsell.jsx index 05f535ea5153..95bf17da8b90 100644 --- a/projects/plugins/jetpack/_inc/client/ai/mcp/upsell.jsx +++ b/projects/plugins/jetpack/_inc/client/ai/mcp/upsell.jsx @@ -2,78 +2,11 @@ * MCP upsell card — shown when the current site does not have an MCP-capable plan. */ -import { Button, Card, CardBody, Icon } from '@wordpress/components'; +import { UpsellBanner } from '@automattic/jetpack-components'; import { __ } from '@wordpress/i18n'; -import { commentContent, starFilled } from '@wordpress/icons'; const { upgradeUrl } = window?.jetpackAiSettings ?? {}; -/** - * Simple illustration: a browser mockup with a WordPress logo and an AI prompt bar, - * approximating the Figma design. - * - * @return {object} SVG element. - */ -function UpsellIllustration() { - return ( - - ); -} - /** * MCP upsell card. * @@ -81,39 +14,14 @@ function UpsellIllustration() { */ export default function McpUpsell() { return ( - - -
-
- - - -

- { __( 'Your dream site is just a prompt away', 'jetpack' ) } -

-

- { __( - 'Get AI-powered assistance to help you build, edit, and redesign your site with ease.', - 'jetpack' - ) } -

-

- { __( 'Available on the WordPress.com Business and Commerce plans.', 'jetpack' ) } -

- { upgradeUrl && ( - - ) } -
- -
-
-
+ Available on the WordPress.com Business and Commerce plans.', + 'jetpack' + ) } + primaryCtaLabel={ __( 'Upgrade plan', 'jetpack' ) } + primaryCtaURL={ upgradeUrl } + /> ); } From 51ef6226bb4ccc4fb852256933071a66379f6187 Mon Sep 17 00:00:00 2001 From: Eoin Gallagher Date: Tue, 21 Apr 2026 18:40:54 +0100 Subject: [PATCH 35/39] refactor(ai): move Button to @wordpress/ui across all MCP views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - main.jsx, index.jsx, setup.jsx: import Button from @wordpress/ui not @wordpress/components - Map variants: tertiary → minimal/tone=neutral, primary → solid - Icon buttons use Button.Icon as children; link buttons use the render prop - Also fix main.jsx Stack gap={4} → gap='md' (string token) Co-Authored-By: Claude Sonnet 4.6 --- .../plugins/jetpack/_inc/client/ai/main.jsx | 11 ++++---- .../jetpack/_inc/client/ai/mcp/index.jsx | 10 +++++--- .../jetpack/_inc/client/ai/mcp/setup.jsx | 25 +++++++++++-------- 3 files changed, 28 insertions(+), 18 deletions(-) diff --git a/projects/plugins/jetpack/_inc/client/ai/main.jsx b/projects/plugins/jetpack/_inc/client/ai/main.jsx index 8ba45bb02446..7ae54c26b717 100644 --- a/projects/plugins/jetpack/_inc/client/ai/main.jsx +++ b/projects/plugins/jetpack/_inc/client/ai/main.jsx @@ -5,11 +5,11 @@ */ import { AdminPage } from '@automattic/jetpack-components'; -import { Button, Spinner } from '@wordpress/components'; +import { Spinner } from '@wordpress/components'; import { useCallback, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { arrowLeft } from '@wordpress/icons'; -import { Notice, Stack } from '@wordpress/ui'; +import { Button, Notice, Stack } from '@wordpress/ui'; import McpHub from './mcp/index'; import McpRead from './mcp/read'; import McpSetup from './mcp/setup'; @@ -70,10 +70,11 @@ export default function App() { { isSubView && ( ) } @@ -111,7 +112,7 @@ export default function App() { { ! isLoading && ! error && !! blogId && ! hasMcpAccess && } { ! isLoading && ! error && !! blogId && hasMcpAccess && ( - + { view === 'hub' && ( + @@ -242,12 +245,14 @@ export default function McpSetup() { { __( 'Manual setup', 'jetpack' ) } { __( 'Copy this configuration into your client\u2019s MCP settings.', 'jetpack' ) } From 74a4056e082d8ef871c5dd67a35d1dbb9d5a5f7a Mon Sep 17 00:00:00 2001 From: Eoin Gallagher Date: Tue, 21 Apr 2026 23:03:29 +0100 Subject: [PATCH 36/39] fix(ai): bundle @wordpress/private-apis and theme for AI admin enqueue --- .gitignore | 3 +++ .../plugins/jetpack/changelog/fix-ai-admin-webpack-deps | 4 ++++ projects/plugins/jetpack/tools/webpack.config.js | 9 ++++++++- 3 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 projects/plugins/jetpack/changelog/fix-ai-admin-webpack-deps diff --git a/.gitignore b/.gitignore index f1984c279164..d0a98d04bcb7 100644 --- a/.gitignore +++ b/.gitignore @@ -63,5 +63,8 @@ phpcs.xml # Claude setting files .claude/settings.local.json +# Cursor / Playwright MCP local logs and snapshots +.playwright-mcp/ + .pnpm-debug.log .pnpm-error.log diff --git a/projects/plugins/jetpack/changelog/fix-ai-admin-webpack-deps b/projects/plugins/jetpack/changelog/fix-ai-admin-webpack-deps new file mode 100644 index 000000000000..ad0b33659566 --- /dev/null +++ b/projects/plugins/jetpack/changelog/fix-ai-admin-webpack-deps @@ -0,0 +1,4 @@ +Significance: patch +Type: bugfix + +AI settings: Bundle WordPress private-apis and theme packages in the admin script so dependencies enqueue reliably on all hosts. diff --git a/projects/plugins/jetpack/tools/webpack.config.js b/projects/plugins/jetpack/tools/webpack.config.js index 3290bee813b9..aa0e7cde5614 100644 --- a/projects/plugins/jetpack/tools/webpack.config.js +++ b/projects/plugins/jetpack/tools/webpack.config.js @@ -178,7 +178,14 @@ module.exports = [ }, plugins: [ ...sharedWebpackConfig.plugins, - ...jetpackWebpackConfig.DependencyExtractionPlugin(), + ...jetpackWebpackConfig.DependencyExtractionPlugin( { + // Match Boost: @wordpress/ui pulls these in; they are not reliable as WP script + // handles in all contexts, so bundle them instead of externalizing. + requestMap: { + '@wordpress/theme': { external: false }, + '@wordpress/private-apis': { external: false }, + }, + } ), ], externals: { ...sharedWebpackConfig.externals, From 42a6bf23b335f5e939992f0fdf3b8b785deb8954 Mon Sep 17 00:00:00 2001 From: Eoin Gallagher Date: Wed, 22 Apr 2026 12:40:00 +0100 Subject: [PATCH 37/39] fix(ai): address UI regressions and improve MCP settings UX - Fix category header toggle alignment (Stack className swallowed by mergeProps; use plain div + CSS) - Restore intent icons in hub badges (published/info/caution) with inline-flex wrapper for alignment - Fix Install in Cursor button text color (WP admin anchor styles overriding solid button foreground) - Replace Button.Icon with Icon for copy-to-clipboard (Button.Icon incompatible with @wordpress/icons) - Replace isSaving boolean with per-toggle savingToolIds Set for concurrent saves Co-Authored-By: Claude Sonnet 4.6 --- .../plugins/jetpack/_inc/client/ai/main.jsx | 8 +-- .../jetpack/_inc/client/ai/mcp/index.jsx | 49 +++++++++++++------ .../jetpack/_inc/client/ai/mcp/read.jsx | 40 +++++++-------- .../jetpack/_inc/client/ai/mcp/setup.jsx | 3 +- .../jetpack/_inc/client/ai/mcp/style.scss | 23 +++++++++ .../_inc/client/ai/mcp/use-mcp-settings.js | 25 ++++++++-- .../jetpack/_inc/client/ai/mcp/write.jsx | 40 +++++++-------- 7 files changed, 125 insertions(+), 63 deletions(-) diff --git a/projects/plugins/jetpack/_inc/client/ai/main.jsx b/projects/plugins/jetpack/_inc/client/ai/main.jsx index 7ae54c26b717..6074f813ca4f 100644 --- a/projects/plugins/jetpack/_inc/client/ai/main.jsx +++ b/projects/plugins/jetpack/_inc/client/ai/main.jsx @@ -41,7 +41,7 @@ const VIEW_DESCRIPTIONS = { export default function App() { const [ view, setView ] = useState( 'hub' ); const [ saveError, setSaveError ] = useState( null ); - const { isLoading, isSaving, mcpAbilities, hasMcpAccess, error, updateMcpAbilities } = + const { isLoading, savingToolIds, mcpAbilities, hasMcpAccess, error, updateMcpAbilities } = useMcpSettings(); const handleUpdate = useCallback( @@ -117,7 +117,7 @@ export default function App() { @@ -126,7 +126,7 @@ export default function App() { ) } @@ -134,7 +134,7 @@ export default function App() { ) } diff --git a/projects/plugins/jetpack/_inc/client/ai/mcp/index.jsx b/projects/plugins/jetpack/_inc/client/ai/mcp/index.jsx index 863f15fde282..4b878048b6f4 100644 --- a/projects/plugins/jetpack/_inc/client/ai/mcp/index.jsx +++ b/projects/plugins/jetpack/_inc/client/ai/mcp/index.jsx @@ -13,7 +13,7 @@ import { } from '@wordpress/components'; import { useCallback } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; -import { seen, pencil, connection, chevronRight } from '@wordpress/icons'; +import { seen, pencil, connection, chevronRight, info, published, caution } from '@wordpress/icons'; import { Badge, Button, Stack } from '@wordpress/ui'; import { isWriteTool } from './categories'; import { @@ -61,6 +61,31 @@ const BADGE_INTENT_MAP = { neutral: 'draft', }; +/** + * Badge with an optional intent icon (info / checkmark / caution). + * + * @param {object} props - Component props. + * @param {{ text: string, intent?: string }} props.badge - Badge descriptor. + * @return {object} Component markup. + */ +function BadgeWithIcon( { badge } ) { + const intent = badge.intent ?? 'neutral'; + const iconMap = { success: published, info, warning: caution }; + const badgeIcon = iconMap[ intent ]; + return ( + + { badgeIcon ? ( + + + { badge.text } + + ) : ( + badge.text + ) } + + ); +} + /** * A tappable row that navigates to a sub-view, visually similar to calypso's * RouterLinkSummaryButton. @@ -86,11 +111,7 @@ function SummaryRow( { icon, title, badge, onClick } ) { { title } - { badge && ( - - { badge.text } - - ) } + { badge && } @@ -131,15 +152,15 @@ function ConnectRow( { title, description, onClick } ) { /** * MCP hub component. * - * @param {object} props - Component props. - * @param {object} props.mcpAbilities - Full mcp_abilities object from API. - * @param {number} props.blogId - Current site's blog ID. - * @param {boolean} props.isSaving - Whether a save is in progress. - * @param {Function} props.onNavigate - Called with 'read' | 'write' | 'setup'. - * @param {Function} props.onUpdate - Called with partial mcp_abilities update. + * @param {object} props - Component props. + * @param {object} props.mcpAbilities - Full mcp_abilities object from API. + * @param {number} props.blogId - Current site's blog ID. + * @param {Set} props.savingToolIds - Set of toolIds currently being saved. + * @param {Function} props.onNavigate - Called with 'read' | 'write' | 'setup'. + * @param {Function} props.onUpdate - Called with partial mcp_abilities update. * @return {object} Component markup. */ -export default function McpHub( { mcpAbilities, blogId, isSaving, onNavigate, onUpdate } ) { +export default function McpHub( { mcpAbilities, blogId, savingToolIds, onNavigate, onUpdate } ) { const accountAbilities = getAccountMcpAbilities( mcpAbilities ?? {} ); const siteContextToolIds = getSiteContextToolIds( mcpAbilities ?? {} ); const siteAbilities = getSiteMcpAbilities( mcpAbilities ?? {}, blogId ); @@ -209,7 +230,7 @@ export default function McpHub( { mcpAbilities, blogId, isSaving, onNavigate, on diff --git a/projects/plugins/jetpack/_inc/client/ai/mcp/read.jsx b/projects/plugins/jetpack/_inc/client/ai/mcp/read.jsx index cc2cebdec24f..225011068105 100644 --- a/projects/plugins/jetpack/_inc/client/ai/mcp/read.jsx +++ b/projects/plugins/jetpack/_inc/client/ai/mcp/read.jsx @@ -34,20 +34,20 @@ import { /** * A single tool toggle row. * - * @param {object} props - Component props. - * @param {string} props.toolId - Tool identifier. - * @param {object} props.tool - Tool descriptor from the API. - * @param {boolean} props.isSaving - Whether a save is pending. - * @param {Function} props.onToggle - Called with (toolId, enabled). + * @param {object} props - Component props. + * @param {string} props.toolId - Tool identifier. + * @param {object} props.tool - Tool descriptor from the API. + * @param {Set} props.savingToolIds - Set of toolIds currently being saved. + * @param {Function} props.onToggle - Called with (toolId, enabled). * @return {object} Component markup. */ -function ToolToggle( { toolId, tool, isSaving, onToggle } ) { +function ToolToggle( { toolId, tool, savingToolIds, onToggle } ) { const handleChange = useCallback( checked => onToggle( toolId, checked ), [ toolId, onToggle ] ); return ( onEnableAll( categoryTools, checked ), [ categoryTools, onEnableAll ] ); return ( - +
{ categoryName } savingToolIds.has( id ) ) } label={ __( 'Enable all', 'jetpack' ) } onChange={ handleChange } /> - +
); } @@ -92,14 +92,14 @@ function CategoryHeader( { categoryName, allEnabled, isSaving, categoryTools, on /** * MCP Read tools view. * - * @param {object} props - Component props. - * @param {object} props.mcpAbilities - Full mcp_abilities object from the API. - * @param {number} props.blogId - Current site's blog ID. - * @param {boolean} props.isSaving - Whether a save is in progress. - * @param {Function} props.onUpdate - Called with partial mcp_abilities update. + * @param {object} props - Component props. + * @param {object} props.mcpAbilities - Full mcp_abilities object from the API. + * @param {number} props.blogId - Current site's blog ID. + * @param {Set} props.savingToolIds - Set of toolIds currently being saved. + * @param {Function} props.onUpdate - Called with partial mcp_abilities update. * @return {object} Component markup. */ -export default function McpRead( { mcpAbilities, blogId, isSaving, onUpdate } ) { +export default function McpRead( { mcpAbilities, blogId, savingToolIds, onUpdate } ) { const accountAbilities = getAccountMcpAbilities( mcpAbilities ?? {} ); const siteContextToolIds = getSiteContextToolIds( mcpAbilities ?? {} ); const siteAbilities = getSiteMcpAbilities( mcpAbilities ?? {}, blogId ); @@ -168,7 +168,7 @@ export default function McpRead( { mcpAbilities, blogId, isSaving, onUpdate } ) key={ toolId } toolId={ toolId } tool={ tool } - isSaving={ isSaving } + savingToolIds={ savingToolIds } onToggle={ handleToolChange } /> ) ); @@ -224,7 +224,7 @@ export default function McpRead( { mcpAbilities, blogId, isSaving, onUpdate } ) diff --git a/projects/plugins/jetpack/_inc/client/ai/mcp/setup.jsx b/projects/plugins/jetpack/_inc/client/ai/mcp/setup.jsx index 279a24e6f4ee..265dd758bcdc 100644 --- a/projects/plugins/jetpack/_inc/client/ai/mcp/setup.jsx +++ b/projects/plugins/jetpack/_inc/client/ai/mcp/setup.jsx @@ -9,6 +9,7 @@ import { getRedirectUrl } from '@automattic/jetpack-components'; import { Card, CardBody, + Icon, SelectControl, TextareaControl, __experimentalText as Text, // eslint-disable-line @wordpress/no-unsafe-wp-apis @@ -251,7 +252,7 @@ export default function McpSetup() { onClick={ copyToClipboard } aria-label={ __( 'Copy configuration to clipboard', 'jetpack' ) } > - + diff --git a/projects/plugins/jetpack/_inc/client/ai/mcp/style.scss b/projects/plugins/jetpack/_inc/client/ai/mcp/style.scss index 1c6be0e8d01b..c357c645a6fd 100644 --- a/projects/plugins/jetpack/_inc/client/ai/mcp/style.scss +++ b/projects/plugins/jetpack/_inc/client/ai/mcp/style.scss @@ -65,6 +65,20 @@ color: var(--color-neutral-60, #50575e); } + &__badge-content { + display: inline-flex; + align-items: center; + gap: 4px; + } + + &__category-header-row { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + width: 100%; + } + &__tool-group-divider { margin: 0; } @@ -103,5 +117,14 @@ &__action-button { align-self: flex-start; + + // When the button renders as an tag, WP admin link styles can override + // the button's foreground color — force white text to match variant="solid". + &, + &:visited, + &:hover, + &:active { + color: var(--wp-ui-button-foreground-color, #fff); + } } } diff --git a/projects/plugins/jetpack/_inc/client/ai/mcp/use-mcp-settings.js b/projects/plugins/jetpack/_inc/client/ai/mcp/use-mcp-settings.js index 78ee71dc7b1f..0db67a67b65c 100644 --- a/projects/plugins/jetpack/_inc/client/ai/mcp/use-mcp-settings.js +++ b/projects/plugins/jetpack/_inc/client/ai/mcp/use-mcp-settings.js @@ -19,7 +19,7 @@ const ENDPOINT = '/wpcom/v2/jetpack-ai/mcp-settings'; */ export function useMcpSettings() { const [ isLoading, setIsLoading ] = useState( true ); - const [ isSaving, setIsSaving ] = useState( false ); + const [ savingToolIds, setSavingToolIds ] = useState( () => new Set() ); const [ mcpAbilities, setMcpAbilities ] = useState( null ); const [ hasMcpAccess, setHasMcpAccess ] = useState( null ); const [ error, setError ] = useState( null ); @@ -64,7 +64,20 @@ export function useMcpSettings() { */ const updateMcpAbilities = useCallback( update => { - setIsSaving( true ); + // Collect the toolIds this request touches so only those toggles are disabled. + const siteEntry = update.sites?.[ 0 ] ?? {}; + const toolIds = Object.keys( siteEntry.abilities ?? {} ); + // Use a sentinel for site_level_enabled so the main toggle is also targeted. + if ( siteEntry.site_level_enabled !== undefined ) { + toolIds.push( '__site_level__' ); + } + + setSavingToolIds( prev => { + const next = new Set( prev ); + toolIds.forEach( id => next.add( id ) ); + return next; + } ); + return apiFetch( { path: ENDPOINT, method: 'POST', @@ -79,11 +92,15 @@ export function useMcpSettings() { throw err; } ) .finally( () => { - setIsSaving( false ); + setSavingToolIds( prev => { + const next = new Set( prev ); + toolIds.forEach( id => next.delete( id ) ); + return next; + } ); } ); }, [ mcpAbilities ] ); - return { isLoading, isSaving, mcpAbilities, hasMcpAccess, error, updateMcpAbilities }; + return { isLoading, savingToolIds, mcpAbilities, hasMcpAccess, error, updateMcpAbilities }; } diff --git a/projects/plugins/jetpack/_inc/client/ai/mcp/write.jsx b/projects/plugins/jetpack/_inc/client/ai/mcp/write.jsx index 30aa690800de..5e5f060be403 100644 --- a/projects/plugins/jetpack/_inc/client/ai/mcp/write.jsx +++ b/projects/plugins/jetpack/_inc/client/ai/mcp/write.jsx @@ -35,20 +35,20 @@ import { /** * A single tool toggle row. * - * @param {object} props - Component props. - * @param {string} props.toolId - Tool identifier. - * @param {object} props.tool - Tool descriptor from the API. - * @param {boolean} props.isSaving - Whether a save is pending. - * @param {Function} props.onToggle - Called with (toolId, enabled). + * @param {object} props - Component props. + * @param {string} props.toolId - Tool identifier. + * @param {object} props.tool - Tool descriptor from the API. + * @param {boolean} props.savingToolIds - Whether a save is pending. + * @param {Function} props.onToggle - Called with (toolId, enabled). * @return {object} Component markup. */ -function ToolToggle( { toolId, tool, isSaving, onToggle } ) { +function ToolToggle( { toolId, tool, savingToolIds, onToggle } ) { const handleChange = useCallback( checked => onToggle( toolId, checked ), [ toolId, onToggle ] ); return ( onEnableAll( categoryTools, checked ), [ categoryTools, onEnableAll ] ); return ( - +
{ categoryName } savingToolIds.has( id ) ) } label={ __( 'Enable all', 'jetpack' ) } onChange={ handleChange } /> - +
); } @@ -93,14 +93,14 @@ function CategoryHeader( { categoryName, allEnabled, isSaving, categoryTools, on /** * MCP Write tools view. * - * @param {object} props - Component props. - * @param {object} props.mcpAbilities - Full mcp_abilities object from the API. - * @param {number} props.blogId - Current site's blog ID. - * @param {boolean} props.isSaving - Whether a save is in progress. - * @param {Function} props.onUpdate - Called with partial mcp_abilities update. + * @param {object} props - Component props. + * @param {object} props.mcpAbilities - Full mcp_abilities object from the API. + * @param {number} props.blogId - Current site's blog ID. + * @param {Set} props.savingToolIds - Set of toolIds currently being saved. + * @param {Function} props.onUpdate - Called with partial mcp_abilities update. * @return {object} Component markup. */ -export default function McpWrite( { mcpAbilities, blogId, isSaving, onUpdate } ) { +export default function McpWrite( { mcpAbilities, blogId, savingToolIds, onUpdate } ) { const accountAbilities = getAccountMcpAbilities( mcpAbilities ?? {} ); const siteContextToolIds = getSiteContextToolIds( mcpAbilities ?? {} ); const siteAbilities = getSiteMcpAbilities( mcpAbilities ?? {}, blogId ); @@ -169,7 +169,7 @@ export default function McpWrite( { mcpAbilities, blogId, isSaving, onUpdate } ) key={ toolId } toolId={ toolId } tool={ tool } - isSaving={ isSaving } + savingToolIds={ savingToolIds } onToggle={ handleToolChange } /> ) ); @@ -227,7 +227,7 @@ export default function McpWrite( { mcpAbilities, blogId, isSaving, onUpdate } ) From 00e53f37b3d06578deaba2217f23932aef88c619 Mon Sep 17 00:00:00 2001 From: Eoin Gallagher Date: Wed, 22 Apr 2026 15:02:30 +0100 Subject: [PATCH 38/39] =?UTF-8?q?fix(ai):=20address=20PR=20review=20feedba?= =?UTF-8?q?ck=20=E2=80=94=20breadcrumbs,=20max-width,=20hide=20when=20disc?= =?UTF-8?q?onnected?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace title-switching + Back button with breadcrumb nav on sub-views - Narrow content max-width from 960px to 660px to match Figma/Dotcom - Hide AI menu entry when Jetpack is not connected (dont_show_if_not_active) Co-Authored-By: Claude Sonnet 4.6 --- .../plugins/jetpack/_inc/client/ai/main.jsx | 50 +++++++++++++------ .../plugins/jetpack/_inc/client/ai/style.scss | 36 +++++++++++-- .../lib/admin-pages/class-jetpack-ai-page.php | 5 +- 3 files changed, 69 insertions(+), 22 deletions(-) diff --git a/projects/plugins/jetpack/_inc/client/ai/main.jsx b/projects/plugins/jetpack/_inc/client/ai/main.jsx index 6074f813ca4f..4b7a4ede3161 100644 --- a/projects/plugins/jetpack/_inc/client/ai/main.jsx +++ b/projects/plugins/jetpack/_inc/client/ai/main.jsx @@ -8,8 +8,7 @@ import { AdminPage } from '@automattic/jetpack-components'; import { Spinner } from '@wordpress/components'; import { useCallback, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; -import { arrowLeft } from '@wordpress/icons'; -import { Button, Notice, Stack } from '@wordpress/ui'; +import { Notice, Stack } from '@wordpress/ui'; import McpHub from './mcp/index'; import McpRead from './mcp/read'; import McpSetup from './mcp/setup'; @@ -33,6 +32,36 @@ const VIEW_DESCRIPTIONS = { setup: __( 'Get instructions for connecting your external AI assistant.', 'jetpack' ), }; +/** + * Breadcrumb nav shown on sub-views: "AI / Read", "AI / Write", etc. + * Replaces both the page title and the ← Back button. + * + * @param {object} props - Component props. + * @param {string} props.view - Current sub-view key. + * @param {Function} props.onNavigate - Called with no args to go back to hub. + * @return {object} Component markup. + */ +function Breadcrumbs( { view, onNavigate } ) { + return ( + + ); +} + /** * Root App component for the Jetpack AI admin page. * @@ -61,24 +90,15 @@ export default function App() { return ( : undefined + } apiRoot={ apiRoot } apiNonce={ apiNonce } >
- { isSubView && ( - - ) } - { isLoading && (
diff --git a/projects/plugins/jetpack/_inc/client/ai/style.scss b/projects/plugins/jetpack/_inc/client/ai/style.scss index 00cc23a9b535..7d6f4a90b46f 100644 --- a/projects/plugins/jetpack/_inc/client/ai/style.scss +++ b/projects/plugins/jetpack/_inc/client/ai/style.scss @@ -20,19 +20,45 @@ &__content { box-sizing: border-box; width: 100%; - max-width: 960px; + max-width: 660px; margin: 0 auto; padding: 24px; } - &__back { - margin-bottom: 8px; + &__breadcrumbs { + // Override admin-ui's default min-height on the list. + min-height: auto; - .components-button.is-tertiary { - padding-left: 0; + li { + display: flex; + align-items: center; + margin-bottom: 0; } } + &__breadcrumb-link { + background: none; + border: none; + padding: 0; + cursor: pointer; + color: inherit; + font-size: inherit; + font-weight: inherit; + line-height: inherit; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + &__breadcrumb-current { + font-size: inherit; + font-weight: inherit; + line-height: inherit; + margin: 0; + } + &__loading { display: flex; justify-content: center; diff --git a/projects/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-ai-page.php b/projects/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-ai-page.php index 38ffcb646401..56aa70d4f69a 100644 --- a/projects/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-ai-page.php +++ b/projects/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-ai-page.php @@ -23,11 +23,12 @@ class Jetpack_AI_Page extends Jetpack_Admin_Page { /** - * Show the page even when Jetpack is not fully active (offline mode). + * Hide the "AI" sidebar entry when Jetpack is not yet connected. + * Other Jetpack products follow the same convention. * * @var bool */ - protected $dont_show_if_not_active = false; + protected $dont_show_if_not_active = true; /** * Register the "AI" submenu under the Jetpack top-level menu. From c763400a7578f36bacc59cedff3887296bf88124 Mon Sep 17 00:00:00 2001 From: Eoin Gallagher Date: Wed, 22 Apr 2026 15:16:46 +0100 Subject: [PATCH 39/39] fix(ai): fix breadcrumb layout and add Jetpack logo - Add flex layout to breadcrumb list so items render in a row - Add JetpackLogo before "AI" in the breadcrumb link Co-Authored-By: Claude Sonnet 4.6 --- projects/plugins/jetpack/_inc/client/ai/main.jsx | 3 ++- projects/plugins/jetpack/_inc/client/ai/style.scss | 10 +++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/projects/plugins/jetpack/_inc/client/ai/main.jsx b/projects/plugins/jetpack/_inc/client/ai/main.jsx index 4b7a4ede3161..dd89e73fffbe 100644 --- a/projects/plugins/jetpack/_inc/client/ai/main.jsx +++ b/projects/plugins/jetpack/_inc/client/ai/main.jsx @@ -4,7 +4,7 @@ * Manages the view stack (hub → read | write | setup) and owns the MCP settings state. */ -import { AdminPage } from '@automattic/jetpack-components'; +import { AdminPage, JetpackLogo } from '@automattic/jetpack-components'; import { Spinner } from '@wordpress/components'; import { useCallback, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; @@ -51,6 +51,7 @@ function Breadcrumbs( { view, onNavigate } ) { className="jetpack-ai-admin__breadcrumb-link" onClick={ onNavigate } > + { __( 'AI', 'jetpack' ) } diff --git a/projects/plugins/jetpack/_inc/client/ai/style.scss b/projects/plugins/jetpack/_inc/client/ai/style.scss index 7d6f4a90b46f..8f766b53f550 100644 --- a/projects/plugins/jetpack/_inc/client/ai/style.scss +++ b/projects/plugins/jetpack/_inc/client/ai/style.scss @@ -26,8 +26,13 @@ } &__breadcrumbs { - // Override admin-ui's default min-height on the list. + // admin-ui sets list-style:none but no flex layout — add it here. + display: flex; + flex-direction: row; + align-items: center; min-height: auto; + padding: 0; + margin: 0; li { display: flex; @@ -37,6 +42,9 @@ } &__breadcrumb-link { + display: inline-flex; + align-items: center; + gap: 6px; background: none; border: none; padding: 0;