diff --git a/src/toolsets.test-d.ts b/src/toolsets.test-d.ts new file mode 100644 index 0000000..2a7642f --- /dev/null +++ b/src/toolsets.test-d.ts @@ -0,0 +1,44 @@ +import { expectTypeOf } from 'vitest'; +import type { StackOneToolSetConfig } from './toolsets'; + +// Valid configurations - only accountId +test('StackOneToolSetConfig accepts only accountId', () => { + expectTypeOf<{ + apiKey: string; + accountId: string; + }>().toExtend(); +}); + +// Valid configurations - only accountIds +test('StackOneToolSetConfig accepts only accountIds', () => { + expectTypeOf<{ + apiKey: string; + accountIds: string[]; + }>().toExtend(); +}); + +// Valid configurations - neither accountId nor accountIds +test('StackOneToolSetConfig accepts neither accountId nor accountIds', () => { + expectTypeOf<{ + apiKey: string; + }>().toExtend(); +}); + +// Invalid configuration - both accountId and accountIds should NOT extend +test('StackOneToolSetConfig rejects both accountId and accountIds', () => { + expectTypeOf<{ + apiKey: string; + accountId: string; + accountIds: string[]; + }>().not.toExtend(); +}); + +// Verify accountId can be string or undefined +test('accountId is typed as string | undefined', () => { + expectTypeOf().toEqualTypeOf(); +}); + +// Verify accountIds can be string[] or undefined +test('accountIds is typed as string[] | undefined', () => { + expectTypeOf().toEqualTypeOf(); +}); diff --git a/src/toolsets.test.ts b/src/toolsets.test.ts index f905407..735083d 100644 --- a/src/toolsets.test.ts +++ b/src/toolsets.test.ts @@ -83,6 +83,71 @@ describe('StackOneToolSet', () => { expect(toolset.accountIds).toEqual(['account-1', 'account-2']); }); + it('should initialise with multiple account IDs from constructor', () => { + const toolset = new StackOneToolSet({ + apiKey: 'custom_key', + accountIds: ['account-1', 'account-2', 'account-3'], + }); + + // @ts-expect-error - Accessing private property for testing + expect(toolset.accountIds).toEqual(['account-1', 'account-2', 'account-3']); + }); + + it('should initialise with empty accountIds array when not provided', () => { + const toolset = new StackOneToolSet({ apiKey: 'custom_key' }); + + // @ts-expect-error - Accessing private property for testing + expect(toolset.accountIds).toEqual([]); + }); + + it('should not allow both accountId and accountIds in constructor (type check)', () => { + // This test verifies the type system prevents using both accountId and accountIds + // The following would be a type error: + // new StackOneToolSet({ + // apiKey: 'custom_key', + // accountId: 'primary-account', + // accountIds: ['account-1', 'account-2'], + // }); + + // Valid: only accountId + const toolsetSingle = new StackOneToolSet({ + apiKey: 'custom_key', + accountId: 'primary-account', + }); + // @ts-expect-error - Accessing private property for testing + expect(toolsetSingle.headers['x-account-id']).toBe('primary-account'); + // @ts-expect-error - Accessing private property for testing + expect(toolsetSingle.accountIds).toEqual([]); + + // Valid: only accountIds + const toolsetMultiple = new StackOneToolSet({ + apiKey: 'custom_key', + accountIds: ['account-1', 'account-2'], + }); + // @ts-expect-error - Accessing private property for testing + expect(toolsetMultiple.headers['x-account-id']).toBeUndefined(); + // @ts-expect-error - Accessing private property for testing + expect(toolsetMultiple.accountIds).toEqual(['account-1', 'account-2']); + }); + + it('should throw error when both accountId and accountIds are provided at runtime', () => { + // Runtime validation for JavaScript users or when TypeScript is bypassed + expect(() => { + new StackOneToolSet({ + apiKey: 'custom_key', + accountId: 'primary-account', + accountIds: ['account-1', 'account-2'], + } as never); // Use 'as never' to bypass TypeScript for runtime test + }).toThrow(ToolSetConfigError); + expect(() => { + new StackOneToolSet({ + apiKey: 'custom_key', + accountId: 'primary-account', + accountIds: ['account-1', 'account-2'], + } as never); + }).toThrow(/Cannot provide both accountId and accountIds/); + }); + it('should set baseUrl from config', () => { const toolset = new StackOneToolSet({ apiKey: 'custom_key', @@ -283,6 +348,51 @@ describe('StackOneToolSet', () => { expect(toolNames).toContain('meta_collect_tool_feedback'); }); + it('uses accountIds from constructor when no accountIds provided in fetchTools', async () => { + const toolset = new StackOneToolSet({ + baseUrl: 'https://api.stackone-dev.com', + apiKey: 'test-key', + accountIds: ['acc1', 'acc2'], + }); + + // Fetch without accountIds - should use constructor accountIds + const tools = await toolset.fetchTools(); + + // Should fetch tools for 2 accounts from constructor + // 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('setAccounts overrides constructor accountIds', async () => { + const toolset = new StackOneToolSet({ + baseUrl: 'https://api.stackone-dev.com', + apiKey: 'test-key', + accountIds: ['acc1'], + }); + + // Override with setAccounts + toolset.setAccounts(['acc2', 'acc3']); + + // Fetch without accountIds - should use setAccounts, not constructor + const tools = await toolset.fetchTools(); + + // Should fetch tools for acc2 and acc3 (not acc1) + // acc2 has 2 tools, acc3 has 1 tool, + 1 feedback tool = 4 + expect(tools.length).toBe(4); + const toolNames = tools.toArray().map((t) => t.name); + expect(toolNames).not.toContain('acc1_tool_1'); + expect(toolNames).toContain('acc2_tool_1'); + expect(toolNames).toContain('acc2_tool_2'); + expect(toolNames).toContain('acc3_tool_1'); + 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', diff --git a/src/toolsets.ts b/src/toolsets.ts index 483dbce..ad31de4 100644 --- a/src/toolsets.ts +++ b/src/toolsets.ts @@ -1,4 +1,5 @@ import { defu } from 'defu'; +import type { MergeExclusive, SimplifyDeep } from 'type-fest'; import { DEFAULT_BASE_URL, UNIFIED_API_PREFIX } from './consts'; import { createFeedbackTool } from './feedback'; import { type StackOneHeaders, normaliseHeaders, stackOneHeadersSchema } from './headers'; @@ -88,14 +89,47 @@ export interface BaseToolSetConfig { } /** - * Configuration for StackOne toolset + * Configuration with a single account ID + */ +interface SingleAccountConfig { + /** + * Single account ID for StackOne API operations + * Use this when working with a single account + */ + accountId: string; +} + +/** + * Configuration with multiple account IDs + */ +interface MultipleAccountsConfig { + /** + * Array of account IDs for filtering tools across multiple accounts + * When provided, tools will be fetched for all specified accounts + * @example ['account-1', 'account-2'] + */ + accountIds: string[]; +} + +/** + * Account configuration options - either single accountId or multiple accountIds, but not both + */ +type AccountConfig = SimplifyDeep>; + +/** + * Base configuration for StackOne toolset (without account options) */ -export interface StackOneToolSetConfig extends BaseToolSetConfig { +interface StackOneToolSetBaseConfig extends BaseToolSetConfig { apiKey?: string; - accountId?: string; strict?: boolean; } +/** + * Configuration for StackOne toolset + * Accepts either accountId (single) or accountIds (multiple), but not both + */ +export type StackOneToolSetConfig = StackOneToolSetBaseConfig & Partial; + /** * Options for filtering tools when fetching from MCP */ @@ -137,10 +171,17 @@ export class StackOneToolSet { private accountIds: string[] = []; /** - * Initialise StackOne toolset with API key and optional account ID - * @param config Configuration object containing API key and optional account ID + * Initialise StackOne toolset with API key and optional account ID(s) + * @param config Configuration object containing API key and optional account ID(s) */ constructor(config?: StackOneToolSetConfig) { + // Validate mutually exclusive account options + if (config?.accountId != null && config?.accountIds != null) { + throw new ToolSetConfigError( + 'Cannot provide both accountId and accountIds. Use accountId for a single account or accountIds for multiple accounts.', + ); + } + const apiKey = config?.apiKey || process.env.STACKONE_API_KEY; if (!apiKey && config?.strict) { @@ -176,6 +217,7 @@ export class StackOneToolSet { this.headers = configHeaders; this.rpcClient = config?.rpcClient; this.accountId = accountId; + this.accountIds = config?.accountIds ?? []; // Set Authentication headers if provided if (this.authentication) {