Skip to content
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,11 @@ Here is an overview list of all the tools provided by the Apify MCP Server.
| `get-dataset-list` | storage | List all available datasets for the user. | |
| `get-key-value-store-list`| storage | List all available key-value stores for the user. | |
| `add-actor` | experimental | Add an Actor as a new tool for the user to call. | |
| `get-actor-output`* | - | Retrieve the output from an Actor call which is not included in the output preview of the Actor tool. | ✅ |

> **Note:**
>
> The `get-actor-output` tool is automatically included with any Actor-related tool, such as `call-actor`, `add-actor`, or any specific Actor tool like `apify-slash-rag-web-browser`. When you call an Actor - either through the `call-actor` tool or directly via an Actor tool (e.g., `apify-slash-rag-web-browser`) - you receive a preview of the output. The preview depends on the Actor's output format and length; for some Actors and runs, it may include the entire output, while for others, only a limited version is returned to avoid overwhelming the LLM. To retrieve the full output of an Actor run, use the `get-actor-output` tool (supports limit, offset, and field filtering) with the `datasetId` provided by the Actor call.

### Tools configuration

Expand Down
19 changes: 13 additions & 6 deletions src/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ export const ACTOR_RUN_DATASET_OUTPUT_MAX_ITEMS = 5;
// Actor run const
export const ACTOR_MAX_MEMORY_MBYTES = 4_096; // If the Actor requires 8GB of memory, free users can't run actors-mcp-server and requested Actor

// Tool output
/**
* Usual tool output limit is 25k tokens, let's use 20k
* just in case where 1 token =~ 4 characters thus 80k chars.
* This is primarily used for Actor tool call output, but we can then
* reuse this in other tools as well.
*/
export const TOOL_MAX_OUTPUT_CHARS = 80000;

// MCP Server
export const SERVER_NAME = 'apify-mcp-server';
export const SERVER_VERSION = '1.0.0';
Expand All @@ -18,9 +27,8 @@ export const USER_AGENT_ORIGIN = 'Origin/mcp-server';
export enum HelperTools {
ACTOR_ADD = 'add-actor',
ACTOR_CALL = 'call-actor',
ACTOR_GET = 'get-actor',
ACTOR_GET_DETAILS = 'fetch-actor-details',
ACTOR_REMOVE = 'remove-actor',
ACTOR_OUTPUT_GET = 'get-actor-output',
ACTOR_RUNS_ABORT = 'abort-actor-run',
ACTOR_RUNS_GET = 'get-actor-run',
ACTOR_RUNS_LOG = 'get-actor-log',
Expand All @@ -33,7 +41,6 @@ export enum HelperTools {
KEY_VALUE_STORE_GET = 'get-key-value-store',
KEY_VALUE_STORE_KEYS_GET = 'get-key-value-store-keys',
KEY_VALUE_STORE_RECORD_GET = 'get-key-value-store-record',
APIFY_MCP_HELP_TOOL = 'apify-actor-help-tool',
STORE_SEARCH = 'search-actors',
DOCS_SEARCH = 'search-apify-docs',
DOCS_FETCH = 'fetch-apify-docs',
Expand All @@ -54,12 +61,12 @@ export const APIFY_DOCS_CACHE_MAX_SIZE = 500;
export const APIFY_DOCS_CACHE_TTL_SECS = 60 * 60; // 1 hour

export const ACTOR_PRICING_MODEL = {
/** Rental actors */
/** Rental Actors */
FLAT_PRICE_PER_MONTH: 'FLAT_PRICE_PER_MONTH',
FREE: 'FREE',
/** Pay per result (PPR) actors */
/** Pay per result (PPR) Actors */
PRICE_PER_DATASET_ITEM: 'PRICE_PER_DATASET_ITEM',
/** Pay per event (PPE) actors */
/** Pay per event (PPE) Actors */
PAY_PER_EVENT: 'PAY_PER_EVENT',
} as const;

Expand Down
8 changes: 4 additions & 4 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,11 @@ if (STANDBY_MODE) {
await Actor.fail('If you need to debug a specific Actor, please provide the debugActor and debugActorInput fields in the input');
}
const options = { memory: input.maxActorMemoryBytes } as ActorCallOptions;
const result = await callActorGetDataset(input.debugActor!, input.debugActorInput!, process.env.APIFY_TOKEN, options);
const callResult = await callActorGetDataset(input.debugActor!, input.debugActorInput!, process.env.APIFY_TOKEN, options);

if (result && result.items) {
await Actor.pushData(result.items);
log.info('Pushed items to dataset', { itemCount: result.items.count });
if (callResult && callResult.previewItems.length > 0) {
await Actor.pushData(callResult.previewItems);
log.info('Pushed items to dataset', { itemCount: callResult.previewItems.length });
}
await Actor.exit();
}
Expand Down
3 changes: 2 additions & 1 deletion src/mcp/actors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { ActorDefinition } from 'apify-client';
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';

/**
* Returns the MCP server path for the given Actor ID.
Expand All @@ -13,7 +14,7 @@ export function getActorMCPServerPath(actorDefinition: ActorDefinition | ActorDe
if ('webServerMcpPath' in actorDefinition && typeof actorDefinition.webServerMcpPath === 'string') {
const webServerMcpPath = actorDefinition.webServerMcpPath.trim();

const paths = webServerMcpPath.split(',').map((path) => path.trim());
const paths = parseCommaSeparatedList(webServerMcpPath);
// If there is only one path, return it directly
if (paths.length === 1) {
return paths[0];
Expand Down
4 changes: 1 addition & 3 deletions src/mcp/proxy.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
import Ajv from 'ajv';

import { fixedAjvCompile } from '../tools/utils.js';
import type { ActorMcpTool, ToolEntry } from '../types.js';
import { ajv } from '../utils/ajv.js';
import { getMCPServerID, getProxyMCPServerToolName } from './utils.js';

export async function getMCPServerTools(
Expand All @@ -14,8 +14,6 @@ export async function getMCPServerTools(
const res = await client.listTools();
const { tools } = res;

const ajv = new Ajv({ coerceTypes: 'array', strict: false });

const compiledTools: ToolEntry[] = [];
for (const tool of tools) {
const mcpTool: ActorMcpTool = {
Expand Down
16 changes: 4 additions & 12 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { prompts } from '../prompts/index.js';
import { callActorGetDataset, defaultTools, getActorsAsTools, toolCategories } from '../tools/index.js';
import { decodeDotPropertyNames } from '../tools/utils.js';
import type { ActorMcpTool, ActorTool, HelperTool, ToolEntry } from '../types.js';
import { buildActorResponseContent } from '../utils/actor-response.js';
import { createProgressTracker } from '../utils/progress.js';
import { getToolPublicFieldOnly } from '../utils/tools.js';
import { connectMCPClient } from './client.js';
Expand Down Expand Up @@ -524,7 +525,7 @@ export class ActorsMcpServer {

try {
log.info('Calling Actor', { actorName: actorTool.actorFullName, input: args });
const result = await callActorGetDataset(
const callResult = await callActorGetDataset(
actorTool.actorFullName,
args,
apifyToken as string,
Expand All @@ -533,22 +534,13 @@ export class ActorsMcpServer {
extra.signal,
);

if (!result) {
if (!callResult) {
// Receivers of cancellation notifications SHOULD NOT send a response for the cancelled request
// https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/cancellation#behavior-requirements
return { };
}

const { runId, datasetId, items } = result;

const content = [
{ type: 'text', text: `Actor finished with runId: ${runId}, datasetId ${datasetId}` },
];

const itemContents = items.items.map((item: Record<string, unknown>) => {
return { type: 'text', text: JSON.stringify(item) };
});
content.push(...itemContents);
const content = buildActorResponseContent(actorTool.actorFullName, callResult);
return { content };
} finally {
if (progressTracker) {
Expand Down
9 changes: 3 additions & 6 deletions src/stdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import log from '@apify/log';
import { processInput } from './input.js';
import { ActorsMcpServer } from './mcp/server.js';
import type { Input, ToolSelector } from './types.js';
import { parseCommaSeparatedList } from './utils/generic.js';
import { loadToolsFromInput } from './utils/tools-loader.js';

// Keeping this interface here and not types.ts since
Expand Down Expand Up @@ -86,13 +87,9 @@ For more details visit https://mcp.apify.com`,
// Respect either the new flag or the deprecated one
const enableAddingActors = Boolean(argv.enableAddingActors || argv.enableActorAutoLoading);
// Split actors argument, trim whitespace, and filter out empty strings
const actorList = argv.actors !== undefined
? argv.actors.split(',').map((a: string) => a.trim()).filter((a: string) => a.length > 0)
: undefined;
const actorList = argv.actors !== undefined ? parseCommaSeparatedList(argv.actors) : undefined;
// Split tools argument, trim whitespace, and filter out empty strings
const toolCategoryKeys = argv.tools !== undefined
? argv.tools.split(',').map((t: string) => t.trim()).filter((t: string) => t.length > 0)
: undefined;
const toolCategoryKeys = argv.tools !== undefined ? parseCommaSeparatedList(argv.tools) : undefined;

// Propagate log.error to console.error for easier debugging
const originalError = log.error.bind(log);
Expand Down
85 changes: 46 additions & 39 deletions src/tools/actor.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { Ajv } from 'ajv';
import type { ActorCallOptions, ActorRun, PaginatedList } from 'apify-client';
import type { ActorCallOptions, ActorRun } from 'apify-client';
import { z } from 'zod';
import zodToJsonSchema from 'zod-to-json-schema';

Expand All @@ -11,37 +10,43 @@ import {
ACTOR_ADDITIONAL_INSTRUCTIONS,
ACTOR_MAX_MEMORY_MBYTES,
HelperTools,
TOOL_MAX_OUTPUT_CHARS,
} from '../const.js';
import { getActorMCPServerPath, getActorMCPServerURL } from '../mcp/actors.js';
import { connectMCPClient } from '../mcp/client.js';
import { getMCPServerTools } from '../mcp/proxy.js';
import { actorDefinitionPrunedCache } from '../state.js';
import type { ActorDefinitionStorage, ActorInfo, ToolEntry } from '../types.js';
import { getActorDefinitionStorageFieldNames } from '../utils/actor.js';
import type { ActorDefinitionStorage, ActorInfo, DatasetItem, ToolEntry } from '../types.js';
import { ensureOutputWithinCharLimit, getActorDefinitionStorageFieldNames } from '../utils/actor.js';
import { fetchActorDetails } from '../utils/actor-details.js';
import { getValuesByDotKeys } from '../utils/generic.js';
import { buildActorResponseContent } from '../utils/actor-response.js';
import { ajv } from '../utils/ajv.js';
import type { ProgressTracker } from '../utils/progress.js';
import type { JsonSchemaProperty } from '../utils/schema-generation.js';
import { generateSchemaFromItems } from '../utils/schema-generation.js';
import { getActorDefinition } from './build.js';
import { actorNameToToolName, fixedAjvCompile, getToolSchemaID, transformActorInputSchemaProperties } from './utils.js';

const ajv = new Ajv({ coerceTypes: 'array', strict: false });

// Define a named return type for callActorGetDataset
export type CallActorGetDatasetResult = {
runId: string;
datasetId: string;
items: PaginatedList<Record<string, unknown>>;
itemCount: number;
schema: JsonSchemaProperty;
previewItems: DatasetItem[];
};

/**
* Calls an Apify actor and retrieves the dataset items.
* Calls an Apify Actor and retrieves metadata about the dataset results.
*
* This function executes an Actor and returns summary information instead with a result items preview of the full dataset
* to prevent overwhelming responses. The actual data can be retrieved using the get-actor-output tool.
*
* It requires the `APIFY_TOKEN` environment variable to be set.
* If the `APIFY_IS_AT_HOME` the dataset items are pushed to the Apify dataset.
*
* @param {string} actorName - The name of the actor to call.
* @param {ActorCallOptions} callOptions - The options to pass to the actor.
* @param {string} actorName - The name of the Actor to call.
* @param {ActorCallOptions} callOptions - The options to pass to the Actor.
* @param {unknown} input - The input to pass to the actor.
* @param {string} apifyToken - The Apify token to use for authentication.
* @param {ProgressTracker} progressTracker - Optional progress tracker for real-time updates.
Expand All @@ -58,6 +63,7 @@ export async function callActorGetDataset(
abortSignal?: AbortSignal,
): Promise<CallActorGetDatasetResult | null> {
const CLIENT_ABORT = Symbol('CLIENT_ABORT'); // Just internal symbol to identify client abort
// TODO: we should remove this throw, we are just catching and then rethrowing with generic message
try {
const client = new ApifyClient({ token: apifyToken });
const actorClient = client.actor(actorName);
Expand Down Expand Up @@ -98,34 +104,45 @@ export async function callActorGetDataset(

// Process the completed run
const dataset = client.dataset(completedRun.defaultDatasetId);
const [items, defaultBuild] = await Promise.all([
const [datasetItems, defaultBuild] = await Promise.all([
dataset.listItems(),
(await actorClient.defaultBuild()).get(),
]);

// Get important properties from storage view definitions and if available return only those properties
// Generate schema using the shared utility
const generatedSchema = generateSchemaFromItems(datasetItems.items, {
clean: true,
arrayMode: 'all',
});
const schema = generatedSchema || { type: 'object', properties: {} };

/**
* Get important fields that are using in any dataset view as they MAY be used in filtering to ensure the output fits
* the tool output limits. Client has to use the get-actor-output tool to retrieve the full dataset or filtered out fields.
*/
const storageDefinition = defaultBuild?.actorDefinition?.storages?.dataset as ActorDefinitionStorage | undefined;
const importantProperties = getActorDefinitionStorageFieldNames(storageDefinition || {});
if (importantProperties.length > 0) {
items.items = items.items.map((item) => {
return getValuesByDotKeys(item, importantProperties);
});
}

log.debug('Actor finished', { actorName, itemCount: items.count });
return { runId: actorRun.id, datasetId: completedRun.defaultDatasetId, items };
const previewItems = ensureOutputWithinCharLimit(datasetItems.items, importantProperties, TOOL_MAX_OUTPUT_CHARS);

return {
runId: actorRun.id,
datasetId: completedRun.defaultDatasetId,
itemCount: datasetItems.count,
schema,
previewItems,
};
} catch (error) {
log.error('Error calling actor', { error, actorName, input });
log.error('Error calling Actor', { error, actorName, input });
throw new Error(`Error calling Actor: ${error}`);
}
}

/**
* This function is used to fetch normal non-MCP server Actors as a tool.
*
* Fetches actor input schemas by Actor IDs or Actor full names and creates MCP tools.
* Fetches Actor input schemas by Actor IDs or Actor full names and creates MCP tools.
*
* This function retrieves the input schemas for the specified actors and compiles them into MCP tools.
* This function retrieves the input schemas for the specified Actors and compiles them into MCP tools.
* It uses the AJV library to validate the input schemas.
*
* Tool name can't contain /, so it is replaced with _
Expand Down Expand Up @@ -228,7 +245,7 @@ export async function getActorsAsTools(
actorIdsOrNames: string[],
apifyToken: string,
): Promise<ToolEntry[]> {
log.debug('Fetching actors as tools', { actorNames: actorIdsOrNames });
log.debug('Fetching Actors as tools', { actorNames: actorIdsOrNames });

const actorsInfo: (ActorInfo | null)[] = await Promise.all(
actorIdsOrNames.map(async (actorIdOrName) => {
Expand Down Expand Up @@ -325,7 +342,7 @@ The step parameter enforces this workflow - you cannot call an Actor without fir

try {
if (step === 'info') {
// Step 1: Return actor card and schema directly
// Step 1: Return Actor card and schema directly
const details = await fetchActorDetails(apifyToken, actorName);
if (!details) {
return {
Expand Down Expand Up @@ -369,7 +386,7 @@ The step parameter enforces this workflow - you cannot call an Actor without fir
}
}

const result = await callActorGetDataset(
const callResult = await callActorGetDataset(
actorName,
input,
apifyToken,
Expand All @@ -378,23 +395,13 @@ The step parameter enforces this workflow - you cannot call an Actor without fir
extra.signal,
);

if (!result) {
if (!callResult) {
// Receivers of cancellation notifications SHOULD NOT send a response for the cancelled request
// https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/cancellation#behavior-requirements
return { };
}

const { runId, datasetId, items } = result;

const content = [
{ type: 'text', text: `Actor finished with runId: ${runId}, datasetId ${datasetId}` },
];

const itemContents = items.items.map((item: Record<string, unknown>) => ({
type: 'text',
text: JSON.stringify(item),
}));
content.push(...itemContents);
const content = buildActorResponseContent(actorName, callResult);

return { content };
} catch (error) {
Expand Down
4 changes: 1 addition & 3 deletions src/tools/build.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { Ajv } from 'ajv';
import { z } from 'zod';
import zodToJsonSchema from 'zod-to-json-schema';

Expand All @@ -13,10 +12,9 @@ import type {
ISchemaProperties,
ToolEntry,
} from '../types.js';
import { ajv } from '../utils/ajv.js';
import { filterSchemaProperties, shortenProperties } from './utils.js';

const ajv = new Ajv({ coerceTypes: 'array', strict: false });

/**
* Get Actor input schema by Actor name.
* First, fetch the Actor details to get the default build tag and buildId.
Expand Down
Loading