Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
10 changes: 9 additions & 1 deletion src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
ListToolsRequestSchema,
McpError,
} from '@modelcontextprotocol/sdk/types.js';
import type { ActorCallOptions } from 'apify-client';
import { type ActorCallOptions, ApifyApiError } from 'apify-client';

import log from '@apify/log';

Expand Down Expand Up @@ -453,6 +453,14 @@ export class ActorsMcpServer {
return { content };
}
} catch (error) {
if (error instanceof ApifyApiError) {
log.error(`Apify API error calling tool ${name}: ${error.message}`);
return {
content: [
{ type: 'text', text: `Apify API erro calling tool ${name}: ${error.message}` },
],
};
}
log.error(`Error calling tool ${name}: ${error}`);
throw new McpError(
ErrorCode.InternalError,
Expand Down
12 changes: 9 additions & 3 deletions src/tools/actor.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { Ajv } from 'ajv';
import type { ActorCallOptions, ActorRun, Dataset, PaginatedList } from 'apify-client';
import { type ActorCallOptions, type ActorRun, type Dataset, type PaginatedList } from 'apify-client';
import { z } from 'zod';
import zodToJsonSchema from 'zod-to-json-schema';

Expand Down Expand Up @@ -245,10 +245,16 @@ export const getActor: ToolEntry = {
ajvValidate: ajv.compile(zodToJsonSchema(getActorArgs)),
call: async (toolArgs) => {
const { args, apifyToken } = toolArgs;
const parsed = getActorArgs.parse(args);
const { actorId } = getActorArgs.parse(args);
if (!actorId || typeof actorId !== 'string' || actorId.trim() === '') {
return { content: [{ type: 'text', text: 'Actor ID is required.' }] };
}
const client = new ApifyClient({ token: apifyToken });
// Get Actor - contains a lot of irrelevant information
const actor = await client.actor(parsed.actorId).get();
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,
Expand Down
6 changes: 6 additions & 0 deletions src/tools/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,13 @@ export const actorDefinitionTool: ToolEntry = {
const { args, apifyToken } = toolArgs;

const parsed = getActorDefinitionArgsSchema.parse(args);
if (!parsed.actorName || typeof parsed.actorName !== 'string' || parsed.actorName.trim() === '') {
return { content: [{ type: 'text', text: 'Actor name is required.' }] };
}
const v = await getActorDefinition(parsed.actorName, apifyToken, parsed.limit);
if (!v) {
return { content: [{ type: 'text', text: `Actor '${parsed.actorName}' not found.` }] };
}
if (v && v.input && 'properties' in v.input && v.input) {
const properties = filterSchemaProperties(v.input.properties as { [key: string]: ISchemaProperties });
v.input.properties = shortenProperties(properties);
Expand Down
13 changes: 12 additions & 1 deletion src/tools/dataset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,14 @@ export const getDataset: ToolEntry = {
call: async (toolArgs) => {
const { args, apifyToken } = toolArgs;
const parsed = getDatasetArgs.parse(args);
if (!parsed.datasetId || typeof parsed.datasetId !== 'string' || parsed.datasetId.trim() === '') {
return { content: [{ type: 'text', text: 'Dataset ID is required.' }] };
}
const client = new ApifyClient({ token: apifyToken });
const v = await client.dataset(parsed.datasetId).get();
if (!v) {
return { content: [{ type: 'text', text: `Dataset '${parsed.datasetId}' not found.` }] };
}
return { content: [{ type: 'text', text: JSON.stringify(v) }] };
},
} as InternalTool,
Expand Down Expand Up @@ -82,8 +88,10 @@ export const getDatasetItems: ToolEntry = {
call: async (toolArgs) => {
const { args, apifyToken } = toolArgs;
const parsed = getDatasetItemsArgs.parse(args);
if (!parsed.datasetId || typeof parsed.datasetId !== 'string' || parsed.datasetId.trim() === '') {
return { content: [{ type: 'text', text: 'Dataset ID is required.' }] };
}
const client = new ApifyClient({ token: apifyToken });

// Convert comma-separated strings to arrays
const fields = parsed.fields?.split(',').map((f) => f.trim());
const omit = parsed.omit?.split(',').map((f) => f.trim());
Expand All @@ -98,6 +106,9 @@ export const getDatasetItems: ToolEntry = {
desc: parsed.desc,
flatten,
});
if (!v) {
return { content: [{ type: 'text', text: `Dataset '${parsed.datasetId}' not found.` }] };
}
return { content: [{ type: 'text', text: JSON.stringify(v) }] };
},
} as InternalTool,
Expand Down
24 changes: 23 additions & 1 deletion src/tools/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,17 @@ export const addTool: ToolEntry = {
call: async (toolArgs) => {
const { apifyMcpServer, mcpServer, apifyToken, args } = toolArgs;
const parsed = addToolArgsSchema.parse(args);
if (!parsed.actorName || typeof parsed.actorName !== 'string' || parsed.actorName.trim() === '') {
return { content: [{ type: 'text', text: 'Actor name is required.' }] };
}
if (apifyMcpServer.listAllToolNames().includes(parsed.actorName)) {
return {
content: [{
type: 'text',
text: `Actor ${parsed.actorName} is already available. No new tools were added.`,
}],
};
}
const tools = await getActorsAsTools([parsed.actorName], apifyToken);
const toolsAdded = apifyMcpServer.upsertTools(tools, true);
await mcpServer.notification({ method: 'notifications/tools/list_changed' });
Expand Down Expand Up @@ -112,8 +123,19 @@ export const removeTool: ToolEntry = {
// TODO: I don't like that we are passing apifyMcpServer and mcpServer to the tool
call: async (toolArgs) => {
const { apifyMcpServer, mcpServer, args } = toolArgs;

const parsed = removeToolArgsSchema.parse(args);
// Check if tool exists before attempting removal
if (!apifyMcpServer.tools.has(parsed.toolName)) {
// Send notification so client can update its tool list
// just in case the client tool list is out of sync
await mcpServer.notification({ method: 'notifications/tools/list_changed' });
return {
content: [{
type: 'text',
text: `Tool '${parsed.toolName}' not found. No tools were removed.`,
}],
};
}
const removedTools = apifyMcpServer.removeToolsByName([parsed.toolName], true);
await mcpServer.notification({ method: 'notifications/tools/list_changed' });
return { content: [{ type: 'text', text: `Tools removed: ${removedTools.join(', ')}` }] };
Expand Down
12 changes: 12 additions & 0 deletions src/tools/key_value_store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ export const getKeyValueStore: ToolEntry = {
call: async (toolArgs) => {
const { args, apifyToken } = toolArgs;
const parsed = getKeyValueStoreArgs.parse(args);
if (!parsed.storeId || typeof parsed.storeId !== 'string' || parsed.storeId.trim() === '') {
return { content: [{ type: 'text', text: 'Store ID is required.' }] };
}
const client = new ApifyClient({ token: apifyToken });
const store = await client.keyValueStore(parsed.storeId).get();
return { content: [{ type: 'text', text: JSON.stringify(store) }] };
Expand Down Expand Up @@ -65,6 +68,9 @@ export const getKeyValueStoreKeys: ToolEntry = {
call: async (toolArgs) => {
const { args, apifyToken } = toolArgs;
const parsed = getKeyValueStoreKeysArgs.parse(args);
if (!parsed.storeId || typeof parsed.storeId !== 'string' || parsed.storeId.trim() === '') {
return { content: [{ type: 'text', text: 'Store ID is required.' }] };
}
const client = new ApifyClient({ token: apifyToken });
const keys = await client.keyValueStore(parsed.storeId).listKeys({
exclusiveStartKey: parsed.exclusiveStartKey,
Expand Down Expand Up @@ -100,6 +106,12 @@ export const getKeyValueStoreRecord: ToolEntry = {
call: async (toolArgs) => {
const { args, apifyToken } = toolArgs;
const parsed = getKeyValueStoreRecordArgs.parse(args);
if (!parsed.storeId || typeof parsed.storeId !== 'string' || parsed.storeId.trim() === '') {
return { content: [{ type: 'text', text: 'Store ID is required.' }] };
}
if (!parsed.recordKey || typeof parsed.recordKey !== 'string' || parsed.recordKey.trim() === '') {
return { content: [{ type: 'text', text: 'Record key is required.' }] };
}
const client = new ApifyClient({ token: apifyToken });
const record = await client.keyValueStore(parsed.storeId).getRecord(parsed.recordKey);
return { content: [{ type: 'text', text: JSON.stringify(record) }] };
Expand Down
12 changes: 12 additions & 0 deletions src/tools/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,14 @@ export const getActorRun: ToolEntry = {
call: async (toolArgs) => {
const { args, apifyToken } = toolArgs;
const parsed = getActorRunArgs.parse(args);
if (!parsed.runId || typeof parsed.runId !== 'string' || parsed.runId.trim() === '') {
return { content: [{ type: 'text', text: 'Run ID is required.' }] };
}
const client = new ApifyClient({ token: apifyToken });
const v = await client.run(parsed.runId).get();
if (!v) {
return { content: [{ type: 'text', text: `Run with ID '${parsed.runId}' not found.` }] };
}
return { content: [{ type: 'text', text: JSON.stringify(v) }] };
},
} as InternalTool,
Expand Down Expand Up @@ -64,6 +70,9 @@ export const getActorLog: ToolEntry = {
call: async (toolArgs) => {
const { args, apifyToken } = toolArgs;
const parsed = GetRunLogArgs.parse(args);
if (!parsed.runId || typeof parsed.runId !== 'string' || parsed.runId.trim() === '') {
return { content: [{ type: 'text', text: 'Run ID is required.' }] };
}
const client = new ApifyClient({ token: apifyToken });
const v = await client.run(parsed.runId).log().get() ?? '';
const lines = v.split('\n');
Expand All @@ -89,6 +98,9 @@ export const abortActorRun: ToolEntry = {
call: async (toolArgs) => {
const { args, apifyToken } = toolArgs;
const parsed = abortRunArgs.parse(args);
if (!parsed.runId || typeof parsed.runId !== 'string' || parsed.runId.trim() === '') {
return { content: [{ type: 'text', text: 'Run ID is required.' }] };
}
const client = new ApifyClient({ token: apifyToken });
const v = await client.run(parsed.runId).abort({ gracefully: parsed.gracefully });
return { content: [{ type: 'text', text: JSON.stringify(v) }] };
Expand Down
8 changes: 8 additions & 0 deletions src/tools/store_collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,14 @@ export const searchActors: ToolEntry = {
call: async (toolArgs) => {
const { args, apifyToken } = toolArgs;
const parsed = searchActorsArgsSchema.parse(args);
if (!parsed.search || parsed.search.trim() === '') {
return {
content: [{
type: 'text',
text: 'Search string must not be empty. Please provide keywords to search for Actors.',
}],
};
}
const actors = await searchActorsByKeywords(
parsed.search,
apifyToken,
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/actor.server-sse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ createIntegrationTestsSuite({
getActorsMcpServer: () => mcpServer,
createClientFn: async (options) => await createMcpSseClient(mcpUrl, options),
beforeAllFn: async () => {
mcpServer = new ActorsMcpServer({ enableAddingActors: false });
mcpServer = new ActorsMcpServer({ enableAddingActors: false, enableDefaultActors: false });
log.setLevel(log.LEVELS.OFF);

// Create an express app using the proper server setup
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/actor.server-streamable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ createIntegrationTestsSuite({
beforeAllFn: async () => {
log.setLevel(log.LEVELS.OFF);
// Create an express app using the proper server setup
mcpServer = new ActorsMcpServer({ enableAddingActors: false });
mcpServer = new ActorsMcpServer({ enableAddingActors: false, enableDefaultActors: false });
app = createExpressApp(httpServerHost, mcpServer);

// Start a test server
Expand Down
21 changes: 11 additions & 10 deletions tests/integration/suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,7 @@ export function createIntegrationTestsSuite(
await client.close();
});

// TODO: This test is not working as there is a problem with server reset, which loads default Actors
it.runIf(false)('should list all default tools and two loaded Actors', async () => {
it('should list all default tools and two loaded Actors', async () => {
const actors = ['apify/website-content-crawler', 'apify/instagram-scraper'];
const client = await createClientFn({ actors, enableAddingActors: false });
const names = getToolNames(await client.listTools());
Expand Down Expand Up @@ -210,25 +209,27 @@ export function createIntegrationTestsSuite(
});

it.runIf(getActorsMcpServer)('should reset and restore tool state with default tools', async () => {
const client = await createClientFn({ enableAddingActors: true });
const firstClient = await createClientFn({ enableAddingActors: true });
const actorsMCPServer = getActorsMcpServer!();
const numberOfTools = defaultTools.length + addRemoveTools.length + defaults.actors.length;
const toolList = actorsMCPServer.listAllToolNames();
expect(toolList.length).toEqual(numberOfTools);
// Add a new Actor
await addActor(client, ACTOR_PYTHON_EXAMPLE);
await addActor(firstClient, ACTOR_PYTHON_EXAMPLE);

// Store the tool name list
const toolListWithActor = actorsMCPServer.listAllToolNames();
expect(toolListWithActor.length).toEqual(numberOfTools + 1); // + 1 for the added Actor
await firstClient.close();

// Remove all tools
// TODO: The reset functions sets the enableAddingActors to false, which is not expected
// await actorsMCPServer.reset();
// const toolListAfterReset = actorsMCPServer.listAllToolNames();
// expect(toolListAfterReset.length).toEqual(numberOfTools);

await client.close();
await actorsMCPServer.reset();
// We connect second client so that the default tools are loaded
// if no specific list of Actors is provided
const secondClient = await createClientFn({ enableAddingActors: true });
const toolListAfterReset = actorsMCPServer.listAllToolNames();
expect(toolListAfterReset.length).toEqual(numberOfTools);
await secondClient.close();
});

it.runIf(getActorsMcpServer)('should notify tools changed handler on tool modifications', async () => {
Expand Down