Skip to content

Commit 0b0331c

Browse files
MQ37Copilot
andauthored
fix: error and input handling (#130)
* fix error and input handling * fix integration tests * handle empty input and no resource in get actor details * handle errors in runs * lint * Update src/tools/helpers.ts Co-authored-by: Copilot <[email protected]> * Improve error handling in getActor tool by catching specific ApifyApiError * refactor ai slop * restore comment * Update src/mcp/server.ts Co-authored-by: Copilot <[email protected]> * use zod validation instead * fix type import --------- Co-authored-by: Copilot <[email protected]>
1 parent 2361b71 commit 0b0331c

File tree

10 files changed

+81
-21
lines changed

10 files changed

+81
-21
lines changed

src/mcp/server.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
ListToolsRequestSchema,
1313
McpError,
1414
} from '@modelcontextprotocol/sdk/types.js';
15-
import type { ActorCallOptions } from 'apify-client';
15+
import { type ActorCallOptions, ApifyApiError } from 'apify-client';
1616

1717
import log from '@apify/log';
1818

@@ -453,6 +453,14 @@ export class ActorsMcpServer {
453453
return { content };
454454
}
455455
} catch (error) {
456+
if (error instanceof ApifyApiError) {
457+
log.error(`Apify API error calling tool ${name}: ${error.message}`);
458+
return {
459+
content: [
460+
{ type: 'text', text: `Apify API error calling tool ${name}: ${error.message}` },
461+
],
462+
};
463+
}
456464
log.error(`Error calling tool ${name}: ${error}`);
457465
throw new McpError(
458466
ErrorCode.InternalError,

src/tools/actor.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,9 @@ export async function getActorsAsTools(
225225
}
226226

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

231233
/**
@@ -245,10 +247,13 @@ export const getActor: ToolEntry = {
245247
ajvValidate: ajv.compile(zodToJsonSchema(getActorArgs)),
246248
call: async (toolArgs) => {
247249
const { args, apifyToken } = toolArgs;
248-
const parsed = getActorArgs.parse(args);
250+
const { actorId } = getActorArgs.parse(args);
249251
const client = new ApifyClient({ token: apifyToken });
250252
// Get Actor - contains a lot of irrelevant information
251-
const actor = await client.actor(parsed.actorId).get();
253+
const actor = await client.actor(actorId).get();
254+
if (!actor) {
255+
return { content: [{ type: 'text', text: `Actor '${actorId}' not found.` }] };
256+
}
252257
return { content: [{ type: 'text', text: JSON.stringify(actor) }] };
253258
},
254259
} as InternalTool,

src/tools/build.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ function truncateActorReadme(readme: string, limit = ACTOR_README_MAX_LENGTH): s
9696

9797
const getActorDefinitionArgsSchema = z.object({
9898
actorName: z.string()
99+
.min(1)
99100
.describe('Retrieve input, readme, and other details for Actor ID or Actor full name. '
100101
+ 'Actor name is always composed from `username/name`'),
101102
limit: z.number()
@@ -124,6 +125,9 @@ export const actorDefinitionTool: ToolEntry = {
124125

125126
const parsed = getActorDefinitionArgsSchema.parse(args);
126127
const v = await getActorDefinition(parsed.actorName, apifyToken, parsed.limit);
128+
if (!v) {
129+
return { content: [{ type: 'text', text: `Actor '${parsed.actorName}' not found.` }] };
130+
}
127131
if (v && v.input && 'properties' in v.input && v.input) {
128132
const properties = filterSchemaProperties(v.input.properties as { [key: string]: ISchemaProperties });
129133
v.input.properties = shortenProperties(properties);

src/tools/dataset.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,15 @@ import type { InternalTool, ToolEntry } from '../types.js';
99
const ajv = new Ajv({ coerceTypes: 'array', strict: false });
1010

1111
const getDatasetArgs = z.object({
12-
datasetId: z.string().describe('Dataset ID or username~dataset-name.'),
12+
datasetId: z.string()
13+
.min(1)
14+
.describe('Dataset ID or username~dataset-name.'),
1315
});
1416

1517
const getDatasetItemsArgs = z.object({
16-
datasetId: z.string().describe('Dataset ID or username~dataset-name.'),
18+
datasetId: z.string()
19+
.min(1)
20+
.describe('Dataset ID or username~dataset-name.'),
1721
clean: z.boolean().optional()
1822
.describe('If true, returns only non-empty items and skips hidden fields (starting with #). Shortcut for skipHidden=true and skipEmpty=true.'),
1923
offset: z.number().optional()
@@ -54,6 +58,9 @@ export const getDataset: ToolEntry = {
5458
const parsed = getDatasetArgs.parse(args);
5559
const client = new ApifyClient({ token: apifyToken });
5660
const v = await client.dataset(parsed.datasetId).get();
61+
if (!v) {
62+
return { content: [{ type: 'text', text: `Dataset '${parsed.datasetId}' not found.` }] };
63+
}
5764
return { content: [{ type: 'text', text: JSON.stringify(v) }] };
5865
},
5966
} as InternalTool,
@@ -98,6 +105,9 @@ export const getDatasetItems: ToolEntry = {
98105
desc: parsed.desc,
99106
flatten,
100107
});
108+
if (!v) {
109+
return { content: [{ type: 'text', text: `Dataset '${parsed.datasetId}' not found.` }] };
110+
}
101111
return { content: [{ type: 'text', text: JSON.stringify(v) }] };
102112
},
103113
} as InternalTool,

src/tools/helpers.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ In that case, the user should check the MCP client documentation to see if the c
6262

6363
export const addToolArgsSchema = z.object({
6464
actorName: z.string()
65+
.min(1)
6566
.describe('Add a tool, Actor or MCP-Server to available tools by Actor ID or tool full name.'
6667
+ 'Tool name is always composed from `username/name`'),
6768
});
@@ -79,6 +80,14 @@ export const addTool: ToolEntry = {
7980
call: async (toolArgs) => {
8081
const { apifyMcpServer, mcpServer, apifyToken, args } = toolArgs;
8182
const parsed = addToolArgsSchema.parse(args);
83+
if (apifyMcpServer.listAllToolNames().includes(parsed.actorName)) {
84+
return {
85+
content: [{
86+
type: 'text',
87+
text: `Actor ${parsed.actorName} is already available. No new tools were added.`,
88+
}],
89+
};
90+
}
8291
const tools = await getActorsAsTools([parsed.actorName], apifyToken);
8392
const toolsAdded = apifyMcpServer.upsertTools(tools, true);
8493
await mcpServer.notification({ method: 'notifications/tools/list_changed' });
@@ -98,6 +107,7 @@ export const addTool: ToolEntry = {
98107
};
99108
export const removeToolArgsSchema = z.object({
100109
toolName: z.string()
110+
.min(1)
101111
.describe('Tool name to remove from available tools.')
102112
.transform((val) => actorNameToToolName(val)),
103113
});
@@ -112,8 +122,19 @@ export const removeTool: ToolEntry = {
112122
// TODO: I don't like that we are passing apifyMcpServer and mcpServer to the tool
113123
call: async (toolArgs) => {
114124
const { apifyMcpServer, mcpServer, args } = toolArgs;
115-
116125
const parsed = removeToolArgsSchema.parse(args);
126+
// Check if tool exists before attempting removal
127+
if (!apifyMcpServer.tools.has(parsed.toolName)) {
128+
// Send notification so client can update its tool list
129+
// just in case the client tool list is out of sync
130+
await mcpServer.notification({ method: 'notifications/tools/list_changed' });
131+
return {
132+
content: [{
133+
type: 'text',
134+
text: `Tool '${parsed.toolName}' not found. No tools were removed.`,
135+
}],
136+
};
137+
}
117138
const removedTools = apifyMcpServer.removeToolsByName([parsed.toolName], true);
118139
await mcpServer.notification({ method: 'notifications/tools/list_changed' });
119140
return { content: [{ type: 'text', text: `Tools removed: ${removedTools.join(', ')}` }] };

src/tools/key_value_store.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const ajv = new Ajv({ coerceTypes: 'array', strict: false });
1010

1111
const getKeyValueStoreArgs = z.object({
1212
storeId: z.string()
13+
.min(1)
1314
.describe('Key-value store ID or username~store-name'),
1415
});
1516

@@ -38,6 +39,7 @@ export const getKeyValueStore: ToolEntry = {
3839

3940
const getKeyValueStoreKeysArgs = z.object({
4041
storeId: z.string()
42+
.min(1)
4143
.describe('Key-value store ID or username~store-name'),
4244
exclusiveStartKey: z.string()
4345
.optional()
@@ -77,8 +79,10 @@ export const getKeyValueStoreKeys: ToolEntry = {
7779

7880
const getKeyValueStoreRecordArgs = z.object({
7981
storeId: z.string()
82+
.min(1)
8083
.describe('Key-value store ID or username~store-name'),
8184
recordKey: z.string()
85+
.min(1)
8286
.describe('Key of the record to retrieve.'),
8387
});
8488

src/tools/run.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,15 @@ import type { InternalTool, ToolEntry } from '../types.js';
99
const ajv = new Ajv({ coerceTypes: 'array', strict: false });
1010

1111
const getActorRunArgs = z.object({
12-
runId: z.string().describe('The ID of the Actor run.'),
12+
runId: z.string()
13+
.min(1)
14+
.describe('The ID of the Actor run.'),
1315
});
1416

1517
const abortRunArgs = z.object({
16-
runId: z.string().describe('The ID of the Actor run to abort.'),
18+
runId: z.string()
19+
.min(1)
20+
.describe('The ID of the Actor run to abort.'),
1721
gracefully: z.boolean().optional().describe('If true, the Actor run will abort gracefully with a 30-second timeout.'),
1822
});
1923

@@ -35,6 +39,9 @@ export const getActorRun: ToolEntry = {
3539
const parsed = getActorRunArgs.parse(args);
3640
const client = new ApifyClient({ token: apifyToken });
3741
const v = await client.run(parsed.runId).get();
42+
if (!v) {
43+
return { content: [{ type: 'text', text: `Run with ID '${parsed.runId}' not found.` }] };
44+
}
3845
return { content: [{ type: 'text', text: JSON.stringify(v) }] };
3946
},
4047
} as InternalTool,

tests/integration/actor.server-sse.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ createIntegrationTestsSuite({
2121
getActorsMcpServer: () => mcpServer,
2222
createClientFn: async (options) => await createMcpSseClient(mcpUrl, options),
2323
beforeAllFn: async () => {
24-
mcpServer = new ActorsMcpServer({ enableAddingActors: false });
24+
mcpServer = new ActorsMcpServer({ enableAddingActors: false, enableDefaultActors: false });
2525
log.setLevel(log.LEVELS.OFF);
2626

2727
// Create an express app using the proper server setup

tests/integration/actor.server-streamable.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ createIntegrationTestsSuite({
2323
beforeAllFn: async () => {
2424
log.setLevel(log.LEVELS.OFF);
2525
// Create an express app using the proper server setup
26-
mcpServer = new ActorsMcpServer({ enableAddingActors: false });
26+
mcpServer = new ActorsMcpServer({ enableAddingActors: false, enableDefaultActors: false });
2727
app = createExpressApp(httpServerHost, mcpServer);
2828

2929
// Start a test server

tests/integration/suite.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,7 @@ export function createIntegrationTestsSuite(
103103
await client.close();
104104
});
105105

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

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

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

225225
// Remove all tools
226-
// TODO: The reset functions sets the enableAddingActors to false, which is not expected
227-
// await actorsMCPServer.reset();
228-
// const toolListAfterReset = actorsMCPServer.listAllToolNames();
229-
// expect(toolListAfterReset.length).toEqual(numberOfTools);
230-
231-
await client.close();
226+
await actorsMCPServer.reset();
227+
// We connect second client so that the default tools are loaded
228+
// if no specific list of Actors is provided
229+
const secondClient = await createClientFn({ enableAddingActors: true });
230+
const toolListAfterReset = actorsMCPServer.listAllToolNames();
231+
expect(toolListAfterReset.length).toEqual(numberOfTools);
232+
await secondClient.close();
232233
});
233234

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

0 commit comments

Comments
 (0)