Skip to content
Merged
17 changes: 13 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,12 +186,21 @@ Build the `actor-mcp-server` package:
npm run build
```

## Debugging
## Start HTTP streamable MCP server

Since MCP servers operate over standard input/output (stdio), debugging can be challenging.
For the best debugging experience, use the [MCP Inspector](https://github.com/modelcontextprotocol/inspector).
Run using Apify CLI:

You can launch the MCP Inspector via [`npm`](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) with this command:
```bash
export APIFY_TOKEN="your-apify-token"
export APIFY_META_ORIGIN=STANDBY
apify run -p
```

Once the server is running, you can use the [MCP Inspector](https://github.com/modelcontextprotocol/inspector) to debug the server exposed at `http://localhost:3001`.

## Start standard input/output (stdio) MCP server

You can launch the MCP Inspector with this command:

```bash
export APIFY_TOKEN="your-apify-token"
Expand Down
2 changes: 2 additions & 0 deletions src/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,5 @@ export const ALGOLIA = {
};

export const PROGRESS_NOTIFICATION_INTERVAL_MS = 5_000; // 5 seconds

export const APIFY_STORE_URL = 'https://apify.com';
49 changes: 7 additions & 42 deletions src/tools/actor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ 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, InternalTool, ToolEntry } from '../types.js';
import type { ActorDefinitionStorage, ActorInfo, ToolEntry } from '../types.js';
import { getActorDefinitionStorageFieldNames } from '../utils/actor.js';
import { getValuesByDotKeys } from '../utils/generic.js';
import type { ProgressTracker } from '../utils/progress.js';
Expand Down Expand Up @@ -257,41 +257,6 @@ export async function getActorsAsTools(
return [...normalTools, ...mcpServerTools];
}

const getActorArgs = z.object({
actorId: z.string()
.min(1)
.describe('Actor ID or a tilde-separated owner\'s username and Actor name.'),
});

/**
* https://docs.apify.com/api/v2/act-get
*/
export const getActor: ToolEntry = {
type: 'internal',
tool: {
name: HelperTools.ACTOR_GET,
actorFullName: HelperTools.ACTOR_GET,
description: 'Gets an object that contains all the details about a specific Actor.'
+ 'Actor basic information (ID, name, owner, description)'
+ 'Statistics (number of runs, users, etc.)'
+ 'Available versions, and configuration details'
+ 'Use Actor ID or Actor full name, separated by tilde username~name.',
inputSchema: zodToJsonSchema(getActorArgs),
ajvValidate: ajv.compile(zodToJsonSchema(getActorArgs)),
call: async (toolArgs) => {
const { args, apifyToken } = toolArgs;
const { actorId } = getActorArgs.parse(args);
const client = new ApifyClient({ token: apifyToken });
// Get Actor - contains a lot of irrelevant information
const actor = await client.actor(actorId).get();
if (!actor) {
return { content: [{ type: 'text', text: `Actor '${actorId}' not found.` }] };
}
return { content: [{ type: 'text', text: JSON.stringify(actor) }] };
},
} as InternalTool,
};

const callActorArgs = z.object({
actor: z.string()
.describe('The name of the Actor to call. For example, "apify/instagram-scraper".'),
Expand Down Expand Up @@ -330,12 +295,12 @@ export const callActor: ToolEntry = {
return {
content: [{
type: 'text',
text: `Actor '${actorName}' is not added. ${toolsText}
To use this MCP server, specify the actors with the parameter, for example:
?actors=apify/instagram-scraper,apify/website-content-crawler
or with the CLI:
--actors "apify/instagram-scraper,apify/website-content-crawler"
You can only use actors that are included in the list; actors not in the list cannot be used.`,
text: `Actor '${actorName}' is not added. ${toolsText}\n`
+ 'To use this MCP server, specify the actors with the parameter, for example:\n'
+ '?actors=apify/instagram-scraper,apify/website-content-crawler\n'
+ 'or with the CLI:\n'
+ '--actors "apify/instagram-scraper,apify/website-content-crawler"\n'
+ 'You can only use actors that are included in the list; actors not in the list cannot be used.',
}],
};
}
Expand Down
89 changes: 20 additions & 69 deletions src/tools/get-actor-details.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type { Actor, Build } from 'apify-client';
import { z } from 'zod';
import zodToJsonSchema from 'zod-to-json-schema';

import { ApifyClient } from '../apify-client.js';
import { HelperTools } from '../const.js';
import type { ExtendedPricingInfo, IActorInputSchema, InternalTool, ToolEntry } from '../types.js';
import type { IActorInputSchema, InternalTool, ToolEntry } from '../types.js';
import { formatActorToActorCard } from '../utils/actor-card.js';
import { ajv } from '../utils/ajv.js';
import { getCurrentPricingInfo, pricingInfoToString } from '../utils/pricing-info.js';
import { filterSchemaProperties, shortenProperties } from './utils.js';

const getActorDetailsToolArgsSchema = z.object({
Expand All @@ -14,42 +15,19 @@ const getActorDetailsToolArgsSchema = z.object({
.describe(`Actor ID or full name in the format "username/name", e.g., "apify/rag-web-browser".`),
});

interface IGetActorDetailsToolResult {
id: string;
actorFullName: string;

isPublic: boolean;
isDeprecated: boolean;
createdAt: string;
modifiedAt: string;

categories?: string[];
description: string;
readme: string;

inputSchema: IActorInputSchema;

pricingInfo: string; // We convert the pricing info into a string representation

usageStatistics: {
totalUsers: {
allTime: number;
last7Days: number;
last30Days: number;
last90Days: number;
};
failedRunsInLast30Days: number | string; // string for 'unknown' case
}
}

export const getActorDetailsTool: ToolEntry = {
type: 'internal',
tool: {
name: HelperTools.ACTOR_GET_DETAILS,
description: `Retrieve information about an Actor by its ID or full name.
The Actor name is always composed of "username/name", for example, "apify/rag-web-browser".
This tool returns information about the Actor, including whether it is public or deprecated, when it was created or modified, the categories in which the Actor is listed, a description, a README (the Actor's documentation), the input schema, and usage statistics - such as how many users are using it and the number of failed runs of the Actor.
For example, use this tool when a user wants to know more about a specific Actor or wants to use optional or advanced parameters of the Actor that are not listed in the default Actor tool input schema - so you know the details and how to pass them.`,
description: `Get detailed information about an Actor by its ID or full name.\n`
+ `This tool returns title, description, URL, README (Actor's documentation), input schema, and usage statistics. \n`
+ `The Actor name is always composed of "username/name", for example, "apify/rag-web-browser".\n`
+ `Present Actor information in user-friendly format as an Actor card.\n`
+ `USAGE:\n`
+ `- Use when user asks about an Actor its details, description, input schema, etc.\n`
+ `EXAMPLES:\n`
+ `- user_input: How to use apify/rag-web-browser\n`
+ `- user_input: What is the input schema for apify/rag-web-browser`,
inputSchema: zodToJsonSchema(getActorDetailsToolArgsSchema),
ajvValidate: ajv.compile(zodToJsonSchema(getActorDetailsToolArgsSchema)),
call: async (toolArgs) => {
Expand All @@ -58,7 +36,7 @@ For example, use this tool when a user wants to know more about a specific Actor
const parsed = getActorDetailsToolArgsSchema.parse(args);
const client = new ApifyClient({ token: apifyToken });

const [actorInfo, buildInfo] = await Promise.all([
const [actorInfo, buildInfo]: [Actor | undefined, Build | undefined] = await Promise.all([
client.actor(parsed.actor).get(),
client.actor(parsed.actor).defaultBuild().then(async (build) => build.get()),
]);
Expand All @@ -76,42 +54,15 @@ For example, use this tool when a user wants to know more about a specific Actor
inputSchema.properties = filterSchemaProperties(inputSchema.properties);
inputSchema.properties = shortenProperties(inputSchema.properties);

const currentPricingInfo = getCurrentPricingInfo(actorInfo.pricingInfos || [], new Date());
// Use the actor formatter to get the main actor details
const actorCard = formatActorToActorCard(actorInfo);

const result: IGetActorDetailsToolResult = {
id: actorInfo.id,
actorFullName: `${actorInfo.username}/${actorInfo.name}`,

isPublic: actorInfo.isPublic,
isDeprecated: actorInfo.isDeprecated || false,
createdAt: actorInfo.createdAt.toISOString(),
modifiedAt: actorInfo.modifiedAt.toISOString(),

categories: actorInfo.categories,
description: actorInfo.description || 'No description provided.',
readme: buildInfo.actorDefinition.readme || 'No README provided.',

inputSchema,

pricingInfo: pricingInfoToString(currentPricingInfo as (ExtendedPricingInfo | null)),

usageStatistics: {
totalUsers: {
allTime: actorInfo.stats.totalUsers,
last7Days: actorInfo.stats.totalUsers7Days,
last30Days: actorInfo.stats.totalUsers30Days,
last90Days: actorInfo.stats.totalUsers90Days,
},
failedRunsInLast30Days: (
'publicActorRunStats30Days' in actorInfo.stats && 'FAILED' in (actorInfo.stats.publicActorRunStats30Days as object)
) ? (actorInfo.stats.publicActorRunStats30Days as { FAILED: number }).FAILED : 'unknown',
},
};
return {
content: [{
type: 'text',
text: JSON.stringify(result),
}],
content: [
{ type: 'text', text: `**Actor card**:\n${actorCard}` },
{ type: 'text', text: `**README:**\n${buildInfo.actorDefinition.readme || 'No README provided.'}` },
{ type: 'text', text: `**Input Schema:**\n${JSON.stringify(inputSchema, null, 0)}` },
],
};
},
} as InternalTool,
Expand Down
20 changes: 10 additions & 10 deletions src/tools/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,12 @@ export const addTool: ToolEntry = {
type: 'internal',
tool: {
name: HelperTools.ACTOR_ADD,
description:
`Add an Actor or MCP server to the available tools of the Apify MCP server.
A tool is an Actor or MCP server that can be called by the user.
Do not execute the tool, only add it and list it in the available tools.
For example, when a user wants to scrape a website, first search for relevant Actors
using ${HelperTools.STORE_SEARCH} tool, and once the user selects one they want to use,
add it as a tool to the Apify MCP server.`,
description: `Add an Actor or MCP server to the available tools of the Apify MCP server.\n`
+ 'A tool is an Actor or MCP server that can be called by the user.\n'
+ 'Do not execute the tool, only add it and list it in the available tools.\n'
+ 'For example, when a user wants to scrape a website, first search for relevant Actors\n'
+ `using ${HelperTools.STORE_SEARCH} tool, and once the user selects one they want to use,\n`
+ 'add it as a tool to the Apify MCP server.',
inputSchema: zodToJsonSchema(addToolArgsSchema),
ajvValidate: ajv.compile(zodToJsonSchema(addToolArgsSchema)),
// TODO: I don't like that we are passing apifyMcpServer and mcpServer to the tool
Expand Down Expand Up @@ -120,9 +119,10 @@ export const helpTool: ToolEntry = {
type: 'internal',
tool: {
name: HelperTools.APIFY_MCP_HELP_TOOL,
description: `Helper tool to get information on how to use and troubleshoot the Apify MCP server.
This tool always returns the same help message with information about the server and how to use it.
ALWAYS CALL THIS TOOL AT THE BEGINNING OF THE CONVERSATION SO THAT YOU HAVE INFORMATION ABOUT THE APIFY MCP SERVER IN CONTEXT, OR WHEN YOU ENCOUNTER ANY ISSUES WITH THE MCP SERVER OR ITS TOOLS.`,
description: `Helper tool to get information on how to use and troubleshoot the Apify MCP server.\n`
+ 'This tool always returns the same help message with information about the server and how to use it.\n'
+ 'ALWAYS CALL THIS TOOL AT THE BEGINNING OF THE CONVERSATION SO THAT YOU HAVE INFORMATION ABOUT THE APIFY MCP SERVER IN CONTEXT, '
+ 'OR WHEN YOU ENCOUNTER ANY ISSUES WITH THE MCP SERVER OR ITS TOOLS.',
inputSchema: zodToJsonSchema(helpToolArgsSchema),
ajvValidate: ajv.compile(zodToJsonSchema(helpToolArgsSchema)),
call: async () => {
Expand Down
6 changes: 3 additions & 3 deletions src/tools/run_collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ export const getUserRunsList: ToolEntry = {
tool: {
name: HelperTools.ACTOR_RUN_LIST_GET,
actorFullName: HelperTools.ACTOR_RUN_LIST_GET,
description: `Gets a paginated list of Actor runs with run details, datasetId, and keyValueStoreId.
Filter by status: READY (not allocated), RUNNING (executing), SUCCEEDED (finished), FAILED (failed),
TIMING-OUT (timing out), TIMED-OUT (timed out), ABORTING (being aborted), ABORTED (aborted).`,
description: `Gets a paginated list of Actor runs with run details, datasetId, and keyValueStoreId.\n`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we use the + to join the strings if we can just use single multiline ``` string?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other tools already used that. I find noindent ugly. I'm sorry. If we agree on using template literals with noindent across the codebase, I'll adapt.

So what do you say @MQ37 @MichalKalita

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also like how the + looks but updating that and splitting the lines is a hassle. Let's keep that for now 👍

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I usually prefer ``` because of simplicity. It's easier to copy/paste content.
But there is no need to refactor all places and update them.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I've created issue for that: #197

+ 'Filter by status: READY (not allocated), RUNNING (executing), SUCCEEDED (finished), FAILED (failed),\n'
+ 'TIMING-OUT (timing out), TIMED-OUT (timed out), ABORTING (being aborted), ABORTED (aborted).',
inputSchema: zodToJsonSchema(getUserRunsListArgs),
ajvValidate: ajv.compile(zodToJsonSchema(getUserRunsListArgs)),
call: async (toolArgs) => {
Expand Down
9 changes: 6 additions & 3 deletions src/tools/search-apify-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,12 @@ export const searchApifyDocsTool: ToolEntry = {
type: 'internal',
tool: {
name: HelperTools.DOCS_SEARCH,
description: `Apify documentation search tool. This tool allows you to search the Apify documentation using Algolia's full-text search.
You can use it to find relevant documentation pages based on keywords. The results will include the URL of the documentation page, a fragment identifier (if available), and a limited piece of content that matches the search query. You can then fetch the full content of the document using the ${HelperTools.DOCS_FETCH} tool by providing the URL.
Use this tool when a user asks for help with Apify documentation or when you need to find relevant documentation pages based on keywords. For example, when a user wants to build an Apify Actor, you can search "How to build Actors" to find relevant guidance.`,
description: `Apify documentation search tool. This tool allows you to search the Apify documentation using Algolia's full-text search.\n`
+ 'You can use it to find relevant documentation pages based on keywords. The results will include the URL of the documentation page, '
+ 'a fragment identifier (if available), and a limited piece of content that matches the search query. '
+ `You can then fetch the full content of the document using the ${HelperTools.DOCS_FETCH} tool by providing the URL.\n`
+ 'Use this tool when a user asks for help with Apify documentation or when you need to find relevant documentation pages based on keywords. '
+ 'For example, when a user wants to build an Apify Actor, you can search "How to build Actors" to find relevant guidance.',
args: searchApifyDocsToolArgsSchema,
inputSchema: zodToJsonSchema(searchApifyDocsToolArgsSchema),
ajvValidate: ajv.compile(zodToJsonSchema(searchApifyDocsToolArgsSchema)),
Expand Down
Loading