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/_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..dd89e73fffbe --- /dev/null +++ b/projects/plugins/jetpack/_inc/client/ai/main.jsx @@ -0,0 +1,168 @@ +/** + * Root component for the Jetpack AI admin page. + * + * Manages the view stack (hub → read | write | setup) and owns the MCP settings state. + */ + +import { AdminPage, JetpackLogo } from '@automattic/jetpack-components'; +import { Spinner } from '@wordpress/components'; +import { useCallback, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { Notice, Stack } from '@wordpress/ui'; +import McpHub from './mcp/index'; +import McpRead from './mcp/read'; +import McpSetup from './mcp/setup'; +import McpUpsell from './mcp/upsell'; +import { useMcpSettings } from './mcp/use-mcp-settings'; +import McpWrite from './mcp/write'; + +const { blogId, apiRoot, apiNonce } = window?.jetpackAiSettings ?? {}; + +const VIEW_TITLES = { + hub: __( 'AI', 'jetpack' ), + read: __( 'Read', 'jetpack' ), + write: __( 'Write', 'jetpack' ), + setup: __( 'Connect external AI agent', 'jetpack' ), +}; + +const VIEW_DESCRIPTIONS = { + 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' ), +}; + +/** + * 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. + * + * @return {object} Component markup. + */ +export default function App() { + const [ view, setView ] = useState( 'hub' ); + const [ saveError, setSaveError ] = useState( null ); + const { isLoading, savingToolIds, mcpAbilities, hasMcpAccess, 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 ( + : undefined + } + apiRoot={ apiRoot } + apiNonce={ apiNonce } + > +
+ { isLoading && ( +
+ +
+ ) } + + { ! isLoading && error && ( + + { error } + + ) } + + { ! 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 && ! hasMcpAccess && } + + { ! isLoading && ! error && !! blogId && hasMcpAccess && ( + + { 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..4b878048b6f4 --- /dev/null +++ b/projects/plugins/jetpack/_inc/client/ai/mcp/index.jsx @@ -0,0 +1,274 @@ +/** + * MCP Settings hub — main view shown at wp-admin/admin.php?page=ai. + * Shows the enable/disable toggle and navigation to Read, Write, and Setup sub-views. + */ + +import { + Card, + CardBody, + CardDivider, + Icon, + ToggleControl, + __experimentalText as Text, // 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, info, published, caution } from '@wordpress/icons'; +import { Badge, Button, Stack } from '@wordpress/ui'; +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', + }; +} + +/* Map our semantic intents to the Badge intents provided by @wordpress/ui. */ +const BADGE_INTENT_MAP = { + success: 'stable', + info: 'informational', + warning: 'medium', + 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. + * + * @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 ( + + ); +} + +/** + * A card row for the "Connect external AI agent" action — shows title + description. + * + * @param {object} props - Component props. + * @param {string} props.title - Row title. + * @param {string} props.description - Row description. + * @param {Function} props.onClick - Click handler. + * @return {object} Component markup. + */ +function ConnectRow( { title, description, 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 {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, savingToolIds, 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 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; + + 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..225011068105 --- /dev/null +++ b/projects/plugins/jetpack/_inc/client/ai/mcp/read.jsx @@ -0,0 +1,245 @@ +/** + * MCP Read tools view. + * + * Lists read-only tools grouped by category, with per-tool and per-category toggles. + */ + +import { + Card, + CardBody, + CardDivider, + CardHeader, + ToggleControl, + __experimentalText as Text, // 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, + getDisplayCategory, + getSubCategory, + isWriteTool, + sortTools, +} from './categories'; +import { + getAccountMcpAbilities, + getSiteContextToolIds, + getSiteLevelEnabled, + 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 {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, savingToolIds, 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 {Set} props.savingToolIds - Set of toolIds currently being saved. + * @param {Array} props.categoryTools - Tools in the category. + * @param {Function} props.onEnableAll - Called with (categoryTools, enabled). + * @return {object} Component markup. + */ +function CategoryHeader( { categoryName, allEnabled, savingToolIds, categoryTools, onEnableAll } ) { + const handleChange = useCallback( + checked => onEnableAll( categoryTools, checked ), + [ categoryTools, onEnableAll ] + ); + return ( + +
+ + { categoryName } + + savingToolIds.has( id ) ) } + label={ __( 'Enable all', 'jetpack' ) } + onChange={ handleChange } + /> +
+
+ ); +} + +/** + * 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 {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, savingToolIds, 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 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 => overrides, [] ); + + 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..265dd758bcdc --- /dev/null +++ b/projects/plugins/jetpack/_inc/client/ai/mcp/setup.jsx @@ -0,0 +1,278 @@ +/** + * 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 { getRedirectUrl } from '@automattic/jetpack-components'; +import { + Card, + CardBody, + Icon, + SelectControl, + TextareaControl, + __experimentalText as Text, // 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'; +import { Button, Link, Stack } from '@wordpress/ui'; + +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: getRedirectUrl( 'https://docs.claude.com/en/docs/mcp' ), + 'claude-code': getRedirectUrl( 'https://code.claude.com/docs/en/mcp' ), + vscode: getRedirectUrl( 'https://code.visualstudio.com/docs/copilot/customization/mcp-servers' ), + cursor: getRedirectUrl( 'https://docs.cursor.com/en/context/mcp' ), + continue: getRedirectUrl( 'https://docs.continue.dev/customize/deep-dives/mcp' ), + default: getRedirectUrl( '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' ) } + + + + + { __( 'Copy this configuration into your client\u2019s MCP settings.', 'jetpack' ) } + + + { CLIENT_DOCS[ selectedClient ] && ( + + { CLIENT_DOCS_LABELS[ selectedClient ] } + + ) } + + + +
+ ); +} diff --git a/projects/plugins/jetpack/_inc/client/ai/mcp/style.scss b/projects/plugins/jetpack/_inc/client/ai/mcp/style.scss new file mode 100644 index 000000000000..c357c645a6fd --- /dev/null +++ b/projects/plugins/jetpack/_inc/client/ai/mcp/style.scss @@ -0,0 +1,130 @@ +.jetpack-ai-mcp { + + &__summary-row { + display: block; + width: 100%; + padding: 16px 24px; + height: auto; + text-align: left; + border-radius: 0; + box-shadow: none; + + &:hover { + background: var(--color-surface-backdrop, #f0f0f1); + } + + // Remove default Button border + &.components-button.is-tertiary { + box-shadow: none; + } + + // Row decoration icons (Read / Write) — neutral gray to match Calypso + svg.jetpack-ai-mcp__row-icon { + fill: var(--color-neutral-70, #3c434a); + } + } + + &__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 { + margin: 0; + } + + &__connect-row-description { + margin: 0; + } + + &__connect-row-chevron { + flex-shrink: 0; + margin-top: 2px; + 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; + } + + &__access-card { + // Override default card margin if any + } +} + +.jetpack-ai-mcp-setup { + + &__steps { + margin: 0; + padding-left: 20px; + + li { + margin-bottom: 8px; + } + } + + &__code { + font-size: 12px; + background: var(--color-neutral-5, #f0f0f1); + padding: 1px 4px; + border-radius: 2px; + } + + &__config-textarea { + + textarea { + font-family: monospace; + font-size: 12px; + min-height: 140px; + } + } + + &__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/upsell.jsx b/projects/plugins/jetpack/_inc/client/ai/mcp/upsell.jsx new file mode 100644 index 000000000000..95bf17da8b90 --- /dev/null +++ b/projects/plugins/jetpack/_inc/client/ai/mcp/upsell.jsx @@ -0,0 +1,27 @@ +/** + * MCP upsell card — shown when the current site does not have an MCP-capable plan. + */ + +import { UpsellBanner } from '@automattic/jetpack-components'; +import { __ } from '@wordpress/i18n'; + +const { upgradeUrl } = window?.jetpackAiSettings ?? {}; + +/** + * MCP upsell card. + * + * @return {object} Component markup. + */ +export default function McpUpsell() { + return ( + Available on the WordPress.com Business and Commerce plans.', + 'jetpack' + ) } + primaryCtaLabel={ __( 'Upgrade plan', 'jetpack' ) } + primaryCtaURL={ upgradeUrl } + /> + ); +} 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 new file mode 100644 index 000000000000..0db67a67b65c --- /dev/null +++ b/projects/plugins/jetpack/_inc/client/ai/mcp/use-mcp-settings.js @@ -0,0 +1,106 @@ +/** + * Custom hook for fetching and updating MCP settings via the wpcom/v2/jetpack-ai/mcp-settings endpoint. + * + * The PHP proxy at wpcom/v2/jetpack-ai/mcp-settings forwards requests to + * the WPCOM /me/settings API which handles partial mcp_abilities merges server-side. + * Updates only need to send the changed portion (e.g. { sites: [...] }). + */ + +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'; + +/** + * Hook that loads and exposes MCP settings for the current site. + * + * @return {{ isLoading: boolean, isSaving: boolean, mcpAbilities: Object|null, error: string|null, updateMcpAbilities: Function }} MCP settings state and updater. + */ +export function useMcpSettings() { + const [ isLoading, setIsLoading ] = useState( true ); + const [ savingToolIds, setSavingToolIds ] = useState( () => new Set() ); + const [ mcpAbilities, setMcpAbilities ] = useState( null ); + const [ hasMcpAccess, setHasMcpAccess ] = useState( null ); + const [ error, setError ] = useState( null ); + + useEffect( () => { + let cancelled = false; + setIsLoading( true ); + apiFetch( { path: ENDPOINT } ) + .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 ); + } + } ) + .catch( err => { + if ( ! cancelled ) { + setError( err?.message ?? __( 'Failed to load MCP settings.', 'jetpack' ) ); + } + } ) + .finally( () => { + if ( ! cancelled ) { + setIsLoading( false ); + } + } ); + return () => { + cancelled = true; + }; + }, [] ); + + /** + * Send a partial mcp_abilities update. + * The WPCOM /me/settings API merges the update into existing abilities server-side. + * + * @param {object} update - Partial mcp_abilities payload, e.g. { sites: [...] } + * @return {Promise} Resolves when the update is saved. + */ + const updateMcpAbilities = useCallback( + update => { + // 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', + data: { mcp_abilities: update }, + } ) + .then( data => { + setMcpAbilities( data?.mcp_abilities ?? mcpAbilities ); + setError( null ); + } ) + .catch( err => { + setError( err?.message ?? __( 'Failed to save MCP settings.', 'jetpack' ) ); + throw err; + } ) + .finally( () => { + setSavingToolIds( prev => { + const next = new Set( prev ); + toolIds.forEach( id => next.delete( id ) ); + return next; + } ); + } ); + }, + [ mcpAbilities ] + ); + + return { isLoading, savingToolIds, mcpAbilities, hasMcpAccess, error, updateMcpAbilities }; +} diff --git a/projects/plugins/jetpack/_inc/client/ai/mcp/utils.js b/projects/plugins/jetpack/_inc/client/ai/mcp/utils.js new file mode 100644 index 000000000000..9c9417ba01bc --- /dev/null +++ b/projects/plugins/jetpack/_inc/client/ai/mcp/utils.js @@ -0,0 +1,92 @@ +/** + * MCP utility functions. + * + * Ported from client/me/mcp/utils.js in wp-calypso. + * Provides helpers for reading and merging MCP ability data from user settings. + */ + +/** + * Get account-level MCP abilities from user settings. + * + * @param {object} userSettings - The user settings object (mcp_abilities response body) + * @return {Record} Account-level abilities keyed by tool ID + */ +export function getAccountMcpAbilities( userSettings ) { + if ( userSettings?.account ) { + return userSettings.account; + } + const mcpData = userSettings?.mcp_abilities; + if ( mcpData?.account ) { + return mcpData.account; + } + if ( mcpData ) { + return mcpData; + } + return {}; +} + +/** + * Get the set of tool IDs that are relevant in a site context. + * + * @param {object} userSettings - The user settings object (mcp_abilities response body). + * @return {Set} Set of tool IDs available in site context. + */ +export function getSiteContextToolIds( userSettings ) { + // 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 ) ); +} + +/** + * Get site-level ability overrides for a specific site. + * + * @param {object} userSettings - The user settings object (mcp_abilities response body). + * @param {number} siteId - The blog ID of the site. + * @return {Record} Site-level ability overrides keyed by tool ID. + */ +export function getSiteMcpAbilities( userSettings, siteId ) { + // 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 || {}; +} + +/** + * Merge account-level abilities with site-level overrides. + * + * @param {Record} accountAbilities - Account-level tool definitions. + * @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, defaultEnabled = null ) { + return Object.fromEntries( + Object.entries( accountAbilities ).map( ( [ toolId, tool ] ) => [ + toolId, + { + ...tool, + enabled: toolId in siteAbilities ? siteAbilities[ toolId ] : defaultEnabled ?? tool.enabled, + }, + ] ) + ); +} + +/** + * Check if site-level MCP is enabled for a specific site. + * + * @param {object} userSettings - The user settings object (mcp_abilities response body). + * @param {number} siteId - The blog ID of the site. + * @return {boolean} Whether site-level MCP access is enabled. + */ +export function getSiteLevelEnabled( userSettings, siteId ) { + // 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 ( + ( userSettings?.site_level_enabled_default ?? + userSettings?.mcp_abilities?.site_level_enabled_default ) === true + ); +} diff --git a/projects/plugins/jetpack/_inc/client/ai/mcp/write.jsx b/projects/plugins/jetpack/_inc/client/ai/mcp/write.jsx new file mode 100644 index 000000000000..5e5f060be403 --- /dev/null +++ b/projects/plugins/jetpack/_inc/client/ai/mcp/write.jsx @@ -0,0 +1,248 @@ +/** + * MCP Write tools view. + * + * Lists write tools (tools where readonly === false) grouped by category, + * with per-tool and per-category toggles. + */ + +import { + Card, + CardBody, + CardDivider, + CardHeader, + ToggleControl, + __experimentalText as Text, // 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, + getDisplayCategory, + getSubCategory, + isWriteTool, + sortTools, +} from './categories'; +import { + getAccountMcpAbilities, + getSiteContextToolIds, + getSiteLevelEnabled, + 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.savingToolIds - Whether a save is pending. + * @param {Function} props.onToggle - Called with (toolId, enabled). + * @return {object} Component markup. + */ +function ToolToggle( { toolId, tool, savingToolIds, 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 {Set} props.savingToolIds - Set of toolIds currently being saved. + * @param {Array} props.categoryTools - Tools in the category. + * @param {Function} props.onEnableAll - Called with (categoryTools, enabled). + * @return {object} Component markup. + */ +function CategoryHeader( { categoryName, allEnabled, savingToolIds, categoryTools, onEnableAll } ) { + const handleChange = useCallback( + checked => onEnableAll( categoryTools, checked ), + [ categoryTools, onEnableAll ] + ); + return ( + +
+ + { categoryName } + + savingToolIds.has( id ) ) } + label={ __( 'Enable all', 'jetpack' ) } + onChange={ handleChange } + /> +
+
+ ); +} + +/** + * 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 {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, savingToolIds, 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 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 => overrides, [] ); + + 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 = {}; + writeTools.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 ( writeTools.length === 0 ) { + return ( + + + + { __( 'No write tools are available for this site.', '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/style.scss b/projects/plugins/jetpack/_inc/client/ai/style.scss new file mode 100644 index 000000000000..8f766b53f550 --- /dev/null +++ b/projects/plugins/jetpack/_inc/client/ai/style.scss @@ -0,0 +1,75 @@ +// phpcs:ignore -- SCSS selector, not a PHP file +.jetpack_page_ai .admin-ui-page { + // Fill the viewport height minus the fixed 32px WP admin bar, + // so the footer sits at the bottom of the visible screen area. + min-height: calc(100vh - 32px); + + // The fluid container (2nd direct child, between header and footer) should + // grow to fill available space, pushing the footer to the bottom. + > div:nth-child(2) { + flex: 1; + } +} + +.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: 660px; + margin: 0 auto; + padding: 24px; + } + + &__breadcrumbs { + // 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; + align-items: center; + margin-bottom: 0; + } + } + + &__breadcrumb-link { + display: inline-flex; + align-items: center; + gap: 6px; + 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; + padding: 48px 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 new file mode 100644 index 000000000000..56aa70d4f69a --- /dev/null +++ b/projects/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-ai-page.php @@ -0,0 +1,137 @@ + $blog_id ? (int) $blog_id : 0, + 'siteAdminUrl' => admin_url(), + '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 + ) . ';', + 'before' + ); + + wp_enqueue_style( + 'jetpack-ai-admin', + plugins_url( '_inc/build/jetpack-ai-admin.css', JETPACK__PLUGIN_FILE ), + array( 'wp-components' ), + $script_version + ); + } + + /** + * 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. + */ + public function page_render() { + ?> +
+ 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 new file mode 100644 index 000000000000..113a296b4511 --- /dev/null +++ b/projects/plugins/jetpack/changelog/aiint-357-jetpack-ai-mcp-settings @@ -0,0 +1,4 @@ +Significance: minor +Type: enhancement + +Add MCP settings admin page and REST endpoint for managing external AI agent access to Jetpack AI. 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/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 ); diff --git a/projects/plugins/jetpack/tools/webpack.config.js b/projects/plugins/jetpack/tools/webpack.config.js index a212410092ee..aa0e7cde5614 100644 --- a/projects/plugins/jetpack/tools/webpack.config.js +++ b/projects/plugins/jetpack/tools/webpack.config.js @@ -170,6 +170,30 @@ module.exports = [ } ), }, }, + // Build AI admin page JS. + { + ...sharedWebpackConfig, + entry: { + 'jetpack-ai-admin': path.join( __dirname, '../_inc/client', 'ai-admin.js' ), + }, + plugins: [ + ...sharedWebpackConfig.plugins, + ...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, + jetpackConfig: JSON.stringify( { + consumer_slug: 'jetpack', + } ), + }, + }, // Build generator.jsx (which produces pre-rendered HTML). { ...sharedWebpackConfig,