Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 76 additions & 3 deletions src/tools/categories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* The final tool ordering presented to MCP clients is determined by tools-loader.ts,
* which also auto-injects get-actor-run and get-actor-output right after call-actor.
*/
import type { ToolCategory } from '../types.js';
import type { ToolEntry, UiMode } from '../types.js';
import { callActor } from './actor.js';
import { getDataset, getDatasetItems, getDatasetSchema } from './common/dataset.js';
import { getUserDatasetsList } from './common/dataset_collection.js';
Expand All @@ -21,11 +21,26 @@ import { getUserKeyValueStoresList } from './common/key_value_store_collection.j
import { abortActorRun, getActorRun, getActorRunLog } from './common/run.js';
import { getUserRunsList } from './common/run_collection.js';
import { searchApifyDocsTool } from './common/search-apify-docs.js';
import { defaultCallActor } from './default/call-actor.js';
import { defaultFetchActorDetails } from './default/fetch-actor-details.js';
import { defaultGetActorRun } from './default/get-actor-run.js';
import { defaultSearchActors } from './default/search-actors.js';
import { fetchActorDetailsTool } from './fetch-actor-details.js';
import { openaiCallActor } from './openai/call-actor.js';
import { openaiFetchActorDetails } from './openai/fetch-actor-details.js';
import { fetchActorDetailsInternalTool } from './openai/fetch-actor-details-internal.js';
import { openaiGetActorRun } from './openai/get-actor-run.js';
import { openaiSearchActors } from './openai/search-actors.js';
import { searchActorsInternalTool } from './openai/search-actors-internal.js';
import { searchActors } from './store_collection.js';

/**
* Static tool categories using adapter tools that dispatch at runtime based on uiMode.
*
* @deprecated Use {@link buildCategories} instead, which returns mode-resolved tool variants
* directly without runtime dispatching. This static map will be removed once the tools-loader
* is refactored to use buildCategories().
*/
export const toolCategories = {
experimental: [
addTool,
Expand Down Expand Up @@ -63,9 +78,67 @@ export const toolCategories = {
dev: [
getHtmlSkeleton,
],
};
} satisfies Record<string, ToolEntry[]>;

/**
* Canonical list of all tool category names, derived from the toolCategories map
* so there is a single source of truth for category definitions.
*/
export const CATEGORY_NAMES = Object.keys(toolCategories) as (keyof typeof toolCategories)[];

/** Map from category name to an array of tool entries. */
export type ToolCategoryMap = Record<(typeof CATEGORY_NAMES)[number], ToolEntry[]>;

/**
* Build tool categories for a given UI mode.
*
* Returns the same category names as {@link toolCategories}, but with mode-resolved
* tool variants: openai mode gets openai-specific implementations (async execution,
* widget metadata), default mode gets standard implementations.
*
* This eliminates the need for runtime adapter dispatch — each tool is the correct
* variant for its mode from the start.
*/
export function buildCategories(uiMode?: UiMode): ToolCategoryMap {
const isOpenai = uiMode === 'openai';
return {
experimental: [
addTool,
],
actors: isOpenai
? [openaiSearchActors, openaiFetchActorDetails, openaiCallActor]
: [defaultSearchActors, defaultFetchActorDetails, defaultCallActor],
ui: isOpenai
? [searchActorsInternalTool, fetchActorDetailsInternalTool]
: [],
docs: [
searchApifyDocsTool,
fetchApifyDocsTool,
],
runs: [
isOpenai ? openaiGetActorRun : defaultGetActorRun,
getUserRunsList,
getActorRunLog,
abortActorRun,
],
storage: [
getDataset,
getDatasetItems,
getDatasetSchema,
getActorOutput,
getKeyValueStore,
getKeyValueStoreKeys,
getKeyValueStoreRecord,
getUserDatasetsList,
getUserKeyValueStoresList,
],
dev: [
getHtmlSkeleton,
],
};
}

export const toolCategoriesEnabledByDefault: ToolCategory[] = [
export const toolCategoriesEnabledByDefault: (typeof CATEGORY_NAMES)[number][] = [
'actors',
'docs',
];
4 changes: 2 additions & 2 deletions src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { HelperTools } from '../const.js';
import type { ToolCategory } from '../types.js';
import { getExpectedToolsByCategories } from '../utils/tool-categories-helpers.js';
import { callActorGetDataset, getActorsAsTools } from './actor.js';
import { toolCategories, toolCategoriesEnabledByDefault } from './categories.js';
import { buildCategories, CATEGORY_NAMES, toolCategories, toolCategoriesEnabledByDefault } from './categories.js';

// Use string constants instead of importing tool objects to avoid circular dependency
export const unauthEnabledTools: string[] = [
Expand All @@ -13,7 +13,7 @@ export const unauthEnabledTools: string[] = [

// Re-export from categories.ts
// This is actually needed to avoid circular dependency issues
export { toolCategories, toolCategoriesEnabledByDefault };
export { buildCategories, CATEGORY_NAMES, toolCategories, toolCategoriesEnabledByDefault };

// Computed here (not in helper file) to avoid module initialization issues
export const defaultTools = getExpectedToolsByCategories(toolCategoriesEnabledByDefault);
Expand Down
4 changes: 2 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import type z from 'zod';
import type { ApifyClient } from './apify-client.js';
import type { ACTOR_PRICING_MODEL, TELEMETRY_ENV, TOOL_STATUS } from './const.js';
import type { ActorsMcpServer } from './mcp/server.js';
import type { toolCategories } from './tools/index.js';
import type { CATEGORY_NAMES } from './tools/categories.js';
import type { StructuredPricingInfo } from './utils/pricing-info.js';
import type { ProgressTracker } from './utils/progress.js';

Expand Down Expand Up @@ -230,7 +230,7 @@ export type PricingInfo = ActorRunPricingInfo & {
tieredPricing?: TieredPricing;
} | PricePerEventActorPricingInfo;

export type ToolCategory = keyof typeof toolCategories;
export type ToolCategory = (typeof CATEGORY_NAMES)[number];
/**
* Selector for tools input - can be a category key or a specific tool name.
*/
Expand Down
100 changes: 100 additions & 0 deletions tests/unit/tools.categories.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest';

import { HelperTools } from '../../src/const.js';
import { buildCategories, CATEGORY_NAMES, toolCategories } from '../../src/tools/index.js';
import type { ToolCategory, ToolEntry } from '../../src/types.js';

describe('CATEGORY_NAMES', () => {
it('should be derived from toolCategories keys', () => {
// CATEGORY_NAMES is derived from toolCategories via Object.keys(),
// so they are guaranteed to match. This test verifies the derivation is correct.
const staticKeys = Object.keys(toolCategories);
expect(CATEGORY_NAMES).toEqual(staticKeys);
});
});

describe('buildCategories', () => {
it('should return all category keys matching CATEGORY_NAMES', () => {
const defaultResult = buildCategories();
const openaiResult = buildCategories('openai');

for (const name of CATEGORY_NAMES) {
expect(defaultResult).toHaveProperty(name);
expect(openaiResult).toHaveProperty(name);
}
});

it('should return no undefined entries in any category (circular-init safety)', () => {
const defaultResult = buildCategories();
const openaiResult = buildCategories('openai');

for (const name of CATEGORY_NAMES) {
for (const tool of defaultResult[name]) {
expect(tool).toBeDefined();
expect(tool.name).toBeDefined();
}
for (const tool of openaiResult[name]) {
expect(tool).toBeDefined();
expect(tool.name).toBeDefined();
}
}
});

it('should return empty ui category in default mode', () => {
const result = buildCategories();
expect(result.ui).toEqual([]);
});

it('should return non-empty ui category in openai mode', () => {
const result = buildCategories('openai');
expect(result.ui.length).toBeGreaterThan(0);
});

it('should return different tool variants for actors category based on mode', () => {
const defaultResult = buildCategories();
const openaiResult = buildCategories('openai');

// Both modes should have the same tool names in actors category
const defaultNames = defaultResult.actors.map((t: ToolEntry) => t.name);
const openaiNames = openaiResult.actors.map((t: ToolEntry) => t.name);
expect(defaultNames).toEqual(openaiNames);

// But the actual tool objects should be different (different call implementations)
expect(defaultResult.actors[0]).not.toBe(openaiResult.actors[0]);
});

it('should return different get-actor-run variants based on mode', () => {
const defaultResult = buildCategories();
const openaiResult = buildCategories('openai');

const defaultGetRun = defaultResult.runs.find((t: ToolEntry) => t.name === HelperTools.ACTOR_RUNS_GET);
const openaiGetRun = openaiResult.runs.find((t: ToolEntry) => t.name === HelperTools.ACTOR_RUNS_GET);

expect(defaultGetRun).toBeDefined();
expect(openaiGetRun).toBeDefined();
// Different objects (different implementations)
expect(defaultGetRun).not.toBe(openaiGetRun);
});

it('should share identical tools for mode-independent categories', () => {
const defaultResult = buildCategories();
const openaiResult = buildCategories('openai');

const modeIndependentCategories: ToolCategory[] = ['experimental', 'docs', 'storage', 'dev'];
for (const cat of modeIndependentCategories) {
expect(defaultResult[cat]).toEqual(openaiResult[cat]);
}
});

it('should preserve tool ordering within categories', () => {
const result = buildCategories();
const actorNames = result.actors.map((t: ToolEntry) => t.name);

// Verify workflow order: search → details → call
expect(actorNames).toEqual([
HelperTools.STORE_SEARCH,
HelperTools.ACTOR_GET_DETAILS,
HelperTools.ACTOR_CALL,
]);
});
});