Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/cli/ai/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export function startAiAgent( config: AiAgentConfig ): Query {
name: activeSite.name,
url: activeSite.url ?? '',
id: activeSite.wpcomSiteId!,
planSlug: activeSite.planSlug,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there cases where the plan is undefined and we need a fallback (I don't know wp.com enough)

},
}
: undefined;
Expand Down
9 changes: 6 additions & 3 deletions apps/cli/ai/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ interface RemoteSiteContext {
name: string;
url: string;
id: number;
planSlug?: string;
}

const AGENT_IDENTITY = `You are WordPress Studio Code, the AI agent built into WordPress Studio CLI. Your name is "WordPress Studio Code".`;
Expand All @@ -27,9 +28,11 @@ ${ LOCAL_DESIGN_GUIDELINES }
function buildRemoteIntro( site: RemoteSiteContext ): string {
return `${ AGENT_IDENTITY } You manage WordPress.com sites using the WordPress.com REST API.

IMPORTANT: The active site is a remote WordPress.com site: "${ site.name }" (ID: ${ site.id }) at ${ site.url }.
IMPORTANT: The active site is a remote WordPress.com site: "${ site.name }" (ID: ${ site.id }) at ${
site.url
}.${ site.planSlug ? ` Plan: ${ site.planSlug }.` : '' }
IMPORTANT: You MUST use the wpcom_request tool (prefixed with mcp__studio__) to manage this site. Do NOT use WP-CLI, file Write/Edit, Bash, or any local file operations — this site is hosted on WordPress.com and cannot be modified through the local filesystem.
IMPORTANT: Before doing ANY work, you MUST first check the site's plan by calling \`GET /\` (apiNamespace: \`""\`). The \`plan.product_slug\` field indicates the plan. If the site is on a free plan (e.g. \`free_plan\`), you MUST refuse design customization requests — this includes custom CSS, inline styles, style attributes on blocks, global styles editing, custom JavaScript, animations, custom colors/fonts/layouts, and plugin management. Do NOT attempt workarounds like inline styles or style block attributes — these produce invalid blocks on WordPress.com. Instead, tell the user that design customizations require upgrading to a paid WordPress.com plan and STOP. Do not proceed with the design task.
IMPORTANT: If the site is on a free plan, you MUST refuse design customization requests — this includes custom CSS, inline styles, style attributes on blocks, global styles editing, custom JavaScript, animations, custom colors/fonts/layouts, and plugin management. Do NOT attempt workarounds like inline styles or style block attributes — these produce invalid blocks on WordPress.com. Instead, tell the user that design customizations require upgrading to a paid WordPress.com plan and STOP. Do not proceed with the design task.

## Available Tools (prefixed with mcp__studio__)

Expand Down Expand Up @@ -77,7 +80,7 @@ Use \`per_page\` and \`page\` for pagination. Use \`status\` to filter by publis

## Workflow

1. **Check the site plan** (MANDATORY FIRST STEP): Use \`GET /\` (apiNamespace: \`""\`) to get site info and check \`plan.product_slug\`. Stop and inform the user if they request features unavailable on their plan.
1. **Check the site plan**: The plan is already provided in your active site context. If it is a free plan, refuse design customization requests as described above.
2. **Understand the site**: Use \`GET /posts\` to list content, \`GET /themes?status=active\` to see the active theme.
3. **Make changes**: Use POST requests to create/update content, manage templates, switch themes.
4. **Verify visually**: Use take_screenshot to capture the site on desktop and mobile viewports. Check spacing, alignment, colors, contrast, and layout. Fix any issues.
Expand Down
60 changes: 60 additions & 0 deletions apps/cli/ai/tests/wpcom-tools.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest';
import { stripNoisyFields } from '../wpcom-tools';

describe( 'stripNoisyFields', () => {
it( 'removes _links and other noisy top-level fields', () => {
const input = {
id: 1,
title: 'Hello',
_links: { self: '/posts/1' },
_embedded: { author: [] },
guid: { rendered: 'http://example.com/?p=1' },
ping_status: 'open',
comment_status: 'open',
generated_slug: 'hello',
permalink_template: 'http://example.com/%postname%/',
};
const { data, removed } = stripNoisyFields( input );
expect( data ).toEqual( { id: 1, title: 'Hello' } );
expect( removed ).toContain( '_links' );
expect( removed ).toContain( 'guid' );
expect( removed ).toContain( 'permalink_template' );
} );

it( 'drops rendered when raw is present in the same object', () => {
const input = {
title: { raw: 'Hello', rendered: '<p>Hello</p>' },
content: { raw: '<!-- wp:paragraph -->', rendered: '<p>rendered html</p>' },
};
const { data, removed } = stripNoisyFields( input );
expect( data ).toEqual( {
title: { raw: 'Hello' },
content: { raw: '<!-- wp:paragraph -->' },
} );
expect( removed ).toContain( 'rendered (raw exists)' );
} );

it( 'keeps rendered when raw is absent', () => {
const input = { title: { rendered: 'Hello' } };
const { data } = stripNoisyFields( input );
expect( data ).toEqual( { title: { rendered: 'Hello' } } );
} );

it( 'recursively strips from arrays', () => {
const input = [
{ id: 1, _links: { self: '/1' }, title: { raw: 'A', rendered: '<p>A</p>' } },
{ id: 2, _links: { self: '/2' }, title: { raw: 'B', rendered: '<p>B</p>' } },
];
const { data } = stripNoisyFields( input );
expect( data ).toEqual( [
{ id: 1, title: { raw: 'A' } },
{ id: 2, title: { raw: 'B' } },
] );
} );

it( 'passes through primitives unchanged', () => {
expect( stripNoisyFields( 42 ).data ).toBe( 42 );
expect( stripNoisyFields( 'hello' ).data ).toBe( 'hello' );
expect( stripNoisyFields( null ).data ).toBe( null );
} );
} );
2 changes: 2 additions & 0 deletions apps/cli/ai/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export interface SiteInfo {
remote?: boolean;
url?: string;
wpcomSiteId?: number;
planSlug?: string;
}

const DEFAULT_COLLAPSE_THRESHOLD_LINES = 5;
Expand Down Expand Up @@ -829,6 +830,7 @@ export class AiChatUI {
remote: true,
url: site.url,
wpcomSiteId: site.id,
planSlug: site.planSlug,
} ) );
this.sitePickerRemoteLoading = false;
this.rebuildSitePickerList();
Expand Down
82 changes: 80 additions & 2 deletions apps/cli/ai/wpcom-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,53 @@ function getErrorMessage( error: unknown ): string {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type ApiResponse = any;

export { stripNoisyFields };

// Fields that bloat API responses without being useful to the agent.
const NOISY_FIELDS = new Set( [
'_links',
'_embedded',
'guid',
'ping_status',
'comment_status',
'generated_slug',
'permalink_template',
] );
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are situations where we need these fields, this is a bit dangerous IMO. Like if we want to retrieve the global styles we need the links... It's also a bit random, we seem to be stripping random fields from all requests.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea is to strip them only when results exceed the limit, and note which ones were stripped and why in the reply so the agent is aware and can ask again with some filtering if it needs the full response.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note which ones were stripped and why in the reply so the agent is aware and can ask again with some filtering if it needs the full response

here: https://github.com/Automattic/studio/pull/3011/changes#diff-8bc987fb45f0d99a9ad64c2c77c6fb430ec58bd3d1be1a3a300004f5e70f4a79R172


/* eslint-disable @typescript-eslint/no-explicit-any */
function stripNoisyFields(
data: any,
removed = new Set< string >()
): { data: any; removed: Set< string > } {
return { data: doStrip( data, removed ), removed };
}
/* eslint-enable @typescript-eslint/no-explicit-any */

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function doStrip( data: any, removed: Set< string > ): any {
if ( Array.isArray( data ) ) {
return data.map( ( item ) => doStrip( item, removed ) );
}
if ( data !== null && typeof data === 'object' ) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const out: Record< string, any > = {};
for ( const [ key, value ] of Object.entries( data ) ) {
if ( NOISY_FIELDS.has( key ) ) {
removed.add( key );
continue;
}
// Drop `rendered` when `raw` is present in the same object
if ( key === 'rendered' && 'raw' in data ) {
removed.add( 'rendered (raw exists)' );
continue;
}
out[ key ] = doStrip( value, removed );
}
return out;
}
return data;
}

/**
* Creates a generic WP.com REST API tool for managing a remote WordPress.com site.
* Instead of hardcoding individual endpoints, this provides a single flexible tool
Expand All @@ -41,7 +88,9 @@ export function createWpcomToolDefinitions( token: string, siteId: number ) {
'Defaults to the WordPress REST API (wp/v2). Use this to manage posts, pages, templates, template parts, ' +
'media, plugins, themes, settings, and any other site resource. ' +
'The path is relative to /sites/{siteId}/ — for example, pass "/posts" to call /wp/v2/sites/{siteId}/posts. ' +
'For non-site endpoints, start the path with "!" (e.g., "!/me") to use an absolute path.',
'For non-site endpoints, start the path with "!" (e.g., "!/me") to use an absolute path. ' +
'IMPORTANT: To avoid oversized responses, always use the "fields" query parameter to request only the fields you need ' +
'(e.g., query: { "fields": "ID,title,slug,status" }). Use "per_page" to limit list results (default: 10, max: 100).',
{
method: z
.enum( [ 'GET', 'POST', 'PUT', 'DELETE' ] )
Expand Down Expand Up @@ -106,7 +155,36 @@ export function createWpcomToolDefinitions( token: string, siteId: number ) {
break;
}

return textResult( JSON.stringify( result, null, 2 ) );
// The Claude Agent SDK limits MCP tool results to ~25K tokens
// (MAX_MCP_OUTPUT_TOKENS, default 25000). At ~3 chars/token
// for JSON, 50K chars ≈ 16.7K tokens.
const MAX_RESPONSE_CHARS = 50000;
const json = JSON.stringify( result );

if ( json.length <= MAX_RESPONSE_CHARS ) {
return textResult( json );
}

// Over limit — strip noisy fields and retry
const { data: cleaned, removed } = stripNoisyFields( result );
const stripped = JSON.stringify( cleaned );
const removedList = [ ...removed ].join( ', ' );
const fieldsHint =
`\n\n[Stripped fields: ${ removedList }. ` +
'Re-request with "fields" parameter if you need any of these, or reduce "per_page".]';

if ( stripped.length + fieldsHint.length <= MAX_RESPONSE_CHARS ) {
return textResult( stripped + fieldsHint );
}

// Still too large — truncate
const truncationNote =
`\n\n[Truncated — response was ${ stripped.length.toLocaleString() } chars. ` +
`Stripped fields: ${ removedList }. ` +
'Use "fields" to request only needed fields (e.g., { "fields": "ID,title,slug" }), or reduce "per_page".]';
return textResult(
stripped.slice( 0, MAX_RESPONSE_CHARS - truncationNote.length ) + truncationNote
);
} catch ( error ) {
return errorResult(
`WP.com API request failed (${ args.method } ${ args.path }): ${ getErrorMessage(
Expand Down
3 changes: 2 additions & 1 deletion apps/cli/commands/ai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,8 @@ export async function runCommand(
let enrichedPrompt = prompt;
const site = ui.activeSite;
if ( site?.remote && site?.url ) {
enrichedPrompt = `[Active site: "${ site.name }" (ID: ${ site.wpcomSiteId }) at ${ site.url } (WordPress.com)]\n\n${ prompt }`;
const planLabel = site.planSlug ? `, plan: ${ site.planSlug }` : '';
enrichedPrompt = `[Active site: "${ site.name }" (ID: ${ site.wpcomSiteId }) at ${ site.url } (WordPress.com${ planLabel })]\n\n${ prompt }`;
} else if ( site ) {
enrichedPrompt = `[Active site: "${ site.name }" at ${ site.path }${
site.running ? ' (running)' : ' (stopped)'
Expand Down
9 changes: 8 additions & 1 deletion apps/cli/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ export interface WpComSiteInfo {
id: number;
name: string;
url: string;
planSlug?: string;
}

const wpComSitesResponseSchema = z.object( {
Expand All @@ -192,6 +193,11 @@ const wpComSitesResponseSchema = z.object( {
name: z.string(),
URL: z.string(),
is_deleted: z.boolean().optional(),
plan: z
.object( {
product_slug: z.string(),
} )
.optional(),
} )
),
} );
Expand All @@ -205,7 +211,7 @@ export async function getWpComSites( token: string ): Promise< WpComSiteInfo[] >
path: '/me/sites',
},
{
fields: 'ID,name,URL,is_deleted',
fields: 'ID,name,URL,is_deleted,plan',
filter: 'atomic,wpcom',
site_activity: 'active',
}
Expand All @@ -217,6 +223,7 @@ export async function getWpComSites( token: string ): Promise< WpComSiteInfo[] >
id: site.ID,
name: site.name,
url: site.URL,
planSlug: site.plan?.product_slug,
} ) );
} catch ( error ) {
if ( error instanceof z.ZodError ) {
Expand Down
Loading