diff --git a/apps/example-nest-mcp-server/src/tools.service.ts b/apps/example-nest-mcp-server/src/tools.service.ts
index 2806c95f..7f029d94 100644
--- a/apps/example-nest-mcp-server/src/tools.service.ts
+++ b/apps/example-nest-mcp-server/src/tools.service.ts
@@ -4,6 +4,7 @@ import { Tool } from '@rekog/mcp-nest';
import { z } from 'zod';
import type { Request } from 'express';
+
@Injectable({ scope: Scope.REQUEST })
export class ToolsService {
constructor(@Inject(REQUEST) private readonly request: Request) {}
@@ -104,6 +105,57 @@ export class ToolsService {
};
}
+ @Tool({
+ name: 'transfer',
+ 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('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 }) {
+ // 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: [
+ {
+ type: 'text',
+ text: JSON.stringify({
+ _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
+ if (token && token !=internal_token_password) {
+ return {
+ content: [{ type: 'text', text: JSON.stringify({ error: true, message: 'Invalid token' }) }],
+ isError: true,
+ };
+ }
+
+ // handle the request here
+ // await executeTransfer(...)
+ 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/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..3ad8455d
--- /dev/null
+++ b/apps/example/src/pages/McpRuntimeApprovalPage.tsx
@@ -0,0 +1,156 @@
+import React from 'react';
+import { useAI } from '@meetsmore-oss/use-ai-client';
+import { CollapsibleCode } from '../components/CollapsibleCode';
+import { docStyles } from '../styles/docStyles';
+
+const TOKEN_EXAMPLE = [
+ '# Single tool with token-based approval',
+ '@server.tool("transfer")',
+ '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 {',
+ ' "_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}',
+ ' }',
+ ' }',
+ '',
+ ' # Small amounts proceed directly',
+ ' return {"success": True, "message": f"Transferred ${amount} to {to}"}',
+].join('\n');
+
+export default function McpRuntimeApprovalPage() {
+ useAI({
+ tools: {},
+ prompt: `MCP Runtime Approval Demo Page.
+
+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.
+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.
+- Large transfers (e.g. $2000) should show an approval dialog first.`,
+ suggestions: [
+ 'Transfer $500 to Alice',
+ 'Transfer $5000 to Bob',
+ ],
+ });
+
+ return (
+
+
MCP Runtime Approval
+
+
+
Prerequisites
+
+ The MCP server must be running on localhost:3002 with
+ the transfer tool registered.
+
+
+
+
+
About
+
+ MCP tools run on remote servers and cannot call{' '}
+ ctx.requestApproval() directly.
+ Instead, they use a two-phase confirmation pattern with
+ a single tool and a token parameter:
+
+
+ First call: token: null — if amount > $1,000, returns _use_ai_type: "confirmation_required" with a server-issued one-time token in additional_columns
+ Server shows approval dialog to the user
+ If approved: server re-calls the same tool with original args merged with additional_columns
+ Tool validates token (one-time, parameter-bound, expiring) and executes
+
+
+ The AI cannot bypass approval because it never sees a valid token — tokens are
+ generated server-side and consumed on use.
+
+
+
+
+
Confirmation Response Schema
+
+{`{
+ "_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": "" }
+ }
+}`}
+
+
+
+
+
MCP Endpoint Code
+ {TOKEN_EXAMPLE}
+
+
+
+
Approval Flow Comparison
+
+
+
+
+ Client Tool
+ Server Tool
+ MCP Tool
+
+
+
+
+ Execution
+ Browser
+ Server
+ Remote MCP endpoint
+
+
+ Approval trigger
+ setPendingApprovals
+ ctx.requestApproval()
+ _use_ai_type: "confirmation_required"
+
+
+ Bypass prevention
+ Client-side only
+ Server-side ctx
+ One-time token in additional_columns
+
+
+ UI
+ Same dialog
+ Same dialog
+ Same dialog
+
+
+
+
+
+
+
Interactive Demo
+
+ Try: "Transfer $500 to Alice" (no approval) vs "Transfer $5000 to Bob" (approval required).
+
+
+
+ );
+}
diff --git a/apps/example/src/pages/RuntimeApprovalPage.tsx b/apps/example/src/pages/RuntimeApprovalPage.tsx
new file mode 100644
index 00000000..4e0bedcb
--- /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' } }
+ ),
+
+ clientTransfer: 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
+ setLog([])}
+ style={styles.clearButton}
+ >
+ Clear
+
+
+
+ {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 Tool
+ Server Tool
+
+
+
+
+ Execution
+ Browser (React)
+ Server (Node/Bun)
+
+
+ Approval trigger
+ setPendingApprovals (React state)
+ events.emit(TOOL_APPROVAL_REQUEST)
+
+
+ Wait mechanism
+ Promise + runtimeApprovalResolversRef
+ waitForApproval(session, approvalId)
+
+
+ UI
+ Same ToolApprovalDialog
+ Same 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/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/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();
});
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') {
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..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,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: 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: 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/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/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/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/agents/AISDKAgent.ts b/packages/server/src/agents/AISDKAgent.ts
index 569a5ec8..35d15806 100644
--- a/packages/server/src/agents/AISDKAgent.ts
+++ b/packages/server/src/agents/AISDKAgent.ts
@@ -5,6 +5,8 @@ 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';
import { isRemoteTool, isServerTool } from '../utils/toolFilters';
@@ -857,7 +859,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 +874,33 @@ export class AISDKAgent implements Agent {
args,
session.currentMcpHeaders // Pass MCP headers from current request
);
+
+ // 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;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
@@ -903,9 +933,9 @@ 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);
+ baseExecutor = createServerToolExecutor(toolDef, session, events);
} else {
baseExecutor = clientToolExecutor;
}
diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts
index 5d3f572f..9c58add9 100644
--- a/packages/server/src/index.ts
+++ b/packages/server/src/index.ts
@@ -17,6 +17,15 @@ export { logger } from './logger';
export { defineServerTool } from './tools';
export type { ServerToolConfig, ServerToolContext, ServerToolDefinition } from './tools';
+// 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 {
createClientToolExecutor,
diff --git a/packages/server/src/mcp/index.ts b/packages/server/src/mcp/index.ts
index ef873829..1ff86f9d 100644
--- a/packages/server/src/mcp/index.ts
+++ b/packages/server/src/mcp/index.ts
@@ -1 +1,7 @@
export { RemoteMcpToolsProvider, type RemoteToolDefinition } from './RemoteMcpToolsProvider';
+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
new file mode 100644
index 00000000..646608b7
--- /dev/null
+++ b/packages/server/src/mcp/mcpConfirmation.test.ts
@@ -0,0 +1,264 @@
+import { describe, expect, test } from 'bun:test';
+import {
+ isMcpConfirmationResponse,
+ 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';
+
+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,
+ };
+}
+
+function createMockEmitter(): EventEmitter & { emittedEvents: unknown[] } {
+ const emittedEvents: unknown[] = [];
+ return {
+ emit: (event: unknown) => { emittedEvents.push(event); },
+ emittedEvents,
+ } as EventEmitter & { emittedEvents: unknown[] };
+}
+
+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 }[] };
+}
+
+// ── isMcpConfirmationResponse (narrows from UseAIInternalResponse) ──────────
+
+describe('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?' },
+ };
+ expect(isMcpConfirmationResponse(value)).toBe(true);
+ });
+
+ test('returns true with optional metadata and additional_columns', () => {
+ const value: UseAIInternalResponse = {
+ _use_ai_internal: true,
+ _use_ai_type: 'confirmation_required',
+ _use_ai_metadata: {
+ message: 'Transfer $5000?',
+ metadata: { amount: 5000 },
+ additional_columns: { token: 'abc' },
+ },
+ };
+ expect(isMcpConfirmationResponse(value)).toBe(true);
+ });
+
+ test('returns false for different _use_ai_type', () => {
+ const value = {
+ _use_ai_internal: true,
+ _use_ai_type: 'future_feature',
+ _use_ai_metadata: { message: 'hello' },
+ };
+ expect(isMcpConfirmationResponse(value)).toBe(false);
+ });
+
+ test('returns false when message is missing', () => {
+ const value = {
+ _use_ai_internal: true,
+ _use_ai_type: 'confirmation_required',
+ _use_ai_metadata: {},
+ };
+ expect(isMcpConfirmationResponse(value)).toBe(false);
+ });
+
+ test('returns false when message is not a string', () => {
+ const value = {
+ _use_ai_internal: true,
+ _use_ai_type: 'confirmation_required',
+ _use_ai_metadata: { message: 123 },
+ };
+ expect(isMcpConfirmationResponse(value)).toBe(false);
+ });
+});
+
+// ── handleMcpConfirmation ───────────────────────────────────────────────────
+
+describe('handleMcpConfirmation', () => {
+ const originalArgs = { to: 'Bob', amount: 5000 };
+ const confirmation: McpConfirmationResponse = {
+ _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 with originalArgs (not merged args)', async () => {
+ const session = createTestSession();
+ const events = createMockEmitter();
+ const provider = createMockProvider();
+
+ const promise = handleMcpConfirmation(
+ confirmation, 'tool-call-1', 'ns_transfer', 'transfer',
+ originalArgs, provider, session, events
+ );
+
+ 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 });
+
+ session.pendingToolApprovals.get('tool-call-1')!({ approved: true });
+ await promise;
+ });
+
+ 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' });
+
+ const promise = handleMcpConfirmation(
+ confirmation, 'tool-call-1', 'ns_transfer', 'transfer',
+ originalArgs, provider, session, events
+ );
+
+ session.pendingToolApprovals.get('tool-call-1')!({ approved: true });
+ const result = await promise;
+
+ 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',
+ });
+ 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
+ );
+
+ session.pendingToolApprovals.get('tool-call-1')!({ approved: true });
+ await promise;
+
+ expect(provider.executedCalls[0].args).toEqual({ to: 'Bob', amount: 5000 });
+ });
+
+ 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', 'transfer',
+ originalArgs, provider, session, events
+ );
+
+ session.pendingToolApprovals.get('tool-call-1')!({ approved: false, reason: 'Too expensive' });
+ const result = await promise;
+
+ 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', 'transfer',
+ originalArgs, provider, session, events
+ );
+
+ 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',
+ });
+ });
+
+ test('catches phase-2 execution error gracefully', async () => {
+ const session = createTestSession();
+ const events = createMockEmitter();
+ 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
+ );
+
+ 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',
+ });
+ });
+
+ 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', 'transfer',
+ originalArgs, provider, session, events, mcpHeaders
+ );
+
+ 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
new file mode 100644
index 00000000..b0ac4bb6
--- /dev/null
+++ b/packages/server/src/mcp/mcpConfirmation.ts
@@ -0,0 +1,106 @@
+/**
+ * MCP tool runtime interactive approval (`_use_ai_type: "confirmation_required"`).
+ *
+ * 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';
+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 type { McpConfirmationResponse } from '@meetsmore-oss/use-ai-core';
+import { logger } from '../logger';
+
+export {
+ isMcpConfirmationResponse,
+ type McpConfirmationResponse,
+} from '@meetsmore-oss/use-ai-core';
+
+/**
+ * Handles an MCP confirmation response:
+ * 1. Emits TOOL_APPROVAL_REQUEST to the client
+ * 2. Waits for user approval via waitForApproval()
+ * 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).
+ */
+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,
+ originalToolName,
+ });
+
+ // Emit approval request event to the client (expose originalArgs, not internal columns)
+ events.emit({
+ type: TOOL_APPROVAL_REQUEST,
+ toolCallId,
+ toolCallName,
+ toolCallArgs: originalArgs,
+ message,
+ 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'}`,
+ };
+ }
+
+ // 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,
+ originalToolName,
+ });
+
+ try {
+ const result = await provider.executeTool(
+ originalToolName,
+ phase2Args,
+ mcpHeaders
+ );
+ return result;
+ } catch (error) {
+ const errorMsg = error instanceof Error ? error.message : String(error);
+ logger.error('[MCP] Phase 2 execution failed', {
+ toolCallId,
+ originalToolName,
+ error: errorMsg,
+ });
+ return {
+ error: true,
+ message: `MCP confirmation execution failed: ${errorMsg}`,
+ };
+ }
+}
diff --git a/packages/server/src/mcp/useAIInternalResponse.test.ts b/packages/server/src/mcp/useAIInternalResponse.test.ts
new file mode 100644
index 00000000..bbfe5a40
--- /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 false for unknown _use_ai_type', () => {
+ expect(isUseAIInternalResponse({
+ _use_ai_internal: true,
+ _use_ai_type: 'future_feature',
+ _use_ai_metadata: { foo: 'bar' },
+ })).toBe(false);
+ });
+
+ 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..d9541155
--- /dev/null
+++ b/packages/server/src/mcp/useAIInternalResponse.ts
@@ -0,0 +1,8 @@
+/**
+ * Re-export shared `_use_ai_` internal response types from core.
+ */
+export {
+ isUseAIInternalResponse,
+ type UseAIInternalResponseBase,
+ type UseAIInternalResponse,
+} from '@meetsmore-oss/use-ai-core';
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 }>;
}
/**