diff --git a/src/index-internals.ts b/src/index-internals.ts index 0c805246..28da9e67 100644 --- a/src/index-internals.ts +++ b/src/index-internals.ts @@ -6,9 +6,11 @@ import { ApifyClient } from './apify-client.js'; import { defaults, HelperTools } from './const.js'; import { processParamsGetTools } from './mcp/utils.js'; import { addTool } from './tools/helpers.js'; -import { defaultTools, getActorsAsTools, toolCategories, toolCategoriesEnabledByDefault } from './tools/index.js'; +import { defaultTools, getActorsAsTools, toolCategories, + toolCategoriesEnabledByDefault, unauthEnabledToolCategories, unauthEnabledTools } from './tools/index.js'; import { actorNameToToolName } from './tools/utils.js'; import type { ToolCategory } from './types.js'; +import { parseCommaSeparatedList, parseQueryParamList } from './utils/generic.js'; import { getExpectedToolNamesByCategories, getToolPublicFieldOnly } from './utils/tools.js'; import { TTLLRUCache } from './utils/ttl-lru.js'; @@ -27,4 +29,8 @@ export { processParamsGetTools, getActorsAsTools, getToolPublicFieldOnly, + unauthEnabledToolCategories, + unauthEnabledTools, + parseCommaSeparatedList, + parseQueryParamList, }; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 6f10614e..13acf92b 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -511,9 +511,9 @@ export class ActorsMcpServer { delete request.params.mcpSessionId; // Validate token - if (!apifyToken && !this.options.skyfireMode) { - const msg = `APIFY_TOKEN is required but was not provided. -Please set the APIFY_TOKEN environment variable or pass it as a parameter in the request body. + if (!apifyToken && !this.options.skyfireMode && !this.options.allowUnauthMode) { + const msg = `Apify API token is required but was not provided. +Please set the APIFY_TOKEN environment variable or pass it as a parameter in the request header as Authorization Bearer . You can obtain your Apify token from https://console.apify.com/account/integrations.`; log.softFail(msg, { statusCode: 400 }); await this.server.sendLoggingMessage({ level: 'error', data: msg }); diff --git a/src/tools/index.ts b/src/tools/index.ts index d03ec75f..c2b30124 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -16,6 +16,13 @@ import { getUserRunsList } from './run_collection.js'; import { searchApifyDocsTool } from './search-apify-docs.js'; import { searchActors } from './store_collection.js'; +/* list of tools that can be used without authentication */ +export const unauthEnabledTools: string[] = [ + // docs + searchApifyDocsTool.name, + fetchApifyDocsTool.name, +]; + export const toolCategories = { experimental: [ addTool, @@ -49,11 +56,29 @@ export const toolCategories = { getHtmlSkeleton, ], }; + export const toolCategoriesEnabledByDefault: ToolCategory[] = [ 'actors', 'docs', ]; +/** + * Builds the list of tool categories that are enabled for unauthenticated users. + * A category is included if all tools in it are in the unauthEnabledTools list. + */ +function buildUnauthEnabledToolCategories(): ToolCategory[] { + const unauthEnabledToolsSet = new Set(unauthEnabledTools); + + return (Object.entries(toolCategories) as [ToolCategory, typeof toolCategories[ToolCategory]][]) + .filter(([, tools]) => { + // Include category only if all tools are in the unauthEnabledTools list + return tools.every((tool) => unauthEnabledToolsSet.has(tool.name)); + }) + .map(([category]) => category); +} + +export const unauthEnabledToolCategories = buildUnauthEnabledToolCategories(); + export const defaultTools = getExpectedToolsByCategories(toolCategoriesEnabledByDefault); // Export only the tools that are being used diff --git a/src/types.ts b/src/types.ts index d660d460..f1cb901d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -320,6 +320,13 @@ export interface ActorsMcpServerOptions { * Switch to enable Skyfire agentic payment mode. */ skyfireMode?: boolean; + /** + * Allow unauthenticated mode - tools can be called without an Apify API token. + * This is primarily used for making documentation tools available without authentication. + * When enabled, Apify token validation is skipped. + * Default: false + */ + allowUnauthMode?: boolean; initializeRequestData?: InitializeRequest; /** * Telemetry configuration options. diff --git a/src/utils/generic.ts b/src/utils/generic.ts index 2f8e09d8..3a627695 100644 --- a/src/utils/generic.ts +++ b/src/utils/generic.ts @@ -15,6 +15,27 @@ export function parseCommaSeparatedList(input?: string): string[] { return input.split(',').map((s) => s.trim()).filter((s) => s.length > 0); } +/** + * Parses a query parameter that can be either a string or an array of strings. + * Handles comma-separated values in strings and filters out empty values. + * + * @param param - A query parameter that can be a string, array of strings, or undefined + * @returns An array of trimmed, non-empty strings + * @example + * parseQueryParamList("a,b,c"); // ["a", "b", "c"] + * parseQueryParamList(["a", "b"]); // ["a", "b"] + * parseQueryParamList(undefined); // [] + */ +export function parseQueryParamList(param?: string | string[]): string[] { + if (!param) { + return []; + } + if (Array.isArray(param)) { + return param.flatMap((item) => parseCommaSeparatedList(item)); + } + return parseCommaSeparatedList(param); +} + /** * Recursively gets the value in a nested object for each key in the keys array. * Each key can be a dot-separated path (e.g. 'a.b.c'). diff --git a/tests/unit/utils.generic.test.ts b/tests/unit/utils.generic.test.ts index dc1f9af2..d40c0dc6 100644 --- a/tests/unit/utils.generic.test.ts +++ b/tests/unit/utils.generic.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { getValuesByDotKeys, isValidHttpUrl, parseBooleanFromString, parseCommaSeparatedList } from '../../src/utils/generic.js'; +import { getValuesByDotKeys, isValidHttpUrl, parseBooleanFromString, parseCommaSeparatedList, parseQueryParamList } from '../../src/utils/generic.js'; describe('getValuesByDotKeys', () => { it('should get value for a key without dot', () => { @@ -83,6 +83,63 @@ describe('parseCommaSeparatedList', () => { }); }); +describe('parseQueryParamList', () => { + it('should parse comma-separated string', () => { + const result = parseQueryParamList('tool1, tool2, tool3'); + expect(result).toEqual(['tool1', 'tool2', 'tool3']); + }); + + it('should parse comma-separated string without spaces', () => { + const result = parseQueryParamList('tool1,tool2,tool3'); + expect(result).toEqual(['tool1', 'tool2', 'tool3']); + }); + + it('should parse array of strings', () => { + const result = parseQueryParamList(['tool1', 'tool2', 'tool3']); + expect(result).toEqual(['tool1', 'tool2', 'tool3']); + }); + + it('should handle undefined input', () => { + const result = parseQueryParamList(undefined); + expect(result).toEqual([]); + }); + + it('should handle empty string', () => { + const result = parseQueryParamList(''); + expect(result).toEqual([]); + }); + + it('should handle empty array', () => { + const result = parseQueryParamList([]); + expect(result).toEqual([]); + }); + + it('should flatten array with comma-separated values', () => { + const result = parseQueryParamList(['tool1, tool2', 'tool3, tool4']); + expect(result).toEqual(['tool1', 'tool2', 'tool3', 'tool4']); + }); + + it('should filter empty strings from array', () => { + const result = parseQueryParamList(['tool1', '', 'tool2']); + expect(result).toEqual(['tool1', 'tool2']); + }); + + it('should handle single tool in string', () => { + const result = parseQueryParamList('single-tool'); + expect(result).toEqual(['single-tool']); + }); + + it('should handle single tool in array', () => { + const result = parseQueryParamList(['single-tool']); + expect(result).toEqual(['single-tool']); + }); + + it('should trim whitespace from array items and their comma-separated values', () => { + const result = parseQueryParamList([' tool1 , tool2 ', ' tool3']); + expect(result).toEqual(['tool1', 'tool2', 'tool3']); + }); +}); + describe('isValidUrl', () => { it('should validate correct URLs', () => { expect(isValidHttpUrl('http://example.com')).toBe(true);