diff --git a/AGENTS.md b/AGENTS.md index e5c9040e..6e44d34f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,7 @@ The codebase is organized into logical modules: - Entry points: - `src/index.ts` - Main library export (`ActorsMcpServer` class) - - `src/index-internals.ts` - Internal exports for testing and advanced usage + - `src/index_internals.ts` - Internal exports for testing and advanced usage - `src/stdio.ts` - Standard input/output entry point (CLI, used for Docker) - `src/main.ts` - Actor entry point (for Apify platform) - `src/input.ts` - Input processing and validation @@ -195,7 +195,7 @@ We use **4 spaces** for indentation (configured in `.editorconfig`). - **Constants**: Use uppercase `SNAKE_CASE` for global, immutable constants (e.g., `ACTOR_MAX_MEMORY_MBYTES`, `SERVER_NAME`) - **Functions & Variables**: Use `camelCase` format (e.g., `fetchActorDetails`, `actorClient`) - **Classes, Types, Interfaces**: Use `PascalCase` format (e.g., `ActorsMcpServer`, `ActorDetailsResult`) -- **Files & Folders**: Use lowercase `snake_case` format (e.g., `actor_details.ts`, `key_value_store.ts`) +- **Files & Folders**: Use lowercase `snake_case` format (e.g., `actor_details.ts`, `key_value_store.ts`). **NEVER use kebab-case** (`kebab-case.ts`) for file or folder names — always use underscores, not hyphens. - **Booleans**: Prefix with `is`, `has`, or `should` (e.g., `isValid`, `hasFinished`, `shouldRetry`) - **Units**: Suffix with the unit of measure (e.g., `intervalMillis`, `maxMemoryBytes`) - **Date/Time**: Suffix with `At` (e.g., `createdAt`, `updatedAt`) @@ -283,10 +283,10 @@ We use **4 spaces** for indentation (configured in `.editorconfig`). ### Common patterns - **Tool implementation**: Tools are defined in `src/tools/` using Zod schemas for validation -- **Actor interaction**: Use `src/utils/apify-client.ts` for Apify API calls, never call Apify API directly +- **Actor interaction**: Use `src/utils/apify_client.ts` for Apify API calls, never call Apify API directly - **Error responses**: Return user-friendly error messages with suggestions - **Input validation**: Always validate tool inputs with Zod before processing -- **Caching**: Use TTL-based caching for Actor schemas and details (see `src/utils/ttl-lru.ts`) +- **Caching**: Use TTL-based caching for Actor schemas and details (see `src/utils/ttl_lru.ts`) - **Constants and Tool Names**: Always use constants and never magic or hardcoded values. When referring to tools, ALWAYS use the `HelperTools` enum. - **Exception**: Integration tests (`tests/integration/`) must use hardcoded strings for tool names. This ensures tests fail if a tool is renamed, helping to prevent accidental breaking changes. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 07f22e14..b54e10ae 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -29,7 +29,7 @@ tests/ Key entry points: - `src/index.ts` - Main library export (`ActorsMcpServer` class) -- `src/index-internals.ts` - Internal exports for testing / advanced usage +- `src/index_internals.ts` - Internal exports for testing / advanced usage - `src/stdio.ts` - Standard input/output (CLI) entry point - `src/main.ts` - Actor entry point (standby server / debugging) - `src/input.ts` - Input processing and validation diff --git a/evals/2025-11-04-failed-cases-analysis.md b/evals/2025_11_04_failed_cases_analysis.md similarity index 100% rename from evals/2025-11-04-failed-cases-analysis.md rename to evals/2025_11_04_failed_cases_analysis.md diff --git a/evals/create-dataset.ts b/evals/create_dataset.ts similarity index 99% rename from evals/create-dataset.ts rename to evals/create_dataset.ts index 99954eb4..63aa077c 100644 --- a/evals/create-dataset.ts +++ b/evals/create_dataset.ts @@ -15,7 +15,7 @@ import { hideBin } from 'yargs/helpers'; import log from '@apify/log'; import { sanitizeHeaderValue, validatePhoenixEnvVars } from './config.js'; -import { loadTestCases, filterByCategory, filterById, type TestCase } from './evaluation-utils.js'; +import { loadTestCases, filterByCategory, filterById, type TestCase } from './evaluation_utils.js'; // Set log level to debug log.setLevel(log.LEVELS.INFO); diff --git a/evals/eval-single.ts b/evals/eval_single.ts similarity index 98% rename from evals/eval-single.ts rename to evals/eval_single.ts index 03b9dc10..de411aed 100755 --- a/evals/eval-single.ts +++ b/evals/eval_single.ts @@ -8,7 +8,7 @@ import { createToolSelectionLLMEvaluator, loadTestCases, filterById, type TestCase -} from './evaluation-utils.js'; +} from './evaluation_utils.js'; import { PASS_THRESHOLD, sanitizeHeaderValue } from './config.js'; dotenv.config({ path: '.env' }); diff --git a/evals/evaluation-utils.ts b/evals/evaluation_utils.ts similarity index 95% rename from evals/evaluation-utils.ts rename to evals/evaluation_utils.ts index 880bf587..855b98d8 100644 --- a/evals/evaluation-utils.ts +++ b/evals/evaluation_utils.ts @@ -9,8 +9,8 @@ import { createClassifierFn } from '@arizeai/phoenix-evals'; import log from '@apify/log'; -import { ApifyClient } from '../src/apify-client.js'; -import { getToolPublicFieldOnly, processParamsGetTools } from '../src/index-internals.js'; +import { ApifyClient } from '../src/apify_client.js'; +import { getToolPublicFieldOnly, processParamsGetTools } from '../src/index_internals.js'; import type { ToolBase, ToolEntry } from '../src/types.js'; import { SYSTEM_PROMPT, @@ -20,8 +20,8 @@ import { TEMPERATURE, sanitizeHeaderValue } from './config.js'; -import { loadTestCases as loadTestCasesShared, filterByCategory, filterById } from './shared/test-case-loader.js'; -import { transformToolsToOpenAIFormat } from './shared/openai-tools.js'; +import { loadTestCases as loadTestCasesShared, filterByCategory, filterById } from './shared/test_case_loader.js'; +import { transformToolsToOpenAIFormat } from './shared/openai_tools.js'; import type { ToolSelectionTestCase, TestData } from './shared/types.js'; // Re-export types for backwards compatibility @@ -29,7 +29,7 @@ export type TestCase = ToolSelectionTestCase; export type { TestData } from './shared/types.js'; // Re-export shared functions for backwards compatibility -export { filterByCategory, filterById } from './shared/test-case-loader.js'; +export { filterByCategory, filterById } from './shared/test_case_loader.js'; type ExampleInputOnly = { input: Record, metadata?: Record, output?: never }; diff --git a/evals/run-evaluation.ts b/evals/run_evaluation.ts similarity index 99% rename from evals/run-evaluation.ts rename to evals/run_evaluation.ts index db0edb57..2a32c54e 100644 --- a/evals/run-evaluation.ts +++ b/evals/run_evaluation.ts @@ -20,7 +20,7 @@ import { loadTools, createOpenRouterTask, createToolSelectionLLMEvaluator -} from './evaluation-utils.js'; +} from './evaluation_utils.js'; import { DATASET_NAME, MODELS_TO_EVALUATE, diff --git a/evals/shared/line-range-filter.ts b/evals/shared/line_range_filter.ts similarity index 95% rename from evals/shared/line-range-filter.ts rename to evals/shared/line_range_filter.ts index 6264ec7d..22ab2338 100644 --- a/evals/shared/line-range-filter.ts +++ b/evals/shared/line_range_filter.ts @@ -2,7 +2,7 @@ * Filter test cases by line ranges */ -import type { LineRange } from './line-range-parser.js'; +import type { LineRange } from './line_range_parser.js'; /** * Type for test cases with line number metadata diff --git a/evals/shared/line-range-parser.ts b/evals/shared/line_range_parser.ts similarity index 100% rename from evals/shared/line-range-parser.ts rename to evals/shared/line_range_parser.ts diff --git a/evals/shared/openai-tools.ts b/evals/shared/openai_tools.ts similarity index 100% rename from evals/shared/openai-tools.ts rename to evals/shared/openai_tools.ts diff --git a/evals/shared/test-case-loader.ts b/evals/shared/test_case_loader.ts similarity index 100% rename from evals/shared/test-case-loader.ts rename to evals/shared/test_case_loader.ts diff --git a/evals/test-cases.json b/evals/test_cases.json similarity index 100% rename from evals/test-cases.json rename to evals/test_cases.json diff --git a/evals/workflows/conversation-executor.ts b/evals/workflows/conversation_executor.ts similarity index 97% rename from evals/workflows/conversation-executor.ts rename to evals/workflows/conversation_executor.ts index a17eab82..714eb189 100644 --- a/evals/workflows/conversation-executor.ts +++ b/evals/workflows/conversation_executor.ts @@ -6,10 +6,10 @@ // eslint-disable-next-line import/extensions import type { ChatCompletionMessageParam, ChatCompletionTool } from 'openai/resources/chat/completions'; -import { mcpToolsToOpenAiTools } from '../shared/openai-tools.js'; +import { mcpToolsToOpenAiTools } from '../shared/openai_tools.js'; import { AGENT_SYSTEM_PROMPT, MAX_CONVERSATION_TURNS, MODELS } from './config.js'; -import type { LlmClient } from './llm-client.js'; -import type { McpClient } from './mcp-client.js'; +import type { LlmClient } from './llm_client.js'; +import type { McpClient } from './mcp_client.js'; import type { ConversationHistory, ConversationTurn } from './types.js'; export type ConversationExecutorOptions = { diff --git a/evals/workflows/llm-client.ts b/evals/workflows/llm_client.ts similarity index 100% rename from evals/workflows/llm-client.ts rename to evals/workflows/llm_client.ts diff --git a/evals/workflows/mcp-client.ts b/evals/workflows/mcp_client.ts similarity index 100% rename from evals/workflows/mcp-client.ts rename to evals/workflows/mcp_client.ts diff --git a/evals/workflows/output-formatter.ts b/evals/workflows/output_formatter.ts similarity index 97% rename from evals/workflows/output-formatter.ts rename to evals/workflows/output_formatter.ts index 72252f78..cd6d95cb 100644 --- a/evals/workflows/output-formatter.ts +++ b/evals/workflows/output_formatter.ts @@ -2,9 +2,9 @@ * Output formatter for evaluation results */ -import type { WorkflowTestCase } from './test-cases-loader.js'; +import type { WorkflowTestCase } from './test_cases_loader.js'; import type { ConversationHistory } from './types.js'; -import type { JudgeResult } from './workflow-judge.js'; +import type { JudgeResult } from './workflow_judge.js'; /** * Single evaluation result diff --git a/evals/workflows/results-writer.ts b/evals/workflows/results_writer.ts similarity index 99% rename from evals/workflows/results-writer.ts rename to evals/workflows/results_writer.ts index a265fd2c..5cf0a0be 100644 --- a/evals/workflows/results-writer.ts +++ b/evals/workflows/results_writer.ts @@ -6,7 +6,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname } from 'node:path'; -import type { EvaluationResult, ResultsDatabase, TestResultRecord } from './output-formatter.js'; +import type { EvaluationResult, ResultsDatabase, TestResultRecord } from './output_formatter.js'; /** * Build composite key for storing results diff --git a/evals/workflows/run-workflow-evals.ts b/evals/workflows/run_workflow_evals.ts similarity index 95% rename from evals/workflows/run-workflow-evals.ts rename to evals/workflows/run_workflow_evals.ts index 093157d5..b32df14a 100644 --- a/evals/workflows/run-workflow-evals.ts +++ b/evals/workflows/run_workflow_evals.ts @@ -18,23 +18,23 @@ import pLimit from 'p-limit'; import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; -import { filterByLineRanges } from '../shared/line-range-filter.js'; -import type { LineRange } from '../shared/line-range-parser.js'; -import { checkRangesOutOfBounds, parseLineRanges, validateLineRanges } from '../shared/line-range-parser.js'; +import { filterByLineRanges } from '../shared/line_range_filter.js'; +import type { LineRange } from '../shared/line_range_parser.js'; +import { checkRangesOutOfBounds, parseLineRanges, validateLineRanges } from '../shared/line_range_parser.js'; import { DEFAULT_TOOL_TIMEOUT_SECONDS, MODELS } from './config.js'; -import { executeConversation } from './conversation-executor.js'; -import { LlmClient } from './llm-client.js'; -import { McpClient } from './mcp-client.js'; -import type { EvaluationResult } from './output-formatter.js'; -import { formatDetailedResult, formatResultsTable } from './output-formatter.js'; +import { executeConversation } from './conversation_executor.js'; +import { LlmClient } from './llm_client.js'; +import { McpClient } from './mcp_client.js'; +import type { EvaluationResult } from './output_formatter.js'; +import { formatDetailedResult, formatResultsTable } from './output_formatter.js'; import { loadResultsDatabase, saveResultsDatabase, updateResultsWithEvaluations, -} from './results-writer.js'; -import type { WorkflowTestCase, WorkflowTestCaseWithLineNumbers } from './test-cases-loader.js'; -import { filterTestCases, loadTestCases, loadTestCasesWithLineNumbers } from './test-cases-loader.js'; -import { evaluateConversation } from './workflow-judge.js'; +} from './results_writer.js'; +import type { WorkflowTestCase, WorkflowTestCaseWithLineNumbers } from './test_cases_loader.js'; +import { filterTestCases, loadTestCases, loadTestCasesWithLineNumbers } from './test_cases_loader.js'; +import { evaluateConversation } from './workflow_judge.js'; type CliArgs = { category?: string; diff --git a/evals/workflows/test-cases.json b/evals/workflows/test_cases.json similarity index 100% rename from evals/workflows/test-cases.json rename to evals/workflows/test_cases.json diff --git a/evals/workflows/test-cases-loader.ts b/evals/workflows/test_cases_loader.ts similarity index 97% rename from evals/workflows/test-cases-loader.ts rename to evals/workflows/test_cases_loader.ts index b4e12d47..172e27ea 100644 --- a/evals/workflows/test-cases-loader.ts +++ b/evals/workflows/test_cases_loader.ts @@ -6,8 +6,8 @@ import fs from 'node:fs'; import path from 'node:path'; -import type { TestCaseWithLineNumbers } from '../shared/line-range-filter.js'; -import { filterTestCases as filterTestCasesShared, loadTestCases as loadTestCasesShared } from '../shared/test-case-loader.js'; +import type { TestCaseWithLineNumbers } from '../shared/line_range_filter.js'; +import { filterTestCases as filterTestCasesShared, loadTestCases as loadTestCasesShared } from '../shared/test_case_loader.js'; import type { WorkflowTestCase } from '../shared/types.js'; // Re-export WorkflowTestCase type for backwards compatibility diff --git a/evals/workflows/workflow-judge.ts b/evals/workflows/workflow_judge.ts similarity index 98% rename from evals/workflows/workflow-judge.ts rename to evals/workflows/workflow_judge.ts index 51816f20..e76be37f 100644 --- a/evals/workflows/workflow-judge.ts +++ b/evals/workflows/workflow_judge.ts @@ -8,7 +8,7 @@ import type { ResponseFormatJSONSchema } from 'openai/resources/shared'; import type { WorkflowTestCase } from '../shared/types.js'; import { JUDGE_PROMPT_TEMPLATE, MODELS } from './config.js'; -import type { LlmClient } from './llm-client.js'; +import type { LlmClient } from './llm_client.js'; import type { ConversationHistory } from './types.js'; /** diff --git a/package.json b/package.json index 6832db5f..97ce0df5 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,8 @@ "main": "dist/index.js", "exports": { ".": "./dist/index.js", - "./internals": "./dist/index-internals.js", - "./internals.js": "./dist/index-internals.js", + "./internals": "./dist/index_internals.js", + "./internals.js": "./dist/index_internals.js", "./manifest.json": "./manifest.json" }, "bin": { @@ -83,7 +83,7 @@ }, "scripts": { "start": "npm run start:standby", - "dev": "node scripts/dev-standby.js", + "dev": "node scripts/dev_standby.js", "start:standby": "APIFY_META_ORIGIN=\"STANDBY\" tsx src/main.ts", "build": "npm run build:core && npm run build:web", "build:core": "tsc -b src", @@ -95,14 +95,14 @@ "lint:fix": "eslint . --fix", "type-check": "tsc -p tsconfig.json --noEmit", "check": "npm run type-check && npm run lint", - "check:widgets": "tsx scripts/check-widgets.ts", + "check:widgets": "tsx scripts/check_widgets.ts", "test": "npm run test:unit", "test:unit": "vitest run tests/unit", "test:integration": "npm run build && vitest run tests/integration", "inspector:stdio": "npx @modelcontextprotocol/inspector -e APIFY_TOKEN=$APIFY_TOKEN -- node dist/stdio.js", - "evals:create-dataset": "tsx evals/create-dataset.ts", - "evals:run": "tsx evals/run-evaluation.ts", - "evals:workflow": "npm run build && tsx evals/workflows/run-workflow-evals.ts" + "evals:create-dataset": "tsx evals/create_dataset.ts", + "evals:run": "tsx evals/run_evaluation.ts", + "evals:workflow": "npm run build && tsx evals/workflows/run_workflow_evals.ts" }, "author": "Apify", "license": "MIT" diff --git a/res/index.md b/res/index.md index 31a80d68..83b40050 100644 --- a/res/index.md +++ b/res/index.md @@ -12,7 +12,7 @@ Technical analysis of Algolia search API responses for each documentation source - Recommendations for response processing logic - **Use case**: Understand what data is actually returned by Algolia to inform simplification decisions -### [mcp-server-refactor-analysis.md](./mcp-server-refactor-analysis.md) +### [mcp_server_refactor_analysis.md](./mcp_server_refactor_analysis.md) Implementation plan for migrating from low-level `Server` to high-level `McpServer` API. **Structure:** @@ -30,19 +30,19 @@ Implementation plan for migrating from low-level `Server` to high-level `McpServ - Testing strategy - **Use case**: Reference for implementing the MCP SDK migration -### [mcp-resources-analysis.md](./mcp-resources-analysis.md) +### [mcp_resources_analysis.md](./mcp_resources_analysis.md) Current MCP resources behavior and constraints (Skyfire readme and OpenAI widgets). - Handler locations and low-level MCP usage - Resource list/read behavior and error handling - **Use case**: Baseline reference before refactoring resources -### [mcp-resources-refactor-analysis.md](./mcp-resources-refactor-analysis.md) +### [mcp_resources_refactor_analysis.md](./mcp_resources_refactor_analysis.md) Refactor plan for modularizing existing resource handling (no new resources). - Minimal resource service API (list/read/templates) - Behavior-preserving steps and non-goals - **Use case**: Step-by-step guide for refactoring without behavior change -### [tool-mode-separation-plan.md](./tool-mode-separation-plan.md) +### [tool_mode_separation_plan.md](./tool_mode_separation_plan.md) Implementation plan for separating UI-mode (OpenAI) and normal-mode tool behavior into independent modules. **Key approach:** Actor Executor pattern + separate tool definitions per mode + shared core logic layer. @@ -58,7 +58,7 @@ Implementation plan for separating UI-mode (OpenAI) and normal-mode tool behavio - Directory structure and complete file manifest with PR assignments - **Use case**: Reference for implementing the UI/normal mode tool separation -### [patterns-for-simplification.md](./patterns-for-simplification.md) +### [patterns_for_simplification.md](./patterns_for_simplification.md) Analysis of patterns from the **official TypeScript MCP SDK** and **FastMCP** framework that could simplify the codebase. **Key patterns identified:** diff --git a/res/mcp-resources-analysis.md b/res/mcp_resources_analysis.md similarity index 100% rename from res/mcp-resources-analysis.md rename to res/mcp_resources_analysis.md diff --git a/res/mcp-resources-refactor-analysis.md b/res/mcp_resources_refactor_analysis.md similarity index 100% rename from res/mcp-resources-refactor-analysis.md rename to res/mcp_resources_refactor_analysis.md diff --git a/res/mcp-server-refactor-analysis.md b/res/mcp_server_refactor_analysis.md similarity index 100% rename from res/mcp-server-refactor-analysis.md rename to res/mcp_server_refactor_analysis.md diff --git a/res/patterns-for-simplification.md b/res/patterns_for_simplification.md similarity index 100% rename from res/patterns-for-simplification.md rename to res/patterns_for_simplification.md diff --git a/res/tool-mode-separation-plan.md b/res/tool_mode_separation_plan.md similarity index 100% rename from res/tool-mode-separation-plan.md rename to res/tool_mode_separation_plan.md diff --git a/scripts/check-widgets.ts b/scripts/check_widgets.ts similarity index 100% rename from scripts/check-widgets.ts rename to scripts/check_widgets.ts diff --git a/scripts/dev-standby.js b/scripts/dev_standby.js similarity index 100% rename from scripts/dev-standby.js rename to scripts/dev_standby.js diff --git a/src/actor/server.ts b/src/actor/server.ts index 722bf46b..bd7b3850 100644 --- a/src/actor/server.ts +++ b/src/actor/server.ts @@ -14,9 +14,10 @@ import express from 'express'; import log from '@apify/log'; import { parseBooleanOrNull } from '@apify/utilities'; -import { ApifyClient } from '../apify-client.js'; +import { ApifyClient } from '../apify_client.js'; import { ActorsMcpServer } from '../mcp/server.js'; -import type { ApifyRequestParams, UiMode } from '../types.js'; +import type { ApifyRequestParams } from '../types.js'; +import { parseUiMode } from '../types.js'; import { getHelpMessage, HEADER_READINESS_PROBE, Routes, TransportType } from './const.js'; import { getActorRunData } from './utils.js'; @@ -90,8 +91,7 @@ export function createExpressApp( ?? parseBooleanOrNull(process.env.TELEMETRY_ENABLED) ?? true; - const uiModeParam = urlParams.get('ui') as UiMode | undefined; - const uiMode = uiModeParam ?? process.env.UI_MODE as UiMode | undefined; + const uiMode = parseUiMode(urlParams.get('ui')) ?? parseUiMode(process.env.UI_MODE); // Extract payment mode parameter - if payment=skyfire, enable skyfire mode const paymentParam = urlParams.get('payment'); @@ -223,8 +223,7 @@ export function createExpressApp( ?? parseBooleanOrNull(process.env.TELEMETRY_ENABLED) ?? true; - const uiModeParam = urlParams.get('ui') as UiMode | undefined; - const uiMode = uiModeParam ?? process.env.UI_MODE as UiMode | undefined; + const uiMode = parseUiMode(urlParams.get('ui')) ?? parseUiMode(process.env.UI_MODE); // Extract payment mode parameter - if payment=skyfire, enable skyfire mode const paymentParam = urlParams.get('payment'); diff --git a/src/apify-client.ts b/src/apify_client.ts similarity index 100% rename from src/apify-client.ts rename to src/apify_client.ts diff --git a/src/index-internals.ts b/src/index_internals.ts similarity index 68% rename from src/index-internals.ts rename to src/index_internals.ts index 5c122e2a..ec9b80d6 100644 --- a/src/index-internals.ts +++ b/src/index_internals.ts @@ -2,20 +2,21 @@ This file provides essential internal functions for Apify MCP servers, serving as an internal library. */ -import { ApifyClient } from './apify-client.js'; +import { ApifyClient } from './apify_client.js'; import { APIFY_FAVICON_URL, defaults, HelperTools, SERVER_NAME, SERVER_TITLE } from './const.js'; import { processParamsGetTools } from './mcp/utils.js'; import { getServerCard } from './server_card.js'; -import { addTool } from './tools/common/helpers.js'; -import { defaultTools, getActorsAsTools, getUnauthEnabledToolCategories, toolCategories, +import { addTool } from './tools/common/add_actor.js'; +import { getActorsAsTools, getCategoryTools, getDefaultTools, getUnauthEnabledToolCategories, toolCategoriesEnabledByDefault, unauthEnabledTools } from './tools/index.js'; import { actorNameToToolName } from './tools/utils.js'; -import type { ActorStore, ServerCard, ToolCategory, UiMode } from './types.js'; +import type { ActorStore, ServerCard, ServerMode, ToolCategory, UiMode } from './types.js'; +import { parseUiMode, SERVER_MODES } from './types.js'; import { parseCommaSeparatedList, parseQueryParamList, readJsonFile } from './utils/generic.js'; import { redactSkyfirePayId } from './utils/logging.js'; -import { getExpectedToolNamesByCategories } from './utils/tool-categories-helpers.js'; +import { getExpectedToolNamesByCategories } from './utils/tool_categories_helpers.js'; import { getToolPublicFieldOnly } from './utils/tools.js'; -import { TTLLRUCache } from './utils/ttl-lru.js'; +import { TTLLRUCache } from './utils/ttl_lru.js'; export { APIFY_FAVICON_URL, @@ -28,9 +29,12 @@ export { SERVER_NAME, SERVER_TITLE, defaults, - defaultTools, + getDefaultTools, addTool, - toolCategories, + getCategoryTools, + parseUiMode, + SERVER_MODES, + type ServerMode, toolCategoriesEnabledByDefault, type ActorStore, type ServerCard, diff --git a/src/main.ts b/src/main.ts index 089756b4..615e3aa3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,7 +9,7 @@ import type { ActorCallOptions } from 'apify-client'; import log from '@apify/log'; import { createExpressApp } from './actor/server.js'; -import { ApifyClient } from './apify-client.js'; +import { ApifyClient } from './apify_client.js'; import { processInput } from './input.js'; import { callActorGetDataset } from './tools/index.js'; import type { Input } from './types.js'; diff --git a/src/mcp/actors.ts b/src/mcp/actors.ts index 64d62991..79507878 100644 --- a/src/mcp/actors.ts +++ b/src/mcp/actors.ts @@ -1,6 +1,6 @@ import type { ActorDefinition } from 'apify-client'; -import { ApifyClient } from '../apify-client.js'; +import { ApifyClient } from '../apify_client.js'; import { MCP_STREAMABLE_ENDPOINT } from '../const.js'; import type { ActorDefinitionPruned } from '../types.js'; import { parseCommaSeparatedList } from '../utils/generic.js'; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index bc8b7048..85b58f73 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -34,7 +34,7 @@ import type { ValidateFunction } from 'ajv'; import log from '@apify/log'; import { parseBooleanOrNull } from '@apify/utilities'; -import { ApifyClient, createApifyClientWithSkyfireSupport } from '../apify-client.js'; +import { ApifyClient, createApifyClientWithSkyfireSupport } from '../apify_client.js'; import { ALLOWED_TASK_TOOL_EXECUTION_MODES, APIFY_MCP_URL, @@ -43,9 +43,6 @@ import { HelperTools, SERVER_NAME, SERVER_VERSION, - SKYFIRE_ENABLED_TOOLS, - SKYFIRE_PAY_ID_PROPERTY_DESCRIPTION, - SKYFIRE_TOOL_INSTRUCTIONS, TOOL_STATUS, } from '../const.js'; import { prompts } from '../prompts/index.js'; @@ -53,9 +50,9 @@ import { createResourceService } from '../resources/resource_service.js'; import type { AvailableWidget } from '../resources/widgets.js'; import { resolveAvailableWidgets } from '../resources/widgets.js'; import { getTelemetryEnv, trackToolCall } from '../telemetry.js'; -import { defaultActorExecutor } from '../tools/default/actor-executor.js'; -import { defaultTools, getActorsAsTools, toolCategories } from '../tools/index.js'; -import { openaiActorExecutor } from '../tools/openai/actor-executor.js'; +import { defaultActorExecutor } from '../tools/default/actor_executor.js'; +import { getActorsAsTools, getCategoryTools, getDefaultTools } from '../tools/index.js'; +import { openaiActorExecutor } from '../tools/openai/actor_executor.js'; import { decodeDotPropertyNames } from '../tools/utils.js'; import type { ActorExecutor, @@ -65,6 +62,7 @@ import type { ActorTool, ApifyRequestParams, HelperTool, + ServerMode, TelemetryEnv, ToolCallTelemetryProperties, ToolEntry, @@ -73,16 +71,22 @@ import type { import { logHttpError, redactSkyfirePayId } from '../utils/logging.js'; import { buildMCPResponse } from '../utils/mcp.js'; import { createProgressTracker } from '../utils/progress.js'; -import { getServerInstructions } from '../utils/server-instructions.js'; +import { getServerInstructions } from '../utils/server-instructions/index.js'; import { validateSkyfirePayId } from '../utils/skyfire.js'; -import { getToolStatusFromError } from '../utils/tool-status.js'; -import { cloneToolEntry, getToolPublicFieldOnly } from '../utils/tools.js'; -import { getUserIdFromTokenCached } from '../utils/userid-cache.js'; +import { getToolStatusFromError } from '../utils/tool_status.js'; +import { applySkyfireAugmentation, getToolPublicFieldOnly } from '../utils/tools.js'; +import { getUserIdFromTokenCached } from '../utils/userid_cache.js'; import { getPackageVersion } from '../utils/version.js'; import { connectMCPClient } from './client.js'; import { EXTERNAL_TOOL_CALL_TIMEOUT_MSEC, LOG_LEVEL_MAP } from './const.js'; import { isTaskCancelled, processParamsGetTools } from './utils.js'; +/** Mode → actor executor. Add new modes here. */ +const actorExecutorsByMode: Record = { + default: defaultActorExecutor, + openai: openaiActorExecutor, +}; + type ToolsChangedHandler = (toolNames: string[]) => void; /** @@ -97,6 +101,8 @@ export class ActorsMcpServer { public readonly options: ActorsMcpServerOptions; public readonly taskStore: TaskStore; public readonly actorStore?: ActorStore; + /** Resolved server mode — normalized once at construction from options.uiMode. */ + public readonly serverMode: ServerMode; /** Mode-specific executor for direct actor tools (`type: 'actor'`). */ private readonly actorExecutor: ActorExecutor; @@ -119,9 +125,8 @@ export class ActorsMcpServer { throw new Error('Task store must be provided for non-stdio transport types'); } this.actorStore = options.actorStore; - this.actorExecutor = options.uiMode === 'openai' - ? openaiActorExecutor - : defaultActorExecutor; + this.serverMode = options.uiMode ?? 'default'; + this.actorExecutor = actorExecutorsByMode[this.serverMode]; const { setupSigintHandler = true } = options; this.server = new Server( @@ -153,7 +158,7 @@ export class ActorsMcpServer { prompts: { }, logging: {}, }, - instructions: getServerInstructions(options.uiMode), + instructions: getServerInstructions(this.serverMode), }, ); this.setupTelemetry(); @@ -273,8 +278,8 @@ export class ActorsMcpServer { const actorsToLoad: string[] = []; const toolsToLoad: ToolEntry[] = []; const internalToolMap = new Map([ - ...defaultTools, - ...Object.values(toolCategories).flat(), + ...getDefaultTools(this.serverMode), + ...Object.values(getCategoryTools(this.serverMode)).flat(), ].map((tool) => [tool.name, tool])); for (const tool of toolNames) { @@ -320,7 +325,7 @@ export class ActorsMcpServer { * Used primarily for SSE. */ public async loadToolsFromUrl(url: string, apifyClient: ApifyClient) { - const tools = await processParamsGetTools(url, apifyClient, this.options.uiMode, this.actorStore); + const tools = await processParamsGetTools(url, apifyClient, this.serverMode, this.actorStore); if (tools.length > 0) { log.debug('Loading tools from query parameters'); this.upsertTools(tools, false); @@ -349,40 +354,9 @@ export class ActorsMcpServer { * @returns Array of added/updated tool wrappers */ public upsertTools(tools: ToolEntry[], shouldNotifyToolsChangedHandler = false) { - if (this.options.skyfireMode) { - for (const wrap of tools) { - // Clone the tool before modifying it to avoid affecting shared objects - const clonedWrap = cloneToolEntry(wrap); - let modified = false; - - // Handle Skyfire mode modifications - if (this.options.skyfireMode && (wrap.type === 'actor' - || (wrap.type === 'internal' && SKYFIRE_ENABLED_TOOLS.has(wrap.name as HelperTools)))) { - // Add Skyfire instructions to description if not already present - if (clonedWrap.description && !clonedWrap.description.includes(SKYFIRE_TOOL_INSTRUCTIONS)) { - clonedWrap.description += `\n\n${SKYFIRE_TOOL_INSTRUCTIONS}`; - } - // Add skyfire-pay-id property if not present - if (clonedWrap.inputSchema && 'properties' in clonedWrap.inputSchema) { - const props = clonedWrap.inputSchema.properties as Record; - if (!props['skyfire-pay-id']) { - props['skyfire-pay-id'] = { - type: 'string', - description: SKYFIRE_PAY_ID_PROPERTY_DESCRIPTION, - }; - } - } - modified = true; - } - - // Store the cloned and modified tool only if modifications were made - this.tools.set(clonedWrap.name, modified ? clonedWrap : wrap); - } - } else { - // No skyfire mode - store tools as-is - for (const tool of tools) { - this.tools.set(tool.name, tool); - } + for (const tool of tools) { + const stored = this.options.skyfireMode ? applySkyfireAugmentation(tool) : tool; + this.tools.set(stored.name, stored); } if (shouldNotifyToolsChangedHandler) this.notifyToolsChangedHandler(); return tools; @@ -447,7 +421,7 @@ export class ActorsMcpServer { private setupResourceHandlers(): void { const resourceService = createResourceService({ skyfireMode: this.options.skyfireMode, - uiMode: this.options.uiMode, + mode: this.serverMode, getAvailableWidgets: () => this.availableWidgets, }); @@ -606,7 +580,7 @@ export class ActorsMcpServer { */ this.server.setRequestHandler(ListToolsRequestSchema, async () => { const tools = Array.from(this.tools.values()).map((tool) => getToolPublicFieldOnly(tool, { - uiMode: this.options.uiMode, + mode: this.serverMode, filterOpenAiMeta: true, })); return { tools }; @@ -1173,7 +1147,7 @@ Please verify the tool name and ensure the tool is properly registered.`; * Resolves widgets and determines which ones are ready to be served. */ private async resolveWidgets(): Promise { - if (this.options.uiMode !== 'openai') { + if (this.serverMode !== 'openai') { return; } diff --git a/src/mcp/utils.ts b/src/mcp/utils.ts index 0744f091..8832d9d5 100644 --- a/src/mcp/utils.ts +++ b/src/mcp/utils.ts @@ -5,8 +5,8 @@ import type { TaskStore } from '@modelcontextprotocol/sdk/experimental/tasks/int import type { ApifyClient } from 'apify-client'; import { processInput } from '../input.js'; -import type { ActorStore, Input, UiMode } from '../types.js'; -import { loadToolsFromInput } from '../utils/tools-loader.js'; +import type { ActorStore, Input, ServerMode } from '../types.js'; +import { loadToolsFromInput } from '../utils/tools_loader.js'; import { MAX_TOOL_NAME_LENGTH, SERVER_ID_LENGTH } from './const.js'; /** @@ -41,11 +41,11 @@ export function getProxyMCPServerToolName(url: string, toolName: string): string * If URL contains query parameter `actors`, return tools from Actors otherwise return null. * @param url The URL to process * @param apifyClient The Apify client instance - * @param uiMode UI mode from server options + * @param mode Server mode for tool variant resolution */ -export async function processParamsGetTools(url: string, apifyClient: ApifyClient, uiMode?: UiMode, actorStore?: ActorStore) { +export async function processParamsGetTools(url: string, apifyClient: ApifyClient, mode: ServerMode, actorStore?: ActorStore) { const input = parseInputParamsFromUrl(url); - return await loadToolsFromInput(input, apifyClient, uiMode, actorStore); + return await loadToolsFromInput(input, apifyClient, mode, actorStore); } export function parseInputParamsFromUrl(url: string): Input { diff --git a/src/prompts/index.ts b/src/prompts/index.ts index 5352abce..e5398310 100644 --- a/src/prompts/index.ts +++ b/src/prompts/index.ts @@ -1,5 +1,5 @@ import type { PromptBase } from '../types.js'; -import { latestNewsOnTopicPrompt } from './latest-news-on-topic.js'; +import { latestNewsOnTopicPrompt } from './latest_news_on_topic.js'; /** * List of all enabled prompts. diff --git a/src/prompts/latest-news-on-topic.ts b/src/prompts/latest_news_on_topic.ts similarity index 100% rename from src/prompts/latest-news-on-topic.ts rename to src/prompts/latest_news_on_topic.ts diff --git a/src/resources/resource_service.ts b/src/resources/resource_service.ts index 15f56813..a48a22ec 100644 --- a/src/resources/resource_service.ts +++ b/src/resources/resource_service.ts @@ -3,7 +3,7 @@ import type { ListResourcesResult, ListResourceTemplatesResult, ReadResourceResu import log from '@apify/log'; import { SKYFIRE_README_CONTENT } from '../const.js'; -import type { UiMode } from '../types.js'; +import type { ServerMode } from '../types.js'; import type { AvailableWidget } from './widgets.js'; type ExtendedResourceContents = TextResourceContents & { @@ -23,12 +23,12 @@ type ResourceService = { type ResourceServiceOptions = { skyfireMode?: boolean; - uiMode?: UiMode; + mode: ServerMode; getAvailableWidgets: () => Map; }; export function createResourceService(options: ResourceServiceOptions): ResourceService { - const { skyfireMode, uiMode, getAvailableWidgets } = options; + const { skyfireMode, mode, getAvailableWidgets } = options; const listResources = async (): Promise => { const resources: Resource[] = []; @@ -43,7 +43,7 @@ export function createResourceService(options: ResourceServiceOptions): Resource }); } - if (uiMode === 'openai') { + if (mode === 'openai') { for (const widget of getAvailableWidgets().values()) { if (!widget.exists) { continue; @@ -72,7 +72,7 @@ export function createResourceService(options: ResourceServiceOptions): Resource }; } - if (uiMode === 'openai' && uri.startsWith('ui://widget/')) { + if (mode === 'openai' && uri.startsWith('ui://widget/')) { const widget = getAvailableWidgets().get(uri); if (!widget || !widget.exists) { diff --git a/src/state.ts b/src/state.ts index 8e995829..a9da2724 100644 --- a/src/state.ts +++ b/src/state.ts @@ -9,7 +9,7 @@ import { MCP_SERVER_CACHE_TTL_SECS, } from './const.js'; import type { ActorDefinitionWithInfo, ApifyDocsSearchResult } from './types.js'; -import { TTLLRUCache } from './utils/ttl-lru.js'; +import { TTLLRUCache } from './utils/ttl_lru.js'; export const actorDefinitionPrunedCache = new TTLLRUCache(ACTOR_CACHE_MAX_SIZE, ACTOR_CACHE_TTL_SECS); export const searchApifyDocsCache = new TTLLRUCache(APIFY_DOCS_CACHE_MAX_SIZE, APIFY_DOCS_CACHE_TTL_SECS); diff --git a/src/stdio.ts b/src/stdio.ts index 25024010..9e86441c 100644 --- a/src/stdio.ts +++ b/src/stdio.ts @@ -31,7 +31,7 @@ import { hideBin } from 'yargs/helpers'; import log from '@apify/log'; -import { ApifyClient } from './apify-client.js'; +import { ApifyClient } from './apify_client.js'; import { DEFAULT_TELEMETRY_ENV, TELEMETRY_ENV } from './const.js'; import { processInput } from './input.js'; import { ActorsMcpServer } from './mcp/server.js'; @@ -39,7 +39,7 @@ import { getTelemetryEnv } from './telemetry.js'; import type { ApifyRequestParams, Input, TelemetryEnv, ToolSelector, UiMode } from './types.js'; import { isApiTokenRequired } from './utils/auth.js'; import { parseCommaSeparatedList } from './utils/generic.js'; -import { loadToolsFromInput } from './utils/tools-loader.js'; +import { loadToolsFromInput } from './utils/tools_loader.js'; // Keeping this type here and not types.ts since // it is only relevant to the CLI/STDIO transport in this file @@ -202,7 +202,7 @@ async function main() { const apifyClient = new ApifyClient({ token: apifyToken }); // Use the shared tools loading logic - const tools = await loadToolsFromInput(normalizedInput, apifyClient, argv.ui); + const tools = await loadToolsFromInput(normalizedInput, apifyClient, argv.ui ?? 'default'); mcpServer.upsertTools(tools); diff --git a/src/tools/actor.ts b/src/tools/actor.ts deleted file mode 100644 index a9102f8c..00000000 --- a/src/tools/actor.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Adapter for call-actor tool — delegates to the appropriate mode-specific variant. - * - * The original monolithic call-actor implementation has been split into: - * - `default/call-actor.ts` — sync execution, references default tools - * - `openai/call-actor.ts` — forced async, widget metadata, references internal tools - * - `core/call-actor-common.ts` — shared pre-execution logic (arg parsing, MCP handling, validation) - * - * This adapter file maintains backward compatibility for existing imports. - * PR #4 will wire variants directly into the category registry, making this adapter unnecessary. - */ -import type { HelperTool, InternalToolArgs, ToolEntry, UiMode } from '../types.js'; -import { defaultCallActor } from './default/call-actor.js'; -import { openaiCallActor } from './openai/call-actor.js'; - -// Re-exports to maintain backward compatibility and support other modules -export { callActorGetDataset, type CallActorGetDatasetResult } from './core/actor-execution.js'; -export { getActorsAsTools } from './core/actor-tools-factory.js'; - -/** - * Returns the call-actor description for the given UI mode. - * Maintained for backward compatibility with tools-loader.ts which mutates the description at load time. - * PR #4 will remove this in favor of direct variant registration. - */ -export function getCallActorDescription(uiMode?: UiMode): string { - const variant = uiMode === 'openai' ? openaiCallActor : defaultCallActor; - return variant.description ?? ''; -} - -const defaultVariant = defaultCallActor as HelperTool; - -/** - * Adapter call-actor tool that dispatches to the correct mode-specific variant at runtime. - * - * The tool definition (name, inputSchema, outputSchema, etc.) uses the default variant's metadata. - * The `call` handler inspects `apifyMcpServer.options.uiMode` to delegate to the right implementation. - * - * Note: The description may be overridden by tools-loader.ts at load time for openai mode. - */ -export const callActor: ToolEntry = Object.freeze({ - ...defaultVariant, - call: async (toolArgs: InternalToolArgs) => { - const variant = (toolArgs.apifyMcpServer.options.uiMode === 'openai' - ? openaiCallActor - : defaultCallActor) as HelperTool; - return variant.call(toolArgs); - }, -}); diff --git a/src/tools/build.ts b/src/tools/build.ts index 85cdce74..90e1a5db 100644 --- a/src/tools/build.ts +++ b/src/tools/build.ts @@ -1,4 +1,4 @@ -import type { ApifyClient } from '../apify-client.js'; +import type { ApifyClient } from '../apify_client.js'; import { ACTOR_README_MAX_LENGTH } from '../const.js'; import type { ActorDefinitionPruned, diff --git a/src/tools/categories.ts b/src/tools/categories.ts index 2f1488e2..56f69d02 100644 --- a/src/tools/categories.ts +++ b/src/tools/categories.ts @@ -7,44 +7,82 @@ * * 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. + * + * Each tool entry can be: + * - A plain ToolEntry — mode-independent, always included + * - A mode map (e.g. { default: ToolEntry, openai: ToolEntry }) — resolver picks entry[mode] + * - A partial mode map (e.g. { openai: ToolEntry }) — included only for listed modes */ -import type { ToolCategory } from '../types.js'; -import { callActor } from './actor.js'; -import { getDataset, getDatasetItems, getDatasetSchema } from './common/dataset.js'; +import type { ServerMode, ToolEntry } from '../types.js'; +import { abortActorRun } from './common/abort_actor_run.js'; +import { addTool } from './common/add_actor.js'; import { getUserDatasetsList } from './common/dataset_collection.js'; -import { fetchApifyDocsTool } from './common/fetch-apify-docs.js'; -import { getActorOutput } from './common/get-actor-output.js'; -import { getHtmlSkeleton } from './common/get-html-skeleton.js'; -import { addTool } from './common/helpers.js'; -import { getKeyValueStore, getKeyValueStoreKeys, getKeyValueStoreRecord } from './common/key_value_store.js'; +import { fetchApifyDocsTool } from './common/fetch_apify_docs.js'; +import { getActorOutput } from './common/get_actor_output.js'; +import { getActorRunLog } from './common/get_actor_run_log.js'; +import { getDataset } from './common/get_dataset.js'; +import { getDatasetItems } from './common/get_dataset_items.js'; +import { getDatasetSchema } from './common/get_dataset_schema.js'; +import { getHtmlSkeleton } from './common/get_html_skeleton.js'; +import { getKeyValueStore } from './common/get_key_value_store.js'; +import { getKeyValueStoreKeys } from './common/get_key_value_store_keys.js'; +import { getKeyValueStoreRecord } from './common/get_key_value_store_record.js'; import { getUserKeyValueStoresList } from './common/key_value_store_collection.js'; -import { abortActorRun, getActorRun, getActorRunLog } from './common/run.js'; import { getUserRunsList } from './common/run_collection.js'; -import { searchApifyDocsTool } from './common/search-apify-docs.js'; -import { fetchActorDetailsTool } from './fetch-actor-details.js'; -import { fetchActorDetailsInternalTool } from './openai/fetch-actor-details-internal.js'; -import { searchActorsInternalTool } from './openai/search-actors-internal.js'; -import { searchActors } from './store_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 { 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'; + +/** + * A mode map: maps one or more ServerMode keys to their ToolEntry variant. + * - All modes present → each mode gets its own implementation + * - Subset of modes → tool is only included for those modes + */ +type ModeMap = Partial>; + +/** A category tool entry: plain ToolEntry (mode-independent) or a mode map. */ +type CategoryToolEntry = ToolEntry | ModeMap; + +/** A plain ToolEntry always has a `name` property; mode maps never do. */ +function isModeMap(entry: CategoryToolEntry): entry is ModeMap { + return !('name' in entry); +} +/** + * Unified tool category definitions — single source of truth. + * + * Each entry is either a plain ToolEntry (mode-independent) or a mode map + * with ServerMode keys mapping to their ToolEntry variant. + * + * Use {@link getCategoryTools} to resolve entries into concrete ToolEntry arrays for a given mode. + */ export const toolCategories = { experimental: [ addTool, ], actors: [ - searchActors, - fetchActorDetailsTool, - callActor, + { default: defaultSearchActors, openai: openaiSearchActors }, + { default: defaultFetchActorDetails, openai: openaiFetchActorDetails }, + { default: defaultCallActor, openai: openaiCallActor }, ], ui: [ - searchActorsInternalTool, - fetchActorDetailsInternalTool, + { openai: searchActorsInternalTool }, + { openai: fetchActorDetailsInternalTool }, ], docs: [ searchApifyDocsTool, fetchApifyDocsTool, ], runs: [ - getActorRun, + { default: defaultGetActorRun, openai: openaiGetActorRun }, getUserRunsList, getActorRunLog, abortActorRun, @@ -63,9 +101,58 @@ export const toolCategories = { dev: [ getHtmlSkeleton, ], -}; +} satisfies Record; + +/** + * Canonical list of all tool category names, derived from toolCategories keys. + */ +export const CATEGORY_NAMES = Object.keys(toolCategories) as (keyof typeof toolCategories)[]; + +/** Set of known category names for O(1) membership checks. */ +export const CATEGORY_NAME_SET: ReadonlySet = new Set(CATEGORY_NAMES); + +/** Map from category name to an array of resolved tool entries. */ +export type ToolCategoryMap = Record<(typeof CATEGORY_NAMES)[number], ToolEntry[]>; + +/** + * Resolve a single category's tool entries for the given server mode. + * + * For each entry: + * - Plain ToolEntry (has `name`) → always included, mode-independent + * - ModeMap → look up `entry[mode]`; included only if the mode key exists + */ +function resolveCategoryEntries(entries: readonly CategoryToolEntry[], mode: ServerMode): ToolEntry[] { + const result: ToolEntry[] = []; + for (const entry of entries) { + if (isModeMap(entry)) { + const tool = entry[mode]; + if (tool) { + result.push(tool); + } + } else { + result.push(entry); + } + } + return result; +} + +/** + * Resolve tool categories for a given server mode. + * + * Returns mode-resolved tool variants: openai mode gets openai-specific implementations + * (async execution, widget metadata), default mode gets standard implementations. + * Openai-only tools are excluded in default mode. + * + * @param mode - Required. Use `'default'` or `'openai'`. + * Made explicit (no default value) to prevent accidentally serving wrong-mode tools. + */ +export function getCategoryTools(mode: ServerMode): ToolCategoryMap { + return Object.fromEntries( + CATEGORY_NAMES.map((name) => [name, resolveCategoryEntries(toolCategories[name], mode)]), + ) as ToolCategoryMap; +} -export const toolCategoriesEnabledByDefault: ToolCategory[] = [ +export const toolCategoriesEnabledByDefault: (typeof CATEGORY_NAMES)[number][] = [ 'actors', 'docs', ]; diff --git a/src/tools/common/abort_actor_run.ts b/src/tools/common/abort_actor_run.ts new file mode 100644 index 00000000..271eee6b --- /dev/null +++ b/src/tools/common/abort_actor_run.ts @@ -0,0 +1,52 @@ +import { z } from 'zod'; + +import { createApifyClientWithSkyfireSupport } from '../../apify_client.js'; +import { HelperTools } from '../../const.js'; +import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../../types.js'; +import { compileSchema } from '../../utils/ajv.js'; + +const abortRunArgs = z.object({ + runId: z.string() + .min(1) + .describe('The ID of the Actor run to abort.'), + gracefully: z.boolean().optional().describe('If true, the Actor run will abort gracefully with a 30-second timeout.'), +}); + +/** + * https://docs.apify.com/api/v2/actor-run-abort-post + */ +export const abortActorRun: ToolEntry = Object.freeze({ + type: 'internal', + name: HelperTools.ACTOR_RUNS_ABORT, + description: `Abort an Actor run that is currently starting or running. +For runs with status FINISHED, FAILED, ABORTING, or TIMED-OUT, this call has no effect. +The results will include the updated run details after the abort request. + +USAGE: +- Use when you need to stop a run that is taking too long or misconfigured. + +USAGE EXAMPLES: +- user_input: Abort run y2h7sK3Wc +- user_input: Gracefully abort run y2h7sK3Wc`, + inputSchema: z.toJSONSchema(abortRunArgs) as ToolInputSchema, + /** + * Allow additional properties for Skyfire mode to pass `skyfire-pay-id`. + */ + ajvValidate: compileSchema({ ...z.toJSONSchema(abortRunArgs), additionalProperties: true }), + requiresSkyfirePayId: true, + annotations: { + title: 'Abort Actor run', + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, + }, + call: async (toolArgs: InternalToolArgs) => { + const { args, apifyToken, apifyMcpServer } = toolArgs; + const parsed = abortRunArgs.parse(args); + + const client = createApifyClientWithSkyfireSupport(apifyMcpServer, args, apifyToken); + const v = await client.run(parsed.runId).abort({ gracefully: parsed.gracefully }); + return { content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(v)}\n\`\`\`` }] }; + }, +} as const); diff --git a/src/tools/common/helpers.ts b/src/tools/common/add_actor.ts similarity index 98% rename from src/tools/common/helpers.ts rename to src/tools/common/add_actor.ts index a0819723..825a4c82 100644 --- a/src/tools/common/helpers.ts +++ b/src/tools/common/add_actor.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -import { ApifyClient } from '../../apify-client.js'; +import { ApifyClient } from '../../apify_client.js'; import { HelperTools } from '../../const.js'; import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../../types.js'; import { compileSchema } from '../../utils/ajv.js'; diff --git a/src/tools/common/dataset.ts b/src/tools/common/dataset.ts deleted file mode 100644 index a9425aa8..00000000 --- a/src/tools/common/dataset.ts +++ /dev/null @@ -1,252 +0,0 @@ -import { z } from 'zod'; - -import { createApifyClientWithSkyfireSupport } from '../../apify-client.js'; -import { HelperTools, TOOL_STATUS } from '../../const.js'; -import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../../types.js'; -import { compileSchema } from '../../utils/ajv.js'; -import { parseCommaSeparatedList } from '../../utils/generic.js'; -import { buildMCPResponse } from '../../utils/mcp.js'; -import { generateSchemaFromItems } from '../../utils/schema-generation.js'; -import { datasetItemsOutputSchema } from '../structured-output-schemas.js'; - -const getDatasetArgs = z.object({ - datasetId: z.string() - .min(1) - .describe('Dataset ID or username~dataset-name.'), -}); - -const getDatasetItemsArgs = z.object({ - datasetId: z.string() - .min(1) - .describe('Dataset ID or username~dataset-name.'), - clean: z.boolean().optional() - .describe('If true, returns only non-empty items and skips hidden fields (starting with #). Shortcut for skipHidden=true and skipEmpty=true.'), - offset: z.number().optional() - .describe('Number of items to skip at the start. Default is 0.'), - limit: z.number().optional() - .describe('Maximum number of items to return. No limit by default.'), - fields: z.string().optional() - .describe('Comma-separated list of fields to include in results. ' - + 'Fields in output are sorted as specified. ' - + 'For nested objects, use dot notation (e.g. "metadata.url") after flattening.'), - omit: z.string().optional() - .describe('Comma-separated list of fields to exclude from results.'), - desc: z.boolean().optional() - .describe('If true, results are returned in reverse order (newest to oldest).'), - flatten: z.string().optional() - .describe('Comma-separated list of fields which should transform nested objects into flat structures. ' - + 'For example, with flatten="metadata" the object {"metadata":{"url":"hello"}} becomes {"metadata.url":"hello"}. ' - + 'This is required before accessing nested fields with the fields parameter.'), -}); - -/** - * https://docs.apify.com/api/v2/dataset-get - */ -export const getDataset: ToolEntry = Object.freeze({ - type: 'internal', - name: HelperTools.DATASET_GET, - description: `Get metadata for a dataset (collection of structured data created by an Actor run). -The results will include dataset details such as itemCount, schema, fields, and stats. -Use fields to understand structure for filtering with ${HelperTools.DATASET_GET_ITEMS}. -Note: itemCount updates may be delayed by up to ~5 seconds. - -USAGE: -- Use when you need dataset metadata to understand its structure before fetching items. - -USAGE EXAMPLES: -- user_input: Show info for dataset xyz123 -- user_input: What fields does username~my-dataset have?`, - inputSchema: z.toJSONSchema(getDatasetArgs) as ToolInputSchema, - /** - * Allow additional properties for Skyfire mode to pass `skyfire-pay-id`. - */ - ajvValidate: compileSchema({ ...z.toJSONSchema(getDatasetArgs), additionalProperties: true }), - requiresSkyfirePayId: true, - annotations: { - title: 'Get dataset', - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - call: async (toolArgs: InternalToolArgs) => { - const { args, apifyToken, apifyMcpServer } = toolArgs; - const parsed = getDatasetArgs.parse(args); - - const client = createApifyClientWithSkyfireSupport(apifyMcpServer, args, apifyToken); - const v = await client.dataset(parsed.datasetId).get(); - if (!v) { - return buildMCPResponse({ - texts: [`Dataset '${parsed.datasetId}' not found.`], - isError: true, - toolStatus: TOOL_STATUS.SOFT_FAIL, - }); - } - return { content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(v)}\n\`\`\`` }] }; - }, -} as const); - -/** - * https://docs.apify.com/api/v2/dataset-items-get - */ -export const getDatasetItems: ToolEntry = Object.freeze({ - type: 'internal', - name: HelperTools.DATASET_GET_ITEMS, - description: `Retrieve dataset items with pagination, sorting, and field selection. -Use clean=true to skip empty items and hidden fields. Include or omit fields using comma-separated lists. -For nested objects, first flatten them (e.g., flatten="metadata"), then reference nested fields via dot notation (e.g., fields="metadata.url"). - -The results will include items along with pagination info (limit, offset) and total count. - -USAGE: -- Use when you need to read data from a dataset (all items or only selected fields). - -USAGE EXAMPLES: -- user_input: Get first 100 items from dataset abd123 -- user_input: Get only metadata.url and title from dataset username~my-dataset (flatten metadata)`, - inputSchema: z.toJSONSchema(getDatasetItemsArgs) as ToolInputSchema, - outputSchema: datasetItemsOutputSchema, - /** - * Allow additional properties for Skyfire mode to pass `skyfire-pay-id`. - */ - ajvValidate: compileSchema({ ...z.toJSONSchema(getDatasetItemsArgs), additionalProperties: true }), - requiresSkyfirePayId: true, - annotations: { - title: 'Get dataset items', - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - call: async (toolArgs: InternalToolArgs) => { - const { args, apifyToken, apifyMcpServer } = toolArgs; - const parsed = getDatasetItemsArgs.parse(args); - - const client = createApifyClientWithSkyfireSupport(apifyMcpServer, args, apifyToken); - - // Convert comma-separated strings to arrays - const fields = parseCommaSeparatedList(parsed.fields); - const omit = parseCommaSeparatedList(parsed.omit); - const flatten = parseCommaSeparatedList(parsed.flatten); - - const v = await client.dataset(parsed.datasetId).listItems({ - clean: parsed.clean, - offset: parsed.offset, - limit: parsed.limit, - fields, - omit, - desc: parsed.desc, - flatten, - }); - if (!v) { - return buildMCPResponse({ - texts: [`Dataset '${parsed.datasetId}' not found.`], - isError: true, - toolStatus: TOOL_STATUS.SOFT_FAIL, - }); - } - - const structuredContent = { - datasetId: parsed.datasetId, - items: v.items, - itemCount: v.items.length, - totalItemCount: v.count, - offset: parsed.offset ?? 0, - limit: parsed.limit, - }; - - return { content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(v)}\n\`\`\`` }], structuredContent }; - }, -} as const); - -const getDatasetSchemaArgs = z.object({ - datasetId: z.string() - .min(1) - .describe('Dataset ID or username~dataset-name.'), - limit: z.number().optional() - .describe('Maximum number of items to use for schema generation. Default is 5.') - .default(5), - clean: z.boolean().optional() - .describe('If true, uses only non-empty items and skips hidden fields (starting with #). Default is true.') - .default(true), - arrayMode: z.enum(['first', 'all']).optional() - .describe('Strategy for handling arrays. "first" uses first item as template, "all" merges all items. Default is "all".') - .default('all'), -}); - -/** - * Generates a JSON schema from dataset items - */ -export const getDatasetSchema: ToolEntry = Object.freeze({ - type: 'internal', - name: HelperTools.DATASET_SCHEMA_GET, - description: `Generate a JSON schema from a sample of dataset items. -The schema describes the structure of the data and can be used for validation, documentation, or processing. -Use this to understand the dataset before fetching many items. - -USAGE: -- Use when you need to infer the structure of dataset items for downstream processing or validation. - -USAGE EXAMPLES: -- user_input: Generate schema for dataset 34das2 using 10 items -- user_input: Show schema of username~my-dataset (clean items only)`, - inputSchema: z.toJSONSchema(getDatasetSchemaArgs) as ToolInputSchema, - /** - * Allow additional properties for Skyfire mode to pass `skyfire-pay-id`. - */ - ajvValidate: compileSchema({ ...z.toJSONSchema(getDatasetSchemaArgs), additionalProperties: true }), - requiresSkyfirePayId: true, - annotations: { - title: 'Get dataset schema', - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - call: async (toolArgs: InternalToolArgs) => { - const { args, apifyToken, apifyMcpServer } = toolArgs; - const parsed = getDatasetSchemaArgs.parse(args); - - const client = createApifyClientWithSkyfireSupport(apifyMcpServer, args, apifyToken); - - // Get dataset items - const datasetResponse = await client.dataset(parsed.datasetId).listItems({ - clean: parsed.clean, - limit: parsed.limit, - }); - - if (!datasetResponse) { - return buildMCPResponse({ - texts: [`Dataset '${parsed.datasetId}' not found.`], - isError: true, - toolStatus: TOOL_STATUS.SOFT_FAIL, - }); - } - - const datasetItems = datasetResponse.items; - - if (datasetItems.length === 0) { - return { content: [{ type: 'text', text: `Dataset '${parsed.datasetId}' is empty.` }] }; - } - - // Generate schema using the shared utility - const schema = generateSchemaFromItems(datasetItems, { - limit: parsed.limit, - clean: parsed.clean, - arrayMode: parsed.arrayMode, - }); - - if (!schema) { - // Schema generation failure is typically a server/processing error, not a user error - return buildMCPResponse({ - texts: [`Failed to generate schema for dataset '${parsed.datasetId}'.`], - isError: true, - toolStatus: TOOL_STATUS.FAILED, - }); - } - - return { - content: [{ - type: 'text', - text: `\`\`\`json\n${JSON.stringify(schema)}\n\`\`\``, - }], - }; - }, -} as const); diff --git a/src/tools/common/dataset_collection.ts b/src/tools/common/dataset_collection.ts index 2fab44a6..00de3bd9 100644 --- a/src/tools/common/dataset_collection.ts +++ b/src/tools/common/dataset_collection.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -import { ApifyClient } from '../../apify-client.js'; +import { ApifyClient } from '../../apify_client.js'; import { HelperTools } from '../../const.js'; import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../../types.js'; import { compileSchema } from '../../utils/ajv.js'; diff --git a/src/tools/common/fetch-apify-docs.ts b/src/tools/common/fetch_apify_docs.ts similarity index 97% rename from src/tools/common/fetch-apify-docs.ts rename to src/tools/common/fetch_apify_docs.ts index 38c2168d..774fead5 100644 --- a/src/tools/common/fetch-apify-docs.ts +++ b/src/tools/common/fetch_apify_docs.ts @@ -6,10 +6,10 @@ import { ALLOWED_DOC_DOMAINS, HelperTools, TOOL_STATUS } from '../../const.js'; import { fetchApifyDocsCache } from '../../state.js'; import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../../types.js'; import { compileSchema } from '../../utils/ajv.js'; -import { htmlToMarkdown } from '../../utils/html-to-md.js'; +import { htmlToMarkdown } from '../../utils/html_to_md.js'; import { logHttpError } from '../../utils/logging.js'; import { buildMCPResponse } from '../../utils/mcp.js'; -import { fetchApifyDocsToolOutputSchema } from '../structured-output-schemas.js'; +import { fetchApifyDocsToolOutputSchema } from '../structured_output_schemas.js'; const fetchApifyDocsToolArgsSchema = z.object({ url: z.string() diff --git a/src/tools/common/get-actor-output.ts b/src/tools/common/get_actor_output.ts similarity index 98% rename from src/tools/common/get-actor-output.ts rename to src/tools/common/get_actor_output.ts index ac19cd75..bb2da41d 100644 --- a/src/tools/common/get-actor-output.ts +++ b/src/tools/common/get_actor_output.ts @@ -1,12 +1,12 @@ import { z } from 'zod'; -import { createApifyClientWithSkyfireSupport } from '../../apify-client.js'; +import { createApifyClientWithSkyfireSupport } from '../../apify_client.js'; import { HelperTools, TOOL_MAX_OUTPUT_CHARS, TOOL_STATUS } from '../../const.js'; import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../../types.js'; import { compileSchema } from '../../utils/ajv.js'; import { getValuesByDotKeys, parseCommaSeparatedList } from '../../utils/generic.js'; import { buildMCPResponse } from '../../utils/mcp.js'; -import { datasetItemsOutputSchema } from '../structured-output-schemas.js'; +import { datasetItemsOutputSchema } from '../structured_output_schemas.js'; /** * Zod schema for get-actor-output tool arguments diff --git a/src/tools/common/get_actor_run_log.ts b/src/tools/common/get_actor_run_log.ts new file mode 100644 index 00000000..58998b1e --- /dev/null +++ b/src/tools/common/get_actor_run_log.ts @@ -0,0 +1,55 @@ +import { z } from 'zod'; + +import { createApifyClientWithSkyfireSupport } from '../../apify_client.js'; +import { HelperTools } from '../../const.js'; +import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../../types.js'; +import { compileSchema } from '../../utils/ajv.js'; + +const GetRunLogArgs = z.object({ + runId: z.string().describe('The ID of the Actor run.'), + lines: z.number() + .max(50) + .describe('Output the last NUM lines, instead of the last 10') + .default(10), +}); + +/** + * https://docs.apify.com/api/v2/actor-run-get + * /v2/actor-runs/{runId}/log{?token} + */ +export const getActorRunLog: ToolEntry = Object.freeze({ + type: 'internal', + name: HelperTools.ACTOR_RUNS_LOG, + description: `Retrieve recent log lines for a specific Actor run. +The results will include the last N lines of the run's log output (plain text). + +USAGE: +- Use when you need to inspect recent logs to debug or monitor a run. + +USAGE EXAMPLES: +- user_input: Show last 20 lines of logs for run y2h7sK3Wc +- user_input: Get logs for run y2h7sK3Wc`, + inputSchema: z.toJSONSchema(GetRunLogArgs) as ToolInputSchema, + // It does not make sense to add structured output here since the log API just returns plain text + /** + * Allow additional properties for Skyfire mode to pass `skyfire-pay-id`. + */ + ajvValidate: compileSchema({ ...z.toJSONSchema(GetRunLogArgs), additionalProperties: true }), + requiresSkyfirePayId: true, + annotations: { + title: 'Get Actor run log', + readOnlyHint: true, + idempotentHint: true, + openWorldHint: false, + }, + call: async (toolArgs: InternalToolArgs) => { + const { args, apifyToken, apifyMcpServer } = toolArgs; + const parsed = GetRunLogArgs.parse(args); + + const client = createApifyClientWithSkyfireSupport(apifyMcpServer, args, apifyToken); + const v = await client.run(parsed.runId).log().get() ?? ''; + const lines = v.split('\n'); + const text = lines.slice(lines.length - parsed.lines - 1, lines.length).join('\n'); + return { content: [{ type: 'text', text }] }; + }, +} as const); diff --git a/src/tools/common/get_dataset.ts b/src/tools/common/get_dataset.ts new file mode 100644 index 00000000..83974b1b --- /dev/null +++ b/src/tools/common/get_dataset.ts @@ -0,0 +1,59 @@ +import { z } from 'zod'; + +import { createApifyClientWithSkyfireSupport } from '../../apify_client.js'; +import { HelperTools, TOOL_STATUS } from '../../const.js'; +import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../../types.js'; +import { compileSchema } from '../../utils/ajv.js'; +import { buildMCPResponse } from '../../utils/mcp.js'; + +const getDatasetArgs = z.object({ + datasetId: z.string() + .min(1) + .describe('Dataset ID or username~dataset-name.'), +}); + +/** + * https://docs.apify.com/api/v2/dataset-get + */ +export const getDataset: ToolEntry = Object.freeze({ + type: 'internal', + name: HelperTools.DATASET_GET, + description: `Get metadata for a dataset (collection of structured data created by an Actor run). +The results will include dataset details such as itemCount, schema, fields, and stats. +Use fields to understand structure for filtering with ${HelperTools.DATASET_GET_ITEMS}. +Note: itemCount updates may be delayed by up to ~5 seconds. + +USAGE: +- Use when you need dataset metadata to understand its structure before fetching items. + +USAGE EXAMPLES: +- user_input: Show info for dataset xyz123 +- user_input: What fields does username~my-dataset have?`, + inputSchema: z.toJSONSchema(getDatasetArgs) as ToolInputSchema, + /** + * Allow additional properties for Skyfire mode to pass `skyfire-pay-id`. + */ + ajvValidate: compileSchema({ ...z.toJSONSchema(getDatasetArgs), additionalProperties: true }), + requiresSkyfirePayId: true, + annotations: { + title: 'Get dataset', + readOnlyHint: true, + idempotentHint: true, + openWorldHint: false, + }, + call: async (toolArgs: InternalToolArgs) => { + const { args, apifyToken, apifyMcpServer } = toolArgs; + const parsed = getDatasetArgs.parse(args); + + const client = createApifyClientWithSkyfireSupport(apifyMcpServer, args, apifyToken); + const v = await client.dataset(parsed.datasetId).get(); + if (!v) { + return buildMCPResponse({ + texts: [`Dataset '${parsed.datasetId}' not found.`], + isError: true, + toolStatus: TOOL_STATUS.SOFT_FAIL, + }); + } + return { content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(v)}\n\`\`\`` }] }; + }, +} as const); diff --git a/src/tools/common/get_dataset_items.ts b/src/tools/common/get_dataset_items.ts new file mode 100644 index 00000000..3643a74b --- /dev/null +++ b/src/tools/common/get_dataset_items.ts @@ -0,0 +1,105 @@ +import { z } from 'zod'; + +import { createApifyClientWithSkyfireSupport } from '../../apify_client.js'; +import { HelperTools, TOOL_STATUS } from '../../const.js'; +import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../../types.js'; +import { compileSchema } from '../../utils/ajv.js'; +import { parseCommaSeparatedList } from '../../utils/generic.js'; +import { buildMCPResponse } from '../../utils/mcp.js'; +import { datasetItemsOutputSchema } from '../structured_output_schemas.js'; + +const getDatasetItemsArgs = z.object({ + datasetId: z.string() + .min(1) + .describe('Dataset ID or username~dataset-name.'), + clean: z.boolean().optional() + .describe('If true, returns only non-empty items and skips hidden fields (starting with #). Shortcut for skipHidden=true and skipEmpty=true.'), + offset: z.number().optional() + .describe('Number of items to skip at the start. Default is 0.'), + limit: z.number().optional() + .describe('Maximum number of items to return. No limit by default.'), + fields: z.string().optional() + .describe('Comma-separated list of fields to include in results. ' + + 'Fields in output are sorted as specified. ' + + 'For nested objects, use dot notation (e.g. "metadata.url") after flattening.'), + omit: z.string().optional() + .describe('Comma-separated list of fields to exclude from results.'), + desc: z.boolean().optional() + .describe('If true, results are returned in reverse order (newest to oldest).'), + flatten: z.string().optional() + .describe('Comma-separated list of fields which should transform nested objects into flat structures. ' + + 'For example, with flatten="metadata" the object {"metadata":{"url":"hello"}} becomes {"metadata.url":"hello"}. ' + + 'This is required before accessing nested fields with the fields parameter.'), +}); + +/** + * https://docs.apify.com/api/v2/dataset-items-get + */ +export const getDatasetItems: ToolEntry = Object.freeze({ + type: 'internal', + name: HelperTools.DATASET_GET_ITEMS, + description: `Retrieve dataset items with pagination, sorting, and field selection. +Use clean=true to skip empty items and hidden fields. Include or omit fields using comma-separated lists. +For nested objects, first flatten them (e.g., flatten="metadata"), then reference nested fields via dot notation (e.g., fields="metadata.url"). + +The results will include items along with pagination info (limit, offset) and total count. + +USAGE: +- Use when you need to read data from a dataset (all items or only selected fields). + +USAGE EXAMPLES: +- user_input: Get first 100 items from dataset abd123 +- user_input: Get only metadata.url and title from dataset username~my-dataset (flatten metadata)`, + inputSchema: z.toJSONSchema(getDatasetItemsArgs) as ToolInputSchema, + outputSchema: datasetItemsOutputSchema, + /** + * Allow additional properties for Skyfire mode to pass `skyfire-pay-id`. + */ + ajvValidate: compileSchema({ ...z.toJSONSchema(getDatasetItemsArgs), additionalProperties: true }), + requiresSkyfirePayId: true, + annotations: { + title: 'Get dataset items', + readOnlyHint: true, + idempotentHint: true, + openWorldHint: false, + }, + call: async (toolArgs: InternalToolArgs) => { + const { args, apifyToken, apifyMcpServer } = toolArgs; + const parsed = getDatasetItemsArgs.parse(args); + + const client = createApifyClientWithSkyfireSupport(apifyMcpServer, args, apifyToken); + + // Convert comma-separated strings to arrays + const fields = parseCommaSeparatedList(parsed.fields); + const omit = parseCommaSeparatedList(parsed.omit); + const flatten = parseCommaSeparatedList(parsed.flatten); + + const v = await client.dataset(parsed.datasetId).listItems({ + clean: parsed.clean, + offset: parsed.offset, + limit: parsed.limit, + fields, + omit, + desc: parsed.desc, + flatten, + }); + if (!v) { + return buildMCPResponse({ + texts: [`Dataset '${parsed.datasetId}' not found.`], + isError: true, + toolStatus: TOOL_STATUS.SOFT_FAIL, + }); + } + + const structuredContent = { + datasetId: parsed.datasetId, + items: v.items, + itemCount: v.items.length, + totalItemCount: v.count, + offset: parsed.offset ?? 0, + limit: parsed.limit, + }; + + return { content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(v)}\n\`\`\`` }], structuredContent }; + }, +} as const); diff --git a/src/tools/common/get_dataset_schema.ts b/src/tools/common/get_dataset_schema.ts new file mode 100644 index 00000000..0a9d5854 --- /dev/null +++ b/src/tools/common/get_dataset_schema.ts @@ -0,0 +1,102 @@ +import { z } from 'zod'; + +import { createApifyClientWithSkyfireSupport } from '../../apify_client.js'; +import { HelperTools, TOOL_STATUS } from '../../const.js'; +import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../../types.js'; +import { compileSchema } from '../../utils/ajv.js'; +import { buildMCPResponse } from '../../utils/mcp.js'; +import { generateSchemaFromItems } from '../../utils/schema_generation.js'; + +const getDatasetSchemaArgs = z.object({ + datasetId: z.string() + .min(1) + .describe('Dataset ID or username~dataset-name.'), + limit: z.number().optional() + .describe('Maximum number of items to use for schema generation. Default is 5.') + .default(5), + clean: z.boolean().optional() + .describe('If true, uses only non-empty items and skips hidden fields (starting with #). Default is true.') + .default(true), + arrayMode: z.enum(['first', 'all']).optional() + .describe('Strategy for handling arrays. "first" uses first item as template, "all" merges all items. Default is "all".') + .default('all'), +}); + +/** + * Generates a JSON schema from dataset items + */ +export const getDatasetSchema: ToolEntry = Object.freeze({ + type: 'internal', + name: HelperTools.DATASET_SCHEMA_GET, + description: `Generate a JSON schema from a sample of dataset items. +The schema describes the structure of the data and can be used for validation, documentation, or processing. +Use this to understand the dataset before fetching many items. + +USAGE: +- Use when you need to infer the structure of dataset items for downstream processing or validation. + +USAGE EXAMPLES: +- user_input: Generate schema for dataset 34das2 using 10 items +- user_input: Show schema of username~my-dataset (clean items only)`, + inputSchema: z.toJSONSchema(getDatasetSchemaArgs) as ToolInputSchema, + /** + * Allow additional properties for Skyfire mode to pass `skyfire-pay-id`. + */ + ajvValidate: compileSchema({ ...z.toJSONSchema(getDatasetSchemaArgs), additionalProperties: true }), + requiresSkyfirePayId: true, + annotations: { + title: 'Get dataset schema', + readOnlyHint: true, + idempotentHint: true, + openWorldHint: false, + }, + call: async (toolArgs: InternalToolArgs) => { + const { args, apifyToken, apifyMcpServer } = toolArgs; + const parsed = getDatasetSchemaArgs.parse(args); + + const client = createApifyClientWithSkyfireSupport(apifyMcpServer, args, apifyToken); + + // Get dataset items + const datasetResponse = await client.dataset(parsed.datasetId).listItems({ + clean: parsed.clean, + limit: parsed.limit, + }); + + if (!datasetResponse) { + return buildMCPResponse({ + texts: [`Dataset '${parsed.datasetId}' not found.`], + isError: true, + toolStatus: TOOL_STATUS.SOFT_FAIL, + }); + } + + const datasetItems = datasetResponse.items; + + if (datasetItems.length === 0) { + return { content: [{ type: 'text', text: `Dataset '${parsed.datasetId}' is empty.` }] }; + } + + // Generate schema using the shared utility + const schema = generateSchemaFromItems(datasetItems, { + limit: parsed.limit, + clean: parsed.clean, + arrayMode: parsed.arrayMode, + }); + + if (!schema) { + // Schema generation failure is typically a server/processing error, not a user error + return buildMCPResponse({ + texts: [`Failed to generate schema for dataset '${parsed.datasetId}'.`], + isError: true, + toolStatus: TOOL_STATUS.FAILED, + }); + } + + return { + content: [{ + type: 'text', + text: `\`\`\`json\n${JSON.stringify(schema)}\n\`\`\``, + }], + }; + }, +} as const); diff --git a/src/tools/common/get-html-skeleton.ts b/src/tools/common/get_html_skeleton.ts similarity index 99% rename from src/tools/common/get-html-skeleton.ts rename to src/tools/common/get_html_skeleton.ts index 784e6240..27f41560 100644 --- a/src/tools/common/get-html-skeleton.ts +++ b/src/tools/common/get_html_skeleton.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -import { ApifyClient } from '../../apify-client.js'; +import { ApifyClient } from '../../apify_client.js'; import { HelperTools, RAG_WEB_BROWSER, TOOL_MAX_OUTPUT_CHARS, TOOL_STATUS } from '../../const.js'; import { getHtmlSkeletonCache } from '../../state.js'; import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../../types.js'; diff --git a/src/tools/common/get_key_value_store.ts b/src/tools/common/get_key_value_store.ts new file mode 100644 index 00000000..e5eabb1c --- /dev/null +++ b/src/tools/common/get_key_value_store.ts @@ -0,0 +1,49 @@ +import { z } from 'zod'; + +import { createApifyClientWithSkyfireSupport } from '../../apify_client.js'; +import { HelperTools } from '../../const.js'; +import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../../types.js'; +import { compileSchema } from '../../utils/ajv.js'; + +const getKeyValueStoreArgs = z.object({ + storeId: z.string() + .min(1) + .describe('Key-value store ID or username~store-name'), +}); + +/** + * https://docs.apify.com/api/v2/key-value-store-get + */ +export const getKeyValueStore: ToolEntry = Object.freeze({ + type: 'internal', + name: HelperTools.KEY_VALUE_STORE_GET, + description: `Get details about a key-value store by ID or username~store-name. +The results will include store metadata (ID, name, owner, access settings) and usage statistics. + +USAGE: +- Use when you need to inspect a store to locate records or understand its properties. + +USAGE EXAMPLES: +- user_input: Show info for key-value store username~my-store +- user_input: Get details for store adb123`, + inputSchema: z.toJSONSchema(getKeyValueStoreArgs) as ToolInputSchema, + /** + * Allow additional properties for Skyfire mode to pass `skyfire-pay-id`. + */ + ajvValidate: compileSchema({ ...z.toJSONSchema(getKeyValueStoreArgs), additionalProperties: true }), + requiresSkyfirePayId: true, + annotations: { + title: 'Get key-value store', + readOnlyHint: true, + idempotentHint: true, + openWorldHint: false, + }, + call: async (toolArgs: InternalToolArgs) => { + const { args, apifyToken, apifyMcpServer } = toolArgs; + const parsed = getKeyValueStoreArgs.parse(args); + + const client = createApifyClientWithSkyfireSupport(apifyMcpServer, args, apifyToken); + const store = await client.keyValueStore(parsed.storeId).get(); + return { content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(store)}\n\`\`\`` }] }; + }, +} as const); diff --git a/src/tools/common/get_key_value_store_keys.ts b/src/tools/common/get_key_value_store_keys.ts new file mode 100644 index 00000000..ff2336a8 --- /dev/null +++ b/src/tools/common/get_key_value_store_keys.ts @@ -0,0 +1,60 @@ +import { z } from 'zod'; + +import { createApifyClientWithSkyfireSupport } from '../../apify_client.js'; +import { HelperTools } from '../../const.js'; +import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../../types.js'; +import { compileSchema } from '../../utils/ajv.js'; + +const getKeyValueStoreKeysArgs = z.object({ + storeId: z.string() + .min(1) + .describe('Key-value store ID or username~store-name'), + exclusiveStartKey: z.string() + .optional() + .describe('All keys up to this one (including) are skipped from the result.'), + limit: z.number() + .max(10) + .optional() + .describe('Number of keys to be returned. Maximum value is 1000.'), +}); + +/** + * https://docs.apify.com/api/v2/key-value-store-keys-get + */ +export const getKeyValueStoreKeys: ToolEntry = Object.freeze({ + type: 'internal', + name: HelperTools.KEY_VALUE_STORE_KEYS_GET, + description: `List keys in a key-value store with optional pagination. +The results will include keys and basic info about stored values (e.g., size). +Use exclusiveStartKey and limit to paginate. + +USAGE: +- Use when you need to discover what records exist in a store. + +USAGE EXAMPLES: +- user_input: List first 100 keys in store username~my-store +- user_input: Continue listing keys in store a123 from key data.json`, + inputSchema: z.toJSONSchema(getKeyValueStoreKeysArgs) as ToolInputSchema, + /** + * Allow additional properties for Skyfire mode to pass `skyfire-pay-id`. + */ + ajvValidate: compileSchema({ ...z.toJSONSchema(getKeyValueStoreKeysArgs), additionalProperties: true }), + requiresSkyfirePayId: true, + annotations: { + title: 'Get key-value store keys', + readOnlyHint: true, + idempotentHint: true, + openWorldHint: false, + }, + call: async (toolArgs: InternalToolArgs) => { + const { args, apifyToken, apifyMcpServer } = toolArgs; + const parsed = getKeyValueStoreKeysArgs.parse(args); + + const client = createApifyClientWithSkyfireSupport(apifyMcpServer, args, apifyToken); + const keys = await client.keyValueStore(parsed.storeId).listKeys({ + exclusiveStartKey: parsed.exclusiveStartKey, + limit: parsed.limit, + }); + return { content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(keys)}\n\`\`\`` }] }; + }, +} as const); diff --git a/src/tools/common/get_key_value_store_record.ts b/src/tools/common/get_key_value_store_record.ts new file mode 100644 index 00000000..a35b6387 --- /dev/null +++ b/src/tools/common/get_key_value_store_record.ts @@ -0,0 +1,52 @@ +import { z } from 'zod'; + +import { createApifyClientWithSkyfireSupport } from '../../apify_client.js'; +import { HelperTools } from '../../const.js'; +import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../../types.js'; +import { compileSchema } from '../../utils/ajv.js'; + +const getKeyValueStoreRecordArgs = z.object({ + storeId: z.string() + .min(1) + .describe('Key-value store ID or username~store-name'), + recordKey: z.string() + .min(1) + .describe('Key of the record to retrieve.'), +}); + +/** + * https://docs.apify.com/api/v2/key-value-store-record-get + */ +export const getKeyValueStoreRecord: ToolEntry = Object.freeze({ + type: 'internal', + name: HelperTools.KEY_VALUE_STORE_RECORD_GET, + description: `Get a value stored in a key-value store under a specific key. +The response preserves the original Content-Encoding; most clients handle decompression automatically. + +USAGE: +- Use when you need to retrieve a specific record (JSON, text, or binary) from a store. + +USAGE EXAMPLES: +- user_input: Get record INPUT from store abc123 +- user_input: Get record data.json from store username~my-store`, + inputSchema: z.toJSONSchema(getKeyValueStoreRecordArgs) as ToolInputSchema, + /** + * Allow additional properties for Skyfire mode to pass `skyfire-pay-id`. + */ + ajvValidate: compileSchema({ ...z.toJSONSchema(getKeyValueStoreRecordArgs), additionalProperties: true }), + requiresSkyfirePayId: true, + annotations: { + title: 'Get key-value store record', + readOnlyHint: true, + idempotentHint: true, + openWorldHint: false, + }, + call: async (toolArgs: InternalToolArgs) => { + const { args, apifyToken, apifyMcpServer } = toolArgs; + const parsed = getKeyValueStoreRecordArgs.parse(args); + + const client = createApifyClientWithSkyfireSupport(apifyMcpServer, args, apifyToken); + const record = await client.keyValueStore(parsed.storeId).getRecord(parsed.recordKey); + return { content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(record)}\n\`\`\`` }] }; + }, +} as const); diff --git a/src/tools/common/key_value_store.ts b/src/tools/common/key_value_store.ts deleted file mode 100644 index 3427b72d..00000000 --- a/src/tools/common/key_value_store.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { z } from 'zod'; - -import { createApifyClientWithSkyfireSupport } from '../../apify-client.js'; -import { HelperTools } from '../../const.js'; -import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../../types.js'; -import { compileSchema } from '../../utils/ajv.js'; - -const getKeyValueStoreArgs = z.object({ - storeId: z.string() - .min(1) - .describe('Key-value store ID or username~store-name'), -}); - -/** - * https://docs.apify.com/api/v2/key-value-store-get - */ -export const getKeyValueStore: ToolEntry = Object.freeze({ - type: 'internal', - name: HelperTools.KEY_VALUE_STORE_GET, - description: `Get details about a key-value store by ID or username~store-name. -The results will include store metadata (ID, name, owner, access settings) and usage statistics. - -USAGE: -- Use when you need to inspect a store to locate records or understand its properties. - -USAGE EXAMPLES: -- user_input: Show info for key-value store username~my-store -- user_input: Get details for store adb123`, - inputSchema: z.toJSONSchema(getKeyValueStoreArgs) as ToolInputSchema, - /** - * Allow additional properties for Skyfire mode to pass `skyfire-pay-id`. - */ - ajvValidate: compileSchema({ ...z.toJSONSchema(getKeyValueStoreArgs), additionalProperties: true }), - requiresSkyfirePayId: true, - annotations: { - title: 'Get key-value store', - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - call: async (toolArgs: InternalToolArgs) => { - const { args, apifyToken, apifyMcpServer } = toolArgs; - const parsed = getKeyValueStoreArgs.parse(args); - - const client = createApifyClientWithSkyfireSupport(apifyMcpServer, args, apifyToken); - const store = await client.keyValueStore(parsed.storeId).get(); - return { content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(store)}\n\`\`\`` }] }; - }, -} as const); - -const getKeyValueStoreKeysArgs = z.object({ - storeId: z.string() - .min(1) - .describe('Key-value store ID or username~store-name'), - exclusiveStartKey: z.string() - .optional() - .describe('All keys up to this one (including) are skipped from the result.'), - limit: z.number() - .max(10) - .optional() - .describe('Number of keys to be returned. Maximum value is 1000.'), -}); - -/** - * https://docs.apify.com/api/v2/key-value-store-keys-get - */ -export const getKeyValueStoreKeys: ToolEntry = Object.freeze({ - type: 'internal', - name: HelperTools.KEY_VALUE_STORE_KEYS_GET, - description: `List keys in a key-value store with optional pagination. -The results will include keys and basic info about stored values (e.g., size). -Use exclusiveStartKey and limit to paginate. - -USAGE: -- Use when you need to discover what records exist in a store. - -USAGE EXAMPLES: -- user_input: List first 100 keys in store username~my-store -- user_input: Continue listing keys in store a123 from key data.json`, - inputSchema: z.toJSONSchema(getKeyValueStoreKeysArgs) as ToolInputSchema, - /** - * Allow additional properties for Skyfire mode to pass `skyfire-pay-id`. - */ - ajvValidate: compileSchema({ ...z.toJSONSchema(getKeyValueStoreKeysArgs), additionalProperties: true }), - requiresSkyfirePayId: true, - annotations: { - title: 'Get key-value store keys', - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - call: async (toolArgs: InternalToolArgs) => { - const { args, apifyToken, apifyMcpServer } = toolArgs; - const parsed = getKeyValueStoreKeysArgs.parse(args); - - const client = createApifyClientWithSkyfireSupport(apifyMcpServer, args, apifyToken); - const keys = await client.keyValueStore(parsed.storeId).listKeys({ - exclusiveStartKey: parsed.exclusiveStartKey, - limit: parsed.limit, - }); - return { content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(keys)}\n\`\`\`` }] }; - }, -} as const); - -const getKeyValueStoreRecordArgs = z.object({ - storeId: z.string() - .min(1) - .describe('Key-value store ID or username~store-name'), - recordKey: z.string() - .min(1) - .describe('Key of the record to retrieve.'), -}); - -/** - * https://docs.apify.com/api/v2/key-value-store-record-get - */ -export const getKeyValueStoreRecord: ToolEntry = Object.freeze({ - type: 'internal', - name: HelperTools.KEY_VALUE_STORE_RECORD_GET, - description: `Get a value stored in a key-value store under a specific key. -The response preserves the original Content-Encoding; most clients handle decompression automatically. - -USAGE: -- Use when you need to retrieve a specific record (JSON, text, or binary) from a store. - -USAGE EXAMPLES: -- user_input: Get record INPUT from store abc123 -- user_input: Get record data.json from store username~my-store`, - inputSchema: z.toJSONSchema(getKeyValueStoreRecordArgs) as ToolInputSchema, - /** - * Allow additional properties for Skyfire mode to pass `skyfire-pay-id`. - */ - ajvValidate: compileSchema({ ...z.toJSONSchema(getKeyValueStoreRecordArgs), additionalProperties: true }), - requiresSkyfirePayId: true, - annotations: { - title: 'Get key-value store record', - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - call: async (toolArgs: InternalToolArgs) => { - const { args, apifyToken, apifyMcpServer } = toolArgs; - const parsed = getKeyValueStoreRecordArgs.parse(args); - - const client = createApifyClientWithSkyfireSupport(apifyMcpServer, args, apifyToken); - const record = await client.keyValueStore(parsed.storeId).getRecord(parsed.recordKey); - return { content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(record)}\n\`\`\`` }] }; - }, -} as const); diff --git a/src/tools/common/key_value_store_collection.ts b/src/tools/common/key_value_store_collection.ts index 2286b5a4..ca756094 100644 --- a/src/tools/common/key_value_store_collection.ts +++ b/src/tools/common/key_value_store_collection.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -import { ApifyClient } from '../../apify-client.js'; +import { ApifyClient } from '../../apify_client.js'; import { HelperTools } from '../../const.js'; import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../../types.js'; import { compileSchema } from '../../utils/ajv.js'; diff --git a/src/tools/common/run.ts b/src/tools/common/run.ts deleted file mode 100644 index 814227b6..00000000 --- a/src/tools/common/run.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * Actor run tools — get-actor-run (adapter), get-actor-run-log, and abort-actor-run. - * - * The get-actor-run tool has been split into mode-specific variants: - * - `default/get-actor-run.ts` — full JSON dump without widget metadata - * - `openai/get-actor-run.ts` — abbreviated text with widget metadata - * - `core/get-actor-run-common.ts` — shared schema, metadata, and data-fetching logic - * - * The getActorRunLog and abortActorRun tools are mode-independent and remain here. - * PR #4 will wire variants directly into the category registry, making the adapter unnecessary. - */ -import { z } from 'zod'; - -import { createApifyClientWithSkyfireSupport } from '../../apify-client.js'; -import { HelperTools } from '../../const.js'; -import type { HelperTool, InternalToolArgs, ToolEntry, ToolInputSchema } from '../../types.js'; -import { compileSchema } from '../../utils/ajv.js'; -import { defaultGetActorRun } from '../default/get-actor-run.js'; -import { openaiGetActorRun } from '../openai/get-actor-run.js'; - -const defaultVariant = defaultGetActorRun as HelperTool; - -/** - * Adapter get-actor-run tool that dispatches to the correct mode-specific variant at runtime. - */ -export const getActorRun: ToolEntry = Object.freeze({ - ...defaultVariant, - call: async (toolArgs: InternalToolArgs) => { - const variant = (toolArgs.apifyMcpServer.options.uiMode === 'openai' - ? openaiGetActorRun - : defaultGetActorRun) as HelperTool; - return variant.call(toolArgs); - }, -}); - -// --- Mode-independent tools below --- - -const GetRunLogArgs = z.object({ - runId: z.string().describe('The ID of the Actor run.'), - lines: z.number() - .max(50) - .describe('Output the last NUM lines, instead of the last 10') - .default(10), -}); - -/** - * https://docs.apify.com/api/v2/actor-run-get - * /v2/actor-runs/{runId}/log{?token} - */ -export const getActorRunLog: ToolEntry = Object.freeze({ - type: 'internal', - name: HelperTools.ACTOR_RUNS_LOG, - description: `Retrieve recent log lines for a specific Actor run. -The results will include the last N lines of the run's log output (plain text). - -USAGE: -- Use when you need to inspect recent logs to debug or monitor a run. - -USAGE EXAMPLES: -- user_input: Show last 20 lines of logs for run y2h7sK3Wc -- user_input: Get logs for run y2h7sK3Wc`, - inputSchema: z.toJSONSchema(GetRunLogArgs) as ToolInputSchema, - // It does not make sense to add structured output here since the log API just returns plain text - /** - * Allow additional properties for Skyfire mode to pass `skyfire-pay-id`. - */ - ajvValidate: compileSchema({ ...z.toJSONSchema(GetRunLogArgs), additionalProperties: true }), - requiresSkyfirePayId: true, - annotations: { - title: 'Get Actor run log', - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - call: async (toolArgs: InternalToolArgs) => { - const { args, apifyToken, apifyMcpServer } = toolArgs; - const parsed = GetRunLogArgs.parse(args); - - const client = createApifyClientWithSkyfireSupport(apifyMcpServer, args, apifyToken); - const v = await client.run(parsed.runId).log().get() ?? ''; - const lines = v.split('\n'); - const text = lines.slice(lines.length - parsed.lines - 1, lines.length).join('\n'); - return { content: [{ type: 'text', text }] }; - }, -} as const); - -const abortRunArgs = z.object({ - runId: z.string() - .min(1) - .describe('The ID of the Actor run to abort.'), - gracefully: z.boolean().optional().describe('If true, the Actor run will abort gracefully with a 30-second timeout.'), -}); - -/** - * https://docs.apify.com/api/v2/actor-run-abort-post - */ -export const abortActorRun: ToolEntry = Object.freeze({ - type: 'internal', - name: HelperTools.ACTOR_RUNS_ABORT, - description: `Abort an Actor run that is currently starting or running. -For runs with status FINISHED, FAILED, ABORTING, or TIMED-OUT, this call has no effect. -The results will include the updated run details after the abort request. - -USAGE: -- Use when you need to stop a run that is taking too long or misconfigured. - -USAGE EXAMPLES: -- user_input: Abort run y2h7sK3Wc -- user_input: Gracefully abort run y2h7sK3Wc`, - inputSchema: z.toJSONSchema(abortRunArgs) as ToolInputSchema, - /** - * Allow additional properties for Skyfire mode to pass `skyfire-pay-id`. - */ - ajvValidate: compileSchema({ ...z.toJSONSchema(abortRunArgs), additionalProperties: true }), - requiresSkyfirePayId: true, - annotations: { - title: 'Abort Actor run', - readOnlyHint: false, - destructiveHint: true, - idempotentHint: true, - openWorldHint: false, - }, - call: async (toolArgs: InternalToolArgs) => { - const { args, apifyToken, apifyMcpServer } = toolArgs; - const parsed = abortRunArgs.parse(args); - - const client = createApifyClientWithSkyfireSupport(apifyMcpServer, args, apifyToken); - const v = await client.run(parsed.runId).abort({ gracefully: parsed.gracefully }); - return { content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(v)}\n\`\`\`` }] }; - }, -} as const); diff --git a/src/tools/common/run_collection.ts b/src/tools/common/run_collection.ts index 8276920d..8000a2c0 100644 --- a/src/tools/common/run_collection.ts +++ b/src/tools/common/run_collection.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -import { ApifyClient } from '../../apify-client.js'; +import { ApifyClient } from '../../apify_client.js'; import { HelperTools } from '../../const.js'; import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../../types.js'; import { compileSchema } from '../../utils/ajv.js'; diff --git a/src/tools/common/search-apify-docs.ts b/src/tools/common/search_apify_docs.ts similarity index 97% rename from src/tools/common/search-apify-docs.ts rename to src/tools/common/search_apify_docs.ts index 69149ea3..33f98b6b 100644 --- a/src/tools/common/search-apify-docs.ts +++ b/src/tools/common/search_apify_docs.ts @@ -3,9 +3,9 @@ import { z } from 'zod'; import { DOCS_SOURCES, HelperTools } from '../../const.js'; import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../../types.js'; import { compileSchema } from '../../utils/ajv.js'; -import { searchDocsBySourceCached } from '../../utils/apify-docs.js'; +import { searchDocsBySourceCached } from '../../utils/apify_docs.js'; import { buildMCPResponse } from '../../utils/mcp.js'; -import { searchApifyDocsToolOutputSchema } from '../structured-output-schemas.js'; +import { searchApifyDocsToolOutputSchema } from '../structured_output_schemas.js'; /** * Build docSource parameter description dynamically from DOCS_SOURCES diff --git a/src/tools/core/actor-execution.ts b/src/tools/core/actor_execution.ts similarity index 96% rename from src/tools/core/actor-execution.ts rename to src/tools/core/actor_execution.ts index a76555c0..35d7a78c 100644 --- a/src/tools/core/actor-execution.ts +++ b/src/tools/core/actor_execution.ts @@ -2,14 +2,14 @@ import type { ActorCallOptions, ActorRun } from 'apify-client'; import log from '@apify/log'; -import type { ApifyClient } from '../../apify-client.js'; +import type { ApifyClient } from '../../apify_client.js'; import { TOOL_MAX_OUTPUT_CHARS } from '../../const.js'; import type { ActorDefinitionStorage, DatasetItem } from '../../types.js'; import { ensureOutputWithinCharLimit, getActorDefinitionStorageFieldNames } from '../../utils/actor.js'; import { logHttpError, redactSkyfirePayId } from '../../utils/logging.js'; import type { ProgressTracker } from '../../utils/progress.js'; -import type { JsonSchemaProperty } from '../../utils/schema-generation.js'; -import { generateSchemaFromItems } from '../../utils/schema-generation.js'; +import type { JsonSchemaProperty } from '../../utils/schema_generation.js'; +import { generateSchemaFromItems } from '../../utils/schema_generation.js'; // Define a named return type for callActorGetDataset export type CallActorGetDatasetResult = { diff --git a/src/tools/core/actor-response.ts b/src/tools/core/actor_response.ts similarity index 98% rename from src/tools/core/actor-response.ts rename to src/tools/core/actor_response.ts index a8c9252e..c1f34ad7 100644 --- a/src/tools/core/actor-response.ts +++ b/src/tools/core/actor_response.ts @@ -1,5 +1,5 @@ import type { DatasetItem } from '../../types.js'; -import type { CallActorGetDatasetResult } from './actor-execution.js'; +import type { CallActorGetDatasetResult } from './actor_execution.js'; /** * Result from buildActorResponseContent function. diff --git a/src/tools/core/actor-tools-factory.ts b/src/tools/core/actor_tools_factory.ts similarity index 99% rename from src/tools/core/actor-tools-factory.ts rename to src/tools/core/actor_tools_factory.ts index cc1e5bbc..4f05b150 100644 --- a/src/tools/core/actor-tools-factory.ts +++ b/src/tools/core/actor_tools_factory.ts @@ -2,7 +2,7 @@ import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; import log from '@apify/log'; -import type { ApifyClient } from '../../apify-client.js'; +import type { ApifyClient } from '../../apify_client.js'; import { ACTOR_MAX_MEMORY_MBYTES, HelperTools, @@ -24,7 +24,7 @@ import type { import { ajv } from '../../utils/ajv.js'; import { logHttpError } from '../../utils/logging.js'; import { getActorDefinition } from '../build.js'; -import { buildEnrichedCallActorOutputSchema, callActorOutputSchema } from '../structured-output-schemas.js'; +import { buildEnrichedCallActorOutputSchema, callActorOutputSchema } from '../structured_output_schemas.js'; import { actorNameToToolName, buildActorInputSchema, fixedAjvCompile, isActorInfoMcpServer } from '../utils.js'; /** diff --git a/src/tools/core/call-actor-common.ts b/src/tools/core/call_actor_common.ts similarity index 99% rename from src/tools/core/call-actor-common.ts rename to src/tools/core/call_actor_common.ts index b771216e..3793a76c 100644 --- a/src/tools/core/call-actor-common.ts +++ b/src/tools/core/call_actor_common.ts @@ -1,7 +1,7 @@ import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { z } from 'zod'; -import { ApifyClient, createApifyClientWithSkyfireSupport } from '../../apify-client.js'; +import { ApifyClient, createApifyClientWithSkyfireSupport } from '../../apify_client.js'; import { CALL_ACTOR_MCP_MISSING_TOOL_NAME_MSG, HelperTools, @@ -13,7 +13,7 @@ import { getActorMcpUrlCached } from '../../utils/actor.js'; import { compileSchema } from '../../utils/ajv.js'; import { logHttpError } from '../../utils/logging.js'; import { buildMCPResponse } from '../../utils/mcp.js'; -import { getActorsAsTools } from './actor-tools-factory.js'; +import { getActorsAsTools } from './actor_tools_factory.js'; /** * Zod schema for call-actor arguments — shared between default and openai variants. diff --git a/src/tools/core/fetch-actor-details-common.ts b/src/tools/core/fetch_actor_details_common.ts similarity index 91% rename from src/tools/core/fetch-actor-details-common.ts rename to src/tools/core/fetch_actor_details_common.ts index cb4afc11..2b951123 100644 --- a/src/tools/core/fetch-actor-details-common.ts +++ b/src/tools/core/fetch_actor_details_common.ts @@ -5,9 +5,9 @@ import { getWidgetConfig, WIDGET_URIS } from '../../resources/widgets.js'; import type { HelperTool, ToolInputSchema } from '../../types.js'; import { actorDetailsOutputOptionsSchema, -} from '../../utils/actor-details.js'; +} from '../../utils/actor_details.js'; import { compileSchema } from '../../utils/ajv.js'; -import { actorDetailsOutputSchema } from '../structured-output-schemas.js'; +import { actorDetailsOutputSchema } from '../structured_output_schemas.js'; /** * Zod schema for fetch-actor-details arguments — shared between default and openai variants. @@ -45,6 +45,7 @@ export const fetchActorDetailsMetadata: Omit = { inputSchema: z.toJSONSchema(fetchActorDetailsToolArgsSchema) as ToolInputSchema, outputSchema: actorDetailsOutputSchema, ajvValidate: compileSchema(z.toJSONSchema(fetchActorDetailsToolArgsSchema)), + // openai/* keys are stripped in non-openai mode by stripOpenAiMeta() in src/utils/tools.ts _meta: { ...getWidgetConfig(WIDGET_URIS.SEARCH_ACTORS)?.meta, }, diff --git a/src/tools/core/get-actor-run-common.ts b/src/tools/core/get_actor_run_common.ts similarity index 94% rename from src/tools/core/get-actor-run-common.ts rename to src/tools/core/get_actor_run_common.ts index 6c26e14e..b99f34b6 100644 --- a/src/tools/core/get-actor-run-common.ts +++ b/src/tools/core/get_actor_run_common.ts @@ -2,14 +2,14 @@ import { z } from 'zod'; import log from '@apify/log'; -import type { ApifyClient } from '../../apify-client.js'; +import type { ApifyClient } from '../../apify_client.js'; import { HelperTools, TOOL_STATUS } from '../../const.js'; import { getWidgetConfig, WIDGET_URIS } from '../../resources/widgets.js'; import type { HelperTool, ToolInputSchema } from '../../types.js'; import { compileSchema } from '../../utils/ajv.js'; import { buildMCPResponse } from '../../utils/mcp.js'; -import { generateSchemaFromItems } from '../../utils/schema-generation.js'; -import { getActorRunOutputSchema } from '../structured-output-schemas.js'; +import { generateSchemaFromItems } from '../../utils/schema_generation.js'; +import { getActorRunOutputSchema } from '../structured_output_schemas.js'; /** * Zod schema for get-actor-run arguments — shared between default and openai variants. @@ -46,6 +46,7 @@ export const getActorRunMetadata: Omit = { outputSchema: getActorRunOutputSchema, ajvValidate: compileSchema({ ...z.toJSONSchema(getActorRunArgs), additionalProperties: true }), requiresSkyfirePayId: true, + // openai/* keys are stripped in non-openai mode by stripOpenAiMeta() in src/utils/tools.ts _meta: { ...getWidgetConfig(WIDGET_URIS.ACTOR_RUN)?.meta, }, diff --git a/src/tools/core/search-actors-common.ts b/src/tools/core/search_actors_common.ts similarity index 97% rename from src/tools/core/search-actors-common.ts rename to src/tools/core/search_actors_common.ts index ecb95509..f5ab639c 100644 --- a/src/tools/core/search-actors-common.ts +++ b/src/tools/core/search_actors_common.ts @@ -4,7 +4,7 @@ import { HelperTools } from '../../const.js'; import { getWidgetConfig, WIDGET_URIS } from '../../resources/widgets.js'; import type { HelperTool, ToolInputSchema } from '../../types.js'; import { compileSchema } from '../../utils/ajv.js'; -import { actorSearchOutputSchema } from '../structured-output-schemas.js'; +import { actorSearchOutputSchema } from '../structured_output_schemas.js'; /** * Zod schema for search-actors arguments — shared between default and openai variants. @@ -93,6 +93,7 @@ export const searchActorsMetadata: Omit = { inputSchema: z.toJSONSchema(searchActorsArgsSchema) as ToolInputSchema, outputSchema: actorSearchOutputSchema, ajvValidate: compileSchema(z.toJSONSchema(searchActorsArgsSchema)), + // openai/* keys are stripped in non-openai mode by stripOpenAiMeta() in src/utils/tools.ts _meta: { ...getWidgetConfig(WIDGET_URIS.SEARCH_ACTORS)?.meta, }, diff --git a/src/tools/default/actor-executor.ts b/src/tools/default/actor_executor.ts similarity index 90% rename from src/tools/default/actor-executor.ts rename to src/tools/default/actor_executor.ts index d92c5046..359db7a7 100644 --- a/src/tools/default/actor-executor.ts +++ b/src/tools/default/actor_executor.ts @@ -1,7 +1,7 @@ import type { ActorExecutionParams, ActorExecutionResult, ActorExecutor } from '../../types.js'; import { buildUsageMeta } from '../../utils/mcp.js'; -import { callActorGetDataset } from '../core/actor-execution.js'; -import { buildActorResponseContent } from '../core/actor-response.js'; +import { callActorGetDataset } from '../core/actor_execution.js'; +import { buildActorResponseContent } from '../core/actor_response.js'; /** * Default actor executor for normal (non-UI) mode. diff --git a/src/tools/default/call-actor.ts b/src/tools/default/call_actor.ts similarity index 95% rename from src/tools/default/call-actor.ts rename to src/tools/default/call_actor.ts index 9cbc7d26..bf3fa25e 100644 --- a/src/tools/default/call-actor.ts +++ b/src/tools/default/call_actor.ts @@ -1,20 +1,20 @@ import log from '@apify/log'; -import { createApifyClientWithSkyfireSupport } from '../../apify-client.js'; +import { createApifyClientWithSkyfireSupport } from '../../apify_client.js'; import { HelperTools } from '../../const.js'; import { getWidgetConfig, WIDGET_URIS } from '../../resources/widgets.js'; import type { InternalToolArgs, ToolEntry } from '../../types.js'; import { logHttpError } from '../../utils/logging.js'; import { buildMCPResponse, buildUsageMeta } from '../../utils/mcp.js'; -import { callActorGetDataset } from '../core/actor-execution.js'; -import { buildActorResponseContent } from '../core/actor-response.js'; +import { callActorGetDataset } from '../core/actor_execution.js'; +import { buildActorResponseContent } from '../core/actor_response.js'; import { callActorAjvValidate, callActorInputSchema, callActorPreExecute, resolveAndValidateActor, -} from '../core/call-actor-common.js'; -import { callActorOutputSchema } from '../structured-output-schemas.js'; +} from '../core/call_actor_common.js'; +import { callActorOutputSchema } from '../structured_output_schemas.js'; import { actorNameToToolName } from '../utils.js'; const CALL_ACTOR_DEFAULT_DESCRIPTION = `Call any Actor from the Apify Store. @@ -62,6 +62,7 @@ export const defaultCallActor: ToolEntry = Object.freeze({ outputSchema: callActorOutputSchema, ajvValidate: callActorAjvValidate, requiresSkyfirePayId: true, + // openai/* keys are stripped in non-openai mode by stripOpenAiMeta() in src/utils/tools.ts _meta: { ...getWidgetConfig(WIDGET_URIS.ACTOR_RUN)?.meta, }, diff --git a/src/tools/default/fetch-actor-details.ts b/src/tools/default/fetch_actor_details.ts similarity index 93% rename from src/tools/default/fetch-actor-details.ts rename to src/tools/default/fetch_actor_details.ts index 06434ba0..7741396c 100644 --- a/src/tools/default/fetch-actor-details.ts +++ b/src/tools/default/fetch_actor_details.ts @@ -1,4 +1,4 @@ -import { ApifyClient } from '../../apify-client.js'; +import { ApifyClient } from '../../apify_client.js'; import type { InternalToolArgs, ToolEntry } from '../../types.js'; import { buildActorDetailsTextResponse, @@ -6,12 +6,12 @@ import { buildCardOptions, fetchActorDetails, resolveOutputOptions, -} from '../../utils/actor-details.js'; +} from '../../utils/actor_details.js'; import { buildMCPResponse } from '../../utils/mcp.js'; import { fetchActorDetailsMetadata, fetchActorDetailsToolArgsSchema, -} from '../core/fetch-actor-details-common.js'; +} from '../core/fetch_actor_details_common.js'; /** * Default mode fetch-actor-details tool. diff --git a/src/tools/default/get-actor-run.ts b/src/tools/default/get_actor_run.ts similarity index 97% rename from src/tools/default/get-actor-run.ts rename to src/tools/default/get_actor_run.ts index 021d4dc1..8b41234f 100644 --- a/src/tools/default/get-actor-run.ts +++ b/src/tools/default/get_actor_run.ts @@ -1,4 +1,4 @@ -import { createApifyClientWithSkyfireSupport } from '../../apify-client.js'; +import { createApifyClientWithSkyfireSupport } from '../../apify_client.js'; import { TOOL_STATUS } from '../../const.js'; import type { InternalToolArgs, ToolEntry } from '../../types.js'; import { logHttpError } from '../../utils/logging.js'; @@ -7,7 +7,7 @@ import { fetchActorRunData, getActorRunArgs, getActorRunMetadata, -} from '../core/get-actor-run-common.js'; +} from '../core/get_actor_run_common.js'; /** * Default mode get-actor-run tool. diff --git a/src/tools/default/search-actors.ts b/src/tools/default/search_actors.ts similarity index 95% rename from src/tools/default/search-actors.ts rename to src/tools/default/search_actors.ts index 06fdea69..4ca155d4 100644 --- a/src/tools/default/search-actors.ts +++ b/src/tools/default/search_actors.ts @@ -1,12 +1,12 @@ import { HelperTools } from '../../const.js'; import type { InternalToolArgs, ToolEntry } from '../../types.js'; -import { formatActorToActorCard, formatActorToStructuredCard } from '../../utils/actor-card.js'; -import { searchAndFilterActors } from '../../utils/actor-search.js'; +import { formatActorToActorCard, formatActorToStructuredCard } from '../../utils/actor_card.js'; +import { searchAndFilterActors } from '../../utils/actor_search.js'; import { buildMCPResponse } from '../../utils/mcp.js'; import { searchActorsArgsSchema, searchActorsMetadata, -} from '../core/search-actors-common.js'; +} from '../core/search_actors_common.js'; /** * Default mode search-actors tool. diff --git a/src/tools/fetch-actor-details.ts b/src/tools/fetch-actor-details.ts deleted file mode 100644 index fa56141f..00000000 --- a/src/tools/fetch-actor-details.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Adapter for fetch-actor-details tool — delegates to the appropriate mode-specific variant. - * - * The original monolithic implementation has been split into: - * - `default/fetch-actor-details.ts` — full text response with output schema fetch - * - `openai/fetch-actor-details.ts` — simplified structured content with widget metadata - * - `core/fetch-actor-details-common.ts` — shared schema, description, and tool metadata - * - * This adapter file maintains backward compatibility for existing imports. - * PR #4 will wire variants directly into the category registry, making this adapter unnecessary. - */ -import type { HelperTool, InternalToolArgs, ToolEntry } from '../types.js'; -import { defaultFetchActorDetails } from './default/fetch-actor-details.js'; -import { openaiFetchActorDetails } from './openai/fetch-actor-details.js'; - -const defaultVariant = defaultFetchActorDetails as HelperTool; - -/** - * Adapter fetch-actor-details tool that dispatches to the correct mode-specific variant at runtime. - */ -export const fetchActorDetailsTool: ToolEntry = Object.freeze({ - ...defaultVariant, - call: async (toolArgs: InternalToolArgs) => { - const variant = (toolArgs.apifyMcpServer.options.uiMode === 'openai' - ? openaiFetchActorDetails - : defaultFetchActorDetails) as HelperTool; - return variant.call(toolArgs); - }, -}); diff --git a/src/tools/index.ts b/src/tools/index.ts index fc22264d..1f7dffac 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,8 +1,9 @@ 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 type { ServerMode, ToolCategory, ToolEntry } from '../types.js'; +import { getExpectedToolsByCategories } from '../utils/tool_categories_helpers.js'; +import { CATEGORY_NAME_SET, CATEGORY_NAMES, getCategoryTools, toolCategories, toolCategoriesEnabledByDefault } from './categories.js'; +import { callActorGetDataset } from './core/actor_execution.js'; +import { getActorsAsTools } from './core/actor_tools_factory.js'; // Use string constants instead of importing tool objects to avoid circular dependency export const unauthEnabledTools: string[] = [ @@ -13,18 +14,25 @@ export const unauthEnabledTools: string[] = [ // Re-export from categories.ts // This is actually needed to avoid circular dependency issues -export { toolCategories, toolCategoriesEnabledByDefault }; +export { CATEGORY_NAME_SET, CATEGORY_NAMES, getCategoryTools, toolCategories, toolCategoriesEnabledByDefault }; -// Computed here (not in helper file) to avoid module initialization issues -export const defaultTools = getExpectedToolsByCategories(toolCategoriesEnabledByDefault); +/** + * Returns the tool entries for the default-enabled categories resolved for the given mode. + * Computed here (not in helper file) to avoid module initialization issues. + */ +export function getDefaultTools(mode: ServerMode): ToolEntry[] { + return getExpectedToolsByCategories(toolCategoriesEnabledByDefault, mode); +} /** * Returns the list of tool categories that are enabled for unauthenticated users. * A category is included only if all tools in it are in the unauthEnabledTools list. + * Tool names are identical across all server modes, so no mode parameter is needed. */ export function getUnauthEnabledToolCategories(): ToolCategory[] { const unauthEnabledToolsSet = new Set(unauthEnabledTools); - return (Object.entries(toolCategories) as [ToolCategory, typeof toolCategories[ToolCategory]][]) + const categories = getCategoryTools('default'); + return (Object.entries(categories) as [ToolCategory, ToolEntry[]][]) .filter(([, tools]) => tools.every((tool) => unauthEnabledToolsSet.has(tool.name))) .map(([category]) => category); } diff --git a/src/tools/openai/actor-executor.ts b/src/tools/openai/actor_executor.ts similarity index 96% rename from src/tools/openai/actor-executor.ts rename to src/tools/openai/actor_executor.ts index 0920c8e9..e7bf1626 100644 --- a/src/tools/openai/actor-executor.ts +++ b/src/tools/openai/actor_executor.ts @@ -44,6 +44,7 @@ Do NOT proactively poll using ${HelperTools.ACTOR_RUNS_GET}. Wait for the widget return { content: [{ type: 'text' as const, text: responseText }], structuredContent, + // Response-level meta; only returned in openai mode (this executor is openai-only) _meta: { ...widgetConfig?.meta, 'openai/widgetDescription': `Actor run progress for ${params.actorFullName}`, diff --git a/src/tools/openai/call-actor.ts b/src/tools/openai/call_actor.ts similarity index 95% rename from src/tools/openai/call-actor.ts rename to src/tools/openai/call_actor.ts index 1d88f49a..e219e9d5 100644 --- a/src/tools/openai/call-actor.ts +++ b/src/tools/openai/call_actor.ts @@ -1,6 +1,6 @@ import log from '@apify/log'; -import { createApifyClientWithSkyfireSupport } from '../../apify-client.js'; +import { createApifyClientWithSkyfireSupport } from '../../apify_client.js'; import { HelperTools } from '../../const.js'; import { getWidgetConfig, WIDGET_URIS } from '../../resources/widgets.js'; import type { InternalToolArgs, ToolEntry } from '../../types.js'; @@ -11,8 +11,8 @@ import { callActorInputSchema, callActorPreExecute, resolveAndValidateActor, -} from '../core/call-actor-common.js'; -import { callActorOutputSchema } from '../structured-output-schemas.js'; +} from '../core/call_actor_common.js'; +import { callActorOutputSchema } from '../structured_output_schemas.js'; import { actorNameToToolName } from '../utils.js'; const CALL_ACTOR_OPENAI_DESCRIPTION = `Call any Actor from the Apify Store. @@ -61,6 +61,7 @@ export const openaiCallActor: ToolEntry = Object.freeze({ outputSchema: callActorOutputSchema, ajvValidate: callActorAjvValidate, requiresSkyfirePayId: true, + // openai-only tool; openai/* keys also stripped in non-openai mode by stripOpenAiMeta() in src/utils/tools.ts _meta: { ...getWidgetConfig(WIDGET_URIS.ACTOR_RUN)?.meta, }, @@ -125,6 +126,7 @@ Do NOT proactively poll using ${HelperTools.ACTOR_RUNS_GET}. Wait for the widget text: responseText, }], structuredContent, + // Response-level meta; only returned in openai mode (this handler is openai-only) _meta: { ...widgetConfig?.meta, 'openai/widgetDescription': `Actor run progress for ${baseActorName}`, diff --git a/src/tools/openai/fetch-actor-details.ts b/src/tools/openai/fetch_actor_details.ts similarity index 89% rename from src/tools/openai/fetch-actor-details.ts rename to src/tools/openai/fetch_actor_details.ts index 2c6feb6f..50ace0da 100644 --- a/src/tools/openai/fetch-actor-details.ts +++ b/src/tools/openai/fetch_actor_details.ts @@ -1,4 +1,4 @@ -import { ApifyClient } from '../../apify-client.js'; +import { ApifyClient } from '../../apify_client.js'; import { getWidgetConfig, WIDGET_URIS } from '../../resources/widgets.js'; import type { InternalToolArgs, ToolEntry } from '../../types.js'; import { @@ -7,12 +7,12 @@ import { fetchActorDetails, processActorDetailsForResponse, resolveOutputOptions, -} from '../../utils/actor-details.js'; +} from '../../utils/actor_details.js'; import { buildMCPResponse } from '../../utils/mcp.js'; import { fetchActorDetailsMetadata, fetchActorDetailsToolArgsSchema, -} from '../core/fetch-actor-details-common.js'; +} from '../core/fetch_actor_details_common.js'; /** * OpenAI mode fetch-actor-details tool. @@ -52,6 +52,7 @@ An interactive widget has been rendered with detailed Actor information. return buildMCPResponse({ texts, structuredContent, + // Response-level meta; only returned in openai mode (this handler is openai-only) _meta: { ...widgetConfig?.meta, 'openai/widgetDescription': `Actor details for ${parsed.actor} from Apify Store`, diff --git a/src/tools/openai/fetch-actor-details-internal.ts b/src/tools/openai/fetch_actor_details_internal.ts similarity index 95% rename from src/tools/openai/fetch-actor-details-internal.ts rename to src/tools/openai/fetch_actor_details_internal.ts index 42434579..986dbc73 100644 --- a/src/tools/openai/fetch-actor-details-internal.ts +++ b/src/tools/openai/fetch_actor_details_internal.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -import { ApifyClient } from '../../apify-client.js'; +import { ApifyClient } from '../../apify_client.js'; import { HelperTools } from '../../const.js'; import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../../types.js'; import { @@ -10,10 +10,10 @@ import { buildCardOptions, fetchActorDetails, resolveOutputOptions, -} from '../../utils/actor-details.js'; +} from '../../utils/actor_details.js'; import { compileSchema } from '../../utils/ajv.js'; import { buildMCPResponse } from '../../utils/mcp.js'; -import { actorDetailsOutputSchema } from '../structured-output-schemas.js'; +import { actorDetailsOutputSchema } from '../structured_output_schemas.js'; const fetchActorDetailsInternalArgsSchema = z.object({ actor: z.string() @@ -26,7 +26,6 @@ const fetchActorDetailsInternalArgsSchema = z.object({ export const fetchActorDetailsInternalTool: ToolEntry = Object.freeze({ type: 'internal', name: HelperTools.ACTOR_GET_DETAILS_INTERNAL, - openaiOnly: true, description: `Fetch Actor details with flexible output options (UI mode internal tool). This tool is available because the LLM is operating in UI mode. Use it for internal lookups diff --git a/src/tools/openai/get-actor-run.ts b/src/tools/openai/get_actor_run.ts similarity index 94% rename from src/tools/openai/get-actor-run.ts rename to src/tools/openai/get_actor_run.ts index c672f712..ba423f47 100644 --- a/src/tools/openai/get-actor-run.ts +++ b/src/tools/openai/get_actor_run.ts @@ -1,4 +1,4 @@ -import { createApifyClientWithSkyfireSupport } from '../../apify-client.js'; +import { createApifyClientWithSkyfireSupport } from '../../apify_client.js'; import { TOOL_STATUS } from '../../const.js'; import { getWidgetConfig, WIDGET_URIS } from '../../resources/widgets.js'; import type { InternalToolArgs, ToolEntry } from '../../types.js'; @@ -8,7 +8,7 @@ import { fetchActorRunData, getActorRunArgs, getActorRunMetadata, -} from '../core/get-actor-run-common.js'; +} from '../core/get_actor_run_common.js'; /** * OpenAI mode get-actor-run tool. @@ -44,6 +44,7 @@ export const openaiGetActorRun: ToolEntry = Object.freeze({ return buildMCPResponse({ texts: [statusText], structuredContent, + // Response-level meta; only returned in openai mode (this handler is openai-only) _meta: { ...widgetConfig?.meta, ...usageMeta, diff --git a/src/tools/openai/search-actors.ts b/src/tools/openai/search_actors.ts similarity index 93% rename from src/tools/openai/search-actors.ts rename to src/tools/openai/search_actors.ts index 49f5b940..783869c8 100644 --- a/src/tools/openai/search-actors.ts +++ b/src/tools/openai/search_actors.ts @@ -1,13 +1,13 @@ import { HelperTools } from '../../const.js'; import { getWidgetConfig, WIDGET_URIS } from '../../resources/widgets.js'; import type { InternalToolArgs, ToolEntry } from '../../types.js'; -import { formatActorForWidget, formatActorToActorCard, formatActorToStructuredCard, type WidgetActor } from '../../utils/actor-card.js'; -import { searchAndFilterActors } from '../../utils/actor-search.js'; +import { formatActorForWidget, formatActorToActorCard, formatActorToStructuredCard, type WidgetActor } from '../../utils/actor_card.js'; +import { searchAndFilterActors } from '../../utils/actor_search.js'; import { buildMCPResponse } from '../../utils/mcp.js'; import { searchActorsArgsSchema, searchActorsMetadata, -} from '../core/search-actors-common.js'; +} from '../core/search_actors_common.js'; /** * OpenAI mode search-actors tool. @@ -75,6 +75,7 @@ An interactive widget has been rendered with the search results. The user can al return buildMCPResponse({ texts, structuredContent, + // Response-level meta; only returned in openai mode (this handler is openai-only) _meta: { ...widgetConfig?.meta, 'openai/widgetDescription': `Interactive actor search results showing ${actors.length} actors from Apify Store`, diff --git a/src/tools/openai/search-actors-internal.ts b/src/tools/openai/search_actors_internal.ts similarity index 95% rename from src/tools/openai/search-actors-internal.ts rename to src/tools/openai/search_actors_internal.ts index beb4c9c4..8de9d8c2 100644 --- a/src/tools/openai/search-actors-internal.ts +++ b/src/tools/openai/search_actors_internal.ts @@ -2,10 +2,10 @@ import { z } from 'zod'; import { HelperTools } from '../../const.js'; import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../../types.js'; -import { searchAndFilterActors } from '../../utils/actor-search.js'; +import { searchAndFilterActors } from '../../utils/actor_search.js'; import { compileSchema } from '../../utils/ajv.js'; import { buildMCPResponse } from '../../utils/mcp.js'; -import { actorSearchInternalOutputSchema } from '../structured-output-schemas.js'; +import { actorSearchInternalOutputSchema } from '../structured_output_schemas.js'; const searchActorsInternalArgsSchema = z.object({ limit: z.number() @@ -30,7 +30,6 @@ const searchActorsInternalArgsSchema = z.object({ export const searchActorsInternalTool: ToolEntry = Object.freeze({ type: 'internal', name: HelperTools.STORE_SEARCH_INTERNAL, - openaiOnly: true, description: `Search Actors internally (UI mode internal tool). This tool is available because the LLM is operating in UI mode. Use it for internal lookups diff --git a/src/tools/store_collection.ts b/src/tools/store_collection.ts deleted file mode 100644 index 2de09587..00000000 --- a/src/tools/store_collection.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Adapter for search-actors tool — delegates to the appropriate mode-specific variant. - * - * The original monolithic implementation has been split into: - * - `default/search-actors.ts` — text-based actor cards without widget metadata - * - `openai/search-actors.ts` — widget-formatted actors with interactive widget metadata - * - `core/search-actors-common.ts` — shared schema, description, and tool metadata - * - * This adapter file maintains backward compatibility for existing imports. - * PR #4 will wire variants directly into the category registry, making this adapter unnecessary. - */ -import type { HelperTool, InternalToolArgs, ToolEntry } from '../types.js'; -import { defaultSearchActors } from './default/search-actors.js'; -import { openaiSearchActors } from './openai/search-actors.js'; - -// Re-export the shared schema for use in other modules -export { searchActorsArgsSchema } from './core/search-actors-common.js'; - -const defaultVariant = defaultSearchActors as HelperTool; - -/** - * Adapter search-actors tool that dispatches to the correct mode-specific variant at runtime. - */ -export const searchActors: ToolEntry = Object.freeze({ - ...defaultVariant, - call: async (toolArgs: InternalToolArgs) => { - const variant = (toolArgs.apifyMcpServer.options.uiMode === 'openai' - ? openaiSearchActors - : defaultSearchActors) as HelperTool; - return variant.call(toolArgs); - }, -}); diff --git a/src/tools/structured-output-schemas.ts b/src/tools/structured_output_schemas.ts similarity index 100% rename from src/tools/structured-output-schemas.ts rename to src/tools/structured_output_schemas.ts diff --git a/src/tools/utils.ts b/src/tools/utils.ts index 86229738..adb4d1f6 100644 --- a/src/tools/utils.ts +++ b/src/tools/utils.ts @@ -10,7 +10,7 @@ import { addPseudoUrlsProperties, addRequestListSourcesProperties, addResourcePickerProperties as addArrayResourcePickerProperties, -} from '../utils/apify-properties.js'; +} from '../utils/apify_properties.js'; /* * Checks if the given ActorInfo represents an MCP server Actor. diff --git a/src/types.ts b/src/types.ts index 3369fbf1..975b0b26 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,11 +14,11 @@ import type { } from 'apify-client'; import type z from 'zod'; -import type { ApifyClient } from './apify-client.js'; +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 { StructuredPricingInfo } from './utils/pricing-info.js'; +import type { CATEGORY_NAMES } from './tools/categories.js'; +import type { StructuredPricingInfo } from './utils/pricing_info.js'; import type { ProgressTracker } from './utils/progress.js'; export type SchemaProperties = { @@ -91,7 +91,11 @@ export type ToolBase = z.infer & { ajvValidate: ValidateFunction; /** Whether this tool requires Skyfire pay ID validation (uses Apify API) */ requiresSkyfirePayId?: boolean; - /** Whether this tool is only available in OpenAI UI mode */ + /** + * Whether this tool is only available in OpenAI UI mode. + * @deprecated No longer used for filtering. Mode-specific tools are resolved at build time + * via getCategoryTools(mode). Will be removed in a future release. + */ openaiOnly?: boolean; }; @@ -230,7 +234,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. */ @@ -387,9 +391,35 @@ export type ToolCallTelemetryProperties = { }; /** - * UI mode for tool responses. + * Server mode controls which tool variants are served. + * + * - `'default'` — standard MCP tools for generic clients + * - `'openai'` — OpenAI-specific tool variants (async execution, widget metadata, internal tools) + * + * Every call site that resolves tool categories must pass an explicit ServerMode value. + * This prevents accidentally serving wrong-mode tools. */ -export type UiMode = 'openai'; +export type ServerMode = 'default' | 'openai'; + +/** All valid server modes, for iteration in tests and caches. */ +export const SERVER_MODES: readonly ServerMode[] = ['default', 'openai'] as const; + +/** + * UI mode for tool responses — the external-facing subset of ServerMode. + * Derived from ServerMode: excludes 'default' since the absence of a UI mode means default. + */ +export type UiMode = Exclude; + +/** Set of valid UiMode values for O(1) membership checks at runtime. */ +const UI_MODES: ReadonlySet = new Set(SERVER_MODES.filter((m): m is UiMode => m !== 'default')); + +/** + * Parse an untrusted string into a valid UiMode, returning `undefined` for invalid values. + * Use at ingestion boundaries (URL params, env vars) to prevent invalid modes from propagating. + */ +export function parseUiMode(value: string | null | undefined): UiMode | undefined { + return value && UI_MODES.has(value) ? (value as UiMode) : undefined; +} /** * Parameters for executing a direct actor tool (`type: 'actor'`). @@ -424,7 +454,7 @@ export type ActorExecutionResult = { /** * Executor for direct actor tools (`type: 'actor'`). - * Selected at server construction time based on uiMode. + * Selected at server construction time based on serverMode. * Default mode runs synchronously; OpenAI mode runs async with widget metadata. */ export type ActorExecutor = { @@ -530,7 +560,8 @@ export type ActorsMcpServerOptions = { /** * UI mode for tool responses. * - 'openai': OpenAI specific widget rendering - * If not specified, there will be no widget rendering. + * If not specified, defaults to 'default' mode (no widget rendering). + * Normalized to {@link ServerMode} at server construction. */ uiMode?: UiMode; } diff --git a/src/utils/actor.ts b/src/utils/actor.ts index 720f4ee8..f170cc7f 100644 --- a/src/utils/actor.ts +++ b/src/utils/actor.ts @@ -1,4 +1,4 @@ -import type { ApifyClient } from '../apify-client.js'; +import type { ApifyClient } from '../apify_client.js'; import { getActorMCPServerPath, getActorMCPServerURL } from '../mcp/actors.js'; import { mcpServerCache } from '../state.js'; import { getActorDefinition } from '../tools/build.js'; diff --git a/src/utils/actor-card.ts b/src/utils/actor_card.ts similarity index 99% rename from src/utils/actor-card.ts rename to src/utils/actor_card.ts index 7dad2b42..22e96e96 100644 --- a/src/utils/actor-card.ts +++ b/src/utils/actor_card.ts @@ -1,6 +1,6 @@ import { APIFY_STORE_URL } from '../const.js'; import type { Actor, ActorCardOptions, ActorStoreList, PricingInfo, StructuredActorCard } from '../types.js'; -import { getCurrentPricingInfo, pricingInfoToString, pricingInfoToStructured, type StructuredPricingInfo } from './pricing-info.js'; +import { getCurrentPricingInfo, pricingInfoToString, pricingInfoToStructured, type StructuredPricingInfo } from './pricing_info.js'; // Helper function to format categories from uppercase with underscores to proper case function formatCategories(categories?: string[]): string[] { diff --git a/src/utils/actor-details.ts b/src/utils/actor_details.ts similarity index 98% rename from src/utils/actor-details.ts rename to src/utils/actor_details.ts index 5e381679..9313dd9a 100644 --- a/src/utils/actor-details.ts +++ b/src/utils/actor_details.ts @@ -1,14 +1,14 @@ import type { Build } from 'apify-client'; import { z } from 'zod'; -import type { ApifyClient } from '../apify-client.js'; +import type { ApifyClient } from '../apify_client.js'; import { HelperTools, TOOL_STATUS } from '../const.js'; import { connectMCPClient } from '../mcp/client.js'; import { filterSchemaProperties, shortenProperties } from '../tools/utils.js'; import type { Actor, ActorCardOptions, ActorInputSchema, ActorStoreList, StructuredActorCard } from '../types.js'; import { getActorMcpUrlCached } from './actor.js'; -import { formatActorDetailsForWidget, formatActorToActorCard, formatActorToStructuredCard } from './actor-card.js'; -import { searchActorsByKeywords } from './actor-search.js'; +import { formatActorDetailsForWidget, formatActorToActorCard, formatActorToStructuredCard } from './actor_card.js'; +import { searchActorsByKeywords } from './actor_search.js'; import { logHttpError } from './logging.js'; import { buildMCPResponse } from './mcp.js'; diff --git a/src/utils/actor-search.ts b/src/utils/actor_search.ts similarity index 98% rename from src/utils/actor-search.ts rename to src/utils/actor_search.ts index 76da6584..f0cd350d 100644 --- a/src/utils/actor-search.ts +++ b/src/utils/actor_search.ts @@ -4,7 +4,7 @@ * of the filtering step and reduce code duplication. */ -import { ApifyClient } from '../apify-client.js'; +import { ApifyClient } from '../apify_client.js'; import { ACTOR_SEARCH_ABOVE_LIMIT } from '../const.js'; import type { ActorPricingModel, ActorStoreList } from '../types.js'; diff --git a/src/utils/apify-docs.ts b/src/utils/apify_docs.ts similarity index 100% rename from src/utils/apify-docs.ts rename to src/utils/apify_docs.ts diff --git a/src/utils/apify-properties.ts b/src/utils/apify_properties.ts similarity index 100% rename from src/utils/apify-properties.ts rename to src/utils/apify_properties.ts diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 9f1a8779..3cc2981b 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -1,8 +1,10 @@ -import { getUnauthEnabledToolCategories, toolCategories, unauthEnabledTools } from '../tools/index.js'; +import { CATEGORY_NAME_SET, getUnauthEnabledToolCategories, unauthEnabledTools } from '../tools/index.js'; import type { ToolCategory } from '../types.js'; /** * Determines if an API token is required based on requested tools and actors. + * Tool names and category membership are identical across all server modes, + * so no mode parameter is needed. */ export function isApiTokenRequired(params: { toolCategoryKeys?: string[]; @@ -28,7 +30,7 @@ export function isApiTokenRequired(params: { if (unauthTokenSet.has(key)) return true; // If it is a known category but not safe -> unsafe - if (key in toolCategories) return false; + if (CATEGORY_NAME_SET.has(key)) return false; // Otherwise it is likely an Actor name -> unsafe return false; diff --git a/src/utils/html-to-md.ts b/src/utils/html_to_md.ts similarity index 100% rename from src/utils/html-to-md.ts rename to src/utils/html_to_md.ts diff --git a/src/utils/mcp-clients.ts b/src/utils/mcp_clients.ts similarity index 100% rename from src/utils/mcp-clients.ts rename to src/utils/mcp_clients.ts diff --git a/src/utils/pricing-info.ts b/src/utils/pricing_info.ts similarity index 100% rename from src/utils/pricing-info.ts rename to src/utils/pricing_info.ts diff --git a/src/utils/progress.ts b/src/utils/progress.ts index 6bcceb05..592096a0 100644 --- a/src/utils/progress.ts +++ b/src/utils/progress.ts @@ -1,6 +1,6 @@ import type { ProgressNotification } from '@modelcontextprotocol/sdk/types.js'; -import type { ApifyClient } from '../apify-client.js'; +import type { ApifyClient } from '../apify_client.js'; import { PROGRESS_NOTIFICATION_INTERVAL_MS } from '../const.js'; export class ProgressTracker { diff --git a/src/utils/schema-generation.ts b/src/utils/schema_generation.ts similarity index 100% rename from src/utils/schema-generation.ts rename to src/utils/schema_generation.ts diff --git a/src/utils/server-instructions.ts b/src/utils/server-instructions/common.ts similarity index 55% rename from src/utils/server-instructions.ts rename to src/utils/server-instructions/common.ts index 07bdc47a..681cd8d2 100644 --- a/src/utils/server-instructions.ts +++ b/src/utils/server-instructions/common.ts @@ -1,49 +1,24 @@ /** - * Server instructions builder with conditional content based on UI mode. - * Generates instructions for the MCP server that adapt based on whether UI mode is enabled. + * Shared server instructions — mode-independent content about Actors, discovery, + * execution workflow, storage, and tool disambiguation. */ -import { HelperTools, RAG_WEB_BROWSER } from '../const.js'; -import type { UiMode } from '../types.js'; +import { HelperTools, RAG_WEB_BROWSER } from '../../const.js'; + +type CommonInstructionsInput = { + /** Mode-specific hint for which tool to use to obtain the Actor's input schema. */ + schemaToolHint: string; + /** Mode-specific workflow rules inserted before the tool dependencies section. */ + workflowRules: string; + /** Mode-specific tool disambiguation content appended to the disambiguation section. */ + toolDisambiguation: string; +}; /** - * Build server instructions conditionally based on UI mode. - * In UI mode, includes sections about internal tools and UI mode workflow rules. - * - * @param uiMode - The UI mode ('openai' or undefined) - * @returns Server instructions string + * Returns the common server instructions shared across all modes. + * Mode-specific content is injected via the input object at designated insertion points. */ -export function getServerInstructions(uiMode?: UiMode): string { - const isUiMode = uiMode === 'openai'; - - // Tool dependency hint - different based on mode - const schemaToolHint = isUiMode - ? `Use \`${HelperTools.ACTOR_GET_DETAILS_INTERNAL}\` first to obtain the Actor's input schema` - : `Use \`${HelperTools.ACTOR_GET_DETAILS}\` first to obtain the Actor's input schema`; - - // UI Mode workflow rules - only in UI mode - const uiModeWorkflowRules = isUiMode - ? ` -## CRITICAL: UI Mode Workflow Rules - -**NEVER call \`${HelperTools.ACTOR_RUNS_GET}\` after \`${HelperTools.ACTOR_CALL}\` in UI mode.** - -When you call \`${HelperTools.ACTOR_CALL}\` in async mode (UI mode), the response will include a widget that automatically polls for status updates. You must NOT call \`${HelperTools.ACTOR_RUNS_GET}\` or any other tool after this - your task is complete. The widget handles everything automatically. - -This is FORBIDDEN and will result in unnecessary duplicate polling. Always stop after receiving the \`${HelperTools.ACTOR_CALL}\` response in UI mode. - -` - : ''; - - // Internal vs public tools section - only in UI mode - const internalToolsSection = isUiMode - ? ` -- **Internal vs public Actor tools:** - - \`${HelperTools.STORE_SEARCH_INTERNAL}\` is for silent name resolution; \`${HelperTools.STORE_SEARCH}\` is for user-facing discovery - - \`${HelperTools.ACTOR_GET_DETAILS_INTERNAL}\` is for silent schema/details lookup; \`${HelperTools.ACTOR_GET_DETAILS}\` is for user-facing details - - When the next step is running an Actor, ALWAYS use \`${HelperTools.STORE_SEARCH_INTERNAL}\` for name resolution, never \`${HelperTools.STORE_SEARCH}\`` - : ''; - +export function getCommonInstructions(input: CommonInstructionsInput): string { return ` Apify is the world's largest marketplace of tools for web scraping, data extraction, and web automation. These tools are called **Actors**. They enable you to extract structured data from social media, e-commerce, search engines, maps, travel sites, and many other sources. @@ -69,11 +44,11 @@ These tools are called **Actors**. They enable you to extract structured data fr ## Storage types - **Dataset:** Structured, append-only storage ideal for tabular or list data (e.g., scraped items). - **Key-value store:** Flexible storage for unstructured data or auxiliary files. -${uiModeWorkflowRules}## Tool dependencies and disambiguation +${input.workflowRules}## Tool dependencies and disambiguation ### Tool dependencies - \`${HelperTools.ACTOR_CALL}\`: - - ${schemaToolHint} + - ${input.schemaToolHint} - Then call with proper input to execute the Actor - For MCP server Actors, use format "actorName:toolName" to call specific tools - Supports async execution via the \`async\` parameter: @@ -84,7 +59,7 @@ ${uiModeWorkflowRules}## Tool dependencies and disambiguation - **${HelperTools.ACTOR_OUTPUT_GET} vs ${HelperTools.DATASET_GET_ITEMS}:** Use \`${HelperTools.ACTOR_OUTPUT_GET}\` for Actor run outputs and \`${HelperTools.DATASET_GET_ITEMS}\` for direct dataset access. - **${HelperTools.STORE_SEARCH} vs ${HelperTools.ACTOR_GET_DETAILS}:** - \`${HelperTools.STORE_SEARCH}\` finds Actors; \`${HelperTools.ACTOR_GET_DETAILS}\` retrieves detailed info, README, and schema for a specific Actor.${internalToolsSection} + \`${HelperTools.STORE_SEARCH}\` finds Actors; \`${HelperTools.ACTOR_GET_DETAILS}\` retrieves detailed info, README, and schema for a specific Actor.${input.toolDisambiguation} - **${HelperTools.STORE_SEARCH} vs ${RAG_WEB_BROWSER}:** \`${HelperTools.STORE_SEARCH}\` finds robust and reliable Actors for specific websites; ${RAG_WEB_BROWSER} is a general and versatile web scraping tool. - **Dedicated Actor tools (e.g. ${RAG_WEB_BROWSER}) vs ${HelperTools.ACTOR_CALL}:** diff --git a/src/utils/server-instructions/default.ts b/src/utils/server-instructions/default.ts new file mode 100644 index 00000000..08abb073 --- /dev/null +++ b/src/utils/server-instructions/default.ts @@ -0,0 +1,15 @@ +/** + * Default mode server instructions — standard tool references without UI-specific rules. + */ + +import { HelperTools } from '../../const.js'; +import { getCommonInstructions } from './common.js'; + +/** Returns server instructions for default (non-UI) mode. */ +export function getDefaultInstructions(): string { + return getCommonInstructions({ + schemaToolHint: `Use \`${HelperTools.ACTOR_GET_DETAILS}\` first to obtain the Actor's input schema`, + workflowRules: '', + toolDisambiguation: '', + }); +} diff --git a/src/utils/server-instructions/index.ts b/src/utils/server-instructions/index.ts new file mode 100644 index 00000000..e7343405 --- /dev/null +++ b/src/utils/server-instructions/index.ts @@ -0,0 +1,21 @@ +/** + * Server instructions entry point. + * Selects the appropriate instructions based on server mode. + */ + +import type { ServerMode } from '../../types.js'; +import { getDefaultInstructions } from './default.js'; +import { getOpenaiInstructions } from './openai.js'; + +/** Mode → instructions builder. Add new modes here. */ +const instructionsByMode: Record string> = { + default: getDefaultInstructions, + openai: getOpenaiInstructions, +}; + +/** + * Build server instructions for the given server mode. + */ +export function getServerInstructions(mode: ServerMode): string { + return instructionsByMode[mode](); +} diff --git a/src/utils/server-instructions/openai.ts b/src/utils/server-instructions/openai.ts new file mode 100644 index 00000000..b4aff765 --- /dev/null +++ b/src/utils/server-instructions/openai.ts @@ -0,0 +1,33 @@ +/** + * OpenAI UI mode server instructions — includes widget workflow rules and + * internal vs public tool disambiguation. + */ + +import { HelperTools } from '../../const.js'; +import { getCommonInstructions } from './common.js'; + +const WORKFLOW_RULES = ` +## CRITICAL: UI Mode Workflow Rules + +**NEVER call \`${HelperTools.ACTOR_RUNS_GET}\` after \`${HelperTools.ACTOR_CALL}\` in UI mode.** + +When you call \`${HelperTools.ACTOR_CALL}\` in async mode (UI mode), the response will include a widget that automatically polls for status updates. You must NOT call \`${HelperTools.ACTOR_RUNS_GET}\` or any other tool after this - your task is complete. The widget handles everything automatically. + +This is FORBIDDEN and will result in unnecessary duplicate polling. Always stop after receiving the \`${HelperTools.ACTOR_CALL}\` response in UI mode. + +`; + +const TOOL_DISAMBIGUATION = ` +- **Internal vs public Actor tools:** + - \`${HelperTools.STORE_SEARCH_INTERNAL}\` is for silent name resolution; \`${HelperTools.STORE_SEARCH}\` is for user-facing discovery + - \`${HelperTools.ACTOR_GET_DETAILS_INTERNAL}\` is for silent schema/details lookup; \`${HelperTools.ACTOR_GET_DETAILS}\` is for user-facing details + - When the next step is running an Actor, ALWAYS use \`${HelperTools.STORE_SEARCH_INTERNAL}\` for name resolution, never \`${HelperTools.STORE_SEARCH}\``; + +/** Returns server instructions for OpenAI UI mode. */ +export function getOpenaiInstructions(): string { + return getCommonInstructions({ + schemaToolHint: `Use \`${HelperTools.ACTOR_GET_DETAILS_INTERNAL}\` first to obtain the Actor's input schema`, + workflowRules: WORKFLOW_RULES, + toolDisambiguation: TOOL_DISAMBIGUATION, + }); +} diff --git a/src/utils/tool-categories-helpers.ts b/src/utils/tool-categories-helpers.ts deleted file mode 100644 index 3a8c78ca..00000000 --- a/src/utils/tool-categories-helpers.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Helper functions for working with tool categories. - * Separated from tools.ts to break circular dependency: tools/index.ts → utils/tools.ts → tools/categories.ts → tools/index.ts - */ -import { toolCategories } from '../tools/categories.js'; -import type { ToolCategory, ToolEntry } from '../types.js'; - -/** - * Returns the tool objects for the given category names using toolCategories. - */ -export function getExpectedToolsByCategories(categories: ToolCategory[]): ToolEntry[] { - return categories - .flatMap((category) => toolCategories[category] || []); -} - -/** - * Returns the tool names for the given category names using getExpectedToolsByCategories. - */ -export function getExpectedToolNamesByCategories(categories: ToolCategory[]): string[] { - return getExpectedToolsByCategories(categories).map((tool) => tool.name); -} diff --git a/src/utils/tool_categories_helpers.ts b/src/utils/tool_categories_helpers.ts new file mode 100644 index 00000000..18e81460 --- /dev/null +++ b/src/utils/tool_categories_helpers.ts @@ -0,0 +1,23 @@ +/** + * Helper functions for working with tool categories. + * Separated from tools.ts to break circular dependency: tools/index.ts → utils/tools.ts → tools/categories.ts → tools/index.ts + */ +import { getCategoryTools } from '../tools/categories.js'; +import type { ServerMode, ToolCategory, ToolEntry } from '../types.js'; + +/** + * Returns the tool objects for the given category names resolved for the specified mode. + */ +export function getExpectedToolsByCategories(categories: ToolCategory[], mode: ServerMode): ToolEntry[] { + const resolved = getCategoryTools(mode); + return categories + .flatMap((category) => resolved[category] || []); +} + +/** + * Returns the tool names for the given category names. + * Tool names are identical across all server modes, so no mode parameter is needed. + */ +export function getExpectedToolNamesByCategories(categories: ToolCategory[]): string[] { + return getExpectedToolsByCategories(categories, 'default').map((tool) => tool.name); +} diff --git a/src/utils/tool-status.ts b/src/utils/tool_status.ts similarity index 100% rename from src/utils/tool-status.ts rename to src/utils/tool_status.ts diff --git a/src/utils/tools.ts b/src/utils/tools.ts index e77f0b93..05eb65dc 100644 --- a/src/utils/tools.ts +++ b/src/utils/tools.ts @@ -1,7 +1,13 @@ -import type { ActorsMcpServerOptions, HelperTool, ToolBase, ToolEntry } from '../types.js'; +import { + type HelperTools, + SKYFIRE_ENABLED_TOOLS, + SKYFIRE_PAY_ID_PROPERTY_DESCRIPTION, + SKYFIRE_TOOL_INSTRUCTIONS, +} from '../const.js'; +import type { HelperTool, ServerMode, ToolBase, ToolEntry } from '../types.js'; type ToolPublicFieldOptions = { - uiMode?: ActorsMcpServerOptions['uiMode']; + mode?: ServerMode; filterOpenAiMeta?: boolean; }; @@ -26,8 +32,8 @@ function stripOpenAiMeta(meta?: ToolBase['_meta']) { * Used for the tools list request. */ export function getToolPublicFieldOnly(tool: ToolBase, options: ToolPublicFieldOptions = {}) { - const { uiMode, filterOpenAiMeta = false } = options; - const meta = filterOpenAiMeta && uiMode !== 'openai' + const { mode, filterOpenAiMeta = false } = options; + const meta = filterOpenAiMeta && mode !== 'openai' ? stripOpenAiMeta(tool._meta) : tool._meta; @@ -67,3 +73,42 @@ export function cloneToolEntry(toolEntry: ToolEntry): ToolEntry { return cloned; } + +/** Returns true if the tool is eligible for Skyfire augmentation. */ +function isSkyfireEligible(tool: ToolEntry): boolean { + return tool.type === 'actor' + || (tool.type === 'internal' && SKYFIRE_ENABLED_TOOLS.has(tool.name as HelperTools)); +} + +/** + * Applies Skyfire augmentation to a tool entry. + * Clones the tool and, if eligible, appends Skyfire instructions to the description + * and adds a `skyfire-pay-id` property to the input schema. + * + * Returns the (possibly augmented) clone if the tool is eligible, + * or the original tool reference if it is not eligible. + * Augmentation is idempotent — calling this on an already-augmented clone is safe. + */ +export function applySkyfireAugmentation(tool: ToolEntry): ToolEntry { + if (!isSkyfireEligible(tool)) return tool; + + const cloned = cloneToolEntry(tool); + + // Append Skyfire instructions to description (idempotent) + if (cloned.description && !cloned.description.includes(SKYFIRE_TOOL_INSTRUCTIONS)) { + cloned.description += `\n\n${SKYFIRE_TOOL_INSTRUCTIONS}`; + } + + // Add skyfire-pay-id property to inputSchema (idempotent) + if (cloned.inputSchema && 'properties' in cloned.inputSchema) { + const props = cloned.inputSchema.properties as Record; + if (!props['skyfire-pay-id']) { + props['skyfire-pay-id'] = { + type: 'string', + description: SKYFIRE_PAY_ID_PROPERTY_DESCRIPTION, + }; + } + } + + return cloned; +} diff --git a/src/utils/tools-loader.ts b/src/utils/tools_loader.ts similarity index 65% rename from src/utils/tools-loader.ts rename to src/utils/tools_loader.ts index d48a5b1b..a7aa567c 100644 --- a/src/utils/tools-loader.ts +++ b/src/utils/tools_loader.ts @@ -3,30 +3,39 @@ * This eliminates duplication between stdio.ts and processParamsGetTools. */ -import type { ValidateFunction } from 'ajv'; import type { ApifyClient } from 'apify'; import log from '@apify/log'; import { defaults, HelperTools } from '../const.js'; -import { callActor, getCallActorDescription } from '../tools/actor.js'; -import { getActorOutput } from '../tools/common/get-actor-output.js'; -import { addTool } from '../tools/common/helpers.js'; -import { getActorRun } from '../tools/common/run.js'; -import { getActorsAsTools, toolCategories, toolCategoriesEnabledByDefault } from '../tools/index.js'; -import type { ActorStore, Input, InternalToolArgs, ToolCategory, ToolEntry, UiMode } from '../types.js'; -import { getExpectedToolsByCategories } from './tool-categories-helpers.js'; - -// Lazily-computed cache of internal tools by name to avoid circular init issues. -let INTERNAL_TOOL_BY_NAME_CACHE: Map | null = null; -function getInternalToolByNameMap(): Map { - if (!INTERNAL_TOOL_BY_NAME_CACHE) { - const allInternal = getExpectedToolsByCategories(Object.keys(toolCategories) as ToolCategory[]); - INTERNAL_TOOL_BY_NAME_CACHE = new Map( - allInternal.map((entry) => [entry.name, entry]), - ); +import { CATEGORY_NAMES, getCategoryTools, toolCategoriesEnabledByDefault } from '../tools/categories.js'; +import { addTool } from '../tools/common/add_actor.js'; +import { getActorOutput } from '../tools/common/get_actor_output.js'; +import { getActorsAsTools } from '../tools/index.js'; +import type { ActorStore, Input, ServerMode, ToolCategory, ToolEntry } from '../types.js'; +import { SERVER_MODES } from '../types.js'; + +/** + * Set of all known internal tool names across ALL modes. + * Used for classifying selectors: if a selector matches a known internal tool name, + * it's not treated as an Actor ID — even if it's absent from the current mode's categories. + */ +let ALL_INTERNAL_TOOL_NAMES_CACHE: Set | null = null; +function getAllInternalToolNames(): Set { + if (!ALL_INTERNAL_TOOL_NAMES_CACHE) { + const allNames = new Set(); + // Collect tool names from both modes to ensure complete classification + for (const mode of SERVER_MODES) { + const categories = getCategoryTools(mode); + for (const name of CATEGORY_NAMES) { + for (const tool of categories[name]) { + allNames.add(tool.name); + } + } + } + ALL_INTERNAL_TOOL_NAMES_CACHE = allNames; } - return INTERNAL_TOOL_BY_NAME_CACHE; + return ALL_INTERNAL_TOOL_NAMES_CACHE; } /** @@ -35,15 +44,18 @@ function getInternalToolByNameMap(): Map { * * @param input The processed Input object * @param apifyClient The Apify client instance - * @param uiMode Optional UI mode. + * @param mode Server mode for tool variant resolution * @returns An array of tool entries */ export async function loadToolsFromInput( input: Input, apifyClient: ApifyClient, - uiMode?: UiMode, + mode: ServerMode, actorStore?: ActorStore, ): Promise { + // Build mode-resolved categories — tools are already the correct variant for this mode + const categories = getCategoryTools(mode); + // Helpers for readability const normalizeSelectors = (value: Input['tools']): (string | ToolCategory)[] | undefined => { if (value === undefined) return undefined; @@ -59,6 +71,14 @@ export async function loadToolsFromInput( const addActorEnabled = input.enableAddingActors === true; const actorsExplicitlyEmpty = (Array.isArray(input.actors) && input.actors.length === 0) || input.actors === ''; + // Build mode-specific tool-by-name map for individual tool selection + const modeToolByName = new Map(); + for (const name of CATEGORY_NAMES) { + for (const tool of categories[name]) { + modeToolByName.set(tool.name, tool); + } + } + // Partition selectors into internal picks (by category or by name) and Actor names const internalSelections: ToolEntry[] = []; const actorSelectorsFromTools: string[] = []; @@ -67,21 +87,27 @@ export async function loadToolsFromInput( if (selector === 'preview') { // 'preview' category is deprecated. It contained `call-actor` which is now default log.warning('Tool category "preview" is deprecated'); - internalSelections.push(callActor); + const callActorTool = modeToolByName.get(HelperTools.ACTOR_CALL); + if (callActorTool) internalSelections.push(callActorTool); continue; } - const categoryTools = toolCategories[selector as ToolCategory]; + const categoryTools = categories[selector as ToolCategory]; if (categoryTools) { internalSelections.push(...categoryTools); continue; } - const internalByName = getInternalToolByNameMap().get(String(selector)); + const internalByName = modeToolByName.get(String(selector)); if (internalByName) { internalSelections.push(internalByName); continue; } + // If this is a known internal tool name (from another mode), skip it silently + // rather than treating it as an Actor ID + if (getAllInternalToolNames().has(String(selector))) { + continue; + } // Treat unknown selectors as Actor IDs/full names. // Potential heuristic (future): if (String(selector).includes('/')) => definitely an Actor. actorSelectorsFromTools.push(String(selector)); @@ -123,12 +149,15 @@ export async function loadToolsFromInput( // No selectors: either expose only add-actor (when enabled), or default categories result.push(addTool); } else if (!actorsExplicitlyEmpty) { - result.push(...getExpectedToolsByCategories(toolCategoriesEnabledByDefault)); + // Use mode-resolved default categories + for (const cat of toolCategoriesEnabledByDefault) { + result.push(...categories[cat]); + } } - // In openai mode, add UI-specific tools - if (uiMode === 'openai') { - result.push(...(toolCategories.ui || [])); + // In openai mode, unconditionally add UI-specific tools (regardless of selectors) + if (mode === 'openai') { + result.push(...categories.ui); } // Actor tools (if any) @@ -141,6 +170,8 @@ export async function loadToolsFromInput( * Auto-inject get-actor-run and get-actor-output when call-actor or actor tools are present. * Insert them right after call-actor to follow the logical workflow order: * search → details → call → run status → output → docs → actor tools + * + * Uses mode-resolved variants from getCategoryTools() for get-actor-run. */ const hasCallActor = result.some((entry) => entry.name === HelperTools.ACTOR_CALL); const hasActorTools = result.some((entry) => entry.type === 'actor'); @@ -149,8 +180,10 @@ export async function loadToolsFromInput( const hasGetActorOutput = result.some((entry) => entry.name === HelperTools.ACTOR_OUTPUT_GET); const toolsToInject: ToolEntry[] = []; - if (!hasGetActorRun && (hasCallActor || uiMode === 'openai')) { - toolsToInject.push(getActorRun); + if (!hasGetActorRun && (hasCallActor || mode === 'openai')) { + // Use mode-resolved get-actor-run variant + const modeGetActorRun = modeToolByName.get(HelperTools.ACTOR_RUNS_GET); + if (modeGetActorRun) toolsToInject.push(modeGetActorRun); } if (!hasGetActorOutput && (hasCallActor || hasActorTools || hasAddActorTool)) { toolsToInject.push(getActorOutput); @@ -178,48 +211,5 @@ export async function loadToolsFromInput( // De-duplicate by tool name for safety const seen = new Set(); - const deduped = result.filter((entry) => !seen.has(entry.name) && seen.add(entry.name)); - - // Filter out openai-only tools when not in openai mode - const filtered = uiMode === 'openai' - ? deduped - : deduped.filter((entry) => !entry.openaiOnly); - - // TODO: rework this solition as it was quickly hacked together for hotfix - // Deep clone except ajvValidate and call functions - - // holds the original functions of the tools - const toolFunctions = new Map; call?:(args: InternalToolArgs) => Promise }>(); - for (const entry of filtered) { - if (entry.type === 'internal') { - toolFunctions.set(entry.name, { ajvValidate: entry.ajvValidate, call: entry.call }); - } else { - toolFunctions.set(entry.name, { ajvValidate: entry.ajvValidate }); - } - } - - const cloned = JSON.parse(JSON.stringify(filtered, (key, value) => { - if (key === 'ajvValidate' || key === 'call') return undefined; - return value; - })) as ToolEntry[]; - - // restore the original functions - for (const entry of cloned) { - const funcs = toolFunctions.get(entry.name); - if (funcs) { - if (funcs.ajvValidate) { - entry.ajvValidate = funcs.ajvValidate; - } - if (entry.type === 'internal' && funcs.call) { - entry.call = funcs.call; - } - } - } - - for (const entry of cloned) { - if (entry.name === HelperTools.ACTOR_CALL) { - entry.description = getCallActorDescription(uiMode); - } - } - return cloned; + return result.filter((entry) => !seen.has(entry.name) && seen.add(entry.name)); } diff --git a/src/utils/ttl-lru.ts b/src/utils/ttl_lru.ts similarity index 100% rename from src/utils/ttl-lru.ts rename to src/utils/ttl_lru.ts diff --git a/src/utils/userid-cache.ts b/src/utils/userid_cache.ts similarity index 90% rename from src/utils/userid-cache.ts rename to src/utils/userid_cache.ts index 685ea326..7b8bc9df 100644 --- a/src/utils/userid-cache.ts +++ b/src/utils/userid_cache.ts @@ -1,8 +1,8 @@ import { createHash } from 'node:crypto'; -import type { ApifyClient } from '../apify-client.js'; +import type { ApifyClient } from '../apify_client.js'; import { USER_CACHE_MAX_SIZE, USER_CACHE_TTL_SECS } from '../const.js'; -import { TTLLRUCache } from './ttl-lru.js'; +import { TTLLRUCache } from './ttl_lru.js'; // LRU cache with TTL for user info - stores the raw User object from API const userIdCache = new TTLLRUCache(USER_CACHE_MAX_SIZE, USER_CACHE_TTL_SECS); diff --git a/tests/const.ts b/tests/const.ts index 562d0c15..52ee6ca2 100644 --- a/tests/const.ts +++ b/tests/const.ts @@ -1,7 +1,7 @@ import { defaults } from '../src/const.js'; import { toolCategoriesEnabledByDefault } from '../src/tools/index.js'; import { actorNameToToolName } from '../src/tools/utils.js'; -import { getExpectedToolNamesByCategories } from '../src/utils/tool-categories-helpers.js'; +import { getExpectedToolNamesByCategories } from '../src/utils/tool_categories_helpers.js'; export const ACTOR_PYTHON_EXAMPLE = 'apify/python-example'; export const ACTOR_MCP_SERVER_ACTOR_NAME = 'apify/actors-mcp-server'; diff --git a/tests/integration/actor.server-sse.test.ts b/tests/integration/actor.server_sse.test.ts similarity index 100% rename from tests/integration/actor.server-sse.test.ts rename to tests/integration/actor.server_sse.test.ts diff --git a/tests/integration/actor.server-streamable.test.ts b/tests/integration/actor.server_streamable.test.ts similarity index 100% rename from tests/integration/actor.server-streamable.test.ts rename to tests/integration/actor.server_streamable.test.ts diff --git a/tests/integration/internals.test.ts b/tests/integration/internals.test.ts index fb2a2e0a..988d6c3a 100644 --- a/tests/integration/internals.test.ts +++ b/tests/integration/internals.test.ts @@ -3,13 +3,13 @@ import { beforeAll, describe, expect, it } from 'vitest'; import log from '@apify/log'; -import { ApifyClient } from '../../src/apify-client.js'; +import { ApifyClient } from '../../src/apify_client.js'; import { ActorsMcpServer } from '../../src/index.js'; -import { addTool } from '../../src/tools/common/helpers.js'; +import { addTool } from '../../src/tools/common/add_actor.js'; import { getActorsAsTools } from '../../src/tools/index.js'; import { actorNameToToolName } from '../../src/tools/utils.js'; import type { Input } from '../../src/types.js'; -import { loadToolsFromInput } from '../../src/utils/tools-loader.js'; +import { loadToolsFromInput } from '../../src/utils/tools_loader.js'; import { ACTOR_PYTHON_EXAMPLE } from '../const.js'; import { expectArrayWeakEquals } from '../helpers.js'; @@ -23,7 +23,7 @@ describe('MCP server internals integration tests', () => { const apifyClient = new ApifyClient({ token: process.env.APIFY_TOKEN }); const initialTools = await loadToolsFromInput({ enableAddingActors: true, - } as Input, apifyClient); + } as Input, apifyClient, 'default'); actorsMcpServer.upsertTools(initialTools); // Load new tool @@ -64,7 +64,7 @@ describe('MCP server internals integration tests', () => { const actorsMCPServer = new ActorsMcpServer({ setupSigintHandler: false, taskStore: new InMemoryTaskStore() }); const apifyClient = new ApifyClient({ token: process.env.APIFY_TOKEN }); - const seeded = await loadToolsFromInput({ enableAddingActors: true } as Input, apifyClient); + const seeded = await loadToolsFromInput({ enableAddingActors: true } as Input, apifyClient, 'default'); actorsMCPServer.upsertTools(seeded); actorsMCPServer.registerToolsChangedHandler(onToolsChanged); @@ -102,7 +102,7 @@ describe('MCP server internals integration tests', () => { const actorsMCPServer = new ActorsMcpServer({ setupSigintHandler: false, taskStore: new InMemoryTaskStore() }); const apifyClient = new ApifyClient({ token: process.env.APIFY_TOKEN }); - const seeded = await loadToolsFromInput({ enableAddingActors: true } as Input, apifyClient); + const seeded = await loadToolsFromInput({ enableAddingActors: true } as Input, apifyClient, 'default'); actorsMCPServer.upsertTools(seeded); actorsMCPServer.registerToolsChangedHandler(onToolsChanged); diff --git a/tests/integration/suite.ts b/tests/integration/suite.ts index ce60d4ab..c44b8544 100644 --- a/tests/integration/suite.ts +++ b/tests/integration/suite.ts @@ -4,20 +4,23 @@ import { CallToolResultSchema, ToolListChangedNotificationSchema } from '@modelc import Ajv from 'ajv'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { ApifyClient } from '../../src/apify-client.js'; +import { ApifyClient } from '../../src/apify_client.js'; import { CALL_ACTOR_MCP_MISSING_TOOL_NAME_MSG, defaults, HelperTools, RAG_WEB_BROWSER, SKYFIRE_ENABLED_TOOLS } from '../../src/const.js'; -// Import tools from toolCategories instead of directly to avoid circular dependency during module initialization -import { defaultTools, toolCategories } from '../../src/tools/index.js'; -import { callActorOutputSchema } from '../../src/tools/structured-output-schemas.js'; +// Import tools from getCategoryTools instead of directly to avoid circular dependency during module initialization +import { getCategoryTools, getDefaultTools } from '../../src/tools/index.js'; +import { callActorOutputSchema } from '../../src/tools/structured_output_schemas.js'; import { actorNameToToolName } from '../../src/tools/utils.js'; -import type { ToolCategory, ToolEntry } from '../../src/types.js'; -import { getExpectedToolNamesByCategories } from '../../src/utils/tool-categories-helpers.js'; +import type { ServerMode, ToolCategory, ToolEntry } from '../../src/types.js'; +import { getExpectedToolNamesByCategories } from '../../src/utils/tool_categories_helpers.js'; import { ACTOR_MCP_SERVER_ACTOR_NAME, ACTOR_PYTHON_EXAMPLE, DEFAULT_ACTOR_NAMES, getDefaultToolNames } from '../const.js'; import { addActor, type McpClientOptions } from '../helpers.js'; -// Helper to find tool by name from toolCategories (avoids circular dependency) -function findToolByName(name: string): ToolEntry | undefined { - for (const tools of Object.values(toolCategories)) { +// Helper to find tool by name, resolving categories for the given mode on each call. +// This ensures we always validate against the correct mode-specific tool definition +// (e.g. outputSchema may diverge between modes in the future). +function findToolByName(name: string, mode: ServerMode): ToolEntry | undefined { + const resolved = getCategoryTools(mode); + for (const tools of Object.values(resolved)) { const tool = tools.find((t) => t.name === name); if (tool) return tool; } @@ -132,8 +135,8 @@ function expectReadmeInStructuredContent( expect(r.structuredContent?.inputSchema).toBeDefined(); } -function validateStructuredOutputForTool(result: unknown, toolName: string): void { - validateStructuredOutput(result, findToolByName(toolName)?.outputSchema, toolName); +function validateStructuredOutputForTool(result: unknown, toolName: string, mode: ServerMode): void { + validateStructuredOutput(result, findToolByName(toolName, mode)?.outputSchema, toolName); } /** Validates that the listed tools have OpenAI metadata (_meta) with outputTemplate and widgetAccessible. */ @@ -231,7 +234,7 @@ export function createIntegrationTestsSuite( it('should list all default tools and Actors', async () => { client = await createClientFn(); const tools = await client.listTools(); - expect(tools.tools.length).toEqual(defaultTools.length + defaults.actors.length + 2); + expect(tools.tools.length).toEqual(getDefaultTools('default').length + defaults.actors.length + 2); const names = getToolNames(tools); expectToolNamesToContain(names, getDefaultToolNames()); @@ -298,7 +301,7 @@ export function createIntegrationTestsSuite( it('should list all default tools and Actors when enableAddingActors is false', async () => { client = await createClientFn({ enableAddingActors: false }); const names = getToolNames(await client.listTools()); - expect(names.length).toEqual(defaultTools.length + defaults.actors.length + 2); + expect(names.length).toEqual(getDefaultTools('default').length + defaults.actors.length + 2); expectToolNamesToContain(names, getDefaultToolNames()); expectToolNamesToContain(names, DEFAULT_ACTOR_NAMES); @@ -509,7 +512,7 @@ export function createIntegrationTestsSuite( client = await createClientFn({ enableAddingActors: true, tools: ['actors'] }); const names = getToolNames(await client.listTools()); // Only the actors category, get-actor-output, get-actor-run, and add-actor should be loaded - const numberOfTools = toolCategories.actors.length + 3; + const numberOfTools = getCategoryTools('default').actors.length + 3; expect(names).toHaveLength(numberOfTools); // get-actor-run should be automatically included when call-actor is present expect(names).toContain(HelperTools.ACTOR_RUNS_GET); @@ -585,7 +588,7 @@ export function createIntegrationTestsSuite( expect(content.some((item) => item.text.includes('Dataset ID'))).toBe(true); // Validate structured output matches schema - validateStructuredOutputForTool(callResult, HelperTools.ACTOR_CALL); + validateStructuredOutputForTool(callResult, HelperTools.ACTOR_CALL, 'default'); // Validate structured content has actual actor results expectPythonExampleStructuredContent(callResult, 1, 2); @@ -616,7 +619,7 @@ export function createIntegrationTestsSuite( expect(typeof resultWithStructured.structuredContent?.runId).toBe('string'); // Validate structured output matches schema - validateStructuredOutputForTool(callResult, HelperTools.ACTOR_CALL); + validateStructuredOutputForTool(callResult, HelperTools.ACTOR_CALL, 'default'); }); it('should support sync mode in call-actor with step call (default behavior)', async () => { @@ -687,7 +690,7 @@ export function createIntegrationTestsSuite( expect(content.some((item) => item.text.includes('"sum": 3') || item.text.includes('"sum":3'))).toBe(false); // Validate structured output matches schema - validateStructuredOutputForTool(callResult, HelperTools.ACTOR_CALL); + validateStructuredOutputForTool(callResult, HelperTools.ACTOR_CALL, 'default'); // Validate structured content has empty items (preview disabled) const resultWithStructured = callResult as { structuredContent?: { items?: unknown[] } }; @@ -713,7 +716,7 @@ export function createIntegrationTestsSuite( expect(content.some((item) => item.text.includes('"sum": 3') || item.text.includes('"sum":3'))).toBe(true); // Validate structured output matches schema - validateStructuredOutputForTool(callResult, HelperTools.ACTOR_CALL); + validateStructuredOutputForTool(callResult, HelperTools.ACTOR_CALL, 'default'); // Validate structured content has actual actor results expectPythonExampleStructuredContent(callResult, 1, 2); @@ -876,8 +879,8 @@ export function createIntegrationTestsSuite( const content = result.content as { text: string }[]; expect(content.length).toBeGreaterThan(0); - // At least one result should contain the standby actor docs URL - const standbyDocUrl = 'https://docs.apify.com/platform/actors/running/standby'; + // Should contain at least one apify docs url + const standbyDocUrl = 'https://docs.apify.com'; expect(content.some((item) => item.text.includes(standbyDocUrl))).toBe(true); }); @@ -962,7 +965,7 @@ export function createIntegrationTestsSuite( const content = result.content as { text: string; isError?: boolean }[]; expect(content.length).toBeGreaterThan(0); - validateStructuredOutputForTool(result, HelperTools.DOCS_SEARCH); + validateStructuredOutputForTool(result, HelperTools.DOCS_SEARCH, 'default'); }); it('should return structured output for fetch-actor-details matching outputSchema', async () => { @@ -981,7 +984,7 @@ export function createIntegrationTestsSuite( const content = result.content as { text: string; isError?: boolean }[]; expect(content.length).toBeGreaterThan(0); - validateStructuredOutputForTool(result, HelperTools.ACTOR_GET_DETAILS); + validateStructuredOutputForTool(result, HelperTools.ACTOR_GET_DETAILS, 'default'); }); it('should return only input schema when output={ inputSchema: true }', async () => { @@ -1121,7 +1124,7 @@ export function createIntegrationTestsSuite( expect(content.length).toBeGreaterThan(0); // This should validate successfully - structured output must match schema - validateStructuredOutputForTool(result, HelperTools.ACTOR_GET_DETAILS); + validateStructuredOutputForTool(result, HelperTools.ACTOR_GET_DETAILS, 'default'); }); it('should return structured output for fetch-actor-details with output={ description: true, readme: true } matching outputSchema', async () => { @@ -1152,7 +1155,7 @@ export function createIntegrationTestsSuite( expect(content.length).toBeGreaterThan(0); // This should validate successfully - structured output must match schema - validateStructuredOutputForTool(result, HelperTools.ACTOR_GET_DETAILS); + validateStructuredOutputForTool(result, HelperTools.ACTOR_GET_DETAILS, 'default'); }); it('should return only pricing when output={ pricing: true }', async () => { @@ -1184,7 +1187,7 @@ export function createIntegrationTestsSuite( expect(content.some((item) => item.text.includes('Input schema'))).toBe(false); // Validate structured output - validateStructuredOutputForTool(result, HelperTools.ACTOR_GET_DETAILS); + validateStructuredOutputForTool(result, HelperTools.ACTOR_GET_DETAILS, 'default'); }); it('should return only readme when output={ readme: true }', async () => { @@ -1216,7 +1219,7 @@ export function createIntegrationTestsSuite( expect(content.some((item) => item.text.includes('Input schema'))).toBe(false); // Validate structured output - validateStructuredOutputForTool(result, HelperTools.ACTOR_GET_DETAILS); + validateStructuredOutputForTool(result, HelperTools.ACTOR_GET_DETAILS, 'default'); }); it('should return README content (summary or full) in text and structured response for fetch-actor-details', async () => { @@ -1247,7 +1250,7 @@ export function createIntegrationTestsSuite( expectReadmeInStructuredContent(result, RAG_WEB_BROWSER); - validateStructuredOutput(result, findToolByName(HelperTools.ACTOR_GET_DETAILS)?.outputSchema, 'fetch-actor-details'); + validateStructuredOutput(result, findToolByName(HelperTools.ACTOR_GET_DETAILS, 'default')?.outputSchema, 'fetch-actor-details'); }); it('should return README content via fetch-actor-details-internal in openai mode', async () => { @@ -1331,7 +1334,7 @@ export function createIntegrationTestsSuite( expect(resultWithStructured.structuredContent?.inputSchema).toBeDefined(); // Validate against schema - validateStructuredOutputForTool(result, HelperTools.ACTOR_GET_DETAILS); + validateStructuredOutputForTool(result, HelperTools.ACTOR_GET_DETAILS, 'default'); }); it('should support granular output controls for rating and metadata', async () => { @@ -1468,10 +1471,10 @@ export function createIntegrationTestsSuite( expect(combinationText).not.toContain('Input schema'); // Validate structured output for all test cases - validateStructuredOutputForTool(pricingOnlyResult, HelperTools.ACTOR_GET_DETAILS); - validateStructuredOutputForTool(ratingOnlyResult, HelperTools.ACTOR_GET_DETAILS); - validateStructuredOutputForTool(metadataOnlyResult, HelperTools.ACTOR_GET_DETAILS); - validateStructuredOutputForTool(combinationResult, HelperTools.ACTOR_GET_DETAILS); + validateStructuredOutputForTool(pricingOnlyResult, HelperTools.ACTOR_GET_DETAILS, 'default'); + validateStructuredOutputForTool(ratingOnlyResult, HelperTools.ACTOR_GET_DETAILS, 'default'); + validateStructuredOutputForTool(metadataOnlyResult, HelperTools.ACTOR_GET_DETAILS, 'default'); + validateStructuredOutputForTool(combinationResult, HelperTools.ACTOR_GET_DETAILS, 'default'); }); it('should dynamically test all output options and verify section presence/absence', async () => { @@ -1561,7 +1564,7 @@ export function createIntegrationTestsSuite( } // Validate structured output - validateStructuredOutputForTool(result, HelperTools.ACTOR_GET_DETAILS); + validateStructuredOutputForTool(result, HelperTools.ACTOR_GET_DETAILS, 'default'); } // Test a combination: all actor card sections (description, stats, pricing, rating, metadata) @@ -1598,7 +1601,7 @@ export function createIntegrationTestsSuite( expect(allCardText).not.toContain('README'); expect(allCardText).not.toContain('Input schema'); - validateStructuredOutputForTool(allCardSectionsResult, HelperTools.ACTOR_GET_DETAILS); + validateStructuredOutputForTool(allCardSectionsResult, HelperTools.ACTOR_GET_DETAILS, 'default'); }); it('should return structured output for search-actors matching outputSchema', async () => { @@ -1619,7 +1622,7 @@ export function createIntegrationTestsSuite( const content = result.content as { text: string; isError?: boolean }[]; expect(content.length).toBeGreaterThan(0); - validateStructuredOutputForTool(result, HelperTools.STORE_SEARCH); + validateStructuredOutputForTool(result, HelperTools.STORE_SEARCH, 'default'); }); it('should return structured output for fetch-apify-docs matching outputSchema', async () => { @@ -1638,10 +1641,10 @@ export function createIntegrationTestsSuite( const content = result.content as { text: string; isError?: boolean }[]; expect(content.length).toBeGreaterThan(0); - validateStructuredOutputForTool(result, HelperTools.DOCS_FETCH); + validateStructuredOutputForTool(result, HelperTools.DOCS_FETCH, 'default'); }); - it.for(Object.keys(toolCategories))('should load correct tools for %s category', async (category) => { + it.for(Object.keys(getCategoryTools('default')))('should load correct tools for %s category', async (category) => { client = await createClientFn({ tools: [category as ToolCategory], }); @@ -1836,7 +1839,7 @@ export function createIntegrationTestsSuite( // Test with enableAddingActors = false via env var client = await createClientFn({ enableAddingActors: false, useEnv: true }); const names = getToolNames(await client.listTools()); - expect(names.length).toEqual(defaultTools.length + defaults.actors.length + 2); + expect(names.length).toEqual(getDefaultTools('default').length + defaults.actors.length + 2); expectToolNamesToContain(names, getDefaultToolNames()); expectToolNamesToContain(names, DEFAULT_ACTOR_NAMES); @@ -1857,15 +1860,16 @@ export function createIntegrationTestsSuite( }); it.runIf(options.transport === 'stdio')('should load tool categories from TOOLS environment variable', async () => { - const categories = ['docs', 'runs'] as ToolCategory[]; - client = await createClientFn({ tools: categories, useEnv: true }); + const selectedCategories = ['docs', 'runs'] as ToolCategory[]; + client = await createClientFn({ tools: selectedCategories, useEnv: true }); const loadedTools = await client.listTools(); const toolNames = getToolNames(loadedTools); + const resolvedCategories = getCategoryTools('default'); const expectedTools = [ - ...toolCategories.docs, - ...toolCategories.runs, + ...resolvedCategories.docs, + ...resolvedCategories.runs, ]; const expectedToolNames = expectedTools.map((tool) => tool.name); @@ -1978,7 +1982,7 @@ export function createIntegrationTestsSuite( expect(resultWithStructured.structuredContent?.items?.[0]).toHaveProperty('crawl'); // Validate structured output for get-actor-output - validateStructuredOutputForTool(outputResult, HelperTools.ACTOR_OUTPUT_GET); + validateStructuredOutputForTool(outputResult, HelperTools.ACTOR_OUTPUT_GET, 'default'); await client.close(); }); @@ -2031,7 +2035,7 @@ export function createIntegrationTestsSuite( expectUsageCostMeta(result); // Validate structured output for get-actor-output - validateStructuredOutputForTool(outputResult, HelperTools.ACTOR_OUTPUT_GET); + validateStructuredOutputForTool(outputResult, HelperTools.ACTOR_OUTPUT_GET, 'default'); }); it('should return structured output for get-actor-run matching outputSchema', async () => { @@ -2059,7 +2063,7 @@ export function createIntegrationTestsSuite( expect(runResult.content).toBeDefined(); // Validate structured output for get-actor-run - validateStructuredOutputForTool(runResult, HelperTools.ACTOR_RUNS_GET); + validateStructuredOutputForTool(runResult, HelperTools.ACTOR_RUNS_GET, 'default'); }); it('should return Actor details both for full Actor name and ID', async () => { @@ -2115,7 +2119,7 @@ export function createIntegrationTestsSuite( expect(datasetResult.content).toBeDefined(); // Validate structured output for get-dataset-items - validateStructuredOutputForTool(datasetResult, HelperTools.DATASET_GET_ITEMS); + validateStructuredOutputForTool(datasetResult, HelperTools.DATASET_GET_ITEMS, 'default'); // Validate structured content has items with actual results const datasetWithStructured = datasetResult as { structuredContent?: { diff --git a/tests/unit/mcp.utils.test.ts b/tests/unit/mcp.utils.test.ts index 9b6e111f..72c80362 100644 --- a/tests/unit/mcp.utils.test.ts +++ b/tests/unit/mcp.utils.test.ts @@ -69,10 +69,12 @@ describe('MCP resources', () => { it('lists the Skyfire readme only when enabled', async () => { const skyfireService = createResourceService({ + mode: 'default', skyfireMode: true, getAvailableWidgets: () => new Map(), }); const defaultService = createResourceService({ + mode: 'default', skyfireMode: false, getAvailableWidgets: () => new Map(), }); @@ -90,7 +92,7 @@ describe('MCP resources', () => { [WIDGET_URIS.ACTOR_RUN, buildAvailableWidget(WIDGET_URIS.ACTOR_RUN, false)], ]); const service = createResourceService({ - uiMode: 'openai', + mode: 'openai', getAvailableWidgets: () => widgets, }); @@ -101,6 +103,7 @@ describe('MCP resources', () => { it('returns a plain-text message for missing resources', async () => { const service = createResourceService({ + mode: 'default', getAvailableWidgets: () => new Map(), }); @@ -112,6 +115,7 @@ describe('MCP resources', () => { it('returns the Skyfire readme content when requested', async () => { const service = createResourceService({ + mode: 'default', skyfireMode: true, getAvailableWidgets: () => new Map(), }); @@ -124,7 +128,7 @@ describe('MCP resources', () => { it('returns a plain-text message for unknown widgets', async () => { const service = createResourceService({ - uiMode: 'openai', + mode: 'openai', getAvailableWidgets: () => new Map(), }); @@ -143,7 +147,7 @@ describe('MCP resources', () => { [WIDGET_URIS.SEARCH_ACTORS, buildAvailableWidget(WIDGET_URIS.SEARCH_ACTORS, true)], ]); const service = createResourceService({ - uiMode: 'openai', + mode: 'openai', getAvailableWidgets: () => widgets, }); @@ -156,6 +160,7 @@ describe('MCP resources', () => { it('returns an empty resource templates list', async () => { const service = createResourceService({ + mode: 'default', getAvailableWidgets: () => new Map(), }); diff --git a/tests/unit/schema-generation.test.ts b/tests/unit/schema_generation.test.ts similarity index 97% rename from tests/unit/schema-generation.test.ts rename to tests/unit/schema_generation.test.ts index a8aea033..24127400 100644 --- a/tests/unit/schema-generation.test.ts +++ b/tests/unit/schema_generation.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { generateSchemaFromItems } from '../../src/utils/schema-generation.js'; +import { generateSchemaFromItems } from '../../src/utils/schema_generation.js'; describe('generateSchemaFromItems', () => { it('should generate basic schema from simple objects', () => { diff --git a/tests/unit/server-card.test.ts b/tests/unit/server_card.test.ts similarity index 100% rename from tests/unit/server-card.test.ts rename to tests/unit/server_card.test.ts diff --git a/tests/unit/tools.categories.test.ts b/tests/unit/tools.categories.test.ts new file mode 100644 index 00000000..9d52c1bf --- /dev/null +++ b/tests/unit/tools.categories.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; + +import { HelperTools } from '../../src/const.js'; +import { CATEGORY_NAMES, getCategoryTools, toolCategories } from '../../src/tools/index.js'; +import type { ToolCategory, ToolEntry } from '../../src/types.js'; + +describe('CATEGORY_NAMES', () => { + it('should match the keys of toolCategories', () => { + const staticKeys = Object.keys(toolCategories); + expect([...CATEGORY_NAMES]).toEqual(staticKeys); + }); +}); + +describe('getCategoryTools', () => { + it('should return all category keys matching CATEGORY_NAMES', () => { + const defaultResult = getCategoryTools('default'); + const openaiResult = getCategoryTools('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 = getCategoryTools('default'); + const openaiResult = getCategoryTools('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 = getCategoryTools('default'); + expect(result.ui).toEqual([]); + }); + + it('should return non-empty ui category in openai mode', () => { + const result = getCategoryTools('openai'); + expect(result.ui.length).toBeGreaterThan(0); + }); + + it('should return different tool variants for actors category based on mode', () => { + const defaultResult = getCategoryTools('default'); + const openaiResult = getCategoryTools('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 = getCategoryTools('default'); + const openaiResult = getCategoryTools('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 = getCategoryTools('default'); + const openaiResult = getCategoryTools('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 = getCategoryTools('default'); + 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, + ]); + }); +}); diff --git a/tests/unit/tools.mode_contract.test.ts b/tests/unit/tools.mode_contract.test.ts new file mode 100644 index 00000000..a8388872 --- /dev/null +++ b/tests/unit/tools.mode_contract.test.ts @@ -0,0 +1,211 @@ +/** + * Contract tests for tool-mode separation. + * + * These tests verify the invariants that must hold across modes: + * - Each mode produces the expected set of tools per category + * - Mode-variant tools share identical inputSchema (same args accepted) + * - Tool definitions are frozen (immutable) + * - _meta stripping works for non-openai modes + */ +import { describe, expect, it } from 'vitest'; + +import { HelperTools } from '../../src/const.js'; +import { CATEGORY_NAMES, getCategoryTools } from '../../src/tools/index.js'; +import type { ToolEntry } from '../../src/types.js'; +import { SERVER_MODES } from '../../src/types.js'; +import { getToolPublicFieldOnly } from '../../src/utils/tools.js'; + +/** Helper to extract tool names from a category. */ +function toolNames(tools: ToolEntry[]): string[] { + return tools.map((t) => t.name); +} + +describe('getCategoryTools mode contract (tool-mode separation)', () => { + const defaultCategories = getCategoryTools('default'); + const openaiCategories = getCategoryTools('openai'); + + describe('per-mode tool lists', () => { + it('should have correct tools in experimental category (both modes)', () => { + expect(toolNames(defaultCategories.experimental)).toEqual([HelperTools.ACTOR_ADD]); + expect(toolNames(openaiCategories.experimental)).toEqual([HelperTools.ACTOR_ADD]); + }); + + it('should have correct tools in actors category (both modes)', () => { + const expected = [HelperTools.STORE_SEARCH, HelperTools.ACTOR_GET_DETAILS, HelperTools.ACTOR_CALL]; + expect(toolNames(defaultCategories.actors)).toEqual(expected); + expect(toolNames(openaiCategories.actors)).toEqual(expected); + }); + + it('should have empty ui category in default mode', () => { + expect(toolNames(defaultCategories.ui)).toEqual([]); + }); + + it('should have internal tools in ui category in openai mode', () => { + expect(toolNames(openaiCategories.ui)).toEqual([ + HelperTools.STORE_SEARCH_INTERNAL, + HelperTools.ACTOR_GET_DETAILS_INTERNAL, + ]); + }); + + it('should have correct tools in docs category (both modes)', () => { + const expected = [HelperTools.DOCS_SEARCH, HelperTools.DOCS_FETCH]; + expect(toolNames(defaultCategories.docs)).toEqual(expected); + expect(toolNames(openaiCategories.docs)).toEqual(expected); + }); + + it('should have correct tools in runs category (both modes)', () => { + const expected = [ + HelperTools.ACTOR_RUNS_GET, + HelperTools.ACTOR_RUN_LIST_GET, + HelperTools.ACTOR_RUNS_LOG, + HelperTools.ACTOR_RUNS_ABORT, + ]; + expect(toolNames(defaultCategories.runs)).toEqual(expected); + expect(toolNames(openaiCategories.runs)).toEqual(expected); + }); + + it('should have correct tools in storage category (both modes)', () => { + const expected = [ + HelperTools.DATASET_GET, + HelperTools.DATASET_GET_ITEMS, + HelperTools.DATASET_SCHEMA_GET, + HelperTools.ACTOR_OUTPUT_GET, + HelperTools.KEY_VALUE_STORE_GET, + HelperTools.KEY_VALUE_STORE_KEYS_GET, + HelperTools.KEY_VALUE_STORE_RECORD_GET, + HelperTools.DATASET_LIST_GET, + HelperTools.KEY_VALUE_STORE_LIST_GET, + ]; + expect(toolNames(defaultCategories.storage)).toEqual(expected); + expect(toolNames(openaiCategories.storage)).toEqual(expected); + }); + + it('should have correct tools in dev category (both modes)', () => { + expect(toolNames(defaultCategories.dev)).toEqual([HelperTools.GET_HTML_SKELETON]); + expect(toolNames(openaiCategories.dev)).toEqual([HelperTools.GET_HTML_SKELETON]); + }); + }); + + describe('tool name invariance across modes', () => { + // Tool names MUST be identical across all modes for every category that has tools in both modes. + // This invariant is relied upon by getExpectedToolNamesByCategories, getUnauthEnabledToolCategories, + // and isApiTokenRequired — which all hardcode 'default' mode internally. + for (const categoryName of CATEGORY_NAMES) { + const defaultNames = toolNames(defaultCategories[categoryName]); + const openaiNames = toolNames(openaiCategories[categoryName]); + + // Only check categories that exist in both modes (ui category is openai-only) + if (defaultNames.length > 0 && openaiNames.length > 0) { + it(`should have identical tool names in ${categoryName} category across modes`, () => { + expect(defaultNames).toEqual(openaiNames); + }); + } + } + }); + + describe('inputSchema parity for mode-variant tools', () => { + const modeVariantToolNames = [ + HelperTools.STORE_SEARCH, + HelperTools.ACTOR_GET_DETAILS, + HelperTools.ACTOR_CALL, + HelperTools.ACTOR_RUNS_GET, + ]; + + for (const name of modeVariantToolNames) { + it(`should have identical inputSchema for ${name} across modes`, () => { + const defaultTool = [...defaultCategories.actors, ...defaultCategories.runs] + .find((t) => t.name === name); + const openaiTool = [...openaiCategories.actors, ...openaiCategories.runs] + .find((t) => t.name === name); + + expect(defaultTool).toBeDefined(); + expect(openaiTool).toBeDefined(); + expect(defaultTool!.inputSchema).toEqual(openaiTool!.inputSchema); + }); + } + }); + + describe('tool definitions are frozen', () => { + for (const mode of SERVER_MODES) { + const categories = getCategoryTools(mode); + + for (const categoryName of CATEGORY_NAMES) { + for (const tool of categories[categoryName]) { + it(`${tool.name} (${mode} mode) should be frozen`, () => { + expect(Object.isFrozen(tool)).toBe(true); + }); + } + } + } + }); + + describe('all tool names match HelperTools enum values', () => { + const allHelperToolNames = new Set(Object.values(HelperTools)); + + for (const mode of SERVER_MODES) { + const categories = getCategoryTools(mode); + + for (const categoryName of CATEGORY_NAMES) { + for (const tool of categories[categoryName]) { + it(`${tool.name} (${mode} mode) should be a known HelperTools value`, () => { + expect(allHelperToolNames.has(tool.name as HelperTools)).toBe(true); + }); + } + } + } + }); +}); + +describe('getToolPublicFieldOnly _meta filtering', () => { + const toolWithOpenAiMeta = { + name: 'test-tool', + description: 'Test', + inputSchema: { type: 'object' as const, properties: {} }, + ajvValidate: (() => true) as never, + _meta: { + 'openai/widget': { type: 'test' }, + 'openai/config': { key: 'value' }, + 'regular-key': { data: 123 }, + }, + }; + + it('should strip openai/ _meta keys when filterOpenAiMeta is true and not in openai mode', () => { + const result = getToolPublicFieldOnly(toolWithOpenAiMeta, { + filterOpenAiMeta: true, + mode: 'default', + }); + expect(result._meta).toBeDefined(); + expect(result._meta).toEqual({ 'regular-key': { data: 123 } }); + expect(result._meta).not.toHaveProperty('openai/widget'); + expect(result._meta).not.toHaveProperty('openai/config'); + }); + + it('should preserve all _meta keys in openai mode', () => { + const result = getToolPublicFieldOnly(toolWithOpenAiMeta, { + filterOpenAiMeta: true, + mode: 'openai', + }); + expect(result._meta).toEqual(toolWithOpenAiMeta._meta); + }); + + it('should preserve all _meta keys when filterOpenAiMeta is false', () => { + const result = getToolPublicFieldOnly(toolWithOpenAiMeta, { + filterOpenAiMeta: false, + }); + expect(result._meta).toEqual(toolWithOpenAiMeta._meta); + }); + + it('should return undefined _meta when all keys are openai/ and mode is not openai', () => { + const toolWithOnlyOpenAiMeta = { + ...toolWithOpenAiMeta, + _meta: { + 'openai/widget': { type: 'test' }, + }, + }; + const result = getToolPublicFieldOnly(toolWithOnlyOpenAiMeta, { + filterOpenAiMeta: true, + mode: 'default', + }); + expect(result._meta).toBeUndefined(); + }); +}); diff --git a/tests/unit/tools.skyfire.test.ts b/tests/unit/tools.skyfire.test.ts new file mode 100644 index 00000000..82348e4a --- /dev/null +++ b/tests/unit/tools.skyfire.test.ts @@ -0,0 +1,338 @@ +/** + * Tests for Skyfire augmentation logic: `applySkyfireAugmentation` and `cloneToolEntry`. + * + * Covers: + * - Eligible vs non-eligible tools + * - Idempotency (double-apply does not duplicate) + * - Frozen originals are not mutated + * - `cloneToolEntry` preserves functions (ajvValidate, call) + * - Actor tools, internal tools, and actor-mcp tools + */ +import { describe, expect, it, vi } from 'vitest'; + +import { + HelperTools, + SKYFIRE_ENABLED_TOOLS, + SKYFIRE_PAY_ID_PROPERTY_DESCRIPTION, + SKYFIRE_TOOL_INSTRUCTIONS, +} from '../../src/const.js'; +import type { ActorMcpTool, ActorTool, HelperTool, ToolEntry } from '../../src/types.js'; +import { applySkyfireAugmentation, cloneToolEntry } from '../../src/utils/tools.js'; + +// --------------------------------------------------------------------------- +// Test fixtures +// --------------------------------------------------------------------------- + +const MOCK_AJV_VALIDATE = vi.fn(() => true); + +function makeInternalTool(overrides: Partial = {}): HelperTool { + return { + name: HelperTools.ACTOR_CALL, + description: 'Call an Actor', + type: 'internal', + inputSchema: { + type: 'object' as const, + properties: { actor: { type: 'string' } }, + }, + ajvValidate: MOCK_AJV_VALIDATE as never, + call: vi.fn(async () => ({ content: [] })), + ...overrides, + }; +} + +function makeActorTool(overrides: Partial = {}): ActorTool { + return { + name: 'apify--web-scraper', + description: 'Web scraper tool', + type: 'actor', + actorFullName: 'apify/web-scraper', + inputSchema: { + type: 'object' as const, + properties: { url: { type: 'string' } }, + }, + ajvValidate: MOCK_AJV_VALIDATE as never, + ...overrides, + }; +} + +function makeActorMcpTool(overrides: Partial = {}): ActorMcpTool { + return { + name: 'some-mcp-tool', + description: 'A proxied MCP tool', + type: 'actor-mcp', + originToolName: 'some-tool', + actorId: 'apify/some-actor', + serverId: 'server-123', + serverUrl: 'https://example.com/mcp', + inputSchema: { + type: 'object' as const, + properties: { input: { type: 'string' } }, + }, + ajvValidate: MOCK_AJV_VALIDATE as never, + ...overrides, + }; +} + +function makeNonEligibleInternalTool(): HelperTool { + // search-apify-docs is NOT in SKYFIRE_ENABLED_TOOLS + return makeInternalTool({ + name: HelperTools.DOCS_SEARCH, + description: 'Search documentation', + }); +} + +// --------------------------------------------------------------------------- +// cloneToolEntry +// --------------------------------------------------------------------------- + +describe('cloneToolEntry', () => { + it('should create a deep copy with independent data', () => { + const original = makeInternalTool(); + const cloned = cloneToolEntry(original); + + // Different objects + expect(cloned).not.toBe(original); + expect(cloned.inputSchema).not.toBe(original.inputSchema); + + // Same data + expect(cloned.name).toBe(original.name); + expect(cloned.description).toBe(original.description); + expect(cloned.type).toBe(original.type); + expect(cloned.inputSchema).toEqual(original.inputSchema); + }); + + it('should preserve ajvValidate function reference', () => { + const original = makeInternalTool(); + const cloned = cloneToolEntry(original); + + expect(cloned.ajvValidate).toBe(original.ajvValidate); + expect(typeof cloned.ajvValidate).toBe('function'); + }); + + it('should preserve call function reference for internal tools', () => { + const original = makeInternalTool(); + const cloned = cloneToolEntry(original) as HelperTool; + + expect(cloned.call).toBe(original.call); + expect(typeof cloned.call).toBe('function'); + }); + + it('should work for actor tools (no call function)', () => { + const original = makeActorTool(); + const cloned = cloneToolEntry(original); + + expect(cloned.ajvValidate).toBe(original.ajvValidate); + expect(cloned.name).toBe(original.name); + expect((cloned as ActorTool).actorFullName).toBe(original.actorFullName); + }); + + it('should not share nested objects with the original', () => { + const original = makeInternalTool(); + const cloned = cloneToolEntry(original); + + // Mutate clone's inputSchema + (cloned.inputSchema.properties as Record).newProp = { type: 'number' }; + + // Original should be unaffected + expect((original.inputSchema.properties as Record).newProp).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// applySkyfireAugmentation — eligible tools +// --------------------------------------------------------------------------- + +describe('applySkyfireAugmentation', () => { + describe('eligible internal tools', () => { + it('should augment an eligible internal tool', () => { + const original = makeInternalTool({ name: HelperTools.ACTOR_CALL }); + const result = applySkyfireAugmentation(original); + + // Returns a different object (cloned) + expect(result).not.toBe(original); + expect(result.description).toContain(SKYFIRE_TOOL_INSTRUCTIONS); + + const props = result.inputSchema.properties as Record; + expect(props['skyfire-pay-id']).toEqual({ + type: 'string', + description: SKYFIRE_PAY_ID_PROPERTY_DESCRIPTION, + }); + }); + + // Test each SKYFIRE_ENABLED_TOOLS member + for (const toolName of SKYFIRE_ENABLED_TOOLS) { + it(`should augment ${toolName}`, () => { + const tool = makeInternalTool({ name: toolName }); + const result = applySkyfireAugmentation(tool); + + expect(result).not.toBe(tool); + expect(result.description).toContain(SKYFIRE_TOOL_INSTRUCTIONS); + }); + } + }); + + describe('actor tools', () => { + it('should augment any actor tool (type: actor)', () => { + const original = makeActorTool(); + const result = applySkyfireAugmentation(original); + + expect(result).not.toBe(original); + expect(result.description).toContain(SKYFIRE_TOOL_INSTRUCTIONS); + + const props = result.inputSchema.properties as Record; + expect(props['skyfire-pay-id']).toBeDefined(); + }); + }); + + describe('non-eligible tools', () => { + it('should return the original reference for non-eligible internal tool', () => { + const original = makeNonEligibleInternalTool(); + const result = applySkyfireAugmentation(original); + + // Returns the exact same object (not cloned) + expect(result).toBe(original); + expect(result.description).not.toContain(SKYFIRE_TOOL_INSTRUCTIONS); + }); + + it('should return the original reference for actor-mcp tool', () => { + const original = makeActorMcpTool(); + const result = applySkyfireAugmentation(original); + + expect(result).toBe(original); + expect(result.description).not.toContain(SKYFIRE_TOOL_INSTRUCTIONS); + }); + }); + + describe('idempotency', () => { + it('should not double-append description when called twice', () => { + const original = makeInternalTool({ name: HelperTools.ACTOR_CALL }); + const firstPass = applySkyfireAugmentation(original); + const secondPass = applySkyfireAugmentation(firstPass); + + // Description should contain SKYFIRE_TOOL_INSTRUCTIONS exactly once + const occurrences = secondPass.description!.split(SKYFIRE_TOOL_INSTRUCTIONS).length - 1; + expect(occurrences).toBe(1); + }); + + it('should not duplicate skyfire-pay-id property when called twice', () => { + const original = makeActorTool(); + const firstPass = applySkyfireAugmentation(original); + const secondPass = applySkyfireAugmentation(firstPass); + + const props = secondPass.inputSchema.properties as Record; + expect(props['skyfire-pay-id']).toEqual({ + type: 'string', + description: SKYFIRE_PAY_ID_PROPERTY_DESCRIPTION, + }); + }); + }); + + describe('frozen originals', () => { + it('should not mutate a frozen internal tool', () => { + const original = Object.freeze(makeInternalTool({ name: HelperTools.ACTOR_CALL })); + const result = applySkyfireAugmentation(original); + + // Result is augmented + expect(result.description).toContain(SKYFIRE_TOOL_INSTRUCTIONS); + + // Original is unchanged + expect(original.description).not.toContain(SKYFIRE_TOOL_INSTRUCTIONS); + expect(Object.isFrozen(original)).toBe(true); + }); + + it('should not mutate a frozen actor tool', () => { + const original = Object.freeze(makeActorTool()); + const result = applySkyfireAugmentation(original); + + expect(result.description).toContain(SKYFIRE_TOOL_INSTRUCTIONS); + expect(original.description).not.toContain(SKYFIRE_TOOL_INSTRUCTIONS); + }); + + it('should return frozen non-eligible tool as-is', () => { + const original = Object.freeze(makeNonEligibleInternalTool()); + const result = applySkyfireAugmentation(original); + + expect(result).toBe(original); + expect(Object.isFrozen(result)).toBe(true); + }); + }); + + describe('function preservation', () => { + it('should preserve ajvValidate on augmented internal tool', () => { + const original = makeInternalTool({ name: HelperTools.ACTOR_CALL }); + const result = applySkyfireAugmentation(original) as HelperTool; + + expect(result.ajvValidate).toBe(original.ajvValidate); + expect(typeof result.ajvValidate).toBe('function'); + }); + + it('should preserve call function on augmented internal tool', () => { + const original = makeInternalTool({ name: HelperTools.ACTOR_CALL }); + const result = applySkyfireAugmentation(original) as HelperTool; + + expect(result.call).toBe(original.call); + expect(typeof result.call).toBe('function'); + }); + + it('should preserve ajvValidate on augmented actor tool', () => { + const original = makeActorTool(); + const result = applySkyfireAugmentation(original); + + expect(result.ajvValidate).toBe(original.ajvValidate); + }); + }); + + describe('edge cases', () => { + it('should handle tool with no description gracefully', () => { + const original = makeInternalTool({ + name: HelperTools.ACTOR_CALL, + description: undefined as unknown as string, + }); + const result = applySkyfireAugmentation(original); + + // Should not throw, description stays undefined + expect(result.description).toBeUndefined(); + }); + + it('should handle tool with empty inputSchema properties', () => { + const original = makeInternalTool({ + name: HelperTools.ACTOR_CALL, + inputSchema: { type: 'object' as const, properties: {} }, + }); + const result = applySkyfireAugmentation(original); + + const props = result.inputSchema.properties as Record; + expect(props['skyfire-pay-id']).toBeDefined(); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Matrix: mode × eligibility (using getCategoryTools) +// --------------------------------------------------------------------------- + +describe('Skyfire eligibility matrix', () => { + const testCases: { tool: ToolEntry; eligible: boolean; label: string }[] = [ + { tool: makeInternalTool({ name: HelperTools.ACTOR_CALL }), eligible: true, label: 'internal/eligible (call-actor)' }, + { tool: makeInternalTool({ name: HelperTools.ACTOR_OUTPUT_GET }), eligible: true, label: 'internal/eligible (get-actor-output)' }, + { tool: makeNonEligibleInternalTool(), eligible: false, label: 'internal/non-eligible (search-apify-docs)' }, + { tool: makeActorTool(), eligible: true, label: 'actor tool' }, + { tool: makeActorMcpTool(), eligible: false, label: 'actor-mcp tool' }, + ]; + + for (const { tool, eligible, label } of testCases) { + it(`${label}: eligible=${eligible}`, () => { + const result = applySkyfireAugmentation(tool); + + if (eligible) { + expect(result).not.toBe(tool); + expect(result.description).toContain(SKYFIRE_TOOL_INSTRUCTIONS); + const props = result.inputSchema.properties as Record; + expect(props['skyfire-pay-id']).toBeDefined(); + } else { + expect(result).toBe(tool); + expect(result.description).not.toContain(SKYFIRE_TOOL_INSTRUCTIONS); + } + }); + } +}); diff --git a/tests/unit/tools.structured-output-schemas.test.ts b/tests/unit/tools.structured_output_schemas.test.ts similarity index 99% rename from tests/unit/tools.structured-output-schemas.test.ts rename to tests/unit/tools.structured_output_schemas.test.ts index 66edfad2..e59daa36 100644 --- a/tests/unit/tools.structured-output-schemas.test.ts +++ b/tests/unit/tools.structured_output_schemas.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { getNormalActorsAsTools } from '../../src/tools/core/actor-tools-factory.js'; -import { buildEnrichedCallActorOutputSchema, callActorOutputSchema } from '../../src/tools/structured-output-schemas.js'; +import { getNormalActorsAsTools } from '../../src/tools/core/actor_tools_factory.js'; +import { buildEnrichedCallActorOutputSchema, callActorOutputSchema } from '../../src/tools/structured_output_schemas.js'; import type { ActorInfo, ActorStore, ActorTool } from '../../src/types.js'; // Helper type for testing schema structure diff --git a/tests/unit/utils.actor-card.test.ts b/tests/unit/utils.actor_card.test.ts similarity index 99% rename from tests/unit/utils.actor-card.test.ts rename to tests/unit/utils.actor_card.test.ts index 8b8162c8..17dff4e7 100644 --- a/tests/unit/utils.actor-card.test.ts +++ b/tests/unit/utils.actor_card.test.ts @@ -2,7 +2,7 @@ import type { Actor } from 'apify-client'; import { describe, expect, it } from 'vitest'; import type { ActorStoreList } from '../../src/types.js'; -import { formatActorToActorCard, formatActorToStructuredCard } from '../../src/utils/actor-card.js'; +import { formatActorToActorCard, formatActorToStructuredCard } from '../../src/utils/actor_card.js'; // Mock Actor data for testing (based on real apify/rag-web-browser Actor) const mockActor: Actor = { diff --git a/tests/unit/utils.tool-status.test.ts b/tests/unit/utils.tool_status.test.ts similarity index 95% rename from tests/unit/utils.tool-status.test.ts rename to tests/unit/utils.tool_status.test.ts index b85579f1..bc73b0ce 100644 --- a/tests/unit/utils.tool-status.test.ts +++ b/tests/unit/utils.tool_status.test.ts @@ -2,7 +2,7 @@ import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; import { describe, expect, it } from 'vitest'; import { TOOL_STATUS } from '../../src/const.js'; -import { getToolStatusFromError } from '../../src/utils/tool-status.js'; +import { getToolStatusFromError } from '../../src/utils/tool_status.js'; describe('getToolStatusFromError', () => { it('returns aborted when isAborted is true', () => { diff --git a/tests/unit/utils.ttl-lru.test.ts b/tests/unit/utils.ttl_lru.test.ts similarity index 96% rename from tests/unit/utils.ttl-lru.test.ts rename to tests/unit/utils.ttl_lru.test.ts index d5889fdb..faa7ed66 100644 --- a/tests/unit/utils.ttl-lru.test.ts +++ b/tests/unit/utils.ttl_lru.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { TTLLRUCache } from '../../src/utils/ttl-lru.js'; +import { TTLLRUCache } from '../../src/utils/ttl_lru.js'; describe('TTLLRUCache', () => { it('should set and get values before TTL expires', () => {