Skip to content

Commit 33973ed

Browse files
authored
fix(toolsets): add accountIds option to StackOneToolSetConfig (#252)
* feat(toolsets): add accountIds option to StackOneToolSetConfig Allow passing multiple account IDs when initialising StackOneToolSet via the constructor, eliminating the need to call setAccounts() separately after instantiation. - Add accountIds property to StackOneToolSetConfig interface with JSDoc documentation - Initialise accountIds from config in constructor - Update constructor JSDoc to reflect support for multiple account IDs This change enables a more ergonomic API for multi-account setups: ```typescript const toolset = new StackOneToolSet({ apiKey: 'key', accountIds: ['acc1', 'acc2', 'acc3'], }); ``` Closes #251 * test(toolsets): add tests for accountIds constructor option Add comprehensive tests verifying the new accountIds configuration: - Test initialising with multiple account IDs from constructor - Test default empty array when accountIds not provided - Test coexistence of accountId and accountIds in constructor - Test fetchTools uses constructor accountIds when present - Test setAccounts() overrides constructor accountIds These tests ensure accountIds from the constructor integrates correctly with existing setAccounts() and fetchTools() behaviours. * refactor(toolsets): use MergeExclusive for mutually exclusive account options Use type-fest's MergeExclusive to enforce that either accountId (single) or accountIds (multiple) can be provided, but not both simultaneously. This improves type safety by catching invalid configurations at compile time rather than runtime. The API now clearly communicates the intended usage pattern through the type system. Before (both allowed - confusing behaviour): ```typescript new StackOneToolSet({ accountId: 'acc1', accountIds: ['acc2', 'acc3'], // Which takes precedence? }); ``` After (type error - clear contract): ```typescript // Valid: single account new StackOneToolSet({ accountId: 'acc1' }); // Valid: multiple accounts new StackOneToolSet({ accountIds: ['acc1', 'acc2'] }); // Type error: cannot use both new StackOneToolSet({ accountId: 'acc1', accountIds: ['acc2'] }); ``` * test(toolsets): update tests for MergeExclusive account config Update test to verify that the type system enforces mutual exclusivity between accountId and accountIds. Replace the test that allowed both with a new test demonstrating the correct API usage patterns. - Test single accountId configuration works correctly - Test multiple accountIds configuration works correctly - Document that combining both is a type error (prevented at compile time) * test(toolsets): add type-level tests for StackOneToolSetConfig Add comprehensive type tests using vitest's expectTypeOf to verify the MergeExclusive behaviour of account configuration options: - Test that accountId alone is valid - Test that accountIds alone is valid - Test that neither is valid (optional) - Test that both together is rejected by the type system - Verify accountId is typed as string | undefined - Verify accountIds is typed as string[] | undefined * refactor(toolset): use simplifyDeep * feat(toolsets): add runtime validation for mutually exclusive account options Add runtime check to throw ToolSetConfigError when both accountId and accountIds are provided simultaneously. This protects JavaScript users and scenarios where TypeScript is bypassed (e.g., using 'as any'). The validation uses != null to check for both null and undefined in a single comparison, ensuring the error is thrown regardless of how the invalid configuration is constructed. * test(toolsets): add runtime validation test for mutually exclusive accounts Add test to verify ToolSetConfigError is thrown when both accountId and accountIds are provided at runtime. Uses 'as never' to bypass TypeScript type checking and simulate JavaScript usage scenarios.
1 parent b5defd8 commit 33973ed

File tree

3 files changed

+201
-5
lines changed

3 files changed

+201
-5
lines changed

src/toolsets.test-d.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { expectTypeOf } from 'vitest';
2+
import type { StackOneToolSetConfig } from './toolsets';
3+
4+
// Valid configurations - only accountId
5+
test('StackOneToolSetConfig accepts only accountId', () => {
6+
expectTypeOf<{
7+
apiKey: string;
8+
accountId: string;
9+
}>().toExtend<StackOneToolSetConfig>();
10+
});
11+
12+
// Valid configurations - only accountIds
13+
test('StackOneToolSetConfig accepts only accountIds', () => {
14+
expectTypeOf<{
15+
apiKey: string;
16+
accountIds: string[];
17+
}>().toExtend<StackOneToolSetConfig>();
18+
});
19+
20+
// Valid configurations - neither accountId nor accountIds
21+
test('StackOneToolSetConfig accepts neither accountId nor accountIds', () => {
22+
expectTypeOf<{
23+
apiKey: string;
24+
}>().toExtend<StackOneToolSetConfig>();
25+
});
26+
27+
// Invalid configuration - both accountId and accountIds should NOT extend
28+
test('StackOneToolSetConfig rejects both accountId and accountIds', () => {
29+
expectTypeOf<{
30+
apiKey: string;
31+
accountId: string;
32+
accountIds: string[];
33+
}>().not.toExtend<StackOneToolSetConfig>();
34+
});
35+
36+
// Verify accountId can be string or undefined
37+
test('accountId is typed as string | undefined', () => {
38+
expectTypeOf<StackOneToolSetConfig['accountId']>().toEqualTypeOf<string | undefined>();
39+
});
40+
41+
// Verify accountIds can be string[] or undefined
42+
test('accountIds is typed as string[] | undefined', () => {
43+
expectTypeOf<StackOneToolSetConfig['accountIds']>().toEqualTypeOf<string[] | undefined>();
44+
});

src/toolsets.test.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,71 @@ describe('StackOneToolSet', () => {
8383
expect(toolset.accountIds).toEqual(['account-1', 'account-2']);
8484
});
8585

86+
it('should initialise with multiple account IDs from constructor', () => {
87+
const toolset = new StackOneToolSet({
88+
apiKey: 'custom_key',
89+
accountIds: ['account-1', 'account-2', 'account-3'],
90+
});
91+
92+
// @ts-expect-error - Accessing private property for testing
93+
expect(toolset.accountIds).toEqual(['account-1', 'account-2', 'account-3']);
94+
});
95+
96+
it('should initialise with empty accountIds array when not provided', () => {
97+
const toolset = new StackOneToolSet({ apiKey: 'custom_key' });
98+
99+
// @ts-expect-error - Accessing private property for testing
100+
expect(toolset.accountIds).toEqual([]);
101+
});
102+
103+
it('should not allow both accountId and accountIds in constructor (type check)', () => {
104+
// This test verifies the type system prevents using both accountId and accountIds
105+
// The following would be a type error:
106+
// new StackOneToolSet({
107+
// apiKey: 'custom_key',
108+
// accountId: 'primary-account',
109+
// accountIds: ['account-1', 'account-2'],
110+
// });
111+
112+
// Valid: only accountId
113+
const toolsetSingle = new StackOneToolSet({
114+
apiKey: 'custom_key',
115+
accountId: 'primary-account',
116+
});
117+
// @ts-expect-error - Accessing private property for testing
118+
expect(toolsetSingle.headers['x-account-id']).toBe('primary-account');
119+
// @ts-expect-error - Accessing private property for testing
120+
expect(toolsetSingle.accountIds).toEqual([]);
121+
122+
// Valid: only accountIds
123+
const toolsetMultiple = new StackOneToolSet({
124+
apiKey: 'custom_key',
125+
accountIds: ['account-1', 'account-2'],
126+
});
127+
// @ts-expect-error - Accessing private property for testing
128+
expect(toolsetMultiple.headers['x-account-id']).toBeUndefined();
129+
// @ts-expect-error - Accessing private property for testing
130+
expect(toolsetMultiple.accountIds).toEqual(['account-1', 'account-2']);
131+
});
132+
133+
it('should throw error when both accountId and accountIds are provided at runtime', () => {
134+
// Runtime validation for JavaScript users or when TypeScript is bypassed
135+
expect(() => {
136+
new StackOneToolSet({
137+
apiKey: 'custom_key',
138+
accountId: 'primary-account',
139+
accountIds: ['account-1', 'account-2'],
140+
} as never); // Use 'as never' to bypass TypeScript for runtime test
141+
}).toThrow(ToolSetConfigError);
142+
expect(() => {
143+
new StackOneToolSet({
144+
apiKey: 'custom_key',
145+
accountId: 'primary-account',
146+
accountIds: ['account-1', 'account-2'],
147+
} as never);
148+
}).toThrow(/Cannot provide both accountId and accountIds/);
149+
});
150+
86151
it('should set baseUrl from config', () => {
87152
const toolset = new StackOneToolSet({
88153
apiKey: 'custom_key',
@@ -283,6 +348,51 @@ describe('StackOneToolSet', () => {
283348
expect(toolNames).toContain('meta_collect_tool_feedback');
284349
});
285350

351+
it('uses accountIds from constructor when no accountIds provided in fetchTools', async () => {
352+
const toolset = new StackOneToolSet({
353+
baseUrl: 'https://api.stackone-dev.com',
354+
apiKey: 'test-key',
355+
accountIds: ['acc1', 'acc2'],
356+
});
357+
358+
// Fetch without accountIds - should use constructor accountIds
359+
const tools = await toolset.fetchTools();
360+
361+
// Should fetch tools for 2 accounts from constructor
362+
// acc1 has 2 tools, acc2 has 2 tools, + 1 feedback tool = 5
363+
expect(tools.length).toBe(5);
364+
const toolNames = tools.toArray().map((t) => t.name);
365+
expect(toolNames).toContain('acc1_tool_1');
366+
expect(toolNames).toContain('acc1_tool_2');
367+
expect(toolNames).toContain('acc2_tool_1');
368+
expect(toolNames).toContain('acc2_tool_2');
369+
expect(toolNames).toContain('meta_collect_tool_feedback');
370+
});
371+
372+
it('setAccounts overrides constructor accountIds', async () => {
373+
const toolset = new StackOneToolSet({
374+
baseUrl: 'https://api.stackone-dev.com',
375+
apiKey: 'test-key',
376+
accountIds: ['acc1'],
377+
});
378+
379+
// Override with setAccounts
380+
toolset.setAccounts(['acc2', 'acc3']);
381+
382+
// Fetch without accountIds - should use setAccounts, not constructor
383+
const tools = await toolset.fetchTools();
384+
385+
// Should fetch tools for acc2 and acc3 (not acc1)
386+
// acc2 has 2 tools, acc3 has 1 tool, + 1 feedback tool = 4
387+
expect(tools.length).toBe(4);
388+
const toolNames = tools.toArray().map((t) => t.name);
389+
expect(toolNames).not.toContain('acc1_tool_1');
390+
expect(toolNames).toContain('acc2_tool_1');
391+
expect(toolNames).toContain('acc2_tool_2');
392+
expect(toolNames).toContain('acc3_tool_1');
393+
expect(toolNames).toContain('meta_collect_tool_feedback');
394+
});
395+
286396
it('overrides setAccounts when accountIds provided in fetchTools', async () => {
287397
const toolset = new StackOneToolSet({
288398
baseUrl: 'https://api.stackone-dev.com',

src/toolsets.ts

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { defu } from 'defu';
2+
import type { MergeExclusive, SimplifyDeep } from 'type-fest';
23
import { DEFAULT_BASE_URL, UNIFIED_API_PREFIX } from './consts';
34
import { createFeedbackTool } from './feedback';
45
import { type StackOneHeaders, normaliseHeaders, stackOneHeadersSchema } from './headers';
@@ -88,14 +89,47 @@ export interface BaseToolSetConfig {
8889
}
8990

9091
/**
91-
* Configuration for StackOne toolset
92+
* Configuration with a single account ID
93+
*/
94+
interface SingleAccountConfig {
95+
/**
96+
* Single account ID for StackOne API operations
97+
* Use this when working with a single account
98+
*/
99+
accountId: string;
100+
}
101+
102+
/**
103+
* Configuration with multiple account IDs
104+
*/
105+
interface MultipleAccountsConfig {
106+
/**
107+
* Array of account IDs for filtering tools across multiple accounts
108+
* When provided, tools will be fetched for all specified accounts
109+
* @example ['account-1', 'account-2']
110+
*/
111+
accountIds: string[];
112+
}
113+
114+
/**
115+
* Account configuration options - either single accountId or multiple accountIds, but not both
116+
*/
117+
type AccountConfig = SimplifyDeep<MergeExclusive<SingleAccountConfig, MultipleAccountsConfig>>;
118+
119+
/**
120+
* Base configuration for StackOne toolset (without account options)
92121
*/
93-
export interface StackOneToolSetConfig extends BaseToolSetConfig {
122+
interface StackOneToolSetBaseConfig extends BaseToolSetConfig {
94123
apiKey?: string;
95-
accountId?: string;
96124
strict?: boolean;
97125
}
98126

127+
/**
128+
* Configuration for StackOne toolset
129+
* Accepts either accountId (single) or accountIds (multiple), but not both
130+
*/
131+
export type StackOneToolSetConfig = StackOneToolSetBaseConfig & Partial<AccountConfig>;
132+
99133
/**
100134
* Options for filtering tools when fetching from MCP
101135
*/
@@ -137,10 +171,17 @@ export class StackOneToolSet {
137171
private accountIds: string[] = [];
138172

139173
/**
140-
* Initialise StackOne toolset with API key and optional account ID
141-
* @param config Configuration object containing API key and optional account ID
174+
* Initialise StackOne toolset with API key and optional account ID(s)
175+
* @param config Configuration object containing API key and optional account ID(s)
142176
*/
143177
constructor(config?: StackOneToolSetConfig) {
178+
// Validate mutually exclusive account options
179+
if (config?.accountId != null && config?.accountIds != null) {
180+
throw new ToolSetConfigError(
181+
'Cannot provide both accountId and accountIds. Use accountId for a single account or accountIds for multiple accounts.',
182+
);
183+
}
184+
144185
const apiKey = config?.apiKey || process.env.STACKONE_API_KEY;
145186

146187
if (!apiKey && config?.strict) {
@@ -176,6 +217,7 @@ export class StackOneToolSet {
176217
this.headers = configHeaders;
177218
this.rpcClient = config?.rpcClient;
178219
this.accountId = accountId;
220+
this.accountIds = config?.accountIds ?? [];
179221

180222
// Set Authentication headers if provided
181223
if (this.authentication) {

0 commit comments

Comments
 (0)