From 4ec19af3a7806089d7ddfd384c1033b0dd24abff Mon Sep 17 00:00:00 2001 From: MQ Date: Tue, 29 Apr 2025 16:37:05 +0200 Subject: [PATCH 01/16] add default tools stdio test --- package.json | 4 +- tests/{ => integration}/actor-server-test.ts | 8 +-- tests/integration/stdio.ts | 59 ++++++++++++++++++++ tests/{ => unit}/actor-test.ts | 4 +- tests/{ => unit}/actor-utils-test.ts | 2 +- tests/{ => unit}/input.test.ts | 4 +- 6 files changed, 71 insertions(+), 10 deletions(-) rename tests/{ => integration}/actor-server-test.ts (90%) create mode 100644 tests/integration/stdio.ts rename tests/{ => unit}/actor-test.ts (95%) rename tests/{ => unit}/actor-utils-test.ts (96%) rename tests/{ => unit}/input.test.ts (94%) diff --git a/package.json b/package.json index 3d6cf8d..559fb64 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 build && vitest run", + "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/tests/actor-server-test.ts b/tests/integration/actor-server-test.ts similarity index 90% rename from tests/actor-server-test.ts rename to tests/integration/actor-server-test.ts index ecdf16e..8d648cd 100644 --- a/tests/actor-server-test.ts +++ b/tests/integration/actor-server-test.ts @@ -5,11 +5,11 @@ 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'; +import { createExpressApp } from '../../src/actor/server.js'; +import { HelperTools } from '../../src/const.js'; +import { ActorsMcpServer } from '../../src/mcp/server.js'; -describe('ApifyMcpServer initialization', () => { +describe('Actors MCP Server', () => { let app: Express; let server: ActorsMcpServer; let httpServer: HttpServer; diff --git a/tests/integration/stdio.ts b/tests/integration/stdio.ts new file mode 100644 index 0000000..7440f97 --- /dev/null +++ b/tests/integration/stdio.ts @@ -0,0 +1,59 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +async function createMCPClient( + options?: { + actors: string[]; + enableAddingActors: boolean; + }, +): 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; +} + +describe('MCP STDIO', () => { + let client: Client; + beforeEach(async () => { + client = await createMCPClient(); + }); + + afterEach(async () => { + await client.close(); + }); + + it('list default tools', async () => { + const tools = await client.listTools(); + const names = tools.tools.map((tool) => tool.name); + + expect(names.length).toEqual(5); + expect(names).toContain('search-actors'); + expect(names).toContain('get-actor-details'); + expect(names).toContain('apify-slash-rag-web-browser'); + expect(names).toContain('apify-slash-instagram-scraper'); + expect(names).toContain('lukaskrivka-slash-google-maps-with-contact-details'); + }); +}); diff --git a/tests/actor-test.ts b/tests/unit/actor-test.ts similarity index 95% rename from tests/actor-test.ts rename to tests/unit/actor-test.ts index 2fd26f3..1a2108d 100644 --- a/tests/actor-test.ts +++ b/tests/unit/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/actor-utils-test.ts b/tests/unit/actor-utils-test.ts similarity index 96% rename from tests/actor-utils-test.ts rename to tests/unit/actor-utils-test.ts index 6389885..6e17e40 100644 --- a/tests/actor-utils-test.ts +++ b/tests/unit/actor-utils-test.ts @@ -1,6 +1,6 @@ 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', () => { 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 5a29196..7752d0e 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 () => { From 93e9d23f5540ba26ce4a368a3992a3449fc62e95 Mon Sep 17 00:00:00 2001 From: MQ Date: Wed, 30 Apr 2025 09:40:02 +0200 Subject: [PATCH 02/16] run unit only by default, add more stdio tests, fix concurrency in actor server tests --- package.json | 2 +- src/const.ts | 8 ++ tests/integration/actor-server-test.ts | 4 +- tests/integration/stdio.ts | 136 ++++++++++++++++++++++--- vitest.config.ts | 1 + 5 files changed, 135 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index 559fb64..4e6401f 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "build:watch": "tsc -b src -w", "type-check": "tsc --noEmit", "inspector": "npm run build && npx @modelcontextprotocol/inspector dist/stdio.js", - "test": "npm run build && 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" diff --git a/src/const.ts b/src/const.ts index 3f41fd5..d9919fb 100644 --- a/src/const.ts +++ b/src/const.ts @@ -34,6 +34,14 @@ export const defaults = { 'apify/rag-web-browser', 'lukaskrivka/google-maps-with-contact-details', ], + helperTools: [ + HelperTools.SEARCH_ACTORS, + HelperTools.GET_ACTOR_DETAILS, + ], + actorAddingTools: [ + HelperTools.ADD_ACTOR, + HelperTools.REMOVE_ACTOR, + ], enableActorAutoLoading: false, maxMemoryMbytes: 4096, }; diff --git a/tests/integration/actor-server-test.ts b/tests/integration/actor-server-test.ts index 8d648cd..f1c1dc5 100644 --- a/tests/integration/actor-server-test.ts +++ b/tests/integration/actor-server-test.ts @@ -9,7 +9,9 @@ import { createExpressApp } from '../../src/actor/server.js'; import { HelperTools } from '../../src/const.js'; import { ActorsMcpServer } from '../../src/mcp/server.js'; -describe('Actors MCP Server', () => { +describe('Actors MCP Server', { + concurrent: false, // Run test serially to prevent port already in use +}, () => { let app: Express; let server: ActorsMcpServer; let httpServer: HttpServer; diff --git a/tests/integration/stdio.ts b/tests/integration/stdio.ts index 7440f97..717cf62 100644 --- a/tests/integration/stdio.ts +++ b/tests/integration/stdio.ts @@ -1,11 +1,14 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { describe, expect, it } from 'vitest'; + +import { defaults, HelperTools } from '../../src/const.js'; +import { actorNameToToolName } from '../../src/tools/utils.js'; async function createMCPClient( options?: { - actors: string[]; - enableAddingActors: boolean; + actors?: string[]; + enableAddingActors?: boolean; }, ): Promise { if (!process.env.APIFY_TOKEN) { @@ -36,24 +39,129 @@ async function createMCPClient( } describe('MCP STDIO', () => { - let client: Client; - beforeEach(async () => { - client = await createMCPClient(); + it('list default tools', async () => { + const client = await createMCPClient(); + 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(); }); - afterEach(async () => { + it('use only apify/python-example Actor and call it', async () => { + const actorName = 'apify/python-example'; + const selectedToolName = actorNameToToolName(actorName); + const client = await createMCPClient({ + 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('list default tools', async () => { + it('load Actors from parameters', async () => { + const actors = ['apify/rag-web-browser', 'apify/instagram-scraper']; + const client = await createMCPClient({ + 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)); + } - expect(names.length).toEqual(5); - expect(names).toContain('search-actors'); - expect(names).toContain('get-actor-details'); - expect(names).toContain('apify-slash-rag-web-browser'); - expect(names).toContain('apify-slash-instagram-scraper'); - expect(names).toContain('lukaskrivka-slash-google-maps-with-contact-details'); + await client.close(); + }); + + it('load Actor dynamically and call it', async () => { + const actor = 'apify/python-example'; + const selectedToolName = actorNameToToolName(actor); + const client = await createMCPClient({ + 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(); }); }); diff --git a/vitest.config.ts b/vitest.config.ts index cbd6098..0db5b17 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,5 +7,6 @@ export default defineConfig({ globals: true, environment: 'node', include: ['tests/**/*.ts'], + testTimeout: 60_000, // 1 minute }, }); From bcab5fb0d84f42737fab0b22f0333ee45af5895a Mon Sep 17 00:00:00 2001 From: MQ Date: Wed, 30 Apr 2025 16:16:44 +0200 Subject: [PATCH 03/16] vibe unit tests --- tests/unit/tools-utils-test.ts | 319 +++++++++++++++++++++++++++++++++ 1 file changed, 319 insertions(+) create mode 100644 tests/unit/tools-utils-test.ts diff --git a/tests/unit/tools-utils-test.ts b/tests/unit/tools-utils-test.ts new file mode 100644 index 0000000..3129389 --- /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); + }); +}); From 1d69ad24fe8f6b7c83e9dd0a8c931368223a30a2 Mon Sep 17 00:00:00 2001 From: MQ Date: Wed, 30 Apr 2025 20:45:15 +0200 Subject: [PATCH 04/16] fix default tool loading for Actors MCP server, more integration tests --- src/actor/server.ts | 15 +- tests/integration/actor-server-test.ts | 214 ++++++++++++++++++++++++- tests/integration/stdio.ts | 29 ++++ 3 files changed, 249 insertions(+), 9 deletions(-) diff --git a/src/actor/server.ts b/src/actor/server.ts index ea00ce9..f9c624f 100644 --- a/src/actor/server.ts +++ b/src/actor/server.ts @@ -12,8 +12,7 @@ import express from 'express'; import log from '@apify/log'; import { type ActorsMcpServer } from '../mcp/server.js'; -import { processParamsGetTools } from '../mcp/utils.js'; -import { addTool, removeTool } from '../tools/helpers.js'; +import { parseInputParamsFromUrl, processParamsGetTools } from '../mcp/utils.js'; import { getHelpMessage, HEADER_READINESS_PROBE, Routes } from './const.js'; import { getActorRunData } from './utils.js'; @@ -47,6 +46,7 @@ export function createExpressApp( } try { log.info(`Received GET message at: ${Routes.ROOT}`); + // TODO: I think we should remove this logic, root should return only help message const tools = await processParamsGetTools(req.url, process.env.APIFY_TOKEN as string); if (tools) { mcpServer.updateTools(tools); @@ -67,13 +67,12 @@ export function createExpressApp( app.get(Routes.SSE, async (req: Request, res: Response) => { try { log.info(`Received GET message at: ${Routes.SSE}`); - const tools = await processParamsGetTools(req.url, process.env.APIFY_TOKEN as string); - if (tools.length > 0) { - mcpServer.updateTools(tools); + const input = parseInputParamsFromUrl(req.url); + if (input.actors || input.enableAddingActors) { + await mcpServer.loadToolsFromUrl(req.url, process.env.APIFY_TOKEN as string); } - // TODO fix this - we should not be loading default tools here or provide more generic way - if (tools.length === 2 && tools.includes(addTool) && tools.includes(removeTool)) { - // We are loading default Actors (if not specified otherwise), so that we don't have "empty" tools + // Load default tools if no actors are specified + if (!input.actors) { await mcpServer.loadDefaultTools(process.env.APIFY_TOKEN as string); } transportSSE = new SSEServerTransport(Routes.MESSAGE, res); diff --git a/tests/integration/actor-server-test.ts b/tests/integration/actor-server-test.ts index f1c1dc5..5c163a2 100644 --- a/tests/integration/actor-server-test.ts +++ b/tests/integration/actor-server-test.ts @@ -1,13 +1,66 @@ import type { Server as HttpServer } from 'node:http'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; 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 { defaults, HelperTools } from '../../src/const.js'; import { ActorsMcpServer } from '../../src/mcp/server.js'; +import { actorNameToToolName } from '../../src/tools/utils.js'; + +async function createMCPClient( + serverUrl: string, + options?: { + actors?: string[]; + enableAddingActors?: boolean; + }, +): 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}`, + }, + }, + eventSourceInit: { + // The EventSource package augments EventSourceInit with a "fetch" parameter. + // You can use this to set additional headers on the outgoing request. + // Based on this example: https://github.com/modelcontextprotocol/typescript-sdk/issues/118 + async fetch(input: Request | URL | string, init?: RequestInit) { + const headers = new Headers(init?.headers || {}); + headers.set('authorization', `Bearer ${process.env.APIFY_TOKEN}`); + return fetch(input, { ...init, headers }); + }, + // We have to cast to "any" to use it, since it's non-standard + } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + }, + ); + + const client = new Client({ + name: 'sse-client', + version: '1.0.0', + }); + await client.connect(transport); + + return client; +} describe('Actors MCP Server', { concurrent: false, // Run test serially to prevent port already in use @@ -67,4 +120,163 @@ describe('Actors MCP Server', { HelperTools.REMOVE_ACTOR, ]); }); + + it('default tools list', async () => { + const client = await createMCPClient(`${testHost}/sse`); + + const tools = await client.listTools(); + const names = tools.tools.map((tool) => tool.name); + expect(names.length).toEqual(defaults.helperTools.length + defaults.actors.length); + for (const tool of defaults.helperTools) { + expect(names).toContain(tool); + } + for (const actorTool of defaults.actors) { + expect(names).toContain(actorNameToToolName(actorTool)); + } + + await client.close(); + }); + + it('use only specific Actor and call it', async () => { + const actorName = 'apify/python-example'; + const selectedToolName = actorNameToToolName(actorName); + const client = await createMCPClient(`${testHost}/sse`, { + 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 via SSE client', async () => { + const actors = ['apify/rag-web-browser', 'apify/instagram-scraper']; + const client = await createMCPClient(`${testHost}/sse`, { + 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 createMCPClient(`${testHost}/sse`, { + 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 createMCPClient(`${testHost}/sse`, { + 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/integration/stdio.ts b/tests/integration/stdio.ts index 717cf62..8c9552f 100644 --- a/tests/integration/stdio.ts +++ b/tests/integration/stdio.ts @@ -164,4 +164,33 @@ describe('MCP STDIO', () => { await client.close(); }); + + it('should remove Actor from tools list', async () => { + const actor = 'apify/python-example'; + const selectedToolName = actorNameToToolName(actor); + const client = await createMCPClient({ + 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(); + }); }); From 940d9ca8ea64fd91d683cceb9f9abfb9e91b9604 Mon Sep 17 00:00:00 2001 From: MQ Date: Thu, 1 May 2025 20:48:14 +0200 Subject: [PATCH 05/16] organize tests --- ...or-server-test.ts => actor.server.test.ts} | 0 tests/integration/{stdio.ts => stdio.test.ts} | 0 tests/unit/{actor-test.ts => actor.test.ts} | 0 ...{actor-utils-test.ts => mcp.utils.test.ts} | 0 ...ools-utils-test.ts => tools.utils.test.ts} | 57 ++++++++++++++++++- 5 files changed, 56 insertions(+), 1 deletion(-) rename tests/integration/{actor-server-test.ts => actor.server.test.ts} (100%) rename tests/integration/{stdio.ts => stdio.test.ts} (100%) rename tests/unit/{actor-test.ts => actor.test.ts} (100%) rename tests/unit/{actor-utils-test.ts => mcp.utils.test.ts} (100%) rename tests/unit/{tools-utils-test.ts => tools.utils.test.ts} (84%) diff --git a/tests/integration/actor-server-test.ts b/tests/integration/actor.server.test.ts similarity index 100% rename from tests/integration/actor-server-test.ts rename to tests/integration/actor.server.test.ts diff --git a/tests/integration/stdio.ts b/tests/integration/stdio.test.ts similarity index 100% rename from tests/integration/stdio.ts rename to tests/integration/stdio.test.ts diff --git a/tests/unit/actor-test.ts b/tests/unit/actor.test.ts similarity index 100% rename from tests/unit/actor-test.ts rename to tests/unit/actor.test.ts diff --git a/tests/unit/actor-utils-test.ts b/tests/unit/mcp.utils.test.ts similarity index 100% rename from tests/unit/actor-utils-test.ts rename to tests/unit/mcp.utils.test.ts diff --git a/tests/unit/tools-utils-test.ts b/tests/unit/tools.utils.test.ts similarity index 84% rename from tests/unit/tools-utils-test.ts rename to tests/unit/tools.utils.test.ts index 3129389..ba50929 100644 --- a/tests/unit/tools-utils-test.ts +++ b/tests/unit/tools.utils.test.ts @@ -1,7 +1,8 @@ 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 { actorNameToToolName, buildNestedProperties, inferArrayItemType, + markInputPropertiesAsRequired, shortenEnum, shortenProperties } from '../../src/tools/utils.js'; import type { IActorInputSchema, ISchemaProperties } from '../../src/types.js'; describe('buildNestedProperties', () => { @@ -317,3 +318,57 @@ describe('shortenProperties', () => { expect(result).toEqual(properties); }); }); + +describe('actors', () => { + describe('actorNameToToolName', () => { + it('should replace slashes and dots with dash notation', () => { + expect(actorNameToToolName('apify/web-scraper')).toBe('apify-slash-web-scraper'); + expect(actorNameToToolName('my.actor.name')).toBe('my-dot-actor-dot-name'); + }); + + it('should handle empty strings', () => { + expect(actorNameToToolName('')).toBe(''); + }); + + it('should handle strings without slashes or dots', () => { + expect(actorNameToToolName('actorname')).toBe('actorname'); + }); + + it('should handle strings with multiple slashes and dots', () => { + expect(actorNameToToolName('actor/name.with/multiple.parts')).toBe('actor-slash-name-dot-with-slash-multiple-dot-parts'); + }); + + it('should handle tool names longer than 64 characters', () => { + const longName = 'a'.repeat(70); + const expected = 'a'.repeat(64); + expect(actorNameToToolName(longName)).toBe(expected); + }); + + it('infers array item type from editor', () => { + const property = { + type: 'array', + editor: 'stringList', + title: '', + description: '', + enum: [], + default: '', + prefill: '', + }; + expect(inferArrayItemType(property)).toBe('string'); + }); + + it('shorten enum list', () => { + const enumList: string[] = []; + const wordLength = 10; + const wordCount = 30; + + for (let i = 0; i < wordCount; i++) { + enumList.push('a'.repeat(wordLength)); + } + + const shortenedList = shortenEnum(enumList); + + expect(shortenedList?.length || 0).toBe(ACTOR_ENUM_MAX_LENGTH / wordLength); + }); + }); +}); From c72676df152a8ec19addaadc2ff32427df314725 Mon Sep 17 00:00:00 2001 From: MQ Date: Thu, 1 May 2025 21:56:09 +0200 Subject: [PATCH 06/16] add streamable Actor server tests, improve consistency with real setup --- src/actor/server.ts | 8 +- src/mcp/server.ts | 4 + tests/integration/actor.server.test.ts | 274 +++++++++++++++++++++++-- 3 files changed, 263 insertions(+), 23 deletions(-) diff --git a/src/actor/server.ts b/src/actor/server.ts index f9c624f..80b8391 100644 --- a/src/actor/server.ts +++ b/src/actor/server.ts @@ -125,10 +125,12 @@ export function createExpressApp( }); // Load MCP server tools // TODO using query parameters in POST request is not standard - const urlSearchParams = new URLSearchParams(req.url.split('?')[1]); - if (urlSearchParams.get('actors')) { + const input = parseInputParamsFromUrl(req.url); + if (input.actors || input.enableAddingActors) { await mcpServer.loadToolsFromUrl(req.url, process.env.APIFY_TOKEN as string); - } else { + } + // Load default tools if no actors are specified + if (!input.actors) { await mcpServer.loadDefaultTools(process.env.APIFY_TOKEN as string); } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 9ee4bf3..4373138 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -295,4 +295,8 @@ export class ActorsMcpServer { async connect(transport: Transport): Promise { await this.server.connect(transport); } + + async close(): Promise { + await this.server.close(); + } } diff --git a/tests/integration/actor.server.test.ts b/tests/integration/actor.server.test.ts index 5c163a2..a972f69 100644 --- a/tests/integration/actor.server.test.ts +++ b/tests/integration/actor.server.test.ts @@ -2,6 +2,7 @@ import type { Server as HttpServer } from 'node:http'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import type { Express } from 'express'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -12,7 +13,7 @@ import { defaults, HelperTools } from '../../src/const.js'; import { ActorsMcpServer } from '../../src/mcp/server.js'; import { actorNameToToolName } from '../../src/tools/utils.js'; -async function createMCPClient( +async function createMCPSSEClient( serverUrl: string, options?: { actors?: string[]; @@ -39,17 +40,45 @@ async function createMCPClient( authorization: `Bearer ${process.env.APIFY_TOKEN}`, }, }, - eventSourceInit: { - // The EventSource package augments EventSourceInit with a "fetch" parameter. - // You can use this to set additional headers on the outgoing request. - // Based on this example: https://github.com/modelcontextprotocol/typescript-sdk/issues/118 - async fetch(input: Request | URL | string, init?: RequestInit) { - const headers = new Headers(init?.headers || {}); - headers.set('authorization', `Bearer ${process.env.APIFY_TOKEN}`); - return fetch(input, { ...init, headers }); + }, + ); + + const client = new Client({ + name: 'sse-client', + version: '1.0.0', + }); + await client.connect(transport); + + return client; +} + +async function createMCPStreamableClient( + serverUrl: string, + options?: { + actors?: string[]; + enableAddingActors?: boolean; + }, +): 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}`, }, - // We have to cast to "any" to use it, since it's non-standard - } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + }, }, ); @@ -62,17 +91,22 @@ async function createMCPClient( return client; } -describe('Actors MCP Server', { +describe('Actors MCP Server SSE', { concurrent: false, // Run test serially to prevent port already in use }, () => { let app: Express; let server: ActorsMcpServer; let httpServer: HttpServer; - const testPort = 7357; + const testPort = 50000; const testHost = `http://localhost:${testPort}`; beforeEach(async () => { - server = new ActorsMcpServer(); + // same as in main.ts + // TODO: unify + server = new ActorsMcpServer({ + enableAddingActors: false, + enableDefaultActors: false, + }); log.setLevel(log.LEVELS.OFF); // Create express app using the proper server setup @@ -82,9 +116,13 @@ describe('Actors MCP Server', { await new Promise((resolve) => { httpServer = app.listen(testPort, () => resolve()); }); + + // TODO: figure out why this is needed + await new Promise((resolve) => { setTimeout(resolve, 1000); }); }); afterEach(async () => { + await server.close(); await new Promise((resolve) => { httpServer.close(() => resolve()); }); @@ -122,7 +160,203 @@ describe('Actors MCP Server', { }); it('default tools list', async () => { - const client = await createMCPClient(`${testHost}/sse`); + const client = await createMCPSSEClient(`${testHost}/sse`); + + const tools = await client.listTools(); + const names = tools.tools.map((tool) => tool.name); + expect(names.length).toEqual(defaults.helperTools.length + defaults.actors.length); + for (const tool of defaults.helperTools) { + expect(names).toContain(tool); + } + for (const actorTool of defaults.actors) { + expect(names).toContain(actorNameToToolName(actorTool)); + } + + await client.close(); + }); + + it('use only specific Actor and call it', async () => { + const actorName = 'apify/python-example'; + const selectedToolName = actorNameToToolName(actorName); + const client = await createMCPSSEClient(`${testHost}/sse`, { + 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 createMCPSSEClient(`${testHost}/sse`, { + 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 createMCPSSEClient(`${testHost}/sse`, { + 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 createMCPSSEClient(`${testHost}/sse`, { + 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(); + }); +}); + +describe('Actors MCP Server Streamable HTTP', { + concurrent: false, // Run test serially to prevent port already in use +}, () => { + let app: Express; + let server: ActorsMcpServer; + let httpServer: HttpServer; + const testPort = 50001; + const testHost = `http://localhost:${testPort}`; + + beforeEach(async () => { + // same as in main.ts + // TODO: unify + server = new ActorsMcpServer({ + enableAddingActors: false, + enableDefaultActors: false, + }); + 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()); + }); + + // TODO: figure out why this is needed + await new Promise((resolve) => { setTimeout(resolve, 1000); }); + }); + + afterEach(async () => { + await new Promise((resolve) => { + httpServer.close(() => resolve()); + }); + }); + + it('default tools list', async () => { + const client = await createMCPStreamableClient(`${testHost}/mcp`); const tools = await client.listTools(); const names = tools.tools.map((tool) => tool.name); @@ -140,7 +374,7 @@ describe('Actors MCP Server', { it('use only specific Actor and call it', async () => { const actorName = 'apify/python-example'; const selectedToolName = actorNameToToolName(actorName); - const client = await createMCPClient(`${testHost}/sse`, { + const client = await createMCPStreamableClient(`${testHost}/mcp`, { actors: [actorName], enableAddingActors: false, }); @@ -175,9 +409,9 @@ describe('Actors MCP Server', { await client.close(); }); - it('load Actors from parameters via SSE client', async () => { + it('load Actors from parameters', async () => { const actors = ['apify/rag-web-browser', 'apify/instagram-scraper']; - const client = await createMCPClient(`${testHost}/sse`, { + const client = await createMCPStreamableClient(`${testHost}/mcp`, { actors, enableAddingActors: false, }); @@ -198,7 +432,7 @@ describe('Actors MCP Server', { it('load Actor dynamically and call it', async () => { const actor = 'apify/python-example'; const selectedToolName = actorNameToToolName(actor); - const client = await createMCPClient(`${testHost}/sse`, { + const client = await createMCPStreamableClient(`${testHost}/mcp`, { enableAddingActors: true, }); @@ -254,7 +488,7 @@ describe('Actors MCP Server', { it('should remove Actor from tools list', async () => { const actor = 'apify/python-example'; const selectedToolName = actorNameToToolName(actor); - const client = await createMCPClient(`${testHost}/sse`, { + const client = await createMCPStreamableClient(`${testHost}/mcp`, { actors: [actor], enableAddingActors: true, }); From 5e7d281ef04dea3d6e30db2bd962b88813648e0c Mon Sep 17 00:00:00 2001 From: MQ Date: Fri, 2 May 2025 11:37:10 +0200 Subject: [PATCH 07/16] decouple actor mcp server helper funcs and actors mcp server main MCPServer class creation --- src/actor/utils.ts | 11 ++++ src/createActorMCPServer.ts | 0 src/main.ts | 8 +-- tests/helpers.ts | 81 +++++++++++++++++++++++++ tests/integration/actor.server.test.ts | 84 +------------------------- 5 files changed, 97 insertions(+), 87 deletions(-) create mode 100644 src/createActorMCPServer.ts create mode 100644 tests/helpers.ts diff --git a/src/actor/utils.ts b/src/actor/utils.ts index f4a2e80..a99b482 100644 --- a/src/actor/utils.ts +++ b/src/actor/utils.ts @@ -1,5 +1,7 @@ import { Actor } from 'apify'; +import { ActorsMcpServer } from '../mcp/server.js'; +import type { Input } from '../types.js'; import type { ActorRunData } from './types.js'; export function getActorRunData(): ActorRunData | null { @@ -26,3 +28,12 @@ export function getActorRunData(): ActorRunData | null { standbyUrl: process.env.ACTOR_STANDBY_URL, } : null; } + +export function createActorMCPServer( + actorInput?: Input, +): ActorsMcpServer { + return new ActorsMcpServer({ + enableAddingActors: Boolean(actorInput?.enableAddingActors || false), + enableDefaultActors: false, + }); +} diff --git a/src/createActorMCPServer.ts b/src/createActorMCPServer.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/main.ts b/src/main.ts index e314b87..7cad33d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,8 +9,8 @@ import type { ActorCallOptions } from 'apify-client'; import log from '@apify/log'; import { createExpressApp } from './actor/server.js'; +import { createActorMCPServer } from './actor/utils.js'; import { processInput } from './input.js'; -import { ActorsMcpServer } from './mcp/server.js'; import { callActorGetDataset, getActorsAsTools } from './tools/index.js'; import type { Input } from './types.js'; @@ -30,11 +30,7 @@ const input = processInput((await Actor.getInput>()) ?? ({} as In log.info(`Loaded input: ${JSON.stringify(input)} `); if (STANDBY_MODE) { - const mcpServer = new ActorsMcpServer({ - enableAddingActors: Boolean(input.enableAddingActors), - enableDefaultActors: false, - }); - + const mcpServer = createActorMCPServer(input); const app = createExpressApp(HOST, mcpServer); log.info('Actor is running in the STANDBY mode.'); diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 0000000..16a1313 --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,81 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; + +export async function createMCPSSEClient( + serverUrl: string, + options?: { + actors?: string[]; + enableAddingActors?: boolean; + }, +): 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?: { + actors?: string[]; + enableAddingActors?: boolean; + }, +): 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: 'sse-client', + version: '1.0.0', + }); + await client.connect(transport); + + return client; +} diff --git a/tests/integration/actor.server.test.ts b/tests/integration/actor.server.test.ts index a972f69..554fe74 100644 --- a/tests/integration/actor.server.test.ts +++ b/tests/integration/actor.server.test.ts @@ -1,95 +1,16 @@ import type { Server as HttpServer } from 'node:http'; -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; 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 { createActorMCPServer } from '../../src/actor/utils.js'; import { defaults, HelperTools } from '../../src/const.js'; import { ActorsMcpServer } from '../../src/mcp/server.js'; import { actorNameToToolName } from '../../src/tools/utils.js'; - -async function createMCPSSEClient( - serverUrl: string, - options?: { - actors?: string[]; - enableAddingActors?: boolean; - }, -): 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; -} - -async function createMCPStreamableClient( - serverUrl: string, - options?: { - actors?: string[]; - enableAddingActors?: boolean; - }, -): 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: 'sse-client', - version: '1.0.0', - }); - await client.connect(transport); - - return client; -} +import { createMCPSSEClient, createMCPStreamableClient } from '../helpers.js'; describe('Actors MCP Server SSE', { concurrent: false, // Run test serially to prevent port already in use @@ -103,6 +24,7 @@ describe('Actors MCP Server SSE', { beforeEach(async () => { // same as in main.ts // TODO: unify + server = createActorMCPServer(); server = new ActorsMcpServer({ enableAddingActors: false, enableDefaultActors: false, From 7f37e325e9ab4a319aab6f93c0c4fa5e3fc87a01 Mon Sep 17 00:00:00 2001 From: MQ Date: Fri, 2 May 2025 14:12:38 +0200 Subject: [PATCH 08/16] add tests readme --- tests/README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 tests/README.md diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..dc9d0b3 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,19 @@ +# 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. + +# 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 +``` From ff0ee6710985139313f612281358b3bf4dd74f5a Mon Sep 17 00:00:00 2001 From: MQ Date: Fri, 2 May 2025 15:00:49 +0200 Subject: [PATCH 09/16] rename streamable client --- tests/helpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/helpers.ts b/tests/helpers.ts index 16a1313..5922bd6 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -72,7 +72,7 @@ export async function createMCPStreamableClient( ); const client = new Client({ - name: 'sse-client', + name: 'streamable-http-client', version: '1.0.0', }); await client.connect(transport); From 5fa5cfa2f5e83aa1002d66428f60029fb0b91b3f Mon Sep 17 00:00:00 2001 From: MQ Date: Mon, 5 May 2025 14:03:54 +0200 Subject: [PATCH 10/16] unify default tools loading --- src/const.ts | 4 --- tests/README.md | 10 ++++++ tests/helpers.ts | 34 +++++++++++++++++++ tests/integration/actor.server.test.ts | 10 +++--- tests/integration/stdio.test.ts | 46 ++++---------------------- 5 files changed, 56 insertions(+), 48 deletions(-) diff --git a/src/const.ts b/src/const.ts index d9919fb..cfc546f 100644 --- a/src/const.ts +++ b/src/const.ts @@ -30,9 +30,7 @@ export enum HelperTools { export const defaults = { actors: [ - 'apify/instagram-scraper', 'apify/rag-web-browser', - 'lukaskrivka/google-maps-with-contact-details', ], helperTools: [ HelperTools.SEARCH_ACTORS, @@ -42,8 +40,6 @@ export const defaults = { HelperTools.ADD_ACTOR, HelperTools.REMOVE_ACTOR, ], - enableActorAutoLoading: false, - maxMemoryMbytes: 4096, }; export const APIFY_USERNAME = 'apify'; diff --git a/tests/README.md b/tests/README.md index dc9d0b3..439e96d 100644 --- a/tests/README.md +++ b/tests/README.md @@ -6,6 +6,11 @@ This directory contains **unit** and **integration** tests for the `actors-mcp-s 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. @@ -17,3 +22,8 @@ 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/helpers.ts b/tests/helpers.ts index 5922bd6..a6c2359 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -1,5 +1,6 @@ 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 async function createMCPSSEClient( @@ -79,3 +80,36 @@ export async function createMCPStreamableClient( return client; } + +export async function createMCPStdioClient( + options?: { + actors?: string[]; + enableAddingActors?: boolean; + }, +): 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.test.ts b/tests/integration/actor.server.test.ts index 554fe74..be80ed2 100644 --- a/tests/integration/actor.server.test.ts +++ b/tests/integration/actor.server.test.ts @@ -20,6 +20,7 @@ describe('Actors MCP Server SSE', { let httpServer: HttpServer; const testPort = 50000; const testHost = `http://localhost:${testPort}`; + const serverStartWaitTimeMillis = 100; beforeEach(async () => { // same as in main.ts @@ -36,11 +37,12 @@ describe('Actors MCP Server SSE', { // Start test server await new Promise((resolve) => { - httpServer = app.listen(testPort, () => resolve()); + httpServer = app.listen(testPort, () => { + // Wait for the server to be fully initialized + // TODO: figure out why this is needed + setTimeout(() => resolve(), serverStartWaitTimeMillis); + }); }); - - // TODO: figure out why this is needed - await new Promise((resolve) => { setTimeout(resolve, 1000); }); }); afterEach(async () => { diff --git a/tests/integration/stdio.test.ts b/tests/integration/stdio.test.ts index 8c9552f..4735630 100644 --- a/tests/integration/stdio.test.ts +++ b/tests/integration/stdio.test.ts @@ -1,46 +1,12 @@ -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { describe, expect, it } from 'vitest'; import { defaults, HelperTools } from '../../src/const.js'; import { actorNameToToolName } from '../../src/tools/utils.js'; - -async function createMCPClient( - options?: { - actors?: string[]; - enableAddingActors?: boolean; - }, -): 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; -} +import { createMCPStdioClient } from '../helpers.js'; describe('MCP STDIO', () => { it('list default tools', async () => { - const client = await createMCPClient(); + const client = await createMCPStdioClient(); const tools = await client.listTools(); const names = tools.tools.map((tool) => tool.name); @@ -57,7 +23,7 @@ describe('MCP STDIO', () => { it('use only apify/python-example Actor and call it', async () => { const actorName = 'apify/python-example'; const selectedToolName = actorNameToToolName(actorName); - const client = await createMCPClient({ + const client = await createMCPStdioClient({ actors: [actorName], enableAddingActors: false, }); @@ -93,7 +59,7 @@ describe('MCP STDIO', () => { it('load Actors from parameters', async () => { const actors = ['apify/rag-web-browser', 'apify/instagram-scraper']; - const client = await createMCPClient({ + const client = await createMCPStdioClient({ actors, enableAddingActors: false, }); @@ -113,7 +79,7 @@ describe('MCP STDIO', () => { it('load Actor dynamically and call it', async () => { const actor = 'apify/python-example'; const selectedToolName = actorNameToToolName(actor); - const client = await createMCPClient({ + const client = await createMCPStdioClient({ enableAddingActors: true, }); const tools = await client.listTools(); @@ -168,7 +134,7 @@ describe('MCP STDIO', () => { it('should remove Actor from tools list', async () => { const actor = 'apify/python-example'; const selectedToolName = actorNameToToolName(actor); - const client = await createMCPClient({ + const client = await createMCPStdioClient({ actors: [actor], enableAddingActors: true, }); From ad960d09600e60ff150065d17c7834b23f8ea5f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kopeck=C3=BD?= Date: Tue, 6 May 2025 14:52:48 +0200 Subject: [PATCH 11/16] feat: add help tool (#111) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add help tool * Update src/tools/helpers.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: move tool text into a constant * fix: helpTool text * Update src/tools/helpers.ts Co-authored-by: Jiří Spilka --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Jiri Spilka Co-authored-by: Jiří Spilka --- src/const.ts | 2 + src/mcp/server.ts | 3 +- src/tools/helpers.ts | 69 ++++++++++++++++++++++++++ tests/integration/actor.server.test.ts | 31 ------------ 4 files changed, 73 insertions(+), 32 deletions(-) diff --git a/src/const.ts b/src/const.ts index cfc546f..67a376e 100644 --- a/src/const.ts +++ b/src/const.ts @@ -26,6 +26,7 @@ export enum HelperTools { ADD_ACTOR = 'add-actor', REMOVE_ACTOR = 'remove-actor', GET_ACTOR_DETAILS = 'get-actor-details', + HELP_TOOL = 'help-tool', } export const defaults = { @@ -35,6 +36,7 @@ export const defaults = { helperTools: [ HelperTools.SEARCH_ACTORS, HelperTools.GET_ACTOR_DETAILS, + HelperTools.HELP_TOOL, ], actorAddingTools: [ HelperTools.ADD_ACTOR, diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 4373138..9408769 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) { diff --git a/src/tools/helpers.ts b/src/tools/helpers.ts index 26c0f50..605a39a 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/integration/actor.server.test.ts b/tests/integration/actor.server.test.ts index be80ed2..e9587e8 100644 --- a/tests/integration/actor.server.test.ts +++ b/tests/integration/actor.server.test.ts @@ -52,37 +52,6 @@ describe('Actors MCP Server SSE', { }); }); - 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, - ]); - }); - it('default tools list', async () => { const client = await createMCPSSEClient(`${testHost}/sse`); From 77bc5b5a4bb5ccd6dc9cbf146639d436cbc28b6e Mon Sep 17 00:00:00 2001 From: MQ Date: Tue, 6 May 2025 19:57:49 +0200 Subject: [PATCH 12/16] removed createActorMCPServer wrapper - using ActorsMCPServer class directly, removed duplicate tools.actor tests and renamed actor.test.ts, removed old TODOs --- src/actor/utils.ts | 11 ---- src/createActorMCPServer.ts | 0 src/main.ts | 8 ++- tests/integration/actor.server.test.ts | 8 --- .../{actor.test.ts => tools.actor.test.ts} | 0 tests/unit/tools.utils.test.ts | 57 +------------------ 6 files changed, 7 insertions(+), 77 deletions(-) delete mode 100644 src/createActorMCPServer.ts rename tests/unit/{actor.test.ts => tools.actor.test.ts} (100%) diff --git a/src/actor/utils.ts b/src/actor/utils.ts index a99b482..f4a2e80 100644 --- a/src/actor/utils.ts +++ b/src/actor/utils.ts @@ -1,7 +1,5 @@ import { Actor } from 'apify'; -import { ActorsMcpServer } from '../mcp/server.js'; -import type { Input } from '../types.js'; import type { ActorRunData } from './types.js'; export function getActorRunData(): ActorRunData | null { @@ -28,12 +26,3 @@ export function getActorRunData(): ActorRunData | null { standbyUrl: process.env.ACTOR_STANDBY_URL, } : null; } - -export function createActorMCPServer( - actorInput?: Input, -): ActorsMcpServer { - return new ActorsMcpServer({ - enableAddingActors: Boolean(actorInput?.enableAddingActors || false), - enableDefaultActors: false, - }); -} diff --git a/src/createActorMCPServer.ts b/src/createActorMCPServer.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/main.ts b/src/main.ts index 7cad33d..e314b87 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,8 +9,8 @@ import type { ActorCallOptions } from 'apify-client'; import log from '@apify/log'; import { createExpressApp } from './actor/server.js'; -import { createActorMCPServer } from './actor/utils.js'; import { processInput } from './input.js'; +import { ActorsMcpServer } from './mcp/server.js'; import { callActorGetDataset, getActorsAsTools } from './tools/index.js'; import type { Input } from './types.js'; @@ -30,7 +30,11 @@ const input = processInput((await Actor.getInput>()) ?? ({} as In log.info(`Loaded input: ${JSON.stringify(input)} `); if (STANDBY_MODE) { - const mcpServer = createActorMCPServer(input); + const mcpServer = new ActorsMcpServer({ + enableAddingActors: Boolean(input.enableAddingActors), + enableDefaultActors: false, + }); + const app = createExpressApp(HOST, mcpServer); log.info('Actor is running in the STANDBY mode.'); diff --git a/tests/integration/actor.server.test.ts b/tests/integration/actor.server.test.ts index e9587e8..d3cd89f 100644 --- a/tests/integration/actor.server.test.ts +++ b/tests/integration/actor.server.test.ts @@ -6,7 +6,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import log from '@apify/log'; import { createExpressApp } from '../../src/actor/server.js'; -import { createActorMCPServer } from '../../src/actor/utils.js'; import { defaults, HelperTools } from '../../src/const.js'; import { ActorsMcpServer } from '../../src/mcp/server.js'; import { actorNameToToolName } from '../../src/tools/utils.js'; @@ -23,11 +22,7 @@ describe('Actors MCP Server SSE', { const serverStartWaitTimeMillis = 100; beforeEach(async () => { - // same as in main.ts - // TODO: unify - server = createActorMCPServer(); server = new ActorsMcpServer({ - enableAddingActors: false, enableDefaultActors: false, }); log.setLevel(log.LEVELS.OFF); @@ -222,10 +217,7 @@ describe('Actors MCP Server Streamable HTTP', { const testHost = `http://localhost:${testPort}`; beforeEach(async () => { - // same as in main.ts - // TODO: unify server = new ActorsMcpServer({ - enableAddingActors: false, enableDefaultActors: false, }); log.setLevel(log.LEVELS.OFF); diff --git a/tests/unit/actor.test.ts b/tests/unit/tools.actor.test.ts similarity index 100% rename from tests/unit/actor.test.ts rename to tests/unit/tools.actor.test.ts diff --git a/tests/unit/tools.utils.test.ts b/tests/unit/tools.utils.test.ts index ba50929..3129389 100644 --- a/tests/unit/tools.utils.test.ts +++ b/tests/unit/tools.utils.test.ts @@ -1,8 +1,7 @@ import { describe, expect, it } from 'vitest'; import { ACTOR_ENUM_MAX_LENGTH, ACTOR_MAX_DESCRIPTION_LENGTH } from '../../src/const.js'; -import { actorNameToToolName, buildNestedProperties, inferArrayItemType, - markInputPropertiesAsRequired, shortenEnum, shortenProperties } from '../../src/tools/utils.js'; +import { buildNestedProperties, markInputPropertiesAsRequired, shortenProperties } from '../../src/tools/utils.js'; import type { IActorInputSchema, ISchemaProperties } from '../../src/types.js'; describe('buildNestedProperties', () => { @@ -318,57 +317,3 @@ describe('shortenProperties', () => { expect(result).toEqual(properties); }); }); - -describe('actors', () => { - describe('actorNameToToolName', () => { - it('should replace slashes and dots with dash notation', () => { - expect(actorNameToToolName('apify/web-scraper')).toBe('apify-slash-web-scraper'); - expect(actorNameToToolName('my.actor.name')).toBe('my-dot-actor-dot-name'); - }); - - it('should handle empty strings', () => { - expect(actorNameToToolName('')).toBe(''); - }); - - it('should handle strings without slashes or dots', () => { - expect(actorNameToToolName('actorname')).toBe('actorname'); - }); - - it('should handle strings with multiple slashes and dots', () => { - expect(actorNameToToolName('actor/name.with/multiple.parts')).toBe('actor-slash-name-dot-with-slash-multiple-dot-parts'); - }); - - it('should handle tool names longer than 64 characters', () => { - const longName = 'a'.repeat(70); - const expected = 'a'.repeat(64); - expect(actorNameToToolName(longName)).toBe(expected); - }); - - it('infers array item type from editor', () => { - const property = { - type: 'array', - editor: 'stringList', - title: '', - description: '', - enum: [], - default: '', - prefill: '', - }; - expect(inferArrayItemType(property)).toBe('string'); - }); - - it('shorten enum list', () => { - const enumList: string[] = []; - const wordLength = 10; - const wordCount = 30; - - for (let i = 0; i < wordCount; i++) { - enumList.push('a'.repeat(wordLength)); - } - - const shortenedList = shortenEnum(enumList); - - expect(shortenedList?.length || 0).toBe(ACTOR_ENUM_MAX_LENGTH / wordLength); - }); - }); -}); From 0ba9c48e49ea726a18f130abb24398a91ed72b1d Mon Sep 17 00:00:00 2001 From: MQ Date: Tue, 6 May 2025 20:13:11 +0200 Subject: [PATCH 13/16] use single http server insteance for each describe clock and reset state for each state to fix MaxListenersExceededWarning --- src/mcp/server.ts | 18 +++++++++++++++++ tests/integration/actor.server.test.ts | 27 +++++++++++++++----------- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 9408769..ae96181 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -80,6 +80,24 @@ 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 + this.initialize().catch((error) => { + log.error('Failed to initialize server:', error); + }); + } + /** * Initialize the server with default tools if enabled */ diff --git a/tests/integration/actor.server.test.ts b/tests/integration/actor.server.test.ts index d3cd89f..04648b0 100644 --- a/tests/integration/actor.server.test.ts +++ b/tests/integration/actor.server.test.ts @@ -1,7 +1,7 @@ import type { Server as HttpServer } from 'node:http'; import type { Express } from 'express'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import log from '@apify/log'; @@ -19,9 +19,8 @@ describe('Actors MCP Server SSE', { let httpServer: HttpServer; const testPort = 50000; const testHost = `http://localhost:${testPort}`; - const serverStartWaitTimeMillis = 100; - beforeEach(async () => { + beforeAll(async () => { server = new ActorsMcpServer({ enableDefaultActors: false, }); @@ -32,15 +31,16 @@ describe('Actors MCP Server SSE', { // Start test server await new Promise((resolve) => { - httpServer = app.listen(testPort, () => { - // Wait for the server to be fully initialized - // TODO: figure out why this is needed - setTimeout(() => resolve(), serverStartWaitTimeMillis); - }); + httpServer = app.listen(testPort, () => resolve()); }); }); - afterEach(async () => { + beforeEach(async () => { + // Reset the MCP server state before each test + await server.reset(); + }); + + afterAll(async () => { await server.close(); await new Promise((resolve) => { httpServer.close(() => resolve()); @@ -216,7 +216,7 @@ describe('Actors MCP Server Streamable HTTP', { const testPort = 50001; const testHost = `http://localhost:${testPort}`; - beforeEach(async () => { + beforeAll(async () => { server = new ActorsMcpServer({ enableDefaultActors: false, }); @@ -234,7 +234,12 @@ describe('Actors MCP Server Streamable HTTP', { await new Promise((resolve) => { setTimeout(resolve, 1000); }); }); - afterEach(async () => { + beforeEach(async () => { + // Reset the MCP server state before each test + await server.reset(); + }); + + afterAll(async () => { await new Promise((resolve) => { httpServer.close(() => resolve()); }); From e43884388ed324fb5170f7c6cc0301918cdd098c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kopeck=C3=BD?= Date: Tue, 6 May 2025 20:18:54 +0200 Subject: [PATCH 14/16] Update src/mcp/server.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/mcp/server.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index ae96181..c0921c4 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -93,9 +93,7 @@ export class ActorsMcpServer { } // Initialize automatically for backward compatibility - this.initialize().catch((error) => { - log.error('Failed to initialize server:', error); - }); + await this.initialize(); } /** From 038ed90d8981d1af35df12338a886fc5b0e61637 Mon Sep 17 00:00:00 2001 From: Jiri Spilka Date: Wed, 7 May 2025 12:32:58 +0200 Subject: [PATCH 15/16] fix: actor -> Actor --- tests/unit/mcp.utils.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/mcp.utils.test.ts b/tests/unit/mcp.utils.test.ts index 6e17e40..10fc8c6 100644 --- a/tests/unit/mcp.utils.test.ts +++ b/tests/unit/mcp.utils.test.ts @@ -3,13 +3,13 @@ import { describe, expect, it } from 'vitest'; 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']); From e127115fe7786a9f8bfe09168ae35a5c3a4156b7 Mon Sep 17 00:00:00 2001 From: MQ Date: Wed, 7 May 2025 14:29:09 +0200 Subject: [PATCH 16/16] refactor tests and create a shared test suite --- tests/helpers.ts | 20 +- tests/integration/actor.server-sse.test.ts | 45 ++ .../actor.server-streamable.test.ts | 45 ++ tests/integration/actor.server.test.ts | 406 ------------------ tests/integration/stdio.test.ts | 163 +------ tests/integration/suite.ts | 199 +++++++++ vitest.config.ts | 2 +- 7 files changed, 302 insertions(+), 578 deletions(-) create mode 100644 tests/integration/actor.server-sse.test.ts create mode 100644 tests/integration/actor.server-streamable.test.ts delete mode 100644 tests/integration/actor.server.test.ts create mode 100644 tests/integration/suite.ts diff --git a/tests/helpers.ts b/tests/helpers.ts index a6c2359..af49096 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -3,12 +3,14 @@ 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?: { - actors?: string[]; - enableAddingActors?: boolean; - }, + options?: MCPClientOptions, ): Promise { if (!process.env.APIFY_TOKEN) { throw new Error('APIFY_TOKEN environment variable is not set.'); @@ -44,10 +46,7 @@ export async function createMCPSSEClient( export async function createMCPStreamableClient( serverUrl: string, - options?: { - actors?: string[]; - enableAddingActors?: boolean; - }, + options?: MCPClientOptions, ): Promise { if (!process.env.APIFY_TOKEN) { throw new Error('APIFY_TOKEN environment variable is not set.'); @@ -82,10 +81,7 @@ export async function createMCPStreamableClient( } export async function createMCPStdioClient( - options?: { - actors?: string[]; - enableAddingActors?: boolean; - }, + options?: MCPClientOptions, ): Promise { if (!process.env.APIFY_TOKEN) { throw new Error('APIFY_TOKEN environment variable is not set.'); diff --git a/tests/integration/actor.server-sse.test.ts b/tests/integration/actor.server-sse.test.ts new file mode 100644 index 0000000..c74fe32 --- /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 0000000..c5c4ab0 --- /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/actor.server.test.ts b/tests/integration/actor.server.test.ts deleted file mode 100644 index 04648b0..0000000 --- a/tests/integration/actor.server.test.ts +++ /dev/null @@ -1,406 +0,0 @@ -import type { Server as HttpServer } from 'node:http'; - -import type { Express } from 'express'; -import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; - -import log from '@apify/log'; - -import { createExpressApp } from '../../src/actor/server.js'; -import { defaults, HelperTools } from '../../src/const.js'; -import { ActorsMcpServer } from '../../src/mcp/server.js'; -import { actorNameToToolName } from '../../src/tools/utils.js'; -import { createMCPSSEClient, createMCPStreamableClient } from '../helpers.js'; - -describe('Actors MCP Server SSE', { - concurrent: false, // Run test serially to prevent port already in use -}, () => { - let app: Express; - let server: ActorsMcpServer; - let httpServer: HttpServer; - const testPort = 50000; - const testHost = `http://localhost:${testPort}`; - - beforeAll(async () => { - server = new ActorsMcpServer({ - enableDefaultActors: false, - }); - 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()); - }); - }); - - beforeEach(async () => { - // Reset the MCP server state before each test - await server.reset(); - }); - - afterAll(async () => { - await server.close(); - await new Promise((resolve) => { - httpServer.close(() => resolve()); - }); - }); - - it('default tools list', async () => { - const client = await createMCPSSEClient(`${testHost}/sse`); - - const tools = await client.listTools(); - const names = tools.tools.map((tool) => tool.name); - expect(names.length).toEqual(defaults.helperTools.length + defaults.actors.length); - for (const tool of defaults.helperTools) { - expect(names).toContain(tool); - } - for (const actorTool of defaults.actors) { - expect(names).toContain(actorNameToToolName(actorTool)); - } - - await client.close(); - }); - - it('use only specific Actor and call it', async () => { - const actorName = 'apify/python-example'; - const selectedToolName = actorNameToToolName(actorName); - const client = await createMCPSSEClient(`${testHost}/sse`, { - 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 createMCPSSEClient(`${testHost}/sse`, { - 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 createMCPSSEClient(`${testHost}/sse`, { - 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 createMCPSSEClient(`${testHost}/sse`, { - 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(); - }); -}); - -describe('Actors MCP Server Streamable HTTP', { - concurrent: false, // Run test serially to prevent port already in use -}, () => { - let app: Express; - let server: ActorsMcpServer; - let httpServer: HttpServer; - const testPort = 50001; - const testHost = `http://localhost:${testPort}`; - - beforeAll(async () => { - server = new ActorsMcpServer({ - enableDefaultActors: false, - }); - 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()); - }); - - // TODO: figure out why this is needed - await new Promise((resolve) => { setTimeout(resolve, 1000); }); - }); - - beforeEach(async () => { - // Reset the MCP server state before each test - await server.reset(); - }); - - afterAll(async () => { - await new Promise((resolve) => { - httpServer.close(() => resolve()); - }); - }); - - it('default tools list', async () => { - const client = await createMCPStreamableClient(`${testHost}/mcp`); - - const tools = await client.listTools(); - const names = tools.tools.map((tool) => tool.name); - expect(names.length).toEqual(defaults.helperTools.length + defaults.actors.length); - for (const tool of defaults.helperTools) { - expect(names).toContain(tool); - } - for (const actorTool of defaults.actors) { - expect(names).toContain(actorNameToToolName(actorTool)); - } - - await client.close(); - }); - - it('use only specific Actor and call it', async () => { - const actorName = 'apify/python-example'; - const selectedToolName = actorNameToToolName(actorName); - const client = await createMCPStreamableClient(`${testHost}/mcp`, { - 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 createMCPStreamableClient(`${testHost}/mcp`, { - 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 createMCPStreamableClient(`${testHost}/mcp`, { - 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 createMCPStreamableClient(`${testHost}/mcp`, { - 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/integration/stdio.test.ts b/tests/integration/stdio.test.ts index 4735630..b3f03c7 100644 --- a/tests/integration/stdio.test.ts +++ b/tests/integration/stdio.test.ts @@ -1,162 +1,7 @@ -import { describe, expect, it } from 'vitest'; - -import { defaults, HelperTools } from '../../src/const.js'; -import { actorNameToToolName } from '../../src/tools/utils.js'; import { createMCPStdioClient } from '../helpers.js'; +import { createIntegrationTestsSuite } from './suite.js'; -describe('MCP STDIO', () => { - it('list default tools', async () => { - const client = await createMCPStdioClient(); - 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 createMCPStdioClient({ - 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 createMCPStdioClient({ - 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 createMCPStdioClient({ - 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 createMCPStdioClient({ - 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(); - }); +createIntegrationTestsSuite({ + suiteName: 'MCP STDIO', + createClientFn: createMCPStdioClient, }); diff --git a/tests/integration/suite.ts b/tests/integration/suite.ts new file mode 100644 index 0000000..e158d65 --- /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/vitest.config.ts b/vitest.config.ts index 0db5b17..e604a35 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ test: { globals: true, environment: 'node', - include: ['tests/**/*.ts'], + include: ['tests/**/*.test.ts'], testTimeout: 60_000, // 1 minute }, });