Skip to content

Commit f4f1525

Browse files
committed
chore(code review): merge received configs and allow validation for forest integrations
1 parent c0c5324 commit f4f1525

7 files changed

Lines changed: 141 additions & 25 deletions

File tree

packages/ai-proxy/src/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1+
import type { ForestIntegrationConfig } from './integration-client';
12
import type { McpConfiguration } from './mcp-client';
23

34
import McpConfigChecker from './mcp-config-checker';
45

56
export { createAiProvider } from './create-ai-provider';
67
export { default as ProviderDispatcher } from './provider-dispatcher';
78

8-
export { ForestIntegrationConfig, CustomConfig } from './integration-client';
9+
export { ForestIntegrationConfig, CustomConfig, ForestIntegrationName } from './integration-client';
910

1011
export * from './provider-dispatcher';
1112
export * from './remote-tools';
@@ -14,6 +15,8 @@ export * from './mcp-client';
1415
export * from './oauth-token-injector';
1516
export * from './errors';
1617

17-
export function validMcpConfigurationOrThrow(mcpConfig: McpConfiguration) {
18+
export function validMcpConfigurationOrThrow(
19+
mcpConfig: McpConfiguration | ForestIntegrationConfig,
20+
) {
1821
return McpConfigChecker.check(mcpConfig);
1922
}

packages/ai-proxy/src/integration-client.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,15 @@
11
import type McpServerRemoteTool from './mcp-server-remote-tool';
22
import type { Logger } from '@forestadmin/datasource-toolkit';
3-
import type { MultiServerMCPClient } from '@langchain/mcp-adapters';
43

54
import getZendeskTools, { type ZendeskConfig } from './integrations/zendesk/tools';
65

7-
export type McpConfiguration = {
8-
configs: MultiServerMCPClient['config']['mcpServers'];
9-
} & Omit<MultiServerMCPClient['config'], 'mcpServers'>;
10-
116
export type CustomConfig = ZendeskConfig;
7+
export type ForestIntegrationName = 'Zendesk';
128

139
export interface ForestIntegrationConfig {
14-
integrationName: string;
10+
integrationName: ForestIntegrationName;
1511
config: CustomConfig;
12+
isForestConnector: true;
1613
}
1714

1815
export default class IntegrationClient {
@@ -29,7 +26,7 @@ export default class IntegrationClient {
2926
loadTools(): McpServerRemoteTool[] {
3027
this.configs.forEach(({ integrationName, config }) => {
3128
switch (integrationName) {
32-
case 'zendesk':
29+
case 'Zendesk':
3330
this.tools.push(...getZendeskTools(config as ZendeskConfig));
3431
break;
3532
default:

packages/ai-proxy/src/integrations/zendesk/tools.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import createGetTicketTool from './tools/get-ticket';
66
import createGetTicketCommentsTool from './tools/get-ticket-comments';
77
import createGetTicketsTool from './tools/get-tickets';
88
import createUpdateTicketTool from './tools/update-ticket';
9+
import { getZendeskConfig } from './utils';
910
import ServerRemoteTool from '../../server-remote-tool';
1011

1112
export interface ZendeskConfig {
@@ -15,12 +16,7 @@ export interface ZendeskConfig {
1516
}
1617

1718
export default function getZendeskTools(config: ZendeskConfig): RemoteTool[] {
18-
const baseUrl = `https://${config.subdomain}.zendesk.com/api/v2`;
19-
const auth = Buffer.from(`${config.email}/token:${config.apiToken}`).toString('base64');
20-
const headers = {
21-
Authorization: `Basic ${auth}`,
22-
'Content-Type': 'application/json',
23-
};
19+
const { baseUrl, headers } = getZendeskConfig(config);
2420

2521
return [
2622
createGetTicketsTool(headers, baseUrl),

packages/ai-proxy/src/mcp-client.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
1+
import type { ForestIntegrationConfig } from './integration-client';
12
import type { Logger } from '@forestadmin/datasource-toolkit';
23

34
import { MultiServerMCPClient } from '@langchain/mcp-adapters';
45

56
import { McpConnectionError } from './errors';
67
import McpServerRemoteTool from './mcp-server-remote-tool';
78

9+
export type McpServers = MultiServerMCPClient['config']['mcpServers'];
10+
811
export type McpServerConfig = MultiServerMCPClient['config']['mcpServers'][string];
912

1013
export type McpConfiguration = {
11-
configs: MultiServerMCPClient['config']['mcpServers'];
14+
configs: McpServers | Record<string, ForestIntegrationConfig>;
1215
} & Omit<MultiServerMCPClient['config'], 'mcpServers'>;
1316

1417
export default class McpClient {
Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,26 @@
1+
import type { ForestIntegrationConfig } from './integration-client';
12
import type { McpConfiguration } from './mcp-client';
23

4+
import { validateZendeskConfig } from './integrations/zendesk/utils';
35
import McpClient from './mcp-client';
46

57
export default class McpConfigChecker {
6-
static check(mcpConfig: McpConfiguration) {
8+
static isForestIntegrationConfig(
9+
config: McpConfiguration | ForestIntegrationConfig,
10+
): config is ForestIntegrationConfig {
11+
return 'integrationName' in config;
12+
}
13+
14+
static check(mcpConfig: McpConfiguration | ForestIntegrationConfig) {
15+
if (McpConfigChecker.isForestIntegrationConfig(mcpConfig)) {
16+
switch (mcpConfig.integrationName) {
17+
case 'Zendesk':
18+
return validateZendeskConfig(mcpConfig.config);
19+
default:
20+
throw new Error(`Unsupported integration: ${mcpConfig.integrationName}`);
21+
}
22+
}
23+
724
return new McpClient(mcpConfig).testConnections();
825
}
926
}

packages/ai-proxy/src/router.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,6 @@ export class Router {
6363
async route(
6464
args: RouteArgs & {
6565
mcpConfigs?: McpConfiguration;
66-
integrationConfigs?: ForestIntegrationConfig[];
6766
},
6867
) {
6968
// Validate input with Zod schema
@@ -77,13 +76,24 @@ export class Router {
7776
let mcpClient: McpClient | undefined;
7877
let integrationClient: IntegrationClient | undefined;
7978

79+
const mcpConfigs: McpConfiguration = { configs: {} };
80+
const integrationConfigs: ForestIntegrationConfig[] = [];
81+
82+
Object.entries(args.mcpConfigs.configs).forEach(([name, config]) => {
83+
if (config.isForestConnector) {
84+
integrationConfigs.push(config as ForestIntegrationConfig);
85+
} else {
86+
mcpConfigs.configs[name] = config;
87+
}
88+
});
89+
8090
try {
81-
if (args.mcpConfigs) {
82-
mcpClient = new McpClient(args.mcpConfigs, this.logger);
91+
if (mcpConfigs && Object.keys(mcpConfigs.configs).length > 0) {
92+
mcpClient = new McpClient(mcpConfigs, this.logger);
8393
}
8494

85-
if (args.integrationConfigs) {
86-
integrationClient = new IntegrationClient(args.integrationConfigs, this.logger);
95+
if (integrationConfigs.length > 0) {
96+
integrationClient = new IntegrationClient(integrationConfigs, this.logger);
8797
}
8898

8999
const remoteTools = new RemoteTools(this.localToolsApiKeys ?? {}, [

packages/ai-proxy/test/router.test.ts

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { DispatchBody, InvokeRemoteToolArgs } from '../src';
22
import type { Logger } from '@forestadmin/datasource-toolkit';
33

44
import { AIModelNotSupportedError, Router } from '../src';
5+
import IntegrationClient from '../src/integration-client';
56
import McpClient from '../src/mcp-client';
67
import ProviderDispatcher from '../src/provider-dispatcher';
78

@@ -39,6 +40,18 @@ jest.mock('../src/mcp-client', () => {
3940

4041
const MockedMcpClient = McpClient as jest.MockedClass<typeof McpClient>;
4142

43+
const loadToolsMock = jest.fn().mockReturnValue([]);
44+
jest.mock('../src/integration-client', () => {
45+
return {
46+
__esModule: true,
47+
default: jest.fn().mockImplementation(() => ({
48+
loadTools: loadToolsMock,
49+
})),
50+
};
51+
});
52+
53+
const MockedIntegrationClient = IntegrationClient as jest.MockedClass<typeof IntegrationClient>;
54+
4255
describe('route', () => {
4356
beforeEach(() => {
4457
jest.clearAllMocks();
@@ -60,6 +73,7 @@ describe('route', () => {
6073
await router.route({
6174
route: 'ai-query',
6275
body: { tools: [], tool_choice: 'required', messages: [] } as unknown as DispatchBody,
76+
mcpConfigs: { configs: {} },
6377
});
6478

6579
expect(dispatchMock).toHaveBeenCalledWith({
@@ -90,6 +104,7 @@ describe('route', () => {
90104
route: 'ai-query',
91105
query: { 'ai-name': 'gpt4mini' },
92106
body: { tools: [], tool_choice: 'required', messages: [] } as unknown as DispatchBody,
107+
mcpConfigs: { configs: {} },
93108
});
94109

95110
expect(ProviderDispatcherMock).toHaveBeenCalledWith(gpt4MiniConfig, expect.anything());
@@ -115,6 +130,7 @@ describe('route', () => {
115130
await router.route({
116131
route: 'ai-query',
117132
body: { tools: [], tool_choice: 'required', messages: [] } as unknown as DispatchBody,
133+
mcpConfigs: { configs: {} },
118134
});
119135

120136
expect(ProviderDispatcherMock).toHaveBeenCalledWith(gpt4Config, expect.anything());
@@ -137,6 +153,7 @@ describe('route', () => {
137153
route: 'ai-query',
138154
query: { 'ai-name': 'non-existent' },
139155
body: { tools: [], tool_choice: 'required', messages: [] } as unknown as DispatchBody,
156+
mcpConfigs: { configs: {} },
140157
});
141158

142159
expect(mockLogger).toHaveBeenCalledWith(
@@ -155,6 +172,7 @@ describe('route', () => {
155172
route: 'invoke-remote-tool',
156173
query: { 'tool-name': 'tool-name' },
157174
body: { inputs: [] },
175+
mcpConfigs: { configs: {} },
158176
});
159177

160178
expect(invokeToolMock).toHaveBeenCalledWith('tool-name', []);
@@ -168,6 +186,7 @@ describe('route', () => {
168186
route: 'invoke-remote-tool',
169187
query: {},
170188
body: { inputs: [] },
189+
mcpConfigs: { configs: {} },
171190
} as any),
172191
).rejects.toThrow('query.tool-name: Missing required query parameter: tool-name');
173192
});
@@ -180,6 +199,7 @@ describe('route', () => {
180199
route: 'invoke-remote-tool',
181200
query: { 'tool-name': 'tool-name' },
182201
body: {} as InvokeRemoteToolArgs['body'],
202+
mcpConfigs: { configs: {} },
183203
}),
184204
).rejects.toThrow('body.inputs: Missing required body parameter: inputs');
185205
});
@@ -192,6 +212,7 @@ describe('route', () => {
192212
route: 'invoke-remote-tool',
193213
query: {},
194214
body: {},
215+
mcpConfigs: { configs: {} },
195216
} as any),
196217
).rejects.toThrow(/tool-name.*;.*inputs|inputs.*;.*tool-name/);
197218
});
@@ -201,7 +222,7 @@ describe('route', () => {
201222
it('returns the remote tools definitions', async () => {
202223
const router = new Router({});
203224

204-
const result = await router.route({ route: 'remote-tools' });
225+
const result = await router.route({ route: 'remote-tools', mcpConfigs: { configs: {} } });
205226

206227
expect(result).toEqual(toolDefinitionsForFrontend);
207228
});
@@ -211,7 +232,9 @@ describe('route', () => {
211232
it('throws a validation error with helpful message', async () => {
212233
const router = new Router({});
213234

214-
await expect(router.route({ route: 'unknown' } as any)).rejects.toThrow(
235+
await expect(
236+
router.route({ route: 'unknown', mcpConfigs: { configs: {} } } as any),
237+
).rejects.toThrow(
215238
"Invalid route. Expected: 'ai-query', 'invoke-remote-tool', 'remote-tools'",
216239
);
217240
});
@@ -264,11 +287,12 @@ describe('route', () => {
264287
expect(mcpClientInstance.closeConnections).toHaveBeenCalledTimes(1);
265288
});
266289

267-
it('does not call closeConnections when no mcpConfigs provided', async () => {
290+
it('does not call closeConnections when no mcp configs provided', async () => {
268291
const router = new Router({});
269292

270293
await router.route({
271294
route: 'remote-tools',
295+
mcpConfigs: { configs: {} },
272296
});
273297

274298
expect(MockedMcpClient).not.toHaveBeenCalled();
@@ -385,6 +409,72 @@ describe('route', () => {
385409
});
386410
});
387411

412+
describe('config splitting between MCP and integration configs', () => {
413+
const zendeskIntegration = {
414+
isForestConnector: true,
415+
integrationName: 'zendesk',
416+
config: { subdomain: 'test', apiToken: 'token', email: 'a@b.c' },
417+
};
418+
419+
it('passes only non-forest configs to McpClient', async () => {
420+
const router = new Router({});
421+
422+
await router.route({
423+
route: 'remote-tools',
424+
mcpConfigs: {
425+
configs: {
426+
server1: { command: 'test', args: [] },
427+
zendesk: zendeskIntegration,
428+
} as any,
429+
},
430+
});
431+
432+
expect(MockedMcpClient).toHaveBeenCalledWith(
433+
{ configs: { server1: { command: 'test', args: [] } } },
434+
undefined,
435+
);
436+
});
437+
438+
it('passes forest configs to IntegrationClient', async () => {
439+
const router = new Router({});
440+
441+
await router.route({
442+
route: 'remote-tools',
443+
mcpConfigs: {
444+
configs: { zendesk: zendeskIntegration } as any,
445+
},
446+
});
447+
448+
expect(MockedIntegrationClient).toHaveBeenCalledWith([zendeskIntegration], undefined);
449+
});
450+
451+
it('does not create IntegrationClient when no forest configs', async () => {
452+
const router = new Router({});
453+
454+
await router.route({
455+
route: 'remote-tools',
456+
mcpConfigs: {
457+
configs: { server1: { command: 'test', args: [] } },
458+
},
459+
});
460+
461+
expect(MockedIntegrationClient).not.toHaveBeenCalled();
462+
});
463+
464+
it('does not create McpClient when only forest configs', async () => {
465+
const router = new Router({});
466+
467+
await router.route({
468+
route: 'remote-tools',
469+
mcpConfigs: {
470+
configs: { zendesk: zendeskIntegration } as any,
471+
},
472+
});
473+
474+
expect(MockedMcpClient).not.toHaveBeenCalled();
475+
});
476+
});
477+
388478
describe('Model validation', () => {
389479
it('throws AIModelNotSupportedError when model does not support tools', () => {
390480
expect(

0 commit comments

Comments
 (0)