diff --git a/package.json b/package.json index 3d6cf8d5..4e6401ff 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,9 @@ "build:watch": "tsc -b src -w", "type-check": "tsc --noEmit", "inspector": "npm run build && npx @modelcontextprotocol/inspector dist/stdio.js", - "test": "vitest run", + "test": "npm run test:unit", + "test:unit": "vitest run tests/unit", + "test:integration": "npm run build && vitest run tests/integration", "clean": "tsc -b src --clean" }, "author": "Apify", diff --git a/src/const.ts b/src/const.ts index 3f41fd5f..67a376e7 100644 --- a/src/const.ts +++ b/src/const.ts @@ -26,16 +26,22 @@ export enum HelperTools { ADD_ACTOR = 'add-actor', REMOVE_ACTOR = 'remove-actor', GET_ACTOR_DETAILS = 'get-actor-details', + HELP_TOOL = 'help-tool', } export const defaults = { actors: [ - 'apify/instagram-scraper', 'apify/rag-web-browser', - 'lukaskrivka/google-maps-with-contact-details', ], - enableActorAutoLoading: false, - maxMemoryMbytes: 4096, + helperTools: [ + HelperTools.SEARCH_ACTORS, + HelperTools.GET_ACTOR_DETAILS, + HelperTools.HELP_TOOL, + ], + actorAddingTools: [ + HelperTools.ADD_ACTOR, + HelperTools.REMOVE_ACTOR, + ], }; export const APIFY_USERNAME = 'apify'; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 9ee4bf30..c0921c4b 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -17,6 +17,7 @@ import { SERVER_NAME, SERVER_VERSION, } from '../const.js'; +import { helpTool } from '../tools/helpers.js'; import { actorDefinitionTool, addTool, @@ -66,7 +67,7 @@ export class ActorsMcpServer { this.setupToolHandlers(); // Add default tools - this.updateTools([searchTool, actorDefinitionTool]); + this.updateTools([searchTool, actorDefinitionTool, helpTool]); // Add tools to dynamically load Actors if (this.options.enableAddingActors) { @@ -79,6 +80,22 @@ export class ActorsMcpServer { }); } + /** + * Resets the server to the default state. + * This method clears all tools and loads the default tools. + * Used primarily for testing purposes. + */ + public async reset(): Promise { + this.tools.clear(); + this.updateTools([searchTool, actorDefinitionTool, helpTool]); + if (this.options.enableAddingActors) { + this.loadToolsToAddActors(); + } + + // Initialize automatically for backward compatibility + await this.initialize(); + } + /** * Initialize the server with default tools if enabled */ @@ -295,4 +312,8 @@ export class ActorsMcpServer { async connect(transport: Transport): Promise { await this.server.connect(transport); } + + async close(): Promise { + await this.server.close(); + } } diff --git a/src/tools/helpers.ts b/src/tools/helpers.ts index 26c0f502..605a39af 100644 --- a/src/tools/helpers.ts +++ b/src/tools/helpers.ts @@ -8,6 +8,58 @@ import { getActorsAsTools } from './actor.js'; import { actorNameToToolName } from './utils.js'; const ajv = new Ajv({ coerceTypes: 'array', strict: false }); + +const HELP_TOOL_TEXT = `Apify MCP server help: + +Note: "MCP" stands for "Model Context Protocol". The user can use the "RAG Web Browser" tool to get the content of the links mentioned in this help and present it to the user. + +This MCP server can be used in the following ways: +- Locally over "STDIO". +- Remotely over "SSE" or streamable "HTTP" transport with the "Actors MCP Server Apify Actor". +- Remotely over "SSE" or streamable "HTTP" transport with "https://mcp.apify.com". + +# Usage +## Locally over "STDIO" +1. The user should install the "@apify/actors-mcp-server" NPM package. +2. The user should configure the MCP client to use the MCP server. Refer to "https://github.com/apify/actors-mcp-server" or the MCP client documentation for more details (the user can specify which MCP client is being used). +The user needs to set the following environment variables: +- "APIFY_TOKEN": Apify token to authenticate with the MCP server. +If the user wants to load an Actor outside the default ones, the user needs to pass it as a CLI argument: +- "--actors " // comma-separated list of Actor names, for example, "apify/rag-web-browser,apify/instagram-scraper". +If the user wants to enable the dynamic addition of Actors to the MCP server, the user needs to pass the following CLI argument: +- "--enable-adding-actors". + +## Remotely over "SSE" or streamable "HTTP" transport with "Actors MCP Server Apify Actor" +1. The user should configure the MCP client to use the "Actors MCP Server Apify Actor" with: + - "SSE" transport URL: "https://actors-mcp-server.apify.actor/sse". + - Streamable "HTTP" transport URL: "https://actors-mcp-server.apify.actor/mcp". +2. The user needs to pass an "APIFY_TOKEN" as a URL query parameter "?token=" or set the following headers: "Authorization: Bearer ". +If the user wants to load an Actor outside the default ones, the user needs to pass it as a URL query parameter: +- "?actors=" // comma-separated list of Actor names, for example, "apify/rag-web-browser,apify/instagram-scraper". +If the user wants to enable the addition of Actors to the MCP server dynamically, the user needs to pass the following URL query parameter: +- "?enable-adding-actors=true". + +## Remotely over "SSE" or streamable "HTTP" transport with "https://mcp.apify.com" +1. The user should configure the MCP client to use "https://mcp.apify.com" with: + - "SSE" transport URL: "https://mcp.apify.com/sse". + - Streamable "HTTP" transport URL: "https://mcp.apify.com/". +2. The user needs to pass an "APIFY_TOKEN" as a URL query parameter "?token=" or set the following headers: "Authorization: Bearer ". +If the user wants to load an Actor outside the default ones, the user needs to pass it as a URL query parameter: +- "?actors=" // comma-separated list of Actor names, for example, "apify/rag-web-browser,apify/instagram-scraper". +If the user wants to enable the addition of Actors to the MCP server dynamically, the user needs to pass the following URL query parameter: +- "?enable-adding-actors=true". + +# Features +## Dynamic adding of Actors +THIS FEATURE MAY NOT BE SUPPORTED BY ALL MCP CLIENTS. THE USER MUST ENSURE THAT THE CLIENT SUPPORTS IT! +To enable this feature, see the usage section. Once dynamic adding is enabled, tools will be added that allow the user to add or remove Actors from the MCP server. +Tools related: +- "add-actor". +- "remove-actor". +If the user is using these tools and it seems like the tools have been added but cannot be called, the issue may be that the client does not support dynamic adding of Actors. +In that case, the user should check the MCP client documentation to see if the client supports this feature. +`; + export const AddToolArgsSchema = z.object({ actorName: z.string() .describe('Add a tool, Actor or MCP-Server to available tools by Actor ID or tool full name.' @@ -64,3 +116,20 @@ export const removeTool: ToolWrap = { }, } as InternalTool, }; + +// Tool takes no arguments +export const HelpToolArgsSchema = z.object({}); +export const helpTool: ToolWrap = { + type: 'internal', + tool: { + name: HelperTools.HELP_TOOL, + description: 'Helper tool to get information on how to use and troubleshoot the Apify MCP server. ' + + 'This tool always returns the same help message with information about the server and how to use it. ' + + 'Call this tool in case of any problems or uncertainties with the server. ', + inputSchema: zodToJsonSchema(HelpToolArgsSchema), + ajvValidate: ajv.compile(zodToJsonSchema(HelpToolArgsSchema)), + call: async () => { + return { content: [{ type: 'text', text: HELP_TOOL_TEXT }] }; + }, + } as InternalTool, +}; diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..439e96dd --- /dev/null +++ b/tests/README.md @@ -0,0 +1,29 @@ +# Tests + +This directory contains **unit** and **integration** tests for the `actors-mcp-server` project. + +# Unit Tests + +Unit tests are located in the `tests/unit` directory. + +To run the unit tests, you can use the following command: +```bash +npm run test:unit +``` + +# Integration Tests + +Integration tests are located in the `tests/integration` directory. +In order to run the integration tests, you need to have the `APIFY_TOKEN` environment variable set. +Also following Actors need to exist on the target execution Apify platform: +``` +ALL DEFAULT ONES DEFINED IN consts.ts AND ALSO EXPLICITLY: +apify/rag-web-browser +apify/instagram-scraper +apify/python-example +``` + +To run the integration tests, you can use the following command: +```bash +APIFY_TOKEN=your_token npm run test:integration +``` diff --git a/tests/actor-server-test.ts b/tests/actor-server-test.ts deleted file mode 100644 index ecdf16ed..00000000 --- a/tests/actor-server-test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { Server as HttpServer } from 'node:http'; - -import type { Express } from 'express'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import log from '@apify/log'; - -import { createExpressApp } from '../src/actor/server.js'; -import { HelperTools } from '../src/const.js'; -import { ActorsMcpServer } from '../src/mcp/server.js'; - -describe('ApifyMcpServer initialization', () => { - let app: Express; - let server: ActorsMcpServer; - let httpServer: HttpServer; - const testPort = 7357; - const testHost = `http://localhost:${testPort}`; - - beforeEach(async () => { - server = new ActorsMcpServer(); - log.setLevel(log.LEVELS.OFF); - - // Create express app using the proper server setup - app = createExpressApp(testHost, server); - - // Start test server - await new Promise((resolve) => { - httpServer = app.listen(testPort, () => resolve()); - }); - }); - - afterEach(async () => { - await new Promise((resolve) => { - httpServer.close(() => resolve()); - }); - }); - - it('should load actors from query parameters', async () => { - // Test with multiple actors including different username cases - const testActors = ['apify/rag-web-browser', 'apify/instagram-scraper']; - const numberOfHelperTools = 2; - - // Make request to trigger server initialization - const response = await fetch(`${testHost}/?actors=${testActors.join(',')}`); - expect(response.status).toBe(200); - - // Verify loaded tools - const toolNames = server.getToolNames(); - expect(toolNames).toEqual(expect.arrayContaining([ - 'apify-slash-rag-web-browser', - 'apify-slash-instagram-scraper', - ])); - expect(toolNames.length).toBe(testActors.length + numberOfHelperTools); - }); - - it('should enable auto-loading tools when flag is set', async () => { - const response = await fetch(`${testHost}/?enableActorAutoLoading=true`); - expect(response.status).toBe(200); - - const toolNames = server.getToolNames(); - expect(toolNames).toEqual([ - HelperTools.SEARCH_ACTORS, - HelperTools.GET_ACTOR_DETAILS, - HelperTools.ADD_ACTOR, - HelperTools.REMOVE_ACTOR, - ]); - }); -}); diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 00000000..af49096c --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,111 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; + +export interface MCPClientOptions { + actors?: string[]; + enableAddingActors?: boolean; +} + +export async function createMCPSSEClient( + serverUrl: string, + options?: MCPClientOptions, +): Promise { + if (!process.env.APIFY_TOKEN) { + throw new Error('APIFY_TOKEN environment variable is not set.'); + } + const url = new URL(serverUrl); + const { actors, enableAddingActors } = options || {}; + if (actors) { + url.searchParams.append('actors', actors.join(',')); + } + if (enableAddingActors) { + url.searchParams.append('enableAddingActors', 'true'); + } + + const transport = new SSEClientTransport( + url, + { + requestInit: { + headers: { + authorization: `Bearer ${process.env.APIFY_TOKEN}`, + }, + }, + }, + ); + + const client = new Client({ + name: 'sse-client', + version: '1.0.0', + }); + await client.connect(transport); + + return client; +} + +export async function createMCPStreamableClient( + serverUrl: string, + options?: MCPClientOptions, +): Promise { + if (!process.env.APIFY_TOKEN) { + throw new Error('APIFY_TOKEN environment variable is not set.'); + } + const url = new URL(serverUrl); + const { actors, enableAddingActors } = options || {}; + if (actors) { + url.searchParams.append('actors', actors.join(',')); + } + if (enableAddingActors) { + url.searchParams.append('enableAddingActors', 'true'); + } + + const transport = new StreamableHTTPClientTransport( + url, + { + requestInit: { + headers: { + authorization: `Bearer ${process.env.APIFY_TOKEN}`, + }, + }, + }, + ); + + const client = new Client({ + name: 'streamable-http-client', + version: '1.0.0', + }); + await client.connect(transport); + + return client; +} + +export async function createMCPStdioClient( + options?: MCPClientOptions, +): Promise { + if (!process.env.APIFY_TOKEN) { + throw new Error('APIFY_TOKEN environment variable is not set.'); + } + const { actors, enableAddingActors } = options || {}; + const args = ['dist/stdio.js']; + if (actors) { + args.push('--actors', actors.join(',')); + } + if (enableAddingActors) { + args.push('--enable-adding-actors'); + } + const transport = new StdioClientTransport({ + command: 'node', + args, + env: { + APIFY_TOKEN: process.env.APIFY_TOKEN as string, + }, + }); + const client = new Client({ + name: 'stdio-client', + version: '1.0.0', + }); + await client.connect(transport); + + return client; +} diff --git a/tests/integration/actor.server-sse.test.ts b/tests/integration/actor.server-sse.test.ts new file mode 100644 index 00000000..c74fe326 --- /dev/null +++ b/tests/integration/actor.server-sse.test.ts @@ -0,0 +1,45 @@ +import type { Server as HttpServer } from 'node:http'; + +import type { Express } from 'express'; + +import log from '@apify/log'; + +import { createExpressApp } from '../../src/actor/server.js'; +import { ActorsMcpServer } from '../../src/mcp/server.js'; +import { createMCPSSEClient } from '../helpers.js'; +import { createIntegrationTestsSuite } from './suite.js'; + +let app: Express; +let mcpServer: ActorsMcpServer; +let httpServer: HttpServer; +const httpServerPort = 50000; +const httpServerHost = `http://localhost:${httpServerPort}`; +const mcpUrl = `${httpServerHost}/sse`; + +createIntegrationTestsSuite({ + suiteName: 'Actors MCP Server SSE', + createClientFn: async (options) => await createMCPSSEClient(mcpUrl, options), + beforeAllFn: async () => { + mcpServer = new ActorsMcpServer({ + enableDefaultActors: false, + }); + log.setLevel(log.LEVELS.OFF); + + // Create express app using the proper server setup + app = createExpressApp(httpServerHost, mcpServer); + + // Start test server + await new Promise((resolve) => { + httpServer = app.listen(httpServerPort, () => resolve()); + }); + }, + beforeEachFn: async () => { + await mcpServer.reset(); + }, + afterAllFn: async () => { + await mcpServer.close(); + await new Promise((resolve) => { + httpServer.close(() => resolve()); + }); + }, +}); diff --git a/tests/integration/actor.server-streamable.test.ts b/tests/integration/actor.server-streamable.test.ts new file mode 100644 index 00000000..c5c4ab01 --- /dev/null +++ b/tests/integration/actor.server-streamable.test.ts @@ -0,0 +1,45 @@ +import type { Server as HttpServer } from 'node:http'; + +import type { Express } from 'express'; + +import log from '@apify/log'; + +import { createExpressApp } from '../../src/actor/server.js'; +import { ActorsMcpServer } from '../../src/mcp/server.js'; +import { createMCPStreamableClient } from '../helpers.js'; +import { createIntegrationTestsSuite } from './suite.js'; + +let app: Express; +let mcpServer: ActorsMcpServer; +let httpServer: HttpServer; +const httpServerPort = 50001; +const httpServerHost = `http://localhost:${httpServerPort}`; +const mcpUrl = `${httpServerHost}/mcp`; + +createIntegrationTestsSuite({ + suiteName: 'Actors MCP Server Streamable HTTP', + createClientFn: async (options) => await createMCPStreamableClient(mcpUrl, options), + beforeAllFn: async () => { + mcpServer = new ActorsMcpServer({ + enableDefaultActors: false, + }); + log.setLevel(log.LEVELS.OFF); + + // Create express app using the proper server setup + app = createExpressApp(httpServerHost, mcpServer); + + // Start test server + await new Promise((resolve) => { + httpServer = app.listen(httpServerPort, () => resolve()); + }); + }, + beforeEachFn: async () => { + await mcpServer.reset(); + }, + afterAllFn: async () => { + await mcpServer.close(); + await new Promise((resolve) => { + httpServer.close(() => resolve()); + }); + }, +}); diff --git a/tests/integration/stdio.test.ts b/tests/integration/stdio.test.ts new file mode 100644 index 00000000..b3f03c72 --- /dev/null +++ b/tests/integration/stdio.test.ts @@ -0,0 +1,7 @@ +import { createMCPStdioClient } from '../helpers.js'; +import { createIntegrationTestsSuite } from './suite.js'; + +createIntegrationTestsSuite({ + suiteName: 'MCP STDIO', + createClientFn: createMCPStdioClient, +}); diff --git a/tests/integration/suite.ts b/tests/integration/suite.ts new file mode 100644 index 00000000..e158d656 --- /dev/null +++ b/tests/integration/suite.ts @@ -0,0 +1,199 @@ +import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import { defaults, HelperTools } from '../../src/const.js'; +import { actorNameToToolName } from '../../src/tools/utils.js'; +import type { MCPClientOptions } from '../helpers'; + +interface IntegrationTestsSuiteOptions { + suiteName: string; + createClientFn: (options?: MCPClientOptions) => Promise; + beforeAllFn?: () => Promise; + afterAllFn?: () => Promise; + beforeEachFn?: () => Promise; + afterEachFn?: () => Promise; +} + +export function createIntegrationTestsSuite( + options: IntegrationTestsSuiteOptions, +) { + const { + suiteName, + createClientFn, + beforeAllFn, + afterAllFn, + beforeEachFn, + afterEachFn, + } = options; + + // Hooks + if (beforeAllFn) { + beforeAll(beforeAllFn); + } + if (afterAllFn) { + afterAll(afterAllFn); + } + if (beforeEachFn) { + beforeEach(beforeEachFn); + } + if (afterEachFn) { + afterEach(afterEachFn); + } + + describe(suiteName, () => { + it('list default tools', async () => { + const client = await createClientFn(); + const tools = await client.listTools(); + const names = tools.tools.map((tool) => tool.name); + + expect(names.length).toEqual(defaults.actors.length + defaults.helperTools.length); + for (const tool of defaults.helperTools) { + expect(names).toContain(tool); + } + for (const actor of defaults.actors) { + expect(names).toContain(actorNameToToolName(actor)); + } + await client.close(); + }); + + it('use only apify/python-example Actor and call it', async () => { + const actorName = 'apify/python-example'; + const selectedToolName = actorNameToToolName(actorName); + const client = await createClientFn({ + actors: [actorName], + enableAddingActors: false, + }); + const tools = await client.listTools(); + const names = tools.tools.map((tool) => tool.name); + expect(names.length).toEqual(defaults.helperTools.length + 1); + for (const tool of defaults.helperTools) { + expect(names).toContain(tool); + } + expect(names).toContain(selectedToolName); + + const result = await client.callTool({ + name: selectedToolName, + arguments: { + first_number: 1, + second_number: 2, + }, + }); + + expect(result).toEqual({ + content: [{ + text: JSON.stringify({ + first_number: 1, + second_number: 2, + sum: 3, + }), + type: 'text', + }], + }); + + await client.close(); + }); + + it('load Actors from parameters', async () => { + const actors = ['apify/rag-web-browser', 'apify/instagram-scraper']; + const client = await createClientFn({ + actors, + enableAddingActors: false, + }); + const tools = await client.listTools(); + const names = tools.tools.map((tool) => tool.name); + expect(names.length).toEqual(defaults.helperTools.length + actors.length); + for (const tool of defaults.helperTools) { + expect(names).toContain(tool); + } + for (const actor of actors) { + expect(names).toContain(actorNameToToolName(actor)); + } + + await client.close(); + }); + + it('load Actor dynamically and call it', async () => { + const actor = 'apify/python-example'; + const selectedToolName = actorNameToToolName(actor); + const client = await createClientFn({ + enableAddingActors: true, + }); + const tools = await client.listTools(); + const names = tools.tools.map((tool) => tool.name); + expect(names.length).toEqual(defaults.helperTools.length + defaults.actorAddingTools.length + defaults.actors.length); + for (const tool of defaults.helperTools) { + expect(names).toContain(tool); + } + for (const tool of defaults.actorAddingTools) { + expect(names).toContain(tool); + } + for (const actorTool of defaults.actors) { + expect(names).toContain(actorNameToToolName(actorTool)); + } + + // Add Actor dynamically + await client.callTool({ + name: HelperTools.ADD_ACTOR, + arguments: { + actorName: actor, + }, + }); + + // Check if tools was added + const toolsAfterAdd = await client.listTools(); + const namesAfterAdd = toolsAfterAdd.tools.map((tool) => tool.name); + expect(namesAfterAdd.length).toEqual(defaults.helperTools.length + defaults.actorAddingTools.length + defaults.actors.length + 1); + expect(namesAfterAdd).toContain(selectedToolName); + + const result = await client.callTool({ + name: selectedToolName, + arguments: { + first_number: 1, + second_number: 2, + }, + }); + + expect(result).toEqual({ + content: [{ + text: JSON.stringify({ + first_number: 1, + second_number: 2, + sum: 3, + }), + type: 'text', + }], + }); + + await client.close(); + }); + + it('should remove Actor from tools list', async () => { + const actor = 'apify/python-example'; + const selectedToolName = actorNameToToolName(actor); + const client = await createClientFn({ + actors: [actor], + enableAddingActors: true, + }); + + // Verify actor is in the tools list + const toolsBefore = await client.listTools(); + const namesBefore = toolsBefore.tools.map((tool) => tool.name); + expect(namesBefore).toContain(selectedToolName); + + // Remove the actor + await client.callTool({ + name: HelperTools.REMOVE_ACTOR, + arguments: { + toolName: selectedToolName, + }, + }); + + // Verify actor is removed + const toolsAfter = await client.listTools(); + const namesAfter = toolsAfter.tools.map((tool) => tool.name); + expect(namesAfter).not.toContain(selectedToolName); + + await client.close(); + }); + }); +} diff --git a/tests/input.test.ts b/tests/unit/input.test.ts similarity index 94% rename from tests/input.test.ts rename to tests/unit/input.test.ts index 5a291960..7752d0e3 100644 --- a/tests/input.test.ts +++ b/tests/unit/input.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { processInput } from '../src/input.js'; -import type { Input } from '../src/types.js'; +import { processInput } from '../../src/input.js'; +import type { Input } from '../../src/types.js'; describe('processInput', () => { it('should handle string actors input and convert to array', async () => { diff --git a/tests/actor-utils-test.ts b/tests/unit/mcp.utils.test.ts similarity index 88% rename from tests/actor-utils-test.ts rename to tests/unit/mcp.utils.test.ts index 6389885b..10fc8c6c 100644 --- a/tests/actor-utils-test.ts +++ b/tests/unit/mcp.utils.test.ts @@ -1,15 +1,15 @@ import { describe, expect, it } from 'vitest'; -import { parseInputParamsFromUrl } from '../src/mcp/utils.js'; +import { parseInputParamsFromUrl } from '../../src/mcp/utils.js'; describe('parseInputParamsFromUrl', () => { - it('should parse actors from URL query params', () => { + it('should parse Actors from URL query params', () => { const url = 'https://actors-mcp-server.apify.actor?token=123&actors=apify/web-scraper'; const result = parseInputParamsFromUrl(url); expect(result.actors).toEqual(['apify/web-scraper']); }); - it('should parse multiple actors from URL', () => { + it('should parse multiple Actors from URL', () => { const url = 'https://actors-mcp-server.apify.actor?actors=apify/instagram-scraper,lukaskrivka/google-maps'; const result = parseInputParamsFromUrl(url); expect(result.actors).toEqual(['apify/instagram-scraper', 'lukaskrivka/google-maps']); @@ -39,7 +39,7 @@ describe('parseInputParamsFromUrl', () => { expect(result.enableAddingActors).toBe(false); }); - it('should handle actors as string parameter', () => { + it('should handle Actors as string parameter', () => { const url = 'https://actors-mcp-server.apify.actor?actors=apify/rag-web-browser'; const result = parseInputParamsFromUrl(url); expect(result.actors).toEqual(['apify/rag-web-browser']); diff --git a/tests/actor-test.ts b/tests/unit/tools.actor.test.ts similarity index 95% rename from tests/actor-test.ts rename to tests/unit/tools.actor.test.ts index 2fd26f35..1a2108d4 100644 --- a/tests/actor-test.ts +++ b/tests/unit/tools.actor.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { ACTOR_ENUM_MAX_LENGTH } from '../src/const.js'; -import { actorNameToToolName, inferArrayItemType, shortenEnum } from '../src/tools/utils.js'; +import { ACTOR_ENUM_MAX_LENGTH } from '../../src/const.js'; +import { actorNameToToolName, inferArrayItemType, shortenEnum } from '../../src/tools/utils.js'; describe('actors', () => { describe('actorNameToToolName', () => { diff --git a/tests/unit/tools.utils.test.ts b/tests/unit/tools.utils.test.ts new file mode 100644 index 00000000..3129389f --- /dev/null +++ b/tests/unit/tools.utils.test.ts @@ -0,0 +1,319 @@ +import { describe, expect, it } from 'vitest'; + +import { ACTOR_ENUM_MAX_LENGTH, ACTOR_MAX_DESCRIPTION_LENGTH } from '../../src/const.js'; +import { buildNestedProperties, markInputPropertiesAsRequired, shortenProperties } from '../../src/tools/utils.js'; +import type { IActorInputSchema, ISchemaProperties } from '../../src/types.js'; + +describe('buildNestedProperties', () => { + it('should add useApifyProxy property to proxy objects', () => { + const properties: Record = { + proxy: { + type: 'object', + editor: 'proxy', + title: 'Proxy configuration', + description: 'Proxy settings', + properties: {}, + }, + otherProp: { + type: 'string', + title: 'Other property', + description: 'Some other property', + }, + }; + + const result = buildNestedProperties(properties); + + // Check that proxy object has useApifyProxy property + expect(result.proxy.properties).toBeDefined(); + expect(result.proxy.properties?.useApifyProxy).toBeDefined(); + expect(result.proxy.properties?.useApifyProxy.type).toBe('boolean'); + expect(result.proxy.properties?.useApifyProxy.default).toBe(true); + expect(result.proxy.required).toContain('useApifyProxy'); + + // Check that other properties remain unchanged + expect(result.otherProp).toEqual(properties.otherProp); + }); + + it('should add URL structure to requestListSources array items', () => { + const properties: Record = { + sources: { + type: 'array', + editor: 'requestListSources', + title: 'Request list sources', + description: 'Sources to scrape', + }, + otherProp: { + type: 'string', + title: 'Other property', + description: 'Some other property', + }, + }; + + const result = buildNestedProperties(properties); + + // Check that requestListSources array has proper item structure + expect(result.sources.items).toBeDefined(); + expect(result.sources.items?.type).toBe('object'); + expect(result.sources.items?.properties?.url).toBeDefined(); + expect(result.sources.items?.properties?.url.type).toBe('string'); + + // Check that other properties remain unchanged + expect(result.otherProp).toEqual(properties.otherProp); + }); + + it('should not modify properties that don\'t match special cases', () => { + const properties: Record = { + regularObject: { + type: 'object', + title: 'Regular object', + description: 'A regular object without special editor', + properties: { + subProp: { + type: 'string', + title: 'Sub property', + description: 'Sub property description', + }, + }, + }, + regularArray: { + type: 'array', + title: 'Regular array', + description: 'A regular array without special editor', + items: { + type: 'string', + title: 'Item', + description: 'Item description', + }, + }, + }; + + const result = buildNestedProperties(properties); + + // Check that regular properties remain unchanged + expect(result).toEqual(properties); + }); + + it('should handle empty properties object', () => { + const properties: Record = {}; + const result = buildNestedProperties(properties); + expect(result).toEqual({}); + }); +}); + +describe('markInputPropertiesAsRequired', () => { + it('should add REQUIRED prefix to required properties', () => { + const input: IActorInputSchema = { + title: 'Test Schema', + type: 'object', + required: ['requiredProp1', 'requiredProp2'], + properties: { + requiredProp1: { + type: 'string', + title: 'Required Property 1', + description: 'This is required', + }, + requiredProp2: { + type: 'number', + title: 'Required Property 2', + description: 'This is also required', + }, + optionalProp: { + type: 'boolean', + title: 'Optional Property', + description: 'This is optional', + }, + }, + }; + + const result = markInputPropertiesAsRequired(input); + + // Check that required properties have REQUIRED prefix + expect(result.requiredProp1.description).toContain('**REQUIRED**'); + expect(result.requiredProp2.description).toContain('**REQUIRED**'); + + // Check that optional properties remain unchanged + expect(result.optionalProp.description).toBe('This is optional'); + }); + + it('should handle input without required fields', () => { + const input: IActorInputSchema = { + title: 'Test Schema', + type: 'object', + properties: { + prop1: { + type: 'string', + title: 'Property 1', + description: 'Description 1', + }, + prop2: { + type: 'number', + title: 'Property 2', + description: 'Description 2', + }, + }, + }; + + const result = markInputPropertiesAsRequired(input); + + // Check that no properties were modified + expect(result).toEqual(input.properties); + }); + + it('should handle empty required array', () => { + const input: IActorInputSchema = { + title: 'Test Schema', + type: 'object', + required: [], + properties: { + prop1: { + type: 'string', + title: 'Property 1', + description: 'Description 1', + }, + }, + }; + + const result = markInputPropertiesAsRequired(input); + + // Check that no properties were modified + expect(result).toEqual(input.properties); + }); +}); + +describe('shortenProperties', () => { + it('should truncate long descriptions', () => { + const longDescription = 'a'.repeat(ACTOR_MAX_DESCRIPTION_LENGTH + 100); + const properties: Record = { + prop1: { + type: 'string', + title: 'Property 1', + description: longDescription, + }, + }; + + const result = shortenProperties(properties); + + // Check that description was truncated + expect(result.prop1.description.length).toBeLessThanOrEqual(ACTOR_MAX_DESCRIPTION_LENGTH + 3); // +3 for "..." + expect(result.prop1.description.endsWith('...')).toBe(true); + }); + + it('should not modify descriptions that are within limits', () => { + const description = 'This is a normal description'; + const properties: Record = { + prop1: { + type: 'string', + title: 'Property 1', + description, + }, + }; + + const result = shortenProperties(properties); + + // Check that description was not modified + expect(result.prop1.description).toBe(description); + }); + + it('should shorten enum values if they exceed the limit', () => { + // Create an enum with many values to exceed the character limit + const enumValues = Array.from({ length: 50 }, (_, i) => `enum-value-${i}`); + const properties: Record = { + prop1: { + type: 'string', + title: 'Property 1', + description: 'Property with enum', + enum: enumValues, + }, + }; + + const result = shortenProperties(properties); + + // Check that enum was shortened + expect(result.prop1.enum).toBeDefined(); + expect(result.prop1.enum!.length).toBeLessThan(enumValues.length); + + // Calculate total character length of enum values + const totalLength = result.prop1.enum!.reduce((sum, val) => sum + val.length, 0); + expect(totalLength).toBeLessThanOrEqual(ACTOR_ENUM_MAX_LENGTH); + }); + + it('should shorten items.enum values if they exceed the limit', () => { + // Create an enum with many values to exceed the character limit + const enumValues = Array.from({ length: 50 }, (_, i) => `enum-value-${i}`); + const properties: Record = { + prop1: { + type: 'array', + title: 'Property 1', + description: 'Property with items.enum', + items: { + type: 'string', + title: 'Item', + description: 'Item description', + enum: enumValues, + }, + }, + }; + + const result = shortenProperties(properties); + + // Check that items.enum was shortened + expect(result.prop1.items?.enum).toBeDefined(); + expect(result.prop1.items!.enum!.length).toBeLessThan(enumValues.length); + + // Calculate total character length of enum values + const totalLength = result.prop1.items!.enum!.reduce((sum, val) => sum + val.length, 0); + expect(totalLength).toBeLessThanOrEqual(ACTOR_ENUM_MAX_LENGTH); + }); + + it('should handle properties without enum or items.enum', () => { + const properties: Record = { + prop1: { + type: 'string', + title: 'Property 1', + description: 'Regular property', + }, + prop2: { + type: 'array', + title: 'Property 2', + description: 'Array property', + items: { + type: 'string', + title: 'Item', + description: 'Item description', + }, + }, + }; + + const result = shortenProperties(properties); + + // Check that properties were not modified + expect(result).toEqual(properties); + }); + + it('should handle empty enum arrays', () => { + const properties: Record = { + prop1: { + type: 'string', + title: 'Property 1', + description: 'Property with empty enum', + enum: [], + }, + prop2: { + type: 'array', + title: 'Property 2', + description: 'Array with empty items.enum', + items: { + type: 'string', + title: 'Item', + description: 'Item description', + enum: [], + }, + }, + }; + + const result = shortenProperties(properties); + + // Check that properties were not modified + expect(result).toEqual(properties); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index cbd60989..e604a359 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ test: { globals: true, environment: 'node', - include: ['tests/**/*.ts'], + include: ['tests/**/*.test.ts'], + testTimeout: 60_000, // 1 minute }, });