Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/index-internals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -27,4 +29,8 @@ export {
processParamsGetTools,
getActorsAsTools,
getToolPublicFieldOnly,
unauthEnabledToolCategories,
unauthEnabledTools,
parseCommaSeparatedList,
parseQueryParamList,
};
6 changes: 3 additions & 3 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>.
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 });
Expand Down
25 changes: 25 additions & 0 deletions src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 21 additions & 0 deletions src/utils/generic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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').
Expand Down
59 changes: 58 additions & 1 deletion tests/unit/utils.generic.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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);
Expand Down
Loading