Skip to content

Commit 899d731

Browse files
authored
[Feat][MCP-8] Add custom (generic) tools (#27)
1 parent 7087c19 commit 899d731

6 files changed

Lines changed: 202 additions & 8 deletions

File tree

.changeset/silly-crabs-burn.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@commercetools/agent-essentials": minor
3+
---
4+
5+
[Feat][MCP-8] Add custom (generic) tools

typescript/src/modelcontextprotocol/__tests__/essentials.test.ts

Lines changed: 160 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,8 @@ describe('CommercetoolsAgentEssentials (ModelContextProtocol)', () => {
229229

230230
expect(mockCommercetoolsAPIInstance.run).toHaveBeenCalledWith(
231231
toolMethod,
232-
handlerArg
232+
handlerArg,
233+
undefined
233234
);
234235
expect(result).toEqual({
235236
content: [
@@ -419,6 +420,8 @@ describe('CommercetoolsAgentEssentials (ModelContextProtocol)', () => {
419420
},
420421
};
421422

423+
const getConfig = (opt: object) => ({..._mockConfiguration, ...opt});
424+
422425
beforeEach(() => {
423426
// Reset mocks
424427
(McpServer as jest.Mock).mockClear();
@@ -467,7 +470,9 @@ describe('CommercetoolsAgentEssentials (ModelContextProtocol)', () => {
467470
);
468471
});
469472

470-
afterAll(() => {
473+
afterEach(() => {
474+
(McpServer as jest.Mock).mockClear();
475+
(CommercetoolsAgentEssentials as unknown as jest.Mock).mockClear();
471476
_mockCommercetoolsAPIInstance.introspect = jest
472477
.fn()
473478
.mockImplementation(function () {
@@ -512,6 +517,159 @@ describe('CommercetoolsAgentEssentials (ModelContextProtocol)', () => {
512517
).rejects.toThrow(/Simulated error in the instropsect method/);
513518
expect(CommercetoolsAgentEssentials.create).toHaveBeenCalled();
514519
});
520+
521+
describe('::CustomTools', () => {
522+
it(`should not register custom tools if 'customTools' is not provided`, async () => {
523+
const config = getConfig({});
524+
await CommercetoolsAgentEssentials.create({
525+
authConfig: {
526+
clientId: 'id',
527+
clientSecret: 'secret',
528+
authUrl: 'auth',
529+
projectKey: 'key',
530+
apiUrl: 'api',
531+
type: 'client_credentials',
532+
},
533+
configuration: config,
534+
});
535+
536+
expect(_mockToolMethod).toHaveBeenCalled();
537+
expect(_mockToolMethod).toHaveBeenCalledTimes(1);
538+
539+
expect(_mockToolMethod).toHaveBeenCalledWith(
540+
'mcpTool1',
541+
expect.any(String),
542+
expect.any(Object),
543+
expect.any(Function)
544+
);
545+
});
546+
547+
it('should register custom tools if provided', async () => {
548+
const customTools = [
549+
{
550+
name: 'custom-tool',
551+
method: 'custom-test-tool',
552+
description: 'custom tool description',
553+
parameters: {shape: {key: 'unique-key'}},
554+
execute: jest.fn(),
555+
},
556+
];
557+
558+
const config = getConfig({customTools});
559+
560+
await CommercetoolsAgentEssentials.create({
561+
authConfig: {
562+
clientId: 'id',
563+
clientSecret: 'secret',
564+
authUrl: 'auth',
565+
projectKey: 'key',
566+
apiUrl: 'api',
567+
type: 'client_credentials',
568+
},
569+
configuration: config,
570+
});
571+
572+
expect(_mockToolMethod).toHaveBeenCalled();
573+
expect(_mockToolMethod).toHaveBeenCalledTimes(2);
574+
expect(_mockToolMethod).toHaveBeenCalledWith(
575+
'custom-test-tool',
576+
expect.any(String),
577+
expect.any(Object),
578+
expect.any(Function)
579+
);
580+
});
581+
582+
it('should throw an error if the `customTools` provided is not an array', () => {
583+
const customTools = {};
584+
const config = getConfig({customTools});
585+
586+
jest.spyOn(CommercetoolsAgentEssentials, 'create');
587+
588+
expect(CommercetoolsAgentEssentials.create).toHaveBeenCalled();
589+
expect(
590+
CommercetoolsAgentEssentials.create({
591+
authConfig: {
592+
clientId: 'id',
593+
clientSecret: 'secret',
594+
authUrl: 'auth',
595+
projectKey: 'key',
596+
apiUrl: 'api',
597+
type: 'client_credentials',
598+
},
599+
configuration: config,
600+
})
601+
).rejects.toThrow(
602+
`Tool Error: 'customTools' must be an array of tools`
603+
);
604+
});
605+
606+
it(`should throw an error if a tool's 'execute' function is not provided`, () => {
607+
const customTools = [
608+
{
609+
name: 'custom-tool-no-exec-fn',
610+
method: 'custom-test-tool-exec-fn',
611+
description: 'custom tool description',
612+
parameters: {shape: {key: 'unique-key'}},
613+
},
614+
];
615+
616+
const getConfig = (opt: object) => ({..._mockConfiguration, ...opt});
617+
const config = getConfig({customTools});
618+
619+
jest.spyOn(CommercetoolsAgentEssentials, 'create');
620+
621+
expect(CommercetoolsAgentEssentials.create).toHaveBeenCalled();
622+
expect(
623+
CommercetoolsAgentEssentials.create({
624+
authConfig: {
625+
clientId: 'id',
626+
clientSecret: 'secret',
627+
authUrl: 'auth',
628+
projectKey: 'key',
629+
apiUrl: 'api',
630+
type: 'client_credentials',
631+
},
632+
configuration: config,
633+
})
634+
).rejects.toThrow(
635+
`Tool Error: Please provide an 'execute' function for '${customTools[0].name}' tool.`
636+
);
637+
});
638+
639+
it(`should throw an error if a tool's 'execute' property is not a function`, () => {
640+
const customTools = [
641+
{
642+
name: 'custom-tool-no-exec-fn',
643+
method: 'custom-test-tool-exec-fn',
644+
description: 'custom tool description',
645+
parameters: {shape: {key: 'unique-key'}},
646+
execute: 'not-a-function',
647+
},
648+
];
649+
650+
const getConfig = (opt: object) => ({..._mockConfiguration, ...opt});
651+
const config = getConfig({customTools});
652+
653+
jest.spyOn(CommercetoolsAgentEssentials, 'create');
654+
655+
expect(CommercetoolsAgentEssentials.create).toHaveBeenCalled();
656+
expect(
657+
CommercetoolsAgentEssentials.create({
658+
authConfig: {
659+
clientId: 'id',
660+
clientSecret: 'secret',
661+
authUrl: 'auth',
662+
projectKey: 'key',
663+
apiUrl: 'api',
664+
type: 'client_credentials',
665+
},
666+
configuration: config,
667+
})
668+
).rejects.toThrow(
669+
`Tool Error: Please provide an 'execute' function for '${customTools[0].name}' tool.`
670+
);
671+
});
672+
});
515673
});
516674

517675
describe('::registerTools with dynamicToolLoadingThreshold', () => {

typescript/src/modelcontextprotocol/essentials.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,26 @@ class CommercetoolsAgentEssentials extends McpServer {
102102

103103
private getFilteredTools() {
104104
const configuration = this.configuration;
105+
const customTools = configuration.customTools || [];
105106

106-
return contextToTools(configuration.context).filter((tool) =>
107-
isToolAllowed(tool, configuration)
108-
);
107+
if (!Array.isArray(customTools)) {
108+
throw new Error(`Tool Error: 'customTools' must be an array of tools`);
109+
}
110+
111+
customTools.forEach((tool) => {
112+
if (!tool.execute || typeof tool.execute != 'function') {
113+
throw new Error(
114+
`Tool Error: Please provide an 'execute' function for '${tool.name}' tool.`
115+
);
116+
}
117+
});
118+
119+
return [
120+
...customTools,
121+
...contextToTools(configuration.context).filter((tool) =>
122+
isToolAllowed(tool, configuration)
123+
),
124+
];
109125
}
110126

111127
private registerAllTools(filteredTools: Tool[]): void {
@@ -149,12 +165,13 @@ class CommercetoolsAgentEssentials extends McpServer {
149165
}
150166

151167
private registerSingleTool(tool: Tool): void {
168+
const {method, execute} = tool;
152169
this.tool(
153170
tool.method,
154171
tool.description,
155172
tool.parameters.shape,
156173
async (args: Record<string, unknown>) => {
157-
const result = await this.commercetoolsAPI.run(tool.method, args);
174+
const result = await this.commercetoolsAPI.run(method, args, execute);
158175
return this.createToolResponse(result);
159176
}
160177
);
@@ -233,6 +250,7 @@ class CommercetoolsAgentEssentials extends McpServer {
233250
)
234251
.join('\n---\n');
235252
}
253+
236254
private handleToolExecutionError(error: unknown, toolMethod: string) {
237255
const errorMessage = error instanceof Error ? error.message : String(error);
238256

typescript/src/shared/api.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,17 @@ class CommercetoolsAPI {
142142
return res.body?.scope.split(' ').map((scope) => scope.split(':')[0]) || [];
143143
}
144144

145-
async run(method: string, arg: any) {
145+
async run(
146+
method: string,
147+
arg: any,
148+
execute?: (args: Record<string, unknown>, api: ApiRoot) => Promise<string>
149+
) {
150+
// handle custom tool execution
151+
if (execute && typeof execute == 'function') {
152+
return JSON.stringify(await execute(arg, this.apiRoot));
153+
}
154+
155+
// handle core tool execution
146156
const functionMap = contextToFunctionMapping(this.context) as Record<
147157
string,
148158
any

typescript/src/types/configuration.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
AuthConfig,
44
CommercetoolsAgentEssentials,
55
} from '../modelcontextprotocol';
6-
import {AvailableNamespaces} from './tools';
6+
import {AvailableNamespaces, Tool} from './tools';
77
import {Express} from 'express';
88

99
// Actions restrict the subset of API calls that can be made. They should
@@ -42,6 +42,7 @@ export type CommercetoolsFuncContext = Context & {
4242
// Configuration provides various settings and options for the integration
4343
// to tune and manage how it behaves.
4444
export type Configuration = {
45+
customTools?: Array<Tool>;
4546
actions?: Actions;
4647
context?: Context;
4748
};

typescript/src/types/tools.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {z} from 'zod';
2+
import {ApiRoot} from '@commercetools/platform-sdk';
23

34
export enum AvailableNamespaces {
45
BusinessUnit = 'business-unit',
@@ -26,6 +27,7 @@ export type Tool = {
2627
name: string;
2728
description: string;
2829
parameters: z.ZodObject<any, any, any, any>;
30+
execute?: (args: Record<string, unknown>, api?: ApiRoot) => Promise<string>;
2931
actions: {
3032
[key: string]: {
3133
[action: string]: boolean;

0 commit comments

Comments
 (0)