Skip to content
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
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import apify from '@apify/eslint-config';

// eslint-disable-next-line import/no-default-export
export default [
{ ignores: ['**/dist'] }, // Ignores need to happen first
{ ignores: ['**/dist', '**/.venv'] }, // Ignores need to happen first
...apify,
{
languageOptions: {
Expand Down
156 changes: 140 additions & 16 deletions src/actors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { ApifyClient } from 'apify-client';

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

export function actorNameToToolName(actorName: string): string {
return actorName
Expand Down Expand Up @@ -67,7 +67,11 @@ function pruneActorDefinition(response: ActorDefinitionWithDesc): ActorDefinitio
actorFullName: response.actorFullName || '',
buildTag: response?.buildTag || '',
readme: response?.readme || '',
input: response?.input || null,
input: response?.input && 'type' in response.input && 'properties' in response.input
? { ...response.input,
type: response.input.type as string,
properties: response.input.properties as Record<string, ISchemaProperties> }
: undefined,
description: response.description,
defaultRunOptions: response.defaultRunOptions,
};
Expand All @@ -77,7 +81,7 @@ function pruneActorDefinition(response: ActorDefinitionWithDesc): ActorDefinitio
* Shortens the description and enum values of schema properties.
* @param properties
*/
export function shortenProperties(properties: { [key: string]: SchemaProperties}): { [key: string]: SchemaProperties } {
export function shortenProperties(properties: { [key: string]: ISchemaProperties}): { [key: string]: ISchemaProperties } {
for (const property of Object.values(properties)) {
if (property.description.length > MAX_DESCRIPTION_LENGTH) {
property.description = `${property.description.slice(0, MAX_DESCRIPTION_LENGTH)}...`;
Expand Down Expand Up @@ -105,59 +109,170 @@ export function truncateActorReadme(readme: string, limit = ACTOR_README_MAX_LEN
/**
* Helps determine the type of items in an array schema property.
* Priority order: explicit type in items > prefill type > default value type > editor type.
*
* Based on JSON schema, the array needs a type, and most of the time Actor input schema does not have this, so we need to infer that.
*
*/
export function inferArrayItemType(property: SchemaProperties): string | null {
export function inferArrayItemType(property: ISchemaProperties): string | null {
return property.items?.type
|| (property.prefill && typeof property.prefill[0])
|| (property.default && typeof property.default[0])
|| (Array.isArray(property.prefill) && property.prefill.length > 0 && typeof property.prefill[0])
|| (Array.isArray(property.default) && property.default.length > 0 && typeof property.default[0])
|| (property.editor && getEditorItemType(property.editor))
|| null;

function getEditorItemType(editor: string): string | null {
const editorTypeMap: Record<string, string> = {
requestListSources: 'object',
stringList: 'string',
json: 'object',
globs: 'object',
};
return editorTypeMap[editor] || null;
}
}

/**
* Add enum values as string to property descriptions.
*
* This is done as a preventive measure to prevent cases where library or agent framework
* does not handle enums or examples based on JSON schema definition.
*
* https://json-schema.org/understanding-json-schema/reference/enum
* https://json-schema.org/understanding-json-schema/reference/annotations
*
* @param properties
*/
export function addEnumsToDescriptionsWithExamples(properties: { [key: string]: SchemaProperties }): { [key: string]: SchemaProperties } {
function addEnumsToDescriptionsWithExamples(properties: Record<string, ISchemaProperties>): Record<string, ISchemaProperties> {
for (const property of Object.values(properties)) {
if (property.enum && property.enum.length > 0) {
property.description = `${property.description}\nPossible values: ${property.enum.join(',')}`;
property.description = `${property.description}\nPossible values: ${property.enum.slice(0, 20).join(',')}`;
}
const value = property.prefill ?? property.default;
if (value && !(Array.isArray(value) && value.length === 0)) {
property.examples = Array.isArray(value) ? value : [value];
property.description = `${property.description}\nExample values: ${JSON.stringify(value)}`;
}
}
return properties;
}

/**
* Filters schema properties to include only the necessary fields.
*
* This is done to reduce the size of the input schema and to make it more readable.
*
* @param properties
*/
export function filterSchemaProperties(properties: { [key: string]: SchemaProperties }): { [key: string]: SchemaProperties } {
const filteredProperties: { [key: string]: SchemaProperties } = {};
export function filterSchemaProperties(properties: { [key: string]: ISchemaProperties }): { [key: string]: ISchemaProperties } {
const filteredProperties: { [key: string]: ISchemaProperties } = {};
for (const [key, property] of Object.entries(properties)) {
const { title, description, enum: enumValues, type, default: defaultValue, prefill } = property;
filteredProperties[key] = { title, description, enum: enumValues, type, default: defaultValue, prefill };
if (type === 'array') {
filteredProperties[key] = {
title: property.title,
description: property.description,
enum: property.enum,
type: property.type,
default: property.default,
prefill: property.prefill,
properties: property.properties,
items: property.items,
required: property.required,
};
if (property.type === 'array' && !property.items?.type) {
const itemsType = inferArrayItemType(property);
if (itemsType) {
filteredProperties[key].items = { type: itemsType };
filteredProperties[key].items = {
...filteredProperties[key].items,
title: filteredProperties[key].title ?? 'Item',
description: filteredProperties[key].description ?? 'Item',
type: itemsType,
};
}
}
}
return filteredProperties;
}

/**
* Marks input properties as required by adding a "REQUIRED" prefix to their descriptions.
* Takes an IActorInput object and returns a modified Record of SchemaProperties.
*
* This is done for maximum compatibility in case where library or agent framework does not consider
* required fields and does not handle the JSON schema properly: we are prepending this to the description
* as a preventive measure.
* @param {IActorInputSchema} input - Actor input object containing properties and required fields
* @returns {Record<string, ISchemaProperties>} - Modified properties with required fields marked
*/
function markInputPropertiesAsRequired(input: IActorInputSchema): Record<string, ISchemaProperties> {
const { required = [], properties } = input;

for (const property of Object.keys(properties)) {
if (required.includes(property)) {
properties[property] = {
...properties[property],
description: `**REQUIRED** ${properties[property].description}`,
};
}
}

return properties;
}

/**
* Builds nested properties for object types in the schema.
*
* Specifically handles special cases like proxy configuration and request list sources
* by adding predefined nested properties to these object types.
* This is necessary for the agent to correctly infer how to structure object inputs
* when passing arguments to the Actor.
*
* For proxy objects (type='object', editor='proxy'), adds 'useApifyProxy' property.
* For request list sources (type='array', editor='requestListSources'), adds URL structure to items.
*
* @param {Record<string, ISchemaProperties>} properties - The input schema properties
* @returns {Record<string, ISchemaProperties>} Modified properties with nested properties
*/
function buildNestedProperties(properties: Record<string, ISchemaProperties>): Record<string, ISchemaProperties> {
const clonedProperties = { ...properties };

for (const [propertyName, property] of Object.entries(clonedProperties)) {
if (property.type === 'object' && property.editor === 'proxy') {
clonedProperties[propertyName] = {
...property,
properties: {
...property.properties,
useApifyProxy: {
title: 'Use Apify Proxy',
type: 'boolean',
description: 'Whether to use Apify Proxy - ALWAYS SET TO TRUE.',
default: true,
examples: [true],
},
},
required: ['useApifyProxy'],
};
} else if (property.type === 'array' && property.editor === 'requestListSources') {
clonedProperties[propertyName] = {
...property,
items: {
...property.items,
type: 'object',
title: 'Request list source',
description: 'Request list source',
properties: {
url: {
title: 'URL',
type: 'string',
description: 'URL of the request list source',
},
},
},
};
}
}

return clonedProperties;
}

/**
* Fetches actor input schemas by Actor IDs or Actor full names and creates MCP tools.
*
Expand All @@ -166,6 +281,13 @@ export function filterSchemaProperties(properties: { [key: string]: SchemaProper
*
* Tool name can't contain /, so it is replaced with _
*
* The input schema processing workflow:
* 1. Properties are marked as required using markInputPropertiesAsRequired()
* 2. Nested properties are built by analyzing editor type (proxy, requestListSources) using buildNestedProperties()
* 3. Properties are filtered using filterSchemaProperties()
* 4. Properties are shortened using shortenProperties()
* 5. Enums are added to descriptions with examples using addEnumsToDescriptionsWithExamples()
*
* @param {string[]} actors - An array of actor IDs or Actor full names.
* @returns {Promise<Tool[]>} - A promise that resolves to an array of MCP tools.
*/
Expand All @@ -176,8 +298,10 @@ export async function getActorsAsTools(actors: string[]): Promise<Tool[]> {
for (const result of results) {
if (result) {
if (result.input && 'properties' in result.input && result.input) {
const properties = filterSchemaProperties(result.input.properties as { [key: string]: SchemaProperties });
const propertiesShortened = shortenProperties(properties);
const propertiesMarkedAsRequired = markInputPropertiesAsRequired(result.input);
const propertiesObjectsBuilt = buildNestedProperties(propertiesMarkedAsRequired);
const propertiesFiltered = filterSchemaProperties(propertiesObjectsBuilt);
const propertiesShortened = shortenProperties(propertiesFiltered);
result.input.properties = addEnumsToDescriptionsWithExamples(propertiesShortened);
}
try {
Expand Down
4 changes: 2 additions & 2 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import {
searchActorsByKeywords,
GetActorDefinition,
} from './tools.js';
import type { SchemaProperties, Tool } from './types.js';
import type { ISchemaProperties, Tool } from './types.js';

/**
* Create Apify MCP server
Expand Down Expand Up @@ -199,7 +199,7 @@ export class ApifyMcpServer {
const parsed = GetActorDefinition.parse(args);
const v = await getActorDefinition(parsed.actorName, parsed.limit);
if (v && v.input && 'properties' in v.input && v.input) {
const properties = filterSchemaProperties(v.input.properties as { [key: string]: SchemaProperties });
const properties = filterSchemaProperties(v.input.properties as { [key: string]: ISchemaProperties });
v.input.properties = shortenProperties(properties);
}
return { content: [{ type: 'text', text: JSON.stringify(v) }] };
Expand Down
56 changes: 34 additions & 22 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,48 @@ export type Input = {
debugActorInput?: unknown;
};

export interface ActorDefinitionPruned {
id: string;
actorFullName: string;
buildTag?: string;
readme?: string | null;
input?: object | null;
export interface ISchemaProperties {
type: string;

title: string;
description: string;
defaultRunOptions: ActorDefaultRunOptions;

enum?: string[]; // Array of string options for the enum
enumTitles?: string[]; // Array of string titles for the enum
default?: unknown;
prefill?: unknown;

items?: ISchemaProperties;
editor?: string;
examples?: unknown[];

properties?: Record<string, ISchemaProperties>;
required?: string[];
}

export interface ActorDefinitionWithDesc extends ActorDefinition {
export interface IActorInputSchema {
title?: string;
description?: string;

type: string;

properties: Record<string, ISchemaProperties>;

required?: string[];
schemaVersion?: number;
}

export type ActorDefinitionWithDesc = Omit<ActorDefinition, 'input'> & {
id: string;
actorFullName: string;
description: string;
defaultRunOptions: ActorDefaultRunOptions
defaultRunOptions: ActorDefaultRunOptions;
input?: IActorInputSchema;
}

export type ActorDefinitionPruned = Pick<ActorDefinitionWithDesc,
'id' | 'actorFullName' | 'buildTag' | 'readme' | 'input' | 'description' | 'defaultRunOptions'>

export interface Tool {
name: string;
actorFullName: string;
Expand All @@ -35,19 +60,6 @@ export interface Tool {
memoryMbytes?: number;
}

export interface SchemaProperties {
title: string;
description: string;
enum: string[]; // Array of string options for the enum
enumTitles?: string[]; // Array of string titles for the enum
type: string; // Data type (e.g., "string")
default: string;
prefill: string;
items?: { type: string; }
editor?: string;
examples?: unknown[];
}

// ActorStoreList for actor-search tool
export interface ActorStats {
totalRuns: number;
Expand Down
Loading