Skip to content

Commit 40f9297

Browse files
abhiaiyer91Mastra Code (anthropic/claude-opus-4-8)
andauthored
feat(core): support conditional, function-based tool approvals (#17337)
Co-authored-by: Mastra Code (anthropic/claude-opus-4-8) <noreply@mastra.ai>
1 parent 0f77241 commit 40f9297

24 files changed

Lines changed: 688 additions & 42 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@mastra/core': minor
3+
'@mastra/mcp': patch
4+
---
5+
6+
Support conditional, function-based tool approvals.
7+
8+
- MCP tools that wrap a server-level `requireToolApproval` function are now honored end-to-end. The per-tool approval function was previously dropped when the agent converted MCP tools (it kept only the boolean flag), so conditional approval silently fell back to always-on. `CoreToolBuilder` now preserves a `needsApprovalFn` attached directly to a tool instance.
9+
- The global `requireToolApproval` option on `agent.stream`/`agent.generate` now accepts a function in addition to a boolean. It is evaluated per tool call with the tool name, arguments, and request context, enabling policies such as regex allowlists on tool names. Returning `true` requires approval for that call; `false` allows it. On error the call defaults to requiring approval. When a function policy is set, tool calls run sequentially so approval suspensions don't race. Durable agents and stored agents continue to accept only a boolean (a function degrades to requiring approval for every call, since their options must be serializable).
10+
```ts
11+
// Approve only tool calls whose name is not on an allowlist.
12+
const allowlist = /^(get|list|search)_/;
13+
await agent.generate('...', {
14+
requireToolApproval: ({ toolName }) => !allowlist.test(toolName),
15+
});
16+
```
17+
18+
- Precedence is unchanged from before: a per-tool approval function (`createTool({ requireApproval: fn })` or an MCP-derived `needsApprovalFn`) is authoritative for that tool and overrides the global setting, so a tool can still opt out of approval by returning `false` even when the global option is on. The only new behavior is that the global option may now be a function in addition to a boolean.
19+
- The previously implicit, runtime-attached per-tool approval predicate is now a typed contract: `@mastra/core` exports `NeedsApprovalFn` and declares the optional `needsApprovalFn` property on the `Tool` class. The MCP client and the agent runtime now share this typed contract instead of reaching through `any`. This is additive — no public API changes.

docs/src/content/en/docs/agents/agent-approval.mdx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,24 @@ for await (const chunk of stream.fullStream) {
9999
}
100100
```
101101

102+
#### Conditional approval with a function
103+
104+
Instead of a boolean, `requireToolApproval` accepts a function that decides per tool call. It receives the `toolName`, the `args` the model passed, the `requestContext`, and the `workspace`. Return `true` to require approval for that call, or `false` to allow it. This lets you gate approval dynamically — for example, only for tools whose name matches a pattern:
105+
106+
```typescript
107+
const stream = await agent.stream('Clean up old records', {
108+
requireToolApproval: ({ toolName }) => /^delete_/.test(toolName),
109+
})
110+
```
111+
112+
A tool's own `requireApproval` setting still takes precedence: if a tool defines its own approval rule, that rule decides for that tool and the function above does not override it. If the function throws, the call requires approval (fail-safe).
113+
114+
:::note
115+
116+
Function-based `requireToolApproval` is only available on regular `stream()` / `generate()` calls. Durable agents and stored agents persist their options, and a function can't be serialized, so they accept only a boolean. If you pass a function in those contexts it falls back to requiring approval for every tool call.
117+
118+
:::
119+
102120
### Runtime suspension with `suspend()`
103121

104122
A tool can also pause _during_ its `execute` function by calling `suspend()`. This is useful when the tool starts running and then discovers it needs additional user input or confirmation before it can finish.

packages/core/src/agent/__tests__/tool-approval.test.ts

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,190 @@ export function toolApprovalAndSuspensionTests(version: 'v1' | 'v2') {
159159
expect(toolName).toBe('findUserTool');
160160
}
161161
}, 500000);
162+
163+
it('should evaluate a function-valued global requireToolApproval per tool call', async () => {
164+
const execute = vi.fn().mockResolvedValue({ name: 'Dero Israel', email: 'dero@mail.com' });
165+
const findUserTool = createTool({
166+
id: 'find-user-tool',
167+
description: 'Returns the name and email for a user',
168+
inputSchema: z.object({ name: z.string() }),
169+
execute: async () => execute(),
170+
});
171+
172+
const makeModel = () =>
173+
new MockLanguageModelV2({
174+
doStream: async () => ({
175+
rawCall: { rawPrompt: null, rawSettings: {} },
176+
warnings: [],
177+
stream: convertArrayToReadableStream([
178+
{ type: 'stream-start', warnings: [] },
179+
{ type: 'response-metadata', id: 'id-0', modelId: 'mock-model-id', timestamp: new Date(0) },
180+
{
181+
type: 'tool-call',
182+
toolCallId: 'call-1',
183+
toolName: 'findUserTool',
184+
input: '{"name":"Dero Israel"}',
185+
providerExecuted: false,
186+
},
187+
{
188+
type: 'finish',
189+
finishReason: 'tool-calls',
190+
usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 },
191+
},
192+
]),
193+
}),
194+
});
195+
196+
const makeAgent = () =>
197+
new Agent({
198+
id: 'global-approval-agent',
199+
name: 'Global Approval Agent',
200+
instructions: 'You can look up users.',
201+
model: makeModel(),
202+
tools: { findUserTool },
203+
memory: new MockMemory(),
204+
});
205+
206+
// Case 1: the policy returns true → the tool call suspends for approval and does not execute.
207+
const approveAll = vi.fn().mockReturnValue(true);
208+
const suspendingAgent = makeAgent();
209+
const suspendStream = await suspendingAgent.stream('Find the user with name - Dero Israel', {
210+
memory: { thread: randomUUID(), resource: randomUUID() },
211+
requireToolApproval: approveAll,
212+
});
213+
let approvedToolName = '';
214+
for await (const chunk of suspendStream.fullStream) {
215+
if (chunk.type === 'tool-call-approval') {
216+
approvedToolName = chunk.payload.toolName;
217+
}
218+
}
219+
expect(approveAll).toHaveBeenCalledWith(
220+
expect.objectContaining({ toolName: 'findUserTool', args: { name: 'Dero Israel' } }),
221+
);
222+
expect(approvedToolName).toBe('findUserTool');
223+
expect(execute).not.toHaveBeenCalled();
224+
225+
// Case 2: the policy returns false → the tool runs without requiring approval.
226+
const denyApproval = vi.fn().mockReturnValue(false);
227+
const runningAgent = makeAgent();
228+
const runStream = await runningAgent.stream('Find the user with name - Dero Israel', {
229+
memory: { thread: randomUUID(), resource: randomUUID() },
230+
requireToolApproval: denyApproval,
231+
});
232+
let sawApproval = false;
233+
for await (const chunk of runStream.fullStream) {
234+
if (chunk.type === 'tool-call-approval') {
235+
sawApproval = true;
236+
}
237+
}
238+
expect(denyApproval).toHaveBeenCalled();
239+
expect(sawApproval).toBe(false);
240+
expect(execute).toHaveBeenCalled();
241+
}, 500000);
242+
243+
it('honors a function-valued global requireToolApproval across suspend and resume', async () => {
244+
// The function policy lives only in the live JS call (RequestContext.toJSON strips it from
245+
// the persisted suspend snapshot). This proves the resume call re-supplies and re-evaluates
246+
// the function, so approval survives a real suspend -> resume cycle without serialization.
247+
let callCount = 0;
248+
const mockModel = new MockLanguageModelV2({
249+
doStream: async () => {
250+
callCount++;
251+
if (callCount === 1) {
252+
return {
253+
rawCall: { rawPrompt: null, rawSettings: {} },
254+
warnings: [],
255+
stream: convertArrayToReadableStream([
256+
{ type: 'stream-start', warnings: [] },
257+
{ type: 'response-metadata', id: 'id-0', modelId: 'mock-model-id', timestamp: new Date(0) },
258+
{
259+
type: 'tool-call',
260+
toolCallId: 'call-1',
261+
toolName: 'findUserTool',
262+
input: '{"name":"Dero Israel"}',
263+
providerExecuted: false,
264+
},
265+
{
266+
type: 'finish',
267+
finishReason: 'tool-calls',
268+
usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 },
269+
},
270+
]),
271+
};
272+
}
273+
return {
274+
rawCall: { rawPrompt: null, rawSettings: {} },
275+
warnings: [],
276+
stream: convertArrayToReadableStream([
277+
{ type: 'stream-start', warnings: [] },
278+
{ type: 'response-metadata', id: 'id-0', modelId: 'mock-model-id', timestamp: new Date(0) },
279+
{
280+
type: 'tool-call',
281+
toolCallId: 'call-2',
282+
toolName: 'findUserTool',
283+
input: '{"name":"Dero Israel", "resumeData": { "approved": true }}',
284+
providerExecuted: false,
285+
},
286+
{
287+
type: 'finish',
288+
finishReason: 'tool-calls',
289+
usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 },
290+
},
291+
]),
292+
};
293+
},
294+
});
295+
296+
const findUserTool = createTool({
297+
id: 'find-user-tool',
298+
description: 'Returns the name and email for a user',
299+
inputSchema: z.object({ name: z.string() }),
300+
execute: async input => mockFindUser(input) as Promise<Record<string, any>>,
301+
});
302+
303+
const resumeAgent = new Agent({
304+
id: 'resume-approval-agent',
305+
name: 'Resume Approval Agent',
306+
instructions: 'You can look up users.',
307+
model: mockModel,
308+
tools: { findUserTool },
309+
memory: new MockMemory(),
310+
});
311+
312+
const mastra = new Mastra({ agents: { resumeAgent }, logger: false, storage: mockStorage });
313+
const agent = mastra.getAgent('resumeAgent');
314+
const memory = { thread: randomUUID(), resource: randomUUID() };
315+
const requireToolApproval = vi.fn().mockReturnValue(true);
316+
317+
mockFindUser.mockClear();
318+
319+
// First call: the function policy requires approval, so the tool suspends.
320+
const suspendStream = await agent.stream('Find the user with name - Dero Israel', {
321+
memory,
322+
requireToolApproval,
323+
});
324+
let toolName = '';
325+
for await (const chunk of suspendStream.fullStream) {
326+
if (chunk.type === 'tool-call-approval') {
327+
toolName = chunk.payload.toolName;
328+
}
329+
}
330+
expect(toolName).toBe('findUserTool');
331+
expect(mockFindUser).not.toHaveBeenCalled();
332+
333+
// Resume call: re-supplies the same function policy. Approval is granted, tool executes.
334+
const resumeStream = await agent.stream('Approve', { memory, requireToolApproval });
335+
for await (const _chunk of resumeStream.fullStream) {
336+
// drain
337+
}
338+
const toolResults = await resumeStream.toolResults;
339+
const toolCall = toolResults?.find((result: any) => result.payload.toolName === 'findUserTool')?.payload;
340+
341+
// The policy was evaluated on both the suspend and resume passes (function survived resume).
342+
expect(requireToolApproval.mock.calls.length).toBeGreaterThanOrEqual(2);
343+
expect(mockFindUser).toHaveBeenCalled();
344+
expect((toolCall?.result as any)?.name).toBe('Dero Israel');
345+
}, 500000);
162346
});
163347
});
164348
}

packages/core/src/agent/__tests__/tools.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1387,6 +1387,54 @@ describe('requireApproval property preservation', () => {
13871387
}
13881388
}
13891389
});
1390+
1391+
it('should preserve a needsApprovalFn attached directly to a tool (MCP shape) through convertTools', async () => {
1392+
const mockModel = new MockLanguageModelV2({
1393+
doGenerate: async () => ({
1394+
rawCall: { rawPrompt: null, rawSettings: {} },
1395+
finishReason: 'stop',
1396+
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
1397+
text: 'ok',
1398+
content: [{ type: 'text', text: 'ok' }],
1399+
warnings: [],
1400+
}),
1401+
});
1402+
1403+
// Mirror how the MCP client builds tools: boolean requireApproval plus a
1404+
// server-level approval function attached directly as needsApprovalFn.
1405+
const needsApprovalFn = (args: any) => args.amount > 100;
1406+
const mcpStyleTool = createTool({
1407+
id: 'transfer-funds',
1408+
description: 'Transfer funds',
1409+
inputSchema: z.object({ amount: z.number() }),
1410+
requireApproval: true,
1411+
execute: async ({ amount }) => ({ success: true, amount }),
1412+
});
1413+
mcpStyleTool.needsApprovalFn = needsApprovalFn;
1414+
1415+
const agent = new Agent({
1416+
id: 'test-agent',
1417+
name: 'Test Agent',
1418+
instructions: 'Test agent for conditional approval',
1419+
model: mockModel,
1420+
});
1421+
1422+
const tools = await agent['convertTools']({
1423+
requestContext: new RequestContext(),
1424+
methodType: 'generate',
1425+
toolsets: {
1426+
banking: {
1427+
transferFunds: mcpStyleTool,
1428+
},
1429+
},
1430+
});
1431+
1432+
expect(tools.transferFunds).toBeDefined();
1433+
expect((tools.transferFunds as any).requireApproval).toBe(true);
1434+
// The conditional approval function must survive conversion so tool-call-step
1435+
// can evaluate it per call instead of falling back to the boolean flag.
1436+
expect((tools.transferFunds as any).needsApprovalFn).toBe(needsApprovalFn);
1437+
});
13901438
});
13911439

13921440
describe('sub-agent prompt input normalization (GitHub #14154)', () => {

packages/core/src/agent/agent.types.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type { VersionOverrides } from '../mastra/types';
99
import type { ObservabilityContext, TracingOptions } from '../observability';
1010
import type { ErrorProcessorOrWorkflow, InputProcessorOrWorkflow, OutputProcessorOrWorkflow } from '../processors';
1111
import type { RequestContext } from '../request-context';
12-
import type { ToolPayloadTransformPolicy } from '../tools';
12+
import type { RequireToolApproval, ToolPayloadTransformPolicy } from '../tools';
1313
import type { OutputWriter, WorkflowRunState } from '../workflows/types';
1414
import type { MessageListInput } from './message-list';
1515
import type {
@@ -568,8 +568,12 @@ export type AgentExecutionOptionsBase<OUTPUT> = {
568568
*/
569569
isTaskComplete?: StreamIsTaskCompleteConfig;
570570

571-
/** Require approval for all tool calls */
572-
requireToolApproval?: boolean;
571+
/**
572+
* Require approval for tool calls. Pass `true` to require approval for every tool call,
573+
* or a function evaluated per call (with the tool name, args, and request context) to
574+
* decide conditionally — e.g. to gate approval by a regex on the tool name.
575+
*/
576+
requireToolApproval?: RequireToolApproval;
573577

574578
/** Automatically resume suspended tools */
575579
autoResumeSuspendedTools?: boolean;

packages/core/src/agent/durable/preparation.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,10 @@ export async function prepareForDurableExecution<OUTPUT = undefined>(
327327
toolChoice: execOptions?.toolChoice as any,
328328
activeTools: execOptions?.activeTools,
329329
temperature: execOptions?.modelSettings?.temperature,
330-
requireToolApproval: execOptions?.requireToolApproval,
330+
// Durable runs serialize their options, so a function-valued global approval policy
331+
// can't be persisted. Degrade safely by requiring approval for every tool call.
332+
requireToolApproval:
333+
typeof execOptions?.requireToolApproval === 'function' ? true : execOptions?.requireToolApproval,
331334
toolCallConcurrency: execOptions?.toolCallConcurrency,
332335
autoResumeSuspendedTools: execOptions?.autoResumeSuspendedTools,
333336
maxProcessorRetries: execOptions?.maxProcessorRetries,

packages/core/src/agent/durable/utils/resolve-runtime.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { StreamInternal } from '../../../loop/types';
55
import type { Mastra } from '../../../mastra';
66
import type { MastraMemory } from '../../../memory/memory';
77
import { RequestContext } from '../../../request-context';
8+
import { getNeedsApprovalFn } from '../../../tools/toolchecks';
89
import type { CoreTool } from '../../../tools/types';
910
import type { Workspace } from '../../../workspace';
1011
import { MessageList } from '../../message-list';
@@ -246,8 +247,9 @@ export function resolveTool(toolName: string, mastra?: Mastra): CoreTool | undef
246247
* Check if a tool requires human approval.
247248
*
248249
* If the tool has a `needsApprovalFn`, it takes precedence over both the
249-
* global `requireToolApproval` flag and the tool-level `requireApproval` flag.
250-
* This matches the behavior of the non-durable agent's tool-call-step.
250+
* global `requireToolApproval` flag and the tool-level `requireApproval` flag
251+
* (e.g. skill tools return `false` to suppress approval). On error the call
252+
* defaults to requiring approval (safe default).
251253
*/
252254
export async function toolRequiresApproval(
253255
tool: CoreTool,
@@ -257,9 +259,10 @@ export async function toolRequiresApproval(
257259
let requires = !!(globalRequireApproval || (tool as any).requireApproval);
258260

259261
// needsApprovalFn overrides all other flags (e.g., skill tools return false)
260-
if ((tool as any).needsApprovalFn) {
262+
const needsApprovalFn = getNeedsApprovalFn(tool);
263+
if (needsApprovalFn) {
261264
try {
262-
requires = await (tool as any).needsApprovalFn(args ?? {});
265+
requires = !!(await needsApprovalFn(args ?? {}));
263266
} catch {
264267
// On error, default to requiring approval (safe default)
265268
requires = true;

packages/core/src/agent/workflows/prepare-stream/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type { Span, SpanType } from '../../../observability';
88
import { InternalSpans } from '../../../observability';
99
import type { RequestContext } from '../../../request-context';
1010
import { MastraModelOutput } from '../../../stream';
11-
import type { ToolPayloadTransformPolicy } from '../../../tools';
11+
import type { RequireToolApproval, ToolPayloadTransformPolicy } from '../../../tools';
1212
import { createWorkflow } from '../../../workflows/workflow';
1313
import type { Workspace } from '../../../workspace/workspace';
1414
import type { InnerAgentExecutionOptions } from '../../agent.types';
@@ -35,7 +35,7 @@ interface CreatePrepareStreamWorkflowOptions<OUTPUT = undefined> {
3535
memory?: MastraMemory;
3636
returnScorerData?: boolean;
3737
saveQueueManager?: SaveQueueManager;
38-
requireToolApproval?: boolean;
38+
requireToolApproval?: RequireToolApproval;
3939
toolCallConcurrency?: number;
4040
resumeContext?: {
4141
resumeData: any;

packages/core/src/agent/workflows/prepare-stream/stream-step.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type { MemoryConfigInternal } from '../../../memory/types';
88
import { resolveObservabilityContext } from '../../../observability';
99
import { RequestContext } from '../../../request-context';
1010
import { MastraModelOutput } from '../../../stream';
11-
import type { ToolPayloadTransformPolicy } from '../../../tools';
11+
import type { RequireToolApproval, ToolPayloadTransformPolicy } from '../../../tools';
1212
import { createStep } from '../../../workflows/workflow';
1313
import type { Workspace } from '../../../workspace/workspace';
1414
import type { SaveQueueManager } from '../../save-queue';
@@ -20,7 +20,7 @@ interface StreamStepOptions {
2020
capabilities: AgentCapabilities;
2121
runId: string;
2222
returnScorerData?: boolean;
23-
requireToolApproval?: boolean;
23+
requireToolApproval?: RequireToolApproval;
2424
toolCallConcurrency?: number;
2525
resumeContext?: {
2626
resumeData: any;

0 commit comments

Comments
 (0)