Skip to content

Commit 0f77241

Browse files
mbenhamdabhiaiyer91Mastra Code (anthropic/claude-opus-4-8)
authored
feat(core): add request-aware tool search filtering (#16088)
Co-authored-by: Abhi Aiyer <abhiaiyer91@gmail.com> Co-authored-by: Mastra Code (anthropic/claude-opus-4-8) <noreply@mastra.ai>
1 parent 212c635 commit 0f77241

5 files changed

Lines changed: 483 additions & 29 deletions

File tree

.changeset/six-badgers-drop.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@mastra/core': minor
3+
---
4+
5+
Added request-aware filtering for ToolSearchProcessor search, load, and active tools. The filter hook receives the resolved tool ID as `toolName`.
6+
7+
```ts
8+
new ToolSearchProcessor({
9+
tools,
10+
filter: ({ toolName, requestContext }) => {
11+
const plan = requestContext?.get('plan')
12+
return plan === 'pro' || !toolName.startsWith('premium_')
13+
},
14+
})
15+
```

docs/src/content/en/reference/processors/tool-search-processor.mdx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,13 @@ const toolSearch = new ToolSearchProcessor({
7676
isOptional: true,
7777
default: '3600000',
7878
},
79+
{
80+
name: 'filter',
81+
type: '(args: ToolSearchFilterArgs) => boolean | Promise<boolean>',
82+
description:
83+
'Optional request-aware hook for hiding tools from search results, blocking tool loading, or hiding already-loaded tools for the current request.',
84+
isOptional: true,
85+
},
7986
],
8087
},
8188
],
@@ -109,6 +116,35 @@ const toolSearch = new ToolSearchProcessor({
109116
]}
110117
/>
111118

119+
## Request-aware filtering
120+
121+
Use `filter` to apply request-specific policy to dynamic tools. The hook receives the resolved tool ID as `toolName`, the tool, request context, and phase. `toolName` is the ID returned by `search_tools`, which may differ from the key used in the `tools` object.
122+
123+
```typescript
124+
import { ToolSearchProcessor } from '@mastra/core/processors'
125+
126+
const toolSearch = new ToolSearchProcessor({
127+
tools: allTools,
128+
filter: ({ toolName, requestContext, phase }) => {
129+
const plan = requestContext?.get('plan')
130+
131+
if (phase === 'search') {
132+
return true
133+
}
134+
135+
return plan === 'pro' || !toolName.startsWith('premium_')
136+
},
137+
})
138+
```
139+
140+
The `phase` value describes where the filter is being applied:
141+
142+
- `search`: Filters results returned by `search_tools`.
143+
- `load`: Blocks `load_tool` from loading disallowed tools.
144+
- `active`: Hides already-loaded tools from the current request if they are no longer allowed.
145+
146+
If the hook throws or rejects, `ToolSearchProcessor` treats the tool as disallowed for that request. The hook may run for every matching search candidate, so keep async policy checks cheap or cached. The meta-tools `search_tools` and `load_tool` are always available. Tools passed directly through the agent or `processInputStep` remain available unless you filter them outside `ToolSearchProcessor`.
147+
112148
## Extended usage example
113149

114150
```typescript title="src/mastra/agents/dynamic-tools-agent.ts"

packages/core/src/processors/processors/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,12 @@ export { ToolCallFilter } from './tool-call-filter';
6363

6464
export { AgentsMDInjector, type ToolResultReminderOptions } from '../tool-result-reminder';
6565

66-
export { ToolSearchProcessor, type ToolSearchProcessorOptions } from './tool-search';
66+
export {
67+
ToolSearchProcessor,
68+
type ToolSearchFilterArgs,
69+
type ToolSearchFilterPhase,
70+
type ToolSearchProcessorOptions,
71+
} from './tool-search';
6772
export { SkillsProcessor, type SkillsProcessorOptions } from './skills';
6873
export { SkillSearchProcessor, type SkillSearchProcessorOptions } from './skill-search';
6974
export { WorkspaceInstructionsProcessor, type WorkspaceInstructionsProcessorOptions } from './workspace-instructions';

packages/core/src/processors/processors/tool-search.test.ts

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,45 @@ describe('ToolSearchProcessor', () => {
474474
expect(loadResult.message).toContain('Did you mean');
475475
});
476476

477+
it('should preserve key-only suggestions when filter is omitted', async () => {
478+
const processor = new ToolSearchProcessor({
479+
tools: {
480+
weather: createMockTool('weather_tool_id', 'Get weather'),
481+
},
482+
});
483+
484+
const args = createMockArgs('thread-suggestion-key');
485+
const result = await processor.processInputStep(args);
486+
const loadTool = result.tools?.load_tool;
487+
488+
const loadResult = await loadTool!.execute?.({ toolName: 'weath' }, undefined);
489+
490+
expect(loadResult.success).toBe(false);
491+
expect(loadResult.message).toContain('Did you mean: weather');
492+
expect(loadResult.message).not.toContain('weather_tool_id');
493+
});
494+
495+
it('should preserve key-first load resolution when filter is omitted', async () => {
496+
const keyedTool = createMockTool('public_weather', 'Public weather');
497+
const idCollisionTool = createMockTool('weather', 'Private weather');
498+
const processor = new ToolSearchProcessor({
499+
tools: {
500+
weather: keyedTool,
501+
private_weather: idCollisionTool,
502+
},
503+
});
504+
505+
const args1 = createMockArgs('thread-no-filter-key-id-collision');
506+
const result1 = await processor.processInputStep(args1);
507+
const loadResult = await result1.tools?.load_tool!.execute?.({ toolName: 'weather' }, undefined);
508+
509+
expect(loadResult.success).toBe(true);
510+
511+
const args2 = createMockArgs('thread-no-filter-key-id-collision');
512+
const result2 = await processor.processInputStep(args2);
513+
expect(result2.tools?.weather).toBe(keyedTool);
514+
});
515+
477516
it('should indicate when tool is already loaded', async () => {
478517
const processor = new ToolSearchProcessor({
479518
tools: {
@@ -695,6 +734,271 @@ describe('ToolSearchProcessor', () => {
695734
});
696735
});
697736

737+
describe('request-aware filtering', () => {
738+
it('should filter search results with filter', async () => {
739+
const processor = new ToolSearchProcessor({
740+
tools: {
741+
weather: createMockTool('weather', 'Get weather forecast'),
742+
weather_alerts: createMockTool('weather_alerts', 'Get weather alerts'),
743+
},
744+
filter: ({ toolName, phase }) => phase !== 'search' || toolName !== 'weather_alerts',
745+
});
746+
747+
const args = createMockArgs('thread-filter-search');
748+
const result = await processor.processInputStep(args);
749+
const searchResult = await result.tools?.search_tools!.execute?.({ query: 'weather' }, undefined);
750+
751+
expect(searchResult.results.map((tool: any) => tool.name)).toEqual(['weather']);
752+
});
753+
754+
it('should filter search results against the indexed tool id when keys collide with tool ids', async () => {
755+
const privateTool = createMockTool('weather', 'Private weather forecast');
756+
const publicTool = createMockTool('public_weather', 'Public weather forecast');
757+
const processor = new ToolSearchProcessor({
758+
tools: {
759+
private_weather: privateTool,
760+
weather: publicTool,
761+
},
762+
filter: ({ toolName }) => toolName !== 'weather',
763+
});
764+
765+
const args = createMockArgs('thread-filter-key-id-collision');
766+
const result = await processor.processInputStep(args);
767+
const searchResult = await result.tools?.search_tools!.execute?.({ query: 'weather' }, undefined);
768+
769+
expect(searchResult.results.map((tool: any) => tool.name)).toEqual(['public_weather']);
770+
});
771+
772+
it('should support async filter hooks', async () => {
773+
const processor = new ToolSearchProcessor({
774+
tools: {
775+
weather: createMockTool('weather', 'Get weather forecast'),
776+
calendar: createMockTool('calendar', 'Manage calendar'),
777+
},
778+
filter: async ({ toolName, phase }) => {
779+
await Promise.resolve();
780+
return phase !== 'search' || toolName !== 'calendar';
781+
},
782+
});
783+
784+
const args = createMockArgs('thread-async-filter');
785+
const result = await processor.processInputStep(args);
786+
const searchResult = await result.tools?.search_tools!.execute?.({ query: 'calendar' }, undefined);
787+
788+
expect(searchResult.results).toEqual([]);
789+
});
790+
791+
it('should block loading disallowed tools', async () => {
792+
const processor = new ToolSearchProcessor({
793+
tools: {
794+
weather: createMockTool('weather', 'Get weather'),
795+
},
796+
filter: ({ phase }) => phase !== 'load',
797+
});
798+
799+
const args1 = createMockArgs('thread-load-filter');
800+
const result1 = await processor.processInputStep(args1);
801+
const loadResult = await result1.tools?.load_tool!.execute?.({ toolName: 'weather' }, undefined);
802+
803+
expect(loadResult.success).toBe(false);
804+
expect(loadResult.toolName).toBe('weather');
805+
806+
const args2 = createMockArgs('thread-load-filter');
807+
const result2 = await processor.processInputStep(args2);
808+
expect(result2.tools?.weather).toBeUndefined();
809+
});
810+
811+
it('should filter load requests against the resolved tool id when keys collide with tool ids', async () => {
812+
const privateTool = createMockTool('weather', 'Private weather forecast');
813+
const publicTool = createMockTool('public_weather', 'Public weather forecast');
814+
const processor = new ToolSearchProcessor({
815+
tools: {
816+
private_weather: privateTool,
817+
weather: publicTool,
818+
},
819+
filter: ({ toolName }) => toolName !== 'weather',
820+
});
821+
822+
const args1 = createMockArgs('thread-load-key-id-collision');
823+
const result1 = await processor.processInputStep(args1);
824+
const loadResult = await result1.tools?.load_tool!.execute?.({ toolName: 'weather' }, undefined);
825+
826+
expect(loadResult.success).toBe(false);
827+
828+
const args2 = createMockArgs('thread-load-key-id-collision');
829+
const result2 = await processor.processInputStep(args2);
830+
expect(result2.tools?.weather).toBeUndefined();
831+
});
832+
833+
it('should not leak disallowed tools in load suggestions', async () => {
834+
const processor = new ToolSearchProcessor({
835+
tools: {
836+
premium_weather: createMockTool('premium_weather', 'Premium weather'),
837+
public_weather: createMockTool('public_weather', 'Public weather'),
838+
},
839+
filter: ({ toolName }) => toolName !== 'premium_weather',
840+
});
841+
842+
const args = createMockArgs('thread-filter-suggestions');
843+
const result = await processor.processInputStep(args);
844+
const loadResult = await result.tools?.load_tool!.execute?.({ toolName: 'premium' }, undefined);
845+
846+
expect(loadResult.success).toBe(false);
847+
expect(loadResult.message).not.toContain('premium_weather');
848+
});
849+
850+
it('should not suggest filtered key aliases that resolve to disallowed tool ids', async () => {
851+
const privateTool = createMockTool('weather', 'Private weather forecast');
852+
const publicTool = createMockTool('public_weather', 'Public weather forecast');
853+
const processor = new ToolSearchProcessor({
854+
tools: {
855+
private_weather: privateTool,
856+
weather: publicTool,
857+
},
858+
filter: ({ toolName }) => toolName !== 'weather',
859+
});
860+
861+
const args = createMockArgs('thread-filter-suggestions-key-id-collision');
862+
const result = await processor.processInputStep(args);
863+
const loadResult = await result.tools?.load_tool!.execute?.({ toolName: 'weath' }, undefined);
864+
865+
expect(loadResult.success).toBe(false);
866+
expect(loadResult.message).not.toContain('Did you mean: weather');
867+
});
868+
869+
it('should fill search results from lower-ranked allowed matches', async () => {
870+
const processor = new ToolSearchProcessor({
871+
tools: {
872+
premium_a: createMockTool('premium_a', 'Shared capability'),
873+
premium_b: createMockTool('premium_b', 'Shared capability'),
874+
premium_c: createMockTool('premium_c', 'Shared capability'),
875+
premium_d: createMockTool('premium_d', 'Shared capability'),
876+
public_a: createMockTool('public_a', 'Shared capability'),
877+
public_b: createMockTool('public_b', 'Shared capability'),
878+
},
879+
search: { topK: 2 },
880+
filter: ({ toolName, phase }) => phase !== 'search' || toolName.startsWith('public_'),
881+
});
882+
883+
const args = createMockArgs('thread-filter-fill');
884+
const result = await processor.processInputStep(args);
885+
const searchResult = await result.tools?.search_tools!.execute?.({ query: 'shared' }, undefined);
886+
887+
expect(searchResult.results.map((tool: any) => tool.name)).toEqual(['public_a', 'public_b']);
888+
});
889+
890+
it('should pass resolved tool id, tool, request context, and phase to filter', async () => {
891+
const calls: Array<{ toolName: string; tool: Tool<any, any>; requestContext?: RequestContext; phase: string }> =
892+
[];
893+
const weatherTool = createMockTool('weather_tool_id', 'Get weather');
894+
const processor = new ToolSearchProcessor({
895+
tools: {
896+
weather: weatherTool,
897+
},
898+
filter: args => {
899+
calls.push(args);
900+
return true;
901+
},
902+
});
903+
904+
const args1 = createMockArgs('thread-filter-args');
905+
args1.requestContext?.set('plan', 'pro');
906+
const result1 = await processor.processInputStep(args1);
907+
await result1.tools?.search_tools!.execute?.({ query: 'weather' }, undefined);
908+
await result1.tools?.load_tool!.execute?.({ toolName: 'weather' }, undefined);
909+
910+
const args2 = createMockArgs('thread-filter-args');
911+
args2.requestContext?.set('plan', 'pro');
912+
await processor.processInputStep(args2);
913+
914+
expect(calls.map(call => call.phase)).toEqual(['search', 'load', 'active']);
915+
expect(calls.every(call => call.toolName === 'weather_tool_id')).toBe(true);
916+
expect(calls.every(call => call.tool === weatherTool)).toBe(true);
917+
expect(calls.every(call => call.requestContext?.get('plan') === 'pro')).toBe(true);
918+
});
919+
920+
it('should fail closed when filter throws', async () => {
921+
const processor = new ToolSearchProcessor({
922+
tools: {
923+
weather: createMockTool('weather', 'Get weather'),
924+
},
925+
filter: () => {
926+
throw new Error('policy unavailable');
927+
},
928+
});
929+
930+
const args = createMockArgs('thread-filter-throws');
931+
const result = await processor.processInputStep(args);
932+
const searchResult = await result.tools?.search_tools!.execute?.({ query: 'weather' }, undefined);
933+
const loadResult = await result.tools?.load_tool!.execute?.({ toolName: 'weather' }, undefined);
934+
935+
expect(searchResult.results).toEqual([]);
936+
expect(loadResult.success).toBe(false);
937+
});
938+
939+
it('should filter active loaded tools per request without clearing thread state', async () => {
940+
const processor = new ToolSearchProcessor({
941+
tools: {
942+
weather: createMockTool('weather', 'Get weather'),
943+
},
944+
filter: ({ phase, requestContext }) => {
945+
if (phase !== 'active') return true;
946+
return requestContext?.get('allowWeather') === true;
947+
},
948+
});
949+
950+
const args1 = createMockArgs('thread-active-filter');
951+
args1.requestContext?.set('allowWeather', true);
952+
const result1 = await processor.processInputStep(args1);
953+
await result1.tools?.load_tool!.execute?.({ toolName: 'weather' }, undefined);
954+
955+
const disallowedArgs = createMockArgs('thread-active-filter');
956+
disallowedArgs.requestContext?.set('allowWeather', false);
957+
const disallowedResult = await processor.processInputStep(disallowedArgs);
958+
expect(disallowedResult.tools?.weather).toBeUndefined();
959+
960+
const allowedArgs = createMockArgs('thread-active-filter');
961+
allowedArgs.requestContext?.set('allowWeather', true);
962+
const allowedResult = await processor.processInputStep(allowedArgs);
963+
expect(allowedResult.tools?.weather).toBeDefined();
964+
});
965+
966+
it('should preserve existing tools passed to processInputStep when filter is provided', async () => {
967+
const existingTool = createMockTool('weather', 'Existing weather tool');
968+
const processor = new ToolSearchProcessor({
969+
tools: {
970+
weather: createMockTool('weather', 'Dynamic weather tool'),
971+
},
972+
filter: () => false,
973+
});
974+
975+
const args = createMockArgs('thread-existing-filter', { weather: existingTool });
976+
const result = await processor.processInputStep(args);
977+
978+
expect(result.tools?.weather).toBe(existingTool);
979+
});
980+
981+
it('should keep existing behavior when filter is omitted', async () => {
982+
const processor = new ToolSearchProcessor({
983+
tools: {
984+
weather: createMockTool('weather', 'Get weather'),
985+
},
986+
});
987+
988+
const args1 = createMockArgs('thread-no-filter');
989+
const result1 = await processor.processInputStep(args1);
990+
const searchResult = await result1.tools?.search_tools!.execute?.({ query: 'weather' }, undefined);
991+
const loadResult = await result1.tools?.load_tool!.execute?.({ toolName: 'weather' }, undefined);
992+
993+
expect(searchResult.results[0].name).toBe('weather');
994+
expect(loadResult.success).toBe(true);
995+
996+
const args2 = createMockArgs('thread-no-filter');
997+
const result2 = await processor.processInputStep(args2);
998+
expect(result2.tools?.weather).toBeDefined();
999+
});
1000+
});
1001+
6981002
describe('processInputStep integration', () => {
6991003
it('should return meta-tools (search_tools and load_tool)', async () => {
7001004
const processor = new ToolSearchProcessor({

0 commit comments

Comments
 (0)