[BUG] @mastra/mcp: a function `requireToolApproval` sets a static `requireApproval: true` on every tool, which forces tool-call concurrency to 1
Package & version
@mastra/mcp1.18.0@mastra/core1.67.0@modelcontextprotocol/sdk1.30.0- Node.js 24.14.0
Summary
MCPClient accepts requireToolApproval as a boolean or as a function. When the value is a function, buildToolFromListEntry stores the function in needsApprovalFn and also sets requireApproval = true on the built tool. The flag is unconditional. It does not depend on what the function returns.
@mastra/core reads that static flag to select the tool-call concurrency. If one considered tool has requireApproval, the concurrency becomes 1. The core evaluates needsApprovalFn later, in the tool-call step, after the concurrency decision.
Result: a policy that approves nothing (() => false) still makes all MCP tool calls run one after the other. The default 'available' strategy extends the effect to every tool in the run, including tools that are not MCP tools.
Steps to reproduce
Install @mastra/[email protected], @mastra/[email protected], @modelcontextprotocol/[email protected] and zod in an empty package with "type": "module".
mcp-server.mjs:
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
const server = new McpServer({ name: 'probe', version: '1.0.0' });
server.registerTool(
'echo',
{ description: 'echo the input', inputSchema: { text: z.string() } },
async ({ text }) => ({ content: [{ type: 'text', text }] }),
);
for (const name of ['slow_a', 'slow_b']) {
server.registerTool(
name,
{ description: 'sleeps 300 ms', inputSchema: {} },
async () => {
await new Promise(r => setTimeout(r, 300));
return { content: [{ type: 'text', text: name }] };
},
);
}
await server.connect(new StdioServerTransport());repro.mjs:
import { Agent } from '@mastra/core/agent';
import { MCPClient } from '@mastra/mcp';
import { fileURLToPath } from 'node:url';
const serverPath = fileURLToPath(new URL('./mcp-server.mjs', import.meta.url));
function stream(parts) {
return {
stream: new ReadableStream({
start(c) {
for (const p of parts) c.enqueue(p);
c.close();
},
}),
};
}
// Stub model: step 1 calls both slow tools, step 2 ends the run with text.
function makeModel(toolNames) {
let call = 0;
return {
specificationVersion: 'v2',
provider: 'mock',
modelId: 'mock-model',
supportedUrls: {},
async doGenerate() {
throw new Error('not used');
},
async doStream() {
if (call++ === 0) {
return stream([
{ type: 'stream-start', warnings: [] },
{ type: 'response-metadata', id: 'r1', modelId: 'mock', timestamp: new Date(0) },
...toolNames.map((toolName, i) => ({
type: 'tool-call',
toolCallId: `call-${i}`,
toolName,
input: '{}',
})),
{ type: 'finish', finishReason: 'tool-calls', usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 } },
]);
}
return stream([
{ type: 'stream-start', warnings: [] },
{ type: 'response-metadata', id: 'r2', modelId: 'mock', timestamp: new Date(0) },
{ type: 'text-start', id: 't1' },
{ type: 'text-delta', id: 't1', delta: 'done' },
{ type: 'text-end', id: 't1' },
{ type: 'finish', finishReason: 'stop', usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 } },
]);
},
};
}
async function run(label, requireToolApproval) {
const mcp = new MCPClient({
id: `probe-${label}`,
servers: {
probe: {
command: process.execPath,
args: [serverPath],
...(requireToolApproval ? { requireToolApproval } : {}),
},
},
});
const toolsets = await mcp.listToolsets();
const agent = new Agent({
name: 'probe-agent',
instructions: 'test',
model: makeModel(['probe_slow_a', 'probe_slow_b']),
});
const t0 = Date.now();
const result = await agent.stream('go', { toolsets });
for await (const _ of result.fullStream) {
/* drain */
}
const elapsed = Date.now() - t0;
const flags = Object.fromEntries(
Object.entries(toolsets.probe).map(([n, t]) => [n, t.requireApproval ?? false]),
);
console.log(`${label}: elapsedMs=${elapsed} requireApproval=${JSON.stringify(flags)}`);
await mcp.disconnect();
}
await run('no-policy', undefined);
await run('fn-policy-always-false', () => false);Run node repro.mjs.
Expected
requireToolApproval: () => false approves nothing. The built tools must carry requireApproval: false (or undefined). The two 300 ms tool calls must run in parallel. The elapsed time must stay near the time of the no-policy run.
Actual
Every built tool carries requireApproval: true. The two tool calls run one after the other. The elapsed time is about the sum of the two tool durations.
no-policy: elapsedMs=363 requireApproval={"echo":false,"slow_a":false,"slow_b":false}
fn-policy-always-false: elapsedMs=629 requireApproval={"echo":true,"slow_a":true,"slow_b":true}Three consecutive runs gave 363 / 406 / 409 ms for no-policy and 629 / 653 / 637 ms for fn-policy-always-false.
A shorter check without an agent gives the same flags:
const mcp = new MCPClient({
id: 'probe-client',
servers: {
probe: { command: process.execPath, args: [serverPath], requireToolApproval: () => false },
},
});
for (const [name, tool] of Object.entries(await mcp.listTools())) {
console.log(JSON.stringify({ tool: name, requireApproval: tool.requireApproval }));
}
await mcp.disconnect();{"tool":"probe_echo","requireApproval":true}
{"tool":"probe_slow_a","requireApproval":true}
{"tool":"probe_slow_b","requireApproval":true}Evidence
Where the flag is set:
packages/mcp/src/client/client.ts#L1386-L1403—buildToolFromListEntry. Line 1392 isrequireApproval = true; // Signal that approval check is needed. The assignment has no condition on the result of the function.
Where the flag selects the concurrency:
packages/core/src/loop/workflows/agentic-execution/tool-call-concurrency.ts#L76-L79—effectiveToolSetRequiresSequentialExecutionreturnstrueif one considered tool hashasSuspendSchemaorrequireApproval.resolveToolCallConcurrencythen returns 1.packages/core/src/agent/durable/workflows/shared/tool-call-concurrency.ts#L67-L69— the same rule in the durable agent path.packages/core/src/loop/workflows/agentic-execution/index.ts#L136-L143— the call site. It has the fulltoolCallsarray but passes onlycalledToolNames.
Where the function runs:
packages/core/src/loop/workflows/agentic-execution/tool-call-step.ts#L522-L527—needsApprovalFn(args, needsApprovalCtx)runs in the tool-call step, after the concurrency decision.
With the default 'available' strategy the effect is wider. The resolver reads the full active tool set. One MCP tool with the static flag makes every batch in that run sequential, including batches that call no MCP tool. toolCallConcurrency: { strategy: 'called' } limits the effect to batches that call an MCP tool, but does not remove it.
Suggested fix
The arguments of a tool call are known at the concurrency call site. packages/core/src/loop/workflows/agentic-execution/index.ts#L136 receives toolCalls, and each entry carries its arguments. Two options:
Evaluate the approval function at that point. Pass the called tools with their arguments to
resolveToolCallConcurrency, callneedsApprovalFn(args, ctx)for each, and select the concurrency from the results. The same evaluation already exists intool-call-step.ts#L522-L527, so the two decisions would use one rule. The'available'strategy keeps the static flag, because it has no arguments.Keep the static flag but make it accurate for the boolean case. Add a separate field, for example
approvalIsDynamic, thatbuildToolFromListEntrysets whenrequireToolApprovalis a function. Let the concurrency resolver treat a dynamic policy as "unknown" understrategy: 'called'and evaluate it, or let the consumer declare a static verdict that the client can forward.
Option 1 needs no new public API. Option 2 needs a new field but keeps the concurrency resolver synchronous.
Source: mastra-ai/mastra