1+ import type { Client } from '@modelcontextprotocol/sdk/client/index.js' ;
12import { ApifyApiError } from 'apify-client' ;
23import type { AxiosResponse } from 'axios' ;
34import { beforeEach , describe , expect , it , vi } from 'vitest' ;
@@ -8,11 +9,14 @@ import {
89 HELPER_TOOLS ,
910 TOOL_STATUS ,
1011} from '../../src/const.js' ;
12+ import * as mcpClient from '../../src/mcp/client.js' ;
13+ import { EXTERNAL_TOOL_CALL_TIMEOUT_MSEC } from '../../src/mcp/const.js' ;
1114import {
1215 buildCallActorAppsDescription ,
1316 buildCallActorDescription ,
1417 buildCallActorErrorResponse ,
1518 callActorArgs ,
19+ handleMcpToolCall ,
1620 resolveAndValidateActor ,
1721} from '../../src/tools/actors/call_actor.js' ;
1822import type { InternalToolArgs , ToolEntry } from '../../src/types.js' ;
@@ -331,4 +335,80 @@ describe('call_actor_common', () => {
331335 } ) ;
332336 } ) ;
333337 } ) ;
338+
339+ describe ( 'handleMcpToolCall()' , ( ) => {
340+ beforeEach ( ( ) => {
341+ vi . restoreAllMocks ( ) ;
342+ } ) ;
343+
344+ it ( 'forwards signal and timeout into client.callTool options' , async ( ) => {
345+ const callTool = vi . fn ( ) . mockResolvedValue ( {
346+ content : [ { type : 'text' , text : 'remote ok' } ] ,
347+ isError : false ,
348+ } ) ;
349+ vi . spyOn ( mcpClient , 'connectMCPClient' ) . mockResolvedValue ( {
350+ callTool,
351+ close : vi . fn ( ) . mockResolvedValue ( undefined ) ,
352+ } as unknown as Client ) ;
353+
354+ const controller = new AbortController ( ) ;
355+ const result = await handleMcpToolCall ( {
356+ baseActorName : 'apify/mcp-demo' ,
357+ mcpToolName : 'search' ,
358+ input : { q : 'x' } ,
359+ isActorMcpServer : true ,
360+ mcpServerUrl : 'https://example.invalid/mcp' ,
361+ apifyToken : 'token' ,
362+ signal : controller . signal ,
363+ } ) ;
364+
365+ expect ( result ?. isError ) . not . toBe ( true ) ;
366+ expect ( callTool ) . toHaveBeenCalledTimes ( 1 ) ;
367+ const options = callTool . mock . calls [ 0 ] [ 2 ] as { signal ?: AbortSignal ; timeout ?: number } ;
368+ expect ( options . signal ) . toBe ( controller . signal ) ;
369+ expect ( options . timeout ) . toBe ( EXTERNAL_TOOL_CALL_TIMEOUT_MSEC ) ;
370+ } ) ;
371+
372+ it ( 'returns aborted when the signal is already aborted before the remote call' , async ( ) => {
373+ const connectSpy = vi . spyOn ( mcpClient , 'connectMCPClient' ) ;
374+ const controller = new AbortController ( ) ;
375+ controller . abort ( ) ;
376+
377+ const result = await handleMcpToolCall ( {
378+ baseActorName : 'apify/mcp-demo' ,
379+ mcpToolName : 'search' ,
380+ input : { q : 'x' } ,
381+ isActorMcpServer : true ,
382+ mcpServerUrl : 'https://example.invalid/mcp' ,
383+ apifyToken : 'token' ,
384+ signal : controller . signal ,
385+ } ) ;
386+
387+ expect ( result ) . toEqual ( { } ) ;
388+ expect ( connectSpy ) . not . toHaveBeenCalled ( ) ;
389+ } ) ;
390+
391+ it ( 'returns aborted when the remote call rejects after the signal aborts' , async ( ) => {
392+ const controller = new AbortController ( ) ;
393+ vi . spyOn ( mcpClient , 'connectMCPClient' ) . mockResolvedValue ( {
394+ callTool : vi . fn ( ) . mockImplementation ( async ( ) => {
395+ controller . abort ( ) ;
396+ throw new Error ( 'aborted' ) ;
397+ } ) ,
398+ close : vi . fn ( ) . mockResolvedValue ( undefined ) ,
399+ } as unknown as Client ) ;
400+
401+ const result = await handleMcpToolCall ( {
402+ baseActorName : 'apify/mcp-demo' ,
403+ mcpToolName : 'search' ,
404+ input : { q : 'x' } ,
405+ isActorMcpServer : true ,
406+ mcpServerUrl : 'https://example.invalid/mcp' ,
407+ apifyToken : 'token' ,
408+ signal : controller . signal ,
409+ } ) ;
410+
411+ expect ( result ) . toEqual ( { } ) ;
412+ } ) ;
413+ } ) ;
334414} ) ;
0 commit comments