Skip to content

Commit c72676d

Browse files
committed
add streamable Actor server tests, improve consistency with real setup
1 parent 940d9ca commit c72676d

File tree

3 files changed

+263
-23
lines changed

3 files changed

+263
-23
lines changed

src/actor/server.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,10 +125,12 @@ export function createExpressApp(
125125
});
126126
// Load MCP server tools
127127
// TODO using query parameters in POST request is not standard
128-
const urlSearchParams = new URLSearchParams(req.url.split('?')[1]);
129-
if (urlSearchParams.get('actors')) {
128+
const input = parseInputParamsFromUrl(req.url);
129+
if (input.actors || input.enableAddingActors) {
130130
await mcpServer.loadToolsFromUrl(req.url, process.env.APIFY_TOKEN as string);
131-
} else {
131+
}
132+
// Load default tools if no actors are specified
133+
if (!input.actors) {
132134
await mcpServer.loadDefaultTools(process.env.APIFY_TOKEN as string);
133135
}
134136

src/mcp/server.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,4 +295,8 @@ export class ActorsMcpServer {
295295
async connect(transport: Transport): Promise<void> {
296296
await this.server.connect(transport);
297297
}
298+
299+
async close(): Promise<void> {
300+
await this.server.close();
301+
}
298302
}

tests/integration/actor.server.test.ts

Lines changed: 254 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Server as HttpServer } from 'node:http';
22

33
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
44
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
5+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
56
import type { Express } from 'express';
67
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
78

@@ -12,7 +13,7 @@ import { defaults, HelperTools } from '../../src/const.js';
1213
import { ActorsMcpServer } from '../../src/mcp/server.js';
1314
import { actorNameToToolName } from '../../src/tools/utils.js';
1415

15-
async function createMCPClient(
16+
async function createMCPSSEClient(
1617
serverUrl: string,
1718
options?: {
1819
actors?: string[];
@@ -39,17 +40,45 @@ async function createMCPClient(
3940
authorization: `Bearer ${process.env.APIFY_TOKEN}`,
4041
},
4142
},
42-
eventSourceInit: {
43-
// The EventSource package augments EventSourceInit with a "fetch" parameter.
44-
// You can use this to set additional headers on the outgoing request.
45-
// Based on this example: https://github.com/modelcontextprotocol/typescript-sdk/issues/118
46-
async fetch(input: Request | URL | string, init?: RequestInit) {
47-
const headers = new Headers(init?.headers || {});
48-
headers.set('authorization', `Bearer ${process.env.APIFY_TOKEN}`);
49-
return fetch(input, { ...init, headers });
43+
},
44+
);
45+
46+
const client = new Client({
47+
name: 'sse-client',
48+
version: '1.0.0',
49+
});
50+
await client.connect(transport);
51+
52+
return client;
53+
}
54+
55+
async function createMCPStreamableClient(
56+
serverUrl: string,
57+
options?: {
58+
actors?: string[];
59+
enableAddingActors?: boolean;
60+
},
61+
): Promise<Client> {
62+
if (!process.env.APIFY_TOKEN) {
63+
throw new Error('APIFY_TOKEN environment variable is not set.');
64+
}
65+
const url = new URL(serverUrl);
66+
const { actors, enableAddingActors } = options || {};
67+
if (actors) {
68+
url.searchParams.append('actors', actors.join(','));
69+
}
70+
if (enableAddingActors) {
71+
url.searchParams.append('enableAddingActors', 'true');
72+
}
73+
74+
const transport = new StreamableHTTPClientTransport(
75+
url,
76+
{
77+
requestInit: {
78+
headers: {
79+
authorization: `Bearer ${process.env.APIFY_TOKEN}`,
5080
},
51-
// We have to cast to "any" to use it, since it's non-standard
52-
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
81+
},
5382
},
5483
);
5584

@@ -62,17 +91,22 @@ async function createMCPClient(
6291
return client;
6392
}
6493

65-
describe('Actors MCP Server', {
94+
describe('Actors MCP Server SSE', {
6695
concurrent: false, // Run test serially to prevent port already in use
6796
}, () => {
6897
let app: Express;
6998
let server: ActorsMcpServer;
7099
let httpServer: HttpServer;
71-
const testPort = 7357;
100+
const testPort = 50000;
72101
const testHost = `http://localhost:${testPort}`;
73102

74103
beforeEach(async () => {
75-
server = new ActorsMcpServer();
104+
// same as in main.ts
105+
// TODO: unify
106+
server = new ActorsMcpServer({
107+
enableAddingActors: false,
108+
enableDefaultActors: false,
109+
});
76110
log.setLevel(log.LEVELS.OFF);
77111

78112
// Create express app using the proper server setup
@@ -82,9 +116,13 @@ describe('Actors MCP Server', {
82116
await new Promise<void>((resolve) => {
83117
httpServer = app.listen(testPort, () => resolve());
84118
});
119+
120+
// TODO: figure out why this is needed
121+
await new Promise<void>((resolve) => { setTimeout(resolve, 1000); });
85122
});
86123

87124
afterEach(async () => {
125+
await server.close();
88126
await new Promise<void>((resolve) => {
89127
httpServer.close(() => resolve());
90128
});
@@ -122,7 +160,203 @@ describe('Actors MCP Server', {
122160
});
123161

124162
it('default tools list', async () => {
125-
const client = await createMCPClient(`${testHost}/sse`);
163+
const client = await createMCPSSEClient(`${testHost}/sse`);
164+
165+
const tools = await client.listTools();
166+
const names = tools.tools.map((tool) => tool.name);
167+
expect(names.length).toEqual(defaults.helperTools.length + defaults.actors.length);
168+
for (const tool of defaults.helperTools) {
169+
expect(names).toContain(tool);
170+
}
171+
for (const actorTool of defaults.actors) {
172+
expect(names).toContain(actorNameToToolName(actorTool));
173+
}
174+
175+
await client.close();
176+
});
177+
178+
it('use only specific Actor and call it', async () => {
179+
const actorName = 'apify/python-example';
180+
const selectedToolName = actorNameToToolName(actorName);
181+
const client = await createMCPSSEClient(`${testHost}/sse`, {
182+
actors: [actorName],
183+
enableAddingActors: false,
184+
});
185+
186+
const tools = await client.listTools();
187+
const names = tools.tools.map((tool) => tool.name);
188+
expect(names.length).toEqual(defaults.helperTools.length + 1);
189+
for (const tool of defaults.helperTools) {
190+
expect(names).toContain(tool);
191+
}
192+
expect(names).toContain(selectedToolName);
193+
194+
const result = await client.callTool({
195+
name: selectedToolName,
196+
arguments: {
197+
first_number: 1,
198+
second_number: 2,
199+
},
200+
});
201+
202+
expect(result).toEqual({
203+
content: [{
204+
text: JSON.stringify({
205+
first_number: 1,
206+
second_number: 2,
207+
sum: 3,
208+
}),
209+
type: 'text',
210+
}],
211+
});
212+
213+
await client.close();
214+
});
215+
216+
it('load Actors from parameters', async () => {
217+
const actors = ['apify/rag-web-browser', 'apify/instagram-scraper'];
218+
const client = await createMCPSSEClient(`${testHost}/sse`, {
219+
actors,
220+
enableAddingActors: false,
221+
});
222+
223+
const tools = await client.listTools();
224+
const names = tools.tools.map((tool) => tool.name);
225+
expect(names.length).toEqual(defaults.helperTools.length + actors.length);
226+
for (const tool of defaults.helperTools) {
227+
expect(names).toContain(tool);
228+
}
229+
for (const actor of actors) {
230+
expect(names).toContain(actorNameToToolName(actor));
231+
}
232+
233+
await client.close();
234+
});
235+
236+
it('load Actor dynamically and call it', async () => {
237+
const actor = 'apify/python-example';
238+
const selectedToolName = actorNameToToolName(actor);
239+
const client = await createMCPSSEClient(`${testHost}/sse`, {
240+
enableAddingActors: true,
241+
});
242+
243+
const tools = await client.listTools();
244+
const names = tools.tools.map((tool) => tool.name);
245+
expect(names.length).toEqual(defaults.helperTools.length + defaults.actorAddingTools.length + defaults.actors.length);
246+
for (const tool of defaults.helperTools) {
247+
expect(names).toContain(tool);
248+
}
249+
for (const tool of defaults.actorAddingTools) {
250+
expect(names).toContain(tool);
251+
}
252+
for (const actorTool of defaults.actors) {
253+
expect(names).toContain(actorNameToToolName(actorTool));
254+
}
255+
256+
// Add Actor dynamically
257+
await client.callTool({
258+
name: HelperTools.ADD_ACTOR,
259+
arguments: {
260+
actorName: actor,
261+
},
262+
});
263+
264+
// Check if tools was added
265+
const toolsAfterAdd = await client.listTools();
266+
const namesAfterAdd = toolsAfterAdd.tools.map((tool) => tool.name);
267+
expect(namesAfterAdd.length).toEqual(defaults.helperTools.length + defaults.actorAddingTools.length + defaults.actors.length + 1);
268+
expect(namesAfterAdd).toContain(selectedToolName);
269+
270+
const result = await client.callTool({
271+
name: selectedToolName,
272+
arguments: {
273+
first_number: 1,
274+
second_number: 2,
275+
},
276+
});
277+
278+
expect(result).toEqual({
279+
content: [{
280+
text: JSON.stringify({
281+
first_number: 1,
282+
second_number: 2,
283+
sum: 3,
284+
}),
285+
type: 'text',
286+
}],
287+
});
288+
289+
await client.close();
290+
});
291+
292+
it('should remove Actor from tools list', async () => {
293+
const actor = 'apify/python-example';
294+
const selectedToolName = actorNameToToolName(actor);
295+
const client = await createMCPSSEClient(`${testHost}/sse`, {
296+
actors: [actor],
297+
enableAddingActors: true,
298+
});
299+
300+
// Verify actor is in the tools list
301+
const toolsBefore = await client.listTools();
302+
const namesBefore = toolsBefore.tools.map((tool) => tool.name);
303+
expect(namesBefore).toContain(selectedToolName);
304+
305+
// Remove the actor
306+
await client.callTool({
307+
name: HelperTools.REMOVE_ACTOR,
308+
arguments: {
309+
toolName: selectedToolName,
310+
},
311+
});
312+
313+
// Verify actor is removed
314+
const toolsAfter = await client.listTools();
315+
const namesAfter = toolsAfter.tools.map((tool) => tool.name);
316+
expect(namesAfter).not.toContain(selectedToolName);
317+
318+
await client.close();
319+
});
320+
});
321+
322+
describe('Actors MCP Server Streamable HTTP', {
323+
concurrent: false, // Run test serially to prevent port already in use
324+
}, () => {
325+
let app: Express;
326+
let server: ActorsMcpServer;
327+
let httpServer: HttpServer;
328+
const testPort = 50001;
329+
const testHost = `http://localhost:${testPort}`;
330+
331+
beforeEach(async () => {
332+
// same as in main.ts
333+
// TODO: unify
334+
server = new ActorsMcpServer({
335+
enableAddingActors: false,
336+
enableDefaultActors: false,
337+
});
338+
log.setLevel(log.LEVELS.OFF);
339+
340+
// Create express app using the proper server setup
341+
app = createExpressApp(testHost, server);
342+
343+
// Start test server
344+
await new Promise<void>((resolve) => {
345+
httpServer = app.listen(testPort, () => resolve());
346+
});
347+
348+
// TODO: figure out why this is needed
349+
await new Promise<void>((resolve) => { setTimeout(resolve, 1000); });
350+
});
351+
352+
afterEach(async () => {
353+
await new Promise<void>((resolve) => {
354+
httpServer.close(() => resolve());
355+
});
356+
});
357+
358+
it('default tools list', async () => {
359+
const client = await createMCPStreamableClient(`${testHost}/mcp`);
126360

127361
const tools = await client.listTools();
128362
const names = tools.tools.map((tool) => tool.name);
@@ -140,7 +374,7 @@ describe('Actors MCP Server', {
140374
it('use only specific Actor and call it', async () => {
141375
const actorName = 'apify/python-example';
142376
const selectedToolName = actorNameToToolName(actorName);
143-
const client = await createMCPClient(`${testHost}/sse`, {
377+
const client = await createMCPStreamableClient(`${testHost}/mcp`, {
144378
actors: [actorName],
145379
enableAddingActors: false,
146380
});
@@ -175,9 +409,9 @@ describe('Actors MCP Server', {
175409
await client.close();
176410
});
177411

178-
it('load Actors from parameters via SSE client', async () => {
412+
it('load Actors from parameters', async () => {
179413
const actors = ['apify/rag-web-browser', 'apify/instagram-scraper'];
180-
const client = await createMCPClient(`${testHost}/sse`, {
414+
const client = await createMCPStreamableClient(`${testHost}/mcp`, {
181415
actors,
182416
enableAddingActors: false,
183417
});
@@ -198,7 +432,7 @@ describe('Actors MCP Server', {
198432
it('load Actor dynamically and call it', async () => {
199433
const actor = 'apify/python-example';
200434
const selectedToolName = actorNameToToolName(actor);
201-
const client = await createMCPClient(`${testHost}/sse`, {
435+
const client = await createMCPStreamableClient(`${testHost}/mcp`, {
202436
enableAddingActors: true,
203437
});
204438

@@ -254,7 +488,7 @@ describe('Actors MCP Server', {
254488
it('should remove Actor from tools list', async () => {
255489
const actor = 'apify/python-example';
256490
const selectedToolName = actorNameToToolName(actor);
257-
const client = await createMCPClient(`${testHost}/sse`, {
491+
const client = await createMCPStreamableClient(`${testHost}/mcp`, {
258492
actors: [actor],
259493
enableAddingActors: true,
260494
});

0 commit comments

Comments
 (0)