From d88a2bbee501d9035a850eed73023d1919f0d9e4 Mon Sep 17 00:00:00 2001 From: ryoppippi <1560508+ryoppippi@users.noreply.github.com> Date: Wed, 10 Dec 2025 16:18:59 +0000 Subject: [PATCH 1/8] refactor(toolsets): flatten directory structure into single module Merge the toolsets directory from a multi-file inheritance structure into a single flat file. Since StackOne is currently the only toolset provider, the abstract base class pattern adds unnecessary complexity. Changes: - Merge base.ts + stackone.ts into src/toolsets.ts - Merge base.test.ts + stackone.test.ts + stackone.mcp-fetch.test.ts into src/toolsets.test.ts - Remove src/toolsets/ directory entirely - Update import paths from '../x' to './x' The public API remains unchanged - consumers still import from '@stackone/ai' with the same exports (StackOneToolSet, ToolSetError, ToolSetConfigError, ToolSetLoadError, AuthenticationConfig, BaseToolSetConfig, StackOneToolSetConfig). If multiple providers are needed in future, the inheritance structure can be re-introduced. --- src/toolsets.test.ts | 522 ++++++++++++++++++++++++ src/{toolsets/base.ts => toolsets.ts} | 284 ++++++++++--- src/toolsets/base.test.ts | 111 ----- src/toolsets/index.ts | 11 - src/toolsets/stackone.mcp-fetch.test.ts | 326 --------------- src/toolsets/stackone.test.ts | 131 ------ src/toolsets/stackone.ts | 201 --------- 7 files changed, 753 insertions(+), 833 deletions(-) create mode 100644 src/toolsets.test.ts rename src/{toolsets/base.ts => toolsets.ts} (62%) delete mode 100644 src/toolsets/base.test.ts delete mode 100644 src/toolsets/index.ts delete mode 100644 src/toolsets/stackone.mcp-fetch.test.ts delete mode 100644 src/toolsets/stackone.test.ts delete mode 100644 src/toolsets/stackone.ts diff --git a/src/toolsets.test.ts b/src/toolsets.test.ts new file mode 100644 index 00000000..82deca7d --- /dev/null +++ b/src/toolsets.test.ts @@ -0,0 +1,522 @@ +/** + * StackOneToolSet tests - comprehensive test suite covering: + * - Initialisation and configuration + * - Authentication (basic, bearer) + * - Glob and filter matching + * - MCP fetch integration + * - Account filtering + * - Provider and action filtering + */ +import { http } from 'msw'; +import { type McpToolDefinition, createMcpApp } from '../mocks/mcp-server'; +import { server } from '../mocks/node'; +import { StackOneToolSet, ToolSetConfigError } from './toolsets'; + +/** + * Test helper: Extends StackOneToolSet to expose private methods for testing + */ +class TestableStackOneToolSet extends StackOneToolSet { + // Expose private methods for testing + public testMatchesFilter(toolName: string, filterPattern: string | string[]): boolean { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Accessing private method for testing + return (this as any).matchesFilter(toolName, filterPattern); + } + + public testMatchGlob(str: string, pattern: string): boolean { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Accessing private method for testing + return (this as any).matchGlob(str, pattern); + } +} + +describe('StackOneToolSet', () => { + beforeEach(() => { + vi.stubEnv('STACKONE_API_KEY', 'test_key'); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + describe('initialisation', () => { + it('should initialise with default values', () => { + const toolset = new StackOneToolSet(); + expect(toolset).toBeDefined(); + }); + + it('should initialise with API key from constructor', () => { + const toolset = new StackOneToolSet({ apiKey: 'custom_key' }); + + expect(toolset).toBeDefined(); + // @ts-expect-error - Accessing private property for testing + expect(toolset.authentication?.credentials?.username).toBe('custom_key'); + }); + + it('should initialise with API key from environment', () => { + const toolset = new StackOneToolSet(); + + expect(toolset).toBeDefined(); + // @ts-expect-error - Accessing private property for testing + expect(toolset.authentication?.credentials?.username).toBe('test_key'); + }); + + it('should initialise with custom values', () => { + const baseUrl = 'https://api.example.com'; + const headers = { 'X-Custom-Header': 'test' }; + + const toolset = new StackOneToolSet({ + apiKey: 'custom_key', + baseUrl, + headers, + }); + + // @ts-expect-error - Accessing private properties for testing + expect(toolset.baseUrl).toBe(baseUrl); + // @ts-expect-error - Accessing private properties for testing + expect(toolset.headers['X-Custom-Header']).toBe('test'); + }); + + it('should set API key in headers', () => { + const toolset = new StackOneToolSet({ apiKey: 'custom_key' }); + + // @ts-expect-error - Accessing private property for testing + expect(toolset.headers.Authorization).toBe('Basic Y3VzdG9tX2tleTo='); + }); + + it('should set account ID in headers if provided', () => { + const toolset = new StackOneToolSet({ + apiKey: 'custom_key', + accountId: 'test_account', + }); + + // Verify account ID is stored in the headers + // @ts-expect-error - Accessing private property for testing + expect(toolset.headers['x-account-id']).toBe('test_account'); + }); + + it('should allow setting account IDs via setAccounts', () => { + const toolset = new StackOneToolSet({ apiKey: 'custom_key' }); + + const result = toolset.setAccounts(['account-1', 'account-2']); + + // Should return this for chaining + expect(result).toBe(toolset); + // @ts-expect-error - Accessing private property for testing + expect(toolset.accountIds).toEqual(['account-1', 'account-2']); + }); + + it('should set baseUrl from config', () => { + const toolset = new StackOneToolSet({ + apiKey: 'custom_key', + baseUrl: 'https://api.example.com', + }); + + // @ts-expect-error - Accessing private property for testing + expect(toolset.baseUrl).toBe('https://api.example.com'); + }); + }); + + describe('authentication', () => { + it('should configure basic auth with API key from constructor', () => { + const toolset = new StackOneToolSet({ apiKey: 'custom_key' }); + + // @ts-expect-error - Accessing private property for testing + expect(toolset.authentication).toEqual({ + type: 'basic', + credentials: { + username: 'custom_key', + password: '', + }, + }); + }); + + it('should configure basic auth with API key from environment', () => { + const toolset = new StackOneToolSet(); + + // @ts-expect-error - Accessing private property for testing + expect(toolset.authentication).toEqual({ + type: 'basic', + credentials: { + username: 'test_key', + password: '', + }, + }); + }); + + it('should throw ToolSetConfigError if no API key is provided and strict mode is enabled', () => { + vi.stubEnv('STACKONE_API_KEY', undefined); + + expect(() => { + new StackOneToolSet({ strict: true }); + }).toThrow(ToolSetConfigError); + }); + + it('should not override custom headers with authentication', () => { + const customHeaders = { + 'Custom-Header': 'test-value', + Authorization: 'Bearer custom-token', + }; + + const toolset = new StackOneToolSet({ + apiKey: 'custom_key', + headers: customHeaders, + }); + + // @ts-expect-error - Accessing private property for testing + expect(toolset.headers).toEqual(customHeaders); + }); + + it('should combine authentication and account ID headers', () => { + const toolset = new StackOneToolSet({ + apiKey: 'custom_key', + accountId: 'test_account', + }); + + const expectedAuthValue = `Basic ${Buffer.from('custom_key:').toString('base64')}`; + // @ts-expect-error - Accessing private property for testing + expect(toolset.headers.Authorization).toBe(expectedAuthValue); + // @ts-expect-error - Accessing private property for testing + expect(toolset.headers['x-account-id']).toBe('test_account'); + }); + }); + + describe('glob and filter matching', () => { + it('should correctly match glob patterns', () => { + const toolset = new TestableStackOneToolSet({ apiKey: 'test_key' }); + + expect(toolset.testMatchGlob('hris_get_employee', 'hris_*')).toBe(true); + expect(toolset.testMatchGlob('hris_get_employee', 'crm_*')).toBe(false); + expect(toolset.testMatchGlob('hris_get_employee', '*_get_*')).toBe(true); + expect(toolset.testMatchGlob('hris_get_employee', 'hris_get_?mployee')).toBe(true); + expect(toolset.testMatchGlob('hris.get.employee', 'hris.get.employee')).toBe(true); + }); + + it('should correctly filter tools with a pattern', () => { + const toolset = new TestableStackOneToolSet({ apiKey: 'test_key' }); + + expect(toolset.testMatchesFilter('hris_get_employee', 'hris_*')).toBe(true); + expect(toolset.testMatchesFilter('crm_get_contact', 'hris_*')).toBe(false); + expect(toolset.testMatchesFilter('hris_get_employee', ['hris_*', 'crm_*'])).toBe(true); + expect(toolset.testMatchesFilter('crm_get_contact', ['hris_*', 'crm_*'])).toBe(true); + expect(toolset.testMatchesFilter('ats_get_candidate', ['hris_*', 'crm_*'])).toBe(false); + + // Test negative patterns + expect(toolset.testMatchesFilter('hris_get_employee', ['*', '!crm_*'])).toBe(true); + expect(toolset.testMatchesFilter('crm_get_contact', ['*', '!crm_*'])).toBe(false); + expect(toolset.testMatchesFilter('hris_get_employee', ['*', '!hris_*'])).toBe(false); + }); + }); + + describe('fetchTools (MCP integration)', () => { + it('creates tools from MCP catalog and wires RPC execution', async () => { + const toolset = new StackOneToolSet({ + baseUrl: 'https://api.stackone-dev.com', + apiKey: 'test-key', + accountId: 'test-account', + }); + + const tools = await toolset.fetchTools(); + // 1 dummy_action tool + 1 feedback tool + expect(tools.length).toBe(2); + + const tool = tools.toArray().find((t) => t.name === 'dummy_action'); + expect(tool).toBeDefined(); + expect(tool?.name).toBe('dummy_action'); + + const aiTools = await tool?.toAISDK({ executable: false }); + const aiToolDefinition = aiTools?.dummy_action; + expect(aiToolDefinition).toBeDefined(); + expect(aiToolDefinition?.description).toBe('Dummy tool'); + // @ts-expect-error - jsonSchema is available on Schema wrapper from ai sdk + expect(aiToolDefinition?.inputSchema.jsonSchema.properties).toBeDefined(); + expect(aiToolDefinition?.execution).toBeUndefined(); + + const executableTool = (await tool?.toAISDK())?.dummy_action; + expect(executableTool?.execute).toBeDefined(); + }); + }); + + describe('account filtering', () => { + it('supports setAccounts() for chaining', () => { + const toolset = new StackOneToolSet({ + baseUrl: 'https://api.stackone-dev.com', + apiKey: 'test-key', + }); + + // Test chaining + const result = toolset.setAccounts(['acc1', 'acc2']); + expect(result).toBe(toolset); + }); + + it('fetches tools without account filtering when no accountIds provided', async () => { + const toolset = new StackOneToolSet({ + baseUrl: 'https://api.stackone-dev.com', + apiKey: 'test-key', + }); + + const tools = await toolset.fetchTools(); + // 2 default tools + 1 feedback tool + expect(tools.length).toBe(3); + const toolNames = tools.toArray().map((t) => t.name); + expect(toolNames).toContain('default_tool_1'); + expect(toolNames).toContain('default_tool_2'); + expect(toolNames).toContain('meta_collect_tool_feedback'); + }); + + it('uses x-account-id header when fetching tools with accountIds', async () => { + const toolset = new StackOneToolSet({ + baseUrl: 'https://api.stackone-dev.com', + apiKey: 'test-key', + }); + + // Fetch tools for acc1 + const tools = await toolset.fetchTools({ accountIds: ['acc1'] }); + // 2 acc1 tools + 1 feedback tool + expect(tools.length).toBe(3); + const toolNames = tools.toArray().map((t) => t.name); + expect(toolNames).toContain('acc1_tool_1'); + expect(toolNames).toContain('acc1_tool_2'); + expect(toolNames).toContain('meta_collect_tool_feedback'); + }); + + it('uses setAccounts when no accountIds provided in fetchTools', async () => { + const toolset = new StackOneToolSet({ + baseUrl: 'https://api.stackone-dev.com', + apiKey: 'test-key', + }); + + // Set accounts using setAccounts + toolset.setAccounts(['acc1', 'acc2']); + + // Fetch without accountIds - should use setAccounts + const tools = await toolset.fetchTools(); + + // Should fetch tools for 2 accounts from setAccounts + // acc1 has 2 tools, acc2 has 2 tools, + 1 feedback tool = 5 + expect(tools.length).toBe(5); + const toolNames = tools.toArray().map((t) => t.name); + expect(toolNames).toContain('acc1_tool_1'); + expect(toolNames).toContain('acc1_tool_2'); + expect(toolNames).toContain('acc2_tool_1'); + expect(toolNames).toContain('acc2_tool_2'); + expect(toolNames).toContain('meta_collect_tool_feedback'); + }); + + it('overrides setAccounts when accountIds provided in fetchTools', async () => { + const toolset = new StackOneToolSet({ + baseUrl: 'https://api.stackone-dev.com', + apiKey: 'test-key', + }); + + // Set accounts using setAccounts + toolset.setAccounts(['acc1', 'acc2']); + + // Fetch with accountIds - should override setAccounts + const tools = await toolset.fetchTools({ accountIds: ['acc3'] }); + + // Should fetch tools only for acc3 (ignoring acc1, acc2) + 1 feedback tool + expect(tools.length).toBe(2); + const toolNames = tools.toArray().map((t) => t.name); + expect(toolNames).toContain('acc3_tool_1'); + expect(toolNames).toContain('meta_collect_tool_feedback'); + }); + }); + + describe('provider and action filtering', () => { + it('filters tools by providers', async () => { + const toolset = new StackOneToolSet({ + baseUrl: 'https://api.stackone-dev.com', + apiKey: 'test-key', + accountId: 'mixed', + }); + + // Filter by providers + const tools = await toolset.fetchTools({ providers: ['hibob', 'bamboohr'] }); + + // 4 filtered tools + 1 feedback tool + expect(tools.length).toBe(5); + 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'); + expect(toolNames).toContain('meta_collect_tool_feedback'); + }); + + it('filters tools by actions with exact match', async () => { + const toolset = new StackOneToolSet({ + baseUrl: 'https://api.stackone-dev.com', + apiKey: 'test-key', + accountId: 'mixed', + }); + + // Filter by exact action names + const tools = await toolset.fetchTools({ + actions: ['hibob_list_employees', 'hibob_create_employees'], + }); + + // 2 filtered tools + 1 feedback tool + expect(tools.length).toBe(3); + const toolNames = tools.toArray().map((t) => t.name); + expect(toolNames).toContain('hibob_list_employees'); + expect(toolNames).toContain('hibob_create_employees'); + expect(toolNames).toContain('meta_collect_tool_feedback'); + }); + + it('filters tools by actions with glob pattern', async () => { + const toolset = new StackOneToolSet({ + baseUrl: 'https://api.stackone-dev.com', + apiKey: 'test-key', + accountId: 'mixed', + }); + + // Filter by glob pattern + const tools = await toolset.fetchTools({ actions: ['*_list_employees'] }); + + // 3 filtered tools + 1 feedback tool + expect(tools.length).toBe(4); + 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'); + expect(toolNames).toContain('meta_collect_tool_feedback'); + }); + + it('combines accountIds and actions filters', async () => { + const acc1Tools: McpToolDefinition[] = [ + { + name: 'hibob_list_employees', + description: 'HiBob List Employees', + inputSchema: { + type: 'object', + properties: { fields: { type: 'string' } }, + }, + }, + { + name: 'hibob_create_employees', + description: 'HiBob Create Employees', + inputSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + }, + ]; + + const acc2Tools: McpToolDefinition[] = [ + { + name: 'bamboohr_list_employees', + description: 'BambooHR List Employees', + inputSchema: { + type: 'object', + properties: { fields: { type: 'string' } }, + }, + }, + { + name: 'bamboohr_get_employee', + description: 'BambooHR Get Employee', + inputSchema: { + type: 'object', + properties: { id: { type: 'string' } }, + required: ['id'], + }, + }, + ]; + + // Override the handler for this specific test + const testMcpApp = createMcpApp({ + accountTools: { + acc1: acc1Tools, + acc2: acc2Tools, + }, + }); + server.use( + http.all('https://api.stackone-dev.com/mcp', async ({ request }) => { + return testMcpApp.fetch(request); + }), + ); + + const toolset = new StackOneToolSet({ + baseUrl: 'https://api.stackone-dev.com', + apiKey: 'test-key', + }); + + // Combine account and action filters + const tools = await toolset.fetchTools({ + accountIds: ['acc1', 'acc2'], + actions: ['*_list_employees'], + }); + + // 2 filtered tools + 1 feedback tool + 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).not.toContain('hibob_create_employees'); + expect(toolNames).not.toContain('bamboohr_get_employee'); + expect(toolNames).toContain('meta_collect_tool_feedback'); + }); + + it('combines all filters: accountIds, providers, and actions', async () => { + const acc1Tools: McpToolDefinition[] = [ + { + name: 'hibob_list_employees', + description: 'HiBob List Employees', + inputSchema: { + type: 'object', + properties: { fields: { type: 'string' } }, + }, + }, + { + name: 'hibob_create_employees', + description: 'HiBob Create Employees', + inputSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + }, + { + name: 'workday_list_employees', + description: 'Workday List Employees', + inputSchema: { + type: 'object', + properties: { fields: { type: 'string' } }, + }, + }, + ]; + + // Override the handler for this specific test + const testMcpApp = createMcpApp({ + accountTools: { + acc1: acc1Tools, + }, + }); + server.use( + http.all('https://api.stackone-dev.com/mcp', async ({ request }) => { + return testMcpApp.fetch(request); + }), + ); + + const toolset = new StackOneToolSet({ + baseUrl: 'https://api.stackone-dev.com', + apiKey: 'test-key', + }); + + // Combine all filters + const tools = await toolset.fetchTools({ + accountIds: ['acc1'], + providers: ['hibob'], + actions: ['*_list_*'], + }); + + // Should only return hibob_list_employees (matches all filters) + 1 feedback tool + expect(tools.length).toBe(2); + const toolNames = tools.toArray().map((t) => t.name); + expect(toolNames).toContain('hibob_list_employees'); + expect(toolNames).toContain('meta_collect_tool_feedback'); + }); + }); +}); diff --git a/src/toolsets/base.ts b/src/toolsets.ts similarity index 62% rename from src/toolsets/base.ts rename to src/toolsets.ts index 21cd0972..bca7b6df 100644 --- a/src/toolsets/base.ts +++ b/src/toolsets.ts @@ -1,16 +1,18 @@ import type { Arrayable } from 'type-fest'; -import { createMCPClient } from '../mcp-client'; -import { type RpcActionResponse, RpcClient } from '../rpc-client'; -import { BaseTool, Tools } from '../tool'; +import { DEFAULT_BASE_URL } from './consts'; +import { createMCPClient } from './mcp-client'; +import { type RpcActionResponse, RpcClient } from './rpc-client'; +import { BaseTool, type StackOneTool, Tools } from './tool'; +import { createFeedbackTool } from './tools/feedback'; import type { ExecuteOptions, JsonDict, JsonSchemaProperties, RpcExecuteConfig, ToolParameters, -} from '../types'; -import { toArray } from '../utils/array'; -import { StackOneError } from '../utils/errors'; +} from './types'; +import { toArray } from './utils/array'; +import { StackOneError } from './utils/errors'; /** * Converts RpcActionResponse to JsonDict in a type-safe manner. @@ -86,23 +88,106 @@ export interface BaseToolSetConfig { } /** - * Base class for all toolsets + * Configuration for StackOne toolset */ -export abstract class ToolSet { - protected baseUrl?: string; - protected authentication?: AuthenticationConfig; - protected headers: Record; - protected rpcClient?: RpcClient; +export interface StackOneToolSetConfig extends BaseToolSetConfig { + apiKey?: string; + accountId?: string; + strict?: boolean; +} + +/** + * Options for filtering tools when fetching from MCP + */ +interface FetchToolsOptions { + /** + * Filter tools by account IDs + * 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[]; +} + +/** + * Configuration for workflow + */ +interface WorkflowConfig { + key: string; + input: string; + model: string; + tools: string[]; + accountIds: string[]; + cache?: boolean; +} + +/** + * Class for loading StackOne tools via MCP + */ +export class StackOneToolSet { + private baseUrl?: string; + private authentication?: AuthenticationConfig; + private headers: Record; + private rpcClient?: RpcClient; + + /** + * Account ID for StackOne API + */ + private accountId?: string; + private accountIds: string[] = []; /** - * Initialise a toolset with optional configuration - * @param config Optional configuration object + * Initialise StackOne toolset with API key and optional account ID + * @param config Configuration object containing API key and optional account ID */ - constructor(config?: BaseToolSetConfig) { - this.baseUrl = config?.baseUrl; - this.authentication = config?.authentication; - this.headers = config?.headers || {}; + constructor(config?: StackOneToolSetConfig) { + const apiKey = config?.apiKey || process.env.STACKONE_API_KEY; + + if (!apiKey && config?.strict) { + throw new ToolSetConfigError( + 'No API key provided. Set STACKONE_API_KEY environment variable or pass apiKey in config.', + ); + } + + if (!apiKey) { + console.warn( + 'No API key provided. Set STACKONE_API_KEY environment variable or pass apiKey in config.', + ); + } + + const authentication: AuthenticationConfig = { + type: 'basic', + credentials: { + username: apiKey || '', + password: '', + }, + }; + + const accountId = config?.accountId || process.env.STACKONE_ACCOUNT_ID; + + const configHeaders = { + ...config?.headers, + ...(accountId ? { 'x-account-id': accountId } : {}), + }; + + // Initialise base properties + this.baseUrl = config?.baseUrl ?? process.env.STACKONE_BASE_URL ?? DEFAULT_BASE_URL; + this.authentication = authentication; + this.headers = configHeaders; this.rpcClient = config?.rpcClient; + this.accountId = accountId; // Set Authentication headers if provided if (this.authentication) { @@ -141,57 +226,71 @@ export abstract class ToolSet { } /** - * Check if a tool name matches a filter pattern - * @param toolName Tool name to check - * @param filterPattern Filter pattern or array of patterns - * @returns True if the tool name matches the filter pattern + * Set account IDs for filtering tools + * @param accountIds Array of account IDs to filter tools by + * @returns This toolset instance for chaining */ - protected _matchesFilter(toolName: string, filterPattern: Arrayable): boolean { - // Convert to array to handle both single string and array patterns - const patterns = toArray(filterPattern); - - // Split into positive and negative patterns - const positivePatterns = patterns.filter((p) => !p.startsWith('!')); - const negativePatterns = patterns.filter((p) => p.startsWith('!')).map((p) => p.substring(1)); - - // If no positive patterns, treat as match all - const matchesPositive = - positivePatterns.length === 0 || positivePatterns.some((p) => this._matchGlob(toolName, p)); - - // If any negative pattern matches, exclude the tool - const matchesNegative = negativePatterns.some((p) => this._matchGlob(toolName, p)); - - return matchesPositive && !matchesNegative; + setAccounts(accountIds: string[]): this { + this.accountIds = accountIds; + return this; } /** - * Check if a string matches a glob pattern - * @param str String to check - * @param pattern Glob pattern - * @returns True if the string matches the pattern + * 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 */ - protected _matchGlob(str: string, pattern: string): boolean { - // Convert glob pattern to regex - const regexPattern = pattern.replace(/\./g, '\\.').replace(/\*/g, '.*').replace(/\?/g, '.'); + async fetchTools(options?: FetchToolsOptions): Promise { + // Use account IDs from options, or fall back to instance state + const effectiveAccountIds = options?.accountIds || this.accountIds; + + // 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 }; + const mergedHeaders = { ...this.headers, ...headers }; + + // Create a temporary toolset instance with the account-specific headers + const tempHeaders = mergedHeaders; + const originalHeaders = this.headers; + this.headers = tempHeaders; + + try { + const tools = await this.fetchToolsFromMcp(); + return tools.toArray(); + } finally { + // Restore original headers + this.headers = originalHeaders; + } + }); + + const toolArrays = await Promise.all(toolsPromises); + const allTools = toolArrays.flat(); + tools = new Tools(allTools); + } else { + // No account filtering - fetch all tools + tools = await this.fetchToolsFromMcp(); + } - // Create regex with start and end anchors - const regex = new RegExp(`^${regexPattern}$`); + // Apply provider and action filters + const filteredTools = this.filterTools(tools, options); - // Test if the string matches the pattern - return regex.test(str); + // Add feedback tool + const feedbackTool = createFeedbackTool(undefined, this.accountId, this.baseUrl); + const toolsWithFeedback = new Tools([...filteredTools.toArray(), feedbackTool]); + + return toolsWithFeedback; } /** * Fetch tool definitions from MCP */ - async fetchTools(): Promise { + private async fetchToolsFromMcp(): Promise { if (!this.baseUrl) { throw new ToolSetConfigError('baseUrl is required to fetch MCP tools'); } - // TODO(ENG-????): allow passing account/provider/action filters when fetching tools - // e.g. fetchTools({ accountIDs: ['123'], actions: ['*_list_employees'] }) - // and eventually expose helpers like stackone.setAccounts([...]) for meta usage. await using clients = await createMCPClient({ baseUrl: `${this.baseUrl}/mcp`, headers: this.headers, @@ -213,6 +312,76 @@ export abstract class ToolSet { return new Tools(tools); } + /** + * 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(); + + // 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)), + ); + } + + return new Tools(filteredTools); + } + + /** + * Check if a tool name matches a filter pattern + * @param toolName Tool name to check + * @param filterPattern Filter pattern or array of patterns + * @returns True if the tool name matches the filter pattern + */ + private matchesFilter(toolName: string, filterPattern: Arrayable): boolean { + // Convert to array to handle both single string and array patterns + const patterns = toArray(filterPattern); + + // Split into positive and negative patterns + const positivePatterns = patterns.filter((p) => !p.startsWith('!')); + const negativePatterns = patterns.filter((p) => p.startsWith('!')).map((p) => p.substring(1)); + + // If no positive patterns, treat as match all + const matchesPositive = + positivePatterns.length === 0 || positivePatterns.some((p) => this.matchGlob(toolName, p)); + + // If any negative pattern matches, exclude the tool + const matchesNegative = negativePatterns.some((p) => this.matchGlob(toolName, p)); + + return matchesPositive && !matchesNegative; + } + + /** + * Check if a string matches a glob pattern + * @param str String to check + * @param pattern Glob pattern + * @returns True if the string matches the pattern + */ + private matchGlob(str: string, pattern: string): boolean { + // Convert glob pattern to regex + const regexPattern = pattern.replace(/\./g, '\\.').replace(/\*/g, '.*').replace(/\?/g, '.'); + + // Create regex with start and end anchors + const regex = new RegExp(`^${regexPattern}$`); + + // Test if the string matches the pattern + return regex.test(str); + } + private getActionsClient(): RpcClient { if (this.rpcClient) { return this.rpcClient; @@ -387,4 +556,13 @@ export abstract class ToolSet { } return undefined; } + + /** + * Plan a workflow + * @param config Configuration object containing workflow details + * @returns Workflow object + */ + plan(_: WorkflowConfig): Promise { + throw new Error('Not implemented yet'); + } } diff --git a/src/toolsets/base.test.ts b/src/toolsets/base.test.ts deleted file mode 100644 index d66a0013..00000000 --- a/src/toolsets/base.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { ToolSet } from './base'; - -// Create a concrete implementation of the abstract ToolSet class for testing -class TestToolSet extends ToolSet { - // Expose protected methods for testing - public matchesFilter(toolName: string, filterPattern: string | string[]): boolean { - return this._matchesFilter(toolName, filterPattern); - } - - public matchGlob(str: string, pattern: string): boolean { - return this._matchGlob(str, pattern); - } -} - -describe('ToolSet', () => { - it('should initialise with default values', () => { - const toolset = new TestToolSet(); - expect(toolset).toBeDefined(); - }); - - it('should initialise with custom values', () => { - const baseUrl = 'https://api.example.com'; - const headers = { 'X-Custom-Header': 'test' }; - - const toolset = new TestToolSet({ - baseUrl, - headers, - }); - - // @ts-ignore - Accessing protected properties for testing - expect(toolset.baseUrl).toBe(baseUrl); - // @ts-ignore - Accessing protected properties for testing - expect(toolset.headers['X-Custom-Header']).toBe('test'); - }); - - it('should correctly match glob patterns', () => { - const toolset = new TestToolSet(); - - expect(toolset.matchGlob('hris_get_employee', 'hris_*')).toBe(true); - expect(toolset.matchGlob('hris_get_employee', 'crm_*')).toBe(false); - expect(toolset.matchGlob('hris_get_employee', '*_get_*')).toBe(true); - expect(toolset.matchGlob('hris_get_employee', 'hris_get_?mployee')).toBe(true); - expect(toolset.matchGlob('hris.get.employee', 'hris.get.employee')).toBe(true); - }); - - it('should correctly filter tools with a pattern', () => { - const toolset = new TestToolSet(); - - expect(toolset.matchesFilter('hris_get_employee', 'hris_*')).toBe(true); - expect(toolset.matchesFilter('crm_get_contact', 'hris_*')).toBe(false); - expect(toolset.matchesFilter('hris_get_employee', ['hris_*', 'crm_*'])).toBe(true); - expect(toolset.matchesFilter('crm_get_contact', ['hris_*', 'crm_*'])).toBe(true); - expect(toolset.matchesFilter('ats_get_candidate', ['hris_*', 'crm_*'])).toBe(false); - - // Test negative patterns - expect(toolset.matchesFilter('hris_get_employee', ['*', '!crm_*'])).toBe(true); - expect(toolset.matchesFilter('crm_get_contact', ['*', '!crm_*'])).toBe(false); - expect(toolset.matchesFilter('hris_get_employee', ['*', '!hris_*'])).toBe(false); - }); - - describe('Authentication', () => { - it('should set basic auth headers when provided', () => { - const toolset = new TestToolSet({ - authentication: { - type: 'basic', - credentials: { - username: 'testuser', - password: 'testpass', - }, - }, - }); - - // @ts-ignore - Accessing protected properties for testing - const expectedAuthValue = `Basic ${Buffer.from('testuser:testpass').toString('base64')}`; - // @ts-ignore - Accessing protected properties for testing - expect(toolset.headers.Authorization).toBe(expectedAuthValue); - }); - - it('should set bearer auth headers when provided', () => { - const toolset = new TestToolSet({ - authentication: { - type: 'bearer', - credentials: { - token: 'test-token', - }, - }, - }); - - // @ts-ignore - Accessing protected properties for testing - expect(toolset.headers.Authorization).toBe('Bearer test-token'); - }); - - it('should not override existing Authorization header', () => { - const toolset = new TestToolSet({ - headers: { - Authorization: 'Custom auth', - }, - authentication: { - type: 'basic', - credentials: { - username: 'testuser', - password: 'testpass', - }, - }, - }); - - // @ts-ignore - Accessing protected properties for testing - expect(toolset.headers.Authorization).toBe('Custom auth'); - }); - }); -}); diff --git a/src/toolsets/index.ts b/src/toolsets/index.ts deleted file mode 100644 index c82987a3..00000000 --- a/src/toolsets/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -// Export base toolset types and classes -export { - ToolSetConfigError, - ToolSetError, - ToolSetLoadError, - type AuthenticationConfig, - type BaseToolSetConfig, -} from './base'; - -// Export StackOne toolset -export { StackOneToolSet, type StackOneToolSetConfig } from './stackone'; diff --git a/src/toolsets/stackone.mcp-fetch.test.ts b/src/toolsets/stackone.mcp-fetch.test.ts deleted file mode 100644 index 8c9aba3b..00000000 --- a/src/toolsets/stackone.mcp-fetch.test.ts +++ /dev/null @@ -1,326 +0,0 @@ -/** - * MCP fetch integration tests using MSW + Hono. - * These tests verify the StackOneToolSet's ability to fetch tools via MCP protocol. - */ -import { http } from 'msw'; -import { type McpToolDefinition, createMcpApp } from '../../mocks/mcp-server'; -import { server } from '../../mocks/node'; -import { ToolSet } from './base'; -import { StackOneToolSet } from './stackone'; - -describe('ToolSet.fetchTools (MCP + RPC integration)', () => { - it('creates tools from MCP catalog and wires RPC execution', async () => { - class TestToolSet extends ToolSet {} - - const toolset = new TestToolSet({ - baseUrl: 'https://api.stackone-dev.com', - authentication: { - type: 'basic', - credentials: { username: 'test-key', password: '' }, - }, - headers: { 'x-account-id': 'test-account' }, - }); - - const tools = await toolset.fetchTools(); - expect(tools.length).toBe(1); - - const tool = tools.toArray()[0]; - expect(tool.name).toBe('dummy_action'); - - const aiTools = await tool.toAISDK({ executable: false }); - const aiToolDefinition = aiTools.dummy_action; - expect(aiToolDefinition).toBeDefined(); - expect(aiToolDefinition.description).toBe('Dummy tool'); - // @ts-expect-error - jsonSchema is available on Schema wrapper from ai sdk - expect(aiToolDefinition.inputSchema.jsonSchema.properties).toBeDefined(); - expect(aiToolDefinition.execution).toBeUndefined(); - - const executableTool = (await tool.toAISDK()).dummy_action; - expect(executableTool.execute).toBeDefined(); - }); -}); - -describe('StackOneToolSet account filtering', () => { - it('supports setAccounts() for chaining', () => { - const toolset = new StackOneToolSet({ - baseUrl: 'https://api.stackone-dev.com', - apiKey: 'test-key', - }); - - // Test chaining - const result = toolset.setAccounts(['acc1', 'acc2']); - expect(result).toBe(toolset); - }); - - it('fetches tools without account filtering when no accountIds provided', async () => { - const toolset = new StackOneToolSet({ - baseUrl: 'https://api.stackone-dev.com', - apiKey: 'test-key', - }); - - const tools = await toolset.fetchTools(); - // 2 default tools + 1 feedback tool - expect(tools.length).toBe(3); - const toolNames = tools.toArray().map((t) => t.name); - expect(toolNames).toContain('default_tool_1'); - expect(toolNames).toContain('default_tool_2'); - expect(toolNames).toContain('meta_collect_tool_feedback'); - }); - - it('uses x-account-id header when fetching tools with accountIds', async () => { - const toolset = new StackOneToolSet({ - baseUrl: 'https://api.stackone-dev.com', - apiKey: 'test-key', - }); - - // Fetch tools for acc1 - const tools = await toolset.fetchTools({ accountIds: ['acc1'] }); - // 2 acc1 tools + 1 feedback tool - expect(tools.length).toBe(3); - const toolNames = tools.toArray().map((t) => t.name); - expect(toolNames).toContain('acc1_tool_1'); - expect(toolNames).toContain('acc1_tool_2'); - expect(toolNames).toContain('meta_collect_tool_feedback'); - }); - - it('uses setAccounts when no accountIds provided in fetchTools', async () => { - const toolset = new StackOneToolSet({ - baseUrl: 'https://api.stackone-dev.com', - apiKey: 'test-key', - }); - - // Set accounts using setAccounts - toolset.setAccounts(['acc1', 'acc2']); - - // Fetch without accountIds - should use setAccounts - const tools = await toolset.fetchTools(); - - // Should fetch tools for 2 accounts from setAccounts - // acc1 has 2 tools, acc2 has 2 tools, + 1 feedback tool = 5 - expect(tools.length).toBe(5); - const toolNames = tools.toArray().map((t) => t.name); - expect(toolNames).toContain('acc1_tool_1'); - expect(toolNames).toContain('acc1_tool_2'); - expect(toolNames).toContain('acc2_tool_1'); - expect(toolNames).toContain('acc2_tool_2'); - expect(toolNames).toContain('meta_collect_tool_feedback'); - }); - - it('overrides setAccounts when accountIds provided in fetchTools', async () => { - const toolset = new StackOneToolSet({ - baseUrl: 'https://api.stackone-dev.com', - apiKey: 'test-key', - }); - - // Set accounts using setAccounts - toolset.setAccounts(['acc1', 'acc2']); - - // Fetch with accountIds - should override setAccounts - const tools = await toolset.fetchTools({ accountIds: ['acc3'] }); - - // Should fetch tools only for acc3 (ignoring acc1, acc2) + 1 feedback tool - expect(tools.length).toBe(2); - const toolNames = tools.toArray().map((t) => t.name); - expect(toolNames).toContain('acc3_tool_1'); - expect(toolNames).toContain('meta_collect_tool_feedback'); - }); -}); - -describe('StackOneToolSet provider and action filtering', () => { - it('filters tools by providers', async () => { - const toolset = new StackOneToolSet({ - baseUrl: 'https://api.stackone-dev.com', - apiKey: 'test-key', - accountId: 'mixed', - }); - - // Filter by providers - const tools = await toolset.fetchTools({ providers: ['hibob', 'bamboohr'] }); - - // 4 filtered tools + 1 feedback tool - expect(tools.length).toBe(5); - 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'); - expect(toolNames).toContain('meta_collect_tool_feedback'); - }); - - it('filters tools by actions with exact match', async () => { - const toolset = new StackOneToolSet({ - baseUrl: 'https://api.stackone-dev.com', - apiKey: 'test-key', - accountId: 'mixed', - }); - - // Filter by exact action names - const tools = await toolset.fetchTools({ - actions: ['hibob_list_employees', 'hibob_create_employees'], - }); - - // 2 filtered tools + 1 feedback tool - expect(tools.length).toBe(3); - const toolNames = tools.toArray().map((t) => t.name); - expect(toolNames).toContain('hibob_list_employees'); - expect(toolNames).toContain('hibob_create_employees'); - expect(toolNames).toContain('meta_collect_tool_feedback'); - }); - - it('filters tools by actions with glob pattern', async () => { - const toolset = new StackOneToolSet({ - baseUrl: 'https://api.stackone-dev.com', - apiKey: 'test-key', - accountId: 'mixed', - }); - - // Filter by glob pattern - const tools = await toolset.fetchTools({ actions: ['*_list_employees'] }); - - // 3 filtered tools + 1 feedback tool - expect(tools.length).toBe(4); - 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'); - expect(toolNames).toContain('meta_collect_tool_feedback'); - }); - - it('combines accountIds and actions filters', async () => { - const acc1Tools: McpToolDefinition[] = [ - { - name: 'hibob_list_employees', - description: 'HiBob List Employees', - inputSchema: { - type: 'object', - properties: { fields: { type: 'string' } }, - }, - }, - { - name: 'hibob_create_employees', - description: 'HiBob Create Employees', - inputSchema: { - type: 'object', - properties: { name: { type: 'string' } }, - required: ['name'], - }, - }, - ]; - - const acc2Tools: McpToolDefinition[] = [ - { - name: 'bamboohr_list_employees', - description: 'BambooHR List Employees', - inputSchema: { - type: 'object', - properties: { fields: { type: 'string' } }, - }, - }, - { - name: 'bamboohr_get_employee', - description: 'BambooHR Get Employee', - inputSchema: { - type: 'object', - properties: { id: { type: 'string' } }, - required: ['id'], - }, - }, - ]; - - // Override the handler for this specific test - const testMcpApp = createMcpApp({ - accountTools: { - acc1: acc1Tools, - acc2: acc2Tools, - }, - }); - server.use( - http.all('https://api.stackone-dev.com/mcp', async ({ request }) => { - return testMcpApp.fetch(request); - }), - ); - - const toolset = new StackOneToolSet({ - baseUrl: 'https://api.stackone-dev.com', - apiKey: 'test-key', - }); - - // Combine account and action filters - const tools = await toolset.fetchTools({ - accountIds: ['acc1', 'acc2'], - actions: ['*_list_employees'], - }); - - // 2 filtered tools + 1 feedback tool - 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).not.toContain('hibob_create_employees'); - expect(toolNames).not.toContain('bamboohr_get_employee'); - expect(toolNames).toContain('meta_collect_tool_feedback'); - }); - - it('combines all filters: accountIds, providers, and actions', async () => { - const acc1Tools: McpToolDefinition[] = [ - { - name: 'hibob_list_employees', - description: 'HiBob List Employees', - inputSchema: { - type: 'object', - properties: { fields: { type: 'string' } }, - }, - }, - { - name: 'hibob_create_employees', - description: 'HiBob Create Employees', - inputSchema: { - type: 'object', - properties: { name: { type: 'string' } }, - required: ['name'], - }, - }, - { - name: 'workday_list_employees', - description: 'Workday List Employees', - inputSchema: { - type: 'object', - properties: { fields: { type: 'string' } }, - }, - }, - ]; - - // Override the handler for this specific test - const testMcpApp = createMcpApp({ - accountTools: { - acc1: acc1Tools, - }, - }); - server.use( - http.all('https://api.stackone-dev.com/mcp', async ({ request }) => { - return testMcpApp.fetch(request); - }), - ); - - const toolset = new StackOneToolSet({ - baseUrl: 'https://api.stackone-dev.com', - apiKey: 'test-key', - }); - - // Combine all filters - const tools = await toolset.fetchTools({ - accountIds: ['acc1'], - providers: ['hibob'], - actions: ['*_list_*'], - }); - - // Should only return hibob_list_employees (matches all filters) + 1 feedback tool - expect(tools.length).toBe(2); - const toolNames = tools.toArray().map((t) => t.name); - expect(toolNames).toContain('hibob_list_employees'); - expect(toolNames).toContain('meta_collect_tool_feedback'); - }); -}); diff --git a/src/toolsets/stackone.test.ts b/src/toolsets/stackone.test.ts deleted file mode 100644 index 2233ecd0..00000000 --- a/src/toolsets/stackone.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { ToolSetConfigError } from './base'; -import { StackOneToolSet } from './stackone'; - -describe('StackOneToolSet', () => { - beforeEach(() => { - vi.stubEnv('STACKONE_API_KEY', 'test_key'); - }); - - afterEach(() => { - vi.unstubAllEnvs(); - }); - - describe('Authentication Configuration', () => { - it('should configure basic auth with API key from constructor', () => { - const toolset = new StackOneToolSet({ apiKey: 'custom_key' }); - - // @ts-expect-error - Accessing protected property for testing - expect(toolset.authentication).toEqual({ - type: 'basic', - credentials: { - username: 'custom_key', - password: '', - }, - }); - }); - - it('should configure basic auth with API key from environment', () => { - const toolset = new StackOneToolSet(); - - // @ts-expect-error - Accessing protected property for testing - expect(toolset.authentication).toEqual({ - type: 'basic', - credentials: { - username: 'test_key', - password: '', - }, - }); - }); - - it('should throw ToolSetConfigError if no API key is provided and strict mode is enabled', () => { - vi.stubEnv('STACKONE_API_KEY', undefined); - - expect(() => { - new StackOneToolSet({ strict: true }); - }).toThrow(ToolSetConfigError); - }); - - it('should not override custom headers with authentication', () => { - const customHeaders = { - 'Custom-Header': 'test-value', - Authorization: 'Bearer custom-token', - }; - - const toolset = new StackOneToolSet({ - apiKey: 'custom_key', - headers: customHeaders, - }); - - // @ts-expect-error - Accessing protected property for testing - expect(toolset.headers).toEqual(customHeaders); - }); - - it('should combine authentication and account ID headers', () => { - const toolset = new StackOneToolSet({ - apiKey: 'custom_key', - accountId: 'test_account', - }); - - const expectedAuthValue = `Basic ${Buffer.from('custom_key:').toString('base64')}`; - // @ts-expect-error - Accessing protected property for testing - expect(toolset.headers.Authorization).toBe(expectedAuthValue); - // @ts-expect-error - Accessing protected property for testing - expect(toolset.headers['x-account-id']).toBe('test_account'); - }); - }); - - it('should initialise with API key from constructor', () => { - const toolset = new StackOneToolSet({ apiKey: 'custom_key' }); - - expect(toolset).toBeDefined(); - // @ts-expect-error - Accessing protected property for testing - expect(toolset.authentication?.credentials?.username).toBe('custom_key'); - }); - - it('should initialise with API key from environment', () => { - const toolset = new StackOneToolSet(); - - expect(toolset).toBeDefined(); - // @ts-expect-error - Accessing protected property for testing - expect(toolset.authentication?.credentials?.username).toBe('test_key'); - }); - - it('should set API key in headers', () => { - const toolset = new StackOneToolSet({ apiKey: 'custom_key' }); - - // @ts-expect-error - Accessing protected property for testing - expect(toolset.headers.Authorization).toBe('Basic Y3VzdG9tX2tleTo='); - }); - - it('should set account ID in headers if provided', () => { - const toolset = new StackOneToolSet({ - apiKey: 'custom_key', - accountId: 'test_account', - }); - - // Verify account ID is stored in the headers - // @ts-expect-error - Accessing protected property for testing - expect(toolset.headers['x-account-id']).toBe('test_account'); - }); - - it('should allow setting account IDs via setAccounts', () => { - const toolset = new StackOneToolSet({ apiKey: 'custom_key' }); - - const result = toolset.setAccounts(['account-1', 'account-2']); - - // Should return this for chaining - expect(result).toBe(toolset); - // @ts-expect-error - Accessing private property for testing - expect(toolset.accountIds).toEqual(['account-1', 'account-2']); - }); - - it('should set baseUrl from config', () => { - const toolset = new StackOneToolSet({ - apiKey: 'custom_key', - baseUrl: 'https://api.example.com', - }); - - // @ts-expect-error - Accessing protected property for testing - expect(toolset.baseUrl).toBe('https://api.example.com'); - }); -}); diff --git a/src/toolsets/stackone.ts b/src/toolsets/stackone.ts deleted file mode 100644 index 7c8ed9ed..00000000 --- a/src/toolsets/stackone.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { DEFAULT_BASE_URL } from '../consts'; -import { type StackOneTool, Tools } from '../tool'; -import { createFeedbackTool } from '../tools/feedback'; -import { type BaseToolSetConfig, ToolSet, ToolSetConfigError } from './base'; - -/** - * Configuration for StackOne toolset - */ -export interface StackOneToolSetConfig extends BaseToolSetConfig { - apiKey?: string; - accountId?: string; - strict?: boolean; -} - -/** - * Options for filtering tools when fetching from MCP - */ -interface FetchToolsOptions { - /** - * Filter tools by account IDs - * 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[]; -} - -/** - * Configuration for workflow - */ -interface WorkflowConfig { - key: string; - input: string; - model: string; - tools: string[]; - accountIds: string[]; - cache?: boolean; -} - -/** - * Class for loading StackOne tools via MCP - */ -export class StackOneToolSet extends ToolSet { - /** - * Account ID for StackOne API - */ - private accountId?: string; - private accountIds: string[] = []; - - /** - * Initialise StackOne toolset with API key and optional account ID - * @param config Configuration object containing API key and optional account ID - */ - constructor(config?: StackOneToolSetConfig) { - const apiKey = config?.apiKey || process.env.STACKONE_API_KEY; - - if (!apiKey && config?.strict) { - throw new ToolSetConfigError( - 'No API key provided. Set STACKONE_API_KEY environment variable or pass apiKey in config.', - ); - } - - if (!apiKey) { - console.warn( - 'No API key provided. Set STACKONE_API_KEY environment variable or pass apiKey in config.', - ); - } - - const authentication = { - type: 'basic' as const, - credentials: { - username: apiKey || '', - password: '', - }, - }; - - const accountId = config?.accountId || process.env.STACKONE_ACCOUNT_ID; - - const headers = { - ...config?.headers, - ...(accountId ? { 'x-account-id': accountId } : {}), - }; - - // Initialise base class - super({ - baseUrl: config?.baseUrl ?? process.env.STACKONE_BASE_URL ?? DEFAULT_BASE_URL, - authentication, - headers, - }); - - this.accountId = accountId; - } - - /** - * Set account IDs for filtering tools - * @param accountIds Array of account IDs to filter tools by - * @returns This toolset instance for chaining - */ - setAccounts(accountIds: string[]): this { - this.accountIds = accountIds; - return this; - } - - /** - * 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 - */ - async fetchTools(options?: FetchToolsOptions): Promise { - // Use account IDs from options, or fall back to instance state - const effectiveAccountIds = options?.accountIds || this.accountIds; - - // 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 }; - const mergedHeaders = { ...this.headers, ...headers }; - - // Create a temporary toolset instance with the account-specific headers - const tempHeaders = mergedHeaders; - const originalHeaders = this.headers; - this.headers = tempHeaders; - - try { - const tools = await super.fetchTools(); - return tools.toArray(); - } finally { - // Restore original headers - this.headers = originalHeaders; - } - }); - - 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 - const filteredTools = this.filterTools(tools, options); - - // Add feedback tool - const feedbackTool = createFeedbackTool(undefined, this.accountId, this.baseUrl); - const toolsWithFeedback = new Tools([...filteredTools.toArray(), feedbackTool]); - - return toolsWithFeedback; - } - - /** - * 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(); - - // 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)), - ); - } - - return new Tools(filteredTools); - } - - /** - * Plan a workflow - * @param config Configuration object containing workflow details - * @returns Workflow object - */ - plan(_: WorkflowConfig): Promise { - throw new Error('Not implemented yet'); - } -} From fa743ea60decfb213b2dd6cb5cc1e13e08c24a85 Mon Sep 17 00:00:00 2001 From: ryoppippi <1560508+ryoppippi@users.noreply.github.com> Date: Wed, 10 Dec 2025 16:20:34 +0000 Subject: [PATCH 2/8] test(mcp-client): add dedicated unit tests for MCP client factory Add tests for the createMCPClient function covering: - Client creation with required options - Client creation with custom headers - asyncDispose cleanup functionality - Integration test for connecting and listing tools This provides direct test coverage for mcp-client.ts, complementing the integration tests in toolsets.test.ts. --- src/mcp-client.test.ts | 68 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 src/mcp-client.test.ts diff --git a/src/mcp-client.test.ts b/src/mcp-client.test.ts new file mode 100644 index 00000000..3836e5b6 --- /dev/null +++ b/src/mcp-client.test.ts @@ -0,0 +1,68 @@ +/** + * MCP client factory tests. + * Tests the createMCPClient function for creating MCP protocol clients. + */ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { createMCPClient } from './mcp-client'; + +test('createMCPClient creates client with required options', async () => { + const mcpClient = await createMCPClient({ + baseUrl: 'https://api.example.com/mcp', + }); + + expect(mcpClient.client).toBeInstanceOf(Client); + expect(mcpClient.transport).toBeInstanceOf(StreamableHTTPClientTransport); + expect(typeof mcpClient[Symbol.asyncDispose]).toBe('function'); +}); + +test('createMCPClient creates client with custom headers', async () => { + const mcpClient = await createMCPClient({ + baseUrl: 'https://api.example.com/mcp', + headers: { + Authorization: 'Bearer test-token', + 'x-custom-header': 'custom-value', + }, + }); + + expect(mcpClient.client).toBeInstanceOf(Client); + expect(mcpClient.transport).toBeInstanceOf(StreamableHTTPClientTransport); +}); + +test('createMCPClient provides asyncDispose for cleanup', async () => { + const mcpClient = await createMCPClient({ + baseUrl: 'https://api.example.com/mcp', + }); + + // Spy on close methods + const clientCloseSpy = vi.spyOn(mcpClient.client, 'close').mockResolvedValue(undefined); + const transportCloseSpy = vi.spyOn(mcpClient.transport, 'close').mockResolvedValue(undefined); + + // Call asyncDispose + await mcpClient[Symbol.asyncDispose](); + + expect(clientCloseSpy).toHaveBeenCalledOnce(); + expect(transportCloseSpy).toHaveBeenCalledOnce(); +}); + +test('createMCPClient can connect and list tools from MCP server', async () => { + await using mcpClient = await createMCPClient({ + baseUrl: 'https://api.stackone-dev.com/mcp', + headers: { + Authorization: `Basic ${Buffer.from('test-key:').toString('base64')}`, + 'x-account-id': 'test-account', + }, + }); + + await mcpClient.client.connect(mcpClient.transport); + const result = await mcpClient.client.listTools(); + + expect(result.tools).toBeDefined(); + expect(Array.isArray(result.tools)).toBe(true); + expect(result.tools.length).toBeGreaterThan(0); + + const tool = result.tools[0]; + expect(tool.name).toBe('dummy_action'); + expect(tool.description).toBe('Dummy tool'); + expect(tool.inputSchema).toBeDefined(); +}); From 30e072f05f5ec24454fc2d0b17ae31821b170fd7 Mon Sep 17 00:00:00 2001 From: ryoppippi <1560508+ryoppippi@users.noreply.github.com> Date: Wed, 10 Dec 2025 16:36:05 +0000 Subject: [PATCH 3/8] test: remove meaningless initialisation tests Remove tests that only verify object instantiation without testing actual behaviour. These tests provide no value as they only check that constructors return defined objects or have expected property types, which is already guaranteed by TypeScript compilation. Removed tests: - StackOneTool: 'should initialize with correct properties' - Tools: 'should initialize with tools array' - Tool: 'should initialize with correct properties' - StackOneToolSet: 'should initialise with default values' - Module Exports: entire file (src/index.test.ts) Also renamed RequestBuilder test from 'should initialize with correct properties' to 'should initialise with headers from constructor' to better describe its actual assertion. --- src/index.test.ts | 21 --------------------- src/modules/requestBuilder.test.ts | 3 +-- src/tool.test.ts | 28 ---------------------------- src/toolsets.test.ts | 5 ----- 4 files changed, 1 insertion(+), 56 deletions(-) delete mode 100644 src/index.test.ts diff --git a/src/index.test.ts b/src/index.test.ts deleted file mode 100644 index db7a3d43..00000000 --- a/src/index.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import * as StackOneAI from './index'; - -describe('Module Exports', () => { - it('should export main classes and utilities', () => { - // Check core classes - expect(StackOneAI.StackOneTool).toBeDefined(); - expect(StackOneAI.Tools).toBeDefined(); - expect(StackOneAI.StackOneToolSet).toBeDefined(); - expect(StackOneAI.BaseTool).toBeDefined(); - - // Check errors - expect(StackOneAI.StackOneError).toBeDefined(); - expect(StackOneAI.StackOneAPIError).toBeDefined(); - expect(StackOneAI.ToolSetError).toBeDefined(); - expect(StackOneAI.ToolSetConfigError).toBeDefined(); - expect(StackOneAI.ToolSetLoadError).toBeDefined(); - - // Check feedback tool - expect(StackOneAI.createFeedbackTool).toBeDefined(); - }); -}); diff --git a/src/modules/requestBuilder.test.ts b/src/modules/requestBuilder.test.ts index ffe7bd44..af41788b 100644 --- a/src/modules/requestBuilder.test.ts +++ b/src/modules/requestBuilder.test.ts @@ -62,8 +62,7 @@ describe('RequestBuilder', () => { server.events.removeAllListeners('request:start'); }); - it('should initialize with correct properties', () => { - expect(builder).toBeDefined(); + it('should initialise with headers from constructor', () => { expect(builder.getHeaders()).toEqual({ 'Initial-Header': 'test' }); }); diff --git a/src/tool.test.ts b/src/tool.test.ts index bec971ce..0fc3e456 100644 --- a/src/tool.test.ts +++ b/src/tool.test.ts @@ -28,16 +28,6 @@ const createMockTool = (headers?: Record): BaseTool => { }; describe('StackOneTool', () => { - it('should initialize with correct properties', () => { - const tool = createMockTool(); - - expect(tool.description).toBe('Test tool'); - expect((tool.parameters as { type: string }).type).toBe('object'); - expect( - (tool.parameters as unknown as { properties: { id: { type: string } } }).properties.id.type, - ).toBe('string'); - }); - it('should execute with parameters', async () => { const tool = createMockTool(); const result = await tool.execute({ id: '123' }); @@ -208,13 +198,6 @@ describe('StackOneTool', () => { }); describe('Tools', () => { - it('should initialize with tools array', () => { - const tool = createMockTool(); - const tools = new Tools([tool]); - - expect(tools.length).toBe(1); - }); - it('should get tool by name', () => { const tool = createMockTool(); const tools = new Tools([tool]); @@ -360,17 +343,6 @@ describe('Tools', () => { }); describe('Tool', () => { - it('should initialize with correct properties', () => { - const tool = createMockTool(); - - expect(tool.name).toBe('test_tool'); - expect(tool.description).toBe('Test tool'); - expect((tool.parameters as { type: string }).type).toBe('object'); - expect( - (tool.parameters as unknown as { properties: { id: { type: string } } }).properties.id.type, - ).toBe('string'); - }); - it('should set and get headers', () => { const tool = createMockTool(); diff --git a/src/toolsets.test.ts b/src/toolsets.test.ts index 82deca7d..8b6731e5 100644 --- a/src/toolsets.test.ts +++ b/src/toolsets.test.ts @@ -38,11 +38,6 @@ describe('StackOneToolSet', () => { }); describe('initialisation', () => { - it('should initialise with default values', () => { - const toolset = new StackOneToolSet(); - expect(toolset).toBeDefined(); - }); - it('should initialise with API key from constructor', () => { const toolset = new StackOneToolSet({ apiKey: 'custom_key' }); From c7bb4d7ca3972539896796fbcfb3991f711596e3 Mon Sep 17 00:00:00 2001 From: ryoppippi <1560508+ryoppippi@users.noreply.github.com> Date: Wed, 10 Dec 2025 16:45:36 +0000 Subject: [PATCH 4/8] test(tool): consolidate duplicated test blocks Merge the separate 'Tool' describe block into 'StackOneTool' block to eliminate test duplication. The 'Tool' block contained: - 4 unique tests (headers, authentication) - moved to StackOneTool - 2 duplicated tests (execute, toOpenAI) - removed This consolidation improves test organisation and removes redundant coverage of the same BaseTool functionality. --- src/tool.test.ts | 127 +++++++++++++++++++---------------------------- 1 file changed, 51 insertions(+), 76 deletions(-) diff --git a/src/tool.test.ts b/src/tool.test.ts index 0fc3e456..17efe891 100644 --- a/src/tool.test.ts +++ b/src/tool.test.ts @@ -195,6 +195,57 @@ describe('StackOneTool', () => { ); expect(result).toMatch(/Error executing tool:/); }); + + it('should set and get headers', () => { + const tool = createMockTool(); + + // Set headers + const headers = { 'X-Custom-Header': 'test-value' }; + tool.setHeaders(headers); + + // Headers should include custom header + const updatedHeaders = tool.getHeaders(); + expect(updatedHeaders['X-Custom-Header']).toBe('test-value'); + + // Set additional headers + tool.setHeaders({ 'X-Another-Header': 'another-value' }); + + // Headers should include all headers + const finalHeaders = tool.getHeaders(); + expect(finalHeaders['X-Custom-Header']).toBe('test-value'); + expect(finalHeaders['X-Another-Header']).toBe('another-value'); + }); + + it('should use basic authentication', async () => { + const headers = { + Authorization: `Basic ${Buffer.from('testuser:testpass').toString('base64')}`, + }; + const tool = createMockTool(headers); + + const result = await tool.execute({ id: '123' }); + expect(result).toEqual({ id: '123', name: 'Test' }); + }); + + it('should use bearer authentication', async () => { + const headers = { + Authorization: 'Bearer test-token', + }; + const tool = createMockTool(headers); + + const result = await tool.execute({ id: '123' }); + expect(result).toEqual({ id: '123', name: 'Test' }); + }); + + it('should use api-key authentication', async () => { + const apiKey = 'test-api-key'; + const headers = { + Authorization: `Bearer ${apiKey}`, + }; + const tool = createMockTool(headers); + + const result = await tool.execute({ id: '123' }); + expect(result).toEqual({ id: '123', name: 'Test' }); + }); }); describe('Tools', () => { @@ -341,79 +392,3 @@ describe('Tools', () => { expect(count).toBe(2); }); }); - -describe('Tool', () => { - it('should set and get headers', () => { - const tool = createMockTool(); - - // Set headers - const headers = { 'X-Custom-Header': 'test-value' }; - tool.setHeaders(headers); - - // Headers should include custom header - const updatedHeaders = tool.getHeaders(); - expect(updatedHeaders['X-Custom-Header']).toBe('test-value'); - - // Set additional headers - tool.setHeaders({ 'X-Another-Header': 'another-value' }); - - // Headers should include all headers - const finalHeaders = tool.getHeaders(); - expect(finalHeaders['X-Custom-Header']).toBe('test-value'); - expect(finalHeaders['X-Another-Header']).toBe('another-value'); - }); - - it('should use basic authentication', async () => { - const headers = { - Authorization: `Basic ${Buffer.from('testuser:testpass').toString('base64')}`, - }; - const tool = createMockTool(headers); - - const result = await tool.execute({ id: '123' }); - expect(result).toEqual({ id: '123', name: 'Test' }); - }); - - it('should use bearer authentication', async () => { - const headers = { - Authorization: 'Bearer test-token', - }; - const tool = createMockTool(headers); - - const result = await tool.execute({ id: '123' }); - expect(result).toEqual({ id: '123', name: 'Test' }); - }); - - it('should use api-key authentication', async () => { - const apiKey = 'test-api-key'; - const headers = { - Authorization: `Bearer ${apiKey}`, - }; - const tool = createMockTool(headers); - - const result = await tool.execute({ id: '123' }); - expect(result).toEqual({ id: '123', name: 'Test' }); - }); - - it('should execute with parameters', async () => { - const tool = createMockTool(); - const result = await tool.execute({ id: '123' }); - expect(result).toEqual({ id: '123', name: 'Test' }); - }); - - it('should convert to OpenAI tool format', () => { - const tool = createMockTool(); - const openAIFormat = tool.toOpenAI(); - - expect(openAIFormat.type).toBe('function'); - expect(openAIFormat.function.name).toBe('test_tool'); - expect(openAIFormat.function.description).toBe('Test tool'); - expect(openAIFormat.function.parameters?.type).toBe('object'); - expect( - ( - openAIFormat.function.parameters as { - properties: { id: { type: string } }; - } - ).properties.id.type, - ).toBe('string'); - }); -}); From 5c8afbe00bc559317c1ca64e56ac2752eda434f5 Mon Sep 17 00:00:00 2001 From: ryoppippi <1560508+ryoppippi@users.noreply.github.com> Date: Wed, 10 Dec 2025 16:52:47 +0000 Subject: [PATCH 5/8] refactor(src): flatten module structure by moving files to root src directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move feedback and requestBuilder modules from subdirectories (src/tools/ and src/modules/) directly into src/ to simplify the project structure. This reduces directory nesting and improves discoverability of core modules. Files moved: - src/tools/feedback.ts → src/feedback.ts - src/tools/feedback.test.ts → src/feedback.test.ts - src/modules/requestBuilder.ts → src/requestBuilder.ts - src/modules/requestBuilder.test.ts → src/requestBuilder.test.ts Updated import paths in affected files: - src/index.ts: export path for createFeedbackTool - src/toolsets.ts: import path for createFeedbackTool - src/tool.ts: import path for RequestBuilder - Both test files updated for new relative import paths The empty src/tools/ and src/modules/ directories have been removed. All tests pass successfully. --- src/{tools => }/feedback.test.ts | 4 ++-- src/{tools => }/feedback.ts | 8 ++++---- src/index.ts | 2 +- src/{modules => }/requestBuilder.test.ts | 6 +++--- src/{modules => }/requestBuilder.ts | 4 ++-- src/tool.ts | 2 +- src/toolsets.ts | 2 +- 7 files changed, 14 insertions(+), 14 deletions(-) rename src/{tools => }/feedback.test.ts (98%) rename src/{tools => }/feedback.ts (97%) rename src/{modules => }/requestBuilder.test.ts (99%) rename src/{modules => }/requestBuilder.ts (99%) diff --git a/src/tools/feedback.test.ts b/src/feedback.test.ts similarity index 98% rename from src/tools/feedback.test.ts rename to src/feedback.test.ts index a8e1e0e4..c8bf18c6 100644 --- a/src/tools/feedback.test.ts +++ b/src/feedback.test.ts @@ -1,6 +1,6 @@ import { http, HttpResponse } from 'msw'; -import { server } from '../../mocks/node'; -import { StackOneError } from '../utils/errors'; +import { server } from '../mocks/node'; +import { StackOneError } from './utils/errors'; import { createFeedbackTool } from './feedback'; interface FeedbackResultItem { diff --git a/src/tools/feedback.ts b/src/feedback.ts similarity index 97% rename from src/tools/feedback.ts rename to src/feedback.ts index 0c319530..c3584b31 100644 --- a/src/tools/feedback.ts +++ b/src/feedback.ts @@ -1,8 +1,8 @@ import { z } from 'zod'; -import { DEFAULT_BASE_URL } from '../consts'; -import { BaseTool } from '../tool'; -import type { ExecuteConfig, ExecuteOptions, JsonDict, ToolParameters } from '../types'; -import { StackOneError } from '../utils/errors'; +import { DEFAULT_BASE_URL } from './consts'; +import { BaseTool } from './tool'; +import type { ExecuteConfig, ExecuteOptions, JsonDict, ToolParameters } from './types'; +import { StackOneError } from './utils/errors'; interface FeedbackToolOptions { baseUrl?: string; diff --git a/src/index.ts b/src/index.ts index 4411bcf3..ea63c091 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,7 @@ export { DEFAULT_BASE_URL, DEFAULT_HYBRID_ALPHA } from './consts'; export { BaseTool, StackOneTool, Tools } from './tool'; -export { createFeedbackTool } from './tools/feedback'; +export { createFeedbackTool } from './feedback'; export { StackOneAPIError, StackOneError } from './utils/errors'; export { diff --git a/src/modules/requestBuilder.test.ts b/src/requestBuilder.test.ts similarity index 99% rename from src/modules/requestBuilder.test.ts rename to src/requestBuilder.test.ts index af41788b..084fd2b0 100644 --- a/src/modules/requestBuilder.test.ts +++ b/src/requestBuilder.test.ts @@ -1,7 +1,7 @@ import { http, HttpResponse } from 'msw'; -import { server } from '../../mocks/node'; -import { type HttpExecuteConfig, ParameterLocation } from '../types'; -import { StackOneAPIError } from '../utils/errors'; +import { server } from '../mocks/node'; +import { type HttpExecuteConfig, ParameterLocation } from './types'; +import { StackOneAPIError } from './utils/errors'; import { RequestBuilder } from './requestBuilder'; describe('RequestBuilder', () => { diff --git a/src/modules/requestBuilder.ts b/src/requestBuilder.ts similarity index 99% rename from src/modules/requestBuilder.ts rename to src/requestBuilder.ts index 14177857..973c64ed 100644 --- a/src/modules/requestBuilder.ts +++ b/src/requestBuilder.ts @@ -4,8 +4,8 @@ import { type HttpExecuteConfig, type JsonDict, ParameterLocation, -} from '../types'; -import { StackOneAPIError } from '../utils/errors'; +} from './types'; +import { StackOneAPIError } from './utils/errors'; interface SerializationOptions { maxDepth?: number; diff --git a/src/tool.ts b/src/tool.ts index 1973d703..5ecdd970 100644 --- a/src/tool.ts +++ b/src/tool.ts @@ -1,7 +1,7 @@ import * as orama from '@orama/orama'; import type { ChatCompletionFunctionTool } from 'openai/resources/chat/completions'; import { DEFAULT_HYBRID_ALPHA } from './consts'; -import { RequestBuilder } from './modules/requestBuilder'; +import { RequestBuilder } from './requestBuilder'; import type { AISDKToolDefinition, AISDKToolResult, diff --git a/src/toolsets.ts b/src/toolsets.ts index bca7b6df..d755413a 100644 --- a/src/toolsets.ts +++ b/src/toolsets.ts @@ -3,7 +3,7 @@ import { DEFAULT_BASE_URL } from './consts'; import { createMCPClient } from './mcp-client'; import { type RpcActionResponse, RpcClient } from './rpc-client'; import { BaseTool, type StackOneTool, Tools } from './tool'; -import { createFeedbackTool } from './tools/feedback'; +import { createFeedbackTool } from './feedback'; import type { ExecuteOptions, JsonDict, From 7854a562c7f97f5c938f6e934b8bfccbecb28896 Mon Sep 17 00:00:00 2001 From: ryoppippi <1560508+ryoppippi@users.noreply.github.com> Date: Wed, 10 Dec 2025 16:54:07 +0000 Subject: [PATCH 6/8] test(tool): consolidate test files into single tool.test.ts Merge tool.meta-tools.test.ts and tool.json-schema.test.ts into tool.test.ts to improve test file organisation. This creates a single, comprehensive test file for all tool-related functionality including: - StackOneTool and Tools class tests - Meta search tools tests (BM25 and hybrid search strategies) - Schema validation tests (array items, nested objects, AI SDK integration) Kept tool.test-d.ts separate as it contains type-level tests using Vitest's `expectTypeOf()` for type assertions. All 272 tests passing successfully with no type errors. --- src/tool.json-schema.test.ts | 160 -------- src/tool.meta-tools.test.ts | 532 --------------------------- src/tool.test.ts | 691 ++++++++++++++++++++++++++++++++++- 3 files changed, 690 insertions(+), 693 deletions(-) delete mode 100644 src/tool.json-schema.test.ts delete mode 100644 src/tool.meta-tools.test.ts diff --git a/src/tool.json-schema.test.ts b/src/tool.json-schema.test.ts deleted file mode 100644 index 06b8cc44..00000000 --- a/src/tool.json-schema.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { jsonSchema } from 'ai'; -import type { JSONSchema7 } from 'json-schema'; -import { StackOneTool } from './tool'; - -describe('Schema Validation', () => { - describe('Array Items in Schema', () => { - it('should preserve array items when provided', () => { - const tool = new StackOneTool( - 'test_tool', - 'Test tool', - { - type: 'object', - properties: { - arrayWithItems: { - type: 'array', - description: 'Array with items', - items: { type: 'number' }, - }, - }, - }, - { - kind: 'http', - method: 'GET', - url: 'https://example.com/test', - bodyType: 'json', - params: [], - }, - { authorization: 'Bearer test_api_key' }, - ); - - const parameters = tool.toOpenAI().function.parameters; - expect(parameters).toBeDefined(); - const properties = parameters?.properties as Record; - - expect(properties.arrayWithItems.items).toBeDefined(); - expect((properties.arrayWithItems.items as JSONSchema7).type).toBe('number'); - }); - - it('should handle nested object structure', () => { - const tool = new StackOneTool( - 'test_tool', - 'Test tool', - { - type: 'object', - properties: { - nestedObject: { - type: 'object', - properties: { - nestedArray: { - type: 'array', - items: { type: 'string' }, - }, - }, - }, - }, - }, - { - kind: 'http', - method: 'GET', - url: 'https://example.com/test', - bodyType: 'json', - params: [], - }, - { authorization: 'Bearer test_api_key' }, - ); - - const parameters = tool.toOpenAI().function.parameters; - expect(parameters).toBeDefined(); - const properties = parameters?.properties as Record; - const nestedObject = properties.nestedObject; - - expect(nestedObject.type).toBe('object'); - expect(nestedObject.properties).toBeDefined(); - }); - }); - - describe('AI SDK Integration', () => { - it('should convert to AI SDK tool format with correct schema structure', async () => { - const tool = new StackOneTool( - 'test_tool', - 'Test tool with arrays', - { - type: 'object', - properties: { - arrayWithItems: { type: 'array', items: { type: 'string' } }, - }, - }, - { - kind: 'http', - method: 'GET', - url: 'https://example.com/test', - bodyType: 'json', - params: [], - }, - { authorization: 'Bearer test_api_key' }, - ); - - const aiSdkTool = await tool.toAISDK(); - const toolObj = aiSdkTool[tool.name]; - - expect(toolObj).toBeDefined(); - expect(typeof toolObj.execute).toBe('function'); - // TODO: Remove ts-ignore once AISDKToolDefinition properly types inputSchema.jsonSchema - // @ts-ignore - jsonSchema is available on Schema wrapper from ai sdk - expect(toolObj.inputSchema.jsonSchema.type).toBe('object'); - - // @ts-ignore - jsonSchema is available on Schema wrapper from ai sdk - const arrayWithItems = toolObj.inputSchema.jsonSchema.properties?.arrayWithItems; - expect(arrayWithItems?.type).toBe('array'); - expect((arrayWithItems?.items as JSONSchema7)?.type).toBe('string'); - }); - - it('should handle nested filter object for AI SDK', async () => { - const tool = new StackOneTool( - 'test_nested_arrays', - 'Test nested arrays', - { - type: 'object', - properties: { - filter: { - type: 'object', - properties: { - type_ids: { - type: 'array', - items: { type: 'string' }, - description: 'List of type IDs', - }, - status: { type: 'string' }, - }, - }, - }, - }, - { - kind: 'http', - method: 'GET', - url: 'https://example.com/test', - bodyType: 'json', - params: [], - }, - { authorization: 'Bearer test_api_key' }, - ); - - const parameters = tool.toOpenAI().function.parameters; - expect(parameters).toBeDefined(); - const aiSchema = jsonSchema(parameters as JSONSchema7); - expect(aiSchema).toBeDefined(); - - const aiSdkTool = await tool.toAISDK(); - // TODO: Remove ts-ignore once AISDKToolDefinition properly types inputSchema.jsonSchema - // @ts-ignore - jsonSchema is available on Schema wrapper from ai sdk - const filterProp = aiSdkTool[tool.name].inputSchema.jsonSchema.properties?.filter as - | (JSONSchema7 & { properties: Record }) - | undefined; - - expect(filterProp?.type).toBe('object'); - expect(filterProp?.properties.type_ids.type).toBe('array'); - expect(filterProp?.properties.type_ids.items).toBeDefined(); - }); - }); -}); diff --git a/src/tool.meta-tools.test.ts b/src/tool.meta-tools.test.ts deleted file mode 100644 index a3efb751..00000000 --- a/src/tool.meta-tools.test.ts +++ /dev/null @@ -1,532 +0,0 @@ -import { assert } from 'vitest'; -import { BaseTool, type MetaToolSearchResult, Tools } from './tool'; -import { ParameterLocation } from './types'; - -// Create mock tools for testing -const createMockTools = (): BaseTool[] => { - const tools: BaseTool[] = []; - - // HRIS tools - tools.push( - new BaseTool( - 'hris_create_employee', - 'Create a new employee record in the HRIS system', - { - type: 'object', - properties: { - name: { type: 'string', description: 'Employee name' }, - email: { type: 'string', description: 'Employee email' }, - }, - required: ['name', 'email'], - }, - { - kind: 'http', - method: 'POST', - url: 'https://api.example.com/hris/employees', - bodyType: 'json', - params: [], - }, - ), - ); - - tools.push( - new BaseTool( - 'hris_list_employees', - 'List all employees in the HRIS system', - { - type: 'object', - properties: { - limit: { type: 'number', description: 'Number of employees to return' }, - }, - }, - { - kind: 'http', - method: 'GET', - url: 'https://api.example.com/hris/employees', - bodyType: 'json', - params: [ - { - name: 'limit', - location: ParameterLocation.QUERY, - type: 'number', - }, - ], - }, - ), - ); - - tools.push( - new BaseTool( - 'hris_create_time_off', - 'Create a time off request for an employee', - { - type: 'object', - properties: { - employeeId: { type: 'string', description: 'Employee ID' }, - startDate: { type: 'string', description: 'Start date of time off' }, - endDate: { type: 'string', description: 'End date of time off' }, - }, - required: ['employeeId', 'startDate', 'endDate'], - }, - { - kind: 'http', - method: 'POST', - url: 'https://api.example.com/hris/time-off', - bodyType: 'json', - params: [], - }, - ), - ); - - // ATS tools - tools.push( - new BaseTool( - 'ats_create_candidate', - 'Create a new candidate in the ATS', - { - type: 'object', - properties: { - name: { type: 'string', description: 'Candidate name' }, - email: { type: 'string', description: 'Candidate email' }, - }, - required: ['name', 'email'], - }, - { - kind: 'http', - method: 'POST', - url: 'https://api.example.com/ats/candidates', - bodyType: 'json', - params: [], - }, - ), - ); - - tools.push( - new BaseTool( - 'ats_list_candidates', - 'List all candidates in the ATS', - { - type: 'object', - properties: { - status: { type: 'string', description: 'Filter by candidate status' }, - }, - }, - { - kind: 'http', - method: 'GET', - url: 'https://api.example.com/ats/candidates', - bodyType: 'json', - params: [ - { - name: 'status', - location: ParameterLocation.QUERY, - type: 'string', - }, - ], - }, - ), - ); - - // CRM tools - tools.push( - new BaseTool( - 'crm_create_contact', - 'Create a new contact in the CRM', - { - type: 'object', - properties: { - name: { type: 'string', description: 'Contact name' }, - company: { type: 'string', description: 'Company name' }, - }, - required: ['name'], - }, - { - kind: 'http', - method: 'POST', - url: 'https://api.example.com/crm/contacts', - bodyType: 'json', - params: [], - }, - ), - ); - - return tools; -}; - -describe('Meta Search Tools', () => { - let tools: Tools; - let metaTools: Tools; - - beforeEach(async () => { - const mockTools = createMockTools(); - tools = new Tools(mockTools); - metaTools = await tools.metaTools(); // default BM25 strategy - }); - - describe('metaTools()', () => { - it('should return two meta tools', () => { - expect(metaTools.length).toBe(2); - }); - - it('should include meta_search_tools', () => { - const filterTool = metaTools.getTool('meta_search_tools'); - expect(filterTool).toBeDefined(); - expect(filterTool?.name).toBe('meta_search_tools'); - }); - - it('should include meta_execute_tool', () => { - const executeTool = metaTools.getTool('meta_execute_tool'); - expect(executeTool).toBeDefined(); - expect(executeTool?.name).toBe('meta_execute_tool'); - }); - }); - - describe('meta_search_tools', () => { - it('should find relevant HRIS tools', async () => { - const filterTool = metaTools.getTool('meta_search_tools'); - assert(filterTool, 'filterTool should be defined'); - - const result = await filterTool.execute({ - query: 'manage employees in HRIS', - limit: 5, - }); - - expect(result.tools).toBeDefined(); - expect(Array.isArray(result.tools)).toBe(true); - - const toolResults = result.tools as MetaToolSearchResult[]; - const toolNames = toolResults.map((t) => t.name); - - expect(toolNames).toContain('hris_create_employee'); - expect(toolNames).toContain('hris_list_employees'); - }); - - it('should find time off related tools', async () => { - const filterTool = metaTools.getTool('meta_search_tools'); - assert(filterTool, 'filterTool should be defined'); - - const result = await filterTool.execute({ - query: 'time off request vacation leave', - limit: 3, - }); - - const toolResults = result.tools as MetaToolSearchResult[]; - const toolNames = toolResults.map((t) => t.name); - - expect(toolNames).toContain('hris_create_time_off'); - }); - - it('should respect limit parameter', async () => { - const filterTool = metaTools.getTool('meta_search_tools'); - assert(filterTool, 'filterTool should be defined'); - - const result = await filterTool.execute({ - query: 'create', - limit: 2, - }); - - const toolResults = result.tools as MetaToolSearchResult[]; - expect(toolResults.length).toBeLessThanOrEqual(2); - }); - - it('should filter by minimum score', async () => { - const filterTool = metaTools.getTool('meta_search_tools'); - assert(filterTool, 'filterTool should be defined'); - - const result = await filterTool.execute({ - query: 'xyz123 nonexistent', - minScore: 0.8, - }); - - const toolResults = result.tools as MetaToolSearchResult[]; - expect(toolResults.length).toBe(0); - }); - - it('should include tool configurations in results', async () => { - const filterTool = metaTools.getTool('meta_search_tools'); - assert(filterTool, 'filterTool should be defined'); - - const result = await filterTool.execute({ - query: 'create employee', - limit: 1, - }); - - const toolResults = result.tools as MetaToolSearchResult[]; - expect(toolResults.length).toBeGreaterThan(0); - - const firstTool = toolResults[0]; - expect(firstTool).toHaveProperty('name'); - expect(firstTool).toHaveProperty('description'); - expect(firstTool).toHaveProperty('parameters'); - expect(firstTool).toHaveProperty('score'); - expect(typeof firstTool.score).toBe('number'); - }); - - it('should handle empty query', async () => { - const filterTool = metaTools.getTool('meta_search_tools'); - assert(filterTool, 'filterTool should be defined'); - - const result = await filterTool.execute({ - query: '', - limit: 5, - }); - - expect(result.tools).toBeDefined(); - expect(Array.isArray(result.tools)).toBe(true); - }); - - it('should handle string parameters', async () => { - const filterTool = metaTools.getTool('meta_search_tools'); - assert(filterTool, 'filterTool should be defined'); - - const result = await filterTool.execute( - JSON.stringify({ - query: 'candidates', - limit: 3, - }), - ); - - const toolResults = result.tools as MetaToolSearchResult[]; - const toolNames = toolResults.map((t) => t.name); - - const hasCandidateTool = toolNames.some( - (name) => name === 'ats_create_candidate' || name === 'ats_list_candidates', - ); - expect(hasCandidateTool).toBe(true); - }); - }); - - describe('meta_execute_tool', () => { - it('should execute a tool by name', async () => { - const executeTool = metaTools.getTool('meta_execute_tool'); - assert(executeTool, 'executeTool should be defined'); - - const result = await executeTool.execute({ - toolName: 'hris_list_employees', - params: { limit: 10 }, - }); - - expect(result).toEqual({ limit: 10 }); - }); - - it('should handle tools with required parameters', async () => { - const executeTool = metaTools.getTool('meta_execute_tool'); - assert(executeTool, 'executeTool should be defined'); - - const result = await executeTool.execute({ - toolName: 'hris_create_employee', - params: { - name: 'John Doe', - email: 'john@example.com', - }, - }); - - expect(result).toEqual({ - name: 'John Doe', - email: 'john@example.com', - }); - }); - - it('should throw error for non-existent tool', async () => { - const executeTool = metaTools.getTool('meta_execute_tool'); - assert(executeTool, 'executeTool should be defined'); - - await expect( - executeTool.execute({ - toolName: 'nonexistent_tool', - params: {}, - }), - ).rejects.toThrow('Tool nonexistent_tool not found'); - }); - - it('should handle string parameters', async () => { - const executeTool = metaTools.getTool('meta_execute_tool'); - assert(executeTool, 'executeTool should be defined'); - - const result = await executeTool.execute( - JSON.stringify({ - toolName: 'crm_create_contact', - params: { - name: 'Jane Smith', - company: 'Acme Corp', - }, - }), - ); - - expect(result).toEqual({ - name: 'Jane Smith', - company: 'Acme Corp', - }); - }); - - it('should pass through execution options', async () => { - const executeTool = metaTools.getTool('meta_execute_tool'); - assert(executeTool, 'executeTool should be defined'); - - const result = await executeTool.execute({ - toolName: 'ats_list_candidates', - params: { status: 'active' }, - }); - - expect(result).toEqual({ status: 'active' }); - }); - }); - - describe('Integration: meta tools workflow', () => { - it('should discover and execute tools in sequence', async () => { - const filterTool = metaTools.getTool('meta_search_tools'); - const executeTool = metaTools.getTool('meta_execute_tool'); - assert(filterTool, 'filterTool should be defined'); - assert(executeTool, 'executeTool should be defined'); - - // Step 1: Discover relevant tools - const searchResult = await filterTool.execute({ - query: 'create new employee in HR system', - limit: 3, - }); - - const toolResults = searchResult.tools as MetaToolSearchResult[]; - expect(toolResults.length).toBeGreaterThan(0); - - // Find the create employee tool - const createEmployeeTool = toolResults.find((t) => t.name === 'hris_create_employee'); - assert(createEmployeeTool, 'createEmployeeTool should be defined'); - - // Step 2: Execute the discovered tool - const executeResult = await executeTool.execute({ - toolName: createEmployeeTool.name, - params: { - name: 'Alice Johnson', - email: 'alice@example.com', - }, - }); - - expect(executeResult).toEqual({ - name: 'Alice Johnson', - email: 'alice@example.com', - }); - }); - }); - - describe('OpenAI format', () => { - it('should convert meta tools to OpenAI format', () => { - const openAITools = metaTools.toOpenAI(); - - expect(openAITools).toHaveLength(2); - - const filterTool = openAITools.find((t) => t.function.name === 'meta_search_tools'); - expect(filterTool).toBeDefined(); - expect(filterTool?.function.parameters?.properties).toHaveProperty('query'); - expect(filterTool?.function.parameters?.properties).toHaveProperty('limit'); - expect(filterTool?.function.parameters?.properties).toHaveProperty('minScore'); - - const executeTool = openAITools.find((t) => t.function.name === 'meta_execute_tool'); - expect(executeTool).toBeDefined(); - expect(executeTool?.function.parameters?.properties).toHaveProperty('toolName'); - expect(executeTool?.function.parameters?.properties).toHaveProperty('params'); - }); - }); - - describe('AI SDK format', () => { - it('should convert meta tools to AI SDK format', async () => { - const aiSdkTools = await metaTools.toAISDK(); - - expect(aiSdkTools).toHaveProperty('meta_search_tools'); - expect(aiSdkTools).toHaveProperty('meta_execute_tool'); - - expect(typeof aiSdkTools.meta_search_tools.execute).toBe('function'); - expect(typeof aiSdkTools.meta_execute_tool.execute).toBe('function'); - }); - - it('should execute through AI SDK format', async () => { - const aiSdkTools = await metaTools.toAISDK(); - - expect(aiSdkTools.meta_search_tools.execute).toBeDefined(); - - const result = await aiSdkTools.meta_search_tools.execute?.( - { query: 'ATS candidates', limit: 2 }, - { toolCallId: 'test-call-1', messages: [] }, - ); - expect(result).toBeDefined(); - - const toolResults = (result as { tools: MetaToolSearchResult[] }).tools; - expect(Array.isArray(toolResults)).toBe(true); - - const toolNames = toolResults.map((t) => t.name); - expect(toolNames).toContain('ats_create_candidate'); - }); - }); -}); - -describe('Meta Search Tools - Hybrid Strategy', () => { - describe('Hybrid BM25 + TF-IDF search', () => { - it('should search using hybrid strategy with default alpha', async () => { - const tools = new Tools(createMockTools()); - const metaTools = await tools.metaTools(); - const searchTool = metaTools.getTool('meta_search_tools'); - assert(searchTool, 'searchTool should be defined'); - - const result = await searchTool.execute({ - query: 'manage employees', - limit: 5, - }); - - expect(result.tools).toBeDefined(); - expect(Array.isArray(result.tools)).toBe(true); - const toolResults = result.tools as MetaToolSearchResult[]; - expect(toolResults.length).toBeGreaterThan(0); - }); - - it('should search using hybrid strategy with custom alpha', async () => { - const tools = new Tools(createMockTools()); - const metaTools = await tools.metaTools(0.7); - const searchTool = metaTools.getTool('meta_search_tools'); - assert(searchTool, 'searchTool should be defined'); - - const result = await searchTool.execute({ - query: 'create candidate', - limit: 3, - }); - - const toolResults = result.tools as MetaToolSearchResult[]; - const toolNames = toolResults.map((t) => t.name); - expect(toolNames).toContain('ats_create_candidate'); - }); - - it('should combine BM25 and TF-IDF scores', async () => { - const tools = new Tools(createMockTools()); - const metaTools = await tools.metaTools(0.5); - const searchTool = metaTools.getTool('meta_search_tools'); - assert(searchTool, 'searchTool should be defined'); - - const result = await searchTool.execute({ - query: 'employee', - limit: 10, - }); - - const toolResults = result.tools as MetaToolSearchResult[]; - expect(toolResults.length).toBeGreaterThan(0); - - for (const tool of toolResults) { - expect(tool.score).toBeGreaterThanOrEqual(0); - expect(tool.score).toBeLessThanOrEqual(1); - } - }); - - it('should find relevant tools', async () => { - const tools = new Tools(createMockTools()); - const metaTools = await tools.metaTools(); - const searchTool = metaTools.getTool('meta_search_tools'); - assert(searchTool, 'searchTool should be defined'); - - const result = await searchTool.execute({ - query: 'time off vacation', - limit: 3, - }); - - const toolResults = result.tools as MetaToolSearchResult[]; - const toolNames = toolResults.map((t) => t.name); - expect(toolNames).toContain('hris_create_time_off'); - }); - }); -}); diff --git a/src/tool.test.ts b/src/tool.test.ts index 17efe891..8ea91c21 100644 --- a/src/tool.test.ts +++ b/src/tool.test.ts @@ -1,4 +1,7 @@ -import { BaseTool, StackOneTool, Tools } from './tool'; +import { assert } from 'vitest'; +import { jsonSchema } from 'ai'; +import type { JSONSchema7 } from 'json-schema'; +import { BaseTool, type MetaToolSearchResult, StackOneTool, Tools } from './tool'; import { type ExecuteConfig, ParameterLocation, type ToolParameters } from './types'; import { StackOneAPIError } from './utils/errors'; @@ -392,3 +395,689 @@ describe('Tools', () => { expect(count).toBe(2); }); }); + +// Create mock tools for meta tools testing +const createMockTools = (): BaseTool[] => { + const tools: BaseTool[] = []; + + // HRIS tools + tools.push( + new BaseTool( + 'hris_create_employee', + 'Create a new employee record in the HRIS system', + { + type: 'object', + properties: { + name: { type: 'string', description: 'Employee name' }, + email: { type: 'string', description: 'Employee email' }, + }, + required: ['name', 'email'], + }, + { + kind: 'http', + method: 'POST', + url: 'https://api.example.com/hris/employees', + bodyType: 'json', + params: [], + }, + ), + ); + + tools.push( + new BaseTool( + 'hris_list_employees', + 'List all employees in the HRIS system', + { + type: 'object', + properties: { + limit: { type: 'number', description: 'Number of employees to return' }, + }, + }, + { + kind: 'http', + method: 'GET', + url: 'https://api.example.com/hris/employees', + bodyType: 'json', + params: [ + { + name: 'limit', + location: ParameterLocation.QUERY, + type: 'number', + }, + ], + }, + ), + ); + + tools.push( + new BaseTool( + 'hris_create_time_off', + 'Create a time off request for an employee', + { + type: 'object', + properties: { + employeeId: { type: 'string', description: 'Employee ID' }, + startDate: { type: 'string', description: 'Start date of time off' }, + endDate: { type: 'string', description: 'End date of time off' }, + }, + required: ['employeeId', 'startDate', 'endDate'], + }, + { + kind: 'http', + method: 'POST', + url: 'https://api.example.com/hris/time-off', + bodyType: 'json', + params: [], + }, + ), + ); + + // ATS tools + tools.push( + new BaseTool( + 'ats_create_candidate', + 'Create a new candidate in the ATS', + { + type: 'object', + properties: { + name: { type: 'string', description: 'Candidate name' }, + email: { type: 'string', description: 'Candidate email' }, + }, + required: ['name', 'email'], + }, + { + kind: 'http', + method: 'POST', + url: 'https://api.example.com/ats/candidates', + bodyType: 'json', + params: [], + }, + ), + ); + + tools.push( + new BaseTool( + 'ats_list_candidates', + 'List all candidates in the ATS', + { + type: 'object', + properties: { + status: { type: 'string', description: 'Filter by candidate status' }, + }, + }, + { + kind: 'http', + method: 'GET', + url: 'https://api.example.com/ats/candidates', + bodyType: 'json', + params: [ + { + name: 'status', + location: ParameterLocation.QUERY, + type: 'string', + }, + ], + }, + ), + ); + + // CRM tools + tools.push( + new BaseTool( + 'crm_create_contact', + 'Create a new contact in the CRM', + { + type: 'object', + properties: { + name: { type: 'string', description: 'Contact name' }, + company: { type: 'string', description: 'Company name' }, + }, + required: ['name'], + }, + { + kind: 'http', + method: 'POST', + url: 'https://api.example.com/crm/contacts', + bodyType: 'json', + params: [], + }, + ), + ); + + return tools; +}; + +describe('Meta Search Tools', () => { + let tools: Tools; + let metaTools: Tools; + + beforeEach(async () => { + const mockTools = createMockTools(); + tools = new Tools(mockTools); + metaTools = await tools.metaTools(); // default BM25 strategy + }); + + describe('metaTools()', () => { + it('should return two meta tools', () => { + expect(metaTools.length).toBe(2); + }); + + it('should include meta_search_tools', () => { + const filterTool = metaTools.getTool('meta_search_tools'); + expect(filterTool).toBeDefined(); + expect(filterTool?.name).toBe('meta_search_tools'); + }); + + it('should include meta_execute_tool', () => { + const executeTool = metaTools.getTool('meta_execute_tool'); + expect(executeTool).toBeDefined(); + expect(executeTool?.name).toBe('meta_execute_tool'); + }); + }); + + describe('meta_search_tools', () => { + it('should find relevant HRIS tools', async () => { + const filterTool = metaTools.getTool('meta_search_tools'); + assert(filterTool, 'filterTool should be defined'); + + const result = await filterTool.execute({ + query: 'manage employees in HRIS', + limit: 5, + }); + + expect(result.tools).toBeDefined(); + expect(Array.isArray(result.tools)).toBe(true); + + const toolResults = result.tools as MetaToolSearchResult[]; + const toolNames = toolResults.map((t) => t.name); + + expect(toolNames).toContain('hris_create_employee'); + expect(toolNames).toContain('hris_list_employees'); + }); + + it('should find time off related tools', async () => { + const filterTool = metaTools.getTool('meta_search_tools'); + assert(filterTool, 'filterTool should be defined'); + + const result = await filterTool.execute({ + query: 'time off request vacation leave', + limit: 3, + }); + + const toolResults = result.tools as MetaToolSearchResult[]; + const toolNames = toolResults.map((t) => t.name); + + expect(toolNames).toContain('hris_create_time_off'); + }); + + it('should respect limit parameter', async () => { + const filterTool = metaTools.getTool('meta_search_tools'); + assert(filterTool, 'filterTool should be defined'); + + const result = await filterTool.execute({ + query: 'create', + limit: 2, + }); + + const toolResults = result.tools as MetaToolSearchResult[]; + expect(toolResults.length).toBeLessThanOrEqual(2); + }); + + it('should filter by minimum score', async () => { + const filterTool = metaTools.getTool('meta_search_tools'); + assert(filterTool, 'filterTool should be defined'); + + const result = await filterTool.execute({ + query: 'xyz123 nonexistent', + minScore: 0.8, + }); + + const toolResults = result.tools as MetaToolSearchResult[]; + expect(toolResults.length).toBe(0); + }); + + it('should include tool configurations in results', async () => { + const filterTool = metaTools.getTool('meta_search_tools'); + assert(filterTool, 'filterTool should be defined'); + + const result = await filterTool.execute({ + query: 'create employee', + limit: 1, + }); + + const toolResults = result.tools as MetaToolSearchResult[]; + expect(toolResults.length).toBeGreaterThan(0); + + const firstTool = toolResults[0]; + expect(firstTool).toHaveProperty('name'); + expect(firstTool).toHaveProperty('description'); + expect(firstTool).toHaveProperty('parameters'); + expect(firstTool).toHaveProperty('score'); + expect(typeof firstTool.score).toBe('number'); + }); + + it('should handle empty query', async () => { + const filterTool = metaTools.getTool('meta_search_tools'); + assert(filterTool, 'filterTool should be defined'); + + const result = await filterTool.execute({ + query: '', + limit: 5, + }); + + expect(result.tools).toBeDefined(); + expect(Array.isArray(result.tools)).toBe(true); + }); + + it('should handle string parameters', async () => { + const filterTool = metaTools.getTool('meta_search_tools'); + assert(filterTool, 'filterTool should be defined'); + + const result = await filterTool.execute( + JSON.stringify({ + query: 'candidates', + limit: 3, + }), + ); + + const toolResults = result.tools as MetaToolSearchResult[]; + const toolNames = toolResults.map((t) => t.name); + + const hasCandidateTool = toolNames.some( + (name) => name === 'ats_create_candidate' || name === 'ats_list_candidates', + ); + expect(hasCandidateTool).toBe(true); + }); + }); + + describe('meta_execute_tool', () => { + it('should execute a tool by name', async () => { + const executeTool = metaTools.getTool('meta_execute_tool'); + assert(executeTool, 'executeTool should be defined'); + + const result = await executeTool.execute({ + toolName: 'hris_list_employees', + params: { limit: 10 }, + }); + + expect(result).toEqual({ limit: 10 }); + }); + + it('should handle tools with required parameters', async () => { + const executeTool = metaTools.getTool('meta_execute_tool'); + assert(executeTool, 'executeTool should be defined'); + + const result = await executeTool.execute({ + toolName: 'hris_create_employee', + params: { + name: 'John Doe', + email: 'john@example.com', + }, + }); + + expect(result).toEqual({ + name: 'John Doe', + email: 'john@example.com', + }); + }); + + it('should throw error for non-existent tool', async () => { + const executeTool = metaTools.getTool('meta_execute_tool'); + assert(executeTool, 'executeTool should be defined'); + + await expect( + executeTool.execute({ + toolName: 'nonexistent_tool', + params: {}, + }), + ).rejects.toThrow('Tool nonexistent_tool not found'); + }); + + it('should handle string parameters', async () => { + const executeTool = metaTools.getTool('meta_execute_tool'); + assert(executeTool, 'executeTool should be defined'); + + const result = await executeTool.execute( + JSON.stringify({ + toolName: 'crm_create_contact', + params: { + name: 'Jane Smith', + company: 'Acme Corp', + }, + }), + ); + + expect(result).toEqual({ + name: 'Jane Smith', + company: 'Acme Corp', + }); + }); + + it('should pass through execution options', async () => { + const executeTool = metaTools.getTool('meta_execute_tool'); + assert(executeTool, 'executeTool should be defined'); + + const result = await executeTool.execute({ + toolName: 'ats_list_candidates', + params: { status: 'active' }, + }); + + expect(result).toEqual({ status: 'active' }); + }); + }); + + describe('Integration: meta tools workflow', () => { + it('should discover and execute tools in sequence', async () => { + const filterTool = metaTools.getTool('meta_search_tools'); + const executeTool = metaTools.getTool('meta_execute_tool'); + assert(filterTool, 'filterTool should be defined'); + assert(executeTool, 'executeTool should be defined'); + + // Step 1: Discover relevant tools + const searchResult = await filterTool.execute({ + query: 'create new employee in HR system', + limit: 3, + }); + + const toolResults = searchResult.tools as MetaToolSearchResult[]; + expect(toolResults.length).toBeGreaterThan(0); + + // Find the create employee tool + const createEmployeeTool = toolResults.find((t) => t.name === 'hris_create_employee'); + assert(createEmployeeTool, 'createEmployeeTool should be defined'); + + // Step 2: Execute the discovered tool + const executeResult = await executeTool.execute({ + toolName: createEmployeeTool.name, + params: { + name: 'Alice Johnson', + email: 'alice@example.com', + }, + }); + + expect(executeResult).toEqual({ + name: 'Alice Johnson', + email: 'alice@example.com', + }); + }); + }); + + describe('OpenAI format', () => { + it('should convert meta tools to OpenAI format', () => { + const openAITools = metaTools.toOpenAI(); + + expect(openAITools).toHaveLength(2); + + const filterTool = openAITools.find((t) => t.function.name === 'meta_search_tools'); + expect(filterTool).toBeDefined(); + expect(filterTool?.function.parameters?.properties).toHaveProperty('query'); + expect(filterTool?.function.parameters?.properties).toHaveProperty('limit'); + expect(filterTool?.function.parameters?.properties).toHaveProperty('minScore'); + + const executeTool = openAITools.find((t) => t.function.name === 'meta_execute_tool'); + expect(executeTool).toBeDefined(); + expect(executeTool?.function.parameters?.properties).toHaveProperty('toolName'); + expect(executeTool?.function.parameters?.properties).toHaveProperty('params'); + }); + }); + + describe('AI SDK format', () => { + it('should convert meta tools to AI SDK format', async () => { + const aiSdkTools = await metaTools.toAISDK(); + + expect(aiSdkTools).toHaveProperty('meta_search_tools'); + expect(aiSdkTools).toHaveProperty('meta_execute_tool'); + + expect(typeof aiSdkTools.meta_search_tools.execute).toBe('function'); + expect(typeof aiSdkTools.meta_execute_tool.execute).toBe('function'); + }); + + it('should execute through AI SDK format', async () => { + const aiSdkTools = await metaTools.toAISDK(); + + expect(aiSdkTools.meta_search_tools.execute).toBeDefined(); + + const result = await aiSdkTools.meta_search_tools.execute?.( + { query: 'ATS candidates', limit: 2 }, + { toolCallId: 'test-call-1', messages: [] }, + ); + expect(result).toBeDefined(); + + const toolResults = (result as { tools: MetaToolSearchResult[] }).tools; + expect(Array.isArray(toolResults)).toBe(true); + + const toolNames = toolResults.map((t) => t.name); + expect(toolNames).toContain('ats_create_candidate'); + }); + }); +}); + +describe('Meta Search Tools - Hybrid Strategy', () => { + describe('Hybrid BM25 + TF-IDF search', () => { + it('should search using hybrid strategy with default alpha', async () => { + const tools = new Tools(createMockTools()); + const metaTools = await tools.metaTools(); + const searchTool = metaTools.getTool('meta_search_tools'); + assert(searchTool, 'searchTool should be defined'); + + const result = await searchTool.execute({ + query: 'manage employees', + limit: 5, + }); + + expect(result.tools).toBeDefined(); + expect(Array.isArray(result.tools)).toBe(true); + const toolResults = result.tools as MetaToolSearchResult[]; + expect(toolResults.length).toBeGreaterThan(0); + }); + + it('should search using hybrid strategy with custom alpha', async () => { + const tools = new Tools(createMockTools()); + const metaTools = await tools.metaTools(0.7); + const searchTool = metaTools.getTool('meta_search_tools'); + assert(searchTool, 'searchTool should be defined'); + + const result = await searchTool.execute({ + query: 'create candidate', + limit: 3, + }); + + const toolResults = result.tools as MetaToolSearchResult[]; + const toolNames = toolResults.map((t) => t.name); + expect(toolNames).toContain('ats_create_candidate'); + }); + + it('should combine BM25 and TF-IDF scores', async () => { + const tools = new Tools(createMockTools()); + const metaTools = await tools.metaTools(0.5); + const searchTool = metaTools.getTool('meta_search_tools'); + assert(searchTool, 'searchTool should be defined'); + + const result = await searchTool.execute({ + query: 'employee', + limit: 10, + }); + + const toolResults = result.tools as MetaToolSearchResult[]; + expect(toolResults.length).toBeGreaterThan(0); + + for (const tool of toolResults) { + expect(tool.score).toBeGreaterThanOrEqual(0); + expect(tool.score).toBeLessThanOrEqual(1); + } + }); + + it('should find relevant tools', async () => { + const tools = new Tools(createMockTools()); + const metaTools = await tools.metaTools(); + const searchTool = metaTools.getTool('meta_search_tools'); + assert(searchTool, 'searchTool should be defined'); + + const result = await searchTool.execute({ + query: 'time off vacation', + limit: 3, + }); + + const toolResults = result.tools as MetaToolSearchResult[]; + const toolNames = toolResults.map((t) => t.name); + expect(toolNames).toContain('hris_create_time_off'); + }); + }); +}); + +describe('Schema Validation', () => { + describe('Array Items in Schema', () => { + it('should preserve array items when provided', () => { + const tool = new StackOneTool( + 'test_tool', + 'Test tool', + { + type: 'object', + properties: { + arrayWithItems: { + type: 'array', + description: 'Array with items', + items: { type: 'number' }, + }, + }, + }, + { + kind: 'http', + method: 'GET', + url: 'https://example.com/test', + bodyType: 'json', + params: [], + }, + { authorization: 'Bearer test_api_key' }, + ); + + const parameters = tool.toOpenAI().function.parameters; + expect(parameters).toBeDefined(); + const properties = parameters?.properties as Record; + + expect(properties.arrayWithItems.items).toBeDefined(); + expect((properties.arrayWithItems.items as JSONSchema7).type).toBe('number'); + }); + + it('should handle nested object structure', () => { + const tool = new StackOneTool( + 'test_tool', + 'Test tool', + { + type: 'object', + properties: { + nestedObject: { + type: 'object', + properties: { + nestedArray: { + type: 'array', + items: { type: 'string' }, + }, + }, + }, + }, + }, + { + kind: 'http', + method: 'GET', + url: 'https://example.com/test', + bodyType: 'json', + params: [], + }, + { authorization: 'Bearer test_api_key' }, + ); + + const parameters = tool.toOpenAI().function.parameters; + expect(parameters).toBeDefined(); + const properties = parameters?.properties as Record; + const nestedObject = properties.nestedObject; + + expect(nestedObject.type).toBe('object'); + expect(nestedObject.properties).toBeDefined(); + }); + }); + + describe('AI SDK Integration', () => { + it('should convert to AI SDK tool format with correct schema structure', async () => { + const tool = new StackOneTool( + 'test_tool', + 'Test tool with arrays', + { + type: 'object', + properties: { + arrayWithItems: { type: 'array', items: { type: 'string' } }, + }, + }, + { + kind: 'http', + method: 'GET', + url: 'https://example.com/test', + bodyType: 'json', + params: [], + }, + { authorization: 'Bearer test_api_key' }, + ); + + const aiSdkTool = await tool.toAISDK(); + const toolObj = aiSdkTool[tool.name]; + + expect(toolObj).toBeDefined(); + expect(typeof toolObj.execute).toBe('function'); + // TODO: Remove ts-ignore once AISDKToolDefinition properly types inputSchema.jsonSchema + // @ts-ignore - jsonSchema is available on Schema wrapper from ai sdk + expect(toolObj.inputSchema.jsonSchema.type).toBe('object'); + + // @ts-ignore - jsonSchema is available on Schema wrapper from ai sdk + const arrayWithItems = toolObj.inputSchema.jsonSchema.properties?.arrayWithItems; + expect(arrayWithItems?.type).toBe('array'); + expect((arrayWithItems?.items as JSONSchema7)?.type).toBe('string'); + }); + + it('should handle nested filter object for AI SDK', async () => { + const tool = new StackOneTool( + 'test_nested_arrays', + 'Test nested arrays', + { + type: 'object', + properties: { + filter: { + type: 'object', + properties: { + type_ids: { + type: 'array', + items: { type: 'string' }, + description: 'List of type IDs', + }, + status: { type: 'string' }, + }, + }, + }, + }, + { + kind: 'http', + method: 'GET', + url: 'https://example.com/test', + bodyType: 'json', + params: [], + }, + { authorization: 'Bearer test_api_key' }, + ); + + const parameters = tool.toOpenAI().function.parameters; + expect(parameters).toBeDefined(); + const aiSchema = jsonSchema(parameters as JSONSchema7); + expect(aiSchema).toBeDefined(); + + const aiSdkTool = await tool.toAISDK(); + // TODO: Remove ts-ignore once AISDKToolDefinition properly types inputSchema.jsonSchema + // @ts-ignore - jsonSchema is available on Schema wrapper from ai sdk + const filterProp = aiSdkTool[tool.name].inputSchema.jsonSchema.properties?.filter as + | (JSONSchema7 & { properties: Record }) + | undefined; + + expect(filterProp?.type).toBe('object'); + expect(filterProp?.properties.type_ids.type).toBe('array'); + expect(filterProp?.properties.type_ids.items).toBeDefined(); + }); + }); +}); From 8342a23a22eb409473e81e50ffb27e74a880bd3c Mon Sep 17 00:00:00 2001 From: ryoppippi <1560508+ryoppippi@users.noreply.github.com> Date: Wed, 10 Dec 2025 16:56:22 +0000 Subject: [PATCH 7/8] remove vitest imports from test files --- src/tool.test-d.ts | 1 - src/tool.test.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/src/tool.test-d.ts b/src/tool.test-d.ts index 12e028f9..9cc1b5be 100644 --- a/src/tool.test-d.ts +++ b/src/tool.test-d.ts @@ -1,5 +1,4 @@ import type { ChatCompletionFunctionTool } from 'openai/resources/chat/completions'; -import { assertType, test } from 'vitest'; import { BaseTool, Tools } from './tool'; import type { AISDKToolResult } from './types'; diff --git a/src/tool.test.ts b/src/tool.test.ts index 8ea91c21..14e9cdee 100644 --- a/src/tool.test.ts +++ b/src/tool.test.ts @@ -1,4 +1,3 @@ -import { assert } from 'vitest'; import { jsonSchema } from 'ai'; import type { JSONSchema7 } from 'json-schema'; import { BaseTool, type MetaToolSearchResult, StackOneTool, Tools } from './tool'; From 55a56ad3a3b3a77f63d019b73bde67c7fb979219 Mon Sep 17 00:00:00 2001 From: ryoppippi <1560508+ryoppippi@users.noreply.github.com> Date: Wed, 10 Dec 2025 17:03:39 +0000 Subject: [PATCH 8/8] refactor(schema): flatten schemas directory structure - Rename src/schemas/rpc.ts to src/schema.ts - Update import paths in rpc-client.ts to use new location - Remove schemas/ subdirectory This simplifies the module structure by moving schema definitions directly into the src root directory. --- src/rpc-client.ts | 4 ++-- src/{schemas/rpc.ts => schema.ts} | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename src/{schemas/rpc.ts => schema.ts} (100%) diff --git a/src/rpc-client.ts b/src/rpc-client.ts index 6716cefd..f6f718c5 100644 --- a/src/rpc-client.ts +++ b/src/rpc-client.ts @@ -5,11 +5,11 @@ import { rpcActionRequestSchema, rpcActionResponseSchema, rpcClientConfigSchema, -} from './schemas/rpc'; +} from './schema'; import { StackOneAPIError } from './utils/errors'; // Re-export types for consumers and to make types portable -export type { RpcActionResponse } from './schemas/rpc'; +export type { RpcActionResponse } from './schema'; /** * Custom RPC client for StackOne API. diff --git a/src/schemas/rpc.ts b/src/schema.ts similarity index 100% rename from src/schemas/rpc.ts rename to src/schema.ts