diff --git a/README.md b/README.md index d720e7e..2378b1c 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,41 @@ const result = await employeeTool?.execute({ `fetchTools()` reuses the credentials you already configured (for example via `STACKONE_API_KEY`) and binds the returned tool objects to StackOne's actions client. +#### Filtering Tools with fetchTools() + +You can filter tools by account IDs, providers, and action patterns: + +```typescript +// Filter by account IDs +toolset.setAccounts(['account-123', 'account-456']); +const tools = await toolset.fetchTools(); +// OR +const tools = await toolset.fetchTools({ accountIds: ['account-123', 'account-456'] }); + +// Filter by providers +const tools = await toolset.fetchTools({ providers: ['hibob', 'bamboohr'] }); + +// Filter by actions with exact match +const tools = await toolset.fetchTools({ + actions: ['hibob_list_employees', 'hibob_create_employees'] +}); + +// Filter by actions with glob patterns +const tools = await toolset.fetchTools({ actions: ['*_list_employees'] }); + +// Combine multiple filters +const tools = await toolset.fetchTools({ + accountIds: ['account-123'], + providers: ['hibob'], + actions: ['*_list_*'] +}); +``` + +This is especially useful when you want to: +- Limit tools to specific linked accounts +- Focus on specific HR/CRM/ATS providers +- Get only certain types of operations (e.g., all "list" operations) + [View full example](examples/fetch-tools.ts) ### File Upload diff --git a/examples/fetch-tools.ts b/examples/fetch-tools.ts index fe25bfc..59c2742 100644 --- a/examples/fetch-tools.ts +++ b/examples/fetch-tools.ts @@ -1,5 +1,5 @@ /** - * Example: fetch the latest StackOne tool catalog and execute a tool. + * Example: fetch the latest StackOne tool catalog with filtering options. * * Set `STACKONE_API_KEY` (and optionally `STACKONE_BASE_URL`) before running. * By default the script exits early in test environments where a real key is @@ -24,10 +24,59 @@ const toolset = new StackOneToolSet({ baseUrl: process.env.STACKONE_BASE_URL ?? 'https://api.stackone.com', }); -const tools = await toolset.fetchTools(); -console.log(`Loaded ${tools.length} tools`); +// Example 1: Fetch all tools +console.log('\n=== Example 1: Fetch all tools ==='); +const allTools = await toolset.fetchTools(); +console.log(`Loaded ${allTools.length} tools`); -const tool = tools.getTool('hris_list_employees'); +// Example 2: Filter by account IDs using setAccounts() +console.log('\n=== Example 2: Filter by account IDs (using setAccounts) ==='); +toolset.setAccounts(['account-123', 'account-456']); +const toolsByAccounts = await toolset.fetchTools(); +console.log(`Loaded ${toolsByAccounts.length} tools for specified accounts`); + +// Example 3: Filter by account IDs using options +console.log('\n=== Example 3: Filter by account IDs (using options) ==='); +const toolsByAccountsOption = await toolset.fetchTools({ + accountIds: ['account-789'], +}); +console.log(`Loaded ${toolsByAccountsOption.length} tools for account-789`); + +// Example 4: Filter by providers +console.log('\n=== Example 4: Filter by providers ==='); +const toolsByProviders = await toolset.fetchTools({ + providers: ['hibob', 'bamboohr'], +}); +console.log(`Loaded ${toolsByProviders.length} tools for HiBob and BambooHR`); + +// Example 5: Filter by actions with exact match +console.log('\n=== Example 5: Filter by actions (exact match) ==='); +const toolsByActions = await toolset.fetchTools({ + actions: ['hris_list_employees', 'hris_create_employee'], +}); +console.log(`Loaded ${toolsByActions.length} tools matching exact action names`); + +// Example 6: Filter by actions with glob pattern +console.log('\n=== Example 6: Filter by actions (glob pattern) ==='); +const toolsByGlobPattern = await toolset.fetchTools({ + actions: ['*_list_employees'], +}); +console.log(`Loaded ${toolsByGlobPattern.length} tools matching *_list_employees pattern`); + +// Example 7: Combine multiple filters +console.log('\n=== Example 7: Combine multiple filters ==='); +const toolsCombined = await toolset.fetchTools({ + accountIds: ['account-123'], + providers: ['hibob'], + actions: ['*_list_*'], +}); +console.log( + `Loaded ${toolsCombined.length} tools for account-123, provider hibob, matching *_list_* pattern` +); + +// Execute a tool +console.log('\n=== Executing a tool ==='); +const tool = allTools.getTool('hris_list_employees'); if (!tool) { throw new Error('Tool hris_list_employees not found in the catalog'); } diff --git a/src/toolsets/stackone.ts b/src/toolsets/stackone.ts index bf44271..7380f19 100644 --- a/src/toolsets/stackone.ts +++ b/src/toolsets/stackone.ts @@ -23,6 +23,20 @@ export interface FetchToolsOptions { * Only tools available on these accounts will be returned */ accountIds?: string[]; + + /** + * Filter tools by provider names + * Only tools from these providers will be returned + * @example ['hibob', 'bamboohr'] + */ + providers?: string[]; + + /** + * Filter tools by action patterns with glob support + * Only tools matching these patterns will be returned + * @example ['*_list_employees', 'hibob_create_employees'] + */ + actions?: string[]; } /** @@ -126,19 +140,16 @@ export class StackOneToolSet extends ToolSet { } /** - * Fetch tools from MCP with optional account ID filtering - * @param options Optional filtering options for account IDs + * Fetch tools from MCP with optional filtering + * @param options Optional filtering options for account IDs, providers, and actions * @returns Collection of tools matching the filter criteria - * - * TODO: Add support for filtering by providers and actions - * - providers: Filter tools by provider names (e.g., ['hibob', 'bamboohr']) - * - actions: Filter tools by action patterns with glob support (e.g., ['*_list_employees']) */ async fetchTools(options?: FetchToolsOptions): Promise { // Use account IDs from options, or fall back to instance state const effectiveAccountIds = options?.accountIds || this.accountIds; - // If account IDs are specified, fetch tools for each account and merge + // Fetch tools (with account filtering if needed) + let tools: Tools; if (effectiveAccountIds.length > 0) { const toolsPromises = effectiveAccountIds.map(async (accountId) => { const headers = { 'x-account-id': accountId }; @@ -160,12 +171,43 @@ export class StackOneToolSet extends ToolSet { const toolArrays = await Promise.all(toolsPromises); const allTools = toolArrays.flat(); + tools = new Tools(allTools); + } else { + // No account filtering - fetch all tools + tools = await super.fetchTools(); + } + + // Apply provider and action filters + return this.filterTools(tools, options); + } + + /** + * Filter tools by providers and actions + * @param tools Tools collection to filter + * @param options Filtering options + * @returns Filtered tools collection + */ + private filterTools(tools: Tools, options?: FetchToolsOptions): Tools { + let filteredTools = tools.toArray(); - return new Tools(allTools); + // Filter by providers if specified + if (options?.providers && options.providers.length > 0) { + const providerSet = new Set(options.providers.map((p) => p.toLowerCase())); + filteredTools = filteredTools.filter((tool) => { + // Extract provider from tool name (assuming format: provider_action) + const provider = tool.name.split('_')[0]?.toLowerCase(); + return provider && providerSet.has(provider); + }); + } + + // Filter by actions if specified (with glob support) + if (options?.actions && options.actions.length > 0) { + filteredTools = filteredTools.filter((tool) => + options.actions?.some((pattern) => this._matchGlob(tool.name, pattern)) + ); } - // No account filtering - fetch all tools - return await super.fetchTools(); + return new Tools(filteredTools); } /** diff --git a/src/toolsets/tests/stackone.mcp-fetch.spec.ts b/src/toolsets/tests/stackone.mcp-fetch.spec.ts index 54bfe80..2004d25 100644 --- a/src/toolsets/tests/stackone.mcp-fetch.spec.ts +++ b/src/toolsets/tests/stackone.mcp-fetch.spec.ts @@ -312,3 +312,237 @@ describe('StackOneToolSet account filtering', () => { expect(toolNames).toContain('acc3_tool_1'); }); }); + +describe('StackOneToolSet provider and action filtering', () => { + const mixedTools = [ + { + name: 'hibob_list_employees', + description: 'HiBob List Employees', + shape: { fields: z.string().optional() }, + }, + { + name: 'hibob_create_employees', + description: 'HiBob Create Employees', + shape: { name: z.string() }, + }, + { + name: 'bamboohr_list_employees', + description: 'BambooHR List Employees', + shape: { fields: z.string().optional() }, + }, + { + name: 'bamboohr_get_employee', + description: 'BambooHR Get Employee', + shape: { id: z.string() }, + }, + { + name: 'workday_list_employees', + description: 'Workday List Employees', + shape: { fields: z.string().optional() }, + }, + ] as const satisfies MockTool[]; + + let origin: string; + let closeServer: () => void; + let restoreMsw: (() => void) | undefined; + + beforeAll(async () => { + mswServer.close(); + restoreMsw = () => mswServer.listen({ onUnhandledRequest: 'warn' }); + + const server = await createMockMcpServer({ + default: mixedTools, + }); + origin = server.origin; + closeServer = server.close; + }); + + afterAll(() => { + closeServer(); + restoreMsw?.(); + }); + + it('filters tools by providers', async () => { + const stackOneClient = { + actions: { + rpcAction: mock(async () => ({ actionsRpcResponse: { data: null } })), + }, + } as unknown as StackOne; + + const toolset = new StackOneToolSet({ + baseUrl: origin, + apiKey: 'test-key', + stackOneClient, + }); + + // Filter by providers + const tools = await toolset.fetchTools({ providers: ['hibob', 'bamboohr'] }); + + expect(tools.length).toBe(4); + const toolNames = tools.toArray().map((t) => t.name); + expect(toolNames).toContain('hibob_list_employees'); + expect(toolNames).toContain('hibob_create_employees'); + expect(toolNames).toContain('bamboohr_list_employees'); + expect(toolNames).toContain('bamboohr_get_employee'); + expect(toolNames).not.toContain('workday_list_employees'); + }); + + it('filters tools by actions with exact match', async () => { + const stackOneClient = { + actions: { + rpcAction: mock(async () => ({ actionsRpcResponse: { data: null } })), + }, + } as unknown as StackOne; + + const toolset = new StackOneToolSet({ + baseUrl: origin, + apiKey: 'test-key', + stackOneClient, + }); + + // Filter by exact action names + const tools = await toolset.fetchTools({ + actions: ['hibob_list_employees', 'hibob_create_employees'], + }); + + expect(tools.length).toBe(2); + const toolNames = tools.toArray().map((t) => t.name); + expect(toolNames).toContain('hibob_list_employees'); + expect(toolNames).toContain('hibob_create_employees'); + }); + + it('filters tools by actions with glob pattern', async () => { + const stackOneClient = { + actions: { + rpcAction: mock(async () => ({ actionsRpcResponse: { data: null } })), + }, + } as unknown as StackOne; + + const toolset = new StackOneToolSet({ + baseUrl: origin, + apiKey: 'test-key', + stackOneClient, + }); + + // Filter by glob pattern + const tools = await toolset.fetchTools({ actions: ['*_list_employees'] }); + + expect(tools.length).toBe(3); + const toolNames = tools.toArray().map((t) => t.name); + expect(toolNames).toContain('hibob_list_employees'); + expect(toolNames).toContain('bamboohr_list_employees'); + expect(toolNames).toContain('workday_list_employees'); + expect(toolNames).not.toContain('hibob_create_employees'); + expect(toolNames).not.toContain('bamboohr_get_employee'); + }); + + it('combines accountIds and actions filters', async () => { + const acc1Tools = [ + { + name: 'hibob_list_employees', + description: 'HiBob List Employees', + shape: { fields: z.string().optional() }, + }, + { + name: 'hibob_create_employees', + description: 'HiBob Create Employees', + shape: { name: z.string() }, + }, + ] as const satisfies MockTool[]; + + const acc2Tools = [ + { + name: 'bamboohr_list_employees', + description: 'BambooHR List Employees', + shape: { fields: z.string().optional() }, + }, + { + name: 'bamboohr_get_employee', + description: 'BambooHR Get Employee', + shape: { id: z.string() }, + }, + ] as const satisfies MockTool[]; + + const server = await createMockMcpServer({ + acc1: acc1Tools, + acc2: acc2Tools, + }); + + const stackOneClient = { + actions: { + rpcAction: mock(async () => ({ actionsRpcResponse: { data: null } })), + }, + } as unknown as StackOne; + + const toolset = new StackOneToolSet({ + baseUrl: server.origin, + apiKey: 'test-key', + stackOneClient, + }); + + // Combine account and action filters + const tools = await toolset.fetchTools({ + accountIds: ['acc1', 'acc2'], + actions: ['*_list_employees'], + }); + + expect(tools.length).toBe(2); + const toolNames = tools.toArray().map((t) => t.name); + expect(toolNames).toContain('hibob_list_employees'); + expect(toolNames).toContain('bamboohr_list_employees'); + expect(toolNames).not.toContain('hibob_create_employees'); + expect(toolNames).not.toContain('bamboohr_get_employee'); + + server.close(); + }); + + it('combines all filters: accountIds, providers, and actions', async () => { + const acc1Tools = [ + { + name: 'hibob_list_employees', + description: 'HiBob List Employees', + shape: { fields: z.string().optional() }, + }, + { + name: 'hibob_create_employees', + description: 'HiBob Create Employees', + shape: { name: z.string() }, + }, + { + name: 'workday_list_employees', + description: 'Workday List Employees', + shape: { fields: z.string().optional() }, + }, + ] as const satisfies MockTool[]; + + const server = await createMockMcpServer({ + acc1: acc1Tools, + }); + + const stackOneClient = { + actions: { + rpcAction: mock(async () => ({ actionsRpcResponse: { data: null } })), + }, + } as unknown as StackOne; + + const toolset = new StackOneToolSet({ + baseUrl: server.origin, + apiKey: 'test-key', + stackOneClient, + }); + + // Combine all filters + const tools = await toolset.fetchTools({ + accountIds: ['acc1'], + providers: ['hibob'], + actions: ['*_list_*'], + }); + + // Should only return hibob_list_employees (matches all filters) + expect(tools.length).toBe(1); + const toolNames = tools.toArray().map((t) => t.name); + expect(toolNames).toContain('hibob_list_employees'); + + server.close(); + }); +});