From 1146c8267af7d80b3e35023c71f609f187219949 Mon Sep 17 00:00:00 2001 From: Miguel Lezama Date: Mon, 4 May 2026 09:28:25 -0300 Subject: [PATCH 1/2] apps/cli: pre-approve mcp__studio__ tools so classifier outages don't block the agent --- apps/cli/ai/agent.ts | 37 ++++++++++++++++++++++----- apps/cli/ai/tests/agent.test.ts | 45 +++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 6 deletions(-) diff --git a/apps/cli/ai/agent.ts b/apps/cli/ai/agent.ts index a2f41edc33..bc3ad39a36 100644 --- a/apps/cli/ai/agent.ts +++ b/apps/cli/ai/agent.ts @@ -1,6 +1,11 @@ import fs from 'fs'; import path from 'path'; -import { query, type HookCallback, type Query } from '@anthropic-ai/claude-agent-sdk'; +import { + query, + type HookCallback, + type HookCallbackMatcher, + type Query, +} from '@anthropic-ai/claude-agent-sdk'; import { AI_MODELS, DEFAULT_MODEL, type AiModelId } from '@studio/common/ai/models'; import { buildSystemPrompt } from 'cli/ai/system-prompt'; import { createRemoteSiteTools, createStudioTools } from 'cli/ai/tools'; @@ -131,6 +136,30 @@ export function startAiAgent( config: AiAgentConfig ): Query { } : undefined; + // Pre-approve our own MCP server's tools so they bypass the SDK's auto + // permission classifier. The classifier is a Sonnet call: when it has an + // outage every Studio tool call fails with "claude-sonnet-4-6 is + // temporarily unavailable, so auto mode cannot determine the safety…", + // which leaves the agent unable to do anything until Sonnet recovers. + // Studio's own tools are scoped to local sites or the user's own + // WordPress.com sites via OAuth, and the system prompt instructs the + // agent to confirm destructive actions, so they don't need an external + // safety check. Built-in tools (Bash, Write, Edit, …) still go through + // the classifier. + const allowStudioToolsHook: HookCallback = async () => ( { + hookSpecificOutput: { + hookEventName: 'PreToolUse' as const, + permissionDecision: 'allow' as const, + }, + } ); + + const preToolUseHooks: HookCallbackMatcher[] = [ + { matcher: '^mcp__studio__', hooks: [ allowStudioToolsHook ] }, + ]; + if ( askUserQuestionHook ) { + preToolUseHooks.push( { matcher: 'AskUserQuestion', hooks: [ askUserQuestionHook ] } ); + } + return query( { prompt, options: { @@ -144,11 +173,7 @@ export function startAiAgent( config: AiAgentConfig ): Query { cwd: STUDIO_SITES_ROOT, tools: { type: 'preset', preset: 'claude_code' }, permissionMode: 'auto', - ...( askUserQuestionHook && { - hooks: { - PreToolUse: [ { matcher: 'AskUserQuestion', hooks: [ askUserQuestionHook ] } ], - }, - } ), + hooks: { PreToolUse: preToolUseHooks }, plugins: [ { type: 'local' as const, path: path.resolve( import.meta.dirname, 'plugin' ) } ], model, resume, diff --git a/apps/cli/ai/tests/agent.test.ts b/apps/cli/ai/tests/agent.test.ts index f301c14a85..ecdda7ae3e 100644 --- a/apps/cli/ai/tests/agent.test.ts +++ b/apps/cli/ai/tests/agent.test.ts @@ -76,4 +76,49 @@ describe( 'AI agent startup', () => { ); expect( result ).toBe( mockQuery ); } ); + + describe( 'permission hooks', () => { + function getPreToolUseHooks() { + const callArgs = vi.mocked( query ).mock.calls[ 0 ][ 0 ]; + return callArgs.options?.hooks?.PreToolUse ?? []; + } + + it( 'pre-approves mcp__studio__ tools so a classifier outage cannot block them', async () => { + startAiAgent( { prompt: 'noop' } ); + + const studioMatcher = getPreToolUseHooks().find( ( m ) => m.matcher === '^mcp__studio__' ); + expect( studioMatcher ).toBeDefined(); + + const result = await studioMatcher!.hooks[ 0 ]( + { + hook_event_name: 'PreToolUse', + tool_name: 'mcp__studio__site_info', + tool_input: {}, + tool_use_id: 'use-1', + } as never, + 'use-1', + { signal: new AbortController().signal } + ); + expect( result ).toEqual( { + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'allow', + }, + } ); + } ); + + it( 'registers the studio pre-approval hook even when onAskUser is not provided', () => { + startAiAgent( { prompt: 'noop' } ); + + const matchers = getPreToolUseHooks().map( ( m ) => m.matcher ); + expect( matchers ).toEqual( [ '^mcp__studio__' ] ); + } ); + + it( 'keeps the AskUserQuestion hook alongside the studio pre-approval', () => { + startAiAgent( { prompt: 'noop', onAskUser: async () => ( {} ) } ); + + const matchers = getPreToolUseHooks().map( ( m ) => m.matcher ); + expect( matchers ).toEqual( [ '^mcp__studio__', 'AskUserQuestion' ] ); + } ); + } ); } ); From c10c0acf0410d3c6ac9ea13948032dd2bfbf71aa Mon Sep 17 00:00:00 2001 From: Miguel Lezama Date: Mon, 4 May 2026 09:37:35 -0300 Subject: [PATCH 2/2] apps/cli: extract STUDIO_MCP_SERVER_NAME constant and tighten hook test types --- apps/cli/ai/agent.ts | 11 ++++++--- apps/cli/ai/tests/agent.test.ts | 40 +++++++++++++++++++++------------ apps/cli/ai/tools.ts | 11 +++++++-- 3 files changed, 43 insertions(+), 19 deletions(-) diff --git a/apps/cli/ai/agent.ts b/apps/cli/ai/agent.ts index bc3ad39a36..2a0ad37c9f 100644 --- a/apps/cli/ai/agent.ts +++ b/apps/cli/ai/agent.ts @@ -8,7 +8,12 @@ import { } from '@anthropic-ai/claude-agent-sdk'; import { AI_MODELS, DEFAULT_MODEL, type AiModelId } from '@studio/common/ai/models'; import { buildSystemPrompt } from 'cli/ai/system-prompt'; -import { createRemoteSiteTools, createStudioTools } from 'cli/ai/tools'; +import { + createRemoteSiteTools, + createStudioTools, + STUDIO_MCP_SERVER_NAME, + STUDIO_MCP_TOOL_PREFIX, +} from 'cli/ai/tools'; import { STUDIO_SITES_ROOT } from 'cli/lib/site-paths'; import type { SiteInfo } from 'cli/ai/ui'; @@ -81,7 +86,7 @@ export function startAiAgent( config: AiAgentConfig ): Query { // Configure MCP servers based on site type: // Remote sites get WP.com REST API tools + screenshot; local sites get the full Studio toolset. const mcpServers = { - studio: isRemoteSite + [ STUDIO_MCP_SERVER_NAME ]: isRemoteSite ? createRemoteSiteTools( wpcomAccessToken, activeSite.wpcomSiteId! ) : createStudioTools( { enablePreviewSteering: isForkedByDesktop } ), }; @@ -154,7 +159,7 @@ export function startAiAgent( config: AiAgentConfig ): Query { } ); const preToolUseHooks: HookCallbackMatcher[] = [ - { matcher: '^mcp__studio__', hooks: [ allowStudioToolsHook ] }, + { matcher: `^${ STUDIO_MCP_TOOL_PREFIX }`, hooks: [ allowStudioToolsHook ] }, ]; if ( askUserQuestionHook ) { preToolUseHooks.push( { matcher: 'AskUserQuestion', hooks: [ askUserQuestionHook ] } ); diff --git a/apps/cli/ai/tests/agent.test.ts b/apps/cli/ai/tests/agent.test.ts index ecdda7ae3e..f1c95a5b49 100644 --- a/apps/cli/ai/tests/agent.test.ts +++ b/apps/cli/ai/tests/agent.test.ts @@ -1,7 +1,8 @@ import fs from 'fs'; -import { query } from '@anthropic-ai/claude-agent-sdk'; +import { query, type PreToolUseHookInput } from '@anthropic-ai/claude-agent-sdk'; import { beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'; import { startAiAgent } from 'cli/ai/agent'; +import { STUDIO_MCP_TOOL_PREFIX } from 'cli/ai/tools'; import { STUDIO_SITES_ROOT } from 'cli/lib/site-paths'; vi.mock( '@anthropic-ai/claude-agent-sdk', () => ( { @@ -15,6 +16,8 @@ vi.mock( 'cli/ai/system-prompt', () => ( { vi.mock( 'cli/ai/tools', () => ( { createRemoteSiteTools: vi.fn().mockReturnValue( { type: 'remote-tools' } ), createStudioTools: vi.fn().mockReturnValue( { type: 'local-tools' } ), + STUDIO_MCP_SERVER_NAME: 'studio', + STUDIO_MCP_TOOL_PREFIX: 'mcp__studio__', } ) ); describe( 'AI agent startup', () => { @@ -78,6 +81,8 @@ describe( 'AI agent startup', () => { } ); describe( 'permission hooks', () => { + const studioMatcherPattern = `^${ STUDIO_MCP_TOOL_PREFIX }`; + function getPreToolUseHooks() { const callArgs = vi.mocked( query ).mock.calls[ 0 ][ 0 ]; return callArgs.options?.hooks?.PreToolUse ?? []; @@ -86,19 +91,24 @@ describe( 'AI agent startup', () => { it( 'pre-approves mcp__studio__ tools so a classifier outage cannot block them', async () => { startAiAgent( { prompt: 'noop' } ); - const studioMatcher = getPreToolUseHooks().find( ( m ) => m.matcher === '^mcp__studio__' ); + const studioMatcher = getPreToolUseHooks().find( + ( m ) => m.matcher === studioMatcherPattern + ); expect( studioMatcher ).toBeDefined(); - const result = await studioMatcher!.hooks[ 0 ]( - { - hook_event_name: 'PreToolUse', - tool_name: 'mcp__studio__site_info', - tool_input: {}, - tool_use_id: 'use-1', - } as never, - 'use-1', - { signal: new AbortController().signal } - ); + const input: PreToolUseHookInput = { + hook_event_name: 'PreToolUse', + tool_name: `${ STUDIO_MCP_TOOL_PREFIX }site_info`, + tool_input: {}, + tool_use_id: 'use-1', + cwd: STUDIO_SITES_ROOT, + session_id: 'session', + transcript_path: '', + permission_mode: 'auto', + }; + const result = await studioMatcher!.hooks[ 0 ]( input, 'use-1', { + signal: new AbortController().signal, + } ); expect( result ).toEqual( { hookSpecificOutput: { hookEventName: 'PreToolUse', @@ -111,14 +121,16 @@ describe( 'AI agent startup', () => { startAiAgent( { prompt: 'noop' } ); const matchers = getPreToolUseHooks().map( ( m ) => m.matcher ); - expect( matchers ).toEqual( [ '^mcp__studio__' ] ); + expect( matchers ).toContain( studioMatcherPattern ); + expect( matchers ).not.toContain( 'AskUserQuestion' ); } ); it( 'keeps the AskUserQuestion hook alongside the studio pre-approval', () => { startAiAgent( { prompt: 'noop', onAskUser: async () => ( {} ) } ); const matchers = getPreToolUseHooks().map( ( m ) => m.matcher ); - expect( matchers ).toEqual( [ '^mcp__studio__', 'AskUserQuestion' ] ); + expect( matchers ).toContain( studioMatcherPattern ); + expect( matchers ).toContain( 'AskUserQuestion' ); } ); } ); } ); diff --git a/apps/cli/ai/tools.ts b/apps/cli/ai/tools.ts index 3798cf3121..54a8a96917 100644 --- a/apps/cli/ai/tools.ts +++ b/apps/cli/ai/tools.ts @@ -38,6 +38,13 @@ import { normalizeHostname } from 'cli/lib/utils'; import { isServerRunning, sendWpCliCommand } from 'cli/lib/wordpress-server-manager'; import { getProgressCallback, setProgressCallback, emitProgress } from 'cli/logger'; +// The MCP server name that prefixes every Studio tool the agent sees as +// `mcp__studio__`. The agent's PreToolUse permission hook matches against +// this prefix to pre-approve our own tools — keep `STUDIO_MCP_TOOL_PREFIX` in +// sync if you ever rename the server. +export const STUDIO_MCP_SERVER_NAME = 'studio'; +export const STUDIO_MCP_TOOL_PREFIX = `mcp__${ STUDIO_MCP_SERVER_NAME }__`; + /** * Splits a command string into arguments, respecting quoted strings. * Handles both single and double quotes, e.g.: @@ -1309,7 +1316,7 @@ export function resolveStudioToolDefinitions( options: CreateStudioToolsOptions export function createStudioTools( options: CreateStudioToolsOptions = {} ) { return createSdkMcpServer( { - name: 'studio', + name: STUDIO_MCP_SERVER_NAME, version: '1.0.0', tools: resolveStudioToolDefinitions( options ), } ); @@ -1325,7 +1332,7 @@ export function createRemoteSiteTools( token: string, siteId: number ) { ? [ takeScreenshotTool, shareScreenshotTool ] : [ takeScreenshotTool ]; return createSdkMcpServer( { - name: 'studio', + name: STUDIO_MCP_SERVER_NAME, version: '1.0.0', tools: [ ...wpcomTools, ...screenshotTools, createSiteTool, pullSiteTool ], } );