Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Add internal tools for Actor discovery #28

Merged
merged 17 commits into from
Jan 30, 2025
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
15 changes: 14 additions & 1 deletion .actor/input_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@
"lukaskrivka/google-maps-with-contact-details"
]
},
"enableActorAutoLoading": {
"title": "Enable automatic loading of Actors based on context and use-case (experimental, check if it supported by your client)",
"type": "boolean",
"description": "When enabled, the server can dynamically add Actors as tools based on user requests and context. \n\nNote: Not all MCP clients support this feature. To try it, you can use the [Tester MCP Client](https://apify.com/jiri.spilka/tester-mcp-client). This is an experimental feature and may require client-specific support.",
"default": false
},
"maxActorMemoryBytes": {
"title": "Limit the maximum memory used by an Actor",
"type": "integer",
"description": "Limit the maximum memory used by an Actor in bytes. This is important setting for Free plan users to avoid exceeding the memory limit.",
"prefill": 4096,
"default": 4096
},
"debugActor": {
"title": "Debug Actor",
"type": "string",
Expand All @@ -28,7 +41,7 @@
"description": "Specify the input for the Actor that will be used for debugging in normal mode",
"editor": "json",
"prefill": {
"query": "hello world"
"query": "hello world"
}
}
}
Expand Down
48 changes: 37 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ Implementation of an MCP server for all [Apify Actors](https://apify.com/store).
This server enables interaction with one or more Apify Actors that can be defined in the MCP Server configuration.

The server can be used in two ways:
- 🇦 **Apify MCP Server Actor**: runs an HTTP server with MCP protocol via Server-Sent Events.
- ⾕ **Apify MCP Server Stdio**: provides support for the MCP protocol via standard input/output stdio.
- 🇦 **Apify MCP Server Actor**: runs an HTTP server with MCP and can be accessed via Server-Sent Events (SSE).
- ⾕ **Apify MCP Server Stdio**: runs the server locally with MCP via standard input/output (stdio).

You can test the MCP server using [Tester MCP Client](https://apify.com/jiri.spilka/tester-mcp-client)

# 🎯 What does Apify MCP server do?

Expand All @@ -19,8 +21,15 @@ For example it can:
- use [Instagram Scraper](https://apify.com/apify/instagram-scraper) to scrape Instagram posts, profiles, places, photos, and comments
- use [RAG Web Browser](https://apify.com/apify/web-scraper) to search the web, scrape the top N URLs, and return their content

# MCP Clients

To interact with the Apify MCP server, you can use MCP clients such as:
- [Claude Desktop](https://claude.ai/download) (only Stdio support)
- [LibreChat](https://www.librechat.ai/) (stdio and SSE support (yeah without Authorization header))
- [Apify Tester MCP Client](https://apify.com/jiri.spilka/tester-mcp-client) (SSE support with Authorization headers)
- other clients at [https://modelcontextprotocol.io/clients](https://modelcontextprotocol.io/clients)
- more clients at [https://glama.ai/mcp/clients](https://glama.ai/mcp/clients)

To interact with the Apify MCP server, you can use MCP clients such as [Claude Desktop](https://claude.ai/download), [LibreChat](https://www.librechat.ai/), or other [MCP clients](https://glama.ai/mcp/clients).
Additionally, you can use simple example clients found in the [examples](https://github.com/apify/actor-mcp-server/tree/main/src/examples) directory.

When you have Actors integrated with the MCP server, you can ask:
Expand Down Expand Up @@ -54,6 +63,8 @@ To learn more, check out the blog post: [What are AI Agents?](https://blog.apify

## Tools

### Actors

Any [Apify Actor](https://apify.com/store) can be used as a tool.
By default, the server is pre-configured with the Actors specified below, but it can be overridden by providing Actor input.

Expand All @@ -79,6 +90,19 @@ You don't need to specify the input parameters or which Actor to call, everythin
When a tool is called, the arguments are automatically passed to the Actor by the LLM.
You can refer to the specific Actor's documentation for a list of available arguments.

### Helper tools

The server provides a set of helper tools to discover available Actors and retrieve their details:
- `get-actor-details`: Retrieves documentation, input schema, and other details about a specific Actor.
- `discover-actors`: Searches for relevant Actors using keywords and returns their details.

There are also tools to manage the available tools list. However, dynamically adding and removing tools requires the MCP client to have the capability to manage the tools list, which is typically not supported.

You can try this functionality using the [Apify Tester MCP Client](https://apify.com/jiri.spilka/tester-mcp-client) Actor. To enable it, set the `enableActorAutoLoading` parameter.

- `add-actor-as-tool`: Adds an Actor by name to the available tools list without executing it, requiring user consent to run later.
- `remove-actor-from-tool`: Removes an Actor by name from the available tools list when it's no longer needed.

## Prompt & Resources

The server does not provide any resources and prompts.
Expand Down Expand Up @@ -110,10 +134,13 @@ https://actors-mcp-server-task.apify.actor?token=<APIFY_TOKEN>

You can find a list of all available Actors in the [Apify Store](https://apify.com/store).

#### 💬 Interact with the MCP Server
#### 💬 Interact with the MCP Server over SSE

Once the server is running, you can interact with Server-Sent Events (SSE) to send messages to the server and receive responses.
You can use MCP clients such as [Superinference.ai](https://superinterface.ai/) or [LibreChat](https://www.librechat.ai/).
The easiest way is to use [Tester MCP Client](https://apify.com/jiri.spilka/tester-mcp-client) on Apify.

Other clients do not support SSE yet, but this will likely change.
Please verify if MCP clients such ass [Superinference.ai](https://superinterface.ai/) or [LibreChat](https://www.librechat.ai/) support SSE with custom headers.
([Claude Desktop](https://claude.ai/download) does not support SSE transport yet, see [Claude Desktop Configuration](#claude-desktop) section for more details).

In the client settings you need to provide server configuration:
Expand Down Expand Up @@ -273,6 +300,7 @@ ANTHROPIC_API_KEY=your-anthropic-api-token
```
In the `examples` directory, you can find two clients that interact with the server via
standard input/output (stdio):

1. [`clientStdio.ts`](https://github.com/apify/actor-mcp-server/tree/main/src/examples/clientStdio.ts)
This client script starts the MCP server with two specified Actors.
It then calls the `apify/rag-web-browser` tool with a query and prints the result.
Expand Down Expand Up @@ -305,12 +333,12 @@ ANTHROPIC_API_KEY=your-anthropic-api-key
```
## Local client (SSE)

To test the server with the SSE transport, you can use python script `examples/client_sse.py`:
To test the server with the SSE transport, you can use python script `examples/clientSse.ts`:
Currently, the node.js client does not support to establish a connection to remote server witch custom headers.
You need to change URL to your local server URL in the script.

```bash
python src/examples/client_sse.py
node dist/examples/clientSse.js
```

## Debugging
Expand All @@ -334,17 +362,15 @@ Upon launching, the Inspector will display a URL that you can access in your bro

## ⓘ Limitations and feedback

To limit the context size the properties in the `input schema` are pruned and description is truncated to 200 characters.
To limit the context size the properties in the `input schema` are pruned and description is truncated to 500 characters.
Enum fields and titles are truncated to max 50 options.

Memory for each Actor is limited to 4GB.
Free users have an 8GB limit, 128MB needs to be allocated for running `Actors-MCP-Server`.

If you need other features or have any feedback, please [submit an issue](https://console.apify.com/actors/3ox4R101TgZz67sLr/issues) in Apify Console to let us know.
If you need other features or have any feedback, please [submit an issue](https://console.apify.com/actors/1lSvMAaRcadrM1Vgv/issues) in Apify Console to let us know.

# 🚀 Roadmap (January 2025)

- Document examples for [LibreChat](https://www.librechat.ai/).
- Provide tools to search for Actors and load them as needed.
- Add Apify's dataset and key-value store as resources.
- Add tools such as Actor logs and Actor runs for debugging.
Binary file modified docs/actors-mcp-server.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@
"apify": "^3.2.6",
"apify-client": "^2.11.1",
"express": "^4.21.2",
"minimist": "^1.2.8"
"minimist": "^1.2.8",
"zod": "^3.24.1",
"zod-to-json-schema": "^3.24.1"
},
"devDependencies": {
"@anthropic-ai/sdk": "^0.33.1",
Expand Down
67 changes: 40 additions & 27 deletions src/actorDefinition.ts → src/actors.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
import { Ajv } from 'ajv';
import { ApifyClient } from 'apify-client';

import { MAX_DESCRIPTION_LENGTH, MAX_ENUM_LENGTH, MAX_MEMORY_MBYTES } from './const.js';
import { ACTOR_ADDITIONAL_INSTRUCTIONS, defaults, MAX_DESCRIPTION_LENGTH } from './const.js';
import { log } from './logger.js';
import type { ActorDefinitionWithDesc, SchemaProperties, Tool } from './types.js';
import type {
ActorDefinitionPruned,
ActorDefinitionWithDesc,
SchemaProperties,
Tool,
} from './types.js';

export function actorNameToToolName(actorName: string): string {
return actorName.replace('/', '--');
}

export function toolNameToActorName(toolName: string): string {
return toolName.replace('--', '/');
}

/**
* Get actor input schema by actor name.
Expand All @@ -12,11 +25,7 @@ import type { ActorDefinitionWithDesc, SchemaProperties, Tool } from './types.js
* @param {string} actorFullName - The full name of the actor.
* @returns {Promise<ActorDefinitionWithDesc | null>} - The actor definition with description or null if not found.
*/
async function fetchActorDefinition(actorFullName: string): Promise<ActorDefinitionWithDesc | null> {
if (!process.env.APIFY_TOKEN) {
log.error('APIFY_TOKEN is required but not set. Please set it as an environment variable');
return null;
}
export async function getActorDefinition(actorFullName: string): Promise<ActorDefinitionPruned | null> {
const client = new ApifyClient({ token: process.env.APIFY_TOKEN });
const actorClient = client.actor(actorFullName);

Expand All @@ -43,9 +52,9 @@ async function fetchActorDefinition(actorFullName: string): Promise<ActorDefinit
if (buildDetails?.actorDefinition) {
const actorDefinitions = buildDetails?.actorDefinition as ActorDefinitionWithDesc;
actorDefinitions.description = actor.description || '';
actorDefinitions.name = actorFullName;
actorDefinitions.actorFullName = actorFullName;
actorDefinitions.defaultRunOptions = actor.defaultRunOptions;
return actorDefinitions;
return pruneActorDefinition(actorDefinitions);
}
return null;
} catch (error) {
Expand All @@ -54,21 +63,26 @@ async function fetchActorDefinition(actorFullName: string): Promise<ActorDefinit
}
}

function pruneActorDefinition(response: ActorDefinitionWithDesc): ActorDefinitionPruned {
return {
actorFullName: response.actorFullName || '',
buildTag: response?.buildTag || '',
readme: response?.readme || '',
input: response?.input || null,
description: response.description,
defaultRunOptions: response.defaultRunOptions,
};
}

/**
* Shortens the description and enum values of schema properties.
* @param properties
*/
function shortenProperties(properties: { [key: string]: SchemaProperties}): { [key: string]: SchemaProperties } {
export function shortenProperties(properties: { [key: string]: SchemaProperties}): { [key: string]: SchemaProperties } {
for (const property of Object.values(properties)) {
if (property.description.length > MAX_DESCRIPTION_LENGTH) {
property.description = `${property.description.slice(0, MAX_DESCRIPTION_LENGTH)}...`;
}
if (property.enum) {
property.enum = property.enum.slice(0, MAX_ENUM_LENGTH);
}
if (property.enumTitles) {
property.enumTitles = property.enumTitles.slice(0, MAX_ENUM_LENGTH);
}
}
return properties;
}
Expand All @@ -77,11 +91,11 @@ function shortenProperties(properties: { [key: string]: SchemaProperties}): { [k
* Filters schema properties to include only the necessary fields.
* @param properties
*/
function filterSchemaProperties(properties: { [key: string]: SchemaProperties }): { [key: string]: SchemaProperties } {
export function filterSchemaProperties(properties: { [key: string]: SchemaProperties }): { [key: string]: SchemaProperties } {
const filteredProperties: { [key: string]: SchemaProperties } = {};
for (const [key, property] of Object.entries(properties)) {
const { title, description, enum: enumValues, enumTitles, type, default: defaultValue, prefill } = property;
filteredProperties[key] = { title, description, enum: enumValues, enumTitles, type, default: defaultValue, prefill };
const { title, description, enum: enumValues, type, default: defaultValue, prefill } = property;
filteredProperties[key] = { title, description, enum: enumValues, type, default: defaultValue, prefill };
}
return filteredProperties;
}
Expand All @@ -98,9 +112,8 @@ function filterSchemaProperties(properties: { [key: string]: SchemaProperties })
* @returns {Promise<Tool[]>} - A promise that resolves to an array of MCP tools.
*/
export async function getActorsAsTools(actors: string[]): Promise<Tool[]> {
// Fetch input schemas in parallel
const ajv = new Ajv({ coerceTypes: 'array', strict: false });
const results = await Promise.all(actors.map(fetchActorDefinition));
const results = await Promise.all(actors.map(getActorDefinition));
const tools = [];
for (const result of results) {
if (result) {
Expand All @@ -109,17 +122,17 @@ export async function getActorsAsTools(actors: string[]): Promise<Tool[]> {
result.input.properties = shortenProperties(properties);
}
try {
const memoryMbytes = result.defaultRunOptions?.memoryMbytes || MAX_MEMORY_MBYTES;
const memoryMbytes = result.defaultRunOptions?.memoryMbytes || defaults.maxMemoryMbytes;
tools.push({
name: result.name.replace('/', '_'),
actorName: result.name,
description: result.description,
name: actorNameToToolName(result.actorFullName),
actorFullName: result.actorFullName,
description: `${result.description} Instructions: ${ACTOR_ADDITIONAL_INSTRUCTIONS}`,
inputSchema: result.input || {},
ajvValidate: ajv.compile(result.input || {}),
memoryMbytes: memoryMbytes > MAX_MEMORY_MBYTES ? MAX_MEMORY_MBYTES : memoryMbytes,
memoryMbytes: memoryMbytes > defaults.maxMemoryMbytes ? defaults.maxMemoryMbytes : memoryMbytes,
});
} catch (validationError) {
log.error(`Failed to compile AJV schema for actor: ${result.name}. Error: ${validationError}`);
log.error(`Failed to compile AJV schema for actor: ${result.actorFullName}. Error: ${validationError}`);
}
}
}
Expand Down
22 changes: 14 additions & 8 deletions src/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,7 @@ export const SERVER_NAME = 'apify-mcp-server';
export const SERVER_VERSION = '0.1.0';

export const HEADER_READINESS_PROBE = 'x-apify-container-server-readiness-probe';

export const MAX_ENUM_LENGTH = 50;
export const MAX_DESCRIPTION_LENGTH = 200;
// Limit memory to 4GB for Actors. Free users have 8 GB limit, but we need to reserve some memory for Actors-MCP-Server too
export const MAX_MEMORY_MBYTES = 4096;

export const MAX_DESCRIPTION_LENGTH = 500;
export const USER_AGENT_ORIGIN = 'Origin/mcp-server';

export const defaults = {
Expand All @@ -16,11 +11,22 @@ export const defaults = {
'apify/rag-web-browser',
'lukaskrivka/google-maps-with-contact-details',
],
enableActorAutoLoading: false,
maxMemoryMbytes: 4096,
};

export const ACTOR_OUTPUT_MAX_CHARS_PER_ITEM = 2_000;
export const ACTOR_OUTPUT_MAX_CHARS_PER_ITEM = 5_000;
export const ACTOR_OUTPUT_TRUNCATED_MESSAGE = `Output was truncated because it will not fit into context.`
+ ` There is no reason to call this tool again!`;
+ `There is no reason to call this tool again!`;
export const ACTOR_ADDITIONAL_INSTRUCTIONS = 'Never call/execute tool/Actor unless confirmed by the user. '
+ 'Always limit the number of results in the call arguments.';

export enum InternalTools {
DISCOVER_ACTORS = 'discover-actors',
ADD_ACTOR_TO_TOOLS = 'add-actor-to-tools',
REMOVE_ACTOR_FROM_TOOLS = 'remove-actor-from-tools',
GET_ACTOR_DETAILS = 'get-actor-details',
}

export enum Routes {
ROOT = '/',
Expand Down
2 changes: 1 addition & 1 deletion src/examples/clientSse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* Connect to the MCP server using SSE transport and call a tool.
* The Actors MCP Server will load default Actors.
*
* It requires the `APIFY_TOKEN` in the `.env` file.
*/

import path from 'path';
Expand All @@ -15,7 +16,6 @@ import dotenv from 'dotenv';
import { EventSource } from 'eventsource';

const REQUEST_TIMEOUT = 120_000; // 2 minutes
// Resolve dirname equivalent in ES module
const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename);

Expand Down
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ log.setLevel(log.LEVELS.ERROR);
const argv = minimist(process.argv.slice(2));
const argActors = argv.actors?.split(',').map((actor: string) => actor.trim()) || [];

if (!process.env.APIFY_TOKEN) {
log.error('APIFY_TOKEN is required but not set in the environment variables.');
process.exit(1);
}

async function main() {
const server = new ApifyMcpServer();
await (argActors.length !== 0
Expand Down
3 changes: 3 additions & 0 deletions src/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,8 @@ export async function processInput(originalInput: Partial<Input>): Promise<Input
if (input.actors && typeof input.actors === 'string') {
input.actors = input.actors.split(',').map((format: string) => format.trim()) as string[];
}
if (!input.enableActorAutoLoading) {
input.enableActorAutoLoading = false;
}
return input;
}
Loading
Loading