Skip to content

Commit fc877f2

Browse files
authored
fix: add tool rule options (#44)
* fix: add tool rule options * fix: remove unneeded file * fix: types * fix: types
1 parent 050ee2b commit fc877f2

10 files changed

Lines changed: 283 additions & 24 deletions

File tree

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
/**
2+
* Test to verify toolRules.allowOnlyApiGroups filtering works correctly.
3+
*/
4+
5+
import { createServer, type AgentToolProtocolServer } from '../../packages/server/src/index';
6+
import { AgentToolProtocolClient } from '../../packages/client/src/index';
7+
import { runInRequestScope } from '../../packages/server/src/core/request-scope';
8+
9+
describe('toolRules.allowOnlyApiGroups filtering', () => {
10+
let server: AgentToolProtocolServer;
11+
let client: AgentToolProtocolClient;
12+
13+
beforeAll(async () => {
14+
server = createServer();
15+
16+
// Register "math" API group
17+
server.use({
18+
name: 'math',
19+
type: 'custom',
20+
functions: [
21+
{
22+
name: 'add',
23+
description: 'Add two numbers',
24+
inputSchema: {
25+
type: 'object',
26+
properties: {
27+
a: { type: 'number' },
28+
b: { type: 'number' },
29+
},
30+
required: ['a', 'b'],
31+
},
32+
handler: async (input: unknown) => {
33+
const { a, b } = input as { a: number; b: number };
34+
return { result: a + b };
35+
},
36+
},
37+
],
38+
});
39+
40+
// Register "text" API group
41+
server.use({
42+
name: 'text',
43+
type: 'custom',
44+
functions: [
45+
{
46+
name: 'uppercase',
47+
description: 'Convert text to uppercase',
48+
inputSchema: {
49+
type: 'object',
50+
properties: {
51+
text: { type: 'string' },
52+
},
53+
required: ['text'],
54+
},
55+
handler: async (input: unknown) => {
56+
const { text } = input as { text: string };
57+
return { result: text.toUpperCase() };
58+
},
59+
},
60+
],
61+
});
62+
63+
await server.start();
64+
65+
client = new AgentToolProtocolClient({
66+
server: server as any,
67+
});
68+
69+
await client.init({ name: 'test-client', version: '1.0.0' });
70+
await client.connect();
71+
});
72+
73+
afterAll(async () => {
74+
// Cleanup if needed
75+
});
76+
77+
it('should allow math API when toolRules.allowOnlyApiGroups includes math', async () => {
78+
const result = await client.execute('return await api.math.add({ a: 2, b: 3 });', {
79+
toolRules: {
80+
allowOnlyApiGroups: ['math'],
81+
},
82+
});
83+
84+
expect(result.status).toBe('completed');
85+
expect((result.result as any)?.result).toBe(5);
86+
});
87+
88+
it('should block text API when toolRules.allowOnlyApiGroups only includes math', async () => {
89+
const result = await client.execute('return await api.text.uppercase({ text: "hello" });', {
90+
toolRules: {
91+
allowOnlyApiGroups: ['math'],
92+
},
93+
});
94+
95+
// When text API is blocked, the code should fail because api.text is undefined
96+
expect(result.status).toBe('failed');
97+
});
98+
99+
it('should allow both APIs when no toolRules are specified', async () => {
100+
const mathResult = await client.execute('return await api.math.add({ a: 1, b: 2 });');
101+
expect(mathResult.status).toBe('completed');
102+
expect((mathResult.result as any)?.result).toBe(3);
103+
104+
const textResult = await client.execute('return await api.text.uppercase({ text: "hello" });');
105+
expect(textResult.status).toBe('completed');
106+
expect((textResult.result as any)?.result).toBe('HELLO');
107+
});
108+
109+
it('should allow text API when toolRules.allowOnlyApiGroups includes text', async () => {
110+
const result = await client.execute('return await api.text.uppercase({ text: "world" });', {
111+
toolRules: {
112+
allowOnlyApiGroups: ['text'],
113+
},
114+
});
115+
116+
expect(result.status).toBe('completed');
117+
expect((result.result as any)?.result).toBe('WORLD');
118+
});
119+
120+
it('should block math API when toolRules.allowOnlyApiGroups only includes text', async () => {
121+
const result = await client.execute('return await api.math.add({ a: 5, b: 5 });', {
122+
toolRules: {
123+
allowOnlyApiGroups: ['text'],
124+
},
125+
});
126+
127+
expect(result.status).toBe('failed');
128+
});
129+
130+
describe('explore_api filtering', () => {
131+
it('should show all API groups at root when no toolRules', async () => {
132+
const result = await client.exploreAPI('/');
133+
134+
// Should see 'custom' directory at root
135+
expect(result).toHaveProperty('items');
136+
expect(Array.isArray((result as any).items)).toBe(true);
137+
});
138+
139+
it('should show both math and text when exploring /custom without toolRules', async () => {
140+
const result = await client.exploreAPI('/custom');
141+
142+
const items = (result as any).items || [];
143+
const itemNames = items.map((i: any) => i.name);
144+
145+
// Both APIs should be visible without filtering
146+
expect(itemNames).toContain('math');
147+
expect(itemNames).toContain('text');
148+
});
149+
150+
it('should filter explore results when runInRequestScope is used with toolRules', async () => {
151+
// Test that the filtering mechanism works when request scope is set
152+
const result = await runInRequestScope(
153+
{ toolRules: { allowOnlyApiGroups: ['math'] } },
154+
async () => {
155+
return await client.exploreAPI('/custom');
156+
}
157+
);
158+
159+
const items = (result as any).items || [];
160+
const itemNames = items.map((i: any) => i.name);
161+
162+
// Only math should be visible when toolRules filters to math only
163+
expect(itemNames).toContain('math');
164+
expect(itemNames).not.toContain('text');
165+
});
166+
167+
it('should filter explore results when toolRules passed directly to exploreAPI', async () => {
168+
const result = await client.exploreAPI('/custom', {
169+
toolRules: { allowOnlyApiGroups: ['math'] },
170+
});
171+
172+
const items = (result as any).items || [];
173+
const itemNames = items.map((i: any) => i.name);
174+
175+
// Only math should be visible when toolRules filters to math only
176+
expect(itemNames).toContain('math');
177+
expect(itemNames).not.toContain('text');
178+
});
179+
});
180+
181+
describe('search_api filtering', () => {
182+
it('should return results from all APIs when no options', async () => {
183+
const results = await client.searchAPI('add');
184+
185+
expect(Array.isArray(results)).toBe(true);
186+
expect(results.length).toBeGreaterThan(0);
187+
});
188+
189+
it('should find text API uppercase when no options', async () => {
190+
const results = await client.searchAPI('uppercase');
191+
192+
expect(results.length).toBeGreaterThan(0);
193+
expect(results.some((r) => r.functionName === 'uppercase')).toBe(true);
194+
});
195+
196+
it('should only return math results when apiGroups filter includes only math', async () => {
197+
// searchAPI accepts apiGroups option for filtering
198+
const results = await client.searchAPI('uppercase', {
199+
query: 'uppercase',
200+
apiGroups: ['math'],
201+
});
202+
203+
// When filtering by apiGroups, searching for "uppercase" with math-only filter
204+
// should return no results because uppercase belongs to text API
205+
expect(results.length).toBe(0);
206+
});
207+
208+
it('should filter search results when toolRules passed directly to searchAPI', async () => {
209+
// searchAPI with toolRules.allowOnlyApiGroups
210+
const results = await client.searchAPI('uppercase', {
211+
query: 'uppercase',
212+
toolRules: { allowOnlyApiGroups: ['math'] },
213+
});
214+
215+
// When filtering by toolRules, searching for "uppercase" with math-only filter
216+
// should return no results because uppercase belongs to text API
217+
expect(results.length).toBe(0);
218+
});
219+
});
220+
});

packages/client/src/client.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
ClientToolDefinition,
88
ExploreResult,
99
ATPEvent,
10+
ApiGroupRules,
1011
} from '@mondaydotcomorg/atp-protocol';
1112
import type { RuntimeAPIName } from '@mondaydotcomorg/atp-runtime';
1213
import { CallbackType } from '@mondaydotcomorg/atp-protocol';
@@ -246,8 +247,8 @@ export class AgentToolProtocolClient {
246247
/**
247248
* Explores the API filesystem at the given path.
248249
*/
249-
async exploreAPI(path: string): Promise<ExploreResult> {
250-
return await this.apiOps.exploreAPI(path);
250+
async exploreAPI(path: string, options?: { toolRules?: ApiGroupRules }): Promise<ExploreResult> {
251+
return await this.apiOps.exploreAPI(path, options);
251252
}
252253

253254
/**

packages/client/src/core/api-operations.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { SearchOptions, SearchResult, ExploreResult } from '@mondaydotcomorg/atp-protocol';
1+
import type { SearchOptions, SearchResult, ExploreResult, ApiGroupRules } from '@mondaydotcomorg/atp-protocol';
22
import type { RuntimeAPIName } from '@mondaydotcomorg/atp-runtime';
33
import type { ISession } from './session.js';
44
import type { InProcessSession } from './in-process-session.js';
@@ -106,15 +106,15 @@ export class APIOperations {
106106
/**
107107
* Explores the API filesystem at the given path.
108108
*/
109-
async exploreAPI(path: string): Promise<ExploreResult> {
109+
async exploreAPI(path: string, options?: { toolRules?: ApiGroupRules }): Promise<ExploreResult> {
110110
await this.session.ensureInitialized();
111111

112112
if (this.inProcessSession) {
113-
return (await this.inProcessSession.explore(path)) as ExploreResult;
113+
return (await this.inProcessSession.explore(path, options)) as ExploreResult;
114114
}
115115

116116
const url = `${this.session.getBaseUrl()}/api/explore`;
117-
const body = JSON.stringify({ path });
117+
const body = JSON.stringify({ path, ...options });
118118
const headers = await this.session.prepareHeaders('POST', url, body);
119119

120120
const response = await fetch(url, {

packages/client/src/core/in-process-session.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,13 +202,13 @@ export class InProcessSession implements ISession {
202202
return (await this.server.handleSearch(ctx)) as { results: unknown[] };
203203
}
204204

205-
async explore(path: string): Promise<unknown> {
205+
async explore(path: string, options?: Record<string, unknown>): Promise<unknown> {
206206
await this.ensureInitialized();
207207

208208
const ctx = this.createContext({
209209
method: 'POST',
210210
path: '/api/explore',
211-
body: { path },
211+
body: { path, ...options },
212212
});
213213

214214
return await this.server.handleExplore(ctx);

packages/protocol/src/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,8 @@ export interface ClientToolRules {
198198
allowOnlyApiGroups?: string[];
199199
}
200200

201+
export type ApiGroupRules = Pick<ClientToolRules, 'allowOnlyApiGroups' | 'blockApiGroups'>;
202+
201203
/**
202204
* Tool/API metadata for security and risk management
203205
*
@@ -422,6 +424,7 @@ export interface SearchOptions {
422424
maxResults?: number;
423425
useEmbeddings?: boolean;
424426
embeddingModel?: string;
427+
toolRules?: ClientToolRules;
425428
}
426429

427430
export interface SearchResult {
@@ -435,6 +438,7 @@ export interface SearchResult {
435438

436439
export interface ExploreRequest {
437440
path: string;
441+
toolRules?: ClientToolRules;
438442
}
439443

440444
export interface ExploreDirectoryResult {

packages/server/src/controllers/stream.controller.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ export async function handleExecuteStream(
9393
provenanceMode: request.config?.provenanceMode,
9494
securityPolicies: request.config?.securityPolicies,
9595
provenanceHints: request.config?.provenanceHints,
96+
toolRules: request.config?.toolRules,
9697
};
9798

9899
logger.info('Validating code for streaming execution', {

packages/server/src/handlers/execute.handler.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ export async function handleExecute(
128128
},
129129
onToolCall,
130130
eventCallback: requestConfig.eventCallback,
131+
toolRules: requestConfig.toolRules,
131132
};
132133

133134
// Verify provenance hints if provided
Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,29 @@
11
import type { RequestContext } from '../core/config.js';
22
import type { ExplorerService } from '../explorer/index.js';
3+
import type { ApiGroupRules } from '@mondaydotcomorg/atp-protocol';
4+
import { runInRequestScope, getRequestScope } from '../core/request-scope.js';
35

46
export async function handleExplore(
57
ctx: RequestContext,
68
explorerService: ExplorerService
79
): Promise<unknown> {
8-
const body = ctx.body as { path?: string };
10+
const body = ctx.body as { path?: string; toolRules?: ApiGroupRules };
911
const path = body.path || '/';
12+
const { toolRules } = body;
1013

11-
const result = explorerService.explore(path);
14+
const executeExplore = () => {
15+
const result = explorerService.explore(path);
1216

13-
if (!result) {
14-
ctx.throw(404, `Path not found: ${path}`);
17+
if (!result) {
18+
ctx.throw(404, `Path not found: ${path}`);
19+
}
20+
21+
return result;
22+
};
23+
24+
if (toolRules && !getRequestScope()?.toolRules) {
25+
return runInRequestScope({ toolRules }, executeExplore);
1526
}
1627

17-
return result;
28+
return executeExplore();
1829
}

packages/server/src/handlers/search.handler.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,30 @@
11
import type { RequestContext, ResolvedServerConfig } from '../core/config.js';
22
import type { SearchEngine } from '../search/index.js';
3+
import { runInRequestScope, getRequestScope } from '../core/request-scope.js';
34

45
export async function handleSearch(
56
ctx: RequestContext,
67
searchEngine: SearchEngine,
78
config: ResolvedServerConfig
89
): Promise<unknown> {
910
const searchOptions = ctx.body as any;
10-
const results = await searchEngine.search(
11-
searchOptions,
12-
ctx.userId,
13-
ctx.auth,
14-
config.discovery.scopeFiltering
15-
);
16-
return { results };
11+
const { toolRules, ...cleanOptions } = searchOptions;
12+
13+
const executeSearch = async () => {
14+
const results = await searchEngine.search(
15+
cleanOptions,
16+
ctx.userId,
17+
ctx.auth,
18+
config.discovery.scopeFiltering
19+
);
20+
return { results };
21+
};
22+
23+
if (toolRules && !getRequestScope()?.toolRules) {
24+
return runInRequestScope({ toolRules }, executeSearch);
25+
}
26+
27+
return executeSearch();
1728
}
1829

1930
export async function handleSearchQuery(

0 commit comments

Comments
 (0)