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/utils/headers.test.ts b/src/headers.test.ts similarity index 100% rename from src/utils/headers.test.ts rename to src/headers.test.ts diff --git a/src/utils/headers.ts b/src/headers.ts similarity index 61% rename from src/utils/headers.ts rename to src/headers.ts index 844091d6..11074839 100644 --- a/src/utils/headers.ts +++ b/src/headers.ts @@ -1,5 +1,21 @@ -import { type StackOneHeaders, stackOneHeadersSchema } from '../schemas/headers'; -import type { JsonDict } from '../types'; +import { z } from 'zod/mini'; +import type { JsonDict } from './types'; + +/** + * Known StackOne API header keys that are forwarded as HTTP headers + */ +export const STACKONE_HEADER_KEYS = ['x-account-id'] as const; + +/** + * Zod schema for StackOne API headers (branded) + * These headers are forwarded as HTTP headers in API requests + */ +export const stackOneHeadersSchema = z.record(z.string(), z.string()).brand<'StackOneHeaders'>(); + +/** + * Branded type for StackOne API headers + */ +export type StackOneHeaders = z.infer; /** * Normalises header values from JsonDict to StackOneHeaders (branded type) 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/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/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(); +}); diff --git a/src/modules/requestBuilder.test.ts b/src/requestBuilder.test.ts similarity index 98% rename from src/modules/requestBuilder.test.ts rename to src/requestBuilder.test.ts index ffe7bd44..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', () => { @@ -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/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/rpc-client.test.ts b/src/rpc-client.test.ts index f70d0255..74b27c31 100644 --- a/src/rpc-client.test.ts +++ b/src/rpc-client.test.ts @@ -1,5 +1,5 @@ import { RpcClient } from './rpc-client'; -import { stackOneHeadersSchema } from './schemas/headers'; +import { stackOneHeadersSchema } from './headers'; import { StackOneAPIError } from './utils/errors'; test('should successfully execute an RPC action', async () => { diff --git a/src/rpc-client.ts b/src/rpc-client.ts index b928a7a3..a277fd89 100644 --- a/src/rpc-client.ts +++ b/src/rpc-client.ts @@ -1,4 +1,4 @@ -import { STACKONE_HEADER_KEYS } from './schemas/headers'; +import { STACKONE_HEADER_KEYS } from './headers'; import { type RpcActionRequest, type RpcActionResponse, @@ -6,11 +6,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 diff --git a/src/schemas/headers.ts b/src/schemas/headers.ts deleted file mode 100644 index 878363b1..00000000 --- a/src/schemas/headers.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { z } from 'zod/mini'; - -/** - * Known StackOne API header keys that are forwarded as HTTP headers - */ -export const STACKONE_HEADER_KEYS = ['x-account-id'] as const; - -/** - * Zod schema for StackOne API headers (branded) - * These headers are forwarded as HTTP headers in API requests - */ -export const stackOneHeadersSchema = z.record(z.string(), z.string()).brand<'StackOneHeaders'>(); - -/** - * Branded type for StackOne API headers - */ -export type StackOneHeaders = z.infer; 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-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 bec971ce..14e9cdee 100644 --- a/src/tool.test.ts +++ b/src/tool.test.ts @@ -1,4 +1,6 @@ -import { BaseTool, StackOneTool, Tools } from './tool'; +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'; @@ -28,16 +30,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' }); @@ -205,16 +197,60 @@ describe('StackOneTool', () => { ); expect(result).toMatch(/Error executing tool:/); }); -}); -describe('Tools', () => { - it('should initialize with tools array', () => { + it('should set and get headers', () => { const tool = createMockTool(); - const tools = new Tools([tool]); - expect(tools.length).toBe(1); + // 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', () => { it('should get tool by name', () => { const tool = createMockTool(); const tools = new Tools([tool]); @@ -359,89 +395,688 @@ describe('Tools', () => { }); }); -describe('Tool', () => { - it('should initialize with correct properties', () => { - const tool = createMockTool(); +// Create mock tools for meta tools testing +const createMockTools = (): BaseTool[] => { + const tools: BaseTool[] = []; - 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'); + // 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 }); - it('should set and get headers', () => { - const tool = createMockTool(); + describe('metaTools()', () => { + it('should return two meta tools', () => { + expect(metaTools.length).toBe(2); + }); - // Set headers - const headers = { 'X-Custom-Header': 'test-value' }; - tool.setHeaders(headers); + it('should include meta_search_tools', () => { + const filterTool = metaTools.getTool('meta_search_tools'); + expect(filterTool).toBeDefined(); + expect(filterTool?.name).toBe('meta_search_tools'); + }); - // Headers should include custom header - const updatedHeaders = tool.getHeaders(); - expect(updatedHeaders['X-Custom-Header']).toBe('test-value'); + it('should include meta_execute_tool', () => { + const executeTool = metaTools.getTool('meta_execute_tool'); + expect(executeTool).toBeDefined(); + expect(executeTool?.name).toBe('meta_execute_tool'); + }); + }); - // Set additional headers - tool.setHeaders({ 'X-Another-Header': 'another-value' }); + describe('meta_search_tools', () => { + it('should find relevant HRIS tools', async () => { + const filterTool = metaTools.getTool('meta_search_tools'); + assert(filterTool, 'filterTool should be defined'); - // 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'); + 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); + }); }); - it('should use basic authentication', async () => { - const headers = { - Authorization: `Basic ${Buffer.from('testuser:testpass').toString('base64')}`, - }; - const tool = createMockTool(headers); + 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 tool.execute({ id: '123' }); - expect(result).toEqual({ id: '123', name: 'Test' }); + 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' }); + }); }); - it('should use bearer authentication', async () => { - const headers = { - Authorization: 'Bearer test-token', - }; - const tool = createMockTool(headers); + 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', + }, + }); - const result = await tool.execute({ id: '123' }); - expect(result).toEqual({ id: '123', name: 'Test' }); + expect(executeResult).toEqual({ + name: 'Alice Johnson', + email: 'alice@example.com', + }); + }); }); - it('should use api-key authentication', async () => { - const apiKey = 'test-api-key'; - const headers = { - Authorization: `Bearer ${apiKey}`, - }; - const tool = createMockTool(headers); + describe('OpenAI format', () => { + it('should convert meta tools to OpenAI format', () => { + const openAITools = metaTools.toOpenAI(); - const result = await tool.execute({ id: '123' }); - expect(result).toEqual({ id: '123', name: 'Test' }); + 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'); + }); }); - it('should execute with parameters', async () => { - const tool = createMockTool(); - const result = await tool.execute({ id: '123' }); - expect(result).toEqual({ id: '123', name: 'Test' }); + 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'); + }); }); +}); - it('should convert to OpenAI tool format', () => { - const tool = createMockTool(); - const openAIFormat = tool.toOpenAI(); +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); + }); - 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'); + 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(); + }); }); }); 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.test.ts b/src/toolsets.test.ts new file mode 100644 index 00000000..8b6731e5 --- /dev/null +++ b/src/toolsets.test.ts @@ -0,0 +1,517 @@ +/** + * 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 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 61% rename from src/toolsets/base.ts rename to src/toolsets.ts index c3168b72..396bdef4 100644 --- a/src/toolsets/base.ts +++ b/src/toolsets.ts @@ -1,19 +1,20 @@ import { defu } from 'defu'; import type { Arrayable } from 'type-fest'; -import { createMCPClient } from '../mcp-client'; -import { type RpcActionResponse, RpcClient } from '../rpc-client'; -import { type StackOneHeaders, stackOneHeadersSchema } from '../schemas/headers'; -import { BaseTool, Tools } from '../tool'; +import { DEFAULT_BASE_URL } from './consts'; +import { createFeedbackTool } from './feedback'; +import { type StackOneHeaders, normaliseHeaders, stackOneHeadersSchema } from './headers'; +import { createMCPClient } from './mcp-client'; +import { type RpcActionResponse, RpcClient } from './rpc-client'; +import { BaseTool, type StackOneTool, Tools } from './tool'; import type { ExecuteOptions, JsonDict, JsonSchemaProperties, RpcExecuteConfig, ToolParameters, -} from '../types'; -import { toArray } from '../utils/array'; -import { StackOneError } from '../utils/errors'; -import { normaliseHeaders } from '../utils/headers'; +} from './types'; +import { toArray } from './utils/array'; +import { StackOneError } from './utils/errors'; /** * Converts RpcActionResponse to JsonDict in a type-safe manner. @@ -89,23 +90,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) { @@ -144,57 +228,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, @@ -216,6 +314,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; @@ -383,4 +551,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'); - } -}