Skip to content

Commit 4696373

Browse files
committed
Return only relevant output dataset properies if defined in storage views defiintion, if dump all properties. Fix input schema for Actors without any input schema.
1 parent 5f44003 commit 4696373

File tree

10 files changed

+259
-40
lines changed

10 files changed

+259
-40
lines changed

src/const.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,7 @@ export const ACTOR_OUTPUT_MAX_CHARS_PER_ITEM = 5_000;
4646
export const ACTOR_OUTPUT_TRUNCATED_MESSAGE = `Output was truncated because it will not fit into context.`
4747
+ `There is no reason to call this tool again! You can use ${HelperTools.DATASET_GET_ITEMS} tool to get more items from the dataset.`;
4848

49-
export const ACTOR_ADDITIONAL_INSTRUCTIONS = `Never call/execute tool/Actor unless confirmed by the user.
50-
Workflow: When an Actor runs, it processes data and stores results in Apify storage,
51-
Datasets (for structured/tabular data) and Key-Value Store (for various data types like JSON, images, HTML).
52-
Each Actor run produces a dataset ID and key-value store ID for accessing the results.
53-
By default, the number of items returned from an Actor run is limited to ${ACTOR_RUN_DATASET_OUTPUT_MAX_ITEMS}.
54-
You can always use ${HelperTools.DATASET_GET_ITEMS} tool to get more items from the dataset.
55-
Actor run input is always stored in the key-value store, recordKey: INPUT.`;
49+
export const ACTOR_ADDITIONAL_INSTRUCTIONS = 'Never call/execute tool/Actor unless confirmed by the user.';
5650

5751
export const ACTOR_CACHE_MAX_SIZE = 500;
5852
export const ACTOR_CACHE_TTL_SECS = 30 * 60; // 30 minutes

src/main.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,10 @@ if (STANDBY_MODE) {
5656
await Actor.fail('If you need to debug a specific Actor, please provide the debugActor and debugActorInput fields in the input');
5757
}
5858
const options = { memory: input.maxActorMemoryBytes } as ActorCallOptions;
59-
const { datasetInfo, items } = await callActorGetDataset(input.debugActor!, input.debugActorInput!, process.env.APIFY_TOKEN, options);
59+
const { items } = await callActorGetDataset(input.debugActor!, input.debugActorInput!, process.env.APIFY_TOKEN, options);
6060

6161
await Actor.pushData(items);
62-
log.info(`Pushed ${datasetInfo?.itemCount} items to the dataset`);
62+
log.info(`Pushed ${items.count} items to the dataset`);
6363
await Actor.exit();
6464
}
6565

src/mcp/server.ts

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,6 @@ import { type ActorCallOptions, ApifyApiError } from 'apify-client';
1919
import log from '@apify/log';
2020

2121
import {
22-
ACTOR_OUTPUT_MAX_CHARS_PER_ITEM,
23-
ACTOR_OUTPUT_TRUNCATED_MESSAGE,
2422
defaults,
2523
SERVER_NAME,
2624
SERVER_VERSION,
@@ -468,25 +466,20 @@ export class ActorsMcpServer {
468466
const actorTool = tool.tool as ActorTool;
469467

470468
const callOptions: ActorCallOptions = { memory: actorTool.memoryMbytes };
471-
const { actorRun, datasetInfo, items } = await callActorGetDataset(
469+
const { items } = await callActorGetDataset(
472470
actorTool.actorFullName,
473471
args,
474472
apifyToken as string,
475473
callOptions,
476474
);
477-
const content = [
478-
{ type: 'text', text: `Actor finished with run information: ${JSON.stringify(actorRun)}` },
479-
{ type: 'text', text: `Dataset information: ${JSON.stringify(datasetInfo)}` },
480-
];
481-
482-
const itemContents = items.items.map((item: Record<string, unknown>) => {
483-
const text = JSON.stringify(item).slice(0, ACTOR_OUTPUT_MAX_CHARS_PER_ITEM);
484-
return text.length === ACTOR_OUTPUT_MAX_CHARS_PER_ITEM
485-
? { type: 'text', text: `${text} ... ${ACTOR_OUTPUT_TRUNCATED_MESSAGE}` }
486-
: { type: 'text', text };
487-
});
488-
content.push(...itemContents);
489-
return { content };
475+
return {
476+
content: items.items.map((item: Record<string, unknown>) => {
477+
return {
478+
type: 'text',
479+
text: JSON.stringify(item),
480+
};
481+
}),
482+
};
490483
}
491484
} catch (error) {
492485
if (error instanceof ApifyApiError) {

src/tools/actor.ts

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
22
import { Ajv } from 'ajv';
3-
import type { ActorCallOptions, ActorRun, Dataset, PaginatedList } from 'apify-client';
3+
import type { ActorCallOptions, ActorRun, PaginatedList } from 'apify-client';
44
import { z } from 'zod';
55
import zodToJsonSchema from 'zod-to-json-schema';
66

@@ -10,14 +10,15 @@ import { ApifyClient } from '../apify-client.js';
1010
import {
1111
ACTOR_ADDITIONAL_INSTRUCTIONS,
1212
ACTOR_MAX_MEMORY_MBYTES,
13-
ACTOR_RUN_DATASET_OUTPUT_MAX_ITEMS,
1413
HelperTools,
1514
} from '../const.js';
1615
import { getActorMCPServerPath, getActorMCPServerURL } from '../mcp/actors.js';
1716
import { connectMCPClient } from '../mcp/client.js';
1817
import { getMCPServerTools } from '../mcp/proxy.js';
1918
import { actorDefinitionPrunedCache } from '../state.js';
20-
import type { ActorInfo, InternalTool, ToolEntry } from '../types.js';
19+
import type { ActorDefinitionStorage, ActorInfo, InternalTool, ToolEntry } from '../types.js';
20+
import { getActorDefinitionStorageFieldNames } from '../utils/actor.js';
21+
import { getValuesByDotKeys } from '../utils/generic.js';
2122
import { getActorDefinition } from './build.js';
2223
import {
2324
actorNameToToolName,
@@ -34,8 +35,6 @@ const ajv = new Ajv({ coerceTypes: 'array', strict: false });
3435

3536
// Define a named return type for callActorGetDataset
3637
export type CallActorGetDatasetResult = {
37-
actorRun: ActorRun;
38-
datasetInfo: Dataset | undefined;
3938
items: PaginatedList<Record<string, unknown>>;
4039
};
4140

@@ -50,7 +49,6 @@ export type CallActorGetDatasetResult = {
5049
* @param {ActorCallOptions} callOptions - The options to pass to the actor.
5150
* @param {unknown} input - The input to pass to the actor.
5251
* @param {string} apifyToken - The Apify token to use for authentication.
53-
* @param {number} limit - The maximum number of items to retrieve from the dataset.
5452
* @returns {Promise<{ actorRun: any, items: object[] }>} - A promise that resolves to an object containing the actor run and dataset items.
5553
* @throws {Error} - Throws an error if the `APIFY_TOKEN` is not set
5654
*/
@@ -59,7 +57,6 @@ export async function callActorGetDataset(
5957
input: unknown,
6058
apifyToken: string,
6159
callOptions: ActorCallOptions | undefined = undefined,
62-
limit = ACTOR_RUN_DATASET_OUTPUT_MAX_ITEMS,
6360
): Promise<CallActorGetDatasetResult> {
6461
try {
6562
log.info(`Calling Actor ${actorName} with input: ${JSON.stringify(input)}`);
@@ -69,13 +66,24 @@ export async function callActorGetDataset(
6966

7067
const actorRun: ActorRun = await actorClient.call(input, callOptions);
7168
const dataset = client.dataset(actorRun.defaultDatasetId);
72-
const [datasetInfo, items] = await Promise.all([
73-
dataset.get(),
74-
dataset.listItems({ limit }),
69+
// const dataset = client.dataset('Ehtn0Y4wIKviFT2WB');
70+
const [items, defaultBuild] = await Promise.all([
71+
dataset.listItems(),
72+
(await actorClient.defaultBuild()).get(),
7573
]);
76-
log.info(`Actor ${actorName} finished with ${datasetInfo?.itemCount} items`);
7774

78-
return { actorRun, datasetInfo, items };
75+
// Get important properties from storage view definitions and if available return only those properties
76+
const storageDefinition = defaultBuild?.actorDefinition?.storages?.dataset as ActorDefinitionStorage | undefined;
77+
const importantProperties = getActorDefinitionStorageFieldNames(storageDefinition || {});
78+
if (importantProperties.length > 0) {
79+
items.items = items.items.map((item) => {
80+
return getValuesByDotKeys(item, importantProperties);
81+
});
82+
}
83+
84+
log.info(`Actor ${actorName} finished with ${items.count} items`);
85+
86+
return { items };
7987
} catch (error) {
8088
log.error(`Error calling actor: ${error}. Actor: ${actorName}, input: ${JSON.stringify(input)}`);
8189
throw new Error(`Error calling Actor: ${error}`);
@@ -132,7 +140,13 @@ export async function getNormalActorsAsTools(
132140
name: actorNameToToolName(actorDefinitionPruned.actorFullName),
133141
actorFullName: actorDefinitionPruned.actorFullName,
134142
description: `${actorDefinitionPruned.description} Instructions: ${ACTOR_ADDITIONAL_INSTRUCTIONS}`,
135-
inputSchema: actorDefinitionPruned.input || {},
143+
inputSchema: actorDefinitionPruned.input
144+
// So Actor without input schema works - MCP client expects JSON schema valid output
145+
|| {
146+
type: 'object',
147+
properties: {},
148+
required: [],
149+
},
136150
ajvValidate: fixedAjvCompile(ajv, actorDefinitionPruned.input || {}),
137151
memoryMbytes: memoryMbytes > ACTOR_MAX_MEMORY_MBYTES ? ACTOR_MAX_MEMORY_MBYTES : memoryMbytes,
138152
},

src/types.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,3 +241,20 @@ export type ExtendedActorStoreList = ActorStoreList & {
241241
bookmarkCount?: number;
242242
actorReviewRating?: number;
243243
};
244+
245+
export type ActorDefinitionStorage = {
246+
views: Record<
247+
string,
248+
{
249+
transformation: {
250+
fields?: string[];
251+
};
252+
display: {
253+
properties: Record<
254+
string,
255+
object
256+
>;
257+
};
258+
}
259+
>;
260+
};

src/utils/actor.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { ActorDefinitionStorage } from '../types.js';
2+
3+
/**
4+
* Returns an array of all field names mentioned in the display.properties
5+
* of all views in the given ActorDefinitionStorage object.
6+
*/
7+
export function getActorDefinitionStorageFieldNames(storage: ActorDefinitionStorage | object): string[] {
8+
const fieldSet = new Set<string>();
9+
if ('views' in storage && typeof storage.views === 'object' && storage.views !== null) {
10+
for (const view of Object.values(storage.views)) {
11+
// Collect from display.properties
12+
if (view.display && view.display.properties) {
13+
Object.keys(view.display.properties).forEach((field) => fieldSet.add(field));
14+
}
15+
// Collect from transformation.fields
16+
if (view.transformation && Array.isArray(view.transformation.fields)) {
17+
view.transformation.fields.forEach((field) => {
18+
if (typeof field === 'string') fieldSet.add(field);
19+
});
20+
}
21+
}
22+
}
23+
return Array.from(fieldSet);
24+
}

src/utils/generic.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* Recursively gets the value in a nested object for each key in the keys array.
3+
* Each key can be a dot-separated path (e.g. 'a.b.c').
4+
* Returns an object mapping each key to its resolved value (or undefined if not found).
5+
*/
6+
export function getValuesByDotKeys<T extends object>(obj: T, keys: string[]): Record<string, unknown> {
7+
const result: Record<string, unknown> = {};
8+
for (const key of keys) {
9+
const path = key.split('.');
10+
let current: unknown = obj;
11+
for (const segment of path) {
12+
if (
13+
current !== null
14+
&& typeof current === 'object'
15+
&& Object.prototype.hasOwnProperty.call(current, segment)
16+
) {
17+
// Use index signature to avoid 'any' and type errors
18+
current = (current as Record<string, unknown>)[segment];
19+
} else {
20+
current = undefined;
21+
break;
22+
}
23+
}
24+
result[key] = current;
25+
}
26+
return result;
27+
}

tests/integration/suite.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,18 @@ async function callPythonExampleActor(client: Client, selectedToolName: string)
4040
type ContentItem = { text: string; type: string };
4141
const content = result.content as ContentItem[];
4242
// The result is { content: [ ... ] }, and the last content is the sum
43-
expect(content[content.length - 1]).toEqual({
43+
const expected = {
4444
text: JSON.stringify({
4545
first_number: 1,
4646
second_number: 2,
4747
sum: 3,
4848
}),
4949
type: 'text',
50-
});
50+
};
51+
// Parse the JSON to compare objects regardless of property order
52+
const actual = content[content.length - 1];
53+
expect(JSON.parse(actual.text)).toEqual(JSON.parse(expected.text));
54+
expect(actual.type).toBe(expected.type);
5155
}
5256

5357
export function createIntegrationTestsSuite(

tests/unit/utils.actor.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { getActorDefinitionStorageFieldNames } from '../../src/utils/actor.js';
4+
5+
describe('getActorDefinitionStorageFieldNames', () => {
6+
it('should return an array of field names from a single view (display.properties and transformation.fields)', () => {
7+
const storage = {
8+
views: {
9+
view1: {
10+
display: {
11+
properties: {
12+
foo: {},
13+
bar: {},
14+
baz: {},
15+
},
16+
},
17+
transformation: {
18+
fields: ['baz', 'qux', 'extra'],
19+
},
20+
},
21+
},
22+
};
23+
const result = getActorDefinitionStorageFieldNames(storage);
24+
expect(result.sort()).toEqual(['bar', 'baz', 'extra', 'foo', 'qux']);
25+
});
26+
27+
it('should return unique field names from multiple views (display.properties and transformation.fields)', () => {
28+
const storage = {
29+
views: {
30+
view1: {
31+
display: {
32+
properties: {
33+
foo: {},
34+
bar: {},
35+
},
36+
},
37+
transformation: {
38+
fields: ['foo', 'alpha'],
39+
},
40+
},
41+
view2: {
42+
display: {
43+
properties: {
44+
bar: {},
45+
baz: {},
46+
},
47+
},
48+
transformation: {
49+
fields: ['baz', 'beta', 'alpha'],
50+
},
51+
},
52+
},
53+
};
54+
const result = getActorDefinitionStorageFieldNames(storage);
55+
expect(result.sort()).toEqual(['alpha', 'bar', 'baz', 'beta', 'foo']);
56+
});
57+
58+
it('should return an empty array if no properties or fields are present', () => {
59+
const storage = {
60+
views: {
61+
view1: {
62+
display: {
63+
properties: {},
64+
},
65+
transformation: {
66+
fields: [],
67+
},
68+
},
69+
},
70+
};
71+
const result = getActorDefinitionStorageFieldNames(storage);
72+
expect(result).toEqual([]);
73+
});
74+
75+
it('should handle empty views object', () => {
76+
const storage = { views: {} };
77+
const result = getActorDefinitionStorageFieldNames(storage);
78+
expect(result).toEqual([]);
79+
});
80+
81+
it('should handle missing transformation or display', () => {
82+
const storage = {
83+
views: {
84+
view1: {
85+
display: {
86+
properties: { foo: {} },
87+
},
88+
},
89+
view2: {
90+
transformation: {
91+
fields: ['bar', 'baz'],
92+
},
93+
},
94+
view3: {},
95+
},
96+
};
97+
const result = getActorDefinitionStorageFieldNames(storage);
98+
expect(result.sort()).toEqual(['bar', 'baz', 'foo']);
99+
});
100+
});

0 commit comments

Comments
 (0)