Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
17 changes: 13 additions & 4 deletions src/tools/actor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,11 +245,20 @@ 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();
return { content: [{ type: 'text', text: JSON.stringify(actor) }] };
try {
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) }] };
} catch {
return { content: [{ type: 'text', text: `Invalid actor ID or actor not found.` }] };
}
},
} as InternalTool,
};
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
53 changes: 36 additions & 17 deletions src/tools/dataset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,19 @@ 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();
return { content: [{ type: 'text', text: JSON.stringify(v) }] };
try {
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) }] };
} catch {
return { content: [{ type: 'text', text: `Invalid dataset ID or dataset not found.` }] };
}
},
} as InternalTool,
};
Expand Down Expand Up @@ -82,23 +92,32 @@ 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 });
try {
// Convert comma-separated strings to arrays
const fields = parsed.fields?.split(',').map((f) => f.trim());
const omit = parsed.omit?.split(',').map((f) => f.trim());
const flatten = parsed.flatten?.split(',').map((f) => f.trim());

// Convert comma-separated strings to arrays
const fields = parsed.fields?.split(',').map((f) => f.trim());
const omit = parsed.omit?.split(',').map((f) => f.trim());
const flatten = parsed.flatten?.split(',').map((f) => f.trim());

const v = await client.dataset(parsed.datasetId).listItems({
clean: parsed.clean,
offset: parsed.offset,
limit: parsed.limit,
fields,
omit,
desc: parsed.desc,
flatten,
});
return { content: [{ type: 'text', text: JSON.stringify(v) }] };
const v = await client.dataset(parsed.datasetId).listItems({
clean: parsed.clean,
offset: parsed.offset,
limit: parsed.limit,
fields,
omit,
desc: parsed.desc,
flatten,
});
if (!v) {
return { content: [{ type: 'text', text: `Dataset '${parsed.datasetId}' not found.` }] };
}
return { content: [{ type: 'text', text: JSON.stringify(v) }] };
} catch {
return { content: [{ type: 'text', text: `Invalid input or dataset not found.` }] };
}
},
} as InternalTool,
};
43 changes: 41 additions & 2 deletions src/tools/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { Ajv } from 'ajv';
import { ApifyApiError } from 'apify-client';
import { z } from 'zod';
import zodToJsonSchema from 'zod-to-json-schema';

import log from '@apify/log';

import { HelperTools } from '../const.js';
import type { InternalTool, ToolEntry } from '../types';
import { getActorsAsTools } from './actor.js';
Expand Down Expand Up @@ -79,7 +82,32 @@ export const addTool: ToolEntry = {
call: async (toolArgs) => {
const { apifyMcpServer, mcpServer, apifyToken, args } = toolArgs;
const parsed = addToolArgsSchema.parse(args);
const tools = await getActorsAsTools([parsed.actorName], apifyToken);
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.`,
}],
};
}
let tools;
try {
tools = await getActorsAsTools([parsed.actorName], apifyToken);
} catch (error) {
if (error instanceof ApifyApiError) {
log.error(`[addTool] Failed to add Actor ${parsed.actorName}: ${error.message}`);
return {
content: [{
type: 'text',
text: `Failed to add Actor ${parsed.actorName}. Error: ${error.message}`,
}],
};
}
throw error;
}
const toolsAdded = apifyMcpServer.upsertTools(tools, true);
await mcpServer.notification({ method: 'notifications/tools/list_changed' });

Expand Down Expand Up @@ -112,8 +140,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 '${args.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
27 changes: 25 additions & 2 deletions src/tools/run.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { Ajv } from 'ajv';
import { ApifyApiError } from 'apify-client';
import { z } from 'zod';
import zodToJsonSchema from 'zod-to-json-schema';

import log from '@apify/log';

import { ApifyClient } from '../apify-client.js';
import { HelperTools } from '../const.js';
import type { InternalTool, ToolEntry } from '../types.js';
Expand Down Expand Up @@ -33,8 +36,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 +73,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,9 +101,20 @@ 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) }] };
try {
const v = await client.run(parsed.runId).abort({ gracefully: parsed.gracefully });
return { content: [{ type: 'text', text: JSON.stringify(v) }] };
} catch (error) {
if (error instanceof ApifyApiError) {
log.error(`[abortActorRun] Failed to abort run ${parsed.runId}: ${error.message}`);
return { content: [{ type: 'text', text: `Failed to abort run: ${error.message}` }] };
}
throw error;
}
},
} as InternalTool,
};
12 changes: 10 additions & 2 deletions src/tools/run_collection.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Ajv } from 'ajv';
import { ApifyApiError } from 'apify-client';
import { z } from 'zod';
import zodToJsonSchema from 'zod-to-json-schema';

Expand Down Expand Up @@ -41,8 +42,15 @@ export const getUserRunsList: ToolEntry = {
const { args, apifyToken } = toolArgs;
const parsed = getUserRunsListArgs.parse(args);
const client = new ApifyClient({ token: apifyToken });
const runs = await client.runs().list({ limit: parsed.limit, offset: parsed.offset, desc: parsed.desc, status: parsed.status });
return { content: [{ type: 'text', text: JSON.stringify(runs) }] };
try {
const runs = await client.runs().list({ limit: parsed.limit, offset: parsed.offset, desc: parsed.desc, status: parsed.status });
return { content: [{ type: 'text', text: JSON.stringify(runs) }] };
} catch (error) {
if (error instanceof ApifyApiError) {
return { content: [{ type: 'text', text: `Failed to get runs list: ${error.message}` }] };
}
throw error;
}
},
} as InternalTool,
};
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