diff --git a/eslint.config.mjs b/eslint.config.mjs index 4c727835..ffe1b904 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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: { diff --git a/src/actors.ts b/src/actors.ts index c3abe07f..63ccdd52 100644 --- a/src/actors.ts +++ b/src/actors.ts @@ -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 @@ -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 } + : undefined, description: response.description, defaultRunOptions: response.defaultRunOptions, }; @@ -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)}...`; @@ -105,11 +109,14 @@ 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; @@ -117,6 +124,8 @@ export function inferArrayItemType(property: SchemaProperties): string | null { const editorTypeMap: Record = { requestListSources: 'object', stringList: 'string', + json: 'object', + globs: 'object', }; return editorTypeMap[editor] || null; } @@ -124,16 +133,24 @@ export function inferArrayItemType(property: SchemaProperties): string | 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): Record { 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; @@ -141,23 +158,121 @@ export function addEnumsToDescriptionsWithExamples(properties: { [key: string]: /** * 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} - Modified properties with required fields marked + */ +function markInputPropertiesAsRequired(input: IActorInputSchema): Record { + 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} properties - The input schema properties + * @returns {Record} Modified properties with nested properties + */ +function buildNestedProperties(properties: Record): Record { + 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. * @@ -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} - A promise that resolves to an array of MCP tools. */ @@ -176,8 +298,10 @@ export async function getActorsAsTools(actors: string[]): Promise { 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 { diff --git a/src/server.ts b/src/server.ts index 0b6263fa..46d865d6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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 @@ -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) }] }; diff --git a/src/types.ts b/src/types.ts index 2ad0fbe0..82f43c5a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; + required?: string[]; } -export interface ActorDefinitionWithDesc extends ActorDefinition { +export interface IActorInputSchema { + title?: string; + description?: string; + + type: string; + + properties: Record; + + required?: string[]; + schemaVersion?: number; +} + +export type ActorDefinitionWithDesc = Omit & { id: string; actorFullName: string; description: string; - defaultRunOptions: ActorDefaultRunOptions + defaultRunOptions: ActorDefaultRunOptions; + input?: IActorInputSchema; } +export type ActorDefinitionPruned = Pick + export interface Tool { name: string; actorFullName: string; @@ -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;