Skip to content

Commit eb30409

Browse files
illumeashu8912
andcommitted
app: MCPClient: Add mcpExecuteTool
Implement a private mcpExecuteTool on MCPClient that: - parses server/tool names via MCPToolStateStore helpers, - validates arguments with validateToolArgs, - checks whether the tool is enabled, - invokes the tool and records usage via mcpToolState, - returns a structured success/error result (including toolCallId). This will be used via Electron ipc from the frontend/. Co-Authored-By: Ashu Ghildiyal <[email protected]>
1 parent 2ae6471 commit eb30409

File tree

2 files changed

+222
-1
lines changed

2 files changed

+222
-1
lines changed

app/electron/mcp/MCPClient.test.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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(/Parameter validation failed: bad-params/);
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(/disabled/);
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(/not found/);
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+
});

app/electron/mcp/MCPClient.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import type { DynamicStructuredTool } from '@langchain/core/dist/tools/index';
1818
import { MultiServerMCPClient } from '@langchain/mcp-adapters';
1919
import { type BrowserWindow, dialog } from 'electron';
2020
import { hasClusterDependentServers, makeMcpServersFromSettings } from './MCPSettings';
21-
import { MCPToolStateStore } from './MCPToolStateStore';
21+
import { MCPToolStateStore, parseServerNameToolName, validateToolArgs } from './MCPToolStateStore';
2222

2323
const DEBUG = true;
2424

@@ -248,6 +248,64 @@ export default class MCPClient {
248248
throw error;
249249
}
250250
}
251+
252+
/**
253+
* Execute an MCP tool with given parameters.
254+
*
255+
* @param toolName - The full name of the tool to execute (including server prefix)
256+
* @param args - The arguments to pass to the tool
257+
* @param toolCallId - Unique identifier for this tool call
258+
*
259+
* @returns Result object containing success status and output or error message
260+
*/
261+
private async mcpExecuteTool(toolName: string, args: any[], toolCallId: string) {
262+
console.log('args in mcp-execute-tool:', args);
263+
if (!this.mcpToolState) {
264+
return;
265+
}
266+
try {
267+
await this.initializeClient();
268+
if (!this.client || this.clientTools.length === 0) {
269+
throw new Error('MCP client not initialized or no tools available');
270+
}
271+
// Parse tool name
272+
const { serverName, toolName: actualToolName } = parseServerNameToolName(toolName);
273+
274+
// Check if tool is enabled
275+
const isEnabled = this.mcpToolState.isToolEnabled(serverName, actualToolName);
276+
if (!isEnabled) {
277+
throw new Error(`Tool ${actualToolName} from server ${serverName} is disabled`);
278+
}
279+
// Find the tool by name
280+
const tool = this.clientTools.find(t => t.name === toolName);
281+
if (!tool) {
282+
throw new Error(`Tool ${toolName} not found`);
283+
}
284+
// Validate parameters against schema from configuration
285+
const validation = validateToolArgs(tool.schema, args);
286+
287+
if (!validation.valid) {
288+
throw new Error(`Parameter validation failed: ${validation.error}`);
289+
}
290+
console.log(`Executing MCP tool: ${toolName} with args:`, args);
291+
// Execute the tool directly using LangChain's invoke method
292+
const result = await tool.invoke(args);
293+
console.log(`MCP tool ${toolName} executed successfully`);
294+
// Record tool usage
295+
this.mcpToolState.recordToolUsage(serverName, actualToolName);
296+
return {
297+
success: true,
298+
result,
299+
toolCallId,
300+
};
301+
} catch (error) {
302+
return {
303+
success: false,
304+
error: error instanceof Error ? error.message : 'Unknown error',
305+
toolCallId,
306+
};
307+
}
308+
}
251309
}
252310

253311
/**

0 commit comments

Comments
 (0)