Skip to content
44 changes: 44 additions & 0 deletions src/toolsets.test-d.ts
Original file line number Diff line number Diff line change
@@ -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<StackOneToolSetConfig>();
});

// Valid configurations - only accountIds
test('StackOneToolSetConfig accepts only accountIds', () => {
expectTypeOf<{
apiKey: string;
accountIds: string[];
}>().toExtend<StackOneToolSetConfig>();
});

// Valid configurations - neither accountId nor accountIds
test('StackOneToolSetConfig accepts neither accountId nor accountIds', () => {
expectTypeOf<{
apiKey: string;
}>().toExtend<StackOneToolSetConfig>();
});

// 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<StackOneToolSetConfig>();
});

// Verify accountId can be string or undefined
test('accountId is typed as string | undefined', () => {
expectTypeOf<StackOneToolSetConfig['accountId']>().toEqualTypeOf<string | undefined>();
});

// Verify accountIds can be string[] or undefined
test('accountIds is typed as string[] | undefined', () => {
expectTypeOf<StackOneToolSetConfig['accountIds']>().toEqualTypeOf<string[] | undefined>();
});
110 changes: 110 additions & 0 deletions src/toolsets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
52 changes: 47 additions & 5 deletions src/toolsets.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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
Comment on lines +107 to +108
Copy link

Copilot AI Dec 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSDoc states "When provided, tools will be fetched for all specified accounts" but this is imprecise. The accountIds are stored during construction, but tools are only fetched when fetchTools() is called later. Consider rephrasing to something like "When provided, these account IDs will be used when fetching tools" or "Array of account IDs to use for filtering tools during fetchTools() calls".

Suggested change
* Array of account IDs for filtering tools across multiple accounts
* When provided, tools will be fetched for all specified accounts
* Array of account IDs to use for filtering tools during fetchTools() calls.
* Only tools available on these accounts will be returned when fetchTools() is called.

Copilot uses AI. Check for mistakes.
* @example ['account-1', 'account-2']
*/
accountIds: string[];
}

/**
* Account configuration options - either single accountId or multiple accountIds, but not both
*/
type AccountConfig = SimplifyDeep<MergeExclusive<SingleAccountConfig, MultipleAccountsConfig>>;

/**
* 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<AccountConfig>;

/**
* Options for filtering tools when fetching from MCP
*/
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
Loading