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: +

+
    +
  1. First call: token: null — if amount > $1,000, returns _use_ai_type: "confirmation_required" with a server-issued one-time token in additional_columns
  2. +
  3. Server shows approval dialog to the user
  4. +
  5. If approved: server re-calls the same tool with original args merged with additional_columns
  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. +

+
+ +
+

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 ToolServer ToolMCP Tool
ExecutionBrowserServerRemote MCP endpoint
Approval triggersetPendingApprovalsctx.requestApproval()_use_ai_type: "confirmation_required"
Bypass preventionClient-side onlyServer-side ctxOne-time token in additional_columns
UISame dialogSame dialogSame 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

+ +
+
+ {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/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 }>; } /**