Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
d6de188
feat(ai-proxy): add slack & zendesk integrations
Jan 15, 2026
7150607
feat: add gmail tools
Jan 15, 2026
c6ff03e
refactor: refactor duplicated methods
Jan 19, 2026
2d11520
feat: update route and create integration client
nbouliol Jan 29, 2026
a43eda4
fix(temp): zendesk auth
nbouliol Mar 20, 2026
90870d8
fix(zendesk): use apiToken from config
nbouliol Mar 20, 2026
394953b
chore: remove gmail and slack tools
nbouliol Mar 24, 2026
0be156c
chore: remove gmail example
nbouliol Mar 24, 2026
e7d4d0d
test: add test
nbouliol Mar 24, 2026
b51e5ba
chore(code review): merge received configs and allow validation for f…
nbouliol Mar 25, 2026
fd1bdcb
chore: code review
nbouliol Mar 25, 2026
e352c1e
fix: error
nbouliol Mar 26, 2026
374aea2
fix: ts
nbouliol Mar 26, 2026
205c0e3
refactor(ai-proxy): introduce ToolProvider abstraction
Mar 27, 2026
1ca098f
refactor(ai-proxy): rename McpConfigChecker to ToolSourceChecker
Mar 27, 2026
4ecc127
refactor(ai-proxy): remove duplicate AiRouterRouteArgs type
Mar 27, 2026
73c81de
refactor(ai-proxy): remove deprecated validMcpConfigurationOrThrow alias
Mar 27, 2026
8135e2f
refactor(ai-proxy): extract Brave from RemoteTools into BraveToolProv…
Mar 27, 2026
ad6a0e5
fix: build tool providers in route
nbouliol Mar 30, 2026
87c33f9
chore(code review): macroscope
nbouliol Mar 30, 2026
780e360
refactor: rename mcpServerConfigs to toolConfigs
nbouliol Mar 30, 2026
eb347cb
chore(review): handle properly http errors and add tests
nbouliol Mar 30, 2026
682c5af
chore(code review): only dispose remote tools
nbouliol Mar 30, 2026
d611a4e
chore(code review): update getConfiguration typing
nbouliol Mar 30, 2026
18d7d99
refactor(ai-proxy): rename IntegrationClient to ForestIntegrationClient
Mar 31, 2026
9989e0b
fix(ai-proxy): restore dispose error logging in Router.route()
Mar 31, 2026
94a6a45
fix(ai-proxy): fix lint errors (prettier, import order, no-shadow)
Mar 31, 2026
6ade284
fix(agent): fix ai-proxy test to match flat toolConfigs type
Mar 31, 2026
402767a
chore: yarn install after rebase
nbouliol Mar 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/agent-testing/src/forest-admin-client-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,12 @@
export default class ForestAdminClientMock implements ForestAdminClient {
readonly chartHandler: ChartHandlerInterface;
readonly contextVariablesInstantiator: ContextVariablesInstantiatorInterface = {
buildContextVariables: () => ({} as any), // TODO: return actual context variables

Check warning on line 47 in packages/agent-testing/src/forest-admin-client-mock.ts

View workflow job for this annotation

GitHub Actions / Linting & Testing (agent-testing)

Unexpected any. Specify a different type
};

readonly modelCustomizationService: ModelCustomizationService;
readonly mcpServerConfigService: ForestAdminClient['mcpServerConfigService'] = {
getConfiguration: () => Promise.resolve({ configs: {} }),
getConfiguration: () => Promise.resolve({}),
};

readonly schemaService: ForestAdminClient['schemaService'] = {
Expand All @@ -61,8 +61,8 @@
updateActivityLogStatus: () => Promise.resolve(),
};

readonly permissionService: any;

Check warning on line 64 in packages/agent-testing/src/forest-admin-client-mock.ts

View workflow job for this annotation

GitHub Actions / Linting & Testing (agent-testing)

Unexpected any. Specify a different type
readonly authService: any;

Check warning on line 65 in packages/agent-testing/src/forest-admin-client-mock.ts

View workflow job for this annotation

GitHub Actions / Linting & Testing (agent-testing)

Unexpected any. Specify a different type

constructor() {
this.permissionService = {
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-toolkit/src/interfaces/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export interface AiRouter {
route: string;
body?: unknown;
query?: Record<string, string | string[] | undefined>;
mcpServerConfigs?: unknown;
toolConfigs?: unknown;
headers?: Record<string, string | string[] | undefined>;
}): Promise<unknown>;
}
Expand Down
4 changes: 2 additions & 2 deletions packages/agent/src/routes/ai/ai-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,15 @@ export default class AiProxyRoute extends BaseRoute {
}

private async handleAiProxy(context: Context): Promise<void> {
const mcpServerConfigs =
const toolConfigs =
await this.options.forestAdminClient.mcpServerConfigService.getConfiguration();

context.response.body = await this.aiRouter.route({
route: context.params.route,
body: context.request.body,
query: context.query,
headers: context.request.headers,
mcpServerConfigs,
toolConfigs,
});
context.response.status = HttpCode.Ok;
}
Expand Down
12 changes: 5 additions & 7 deletions packages/agent/test/routes/ai/ai-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,13 @@
requestBody: { messages: [] },
});

await (route as any).handleAiProxy(context);

Check warning on line 54 in packages/agent/test/routes/ai/ai-proxy.test.ts

View workflow job for this annotation

GitHub Actions / Linting & Testing (agent)

Unexpected any. Specify a different type

expect(context.response.status).toBe(HttpCode.Ok);
expect(context.response.body).toEqual(expectedResponse);
});

test('should pass route, body, query, mcpServerConfigs and headers to router', async () => {
test('should pass route, body, query, toolConfigs and headers to router', async () => {
const route = new AiProxyRoute(services, options, aiRouter);
mockRoute.mockResolvedValueOnce({});

Expand All @@ -69,25 +69,23 @@
});
context.query = { 'ai-name': 'gpt4' };

await (route as any).handleAiProxy(context);

Check warning on line 72 in packages/agent/test/routes/ai/ai-proxy.test.ts

View workflow job for this annotation

GitHub Actions / Linting & Testing (agent)

Unexpected any. Specify a different type

expect(mockRoute).toHaveBeenCalledWith({
route: 'ai-query',
body: { messages: [{ role: 'user', content: 'Hello' }] },
query: { 'ai-name': 'gpt4' },
mcpServerConfigs: undefined,
toolConfigs: undefined,
headers: context.request.headers,
});
});

test('should pass mcpServerConfigs from forestAdminClient to router', async () => {
test('should pass toolConfigs from forestAdminClient to router', async () => {
const route = new AiProxyRoute(services, options, aiRouter);
mockRoute.mockResolvedValueOnce({});

const mcpConfigs = {
configs: {
server1: { type: 'http' as const, url: 'https://server1.com' },
},
server1: { type: 'http' as const, url: 'https://server1.com' },
};
jest
.spyOn(options.forestAdminClient.mcpServerConfigService, 'getConfiguration')
Expand All @@ -101,11 +99,11 @@
});
context.query = {};

await (route as any).handleAiProxy(context);

Check warning on line 102 in packages/agent/test/routes/ai/ai-proxy.test.ts

View workflow job for this annotation

GitHub Actions / Linting & Testing (agent)

Unexpected any. Specify a different type

expect(mockRoute).toHaveBeenCalledWith(
expect.objectContaining({
mcpServerConfigs: mcpConfigs,
toolConfigs: mcpConfigs,
headers: context.request.headers,
}),
);
Expand All @@ -124,7 +122,7 @@
requestBody: {},
});

await expect((route as any).handleAiProxy(context)).rejects.toBe(error);

Check warning on line 125 in packages/agent/test/routes/ai/ai-proxy.test.ts

View workflow job for this annotation

GitHub Actions / Linting & Testing (agent)

Unexpected any. Specify a different type
});
});
});
10 changes: 6 additions & 4 deletions packages/ai-proxy/src/create-ai-provider.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import type { McpConfiguration } from './mcp-client';
import type { AiConfiguration } from './provider';
import type { RouterRouteArgs } from './schemas/route';
import type { ToolConfig } from './tool-provider-factory';
import type { AiProviderDefinition, AiRouter } from '@forestadmin/agent-toolkit';

import { extractMcpOauthTokensFromHeaders, injectOauthTokens } from './oauth-token-injector';
import { Router } from './router';

function resolveMcpConfigs(args: Parameters<AiRouter['route']>[0]): McpConfiguration | undefined {
function resolveMcpConfigs(
args: Parameters<AiRouter['route']>[0],
): Record<string, ToolConfig> | undefined {
const tokensByMcpServerName = args.headers
? extractMcpOauthTokensFromHeaders(args.headers)
: undefined;

return injectOauthTokens({
mcpConfigs: args.mcpServerConfigs as McpConfiguration | undefined,
configs: args.toolConfigs as Record<string, ToolConfig> | undefined,
tokensByMcpServerName,
});
}
Expand All @@ -32,7 +34,7 @@ export function createAiProvider(config: AiConfiguration): AiProviderDefinition
route: args.route,
body: args.body,
query: args.query,
mcpConfigs: resolveMcpConfigs(args),
toolConfigs: resolveMcpConfigs(args),
} as RouterRouteArgs),
};
},
Expand Down
68 changes: 68 additions & 0 deletions packages/ai-proxy/src/forest-integration-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type RemoteTool from './remote-tool';
import type { ToolProvider } from './tool-provider';
import type { Logger } from '@forestadmin/datasource-toolkit';

import getZendeskTools, { type ZendeskConfig } from './integrations/zendesk/tools';
import { validateZendeskConfig } from './integrations/zendesk/utils';

export type CustomConfig = ZendeskConfig;
export type ForestIntegrationName = 'Zendesk';

export interface ForestIntegrationConfig {
integrationName: ForestIntegrationName;
config: CustomConfig;
isForestConnector: true;
}

export function isForestIntegrationConfig(
config: ForestIntegrationConfig | Record<string, unknown>,
): config is ForestIntegrationConfig {
return (
'isForestConnector' in config && (config as ForestIntegrationConfig).isForestConnector === true
);
}

export default class ForestIntegrationClient implements ToolProvider {
private readonly logger?: Logger;
private readonly configs: ForestIntegrationConfig[];

constructor(configs: ForestIntegrationConfig[], logger?: Logger) {
this.logger = logger;
this.configs = configs;
}

async loadTools(): Promise<RemoteTool[]> {
const tools: RemoteTool[] = [];

this.configs.forEach(({ integrationName, config }) => {
switch (integrationName) {
case 'Zendesk':
tools.push(...getZendeskTools(config as ZendeskConfig));
break;
default:
this.logger?.('Warn', `Unsupported integration: ${integrationName}`);
}
});

return tools;
}

async checkConnection(): Promise<true> {
await Promise.all(
this.configs.map(({ integrationName, config }) => {
switch (integrationName) {
case 'Zendesk':
return validateZendeskConfig(config as ZendeskConfig);
default:
throw new Error(`Unsupported integration: ${integrationName}`);
}
}),
);

return true;
}

async dispose(): Promise<void> {
// No-op: integrations don't hold persistent connections
}
}
22 changes: 18 additions & 4 deletions packages/ai-proxy/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,30 @@
import type { McpConfiguration } from './mcp-client';
import type { ToolConfig } from './tool-provider-factory';
import type { Logger } from '@forestadmin/datasource-toolkit';

import McpConfigChecker from './mcp-config-checker';
import ToolSourceChecker from './tool-source-checker';

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

export {
ForestIntegrationConfig,
CustomConfig,
ForestIntegrationName,
} from './forest-integration-client';

export * from './provider-dispatcher';
export * from './remote-tools';
export { default as RemoteTool } from './remote-tool';
export * from './router';
export * from './mcp-client';
export * from './oauth-token-injector';
export * from './errors';
export * from './tool-provider';
export * from './tool-provider-factory';

export function validMcpConfigurationOrThrow(mcpConfig: McpConfiguration) {
return McpConfigChecker.check(mcpConfig);
export function validToolConfigurationOrThrow(
configs: Record<string, ToolConfig>,
logger?: Logger,
) {
return ToolSourceChecker.check(configs, logger);
}
25 changes: 25 additions & 0 deletions packages/ai-proxy/src/integrations/brave/brave-tool-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type RemoteTool from '../../remote-tool';
import type { ToolProvider } from '../../tool-provider';

import getBraveTools, { type BraveConfig } from './tools';

export default class BraveToolProvider implements ToolProvider {
private readonly config: BraveConfig;

constructor(config: BraveConfig) {
this.config = config;
}

async loadTools(): Promise<RemoteTool[]> {
return getBraveTools(this.config);
}

async checkConnection(): Promise<true> {
return true;
}

// eslint-disable-next-line class-methods-use-this
async dispose(): Promise<void> {
// No-op: Brave search has no persistent connections
}
}
18 changes: 18 additions & 0 deletions packages/ai-proxy/src/integrations/brave/tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type RemoteTool from '../../remote-tool';

import { BraveSearch } from '@langchain/community/tools/brave_search';

import ServerRemoteTool from '../../server-remote-tool';

export interface BraveConfig {
apiKey: string;
}

export default function getBraveTools(config: BraveConfig): RemoteTool[] {
return [
new ServerRemoteTool({
sourceId: 'brave_search',
tool: new BraveSearch({ apiKey: config.apiKey }),
}),
];
}
35 changes: 35 additions & 0 deletions packages/ai-proxy/src/integrations/zendesk/tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type RemoteTool from '../../remote-tool';

import createCreateTicketTool from './tools/create-ticket';
import createCreateTicketCommentTool from './tools/create-ticket-comment';
import createGetTicketTool from './tools/get-ticket';
import createGetTicketCommentsTool from './tools/get-ticket-comments';
import createGetTicketsTool from './tools/get-tickets';
import createUpdateTicketTool from './tools/update-ticket';
import { getZendeskConfig } from './utils';
import ServerRemoteTool from '../../server-remote-tool';

export interface ZendeskConfig {
subdomain: string;
email: string;
apiToken: string;
}

export default function getZendeskTools(config: ZendeskConfig): RemoteTool[] {
const { baseUrl, headers } = getZendeskConfig(config);

return [
createGetTicketsTool(headers, baseUrl),
createGetTicketTool(headers, baseUrl),
createGetTicketCommentsTool(headers, baseUrl),
createCreateTicketTool(headers, baseUrl),
createCreateTicketCommentTool(headers, baseUrl),
createUpdateTicketTool(headers, baseUrl),
].map(
tool =>
new ServerRemoteTool({
sourceId: 'zendesk',
tool,
}),
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { DynamicStructuredTool } from '@langchain/core/tools';
import { z } from 'zod';

import { assertResponseOk } from '../utils';

export default function createCreateTicketCommentTool(
headers: Record<string, string>,
baseUrl: string,
): DynamicStructuredTool {
return new DynamicStructuredTool({
name: 'zendesk_create_ticket_comment',
description: 'Add a new comment to an existing Zendesk ticket',
schema: z.object({
ticket_id: z.number().int().positive().describe('The ID of the ticket to add a comment to'),
comment: z.string().min(1).describe('The comment text to add'),
public: z
.boolean()
.optional()
.default(true)
.describe(
'Whether the comment is visible to the requester (true) or internal only (false)',
),
}),
func: async ({ ticket_id, comment, public: isPublic }) => {
const updateData = {
ticket: {
comment: {
body: comment,
public: isPublic,
},
},
};

const response = await fetch(`${baseUrl}/tickets/${ticket_id}.json`, {
method: 'PUT',
headers,
body: JSON.stringify(updateData),
});

await assertResponseOk(response, 'create ticket comment');

return JSON.stringify(await response.json());
},
});
}
Loading
Loading