@@ -301,3 +301,166 @@ describe('MCPClient logging behavior', () => {
301301 infoSpy . mockRestore ( ) ;
302302 } ) ;
303303} ) ;
304+
305+ describe ( 'MCPClient#mcpExecuteTool' , ( ) => {
306+ const cfgPath = tmpPath ( ) ;
307+ const settingsPath = tmpPath ( ) ;
308+
309+ beforeEach ( ( ) => {
310+ try {
311+ if ( fs . existsSync ( cfgPath ) ) fs . unlinkSync ( cfgPath ) ;
312+ } catch { }
313+ try {
314+ if ( fs . existsSync ( settingsPath ) ) fs . unlinkSync ( settingsPath ) ;
315+ } catch { }
316+ } ) ;
317+
318+ it ( 'executes a tool successfully and records usage' , async ( ) => {
319+ jest . resetModules ( ) ;
320+ jest . doMock ( './MCPToolStateStore' , ( ) => ( {
321+ parseServerNameToolName : jest . fn ( ) . mockImplementation ( ( fullName : string ) => {
322+ const [ serverName , ...rest ] = fullName . split ( '.' ) ;
323+ return { serverName, toolName : rest . join ( '.' ) } ;
324+ } ) ,
325+ validateToolArgs : jest . fn ( ) . mockReturnValue ( { valid : true } ) ,
326+ MCPToolStateStore : jest . fn ( ) . mockImplementation ( ( ) => ( {
327+ // initialize config from client tools is invoked during MCPClient.initialize
328+ // provide a no-op mock so tests that don't assert this behavior don't fail
329+ initConfigFromClientTools : jest . fn ( ) ,
330+ } ) ) ,
331+ } ) ) ;
332+
333+ // Ensure initialize can construct a client with getTools/close methods
334+ jest . doMock ( '@langchain/mcp-adapters' , ( ) => ( {
335+ MultiServerMCPClient : jest . fn ( ) . mockImplementation ( ( ) => ( {
336+ getTools : jest . fn ( ) . mockResolvedValue ( [ ] ) ,
337+ close : jest . fn ( ) . mockResolvedValue ( undefined ) ,
338+ } ) ) ,
339+ } ) ) ;
340+
341+ const MCPClient = require ( './MCPClient' ) . default as typeof import ( './MCPClient' ) . default ;
342+ const client = new MCPClient ( cfgPath , settingsPath ) as any ;
343+
344+ await client . initialize ( ) ;
345+
346+ const invoke = jest . fn ( ) . mockResolvedValue ( { ok : true } ) ;
347+ client . clientTools = [ { name : 'serverA.tool1' , schema : { } , invoke } ] ;
348+ client . mcpToolState = {
349+ isToolEnabled : jest . fn ( ) . mockReturnValue ( true ) ,
350+ recordToolUsage : jest . fn ( ) ,
351+ } ;
352+ client . isInitialized = true ;
353+ client . client = { } ;
354+
355+ const res = await client . mcpExecuteTool ( 'serverA.tool1' , [ { a : 1 } ] , 'call-1' ) ;
356+
357+ expect ( res . success ) . toBe ( true ) ;
358+ expect ( res . result ) . toEqual ( { ok : true } ) ;
359+ expect ( res . toolCallId ) . toBe ( 'call-1' ) ;
360+ expect ( client . mcpToolState . recordToolUsage ) . toHaveBeenCalledWith ( 'serverA' , 'tool1' ) ;
361+ } ) ;
362+
363+ it ( 'returns error when parameter validation fails' , async ( ) => {
364+ jest . resetModules ( ) ;
365+ jest . doMock ( './MCPToolStateStore' , ( ) => ( {
366+ parseServerNameToolName : jest
367+ . fn ( )
368+ . mockReturnValue ( { serverName : 'serverA' , toolName : 'tool1' } ) ,
369+ validateToolArgs : jest . fn ( ) . mockReturnValue ( { valid : false , error : 'bad-params' } ) ,
370+ MCPToolStateStore : jest . fn ( ) . mockImplementation ( ( ) => ( {
371+ initConfigFromClientTools : jest . fn ( ) ,
372+ } ) ) ,
373+ } ) ) ;
374+
375+ const MCPClient = require ( './MCPClient' ) . default as typeof import ( './MCPClient' ) . default ;
376+ const client = new MCPClient ( cfgPath , settingsPath ) as any ;
377+
378+ // ensure the client is initialized so mcpExecuteTool follows the normal execution path
379+ await client . initialize ( ) ;
380+
381+ client . clientTools = [ { name : 'serverA.tool1' , schema : { } , invoke : jest . fn ( ) } ] ;
382+ client . mcpToolState = {
383+ isToolEnabled : jest . fn ( ) . mockReturnValue ( true ) ,
384+ recordToolUsage : jest . fn ( ) ,
385+ } ;
386+ client . isInitialized = true ;
387+ // provide a minimal client object so mcpExecuteTool does not early-return
388+ client . client = { } ;
389+
390+ const res = await client . mcpExecuteTool ( 'serverA.tool1' , [ ] , 'call-2' ) ;
391+ expect ( res . success ) . toBe ( false ) ;
392+ expect ( res . error ) . toMatch ( / P a r a m e t e r v a l i d a t i o n f a i l e d : b a d - p a r a m s / ) ;
393+ expect ( res . toolCallId ) . toBe ( 'call-2' ) ;
394+ } ) ;
395+
396+ it ( 'returns error when tool is disabled' , async ( ) => {
397+ jest . resetModules ( ) ;
398+ jest . doMock ( './MCPToolStateStore' , ( ) => ( {
399+ parseServerNameToolName : jest . fn ( ) . mockReturnValue ( { serverName : 's' , toolName : 't' } ) ,
400+ validateToolArgs : jest . fn ( ) . mockReturnValue ( { valid : true } ) ,
401+ MCPToolStateStore : jest . fn ( ) . mockImplementation ( ( ) => ( { } ) ) ,
402+ } ) ) ;
403+
404+ const client = new MCPClient ( cfgPath , settingsPath ) as any ;
405+
406+ client . clientTools = [ { name : 's.t' , schema : { } , invoke : jest . fn ( ) } ] ;
407+ client . mcpToolState = {
408+ isToolEnabled : jest . fn ( ) . mockReturnValue ( false ) ,
409+ recordToolUsage : jest . fn ( ) ,
410+ } ;
411+ client . isInitialized = true ;
412+ client . client = { } ;
413+
414+ const res = await client . mcpExecuteTool ( 's.t' , [ ] , 'call-3' ) ;
415+ expect ( res . success ) . toBe ( false ) ;
416+ expect ( res . error ) . toMatch ( / d i s a b l e d / ) ;
417+ expect ( res . toolCallId ) . toBe ( 'call-3' ) ;
418+ } ) ;
419+
420+ it ( 'returns error when tool not found' , async ( ) => {
421+ jest . resetModules ( ) ;
422+ jest . doMock ( './MCPToolStateStore' , ( ) => ( {
423+ parseServerNameToolName : jest
424+ . fn ( )
425+ . mockReturnValue ( { serverName : 'srv' , toolName : 'missing' } ) ,
426+ validateToolArgs : jest . fn ( ) . mockReturnValue ( { valid : true } ) ,
427+ MCPToolStateStore : jest . fn ( ) . mockImplementation ( ( ) => ( { } ) ) ,
428+ } ) ) ;
429+
430+ const client = new MCPClient ( cfgPath , settingsPath ) as any ;
431+
432+ // clientTools does not contain the requested tool
433+ client . clientTools = [ { name : 'srv.other' , schema : { } , invoke : jest . fn ( ) } ] ;
434+ client . mcpToolState = {
435+ isToolEnabled : jest . fn ( ) . mockReturnValue ( true ) ,
436+ recordToolUsage : jest . fn ( ) ,
437+ } ;
438+ client . isInitialized = true ;
439+ // provide a minimal client object so mcpExecuteTool does not early-return
440+ client . client = { } ;
441+
442+ const res = await client . mcpExecuteTool ( 'srv.missing' , [ ] , 'call-4' ) ;
443+ expect ( res . success ) . toBe ( false ) ;
444+ expect ( res . error ) . toMatch ( / n o t f o u n d / ) ;
445+ expect ( res . toolCallId ) . toBe ( 'call-4' ) ;
446+ } ) ;
447+
448+ it ( 'returns undefined when mcpToolState is not set' , async ( ) => {
449+ jest . resetModules ( ) ;
450+ // Keep default behavior for parse/validate but it's irrelevant here
451+ jest . doMock ( './MCPToolStateStore' , ( ) => ( {
452+ parseServerNameToolName : jest . fn ( ) . mockReturnValue ( { serverName : 'x' , toolName : 'y' } ) ,
453+ validateToolArgs : jest . fn ( ) . mockReturnValue ( { valid : true } ) ,
454+ MCPToolStateStore : jest . fn ( ) . mockImplementation ( ( ) => ( { } ) ) ,
455+ } ) ) ;
456+
457+ const client = new MCPClient ( cfgPath , settingsPath ) as any ;
458+
459+ client . clientTools = [ { name : 'x.y' , schema : { } , invoke : jest . fn ( ) } ] ;
460+ client . mcpToolState = null ;
461+ client . isInitialized = true ;
462+
463+ const res = await client . mcpExecuteTool ( 'x.y' , [ ] , 'call-5' ) ;
464+ expect ( res ) . toBeUndefined ( ) ;
465+ } ) ;
466+ } ) ;
0 commit comments