From d462f5f0234a5453a87bb20484dda0549f613068 Mon Sep 17 00:00:00 2001 From: masudahiroto <96814344+masudahiroto@users.noreply.github.com> Date: Wed, 4 Mar 2026 18:04:33 +0900 Subject: [PATCH 01/11] feat: add runtime interactive approval via ctx.requestApproval Enable tools to dynamically request user approval mid-execution based on runtime conditions (e.g., API endpoint, input values), complementing the existing static destructiveHint annotation flow. - Add ToolExecutionContext with requestApproval() to client defineTool - Add message/metadata fields to ToolApprovalRequestEvent (core types) - Wire runtime approval resolvers in useToolSystem (client-side flow) - Add requestApproval to ServerToolContext and serverToolExecutor - Pass events to createServerToolExecutor in AISDKAgent - Display runtime approval messages in ToolApprovalDialog - Export ToolExecutionContext from client package - Add unit tests for both client and server requestApproval flows Co-Authored-By: Claude Opus 4.6 --- .../src/components/ToolApprovalDialog.tsx | 16 +- packages/client/src/defineTool.test.ts | 142 +++++++++++++++++- packages/client/src/defineTool.ts | 65 ++++++-- packages/client/src/hooks/useStableTools.ts | 8 +- packages/client/src/hooks/useToolSystem.ts | 70 +++++++-- packages/client/src/index.ts | 2 +- packages/client/src/useAIWorkflow.ts | 6 +- packages/core/src/types.ts | 4 + packages/server/src/agents/AISDKAgent.ts | 2 +- .../server/src/tools/defineServerTool.test.ts | 6 +- .../src/tools/serverToolExecutor.test.ts | 104 ++++++++++++- .../server/src/tools/serverToolExecutor.ts | 31 +++- packages/server/src/tools/types.ts | 11 ++ 13 files changed, 414 insertions(+), 53 deletions(-) diff --git a/packages/client/src/components/ToolApprovalDialog.tsx b/packages/client/src/components/ToolApprovalDialog.tsx index 6bcc8f2a..921abdad 100644 --- a/packages/client/src/components/ToolApprovalDialog.tsx +++ b/packages/client/src/components/ToolApprovalDialog.tsx @@ -10,6 +10,8 @@ export interface PendingToolItem { toolCallName: string; toolCallArgs: Record; annotations?: ToolAnnotations; + /** Optional message explaining why approval is needed (runtime approval) */ + message?: string; } /** @@ -56,11 +58,17 @@ export function ToolApprovalDialog({ const displayName = annotations?.title || toolCallName; const isBatch = toolCount > 1; + // Check if any pending tool has a runtime approval message + const runtimeMessage = pendingTools.find(t => t.message)?.message; + // For batch mode, show count; otherwise show the tool name - const message = isBatch - ? strings.toolApproval.batchMessage?.replace('{count}', String(toolCount)) - ?? `${toolCount} actions are waiting for your approval` - : strings.toolApproval.message.replace('{toolName}', displayName); + // Runtime messages take priority over default generated messages + const message = runtimeMessage + ? runtimeMessage + : isBatch + ? strings.toolApproval.batchMessage?.replace('{count}', String(toolCount)) + ?? `${toolCount} actions are waiting for your approval` + : strings.toolApproval.message.replace('{toolName}', displayName); // Get display name for a tool (use annotation title if available) const getToolDisplayName = (tool: PendingToolItem) => diff --git a/packages/client/src/defineTool.test.ts b/packages/client/src/defineTool.test.ts index 19e8e081..ba1f0321 100644 --- a/packages/client/src/defineTool.test.ts +++ b/packages/client/src/defineTool.test.ts @@ -1,6 +1,12 @@ import { describe, test, expect } from 'bun:test'; import { z } from 'zod'; -import { defineTool } from './defineTool'; +import { defineTool, executeDefinedTool } from './defineTool'; +import type { ToolExecutionContext } from './defineTool'; + +/** No-op context for tests that don't use requestApproval */ +const noopCtx: ToolExecutionContext = { + requestApproval: async () => ({ approved: true }), +}; describe('Tools can be defined with type-safe parameters using Zod schemas', () => { test('defines a tool with Zod schema and typed parameters', async () => { @@ -270,6 +276,138 @@ describe('Additional tool definition functionality', () => { throw new Error('Tool execution failed'); }); - await expect(tool._execute({})).rejects.toThrow('Tool execution failed'); + await expect(tool._execute({}, noopCtx)).rejects.toThrow('Tool execution failed'); + }); +}); + +describe('ToolExecutionContext with requestApproval', () => { + test('tool with schema receives ctx as second argument', async () => { + let receivedCtx: ToolExecutionContext | undefined; + + const tool = defineTool( + 'Tool with context', + z.object({ value: z.string() }), + (_input, ctx) => { + receivedCtx = ctx; + return 'ok'; + } + ); + + await tool._execute({ value: 'test' }, noopCtx); + expect(receivedCtx).toBeDefined(); + expect(receivedCtx!.requestApproval).toBeInstanceOf(Function); + }); + + test('parameterless tool receives ctx', async () => { + let receivedCtx: ToolExecutionContext | undefined; + + const tool = defineTool( + 'No-param tool with context', + (ctx) => { + receivedCtx = ctx; + return 'ok'; + } + ); + + await tool._execute({}, noopCtx); + expect(receivedCtx).toBeDefined(); + expect(receivedCtx!.requestApproval).toBeInstanceOf(Function); + }); + + test('tool can call requestApproval and receive approved result', async () => { + const approveCtx: ToolExecutionContext = { + requestApproval: async () => ({ approved: true }), + }; + + const tool = defineTool( + 'Conditionally approved tool', + z.object({ action: z.string() }), + async (input, ctx) => { + const { approved } = await ctx.requestApproval({ + message: `Approve ${input.action}?`, + }); + return approved ? 'executed' : 'rejected'; + } + ); + + const result = await tool._execute({ action: 'delete' }, approveCtx); + expect(result).toBe('executed'); + }); + + test('tool can call requestApproval and receive rejected result', async () => { + const rejectCtx: ToolExecutionContext = { + requestApproval: async () => ({ approved: false, reason: 'Too risky' }), + }; + + const tool = defineTool( + 'Rejected tool', + z.object({ action: z.string() }), + async (_input, ctx) => { + const { approved, reason } = await ctx.requestApproval({ + message: 'Approve this?', + }); + return approved ? 'executed' : `rejected: ${reason}`; + } + ); + + const result = await tool._execute({ action: 'delete' }, rejectCtx); + expect(result).toBe('rejected: Too risky'); + }); + + test('requestApproval receives message and metadata', async () => { + let capturedInput: { message: string; metadata?: Record } | undefined; + + const ctx: ToolExecutionContext = { + requestApproval: async (input) => { + capturedInput = input; + return { approved: true }; + }, + }; + + const tool = defineTool( + 'Tool with metadata', + z.object({ url: z.string() }), + async (input, ctx) => { + await ctx.requestApproval({ + message: `Calling ${input.url}`, + metadata: { endpoint: input.url, risk: 'high' }, + }); + return 'done'; + } + ); + + await tool._execute({ url: 'https://prod.example.com' }, ctx); + expect(capturedInput!.message).toBe('Calling https://prod.example.com'); + expect(capturedInput!.metadata).toEqual({ endpoint: 'https://prod.example.com', risk: 'high' }); + }); + + test('executeDefinedTool forwards ctx to tool', async () => { + let ctxReceived = false; + + const tools = { + myTool: defineTool( + 'Test', + z.object({ x: z.number() }), + (_input, ctx) => { + ctxReceived = ctx.requestApproval !== undefined; + return 'ok'; + } + ), + }; + + await executeDefinedTool(tools, 'myTool', { x: 1 }, noopCtx); + expect(ctxReceived).toBe(true); + }); + + test('existing tools without ctx parameter still work (backward compatibility)', async () => { + // Simulate an existing tool that ignores ctx + const tool = defineTool( + 'Legacy tool', + z.object({ n: z.number() }), + (input) => input.n * 2 + ); + + const result = await tool._execute({ n: 21 }, noopCtx); + expect(result).toBe(42); }); }); diff --git a/packages/client/src/defineTool.ts b/packages/client/src/defineTool.ts index ca14f240..153ffa9e 100644 --- a/packages/client/src/defineTool.ts +++ b/packages/client/src/defineTool.ts @@ -4,6 +4,42 @@ import type { ToolDefinition, ToolAnnotations } from '@meetsmore-oss/use-ai-core // Re-export ToolAnnotations for convenience export type { ToolAnnotations }; +/** + * Context provided to tool execution functions. + * Allows tools to dynamically request user approval at runtime. + */ +export interface ToolExecutionContext { + /** + * Request user approval during tool execution. + * Use this for conditional approval based on runtime values + * (e.g., API endpoint, input values, computed risk). + * + * @param input - Approval request details + * @returns Promise resolving with approval decision + * + * @example + * ```typescript + * const callApi = defineTool( + * 'Call an external API', + * z.object({ url: z.string() }), + * async (input, ctx) => { + * if (input.url.includes('production')) { + * const { approved } = await ctx.requestApproval({ + * message: `This will call production API: ${input.url}`, + * }); + * if (!approved) return { error: 'User rejected the action' }; + * } + * return fetch(input.url); + * } + * ); + * ``` + */ + requestApproval(input: { + message: string; + metadata?: Record; + }): Promise<{ approved: boolean; reason?: string }>; +} + /** * Options for configuring tool behavior. */ @@ -37,13 +73,13 @@ export interface DefinedTool { /** Zod schema for validating input */ _zodSchema: T; /** The function to execute when the tool is called */ - fn: (input: z.infer) => unknown | Promise; + fn: (input: z.infer, ctx: ToolExecutionContext) => unknown | Promise; /** Configuration options for the tool */ _options: ToolOptions; /** Converts this tool to a ToolDefinition for registration with the server */ _toToolDefinition: (name: string) => ToolDefinition; /** Validates input and executes the tool function */ - _execute: (input: unknown) => Promise; + _execute: (input: unknown, ctx: ToolExecutionContext) => Promise; } /** @@ -65,7 +101,7 @@ export interface DefinedTool { */ export function defineTool( description: string, - fn: () => TReturn | Promise, + fn: (ctx: ToolExecutionContext) => TReturn | Promise, options?: ToolOptions ): DefinedTool>; @@ -97,7 +133,7 @@ export function defineTool( export function defineTool( description: string, schema: TSchema, - fn: (input: z.infer) => unknown | Promise, + fn: (input: z.infer, ctx: ToolExecutionContext) => unknown | Promise, options?: ToolOptions ): DefinedTool; @@ -107,21 +143,23 @@ export function defineTool( */ export function defineTool( description: string, - schemaOrFn: T | (() => unknown), - fnOrOptions?: ((input: z.infer) => unknown | Promise) | ToolOptions, + schemaOrFn: T | ((ctx: ToolExecutionContext) => unknown), + fnOrOptions?: ((input: z.infer, ctx: ToolExecutionContext) => unknown | Promise) | ToolOptions, options?: ToolOptions ): DefinedTool { const isNoParamFunction = typeof schemaOrFn === 'function'; const schema = (isNoParamFunction ? z.object({}) : schemaOrFn) as T; - let actualFn: (input: z.infer) => unknown | Promise; + let actualFn: (input: z.infer, ctx: ToolExecutionContext) => unknown | Promise; let actualOptions: ToolOptions; if (isNoParamFunction) { - actualFn = schemaOrFn as () => unknown | Promise; + // Wrap no-param function: user writes (ctx?) => ..., we adapt to (input, ctx) => ... + const noParamFn = schemaOrFn as (ctx: ToolExecutionContext) => unknown | Promise; + actualFn = (_input: z.infer, ctx: ToolExecutionContext) => noParamFn(ctx); actualOptions = (fnOrOptions as ToolOptions) || {}; } else { - actualFn = fnOrOptions as (input: z.infer) => unknown | Promise; + actualFn = fnOrOptions as (input: z.infer, ctx: ToolExecutionContext) => unknown | Promise; actualOptions = options || {}; } @@ -167,9 +205,9 @@ export function defineTool( return toolDef; }, - async _execute(input: unknown) { + async _execute(input: unknown, ctx: ToolExecutionContext) { const validated = this._zodSchema.parse(input); - return await actualFn(validated); + return await actualFn(validated, ctx); }, }; } @@ -204,11 +242,12 @@ export function convertToolsToDefinitions(tools: ToolsDefinition): ToolDefinitio export async function executeDefinedTool( tools: ToolsDefinition, toolName: string, - input: unknown + input: unknown, + ctx: ToolExecutionContext ): Promise { const tool = tools[toolName]; if (!tool) { throw new Error(`Tool "${toolName}" not found`); } - return await tool._execute(input); + return await tool._execute(input, ctx); } diff --git a/packages/client/src/hooks/useStableTools.ts b/packages/client/src/hooks/useStableTools.ts index a2e094e5..1dda8290 100644 --- a/packages/client/src/hooks/useStableTools.ts +++ b/packages/client/src/hooks/useStableTools.ts @@ -88,21 +88,21 @@ function createStableToolWrapper( latestToolsRef: React.MutableRefObject ): DefinedTool { // Create a stable handler that proxies to the latest version - const stableHandler = (input: unknown) => { + const stableHandler = (input: unknown, ctx: import('../defineTool').ToolExecutionContext) => { const currentTool = latestToolsRef.current[name]; if (!currentTool) { throw new Error(`Tool "${name}" no longer exists`); } - return currentTool.fn(input); + return currentTool.fn(input, ctx); }; // Create the stable _execute function - const stableExecute = async (input: unknown) => { + const stableExecute = async (input: unknown, ctx: import('../defineTool').ToolExecutionContext) => { const currentTool = latestToolsRef.current[name]; if (!currentTool) { throw new Error(`Tool "${name}" no longer exists`); } - return await currentTool._execute(input); + return await currentTool._execute(input, ctx); }; return { diff --git a/packages/client/src/hooks/useToolSystem.ts b/packages/client/src/hooks/useToolSystem.ts index 936f3ef1..218dd6f6 100644 --- a/packages/client/src/hooks/useToolSystem.ts +++ b/packages/client/src/hooks/useToolSystem.ts @@ -1,7 +1,7 @@ import { useState, useCallback, useRef, useMemo, type RefObject, type MutableRefObject } from 'react'; import type { ToolAnnotations, ToolApprovalRequestEvent } from '../types'; import type { UseAIClient } from '../client'; -import type { ToolsDefinition } from '../defineTool'; +import type { ToolsDefinition, ToolExecutionContext } from '../defineTool'; import { executeDefinedTool } from '../defineTool'; // ── Registry Types ────────────────────────────────────────────────────────── @@ -21,6 +21,10 @@ export interface PendingToolApproval { toolCallName: string; toolCallArgs: Record; annotations?: ToolAnnotations; + /** Optional message explaining why approval is needed (runtime approval) */ + message?: string; + /** Optional metadata for the approval request (runtime approval) */ + metadata?: Record; } // ── Hook Options & Return ─────────────────────────────────────────────────── @@ -112,6 +116,9 @@ export function useToolSystem({ const [pendingApprovals, setPendingApprovals] = useState([]); const pendingApprovalToolCallsRef = useRef>(new Map()); + /** Resolvers for runtime approval requests (from ctx.requestApproval) */ + const runtimeApprovalResolversRef = useRef void>>(new Map()); + // ── Registry Methods ──────────────────────────────────────────────────── const registerTools = useCallback(( @@ -270,6 +277,8 @@ export function useToolSystem({ toolCallName: event.toolCallName, toolCallArgs: event.toolCallArgs, annotations: event.annotations, + message: event.message, + metadata: event.metadata, }, ]); }, []); @@ -289,8 +298,29 @@ export function useToolSystem({ const ownerId = toolOwnershipRef.current.get(name); console.log(`[useToolSystem] Tool "${name}" owned by component:`, ownerId); + // Build ToolExecutionContext with requestApproval for runtime approvals + const ctx: ToolExecutionContext = { + requestApproval: ({ message, metadata }) => { + return new Promise<{ approved: boolean; reason?: string }>((resolve) => { + const approvalId = `${toolCallId}-runtime-${Date.now()}`; + runtimeApprovalResolversRef.current.set(approvalId, resolve); + + setPendingApprovals(prev => [ + ...prev, + { + toolCallId: approvalId, + toolCallName: name, + toolCallArgs: (input as Record) || {}, + message, + metadata, + }, + ]); + }); + }, + }; + console.log('[useToolSystem] Executing tool...'); - const result = await executeDefinedTool(aggregatedToolsRef.current, name, input); + const result = await executeDefinedTool(aggregatedToolsRef.current, name, input, ctx); const isErrorResult = result && typeof result === 'object' && ('error' in result || (result as Record).success === false); @@ -354,15 +384,19 @@ export function useToolSystem({ console.log('[useToolSystem] Approving all tool calls:', pendingApprovals.length); const pendingTools = [...pendingApprovals]; - - for (const pending of pendingTools) { - clientRef.current.sendToolApprovalResponse(pending.toolCallId, true); - } - setPendingApprovals([]); - for (const tool of pendingTools) { - await executePendingToolAfterApproval(tool.toolCallId); + for (const pending of pendingTools) { + // Check if this is a runtime client-side approval (from ctx.requestApproval) + const runtimeResolver = runtimeApprovalResolversRef.current.get(pending.toolCallId); + if (runtimeResolver) { + runtimeApprovalResolversRef.current.delete(pending.toolCallId); + runtimeResolver({ approved: true }); + } else { + // Server-side approval (destructiveHint flow) + clientRef.current.sendToolApprovalResponse(pending.toolCallId, true); + await executePendingToolAfterApproval(pending.toolCallId); + } } }, [clientRef, pendingApprovals, executePendingToolAfterApproval]); @@ -371,15 +405,19 @@ export function useToolSystem({ console.log('[useToolSystem] Rejecting all tool calls:', pendingApprovals.length, reason); const pendingTools = [...pendingApprovals]; - - for (const pending of pendingTools) { - clientRef.current.sendToolApprovalResponse(pending.toolCallId, false, reason); - } - setPendingApprovals([]); - for (const tool of pendingTools) { - pendingApprovalToolCallsRef.current.delete(tool.toolCallId); + for (const pending of pendingTools) { + // Check if this is a runtime client-side approval (from ctx.requestApproval) + const runtimeResolver = runtimeApprovalResolversRef.current.get(pending.toolCallId); + if (runtimeResolver) { + runtimeApprovalResolversRef.current.delete(pending.toolCallId); + runtimeResolver({ approved: false, reason }); + } else { + // Server-side approval (destructiveHint flow) + clientRef.current.sendToolApprovalResponse(pending.toolCallId, false, reason); + pendingApprovalToolCallsRef.current.delete(pending.toolCallId); + } } }, [clientRef, pendingApprovals]); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index c56df53e..1ffc4635 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -43,7 +43,7 @@ export type { UseAIProviderProps, } from './providers/useAIProvider'; export type { SendMessageOptions } from './hooks/useMessageQueue'; -export type { DefinedTool, ToolsDefinition, ToolOptions, ToolAnnotations } from './defineTool'; +export type { DefinedTool, ToolsDefinition, ToolOptions, ToolAnnotations, ToolExecutionContext } from './defineTool'; // Chat persistence export { LocalStorageChatRepository } from './providers/chatRepository/LocalStorageChatRepository'; diff --git a/packages/client/src/useAIWorkflow.ts b/packages/client/src/useAIWorkflow.ts index cc07c782..9aec3da1 100644 --- a/packages/client/src/useAIWorkflow.ts +++ b/packages/client/src/useAIWorkflow.ts @@ -188,7 +188,11 @@ export function useAIWorkflow(runner: string, workflowId: string): UseAIWorkflow try { // Execute the tool - const result = await executeDefinedTool(currentWorkflow.tools, toolName, toolArgs); + // Workflows are headless — no UI for runtime approval, so provide a no-op context + const noopCtx = { + requestApproval: async () => ({ approved: true }), + }; + const result = await executeDefinedTool(currentWorkflow.tools, toolName, toolArgs, noopCtx); // Track tool call currentWorkflow.toolCalls.push({ diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 626b35de..59ba1c78 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -261,6 +261,10 @@ export interface ToolApprovalRequestEvent { annotations?: ToolAnnotations; /** Timestamp when this event was generated */ timestamp: number; + /** Optional message explaining why approval is needed (runtime approval) */ + message?: string; + /** Optional metadata for the approval request (runtime approval) */ + metadata?: Record; } /** diff --git a/packages/server/src/agents/AISDKAgent.ts b/packages/server/src/agents/AISDKAgent.ts index 569a5ec8..e0db5f04 100644 --- a/packages/server/src/agents/AISDKAgent.ts +++ b/packages/server/src/agents/AISDKAgent.ts @@ -905,7 +905,7 @@ export class AISDKAgent implements Agent { if (isRemoteTool(toolDef)) { baseExecutor = this.createMcpToolExecutor(toolDef, session); } else if (isServerTool(toolDef)) { - baseExecutor = createServerToolExecutor(toolDef, session); + baseExecutor = createServerToolExecutor(toolDef, session, events); } else { baseExecutor = clientToolExecutor; } diff --git a/packages/server/src/tools/defineServerTool.test.ts b/packages/server/src/tools/defineServerTool.test.ts index 8f216a04..09d5b281 100644 --- a/packages/server/src/tools/defineServerTool.test.ts +++ b/packages/server/src/tools/defineServerTool.test.ts @@ -66,11 +66,12 @@ describe('defineServerTool', () => { } ); - const mockContext = { + const mockContext: ServerToolContext = { session: {} as ServerToolContext['session'], state: null, runId: 'run-1', toolCallId: 'tc-1', + requestApproval: async () => ({ approved: true }), }; const result = await tool.execute({ value: 'hello' }, mockContext); @@ -126,11 +127,12 @@ describe('defineServerTool', () => { async () => 'now' ); - const mockContext = { + const mockContext: ServerToolContext = { session: {} as ServerToolContext['session'], state: null, runId: 'run-1', toolCallId: 'tc-1', + requestApproval: async () => ({ approved: true }), }; const result = await tool.execute({}, mockContext); diff --git a/packages/server/src/tools/serverToolExecutor.test.ts b/packages/server/src/tools/serverToolExecutor.test.ts index 89d17662..e8c338ca 100644 --- a/packages/server/src/tools/serverToolExecutor.test.ts +++ b/packages/server/src/tools/serverToolExecutor.test.ts @@ -1,7 +1,16 @@ import { describe, expect, test } from 'bun:test'; import { createServerToolExecutor } from './serverToolExecutor'; import type { ServerToolDefinition, ServerToolContext } from './types'; -import type { ClientSession } from '../agents/types'; +import type { ClientSession, EventEmitter } from '../agents/types'; + +/** + * Helper to create a mock EventEmitter for testing + */ +function createMockEvents(): EventEmitter { + return { + emit: () => {}, + } as unknown as EventEmitter; +} /** * Helper to create a minimal session for testing @@ -46,7 +55,7 @@ describe('createServerToolExecutor', () => { }); const session = createTestSession({ state: { page: 'home' } }); - const executor = createServerToolExecutor(tool, session); + const executor = createServerToolExecutor(tool, session, createMockEvents()); const result = await executor({ city: 'Tokyo' }, { toolCallId: 'tc-1' }); expect(result).toEqual({ result: 'ok' }); @@ -63,7 +72,7 @@ describe('createServerToolExecutor', () => { }); const session = createTestSession(); - const executor = createServerToolExecutor(tool, session); + const executor = createServerToolExecutor(tool, session, createMockEvents()); await expect( executor({}, { toolCallId: 'tc-1' }) @@ -76,7 +85,7 @@ describe('createServerToolExecutor', () => { }); const session = createTestSession(); - const executor = createServerToolExecutor(tool, session); + const executor = createServerToolExecutor(tool, session, createMockEvents()); const result = await executor({ value: 21 }, { toolCallId: 'tc-1' }); expect(result).toEqual({ doubled: 42 }); @@ -91,7 +100,7 @@ describe('createServerToolExecutor', () => { }); const session = createTestSession({ currentRunId: undefined }); - const executor = createServerToolExecutor(tool, session); + const executor = createServerToolExecutor(tool, session, createMockEvents()); await executor({}, { toolCallId: 'tc-1' }); expect(capturedContext!.runId).toBe(''); @@ -106,7 +115,7 @@ describe('createServerToolExecutor', () => { }); const session = createTestSession({ state: { count: 0 } }); - const executor = createServerToolExecutor(tool, session); + const executor = createServerToolExecutor(tool, session, createMockEvents()); // Update state after creating executor but before calling it session.state = { count: 42 }; @@ -120,9 +129,90 @@ describe('createServerToolExecutor', () => { const tool = createTestServerTool(async () => 'result'); const session = createTestSession(); - const executor = createServerToolExecutor(tool, session); + const executor = createServerToolExecutor(tool, session, createMockEvents()); await executor({}, { toolCallId: 'tc-1' }); expect(session.pendingToolCalls.size).toBe(0); }); + + test('provides requestApproval in context', async () => { + let hasRequestApproval = false; + + const tool = createTestServerTool(async (_args, context) => { + hasRequestApproval = typeof context.requestApproval === 'function'; + return 'ok'; + }); + + const session = createTestSession(); + const executor = createServerToolExecutor(tool, session, createMockEvents()); + await executor({}, { toolCallId: 'tc-1' }); + + expect(hasRequestApproval).toBe(true); + }); + + test('requestApproval emits TOOL_APPROVAL_REQUEST event', async () => { + const emittedEvents: unknown[] = []; + const mockEvents = { + emit: (event: unknown) => { emittedEvents.push(event); }, + } as unknown as EventEmitter; + + const tool = createTestServerTool(async (_args, context) => { + // Start requestApproval but don't await — we'll resolve it via session + const approvalPromise = context.requestApproval({ + message: 'Confirm production deploy?', + metadata: { env: 'production' }, + }); + + // Simulate client approving after a tick + setTimeout(() => { + // Find the approvalId from the emitted event + const event = emittedEvents[0] as { toolCallId: string }; + const resolver = session.pendingToolApprovals.get(event.toolCallId); + resolver?.({ approved: true }); + }, 10); + + return approvalPromise; + }); + + const session = createTestSession(); + const executor = createServerToolExecutor(tool, session, mockEvents); + const result = await executor({}, { toolCallId: 'tc-1' }); + + expect(result).toEqual({ approved: true }); + expect(emittedEvents.length).toBe(1); + + const event = emittedEvents[0] as Record; + expect(event.type).toBe('TOOL_APPROVAL_REQUEST'); + expect(event.toolCallName).toBe('test_tool'); + expect(event.message).toBe('Confirm production deploy?'); + expect(event.metadata).toEqual({ env: 'production' }); + expect((event.toolCallId as string).startsWith('tc-1-approval-')).toBe(true); + }); + + test('requestApproval resolves with rejection when user rejects', async () => { + const emittedEvents: unknown[] = []; + const mockEvents = { + emit: (event: unknown) => { emittedEvents.push(event); }, + } as unknown as EventEmitter; + + const tool = createTestServerTool(async (_args, context) => { + const approvalPromise = context.requestApproval({ + message: 'Delete all data?', + }); + + setTimeout(() => { + const event = emittedEvents[0] as { toolCallId: string }; + const resolver = session.pendingToolApprovals.get(event.toolCallId); + resolver?.({ approved: false, reason: 'Too dangerous' }); + }, 10); + + return approvalPromise; + }); + + const session = createTestSession(); + const executor = createServerToolExecutor(tool, session, mockEvents); + const result = await executor({}, { toolCallId: 'tc-2' }); + + expect(result).toEqual({ approved: false, reason: 'Too dangerous' }); + }); }); diff --git a/packages/server/src/tools/serverToolExecutor.ts b/packages/server/src/tools/serverToolExecutor.ts index dd95c46e..d4339054 100644 --- a/packages/server/src/tools/serverToolExecutor.ts +++ b/packages/server/src/tools/serverToolExecutor.ts @@ -1,5 +1,8 @@ -import type { ClientSession } from '../agents/types'; +import type { ClientSession, EventEmitter } from '../agents/types'; +import type { ToolApprovalRequestEvent } from '../types'; +import { TOOL_APPROVAL_REQUEST } from '../types'; import type { ServerToolDefinition, ServerToolContext } from './types'; +import { waitForApproval } from '../agents/toolApproval'; import { logger } from '../logger'; /** Generic tool arguments type */ @@ -14,11 +17,13 @@ type ToolResult = unknown; * * @param serverTool - The server tool definition with execute function * @param session - The client session (for context) + * @param events - Event emitter for sending approval requests to client * @returns An async function compatible with the ToolExecutor signature */ export function createServerToolExecutor( serverTool: ServerToolDefinition, - session: ClientSession + session: ClientSession, + events: EventEmitter ): (args: ToolArguments, options: { toolCallId: string }) => Promise { return async (args: ToolArguments, { toolCallId }): Promise => { logger.info('[Server Tool] Executing', { @@ -31,6 +36,28 @@ export function createServerToolExecutor( state: session.state, runId: session.currentRunId || '', toolCallId, + requestApproval: async ({ message, metadata }) => { + const approvalId = `${toolCallId}-approval-${Date.now()}`; + + logger.info('[Server Tool] Runtime approval requested', { + toolName: serverTool.name, + toolCallId, + approvalId, + message, + }); + + events.emit({ + type: TOOL_APPROVAL_REQUEST, + toolCallId: approvalId, + toolCallName: serverTool.name, + toolCallArgs: args, + timestamp: Date.now(), + message, + metadata, + }); + + return waitForApproval(session, approvalId); + }, }; try { diff --git a/packages/server/src/tools/types.ts b/packages/server/src/tools/types.ts index 1e2eaf20..a6c335e3 100644 --- a/packages/server/src/tools/types.ts +++ b/packages/server/src/tools/types.ts @@ -14,6 +14,17 @@ export interface ServerToolContext { runId: string; /** Unique identifier for this specific tool call */ toolCallId: string; + /** + * Request user approval during tool execution. + * Use this for conditional approval based on runtime values. + * + * @param input - Approval request details + * @returns Promise resolving with approval decision + */ + requestApproval(input: { + message: string; + metadata?: Record; + }): Promise<{ approved: boolean; reason?: string }>; } /** From e294f4a0177135b470b989a56f6815a24810c849 Mon Sep 17 00:00:00 2001 From: masudahiroto <96814344+masudahiroto@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:57:57 +0900 Subject: [PATCH 02/11] feat: add MCP tool runtime interactive approval via two-phase confirmation MCP tools can now request user confirmation by returning a JSON response with `confirmation_required: true`. The server intercepts this, shows the approval dialog, and if approved, calls the specified execution tool on the same MCP endpoint (phase 2). Co-Authored-By: Claude Opus 4.6 --- apps/example/src/App.tsx | 9 + .../src/pages/McpRuntimeApprovalPage.tsx | 163 +++++++++ packages/server/src/agents/AISDKAgent.ts | 20 +- packages/server/src/index.ts | 3 + packages/server/src/mcp/index.ts | 1 + .../server/src/mcp/mcpConfirmation.test.ts | 316 ++++++++++++++++++ packages/server/src/mcp/mcpConfirmation.ts | 132 ++++++++ 7 files changed, 642 insertions(+), 2 deletions(-) create mode 100644 apps/example/src/pages/McpRuntimeApprovalPage.tsx create mode 100644 packages/server/src/mcp/mcpConfirmation.test.ts create mode 100644 packages/server/src/mcp/mcpConfirmation.ts diff --git a/apps/example/src/App.tsx b/apps/example/src/App.tsx index 8b2d6222..1c5ead71 100644 --- a/apps/example/src/App.tsx +++ b/apps/example/src/App.tsx @@ -19,6 +19,9 @@ import CustomUIPage from './pages/CustomUIPage'; import ThemeI18nPage from './pages/ThemeI18nPage'; import SuggestionsPage from './pages/SuggestionsPage'; import DestructiveApprovalPage from './pages/DestructiveApprovalPage'; +import RuntimeApprovalPage from './pages/RuntimeApprovalPage'; +import ServerRuntimeApprovalPage from './pages/ServerRuntimeApprovalPage'; +import McpRuntimeApprovalPage from './pages/McpRuntimeApprovalPage'; import MultimodalPage from './pages/MultimodalPage'; import MultiAgentPage from './pages/MultiAgentPage'; import { NavigationAIProvider } from './providers/NavigationAIProvider'; @@ -63,6 +66,9 @@ const NAV_CATEGORIES: NavCategory[] = [ { path: '/theme-i18n', label: 'Theme & i18n' }, { path: '/suggestions', label: 'Suggestions' }, { path: '/destructive-approval', label: 'Destructive Approval' }, + { path: '/runtime-approval', label: 'Runtime Approval (Client)' }, + { path: '/server-runtime-approval', label: 'Runtime Approval (Server)' }, + { path: '/mcp-runtime-approval', label: 'Runtime Approval (MCP)' }, ], }, { @@ -164,6 +170,9 @@ function AppContent() { + + + diff --git a/apps/example/src/pages/McpRuntimeApprovalPage.tsx b/apps/example/src/pages/McpRuntimeApprovalPage.tsx new file mode 100644 index 00000000..91d5069b --- /dev/null +++ b/apps/example/src/pages/McpRuntimeApprovalPage.tsx @@ -0,0 +1,163 @@ +import React from 'react'; +import { useAI } from '@meetsmore-oss/use-ai-client'; +import { CollapsibleCode } from '../components/CollapsibleCode'; +import { docStyles } from '../styles/docStyles'; + +const PYTHON_EXAMPLE = [ + '# Phase 1: check tool — returns confirmation response', + '@server.tool("transfer")', + 'async def transfer(to: str, amount: float):', + ' if amount > 1000:', + ' return {', + ' "confirmation_required": True,', + ' "message": f"Transfer ${amount} to {to}. Are you sure?",', + ' "metadata": {"amount": amount, "to": to},', + ' "execute_on_approval": {', + ' "tool": "confirm_transfer",', + ' "args": {"to": to, "amount": amount, "confirmed": True}', + ' }', + ' }', + ' # Small amounts proceed directly', + ' return do_transfer(to, amount)', + '', + '# Phase 2: execution tool — called by server after approval', + '@server.tool("confirm_transfer")', + 'async def confirm_transfer(to: str, amount: float, confirmed: bool):', + ' return do_transfer(to, amount)', +].join('\n'); + +const SEQUENCE_DIAGRAM = [ + 'Client Server MCP Endpoint', + ' | | |', + ' | run_agent | |', + ' |---------------->| tools/call (ph.1) |', + ' | |--------------------->|', + ' | | { confirmation_ |', + ' | | required: true } |', + ' | |<---------------------|', + ' | TOOL_APPROVAL | |', + ' | _REQUEST | |', + ' |<----------------| |', + ' | (user approves) | |', + ' |---------------->| tools/call (ph.2) |', + ' | |--------------------->|', + ' | | { success: true } |', + ' | |<---------------------|', + ' | final result | |', + ' |<----------------| |', +].join('\n'); + +export default function McpRuntimeApprovalPage() { + useAI({ + tools: {}, + prompt: `MCP Runtime Approval Demo Page. + +This page explains how MCP tools can request runtime user confirmation using the two-phase confirmation pattern. +There is no live MCP endpoint connected — this is a documentation page. + +Help the user understand the MCP confirmation flow.`, + suggestions: [ + 'How does MCP runtime approval work?', + 'What is the two-phase confirmation pattern?', + ], + }); + + return ( +
+

MCP Runtime Approval

+ +
+

About

+

+ MCP tools run on remote servers and cannot call{' '} + ctx.requestApproval() directly. + Instead, they use a two-phase confirmation pattern: + the tool returns a special JSON response, the server intercepts it, + shows the approval dialog, and if approved, calls the execution tool + on the same MCP endpoint. +

+
+ +
+

Phase 1: Confirmation Response

+

+ The MCP tool returns this JSON structure instead of a normal result: +

+ +{`{ + "confirmation_required": true, + "message": "Transfer $5000 to Bob. Are you sure?", + "metadata": { "amount": 5000, "to": "Bob" }, + "execute_on_approval": { + "tool": "confirm_transfer", + "args": { "to": "Bob", "amount": 5000, "confirmed": true } + } +}`} + +
+ +
+

Phase 2: Execution

+

+ The server detects confirmation_required: true, + emits a TOOL_APPROVAL_REQUEST event to the client, + and waits for user approval. If approved, it calls{' '} + execute_on_approval.tool with the specified args + on the same MCP endpoint. +

+
+ +
+

MCP Endpoint Example (Python)

+ {PYTHON_EXAMPLE} +
+ +
+

Approval Flow Comparison

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Client ToolServer ToolMCP Tool
ExecutionBrowserServerRemote MCP endpoint
Approval triggersetPendingApprovalsctx.requestApproval()confirmation_required response
Wait mechanismReact state + refwaitForApproval()waitForApproval()
UISame dialogSame dialogSame dialog
+
+ +
+

Sequence

+
+          {SEQUENCE_DIAGRAM}
+        
+
+
+ ); +} diff --git a/packages/server/src/agents/AISDKAgent.ts b/packages/server/src/agents/AISDKAgent.ts index e0db5f04..c1514c1f 100644 --- a/packages/server/src/agents/AISDKAgent.ts +++ b/packages/server/src/agents/AISDKAgent.ts @@ -5,6 +5,7 @@ import { z } from 'zod'; import type { Agent, AgentInput, EventEmitter, AgentResult, ClientSession } from './types'; import type { ToolDefinition, UseAIForwardedProps } from '../types'; import type { RemoteToolDefinition } from '../mcp'; +import { isMcpConfirmationResponse, handleMcpConfirmation } from '../mcp/mcpConfirmation'; import { EventType, ErrorCode } from '../types'; import { createClientToolExecutor } from '../utils/toolConverter'; import { isRemoteTool, isServerTool } from '../utils/toolFilters'; @@ -857,7 +858,8 @@ export class AISDKAgent implements Agent { */ private createMcpToolExecutor( remoteTool: RemoteToolDefinition, - session: ClientSession + session: ClientSession, + events: EventEmitter ): (args: ToolArguments, options: { toolCallId: string }) => Promise { return async (args: ToolArguments, { toolCallId }) => { logger.info('[MCP] Executing remote tool', { @@ -871,6 +873,20 @@ export class AISDKAgent implements Agent { args, session.currentMcpHeaders // Pass MCP headers from current request ); + + // Intercept MCP confirmation responses (phase 1 → approval → phase 2) + if (isMcpConfirmationResponse(result)) { + return handleMcpConfirmation( + result, + toolCallId, + remoteTool.name, + remoteTool._remote.provider, + session, + events, + session.currentMcpHeaders + ); + } + return result; } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); @@ -903,7 +919,7 @@ export class AISDKAgent implements Agent { // Get the base executor based on tool type let baseExecutor; if (isRemoteTool(toolDef)) { - baseExecutor = this.createMcpToolExecutor(toolDef, session); + baseExecutor = this.createMcpToolExecutor(toolDef, session, events); } else if (isServerTool(toolDef)) { baseExecutor = createServerToolExecutor(toolDef, session, events); } else { diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 5d3f572f..8ec0c83d 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -17,6 +17,9 @@ export { logger } from './logger'; export { defineServerTool } from './tools'; export type { ServerToolConfig, ServerToolContext, ServerToolDefinition } from './tools'; +// Export MCP confirmation types for consumers +export { isMcpConfirmationResponse, type McpConfirmationResponse } from './mcp'; + // Export utilities for plugins and custom agents export { createClientToolExecutor, diff --git a/packages/server/src/mcp/index.ts b/packages/server/src/mcp/index.ts index ef873829..b9d69cf4 100644 --- a/packages/server/src/mcp/index.ts +++ b/packages/server/src/mcp/index.ts @@ -1 +1,2 @@ export { RemoteMcpToolsProvider, type RemoteToolDefinition } from './RemoteMcpToolsProvider'; +export { isMcpConfirmationResponse, type McpConfirmationResponse } from './mcpConfirmation'; diff --git a/packages/server/src/mcp/mcpConfirmation.test.ts b/packages/server/src/mcp/mcpConfirmation.test.ts new file mode 100644 index 00000000..e251ab45 --- /dev/null +++ b/packages/server/src/mcp/mcpConfirmation.test.ts @@ -0,0 +1,316 @@ +import { describe, expect, test, mock } from 'bun:test'; +import { + isMcpConfirmationResponse, + handleMcpConfirmation, + type McpConfirmationResponse, +} from './mcpConfirmation'; +import type { ClientSession, EventEmitter } from '../agents/types'; +import type { RemoteMcpToolsProvider } from './RemoteMcpToolsProvider'; +import { TOOL_APPROVAL_REQUEST } from '../types'; + +/** + * Helper to create a minimal session for testing + */ +function createTestSession(overrides: Partial = {}): ClientSession { + return { + clientId: 'client-1', + ipAddress: '127.0.0.1', + socket: {} as never, + threadId: 'thread-1', + tools: [], + state: null, + conversationHistory: [], + pendingToolCalls: new Map(), + pendingToolApprovals: new Map(), + abortController: new AbortController(), + ...overrides, + }; +} + +/** + * Helper to create a mock EventEmitter + */ +function createMockEmitter(): EventEmitter & { emittedEvents: unknown[] } { + const emittedEvents: unknown[] = []; + return { + emit: (event: unknown) => { emittedEvents.push(event); }, + emittedEvents, + } as EventEmitter & { emittedEvents: unknown[] }; +} + +/** + * Helper to create a mock MCP provider + */ +function createMockProvider( + executeResult: unknown = { success: true } +): RemoteMcpToolsProvider & { executedCalls: { toolName: string; args: unknown }[] } { + const executedCalls: { toolName: string; args: unknown }[] = []; + return { + executeTool: async (toolName: string, args: unknown) => { + executedCalls.push({ toolName, args }); + return executeResult; + }, + executedCalls, + } as RemoteMcpToolsProvider & { executedCalls: { toolName: string; args: unknown }[] }; +} + +describe('isMcpConfirmationResponse', () => { + test('returns true for valid confirmation response', () => { + expect(isMcpConfirmationResponse({ + confirmation_required: true, + message: 'Are you sure?', + execute_on_approval: { + tool: 'confirm_action', + args: { id: 1 }, + }, + })).toBe(true); + }); + + test('returns true with optional metadata', () => { + expect(isMcpConfirmationResponse({ + confirmation_required: true, + message: 'Transfer $5000?', + metadata: { amount: 5000, to: 'Bob' }, + execute_on_approval: { + tool: 'confirm_transfer', + args: { to: 'Bob', amount: 5000 }, + }, + })).toBe(true); + }); + + test('returns false for null', () => { + expect(isMcpConfirmationResponse(null)).toBe(false); + }); + + test('returns false for undefined', () => { + expect(isMcpConfirmationResponse(undefined)).toBe(false); + }); + + test('returns false for non-object', () => { + expect(isMcpConfirmationResponse('string')).toBe(false); + expect(isMcpConfirmationResponse(42)).toBe(false); + }); + + test('returns false when confirmation_required is not true', () => { + expect(isMcpConfirmationResponse({ + confirmation_required: false, + message: 'msg', + execute_on_approval: { tool: 't', args: {} }, + })).toBe(false); + }); + + test('returns false when message is missing', () => { + expect(isMcpConfirmationResponse({ + confirmation_required: true, + execute_on_approval: { tool: 't', args: {} }, + })).toBe(false); + }); + + test('returns false when execute_on_approval is missing', () => { + expect(isMcpConfirmationResponse({ + confirmation_required: true, + message: 'msg', + })).toBe(false); + }); + + test('returns false when execute_on_approval.tool is missing', () => { + expect(isMcpConfirmationResponse({ + confirmation_required: true, + message: 'msg', + execute_on_approval: { args: {} }, + })).toBe(false); + }); + + test('returns false when execute_on_approval.args is missing', () => { + expect(isMcpConfirmationResponse({ + confirmation_required: true, + message: 'msg', + execute_on_approval: { tool: 't' }, + })).toBe(false); + }); + + test('returns false for normal tool result', () => { + expect(isMcpConfirmationResponse({ success: true, data: 'ok' })).toBe(false); + }); +}); + +describe('handleMcpConfirmation', () => { + const confirmation: McpConfirmationResponse = { + confirmation_required: true, + message: 'Transfer $5000 to Bob. Are you sure?', + metadata: { amount: 5000, to: 'Bob' }, + execute_on_approval: { + tool: 'confirm_transfer', + args: { to: 'Bob', amount: 5000, confirmed: true }, + }, + }; + + test('emits TOOL_APPROVAL_REQUEST event', async () => { + const session = createTestSession(); + const events = createMockEmitter(); + const provider = createMockProvider(); + + // Start the handler (it will wait for approval) + const promise = handleMcpConfirmation( + confirmation, + 'tool-call-1', + 'ns_transfer', + provider, + session, + events + ); + + // Verify event was emitted + expect(events.emittedEvents).toHaveLength(1); + const emitted = events.emittedEvents[0] as Record; + expect(emitted.type).toBe(TOOL_APPROVAL_REQUEST); + expect(emitted.toolCallId).toBe('tool-call-1'); + expect(emitted.toolCallName).toBe('ns_transfer'); + expect(emitted.message).toBe('Transfer $5000 to Bob. Are you sure?'); + expect(emitted.metadata).toEqual({ amount: 5000, to: 'Bob' }); + expect(emitted.toolCallArgs).toEqual({ to: 'Bob', amount: 5000, confirmed: true }); + + // Resolve approval to complete the promise + const resolver = session.pendingToolApprovals.get('tool-call-1'); + resolver!({ approved: true }); + await promise; + }); + + test('calls phase-2 tool when approved', async () => { + const session = createTestSession(); + const events = createMockEmitter(); + const provider = createMockProvider({ success: true, message: 'Transferred' }); + + const promise = handleMcpConfirmation( + confirmation, + 'tool-call-1', + 'ns_transfer', + provider, + session, + events + ); + + // Approve + const resolver = session.pendingToolApprovals.get('tool-call-1'); + resolver!({ approved: true }); + + const result = await promise; + + // Verify phase-2 was called with correct tool/args + expect(provider.executedCalls).toHaveLength(1); + expect(provider.executedCalls[0].toolName).toBe('confirm_transfer'); + expect(provider.executedCalls[0].args).toEqual({ to: 'Bob', amount: 5000, confirmed: true }); + expect(result).toEqual({ success: true, message: 'Transferred' }); + }); + + test('returns error result when rejected', async () => { + const session = createTestSession(); + const events = createMockEmitter(); + const provider = createMockProvider(); + + const promise = handleMcpConfirmation( + confirmation, + 'tool-call-1', + 'ns_transfer', + provider, + session, + events + ); + + // Reject + const resolver = session.pendingToolApprovals.get('tool-call-1'); + resolver!({ approved: false, reason: 'Too expensive' }); + + const result = await promise; + + // Verify no phase-2 call was made + expect(provider.executedCalls).toHaveLength(0); + expect(result).toEqual({ + error: true, + message: 'Tool execution denied by user: Too expensive', + }); + }); + + test('returns error result when rejection has no reason', async () => { + const session = createTestSession(); + const events = createMockEmitter(); + const provider = createMockProvider(); + + const promise = handleMcpConfirmation( + confirmation, + 'tool-call-1', + 'ns_transfer', + provider, + session, + events + ); + + const resolver = session.pendingToolApprovals.get('tool-call-1'); + resolver!({ approved: false }); + + const result = await promise; + expect(result).toEqual({ + error: true, + message: 'Tool execution denied by user: Action was rejected', + }); + }); + + test('catches phase-2 execution error gracefully', async () => { + const session = createTestSession(); + const events = createMockEmitter(); + + // Provider that throws on executeTool + const provider = { + executeTool: async () => { throw new Error('MCP endpoint down'); }, + } as unknown as RemoteMcpToolsProvider; + + const promise = handleMcpConfirmation( + confirmation, + 'tool-call-1', + 'ns_transfer', + provider, + session, + events + ); + + // Approve + const resolver = session.pendingToolApprovals.get('tool-call-1'); + resolver!({ approved: true }); + + const result = await promise; + expect(result).toEqual({ + error: true, + message: 'MCP confirmation execution failed: MCP endpoint down', + }); + }); + + test('passes MCP headers to phase-2 call', async () => { + const session = createTestSession(); + const events = createMockEmitter(); + + const mcpHeaders = { 'https://example.com/*': { headers: { Authorization: 'Bearer tok' } } }; + let capturedHeaders: unknown; + const provider = { + executeTool: async (_name: string, _args: unknown, headers: unknown) => { + capturedHeaders = headers; + return { success: true }; + }, + } as unknown as RemoteMcpToolsProvider; + + const promise = handleMcpConfirmation( + confirmation, + 'tool-call-1', + 'ns_transfer', + provider, + session, + events, + mcpHeaders + ); + + const resolver = session.pendingToolApprovals.get('tool-call-1'); + resolver!({ approved: true }); + await promise; + + expect(capturedHeaders).toBe(mcpHeaders); + }); +}); diff --git a/packages/server/src/mcp/mcpConfirmation.ts b/packages/server/src/mcp/mcpConfirmation.ts new file mode 100644 index 00000000..967f033d --- /dev/null +++ b/packages/server/src/mcp/mcpConfirmation.ts @@ -0,0 +1,132 @@ +/** + * MCP tool runtime interactive approval. + * + * MCP tools that need user confirmation return a special JSON response with + * `confirmation_required: true`. The server intercepts this, shows an approval + * dialog to the user, and if approved, calls the specified execution tool on + * the same MCP endpoint (phase 2). + */ + +import type { ClientSession, EventEmitter } from '../agents/types'; +import type { ToolApprovalRequestEvent } from '../types'; +import { TOOL_APPROVAL_REQUEST } from '../types'; +import { waitForApproval } from '../agents/toolApproval'; +import type { RemoteMcpToolsProvider } from './RemoteMcpToolsProvider'; +import type { McpHeadersMap } from '@meetsmore-oss/use-ai-core'; +import { logger } from '../logger'; + +/** + * Response shape returned by MCP tools that require user confirmation. + * The server detects this via the `confirmation_required` sentinel field. + */ +export interface McpConfirmationResponse { + /** Sentinel field — must be `true` */ + confirmation_required: true; + /** Message shown in the approval dialog */ + message: string; + /** Optional metadata passed through to the approval dialog */ + metadata?: Record; + /** Tool to call on the same MCP endpoint if the user approves */ + execute_on_approval: { + /** MCP tool name (original, without namespace) */ + tool: string; + /** Arguments to pass to the tool */ + args: Record; + }; +} + +/** + * Type guard that checks whether a tool result is an MCP confirmation response. + */ +export function isMcpConfirmationResponse( + value: unknown +): value is McpConfirmationResponse { + if (value == null || typeof value !== 'object') return false; + const obj = value as Record; + return ( + obj.confirmation_required === true && + typeof obj.message === 'string' && + obj.execute_on_approval != null && + typeof obj.execute_on_approval === 'object' && + typeof (obj.execute_on_approval as Record).tool === 'string' && + (obj.execute_on_approval as Record).args != null && + typeof (obj.execute_on_approval as Record).args === 'object' + ); +} + +/** + * Handles an MCP confirmation response: + * 1. Emits TOOL_APPROVAL_REQUEST to the client + * 2. Waits for user approval via waitForApproval() + * 3. If approved → calls provider.executeTool() with execute_on_approval tool/args + * 4. If rejected → returns error result + * + * Phase-2 results are returned as-is (no re-interception). + */ +export async function handleMcpConfirmation( + confirmation: McpConfirmationResponse, + toolCallId: string, + toolCallName: string, + provider: RemoteMcpToolsProvider, + session: ClientSession, + events: EventEmitter, + mcpHeaders?: McpHeadersMap +): Promise { + logger.info('[MCP] Tool returned confirmation_required', { + toolCallId, + toolCallName, + message: confirmation.message, + phase2Tool: confirmation.execute_on_approval.tool, + }); + + // Emit approval request event to the client + events.emit({ + type: TOOL_APPROVAL_REQUEST, + toolCallId, + toolCallName, + toolCallArgs: confirmation.execute_on_approval.args, + message: confirmation.message, + metadata: confirmation.metadata, + timestamp: Date.now(), + }); + + // Wait for user response + const approvalResult = await waitForApproval(session, toolCallId); + + if (!approvalResult.approved) { + logger.info('[MCP] Confirmation rejected by user', { + toolCallId, + reason: approvalResult.reason, + }); + return { + error: true, + message: `Tool execution denied by user: ${approvalResult.reason || 'Action was rejected'}`, + }; + } + + logger.info('[MCP] Confirmation approved, executing phase 2', { + toolCallId, + phase2Tool: confirmation.execute_on_approval.tool, + }); + + // Phase 2: call the execution tool on the same MCP endpoint + try { + const result = await provider.executeTool( + confirmation.execute_on_approval.tool, + confirmation.execute_on_approval.args, + mcpHeaders + ); + return result; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + logger.error('[MCP] Phase 2 execution failed', { + toolCallId, + phase2Tool: confirmation.execute_on_approval.tool, + error: errorMsg, + }); + return { + error: true, + message: `MCP confirmation execution failed: ${errorMsg}`, + }; + } +} From 10bdadb1894d737e2b92a0f68fb755195e563793 Mon Sep 17 00:00:00 2001 From: masudahiroto <96814344+masudahiroto@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:31:31 +0900 Subject: [PATCH 03/11] feat: add runtime approval example pages and server transfer tool Add RuntimeApprovalPage (client) and ServerRuntimeApprovalPage (server) demo pages, and register serverTransfer tool with ctx.requestApproval() for amounts over $1,000. Co-Authored-By: Claude Opus 4.6 --- .../example/src/pages/RuntimeApprovalPage.tsx | 214 ++++++++++++++++++ .../src/pages/ServerRuntimeApprovalPage.tsx | 119 ++++++++++ apps/use-ai-server-app/src/index.ts | 24 ++ 3 files changed, 357 insertions(+) create mode 100644 apps/example/src/pages/RuntimeApprovalPage.tsx create mode 100644 apps/example/src/pages/ServerRuntimeApprovalPage.tsx diff --git a/apps/example/src/pages/RuntimeApprovalPage.tsx b/apps/example/src/pages/RuntimeApprovalPage.tsx new file mode 100644 index 00000000..ba095811 --- /dev/null +++ b/apps/example/src/pages/RuntimeApprovalPage.tsx @@ -0,0 +1,214 @@ +import React, { useState } from 'react'; +import { useAI, defineTool } from '@meetsmore-oss/use-ai-client'; +import { z } from 'zod'; +import { CollapsibleCode } from '../components/CollapsibleCode'; +import { docStyles } from '../styles/docStyles'; + +export default function RuntimeApprovalPage() { + const [log, setLog] = useState([]); + const [balance, setBalance] = useState(10000); + + const addLog = (msg: string) => + setLog((prev) => [ + ...prev, + `[${new Date().toLocaleTimeString()}] ${msg}`, + ]); + + const tools = { + checkBalance: defineTool( + 'Check the current account balance', + () => { + addLog('Checked balance'); + return { balance }; + }, + { annotations: { readOnlyHint: true, title: 'Check Balance' } } + ), + + transfer: defineTool( + 'Transfer money to another account. Requires user approval for large amounts (over 1000).', + z.object({ + to: z.string().describe('Recipient account name'), + amount: z.number().describe('Amount to transfer'), + }), + async (input, ctx) => { + if (input.amount > 1000) { + addLog( + `Large transfer detected: $${input.amount} to ${input.to} — requesting approval...` + ); + const { approved, reason } = await ctx.requestApproval({ + message: `Transfer $${input.amount} to "${input.to}"? This exceeds the $1,000 threshold.`, + metadata: { amount: input.amount, to: input.to }, + }); + if (!approved) { + addLog( + `Transfer REJECTED by user${reason ? `: ${reason}` : ''}` + ); + return { + error: 'User rejected the transfer', + reason, + }; + } + addLog(`Transfer APPROVED by user`); + } + setBalance((prev) => prev - input.amount); + addLog(`Transferred $${input.amount} to ${input.to}`); + return { + success: true, + message: `Transferred $${input.amount} to ${input.to}`, + newBalance: balance - input.amount, + }; + } + ), + + resetBalance: defineTool('Reset account balance to $10,000', () => { + setBalance(10000); + addLog('Balance reset to $10,000'); + return { success: true, balance: 10000 }; + }), + }; + + useAI({ + tools, + prompt: `Runtime Approval Demo — Bank Account. Current balance: $${balance}. Transfers over $1,000 require user approval via ctx.requestApproval().`, + suggestions: [ + 'Transfer $500 to Alice', + 'Transfer $2000 to Bob', + 'Transfer $100 to Carol and $5000 to Dave', + ], + }); + + return ( +
+

Runtime Interactive Approval

+ +
+

About

+

+ Unlike static destructiveHint, + runtime approval uses{' '} + ctx.requestApproval() inside the + tool function to conditionally ask for user confirmation based on + runtime values. +

+

+ In this demo, transfers under $1,000 execute immediately, while + larger transfers pause and prompt the user for approval. +

+
+ +
+

Code Example

+ + {`const transfer = defineTool( + 'Transfer money', + z.object({ to: z.string(), amount: z.number() }), + async (input, ctx) => { + if (input.amount > 1000) { + const { approved } = await ctx.requestApproval({ + message: \`Transfer $\${input.amount} to "\${input.to}"?\`, + }); + if (!approved) return { error: 'Rejected' }; + } + // proceed with transfer... + } +);`} + +
+ +
+

Interactive Demo

+

+ Try: "Transfer $500 to Alice" (no approval needed) vs "Transfer + $2000 to Bob" (approval required). +

+ +
+ Account Balance + ${balance.toLocaleString()} +
+ + {log.length > 0 && ( +
+
+

Action Log

+ +
+
+ {log.map((entry, i) => ( +
+ {entry} +
+ ))} +
+
+ )} +
+
+ ); +} + +const styles: Record = { + balanceCard: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: '16px 20px', + background: '#f0fdf4', + border: '1px solid #bbf7d0', + borderRadius: '8px', + marginBottom: '16px', + }, + balanceLabel: { + fontSize: '14px', + fontWeight: '600', + color: '#166534', + }, + balanceValue: { + fontSize: '24px', + fontWeight: '700', + color: '#15803d', + }, + listTitle: { + fontSize: '14px', + fontWeight: '600', + color: '#374151', + marginBottom: '8px', + marginTop: 0, + }, + logSection: { + marginTop: '16px', + }, + logHeader: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: '8px', + }, + clearButton: { + padding: '4px 10px', + background: '#fef2f2', + border: '1px solid #fca5a5', + borderRadius: '4px', + cursor: 'pointer', + fontSize: '12px', + color: '#dc2626', + }, + logContainer: { + background: '#1f2937', + borderRadius: '6px', + padding: '12px', + maxHeight: '200px', + overflowY: 'auto', + fontFamily: 'monospace', + }, + logEntry: { + color: '#d1d5db', + fontSize: '12px', + lineHeight: '1.5', + }, +}; diff --git a/apps/example/src/pages/ServerRuntimeApprovalPage.tsx b/apps/example/src/pages/ServerRuntimeApprovalPage.tsx new file mode 100644 index 00000000..db29b6c9 --- /dev/null +++ b/apps/example/src/pages/ServerRuntimeApprovalPage.tsx @@ -0,0 +1,119 @@ +import React from 'react'; +import { useAI } from '@meetsmore-oss/use-ai-client'; +import { CollapsibleCode } from '../components/CollapsibleCode'; +import { docStyles } from '../styles/docStyles'; + +export default function ServerRuntimeApprovalPage() { + useAI({ + tools: {}, + prompt: `Server Runtime Approval Demo Page. + +This page demonstrates server-side tools that use ctx.requestApproval() for runtime interactive approval. +The following server tool is available: +- serverTransfer: Transfer money. Transfers over $1,000 require user approval via ctx.requestApproval(). + +Help the user test the server-side runtime approval flow. The tool runs server-side — there is no client-side state to track.`, + suggestions: [ + 'Transfer $500 to Alice (server)', + 'Transfer $2000 to Bob (server)', + ], + }); + + return ( +
+

Server Runtime Approval

+ +
+

Prerequisites

+

+ Server tools require ENABLE_EXAMPLE_SERVER_TOOLS=true in + your .env file. Restart the server after changing it. +

+
+ +
+

About

+

+ This page tests ctx.requestApproval() on{' '} + server-side tools. Unlike client tools (which resolve approval + via React state), server tools send a{' '} + TOOL_APPROVAL_REQUEST event over Socket.IO + and wait for the client's response via{' '} + waitForApproval(). +

+

+ The approval dialog looks the same to the user, but the underlying mechanism is different. +

+
+ +
+

Server-Side Code

+ +{`// In server config (apps/use-ai-server-app/src/index.ts) +serverTransfer: defineServerTool( + 'Transfer money between accounts', + z.object({ + to: z.string(), + amount: z.number(), + }), + async ({ to, amount }, ctx) => { + if (amount > 1000) { + const { approved, reason } = await ctx.requestApproval({ + message: \`Transfer $\${amount} to "\${to}"?\`, + metadata: { amount, to, source: 'server' }, + }); + if (!approved) { + return { error: 'User rejected', reason }; + } + } + return { success: true, message: \`Transferred $\${amount} to \${to}\` }; + } +)`} + +
+ +
+

Client vs Server Approval Flow

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Client ToolServer Tool
ExecutionBrowser (React)Server (Node/Bun)
Approval triggersetPendingApprovals (React state)events.emit(TOOL_APPROVAL_REQUEST)
Wait mechanismPromise + runtimeApprovalResolversRefwaitForApproval(session, approvalId)
UISame ToolApprovalDialogSame ToolApprovalDialog
+
+ +
+

Interactive Demo

+

+ Try: "Transfer $500 to Alice" (no approval) vs "Transfer $2000 to Bob" (approval required). + The tool executes on the server — there is no client-side balance to display. +

+
+
+ ); +} diff --git a/apps/use-ai-server-app/src/index.ts b/apps/use-ai-server-app/src/index.ts index aa5228d9..f914499a 100644 --- a/apps/use-ai-server-app/src/index.ts +++ b/apps/use-ai-server-app/src/index.ts @@ -184,6 +184,30 @@ function createServerTools(): Record | undefined { async ({ a, b }) => ({ result: a + b }), { annotations: { readOnlyHint: true } } ), + serverTransfer: defineServerTool( + 'Transfer money between accounts on the server side. Transfers over $1000 require user approval via ctx.requestApproval().', + z.object({ + to: z.string().describe('Recipient account name'), + amount: z.number().describe('Amount to transfer'), + }), + async ({ to, amount }, ctx) => { + if (amount > 1000) { + const { approved, reason } = await ctx.requestApproval({ + message: `[Server Tool] Transfer $${amount} to "${to}"? This exceeds the $1,000 threshold.`, + metadata: { amount, to, source: 'server' }, + }); + if (!approved) { + return { error: 'User rejected the transfer', reason }; + } + } + return { + success: true, + message: `Server transferred $${amount} to ${to}`, + amount, + to, + }; + } + ), }; if (logFormat === 'pretty') { From 7cfd24ac95bf64b2dd94bd5a4fd1760f0446656f Mon Sep 17 00:00:00 2001 From: masudahiroto <96814344+masudahiroto@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:54:04 +0900 Subject: [PATCH 04/11] feat: add MCP transfer/confirm_transfer tools and update example page Add transfer and confirm_transfer tools to the MCP test server that demonstrate the two-phase confirmation pattern. Update the example page to be an interactive demo instead of documentation-only. Co-Authored-By: Claude Opus 4.6 --- .../src/tools.service.ts | 82 +++++++++++++++++++ .../src/pages/McpRuntimeApprovalPage.tsx | 79 +++++++----------- 2 files changed, 112 insertions(+), 49 deletions(-) diff --git a/apps/example-nest-mcp-server/src/tools.service.ts b/apps/example-nest-mcp-server/src/tools.service.ts index 2806c95f..fbc74ed4 100644 --- a/apps/example-nest-mcp-server/src/tools.service.ts +++ b/apps/example-nest-mcp-server/src/tools.service.ts @@ -104,6 +104,88 @@ export class ToolsService { }; } + @Tool({ + name: 'transfer', + description: '[MCP] Transfer money to a recipient via the remote MCP endpoint. Transfers over $1000 require user confirmation via the two-phase approval flow. This is an MCP tool (not a server tool).', + parameters: z.object({ + to: z.string().describe('Recipient name'), + amount: z.number().describe('Amount to transfer'), + }), + annotations: { + title: 'Transferring Money', + }, + }) + async transfer({ to, amount }: { to: string; amount: number }) { + if (amount > 1000) { + // Phase 1: Return confirmation_required response + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + confirmation_required: true, + message: `Transfer $${amount} to "${to}". Are you sure?`, + metadata: { amount, to }, + execute_on_approval: { + tool: 'confirm_transfer', + args: { to, amount, confirmed: true }, + }, + }), + }, + ], + }; + } + // Small amounts proceed directly + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + message: `Transferred $${amount} to ${to}`, + }), + }, + ], + }; + } + + @Tool({ + name: 'confirm_transfer', + description: '[MCP] Execute a confirmed money transfer via the remote MCP endpoint (phase 2 of the two-phase approval flow). This tool is called automatically by the server after user approval — do not call it directly.', + parameters: z.object({ + to: z.string().describe('Recipient name'), + amount: z.number().describe('Amount to transfer'), + confirmed: z.boolean().describe('Must be true'), + }), + annotations: { + title: 'Executing Transfer', + }, + }) + async confirmTransfer({ to, amount, confirmed }: { to: string; amount: number; confirmed: boolean }) { + if (!confirmed) { + return { + content: [ + { + type: 'text', + text: JSON.stringify({ error: true, message: 'Transfer not confirmed' }), + }, + ], + isError: true, + }; + } + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + message: `Transferred $${amount} to ${to} (confirmed)`, + }), + }, + ], + }; + } + @Tool({ name: 'get_secure_data', description: 'Get secure data (requires authentication via X-API-Key header)', diff --git a/apps/example/src/pages/McpRuntimeApprovalPage.tsx b/apps/example/src/pages/McpRuntimeApprovalPage.tsx index 91d5069b..c0931f79 100644 --- a/apps/example/src/pages/McpRuntimeApprovalPage.tsx +++ b/apps/example/src/pages/McpRuntimeApprovalPage.tsx @@ -26,39 +26,24 @@ const PYTHON_EXAMPLE = [ ' return do_transfer(to, amount)', ].join('\n'); -const SEQUENCE_DIAGRAM = [ - 'Client Server MCP Endpoint', - ' | | |', - ' | run_agent | |', - ' |---------------->| tools/call (ph.1) |', - ' | |--------------------->|', - ' | | { confirmation_ |', - ' | | required: true } |', - ' | |<---------------------|', - ' | TOOL_APPROVAL | |', - ' | _REQUEST | |', - ' |<----------------| |', - ' | (user approves) | |', - ' |---------------->| tools/call (ph.2) |', - ' | |--------------------->|', - ' | | { success: true } |', - ' | |<---------------------|', - ' | final result | |', - ' |<----------------| |', -].join('\n'); - export default function McpRuntimeApprovalPage() { useAI({ tools: {}, prompt: `MCP Runtime Approval Demo Page. -This page explains how MCP tools can request runtime user confirmation using the two-phase confirmation pattern. -There is no live MCP endpoint connected — this is a documentation page. +This page demonstrates MCP tools that use the two-phase confirmation pattern. +The following remote MCP tools (prefixed with "mcp_") are available: +- mcp_transfer: [MCP] Transfer money via the remote MCP endpoint. Transfers over $1,000 return a confirmation_required response which triggers the approval dialog. +- mcp_confirm_transfer: [MCP] Phase-2 execution tool, called automatically by the server after user approval. Do NOT call this tool directly. -Help the user understand the MCP confirmation flow.`, +IMPORTANT: Always use the mcp_transfer tool (the MCP tool), NOT the serverTransfer tool (which is a different server-side tool). + +Help the user test the MCP confirmation flow: +- Small transfers (e.g. $500) should proceed directly without approval. +- Large transfers (e.g. $2000) should show an approval dialog first.`, suggestions: [ - 'How does MCP runtime approval work?', - 'What is the two-phase confirmation pattern?', + 'Transfer $500 to Alice', + 'Transfer $5000 to Bob', ], }); @@ -66,23 +51,30 @@ Help the user understand the MCP confirmation flow.`,

MCP Runtime Approval

+
+

Prerequisites

+

+ The MCP server must be running on localhost:3002 with + the transfer and confirm_transfer tools + registered. +

+
+

About

MCP tools run on remote servers and cannot call{' '} ctx.requestApproval() directly. Instead, they use a two-phase confirmation pattern: - the tool returns a special JSON response, the server intercepts it, - shows the approval dialog, and if approved, calls the execution tool - on the same MCP endpoint. + the tool returns a special JSON response with{' '} + confirmation_required: true, + the server intercepts it, shows the approval dialog, and if approved, + calls the execution tool on the same MCP endpoint.

-

Phase 1: Confirmation Response

-

- The MCP tool returns this JSON structure instead of a normal result: -

+

Confirmation Response Schema

{`{ "confirmation_required": true, @@ -97,18 +89,7 @@ Help the user understand the MCP confirmation flow.`,
-

Phase 2: Execution

-

- The server detects confirmation_required: true, - emits a TOOL_APPROVAL_REQUEST event to the client, - and waits for user approval. If approved, it calls{' '} - execute_on_approval.tool with the specified args - on the same MCP endpoint. -

-
- -
-

MCP Endpoint Example (Python)

+

MCP Endpoint Code

{PYTHON_EXAMPLE}
@@ -153,10 +134,10 @@ Help the user understand the MCP confirmation flow.`,
-

Sequence

-
-          {SEQUENCE_DIAGRAM}
-        
+

Interactive Demo

+

+ Try: "Transfer $500 to Alice" (no approval) vs "Transfer $5000 to Bob" (approval required). +

); From 153841dfc63543be623840d8d33679f9612db52b Mon Sep 17 00:00:00 2001 From: masudahiroto <96814344+masudahiroto@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:07:34 +0900 Subject: [PATCH 05/11] refactor: use single transfer tool with one-time token for MCP approval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace separate transfer + confirm_transfer tools with a single transfer tool using a token parameter. The token is server-generated, one-time use, parameter-bound, and expires after 5 minutes — preventing AI from bypassing the approval flow. Co-Authored-By: Claude Opus 4.6 --- .../src/tools.service.ts | 100 +++++++++--------- .../src/pages/McpRuntimeApprovalPage.tsx | 75 +++++++------ 2 files changed, 92 insertions(+), 83 deletions(-) diff --git a/apps/example-nest-mcp-server/src/tools.service.ts b/apps/example-nest-mcp-server/src/tools.service.ts index fbc74ed4..4d4b550d 100644 --- a/apps/example-nest-mcp-server/src/tools.service.ts +++ b/apps/example-nest-mcp-server/src/tools.service.ts @@ -2,8 +2,16 @@ import { Injectable, Inject, Scope } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { Tool } from '@rekog/mcp-nest'; import { z } from 'zod'; +import { randomUUID } from 'crypto'; import type { Request } from 'express'; +/** + * In-memory store for one-time approval tokens. + * Each token is tied to specific transfer parameters and expires after 5 minutes. + * Module-scoped so it persists across request-scoped service instances. + */ +const pendingTokens = new Map(); + @Injectable({ scope: Scope.REQUEST }) export class ToolsService { constructor(@Inject(REQUEST) private readonly request: Request) {} @@ -106,18 +114,52 @@ export class ToolsService { @Tool({ name: 'transfer', - description: '[MCP] Transfer money to a recipient via the remote MCP endpoint. Transfers over $1000 require user confirmation via the two-phase approval flow. This is an MCP tool (not a server tool).', + description: '[MCP] Transfer money to a recipient via the remote MCP endpoint. Transfers over $1000 require user confirmation. On the first call, pass token as null. If confirmation is needed, the server issues a one-time token and asks for approval. The server will re-call this tool with the issued token after the user approves.', parameters: z.object({ to: z.string().describe('Recipient name'), amount: z.number().describe('Amount to transfer'), + token: z.string().nullable().describe('One-time approval token. Pass null on the first call. The server issues and provides this token automatically after user approval — never fabricate a token.'), }), annotations: { title: 'Transferring Money', }, }) - async transfer({ to, amount }: { to: string; amount: number }) { + async transfer({ to, amount, token }: { to: string; amount: number; token: string | null }) { + // Phase 2: token provided — validate and execute + if (token != null) { + const stored = pendingTokens.get(token); + if (!stored) { + return { + content: [{ type: 'text', text: JSON.stringify({ error: true, message: 'Invalid or expired token' }) }], + isError: true, + }; + } + // Consume the token (one-time use) + pendingTokens.delete(token); + + if (stored.expiresAt < Date.now()) { + return { + content: [{ type: 'text', text: JSON.stringify({ error: true, message: 'Token expired' }) }], + isError: true, + }; + } + if (stored.to !== to || stored.amount !== amount) { + return { + content: [{ type: 'text', text: JSON.stringify({ error: true, message: 'Token does not match transfer parameters' }) }], + isError: true, + }; + } + + return { + content: [{ type: 'text', text: JSON.stringify({ success: true, message: `Transferred $${amount} to ${to} (confirmed)` }) }], + }; + } + + // Phase 1: no token — check if approval is needed if (amount > 1000) { - // Phase 1: Return confirmation_required response + const approvalToken = randomUUID(); + pendingTokens.set(approvalToken, { to, amount, expiresAt: Date.now() + 5 * 60 * 1000 }); + return { content: [ { @@ -127,62 +169,18 @@ export class ToolsService { message: `Transfer $${amount} to "${to}". Are you sure?`, metadata: { amount, to }, execute_on_approval: { - tool: 'confirm_transfer', - args: { to, amount, confirmed: true }, + tool: 'transfer', + args: { to, amount, token: approvalToken }, }, }), }, ], }; } - // Small amounts proceed directly - return { - content: [ - { - type: 'text', - text: JSON.stringify({ - success: true, - message: `Transferred $${amount} to ${to}`, - }), - }, - ], - }; - } - @Tool({ - name: 'confirm_transfer', - description: '[MCP] Execute a confirmed money transfer via the remote MCP endpoint (phase 2 of the two-phase approval flow). This tool is called automatically by the server after user approval — do not call it directly.', - parameters: z.object({ - to: z.string().describe('Recipient name'), - amount: z.number().describe('Amount to transfer'), - confirmed: z.boolean().describe('Must be true'), - }), - annotations: { - title: 'Executing Transfer', - }, - }) - async confirmTransfer({ to, amount, confirmed }: { to: string; amount: number; confirmed: boolean }) { - if (!confirmed) { - return { - content: [ - { - type: 'text', - text: JSON.stringify({ error: true, message: 'Transfer not confirmed' }), - }, - ], - isError: true, - }; - } + // Small amounts proceed directly return { - content: [ - { - type: 'text', - text: JSON.stringify({ - success: true, - message: `Transferred $${amount} to ${to} (confirmed)`, - }), - }, - ], + content: [{ type: 'text', text: JSON.stringify({ success: true, message: `Transferred $${amount} to ${to}` }) }], }; } diff --git a/apps/example/src/pages/McpRuntimeApprovalPage.tsx b/apps/example/src/pages/McpRuntimeApprovalPage.tsx index c0931f79..665b841c 100644 --- a/apps/example/src/pages/McpRuntimeApprovalPage.tsx +++ b/apps/example/src/pages/McpRuntimeApprovalPage.tsx @@ -3,27 +3,32 @@ import { useAI } from '@meetsmore-oss/use-ai-client'; import { CollapsibleCode } from '../components/CollapsibleCode'; import { docStyles } from '../styles/docStyles'; -const PYTHON_EXAMPLE = [ - '# Phase 1: check tool — returns confirmation response', +const TOKEN_EXAMPLE = [ + '# Single tool with token-based approval', '@server.tool("transfer")', - 'async def transfer(to: str, amount: float):', + 'async def transfer(to: str, amount: float, token: str | None):', + ' # Phase 2: token provided — validate and execute', + ' if token is not None:', + ' stored = pending_tokens.pop(token, None)', + ' if not stored or stored["to"] != to or stored["amount"] != amount:', + ' return {"error": True, "message": "Invalid token"}', + ' return {"success": True, "message": f"Transferred ${amount} to {to}"}', + '', + ' # Phase 1: no token — check if approval is needed', ' if amount > 1000:', + ' approval_token = generate_token()', + ' pending_tokens[approval_token] = {"to": to, "amount": amount}', ' return {', ' "confirmation_required": True,', ' "message": f"Transfer ${amount} to {to}. Are you sure?",', - ' "metadata": {"amount": amount, "to": to},', ' "execute_on_approval": {', - ' "tool": "confirm_transfer",', - ' "args": {"to": to, "amount": amount, "confirmed": True}', + ' "tool": "transfer", # same tool!', + ' "args": {"to": to, "amount": amount, "token": approval_token}', ' }', ' }', - ' # Small amounts proceed directly', - ' return do_transfer(to, amount)', '', - '# Phase 2: execution tool — called by server after approval', - '@server.tool("confirm_transfer")', - 'async def confirm_transfer(to: str, amount: float, confirmed: bool):', - ' return do_transfer(to, amount)', + ' # Small amounts proceed directly', + ' return {"success": True, "message": f"Transferred ${amount} to {to}"}', ].join('\n'); export default function McpRuntimeApprovalPage() { @@ -31,12 +36,12 @@ export default function McpRuntimeApprovalPage() { tools: {}, prompt: `MCP Runtime Approval Demo Page. -This page demonstrates MCP tools that use the two-phase confirmation pattern. -The following remote MCP tools (prefixed with "mcp_") are available: -- mcp_transfer: [MCP] Transfer money via the remote MCP endpoint. Transfers over $1,000 return a confirmation_required response which triggers the approval dialog. -- mcp_confirm_transfer: [MCP] Phase-2 execution tool, called automatically by the server after user approval. Do NOT call this tool directly. +This page demonstrates MCP tools that use the two-phase confirmation pattern with token-based security. +The following remote MCP tool (prefixed with "mcp_") is available: +- mcp_transfer: Transfer money via the remote MCP endpoint. Pass token as null on the first call. Transfers over $1,000 require user approval — the server issues a one-time token and re-calls the same tool after approval. -IMPORTANT: Always use the mcp_transfer tool (the MCP tool), NOT the serverTransfer tool (which is a different server-side tool). +IMPORTANT: Always use the mcp_transfer tool (the MCP tool), NOT the serverTransfer tool. +IMPORTANT: Always pass token as null. Never fabricate or guess a token value. Help the user test the MCP confirmation flow: - Small transfers (e.g. $500) should proceed directly without approval. @@ -55,8 +60,7 @@ Help the user test the MCP confirmation flow:

Prerequisites

The MCP server must be running on localhost:3002 with - the transfer and confirm_transfer tools - registered. + the transfer tool registered.

@@ -65,11 +69,18 @@ Help the user test the MCP confirmation flow:

MCP tools run on remote servers and cannot call{' '} ctx.requestApproval() directly. - Instead, they use a two-phase confirmation pattern: - the tool returns a special JSON response with{' '} - confirmation_required: true, - the server intercepts it, shows the approval dialog, and if approved, - calls the execution tool on the same MCP endpoint. + Instead, they use a two-phase confirmation pattern with + a single tool and a token parameter: +

+
    +
  1. First call: token: null — if amount > $1,000, returns confirmation_required with a server-issued one-time token
  2. +
  3. Server shows approval dialog to the user
  4. +
  5. If approved: server re-calls the same tool with the issued token
  6. +
  7. Tool validates token (one-time, parameter-bound, expiring) and executes
  8. +
+

+ The AI cannot bypass approval because it never sees a valid token — tokens are + generated server-side and consumed on use.

@@ -81,8 +92,8 @@ Help the user test the MCP confirmation flow: "message": "Transfer $5000 to Bob. Are you sure?", "metadata": { "amount": 5000, "to": "Bob" }, "execute_on_approval": { - "tool": "confirm_transfer", - "args": { "to": "Bob", "amount": 5000, "confirmed": true } + "tool": "transfer", + "args": { "to": "Bob", "amount": 5000, "token": "" } } }`} @@ -90,7 +101,7 @@ Help the user test the MCP confirmation flow:

MCP Endpoint Code

- {PYTHON_EXAMPLE} + {TOKEN_EXAMPLE}
@@ -115,13 +126,13 @@ Help the user test the MCP confirmation flow: Approval trigger setPendingApprovals ctx.requestApproval() - confirmation_required response + confirmation_required + token - Wait mechanism - React state + ref - waitForApproval() - waitForApproval() + Bypass prevention + Client-side only + Server-side ctx + One-time token validation UI From e695f0b1fc2b36c345255192ee3673b89033a07b Mon Sep 17 00:00:00 2001 From: masudahiroto <96814344+masudahiroto@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:49:39 +0900 Subject: [PATCH 06/11] refactor: simplify MCP transfer tool to use fixed internal token The token never appears in the AI's context, so complex validation (UUID generation, parameter binding, expiry) is unnecessary. A simple fixed internal token suffices for bypass prevention. Co-Authored-By: Claude Opus 4.6 --- .../src/tools.service.ts | 64 ++++++------------- 1 file changed, 18 insertions(+), 46 deletions(-) diff --git a/apps/example-nest-mcp-server/src/tools.service.ts b/apps/example-nest-mcp-server/src/tools.service.ts index 4d4b550d..6773fa9c 100644 --- a/apps/example-nest-mcp-server/src/tools.service.ts +++ b/apps/example-nest-mcp-server/src/tools.service.ts @@ -2,15 +2,8 @@ import { Injectable, Inject, Scope } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { Tool } from '@rekog/mcp-nest'; import { z } from 'zod'; -import { randomUUID } from 'crypto'; import type { Request } from 'express'; -/** - * In-memory store for one-time approval tokens. - * Each token is tied to specific transfer parameters and expires after 5 minutes. - * Module-scoped so it persists across request-scoped service instances. - */ -const pendingTokens = new Map(); @Injectable({ scope: Scope.REQUEST }) export class ToolsService { @@ -114,52 +107,22 @@ export class ToolsService { @Tool({ name: 'transfer', - description: '[MCP] Transfer money to a recipient via the remote MCP endpoint. Transfers over $1000 require user confirmation. On the first call, pass token as null. If confirmation is needed, the server issues a one-time token and asks for approval. The server will re-call this tool with the issued token after the user approves.', + description: '[MCP] Transfer money to a recipient via the remote MCP endpoint.', parameters: z.object({ to: z.string().describe('Recipient name'), amount: z.number().describe('Amount to transfer'), - token: z.string().nullable().describe('One-time approval token. Pass null on the first call. The server issues and provides this token automatically after user approval — never fabricate a token.'), + token: z.string().nullable().describe('This is used for internal authentication. This will be filled automatically, so always set null.'), }), annotations: { title: 'Transferring Money', }, }) async transfer({ to, amount, token }: { to: string; amount: number; token: string | null }) { - // Phase 2: token provided — validate and execute - if (token != null) { - const stored = pendingTokens.get(token); - if (!stored) { - return { - content: [{ type: 'text', text: JSON.stringify({ error: true, message: 'Invalid or expired token' }) }], - isError: true, - }; - } - // Consume the token (one-time use) - pendingTokens.delete(token); - - if (stored.expiresAt < Date.now()) { - return { - content: [{ type: 'text', text: JSON.stringify({ error: true, message: 'Token expired' }) }], - isError: true, - }; - } - if (stored.to !== to || stored.amount !== amount) { - return { - content: [{ type: 'text', text: JSON.stringify({ error: true, message: 'Token does not match transfer parameters' }) }], - isError: true, - }; - } - - return { - content: [{ type: 'text', text: JSON.stringify({ success: true, message: `Transferred $${amount} to ${to} (confirmed)` }) }], - }; - } - - // Phase 1: no token — check if approval is needed - if (amount > 1000) { - const approvalToken = randomUUID(); - pendingTokens.set(approvalToken, { to, amount, expiresAt: Date.now() + 5 * 60 * 1000 }); + // This token will not be in the context of AI Agent. So it is OK to set some random fixed token. + const internal_token_password = "random_fixed_token" + if (!token && amount > 1000){ + // needs user confirmation return { content: [ { @@ -170,17 +133,26 @@ export class ToolsService { metadata: { amount, to }, execute_on_approval: { tool: 'transfer', - args: { to, amount, token: approvalToken }, + args: { to, amount, token: internal_token_password }, }, }), }, ], + }; + } + + // token is set but invalid + if (token && token !=internal_token_password) { + return { + content: [{ type: 'text', text: JSON.stringify({ error: true, message: 'Invalid token' }) }], + isError: true, }; } - // Small amounts proceed directly + // handle the request here + // await executeTransfer(...) return { - content: [{ type: 'text', text: JSON.stringify({ success: true, message: `Transferred $${amount} to ${to}` }) }], + content: [{ type: 'text', text: JSON.stringify({ success: true, message: `Transferred $${amount} to ${to} (confirmed)` }) }], }; } From c1520d583497ded7c083adb74b4ebf0f245baac5 Mon Sep 17 00:00:00 2001 From: masudahiroto <96814344+masudahiroto@users.noreply.github.com> Date: Thu, 5 Mar 2026 13:33:53 +0900 Subject: [PATCH 07/11] refactor: simplify MCP confirmation schema with _use_ai_ prefix and add E2E tests Replace old confirmation_required/execute_on_approval schema with _use_ai_internal/_use_ai_type/_use_ai_metadata to avoid namespace collisions with user data. Server now re-calls the same tool with original args merged with additional_columns instead of requiring redundant tool name and full args in the response. Also rename client-side transfer tool to clientTransfer for disambiguation, and add 9 E2E tests covering approve/deny/small-transfer flows across client, server, and MCP approval types. Co-Authored-By: Claude Opus 4.6 --- .../src/tools.service.ts | 14 +- .../src/pages/McpRuntimeApprovalPage.tsx | 31 ++-- .../example/src/pages/RuntimeApprovalPage.tsx | 2 +- .../test/client-runtime-approval.e2e.test.ts | 95 +++++++++++ .../test/mcp-runtime-approval.e2e.test.ts | 96 ++++++++++++ .../test/server-runtime-approval.e2e.test.ts | 95 +++++++++++ packages/server/src/agents/AISDKAgent.ts | 2 + .../server/src/mcp/mcpConfirmation.test.ts | 148 ++++++++++++------ packages/server/src/mcp/mcpConfirmation.ts | 81 +++++----- 9 files changed, 460 insertions(+), 104 deletions(-) create mode 100644 apps/example/test/client-runtime-approval.e2e.test.ts create mode 100644 apps/example/test/mcp-runtime-approval.e2e.test.ts create mode 100644 apps/example/test/server-runtime-approval.e2e.test.ts diff --git a/apps/example-nest-mcp-server/src/tools.service.ts b/apps/example-nest-mcp-server/src/tools.service.ts index 6773fa9c..7f029d94 100644 --- a/apps/example-nest-mcp-server/src/tools.service.ts +++ b/apps/example-nest-mcp-server/src/tools.service.ts @@ -128,17 +128,17 @@ export class ToolsService { { type: 'text', text: JSON.stringify({ - confirmation_required: true, - message: `Transfer $${amount} to "${to}". Are you sure?`, - metadata: { amount, to }, - execute_on_approval: { - tool: 'transfer', - args: { to, amount, token: internal_token_password }, + _use_ai_internal: true, + _use_ai_type: 'confirmation_required', + _use_ai_metadata: { + message: `Transfer $${amount} to "${to}". Are you sure?`, + metadata: { amount, to }, + additional_columns: { token: internal_token_password }, }, }), }, ], - }; + }; } // token is set but invalid diff --git a/apps/example/src/pages/McpRuntimeApprovalPage.tsx b/apps/example/src/pages/McpRuntimeApprovalPage.tsx index 665b841c..3ad8455d 100644 --- a/apps/example/src/pages/McpRuntimeApprovalPage.tsx +++ b/apps/example/src/pages/McpRuntimeApprovalPage.tsx @@ -19,11 +19,12 @@ const TOKEN_EXAMPLE = [ ' approval_token = generate_token()', ' pending_tokens[approval_token] = {"to": to, "amount": amount}', ' return {', - ' "confirmation_required": True,', - ' "message": f"Transfer ${amount} to {to}. Are you sure?",', - ' "execute_on_approval": {', - ' "tool": "transfer", # same tool!', - ' "args": {"to": to, "amount": amount, "token": approval_token}', + ' "_use_ai_internal": True,', + ' "_use_ai_type": "confirmation_required",', + ' "_use_ai_metadata": {', + ' "message": f"Transfer ${amount} to {to}. Are you sure?",', + ' "metadata": {"amount": amount, "to": to},', + ' "additional_columns": {"token": approval_token}', ' }', ' }', '', @@ -73,9 +74,9 @@ Help the user test the MCP confirmation flow: a single tool and a token parameter:

    -
  1. First call: token: null — if amount > $1,000, returns confirmation_required with a server-issued one-time token
  2. +
  3. First call: token: null — if amount > $1,000, returns _use_ai_type: "confirmation_required" with a server-issued one-time token in additional_columns
  4. Server shows approval dialog to the user
  5. -
  6. If approved: server re-calls the same tool with the issued token
  7. +
  8. If approved: server re-calls the same tool with original args merged with additional_columns
  9. Tool validates token (one-time, parameter-bound, expiring) and executes

@@ -88,12 +89,12 @@ Help the user test the MCP confirmation flow:

Confirmation Response Schema

{`{ - "confirmation_required": true, - "message": "Transfer $5000 to Bob. Are you sure?", - "metadata": { "amount": 5000, "to": "Bob" }, - "execute_on_approval": { - "tool": "transfer", - "args": { "to": "Bob", "amount": 5000, "token": "" } + "_use_ai_internal": true, + "_use_ai_type": "confirmation_required", + "_use_ai_metadata": { + "message": "Transfer $5000 to Bob. Are you sure?", + "metadata": { "amount": 5000, "to": "Bob" }, + "additional_columns": { "token": "" } } }`} @@ -126,13 +127,13 @@ Help the user test the MCP confirmation flow: Approval trigger setPendingApprovals ctx.requestApproval() - confirmation_required + token + _use_ai_type: "confirmation_required" Bypass prevention Client-side only Server-side ctx - One-time token validation + One-time token in additional_columns UI diff --git a/apps/example/src/pages/RuntimeApprovalPage.tsx b/apps/example/src/pages/RuntimeApprovalPage.tsx index ba095811..4e0bedcb 100644 --- a/apps/example/src/pages/RuntimeApprovalPage.tsx +++ b/apps/example/src/pages/RuntimeApprovalPage.tsx @@ -24,7 +24,7 @@ export default function RuntimeApprovalPage() { { annotations: { readOnlyHint: true, title: 'Check Balance' } } ), - transfer: defineTool( + clientTransfer: defineTool( 'Transfer money to another account. Requires user approval for large amounts (over 1000).', z.object({ to: z.string().describe('Recipient account name'), diff --git a/apps/example/test/client-runtime-approval.e2e.test.ts b/apps/example/test/client-runtime-approval.e2e.test.ts new file mode 100644 index 00000000..4a1942c5 --- /dev/null +++ b/apps/example/test/client-runtime-approval.e2e.test.ts @@ -0,0 +1,95 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Client Runtime Approval', () => { + test.setTimeout(120000); + + test.beforeAll(async () => { + if (!process.env.ANTHROPIC_API_KEY) { + console.log('Skipping E2E tests: ANTHROPIC_API_KEY environment variable not set'); + return; + } + }); + + test.beforeEach(async ({ page }) => { + if (!process.env.ANTHROPIC_API_KEY) { + test.skip(); + } + + await page.goto('/'); + await page.click('text=Runtime Approval (Client)'); + await expect(page.locator('h1:has-text("Runtime Interactive Approval")')).toBeVisible(); + }); + + async function openChat(page: import('@playwright/test').Page) { + const aiButton = page.getByTestId('ai-button'); + await expect(aiButton).toBeVisible({ timeout: 10000 }); + await aiButton.click(); + await expect(page.getByTestId('chat-input')).toBeVisible({ timeout: 5000 }); + + return { + chatInput: page.getByTestId('chat-input'), + sendButton: page.getByTestId('chat-send-button'), + approvalDialog: page.getByTestId('tool-approval-dialog'), + approveButton: page.getByTestId('approve-tool-button'), + rejectButton: page.getByTestId('reject-tool-button'), + }; + } + + test('small transfer proceeds without approval dialog', async ({ page }) => { + const { chatInput, sendButton, approvalDialog } = await openChat(page); + + await chatInput.fill('Use the clientTransfer tool to send $500 to Alice'); + await sendButton.click(); + + await page.waitForTimeout(2000); + await expect(async () => { + const messages = await page.getByTestId('chat-message-assistant').all(); + expect(messages.length).toBeGreaterThan(0); + const lastMessage = await messages[messages.length - 1].textContent(); + console.log(`[Test] Last message: ${lastMessage}`); + expect(lastMessage?.toLowerCase()).toMatch(/transfer|500|alice|success/); + }).toPass({ timeout: 60000, intervals: [1000] }); + + await expect(approvalDialog).not.toBeVisible(); + }); + + test('large transfer - approve completes transfer', async ({ page }) => { + const { chatInput, sendButton, approvalDialog, approveButton } = await openChat(page); + + await chatInput.fill('Use the clientTransfer tool to send $2000 to Bob'); + await sendButton.click(); + + await expect(approvalDialog).toBeVisible({ timeout: 60000 }); + const dialogText = await approvalDialog.textContent(); + expect(dialogText).toContain('Confirmation Required'); + + await approveButton.click(); + + await expect(async () => { + const messages = await page.getByTestId('chat-message-assistant').all(); + expect(messages.length).toBeGreaterThan(0); + const lastMessage = await messages[messages.length - 1].textContent(); + console.log(`[Test] Last message: ${lastMessage}`); + expect(lastMessage?.toLowerCase()).toMatch(/transfer|2000|bob|success/); + }).toPass({ timeout: 60000, intervals: [1000] }); + }); + + test('large transfer - deny prevents transfer', async ({ page }) => { + const { chatInput, sendButton, approvalDialog, rejectButton } = await openChat(page); + + await chatInput.fill('Use the clientTransfer tool to send $2000 to Bob'); + await sendButton.click(); + + await expect(approvalDialog).toBeVisible({ timeout: 60000 }); + + await rejectButton.click(); + + await expect(async () => { + const messages = await page.getByTestId('chat-message-assistant').all(); + expect(messages.length).toBeGreaterThan(0); + const lastMessage = await messages[messages.length - 1].textContent(); + console.log(`[Test] Last message: ${lastMessage}`); + expect(lastMessage?.toLowerCase()).toMatch(/denied|rejected|cancel/); + }).toPass({ timeout: 60000, intervals: [1000] }); + }); +}); diff --git a/apps/example/test/mcp-runtime-approval.e2e.test.ts b/apps/example/test/mcp-runtime-approval.e2e.test.ts new file mode 100644 index 00000000..a88d54bb --- /dev/null +++ b/apps/example/test/mcp-runtime-approval.e2e.test.ts @@ -0,0 +1,96 @@ +import { test, expect } from '@playwright/test'; + +test.describe('MCP Runtime Approval', () => { + test.setTimeout(120000); + + test.beforeAll(async () => { + if (!process.env.ANTHROPIC_API_KEY) { + console.log('Skipping E2E tests: ANTHROPIC_API_KEY environment variable not set'); + return; + } + console.log('Using MCP server on http://localhost:3002'); + }); + + test.beforeEach(async ({ page }) => { + if (!process.env.ANTHROPIC_API_KEY) { + test.skip(); + } + + await page.goto('/'); + await page.click('text=Runtime Approval (MCP)'); + await expect(page.locator('h1:has-text("MCP Runtime Approval")')).toBeVisible(); + }); + + async function openChat(page: import('@playwright/test').Page) { + const aiButton = page.getByTestId('ai-button'); + await expect(aiButton).toBeVisible({ timeout: 10000 }); + await aiButton.click(); + await expect(page.getByTestId('chat-input')).toBeVisible({ timeout: 5000 }); + + return { + chatInput: page.getByTestId('chat-input'), + sendButton: page.getByTestId('chat-send-button'), + approvalDialog: page.getByTestId('tool-approval-dialog'), + approveButton: page.getByTestId('approve-tool-button'), + rejectButton: page.getByTestId('reject-tool-button'), + }; + } + + test('small transfer proceeds without approval dialog', async ({ page }) => { + const { chatInput, sendButton, approvalDialog } = await openChat(page); + + await chatInput.fill('Use the mcp_transfer tool to send $500 to Alice'); + await sendButton.click(); + + await page.waitForTimeout(2000); + await expect(async () => { + const messages = await page.getByTestId('chat-message-assistant').all(); + expect(messages.length).toBeGreaterThan(0); + const lastMessage = await messages[messages.length - 1].textContent(); + console.log(`[Test] Last message: ${lastMessage}`); + expect(lastMessage?.toLowerCase()).toMatch(/transfer|500|alice|success/); + }).toPass({ timeout: 60000, intervals: [1000] }); + + await expect(approvalDialog).not.toBeVisible(); + }); + + test('large transfer - approve completes transfer', async ({ page }) => { + const { chatInput, sendButton, approvalDialog, approveButton } = await openChat(page); + + await chatInput.fill('Use the mcp_transfer tool to send $5000 to Bob'); + await sendButton.click(); + + await expect(approvalDialog).toBeVisible({ timeout: 60000 }); + const dialogText = await approvalDialog.textContent(); + expect(dialogText).toContain('Confirmation Required'); + + await approveButton.click(); + + await expect(async () => { + const messages = await page.getByTestId('chat-message-assistant').all(); + expect(messages.length).toBeGreaterThan(0); + const lastMessage = await messages[messages.length - 1].textContent(); + console.log(`[Test] Last message: ${lastMessage}`); + expect(lastMessage?.toLowerCase()).toMatch(/transfer|5000|bob|success|confirmed/); + }).toPass({ timeout: 60000, intervals: [1000] }); + }); + + test('large transfer - deny prevents transfer', async ({ page }) => { + const { chatInput, sendButton, approvalDialog, rejectButton } = await openChat(page); + + await chatInput.fill('Use the mcp_transfer tool to send $5000 to Bob'); + await sendButton.click(); + + await expect(approvalDialog).toBeVisible({ timeout: 60000 }); + + await rejectButton.click(); + + await expect(async () => { + const messages = await page.getByTestId('chat-message-assistant').all(); + expect(messages.length).toBeGreaterThan(0); + const lastMessage = await messages[messages.length - 1].textContent(); + console.log(`[Test] Last message: ${lastMessage}`); + expect(lastMessage?.toLowerCase()).toMatch(/denied|rejected|cancel/); + }).toPass({ timeout: 60000, intervals: [1000] }); + }); +}); diff --git a/apps/example/test/server-runtime-approval.e2e.test.ts b/apps/example/test/server-runtime-approval.e2e.test.ts new file mode 100644 index 00000000..a248445e --- /dev/null +++ b/apps/example/test/server-runtime-approval.e2e.test.ts @@ -0,0 +1,95 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Server Runtime Approval', () => { + test.setTimeout(120000); + + test.beforeAll(async () => { + if (!process.env.ANTHROPIC_API_KEY) { + console.log('Skipping E2E tests: ANTHROPIC_API_KEY environment variable not set'); + return; + } + }); + + test.beforeEach(async ({ page }) => { + if (!process.env.ANTHROPIC_API_KEY) { + test.skip(); + } + + await page.goto('/'); + await page.click('text=Runtime Approval (Server)'); + await expect(page.locator('h1:has-text("Server Runtime Approval")')).toBeVisible(); + }); + + async function openChat(page: import('@playwright/test').Page) { + const aiButton = page.getByTestId('ai-button'); + await expect(aiButton).toBeVisible({ timeout: 10000 }); + await aiButton.click(); + await expect(page.getByTestId('chat-input')).toBeVisible({ timeout: 5000 }); + + return { + chatInput: page.getByTestId('chat-input'), + sendButton: page.getByTestId('chat-send-button'), + approvalDialog: page.getByTestId('tool-approval-dialog'), + approveButton: page.getByTestId('approve-tool-button'), + rejectButton: page.getByTestId('reject-tool-button'), + }; + } + + test('small transfer proceeds without approval dialog', async ({ page }) => { + const { chatInput, sendButton, approvalDialog } = await openChat(page); + + await chatInput.fill('Use the serverTransfer tool to send $500 to Alice'); + await sendButton.click(); + + await page.waitForTimeout(2000); + await expect(async () => { + const messages = await page.getByTestId('chat-message-assistant').all(); + expect(messages.length).toBeGreaterThan(0); + const lastMessage = await messages[messages.length - 1].textContent(); + console.log(`[Test] Last message: ${lastMessage}`); + expect(lastMessage?.toLowerCase()).toMatch(/transfer|500|alice|success/); + }).toPass({ timeout: 60000, intervals: [1000] }); + + await expect(approvalDialog).not.toBeVisible(); + }); + + test('large transfer - approve completes transfer', async ({ page }) => { + const { chatInput, sendButton, approvalDialog, approveButton } = await openChat(page); + + await chatInput.fill('Use the serverTransfer tool to send $2000 to Bob'); + await sendButton.click(); + + await expect(approvalDialog).toBeVisible({ timeout: 60000 }); + const dialogText = await approvalDialog.textContent(); + expect(dialogText).toContain('Confirmation Required'); + + await approveButton.click(); + + await expect(async () => { + const messages = await page.getByTestId('chat-message-assistant').all(); + expect(messages.length).toBeGreaterThan(0); + const lastMessage = await messages[messages.length - 1].textContent(); + console.log(`[Test] Last message: ${lastMessage}`); + expect(lastMessage?.toLowerCase()).toMatch(/transfer|2000|bob|success/); + }).toPass({ timeout: 60000, intervals: [1000] }); + }); + + test('large transfer - deny prevents transfer', async ({ page }) => { + const { chatInput, sendButton, approvalDialog, rejectButton } = await openChat(page); + + await chatInput.fill('Use the serverTransfer tool to send $2000 to Bob'); + await sendButton.click(); + + await expect(approvalDialog).toBeVisible({ timeout: 60000 }); + + await rejectButton.click(); + + await expect(async () => { + const messages = await page.getByTestId('chat-message-assistant').all(); + expect(messages.length).toBeGreaterThan(0); + const lastMessage = await messages[messages.length - 1].textContent(); + console.log(`[Test] Last message: ${lastMessage}`); + expect(lastMessage?.toLowerCase()).toMatch(/denied|rejected|cancel/); + }).toPass({ timeout: 60000, intervals: [1000] }); + }); +}); diff --git a/packages/server/src/agents/AISDKAgent.ts b/packages/server/src/agents/AISDKAgent.ts index c1514c1f..cf9222d2 100644 --- a/packages/server/src/agents/AISDKAgent.ts +++ b/packages/server/src/agents/AISDKAgent.ts @@ -880,6 +880,8 @@ export class AISDKAgent implements Agent { result, toolCallId, remoteTool.name, + remoteTool._remote.originalName, + args as Record, remoteTool._remote.provider, session, events, diff --git a/packages/server/src/mcp/mcpConfirmation.test.ts b/packages/server/src/mcp/mcpConfirmation.test.ts index e251ab45..2d86c86f 100644 --- a/packages/server/src/mcp/mcpConfirmation.test.ts +++ b/packages/server/src/mcp/mcpConfirmation.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test, mock } from 'bun:test'; +import { describe, expect, test } from 'bun:test'; import { isMcpConfirmationResponse, handleMcpConfirmation, @@ -57,23 +57,22 @@ function createMockProvider( describe('isMcpConfirmationResponse', () => { test('returns true for valid confirmation response', () => { expect(isMcpConfirmationResponse({ - confirmation_required: true, - message: 'Are you sure?', - execute_on_approval: { - tool: 'confirm_action', - args: { id: 1 }, + _use_ai_internal: true, + _use_ai_type: 'confirmation_required', + _use_ai_metadata: { + message: 'Are you sure?', }, })).toBe(true); }); - test('returns true with optional metadata', () => { + test('returns true with optional metadata and additional_columns', () => { expect(isMcpConfirmationResponse({ - confirmation_required: true, - message: 'Transfer $5000?', - metadata: { amount: 5000, to: 'Bob' }, - execute_on_approval: { - tool: 'confirm_transfer', - args: { to: 'Bob', amount: 5000 }, + _use_ai_internal: true, + _use_ai_type: 'confirmation_required', + _use_ai_metadata: { + message: 'Transfer $5000?', + metadata: { amount: 5000, to: 'Bob' }, + additional_columns: { token: 'abc123' }, }, })).toBe(true); }); @@ -91,70 +90,84 @@ describe('isMcpConfirmationResponse', () => { expect(isMcpConfirmationResponse(42)).toBe(false); }); - test('returns false when confirmation_required is not true', () => { + test('returns false when _use_ai_internal is not true', () => { expect(isMcpConfirmationResponse({ - confirmation_required: false, - message: 'msg', - execute_on_approval: { tool: 't', args: {} }, + _use_ai_internal: false, + _use_ai_type: 'confirmation_required', + _use_ai_metadata: { message: 'msg' }, })).toBe(false); }); - test('returns false when message is missing', () => { + test('returns false when _use_ai_type is wrong', () => { expect(isMcpConfirmationResponse({ - confirmation_required: true, - execute_on_approval: { tool: 't', args: {} }, + _use_ai_internal: true, + _use_ai_type: 'something_else', + _use_ai_metadata: { message: 'msg' }, })).toBe(false); }); - test('returns false when execute_on_approval is missing', () => { + test('returns false when _use_ai_metadata is missing', () => { expect(isMcpConfirmationResponse({ - confirmation_required: true, - message: 'msg', + _use_ai_internal: true, + _use_ai_type: 'confirmation_required', })).toBe(false); }); - test('returns false when execute_on_approval.tool is missing', () => { + test('returns false when _use_ai_metadata.message is missing', () => { expect(isMcpConfirmationResponse({ - confirmation_required: true, - message: 'msg', - execute_on_approval: { args: {} }, + _use_ai_internal: true, + _use_ai_type: 'confirmation_required', + _use_ai_metadata: {}, })).toBe(false); }); - test('returns false when execute_on_approval.args is missing', () => { + test('returns false when _use_ai_metadata.message is not a string', () => { expect(isMcpConfirmationResponse({ - confirmation_required: true, - message: 'msg', - execute_on_approval: { tool: 't' }, + _use_ai_internal: true, + _use_ai_type: 'confirmation_required', + _use_ai_metadata: { message: 123 }, })).toBe(false); }); test('returns false for normal tool result', () => { expect(isMcpConfirmationResponse({ success: true, data: 'ok' })).toBe(false); }); + + test('rejects old schema (confirmation_required + execute_on_approval)', () => { + expect(isMcpConfirmationResponse({ + confirmation_required: true, + message: 'Are you sure?', + execute_on_approval: { + tool: 'confirm_action', + args: { id: 1 }, + }, + })).toBe(false); + }); }); describe('handleMcpConfirmation', () => { + const originalArgs = { to: 'Bob', amount: 5000 }; const confirmation: McpConfirmationResponse = { - confirmation_required: true, - message: 'Transfer $5000 to Bob. Are you sure?', - metadata: { amount: 5000, to: 'Bob' }, - execute_on_approval: { - tool: 'confirm_transfer', - args: { to: 'Bob', amount: 5000, confirmed: true }, + _use_ai_internal: true, + _use_ai_type: 'confirmation_required', + _use_ai_metadata: { + message: 'Transfer $5000 to Bob. Are you sure?', + metadata: { amount: 5000, to: 'Bob' }, + additional_columns: { token: 'random_fixed_token' }, }, }; - test('emits TOOL_APPROVAL_REQUEST event', async () => { + test('emits TOOL_APPROVAL_REQUEST with originalArgs (not merged args)', async () => { const session = createTestSession(); const events = createMockEmitter(); const provider = createMockProvider(); - // Start the handler (it will wait for approval) const promise = handleMcpConfirmation( confirmation, 'tool-call-1', 'ns_transfer', + 'transfer', + originalArgs, provider, session, events @@ -168,7 +181,8 @@ describe('handleMcpConfirmation', () => { expect(emitted.toolCallName).toBe('ns_transfer'); expect(emitted.message).toBe('Transfer $5000 to Bob. Are you sure?'); expect(emitted.metadata).toEqual({ amount: 5000, to: 'Bob' }); - expect(emitted.toolCallArgs).toEqual({ to: 'Bob', amount: 5000, confirmed: true }); + // toolCallArgs should be originalArgs, NOT merged with additional_columns + expect(emitted.toolCallArgs).toEqual({ to: 'Bob', amount: 5000 }); // Resolve approval to complete the promise const resolver = session.pendingToolApprovals.get('tool-call-1'); @@ -176,7 +190,7 @@ describe('handleMcpConfirmation', () => { await promise; }); - test('calls phase-2 tool when approved', async () => { + test('calls phase-2 with originalToolName and merged args when approved', async () => { const session = createTestSession(); const events = createMockEmitter(); const provider = createMockProvider({ success: true, message: 'Transferred' }); @@ -185,6 +199,8 @@ describe('handleMcpConfirmation', () => { confirmation, 'tool-call-1', 'ns_transfer', + 'transfer', + originalArgs, provider, session, events @@ -196,13 +212,49 @@ describe('handleMcpConfirmation', () => { const result = await promise; - // Verify phase-2 was called with correct tool/args + // Verify phase-2 was called with originalToolName and merged args expect(provider.executedCalls).toHaveLength(1); - expect(provider.executedCalls[0].toolName).toBe('confirm_transfer'); - expect(provider.executedCalls[0].args).toEqual({ to: 'Bob', amount: 5000, confirmed: true }); + expect(provider.executedCalls[0].toolName).toBe('transfer'); + expect(provider.executedCalls[0].args).toEqual({ + to: 'Bob', + amount: 5000, + token: 'random_fixed_token', + }); expect(result).toEqual({ success: true, message: 'Transferred' }); }); + test('calls phase-2 with originalArgs only when no additional_columns', async () => { + const session = createTestSession(); + const events = createMockEmitter(); + const provider = createMockProvider({ success: true }); + + const noColumnsConfirmation: McpConfirmationResponse = { + _use_ai_internal: true, + _use_ai_type: 'confirmation_required', + _use_ai_metadata: { + message: 'Are you sure?', + }, + }; + + const promise = handleMcpConfirmation( + noColumnsConfirmation, + 'tool-call-1', + 'ns_transfer', + 'transfer', + originalArgs, + provider, + session, + events + ); + + const resolver = session.pendingToolApprovals.get('tool-call-1'); + resolver!({ approved: true }); + await promise; + + // Without additional_columns, phase-2 should use originalArgs as-is + expect(provider.executedCalls[0].args).toEqual({ to: 'Bob', amount: 5000 }); + }); + test('returns error result when rejected', async () => { const session = createTestSession(); const events = createMockEmitter(); @@ -212,6 +264,8 @@ describe('handleMcpConfirmation', () => { confirmation, 'tool-call-1', 'ns_transfer', + 'transfer', + originalArgs, provider, session, events @@ -240,6 +294,8 @@ describe('handleMcpConfirmation', () => { confirmation, 'tool-call-1', 'ns_transfer', + 'transfer', + originalArgs, provider, session, events @@ -268,6 +324,8 @@ describe('handleMcpConfirmation', () => { confirmation, 'tool-call-1', 'ns_transfer', + 'transfer', + originalArgs, provider, session, events @@ -301,6 +359,8 @@ describe('handleMcpConfirmation', () => { confirmation, 'tool-call-1', 'ns_transfer', + 'transfer', + originalArgs, provider, session, events, diff --git a/packages/server/src/mcp/mcpConfirmation.ts b/packages/server/src/mcp/mcpConfirmation.ts index 967f033d..4d2de212 100644 --- a/packages/server/src/mcp/mcpConfirmation.ts +++ b/packages/server/src/mcp/mcpConfirmation.ts @@ -2,9 +2,12 @@ * MCP tool runtime interactive approval. * * MCP tools that need user confirmation return a special JSON response with - * `confirmation_required: true`. The server intercepts this, shows an approval - * dialog to the user, and if approved, calls the specified execution tool on - * the same MCP endpoint (phase 2). + * `_use_ai_internal: true` and `_use_ai_type: "confirmation_required"`. + * The server intercepts this, shows an approval dialog to the user, and if + * approved, re-calls the same tool with original args merged with + * `additional_columns` (phase 2). + * + * The `_use_ai_` prefix avoids namespace collisions with user data. */ import type { ClientSession, EventEmitter } from '../agents/types'; @@ -17,21 +20,21 @@ import { logger } from '../logger'; /** * Response shape returned by MCP tools that require user confirmation. - * The server detects this via the `confirmation_required` sentinel field. + * Uses `_use_ai_` prefix to avoid collisions with user data fields. */ export interface McpConfirmationResponse { - /** Sentinel field — must be `true` */ - confirmation_required: true; - /** Message shown in the approval dialog */ - message: string; - /** Optional metadata passed through to the approval dialog */ - metadata?: Record; - /** Tool to call on the same MCP endpoint if the user approves */ - execute_on_approval: { - /** MCP tool name (original, without namespace) */ - tool: string; - /** Arguments to pass to the tool */ - args: Record; + /** Sentinel — must be `true` */ + _use_ai_internal: true; + /** Type discriminator */ + _use_ai_type: 'confirmation_required'; + /** Payload */ + _use_ai_metadata: { + /** Message shown in the approval dialog */ + message: string; + /** Optional metadata passed through to the approval dialog */ + metadata?: Record; + /** Optional extra columns merged into original args for phase 2 */ + additional_columns?: Record; }; } @@ -43,22 +46,18 @@ export function isMcpConfirmationResponse( ): value is McpConfirmationResponse { if (value == null || typeof value !== 'object') return false; const obj = value as Record; - return ( - obj.confirmation_required === true && - typeof obj.message === 'string' && - obj.execute_on_approval != null && - typeof obj.execute_on_approval === 'object' && - typeof (obj.execute_on_approval as Record).tool === 'string' && - (obj.execute_on_approval as Record).args != null && - typeof (obj.execute_on_approval as Record).args === 'object' - ); + if (obj._use_ai_internal !== true) return false; + if (obj._use_ai_type !== 'confirmation_required') return false; + const meta = obj._use_ai_metadata; + if (meta == null || typeof meta !== 'object') return false; + return typeof (meta as Record).message === 'string'; } /** * Handles an MCP confirmation response: * 1. Emits TOOL_APPROVAL_REQUEST to the client * 2. Waits for user approval via waitForApproval() - * 3. If approved → calls provider.executeTool() with execute_on_approval tool/args + * 3. If approved → re-calls the same tool with { ...originalArgs, ...additional_columns } * 4. If rejected → returns error result * * Phase-2 results are returned as-is (no re-interception). @@ -67,26 +66,30 @@ export async function handleMcpConfirmation( confirmation: McpConfirmationResponse, toolCallId: string, toolCallName: string, + originalToolName: string, + originalArgs: Record, provider: RemoteMcpToolsProvider, session: ClientSession, events: EventEmitter, mcpHeaders?: McpHeadersMap ): Promise { + const { message, metadata, additional_columns } = confirmation._use_ai_metadata; + logger.info('[MCP] Tool returned confirmation_required', { toolCallId, toolCallName, - message: confirmation.message, - phase2Tool: confirmation.execute_on_approval.tool, + message, + originalToolName, }); - // Emit approval request event to the client + // Emit approval request event to the client (expose originalArgs, not internal columns) events.emit({ type: TOOL_APPROVAL_REQUEST, toolCallId, toolCallName, - toolCallArgs: confirmation.execute_on_approval.args, - message: confirmation.message, - metadata: confirmation.metadata, + toolCallArgs: originalArgs, + message, + metadata, timestamp: Date.now(), }); @@ -104,16 +107,20 @@ export async function handleMcpConfirmation( }; } + // Phase 2: re-call the same tool with original args merged with additional_columns + const phase2Args = additional_columns + ? { ...originalArgs, ...additional_columns } + : originalArgs; + logger.info('[MCP] Confirmation approved, executing phase 2', { toolCallId, - phase2Tool: confirmation.execute_on_approval.tool, + originalToolName, }); - // Phase 2: call the execution tool on the same MCP endpoint try { const result = await provider.executeTool( - confirmation.execute_on_approval.tool, - confirmation.execute_on_approval.args, + originalToolName, + phase2Args, mcpHeaders ); return result; @@ -121,7 +128,7 @@ export async function handleMcpConfirmation( const errorMsg = error instanceof Error ? error.message : String(error); logger.error('[MCP] Phase 2 execution failed', { toolCallId, - phase2Tool: confirmation.execute_on_approval.tool, + originalToolName, error: errorMsg, }); return { From 0e83b04aef2e465373c943b4e39f0592e71a2b64 Mon Sep 17 00:00:00 2001 From: masudahiroto <96814344+masudahiroto@users.noreply.github.com> Date: Thu, 5 Mar 2026 13:42:56 +0900 Subject: [PATCH 08/11] refactor: extract UseAIInternalResponse base type for extensible _use_ai_ dispatch Split type guard into two layers: - isUseAIInternalResponse: validates envelope (_use_ai_internal, _use_ai_type, _use_ai_metadata) - isMcpConfirmationResponse: narrows to confirmation_required variant AISDKAgent now uses isUseAIInternalResponse + switch on _use_ai_type, making it easy to add new internal response types in the future. Co-Authored-By: Claude Opus 4.6 --- packages/server/src/agents/AISDKAgent.ts | 38 +-- packages/server/src/mcp/index.ts | 1 + .../server/src/mcp/mcpConfirmation.test.ts | 220 +++++------------- packages/server/src/mcp/mcpConfirmation.ts | 37 ++- .../src/mcp/useAIInternalResponse.test.ts | 71 ++++++ .../server/src/mcp/useAIInternalResponse.ts | 42 ++++ 6 files changed, 206 insertions(+), 203 deletions(-) create mode 100644 packages/server/src/mcp/useAIInternalResponse.test.ts create mode 100644 packages/server/src/mcp/useAIInternalResponse.ts diff --git a/packages/server/src/agents/AISDKAgent.ts b/packages/server/src/agents/AISDKAgent.ts index cf9222d2..35d15806 100644 --- a/packages/server/src/agents/AISDKAgent.ts +++ b/packages/server/src/agents/AISDKAgent.ts @@ -5,6 +5,7 @@ import { z } from 'zod'; import type { Agent, AgentInput, EventEmitter, AgentResult, ClientSession } from './types'; import type { ToolDefinition, UseAIForwardedProps } from '../types'; import type { RemoteToolDefinition } from '../mcp'; +import { isUseAIInternalResponse } from '../mcp/useAIInternalResponse'; import { isMcpConfirmationResponse, handleMcpConfirmation } from '../mcp/mcpConfirmation'; import { EventType, ErrorCode } from '../types'; import { createClientToolExecutor } from '../utils/toolConverter'; @@ -874,19 +875,30 @@ export class AISDKAgent implements Agent { session.currentMcpHeaders // Pass MCP headers from current request ); - // Intercept MCP confirmation responses (phase 1 → approval → phase 2) - if (isMcpConfirmationResponse(result)) { - return handleMcpConfirmation( - result, - toolCallId, - remoteTool.name, - remoteTool._remote.originalName, - args as Record, - remoteTool._remote.provider, - session, - events, - session.currentMcpHeaders - ); + // Intercept _use_ai_ internal responses from MCP tools + if (isUseAIInternalResponse(result)) { + switch (result._use_ai_type) { + case 'confirmation_required': + if (isMcpConfirmationResponse(result)) { + return handleMcpConfirmation( + result, + toolCallId, + remoteTool.name, + remoteTool._remote.originalName, + args as Record, + remoteTool._remote.provider, + session, + events, + session.currentMcpHeaders + ); + } + break; + default: + logger.warn('[MCP] Unknown _use_ai_type, returning as-is', { + toolCallId, + type: result._use_ai_type, + }); + } } return result; diff --git a/packages/server/src/mcp/index.ts b/packages/server/src/mcp/index.ts index b9d69cf4..5db9cd6a 100644 --- a/packages/server/src/mcp/index.ts +++ b/packages/server/src/mcp/index.ts @@ -1,2 +1,3 @@ export { RemoteMcpToolsProvider, type RemoteToolDefinition } from './RemoteMcpToolsProvider'; +export { isUseAIInternalResponse, type UseAIInternalResponse } from './useAIInternalResponse'; export { isMcpConfirmationResponse, type McpConfirmationResponse } from './mcpConfirmation'; diff --git a/packages/server/src/mcp/mcpConfirmation.test.ts b/packages/server/src/mcp/mcpConfirmation.test.ts index 2d86c86f..086cb81b 100644 --- a/packages/server/src/mcp/mcpConfirmation.test.ts +++ b/packages/server/src/mcp/mcpConfirmation.test.ts @@ -4,13 +4,11 @@ import { handleMcpConfirmation, type McpConfirmationResponse, } from './mcpConfirmation'; +import type { UseAIInternalResponse } from './useAIInternalResponse'; import type { ClientSession, EventEmitter } from '../agents/types'; import type { RemoteMcpToolsProvider } from './RemoteMcpToolsProvider'; import { TOOL_APPROVAL_REQUEST } from '../types'; -/** - * Helper to create a minimal session for testing - */ function createTestSession(overrides: Partial = {}): ClientSession { return { clientId: 'client-1', @@ -27,9 +25,6 @@ function createTestSession(overrides: Partial = {}): ClientSessio }; } -/** - * Helper to create a mock EventEmitter - */ function createMockEmitter(): EventEmitter & { emittedEvents: unknown[] } { const emittedEvents: unknown[] = []; return { @@ -38,9 +33,6 @@ function createMockEmitter(): EventEmitter & { emittedEvents: unknown[] } { } as EventEmitter & { emittedEvents: unknown[] }; } -/** - * Helper to create a mock MCP provider - */ function createMockProvider( executeResult: unknown = { success: true } ): RemoteMcpToolsProvider & { executedCalls: { toolName: string; args: unknown }[] } { @@ -54,97 +46,61 @@ function createMockProvider( } as RemoteMcpToolsProvider & { executedCalls: { toolName: string; args: unknown }[] }; } +// ── isMcpConfirmationResponse (narrows from UseAIInternalResponse) ────────── + describe('isMcpConfirmationResponse', () => { - test('returns true for valid confirmation response', () => { - expect(isMcpConfirmationResponse({ + test('returns true for valid confirmation_required', () => { + const value: UseAIInternalResponse = { _use_ai_internal: true, _use_ai_type: 'confirmation_required', - _use_ai_metadata: { - message: 'Are you sure?', - }, - })).toBe(true); + _use_ai_metadata: { message: 'Are you sure?' }, + }; + expect(isMcpConfirmationResponse(value)).toBe(true); }); test('returns true with optional metadata and additional_columns', () => { - expect(isMcpConfirmationResponse({ + const value: UseAIInternalResponse = { _use_ai_internal: true, _use_ai_type: 'confirmation_required', _use_ai_metadata: { message: 'Transfer $5000?', - metadata: { amount: 5000, to: 'Bob' }, - additional_columns: { token: 'abc123' }, + metadata: { amount: 5000 }, + additional_columns: { token: 'abc' }, }, - })).toBe(true); - }); - - test('returns false for null', () => { - expect(isMcpConfirmationResponse(null)).toBe(false); - }); - - test('returns false for undefined', () => { - expect(isMcpConfirmationResponse(undefined)).toBe(false); - }); - - test('returns false for non-object', () => { - expect(isMcpConfirmationResponse('string')).toBe(false); - expect(isMcpConfirmationResponse(42)).toBe(false); - }); - - test('returns false when _use_ai_internal is not true', () => { - expect(isMcpConfirmationResponse({ - _use_ai_internal: false, - _use_ai_type: 'confirmation_required', - _use_ai_metadata: { message: 'msg' }, - })).toBe(false); - }); - - test('returns false when _use_ai_type is wrong', () => { - expect(isMcpConfirmationResponse({ - _use_ai_internal: true, - _use_ai_type: 'something_else', - _use_ai_metadata: { message: 'msg' }, - })).toBe(false); + }; + expect(isMcpConfirmationResponse(value)).toBe(true); }); - test('returns false when _use_ai_metadata is missing', () => { - expect(isMcpConfirmationResponse({ + test('returns false for different _use_ai_type', () => { + const value: UseAIInternalResponse = { _use_ai_internal: true, - _use_ai_type: 'confirmation_required', - })).toBe(false); + _use_ai_type: 'future_feature', + _use_ai_metadata: { message: 'hello' }, + }; + expect(isMcpConfirmationResponse(value)).toBe(false); }); - test('returns false when _use_ai_metadata.message is missing', () => { - expect(isMcpConfirmationResponse({ + test('returns false when message is missing', () => { + const value: UseAIInternalResponse = { _use_ai_internal: true, _use_ai_type: 'confirmation_required', _use_ai_metadata: {}, - })).toBe(false); + }; + expect(isMcpConfirmationResponse(value)).toBe(false); }); - test('returns false when _use_ai_metadata.message is not a string', () => { - expect(isMcpConfirmationResponse({ + test('returns false when message is not a string', () => { + const value: UseAIInternalResponse = { _use_ai_internal: true, _use_ai_type: 'confirmation_required', _use_ai_metadata: { message: 123 }, - })).toBe(false); - }); - - test('returns false for normal tool result', () => { - expect(isMcpConfirmationResponse({ success: true, data: 'ok' })).toBe(false); - }); - - test('rejects old schema (confirmation_required + execute_on_approval)', () => { - expect(isMcpConfirmationResponse({ - confirmation_required: true, - message: 'Are you sure?', - execute_on_approval: { - tool: 'confirm_action', - args: { id: 1 }, - }, - })).toBe(false); + }; + expect(isMcpConfirmationResponse(value)).toBe(false); }); }); +// ── handleMcpConfirmation ─────────────────────────────────────────────────── + describe('handleMcpConfirmation', () => { const originalArgs = { to: 'Bob', amount: 5000 }; const confirmation: McpConfirmationResponse = { @@ -163,17 +119,10 @@ describe('handleMcpConfirmation', () => { const provider = createMockProvider(); const promise = handleMcpConfirmation( - confirmation, - 'tool-call-1', - 'ns_transfer', - 'transfer', - originalArgs, - provider, - session, - events + confirmation, 'tool-call-1', 'ns_transfer', 'transfer', + originalArgs, provider, session, events ); - // Verify event was emitted expect(events.emittedEvents).toHaveLength(1); const emitted = events.emittedEvents[0] as Record; expect(emitted.type).toBe(TOOL_APPROVAL_REQUEST); @@ -181,12 +130,9 @@ describe('handleMcpConfirmation', () => { expect(emitted.toolCallName).toBe('ns_transfer'); expect(emitted.message).toBe('Transfer $5000 to Bob. Are you sure?'); expect(emitted.metadata).toEqual({ amount: 5000, to: 'Bob' }); - // toolCallArgs should be originalArgs, NOT merged with additional_columns expect(emitted.toolCallArgs).toEqual({ to: 'Bob', amount: 5000 }); - // Resolve approval to complete the promise - const resolver = session.pendingToolApprovals.get('tool-call-1'); - resolver!({ approved: true }); + session.pendingToolApprovals.get('tool-call-1')!({ approved: true }); await promise; }); @@ -196,29 +142,17 @@ describe('handleMcpConfirmation', () => { const provider = createMockProvider({ success: true, message: 'Transferred' }); const promise = handleMcpConfirmation( - confirmation, - 'tool-call-1', - 'ns_transfer', - 'transfer', - originalArgs, - provider, - session, - events + confirmation, 'tool-call-1', 'ns_transfer', 'transfer', + originalArgs, provider, session, events ); - // Approve - const resolver = session.pendingToolApprovals.get('tool-call-1'); - resolver!({ approved: true }); - + session.pendingToolApprovals.get('tool-call-1')!({ approved: true }); const result = await promise; - // Verify phase-2 was called with originalToolName and merged args expect(provider.executedCalls).toHaveLength(1); expect(provider.executedCalls[0].toolName).toBe('transfer'); expect(provider.executedCalls[0].args).toEqual({ - to: 'Bob', - amount: 5000, - token: 'random_fixed_token', + to: 'Bob', amount: 5000, token: 'random_fixed_token', }); expect(result).toEqual({ success: true, message: 'Transferred' }); }); @@ -231,27 +165,17 @@ describe('handleMcpConfirmation', () => { const noColumnsConfirmation: McpConfirmationResponse = { _use_ai_internal: true, _use_ai_type: 'confirmation_required', - _use_ai_metadata: { - message: 'Are you sure?', - }, + _use_ai_metadata: { message: 'Are you sure?' }, }; const promise = handleMcpConfirmation( - noColumnsConfirmation, - 'tool-call-1', - 'ns_transfer', - 'transfer', - originalArgs, - provider, - session, - events + noColumnsConfirmation, 'tool-call-1', 'ns_transfer', 'transfer', + originalArgs, provider, session, events ); - const resolver = session.pendingToolApprovals.get('tool-call-1'); - resolver!({ approved: true }); + session.pendingToolApprovals.get('tool-call-1')!({ approved: true }); await promise; - // Without additional_columns, phase-2 should use originalArgs as-is expect(provider.executedCalls[0].args).toEqual({ to: 'Bob', amount: 5000 }); }); @@ -261,23 +185,13 @@ describe('handleMcpConfirmation', () => { const provider = createMockProvider(); const promise = handleMcpConfirmation( - confirmation, - 'tool-call-1', - 'ns_transfer', - 'transfer', - originalArgs, - provider, - session, - events + confirmation, 'tool-call-1', 'ns_transfer', 'transfer', + originalArgs, provider, session, events ); - // Reject - const resolver = session.pendingToolApprovals.get('tool-call-1'); - resolver!({ approved: false, reason: 'Too expensive' }); - + session.pendingToolApprovals.get('tool-call-1')!({ approved: false, reason: 'Too expensive' }); const result = await promise; - // Verify no phase-2 call was made expect(provider.executedCalls).toHaveLength(0); expect(result).toEqual({ error: true, @@ -291,20 +205,13 @@ describe('handleMcpConfirmation', () => { const provider = createMockProvider(); const promise = handleMcpConfirmation( - confirmation, - 'tool-call-1', - 'ns_transfer', - 'transfer', - originalArgs, - provider, - session, - events + confirmation, 'tool-call-1', 'ns_transfer', 'transfer', + originalArgs, provider, session, events ); - const resolver = session.pendingToolApprovals.get('tool-call-1'); - resolver!({ approved: false }); - + session.pendingToolApprovals.get('tool-call-1')!({ approved: false }); const result = await promise; + expect(result).toEqual({ error: true, message: 'Tool execution denied by user: Action was rejected', @@ -314,28 +221,18 @@ describe('handleMcpConfirmation', () => { test('catches phase-2 execution error gracefully', async () => { const session = createTestSession(); const events = createMockEmitter(); - - // Provider that throws on executeTool const provider = { executeTool: async () => { throw new Error('MCP endpoint down'); }, } as unknown as RemoteMcpToolsProvider; const promise = handleMcpConfirmation( - confirmation, - 'tool-call-1', - 'ns_transfer', - 'transfer', - originalArgs, - provider, - session, - events + confirmation, 'tool-call-1', 'ns_transfer', 'transfer', + originalArgs, provider, session, events ); - // Approve - const resolver = session.pendingToolApprovals.get('tool-call-1'); - resolver!({ approved: true }); - + session.pendingToolApprovals.get('tool-call-1')!({ approved: true }); const result = await promise; + expect(result).toEqual({ error: true, message: 'MCP confirmation execution failed: MCP endpoint down', @@ -345,7 +242,6 @@ describe('handleMcpConfirmation', () => { test('passes MCP headers to phase-2 call', async () => { const session = createTestSession(); const events = createMockEmitter(); - const mcpHeaders = { 'https://example.com/*': { headers: { Authorization: 'Bearer tok' } } }; let capturedHeaders: unknown; const provider = { @@ -356,19 +252,11 @@ describe('handleMcpConfirmation', () => { } as unknown as RemoteMcpToolsProvider; const promise = handleMcpConfirmation( - confirmation, - 'tool-call-1', - 'ns_transfer', - 'transfer', - originalArgs, - provider, - session, - events, - mcpHeaders + confirmation, 'tool-call-1', 'ns_transfer', 'transfer', + originalArgs, provider, session, events, mcpHeaders ); - const resolver = session.pendingToolApprovals.get('tool-call-1'); - resolver!({ approved: true }); + session.pendingToolApprovals.get('tool-call-1')!({ approved: true }); await promise; expect(capturedHeaders).toBe(mcpHeaders); diff --git a/packages/server/src/mcp/mcpConfirmation.ts b/packages/server/src/mcp/mcpConfirmation.ts index 4d2de212..b5ebb5a0 100644 --- a/packages/server/src/mcp/mcpConfirmation.ts +++ b/packages/server/src/mcp/mcpConfirmation.ts @@ -1,13 +1,9 @@ /** - * MCP tool runtime interactive approval. + * MCP tool runtime interactive approval (`_use_ai_type: "confirmation_required"`). * - * MCP tools that need user confirmation return a special JSON response with - * `_use_ai_internal: true` and `_use_ai_type: "confirmation_required"`. - * The server intercepts this, shows an approval dialog to the user, and if - * approved, re-calls the same tool with original args merged with - * `additional_columns` (phase 2). - * - * The `_use_ai_` prefix avoids namespace collisions with user data. + * When an MCP tool returns this type the server shows an approval dialog. + * If the user approves, the server re-calls the same tool with + * `{ ...originalArgs, ...additional_columns }`. */ import type { ClientSession, EventEmitter } from '../agents/types'; @@ -17,17 +13,13 @@ import { waitForApproval } from '../agents/toolApproval'; import type { RemoteMcpToolsProvider } from './RemoteMcpToolsProvider'; import type { McpHeadersMap } from '@meetsmore-oss/use-ai-core'; import { logger } from '../logger'; +import type { UseAIInternalResponse } from './useAIInternalResponse'; /** - * Response shape returned by MCP tools that require user confirmation. - * Uses `_use_ai_` prefix to avoid collisions with user data fields. + * Confirmation-specific internal response. */ -export interface McpConfirmationResponse { - /** Sentinel — must be `true` */ - _use_ai_internal: true; - /** Type discriminator */ +export interface McpConfirmationResponse extends UseAIInternalResponse { _use_ai_type: 'confirmation_required'; - /** Payload */ _use_ai_metadata: { /** Message shown in the approval dialog */ message: string; @@ -39,18 +31,15 @@ export interface McpConfirmationResponse { } /** - * Type guard that checks whether a tool result is an MCP confirmation response. + * Narrow a `UseAIInternalResponse` to the confirmation variant. */ export function isMcpConfirmationResponse( - value: unknown + value: UseAIInternalResponse ): value is McpConfirmationResponse { - if (value == null || typeof value !== 'object') return false; - const obj = value as Record; - if (obj._use_ai_internal !== true) return false; - if (obj._use_ai_type !== 'confirmation_required') return false; - const meta = obj._use_ai_metadata; - if (meta == null || typeof meta !== 'object') return false; - return typeof (meta as Record).message === 'string'; + return ( + value._use_ai_type === 'confirmation_required' && + typeof value._use_ai_metadata.message === 'string' + ); } /** diff --git a/packages/server/src/mcp/useAIInternalResponse.test.ts b/packages/server/src/mcp/useAIInternalResponse.test.ts new file mode 100644 index 00000000..3902a944 --- /dev/null +++ b/packages/server/src/mcp/useAIInternalResponse.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from 'bun:test'; +import { isUseAIInternalResponse } from './useAIInternalResponse'; + +describe('isUseAIInternalResponse', () => { + test('returns true for valid internal response', () => { + expect(isUseAIInternalResponse({ + _use_ai_internal: true, + _use_ai_type: 'confirmation_required', + _use_ai_metadata: { message: 'hello' }, + })).toBe(true); + }); + + test('returns true for unknown _use_ai_type (base does not restrict type)', () => { + expect(isUseAIInternalResponse({ + _use_ai_internal: true, + _use_ai_type: 'future_feature', + _use_ai_metadata: { foo: 'bar' }, + })).toBe(true); + }); + + test('returns false for null / undefined / primitives', () => { + expect(isUseAIInternalResponse(null)).toBe(false); + expect(isUseAIInternalResponse(undefined)).toBe(false); + expect(isUseAIInternalResponse('string')).toBe(false); + expect(isUseAIInternalResponse(42)).toBe(false); + }); + + test('returns false when _use_ai_internal is not true', () => { + expect(isUseAIInternalResponse({ + _use_ai_internal: false, + _use_ai_type: 'confirmation_required', + _use_ai_metadata: { message: 'msg' }, + })).toBe(false); + }); + + test('returns false when _use_ai_type is missing or non-string', () => { + expect(isUseAIInternalResponse({ + _use_ai_internal: true, + _use_ai_metadata: { message: 'msg' }, + })).toBe(false); + expect(isUseAIInternalResponse({ + _use_ai_internal: true, + _use_ai_type: 123, + _use_ai_metadata: { message: 'msg' }, + })).toBe(false); + }); + + test('returns false when _use_ai_metadata is missing or non-object', () => { + expect(isUseAIInternalResponse({ + _use_ai_internal: true, + _use_ai_type: 'confirmation_required', + })).toBe(false); + expect(isUseAIInternalResponse({ + _use_ai_internal: true, + _use_ai_type: 'confirmation_required', + _use_ai_metadata: 'not-an-object', + })).toBe(false); + }); + + test('returns false for normal tool results', () => { + expect(isUseAIInternalResponse({ success: true, data: 'ok' })).toBe(false); + }); + + test('rejects old schema (confirmation_required + execute_on_approval)', () => { + expect(isUseAIInternalResponse({ + confirmation_required: true, + message: 'Are you sure?', + execute_on_approval: { tool: 'confirm', args: {} }, + })).toBe(false); + }); +}); diff --git a/packages/server/src/mcp/useAIInternalResponse.ts b/packages/server/src/mcp/useAIInternalResponse.ts new file mode 100644 index 00000000..abbc3dea --- /dev/null +++ b/packages/server/src/mcp/useAIInternalResponse.ts @@ -0,0 +1,42 @@ +/** + * Base type and type guard for `_use_ai_` internal responses from MCP tools. + * + * MCP tools can return a JSON object with `_use_ai_internal: true` to signal + * that the response requires special server-side handling (e.g. user approval). + * The `_use_ai_type` discriminator determines the specific behavior. + * + * This module provides the base plumbing; concrete types (e.g. + * `McpConfirmationResponse`) extend the base and are handled by dedicated + * modules. + */ + +/** + * Base shape shared by all `_use_ai_` internal responses. + */ +export interface UseAIInternalResponse { + /** Sentinel — must be `true` */ + _use_ai_internal: true; + /** Discriminator — determines how the server handles this response */ + _use_ai_type: string; + /** Type-specific payload */ + _use_ai_metadata: Record; +} + +/** + * Type guard that checks whether a value is a `_use_ai_` internal response. + * + * Validates only the envelope (`_use_ai_internal`, `_use_ai_type`, + * `_use_ai_metadata`). Callers should further narrow via `_use_ai_type`. + */ +export function isUseAIInternalResponse( + value: unknown +): value is UseAIInternalResponse { + if (value == null || typeof value !== 'object') return false; + const obj = value as Record; + return ( + obj._use_ai_internal === true && + typeof obj._use_ai_type === 'string' && + obj._use_ai_metadata != null && + typeof obj._use_ai_metadata === 'object' + ); +} From 8e6512e112a0f8da0ff6d622cee128282cdf9294 Mon Sep 17 00:00:00 2001 From: masudahiroto <96814344+masudahiroto@users.noreply.github.com> Date: Thu, 5 Mar 2026 14:03:06 +0900 Subject: [PATCH 09/11] refactor: move inline type import to top-level in useStableTools Co-Authored-By: Claude Opus 4.6 --- packages/client/src/hooks/useStableTools.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/client/src/hooks/useStableTools.ts b/packages/client/src/hooks/useStableTools.ts index 1dda8290..5fcbd86f 100644 --- a/packages/client/src/hooks/useStableTools.ts +++ b/packages/client/src/hooks/useStableTools.ts @@ -1,5 +1,5 @@ import { useRef } from 'react'; -import type { ToolsDefinition, DefinedTool } from '../defineTool'; +import type { ToolsDefinition, DefinedTool, ToolExecutionContext } from '../defineTool'; import type { z } from 'zod'; /** @@ -88,7 +88,7 @@ function createStableToolWrapper( latestToolsRef: React.MutableRefObject ): DefinedTool { // Create a stable handler that proxies to the latest version - const stableHandler = (input: unknown, ctx: import('../defineTool').ToolExecutionContext) => { + const stableHandler = (input: unknown, ctx: ToolExecutionContext) => { const currentTool = latestToolsRef.current[name]; if (!currentTool) { throw new Error(`Tool "${name}" no longer exists`); @@ -97,7 +97,7 @@ function createStableToolWrapper( }; // Create the stable _execute function - const stableExecute = async (input: unknown, ctx: import('../defineTool').ToolExecutionContext) => { + const stableExecute = async (input: unknown, ctx: ToolExecutionContext) => { const currentTool = latestToolsRef.current[name]; if (!currentTool) { throw new Error(`Tool "${name}" no longer exists`); From c6d49f78626bf3d7a2f4d4868279ea6ae952b15a Mon Sep 17 00:00:00 2001 From: masudahiroto <96814344+masudahiroto@users.noreply.github.com> Date: Thu, 5 Mar 2026 16:26:41 +0900 Subject: [PATCH 10/11] fix: fix server-tools E2E nav selector broken by new nav items Use exact button text match to avoid ambiguous 'Server Tools' click matching 'Runtime Approval (Server)'. Also fix h1 text to match actual page heading. Co-Authored-By: Claude Opus 4.6 --- apps/example/test/server-tools.e2e.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/example/test/server-tools.e2e.test.ts b/apps/example/test/server-tools.e2e.test.ts index 5790d3ca..89a62292 100644 --- a/apps/example/test/server-tools.e2e.test.ts +++ b/apps/example/test/server-tools.e2e.test.ts @@ -18,10 +18,10 @@ test.describe('Server-Side Tools', () => { // Navigate to the Server Tools page await page.goto('/'); - await page.click('text=Server Tools'); + await page.click('button:text-is("Server Tools")'); // Wait for the page to load - await expect(page.locator('h1:has-text("Server Tools Demo")')).toBeVisible(); + await expect(page.locator('h1:has-text("Server Tools")')).toBeVisible(); // Open AI chat const aiButton = page.getByTestId('ai-button'); @@ -31,7 +31,7 @@ test.describe('Server-Side Tools', () => { }); test('should display server tools page', async ({ page }) => { - await expect(page.locator('h1:has-text("Server Tools Demo")')).toBeVisible(); + await expect(page.locator('h1:has-text("Server Tools")')).toBeVisible(); await expect(page.locator('text=About Server Tools')).toBeVisible(); }); From cc9c586514dff71bef488c22b7efec9bf1843529 Mon Sep 17 00:00:00 2001 From: masudahiroto <96814344+masudahiroto@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:05:25 +0900 Subject: [PATCH 11/11] refactor: share use-ai internal response types in core --- packages/core/src/index.ts | 17 +++- packages/core/src/useAIInternalResponse.ts | 79 +++++++++++++++++++ packages/server/src/index.ts | 10 ++- packages/server/src/mcp/index.ts | 6 +- .../server/src/mcp/mcpConfirmation.test.ts | 6 +- packages/server/src/mcp/mcpConfirmation.ts | 32 ++------ .../src/mcp/useAIInternalResponse.test.ts | 4 +- .../server/src/mcp/useAIInternalResponse.ts | 46 ++--------- 8 files changed, 124 insertions(+), 76 deletions(-) create mode 100644 packages/core/src/useAIInternalResponse.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a861cbaa..13f60852 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -60,4 +60,19 @@ export type { UserMessageContent, } from './types'; -export { EventType, ErrorCode, TOOL_APPROVAL_REQUEST } from './types'; +export type { + UseAIInternalResponseBase, + UseAIInternalResponse, + McpConfirmationResponse, +} from './useAIInternalResponse'; + +export { + EventType, + ErrorCode, + TOOL_APPROVAL_REQUEST, +} from './types'; + +export { + isUseAIInternalResponse, + isMcpConfirmationResponse, +} from './useAIInternalResponse'; diff --git a/packages/core/src/useAIInternalResponse.ts b/packages/core/src/useAIInternalResponse.ts new file mode 100644 index 00000000..6eb24a23 --- /dev/null +++ b/packages/core/src/useAIInternalResponse.ts @@ -0,0 +1,79 @@ +/** + * Shared `_use_ai_` internal response types. + * + * MCP tools can return these sentinel objects to request special handling from + * the use-ai server. Only explicitly supported combinations of + * `_use_ai_type` and `_use_ai_metadata` are accepted. + */ + +function asRecord(value: unknown): Record | null { + return value != null && typeof value === 'object' + ? (value as Record) + : null; +} + +/** + * Base shape shared by all `_use_ai_` internal responses. + */ +export interface UseAIInternalResponseBase { + /** Sentinel — must be `true` */ + _use_ai_internal: true; + /** Discriminator — determines how the server handles this response */ + _use_ai_type: string; + /** Type-specific payload */ + _use_ai_metadata: Record; +} + +/** + * MCP runtime approval response. + * + * When returned from an MCP tool, the server should ask the user for approval. + * If approved, the same tool is re-executed with `additional_columns` merged + * into the original arguments. + */ +export interface McpConfirmationResponse extends UseAIInternalResponseBase { + _use_ai_type: 'confirmation_required'; + _use_ai_metadata: { + /** Message shown in the approval dialog */ + message: string; + /** Optional metadata passed through to the approval dialog */ + metadata?: Record; + /** Optional extra columns merged into original args for phase 2 */ + additional_columns?: Record; + }; +} + +/** + * Union of all supported `_use_ai_` internal responses. + * + * Add new variants here as new internal response types are introduced. + */ +export type UseAIInternalResponse = McpConfirmationResponse; + +/** + * Type guard for the confirmation-required internal response. + */ +export function isMcpConfirmationResponse( + value: unknown +): value is McpConfirmationResponse { + const obj = asRecord(value); + const metadata = obj ? asRecord(obj._use_ai_metadata) : null; + + return !!( + obj && + obj._use_ai_internal === true && + obj._use_ai_type === 'confirmation_required' && + metadata && + typeof metadata.message === 'string' + ); +} + +/** + * Type guard that checks whether a value is a supported `_use_ai_` internal + * response. + */ +export function isUseAIInternalResponse( + value: unknown +): value is UseAIInternalResponse { + return isMcpConfirmationResponse(value); +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 8ec0c83d..9c58add9 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -17,8 +17,14 @@ export { logger } from './logger'; export { defineServerTool } from './tools'; export type { ServerToolConfig, ServerToolContext, ServerToolDefinition } from './tools'; -// Export MCP confirmation types for consumers -export { isMcpConfirmationResponse, type McpConfirmationResponse } from './mcp'; +// Export shared `_use_ai_` internal response types for consumers +export { + isUseAIInternalResponse, + type UseAIInternalResponseBase, + type UseAIInternalResponse, + isMcpConfirmationResponse, + type McpConfirmationResponse, +} from './mcp'; // Export utilities for plugins and custom agents export { diff --git a/packages/server/src/mcp/index.ts b/packages/server/src/mcp/index.ts index 5db9cd6a..1ff86f9d 100644 --- a/packages/server/src/mcp/index.ts +++ b/packages/server/src/mcp/index.ts @@ -1,3 +1,7 @@ export { RemoteMcpToolsProvider, type RemoteToolDefinition } from './RemoteMcpToolsProvider'; -export { isUseAIInternalResponse, type UseAIInternalResponse } from './useAIInternalResponse'; +export { + isUseAIInternalResponse, + type UseAIInternalResponseBase, + type UseAIInternalResponse, +} from './useAIInternalResponse'; export { isMcpConfirmationResponse, type McpConfirmationResponse } from './mcpConfirmation'; diff --git a/packages/server/src/mcp/mcpConfirmation.test.ts b/packages/server/src/mcp/mcpConfirmation.test.ts index 086cb81b..646608b7 100644 --- a/packages/server/src/mcp/mcpConfirmation.test.ts +++ b/packages/server/src/mcp/mcpConfirmation.test.ts @@ -72,7 +72,7 @@ describe('isMcpConfirmationResponse', () => { }); test('returns false for different _use_ai_type', () => { - const value: UseAIInternalResponse = { + const value = { _use_ai_internal: true, _use_ai_type: 'future_feature', _use_ai_metadata: { message: 'hello' }, @@ -81,7 +81,7 @@ describe('isMcpConfirmationResponse', () => { }); test('returns false when message is missing', () => { - const value: UseAIInternalResponse = { + const value = { _use_ai_internal: true, _use_ai_type: 'confirmation_required', _use_ai_metadata: {}, @@ -90,7 +90,7 @@ describe('isMcpConfirmationResponse', () => { }); test('returns false when message is not a string', () => { - const value: UseAIInternalResponse = { + const value = { _use_ai_internal: true, _use_ai_type: 'confirmation_required', _use_ai_metadata: { message: 123 }, diff --git a/packages/server/src/mcp/mcpConfirmation.ts b/packages/server/src/mcp/mcpConfirmation.ts index b5ebb5a0..b0ac4bb6 100644 --- a/packages/server/src/mcp/mcpConfirmation.ts +++ b/packages/server/src/mcp/mcpConfirmation.ts @@ -12,35 +12,13 @@ import { TOOL_APPROVAL_REQUEST } from '../types'; import { waitForApproval } from '../agents/toolApproval'; import type { RemoteMcpToolsProvider } from './RemoteMcpToolsProvider'; import type { McpHeadersMap } from '@meetsmore-oss/use-ai-core'; +import type { McpConfirmationResponse } from '@meetsmore-oss/use-ai-core'; import { logger } from '../logger'; -import type { UseAIInternalResponse } from './useAIInternalResponse'; -/** - * Confirmation-specific internal response. - */ -export interface McpConfirmationResponse extends UseAIInternalResponse { - _use_ai_type: 'confirmation_required'; - _use_ai_metadata: { - /** Message shown in the approval dialog */ - message: string; - /** Optional metadata passed through to the approval dialog */ - metadata?: Record; - /** Optional extra columns merged into original args for phase 2 */ - additional_columns?: Record; - }; -} - -/** - * Narrow a `UseAIInternalResponse` to the confirmation variant. - */ -export function isMcpConfirmationResponse( - value: UseAIInternalResponse -): value is McpConfirmationResponse { - return ( - value._use_ai_type === 'confirmation_required' && - typeof value._use_ai_metadata.message === 'string' - ); -} +export { + isMcpConfirmationResponse, + type McpConfirmationResponse, +} from '@meetsmore-oss/use-ai-core'; /** * Handles an MCP confirmation response: diff --git a/packages/server/src/mcp/useAIInternalResponse.test.ts b/packages/server/src/mcp/useAIInternalResponse.test.ts index 3902a944..bbfe5a40 100644 --- a/packages/server/src/mcp/useAIInternalResponse.test.ts +++ b/packages/server/src/mcp/useAIInternalResponse.test.ts @@ -10,12 +10,12 @@ describe('isUseAIInternalResponse', () => { })).toBe(true); }); - test('returns true for unknown _use_ai_type (base does not restrict type)', () => { + test('returns false for unknown _use_ai_type', () => { expect(isUseAIInternalResponse({ _use_ai_internal: true, _use_ai_type: 'future_feature', _use_ai_metadata: { foo: 'bar' }, - })).toBe(true); + })).toBe(false); }); test('returns false for null / undefined / primitives', () => { diff --git a/packages/server/src/mcp/useAIInternalResponse.ts b/packages/server/src/mcp/useAIInternalResponse.ts index abbc3dea..d9541155 100644 --- a/packages/server/src/mcp/useAIInternalResponse.ts +++ b/packages/server/src/mcp/useAIInternalResponse.ts @@ -1,42 +1,8 @@ /** - * Base type and type guard for `_use_ai_` internal responses from MCP tools. - * - * MCP tools can return a JSON object with `_use_ai_internal: true` to signal - * that the response requires special server-side handling (e.g. user approval). - * The `_use_ai_type` discriminator determines the specific behavior. - * - * This module provides the base plumbing; concrete types (e.g. - * `McpConfirmationResponse`) extend the base and are handled by dedicated - * modules. + * Re-export shared `_use_ai_` internal response types from core. */ - -/** - * Base shape shared by all `_use_ai_` internal responses. - */ -export interface UseAIInternalResponse { - /** Sentinel — must be `true` */ - _use_ai_internal: true; - /** Discriminator — determines how the server handles this response */ - _use_ai_type: string; - /** Type-specific payload */ - _use_ai_metadata: Record; -} - -/** - * Type guard that checks whether a value is a `_use_ai_` internal response. - * - * Validates only the envelope (`_use_ai_internal`, `_use_ai_type`, - * `_use_ai_metadata`). Callers should further narrow via `_use_ai_type`. - */ -export function isUseAIInternalResponse( - value: unknown -): value is UseAIInternalResponse { - if (value == null || typeof value !== 'object') return false; - const obj = value as Record; - return ( - obj._use_ai_internal === true && - typeof obj._use_ai_type === 'string' && - obj._use_ai_metadata != null && - typeof obj._use_ai_metadata === 'object' - ); -} +export { + isUseAIInternalResponse, + type UseAIInternalResponseBase, + type UseAIInternalResponse, +} from '@meetsmore-oss/use-ai-core';