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' && (
+
+ -
+
+ { createInterpolateElement( __( 'Open .', 'jetpack' ), {
+ ClaudeSettings: (
+
+ { __( 'Claude settings', 'jetpack' ) }
+
+ ),
+ } ) }
+
+
+ -
+
+ { __( 'Click "Browse connectors" and search for WordPress.com.', 'jetpack' ) }
+
+
+ -
+
+ { __( 'Select WordPress.com and follow the prompts.', 'jetpack' ) }
+
+
+
+ ) }
+
+ { selectedClient === 'claude-code' && (
+
+
+ { __(
+ 'Claude Code uses a different config format with type: "http". Use the CLI or copy the configuration below.',
+ 'jetpack'
+ ) }
+
+
+ -
+
+ { createInterpolateElement(
+ __( 'Run in your terminal:
', 'jetpack' ),
+ {
+ code: (
+
+ { `claude mcp add --transport http ${ MCP_SERVER_NAME } ${ MCP_SERVER_URL }` }
+
+ ),
+ }
+ ) }
+
+
+ -
+
+ { createInterpolateElement(
+ __(
+ 'Or copy the configuration below and add it to your or file.',
+ 'jetpack'
+ ),
+ {
+ mcpJson:
.mcp.json,
+ claudeJson: (
+ ~/.claude.json
+ ),
+ }
+ ) }
+
+
+ -
+
+ { createInterpolateElement(
+ __(
+ 'In Claude Code, run
to authenticate with your WordPress.com account.',
+ 'jetpack'
+ ),
+ {
+ code: /mcp,
+ }
+ ) }
+
+
+
+
+ ) }
+
+ { selectedClient === 'cursor' && (
+
+
+ { __(
+ 'For Cursor users, use the one-click install to add the WordPress.com MCP app.',
+ 'jetpack'
+ ) }
+
+
+ }
+ >
+ { __( 'Install in Cursor', '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,