From 15ac7010b1c771efd5fa5b5f82718af04e51bc0a Mon Sep 17 00:00:00 2001 From: Enki Pontvianne Date: Thu, 15 Jan 2026 16:47:06 +0100 Subject: [PATCH 01/23] feat(ai-proxy): add slack & zendesk integrations --- .../ai-proxy/src/integrations/brave/tools.ts | 17 +++++ .../ai-proxy/src/integrations/slack/tools.ts | 40 ++++++++++ .../integrations/slack/tools/add-reaction.ts | 29 ++++++++ .../slack/tools/get-channel-history.ts | 30 ++++++++ .../slack/tools/get-thread-replies.ts | 32 ++++++++ .../slack/tools/get-user-profile.ts | 25 +++++++ .../src/integrations/slack/tools/get-users.ts | 36 +++++++++ .../integrations/slack/tools/list-channels.ts | 68 +++++++++++++++++ .../integrations/slack/tools/post-message.ts | 27 +++++++ .../slack/tools/reply-to-thread.ts | 35 +++++++++ packages/ai-proxy/src/integrations/tools.ts | 18 +++++ .../src/integrations/zendesk/tools.ts | 38 ++++++++++ .../zendesk/tools/create-ticket-comment.ts | 39 ++++++++++ .../zendesk/tools/create-ticket.ts | 58 +++++++++++++++ .../zendesk/tools/get-ticket-comments.ts | 22 ++++++ .../integrations/zendesk/tools/get-ticket.ts | 22 ++++++ .../integrations/zendesk/tools/get-tickets.ts | 43 +++++++++++ .../zendesk/tools/update-ticket.ts | 73 +++++++++++++++++++ packages/ai-proxy/src/remote-tools.ts | 17 ++--- 19 files changed, 660 insertions(+), 9 deletions(-) create mode 100644 packages/ai-proxy/src/integrations/brave/tools.ts create mode 100644 packages/ai-proxy/src/integrations/slack/tools.ts create mode 100644 packages/ai-proxy/src/integrations/slack/tools/add-reaction.ts create mode 100644 packages/ai-proxy/src/integrations/slack/tools/get-channel-history.ts create mode 100644 packages/ai-proxy/src/integrations/slack/tools/get-thread-replies.ts create mode 100644 packages/ai-proxy/src/integrations/slack/tools/get-user-profile.ts create mode 100644 packages/ai-proxy/src/integrations/slack/tools/get-users.ts create mode 100644 packages/ai-proxy/src/integrations/slack/tools/list-channels.ts create mode 100644 packages/ai-proxy/src/integrations/slack/tools/post-message.ts create mode 100644 packages/ai-proxy/src/integrations/slack/tools/reply-to-thread.ts create mode 100644 packages/ai-proxy/src/integrations/tools.ts create mode 100644 packages/ai-proxy/src/integrations/zendesk/tools.ts create mode 100644 packages/ai-proxy/src/integrations/zendesk/tools/create-ticket-comment.ts create mode 100644 packages/ai-proxy/src/integrations/zendesk/tools/create-ticket.ts create mode 100644 packages/ai-proxy/src/integrations/zendesk/tools/get-ticket-comments.ts create mode 100644 packages/ai-proxy/src/integrations/zendesk/tools/get-ticket.ts create mode 100644 packages/ai-proxy/src/integrations/zendesk/tools/get-tickets.ts create mode 100644 packages/ai-proxy/src/integrations/zendesk/tools/update-ticket.ts diff --git a/packages/ai-proxy/src/integrations/brave/tools.ts b/packages/ai-proxy/src/integrations/brave/tools.ts new file mode 100644 index 0000000000..f04fa62a16 --- /dev/null +++ b/packages/ai-proxy/src/integrations/brave/tools.ts @@ -0,0 +1,17 @@ +import { BraveSearch } from '@langchain/community/tools/brave_search'; + +import RemoteTool from '../../remote-tool'; + +export interface BraveConfig { + apiKey: string; +} + +export default function getBraveTools(config: BraveConfig): RemoteTool[] { + return [ + new RemoteTool({ + sourceId: 'brave_search', + sourceType: 'server', + tool: new BraveSearch({ apiKey: config.apiKey }), + }), + ]; +} diff --git a/packages/ai-proxy/src/integrations/slack/tools.ts b/packages/ai-proxy/src/integrations/slack/tools.ts new file mode 100644 index 0000000000..44950ee509 --- /dev/null +++ b/packages/ai-proxy/src/integrations/slack/tools.ts @@ -0,0 +1,40 @@ +import RemoteTool from '../../remote-tool'; +import createAddReactionTool from './tools/add-reaction'; +import createGetChannelHistoryTool from './tools/get-channel-history'; +import createGetThreadRepliesTool from './tools/get-thread-replies'; +import createGetUserProfileTool from './tools/get-user-profile'; +import createGetUsersTool from './tools/get-users'; +import createListChannelsTool from './tools/list-channels'; +import createPostMessageTool from './tools/post-message'; +import createReplyToThreadTool from './tools/reply-to-thread'; + +export interface SlackConfig { + authToken: string; + teamId: string; + channelIds?: string; +} + +export default function getSlackTools(config: SlackConfig): RemoteTool[] { + const headers = { + Authorization: `Bearer ${config.authToken}`, + 'Content-Type': 'application/json', + }; + + return [ + createListChannelsTool(headers, config), + createPostMessageTool(headers), + createReplyToThreadTool(headers), + createAddReactionTool(headers), + createGetChannelHistoryTool(headers), + createGetThreadRepliesTool(headers), + createGetUsersTool(headers, config.teamId), + createGetUserProfileTool(headers), + ].map( + tool => + new RemoteTool({ + sourceId: 'slack', + sourceType: 'server', + tool, + }), + ); +} diff --git a/packages/ai-proxy/src/integrations/slack/tools/add-reaction.ts b/packages/ai-proxy/src/integrations/slack/tools/add-reaction.ts new file mode 100644 index 0000000000..924f554750 --- /dev/null +++ b/packages/ai-proxy/src/integrations/slack/tools/add-reaction.ts @@ -0,0 +1,29 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +export default function createAddReactionTool( + headers: Record, +): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: 'slack_add_reaction', + description: 'Add a reaction emoji to a message', + schema: z.object({ + channel_id: z.string().describe('The ID of the channel containing the message'), + timestamp: z.string().describe('The timestamp of the message to react to'), + reaction: z.string().describe('The name of the emoji reaction (without ::)'), + }), + func: async ({ channel_id, timestamp, reaction }) => { + const response = await fetch('https://slack.com/api/reactions.add', { + method: 'POST', + headers, + body: JSON.stringify({ + channel: channel_id, + timestamp, + name: reaction, + }), + }); + + return JSON.stringify(await response.json()); + }, + }); +} diff --git a/packages/ai-proxy/src/integrations/slack/tools/get-channel-history.ts b/packages/ai-proxy/src/integrations/slack/tools/get-channel-history.ts new file mode 100644 index 0000000000..f2be20806e --- /dev/null +++ b/packages/ai-proxy/src/integrations/slack/tools/get-channel-history.ts @@ -0,0 +1,30 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +export default function createGetChannelHistoryTool( + headers: Record, +): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: 'slack_get_channel_history', + description: 'Get recent messages from a channel', + schema: z.object({ + channel_id: z.string().describe('The ID of the channel'), + limit: z + .number() + .optional() + .default(10) + .describe('Number of messages to retrieve (default 10)'), + }), + func: async ({ channel_id, limit }) => { + const params = new URLSearchParams({ + channel: channel_id, + limit: limit.toString(), + }); + const response = await fetch(`https://slack.com/api/conversations.history?${params}`, { + headers, + }); + + return JSON.stringify(await response.json()); + }, + }); +} diff --git a/packages/ai-proxy/src/integrations/slack/tools/get-thread-replies.ts b/packages/ai-proxy/src/integrations/slack/tools/get-thread-replies.ts new file mode 100644 index 0000000000..3f2c6da0d6 --- /dev/null +++ b/packages/ai-proxy/src/integrations/slack/tools/get-thread-replies.ts @@ -0,0 +1,32 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +export default function createGetThreadRepliesTool( + headers: Record, +): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: 'slack_get_thread_replies', + description: 'Get all replies in a message thread', + schema: z.object({ + channel_id: z.string().describe('The ID of the channel containing the thread'), + thread_ts: z + .string() + .describe( + "The timestamp of the parent message in the format '1234567890.123456'. " + + 'Timestamps in the format without the period can be converted by adding the period ' + + 'such that 6 numbers come after it.', + ), + }), + func: async ({ channel_id, thread_ts }) => { + const params = new URLSearchParams({ + channel: channel_id, + ts: thread_ts, + }); + const response = await fetch(`https://slack.com/api/conversations.replies?${params}`, { + headers, + }); + + return JSON.stringify(await response.json()); + }, + }); +} diff --git a/packages/ai-proxy/src/integrations/slack/tools/get-user-profile.ts b/packages/ai-proxy/src/integrations/slack/tools/get-user-profile.ts new file mode 100644 index 0000000000..df6d4f5d92 --- /dev/null +++ b/packages/ai-proxy/src/integrations/slack/tools/get-user-profile.ts @@ -0,0 +1,25 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +export default function createGetUserProfileTool( + headers: Record, +): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: 'slack_get_user_profile', + description: 'Get detailed profile information for a specific user', + schema: z.object({ + user_id: z.string().describe('The ID of the user'), + }), + func: async ({ user_id }) => { + const params = new URLSearchParams({ + user: user_id, + include_labels: 'true', + }); + const response = await fetch(`https://slack.com/api/users.profile.get?${params}`, { + headers, + }); + + return JSON.stringify(await response.json()); + }, + }); +} diff --git a/packages/ai-proxy/src/integrations/slack/tools/get-users.ts b/packages/ai-proxy/src/integrations/slack/tools/get-users.ts new file mode 100644 index 0000000000..9ab30cdca4 --- /dev/null +++ b/packages/ai-proxy/src/integrations/slack/tools/get-users.ts @@ -0,0 +1,36 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +export default function createGetUsersTool( + headers: Record, + teamId: string, +): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: 'slack_get_users', + description: 'Get a list of all users in the workspace with their basic profile information', + schema: z.object({ + cursor: z.string().optional().describe('Pagination cursor for next page of results'), + limit: z + .number() + .optional() + .default(100) + .describe('Maximum number of users to return (default 100, max 200)'), + }), + func: async ({ cursor, limit }) => { + const params = new URLSearchParams({ + limit: Math.min(limit, 200).toString(), + team_id: teamId, + }); + + if (cursor) { + params.append('cursor', cursor); + } + + const response = await fetch(`https://slack.com/api/users.list?${params}`, { + headers, + }); + + return JSON.stringify(await response.json()); + }, + }); +} diff --git a/packages/ai-proxy/src/integrations/slack/tools/list-channels.ts b/packages/ai-proxy/src/integrations/slack/tools/list-channels.ts new file mode 100644 index 0000000000..008016962c --- /dev/null +++ b/packages/ai-proxy/src/integrations/slack/tools/list-channels.ts @@ -0,0 +1,68 @@ +import type { SlackConfig } from '../tools'; + +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +export default function createListChannelsTool( + headers: Record, + config: SlackConfig, +): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: 'slack_list_channels', + description: 'List public or pre-defined channels in the workspace with pagination', + schema: z.object({ + limit: z + .number() + .optional() + .default(100) + .describe('Maximum number of channels to return (default 100, max 200)'), + cursor: z.string().optional().describe('Pagination cursor for next page of results'), + }), + func: async ({ limit, cursor }) => { + const { channelIds } = config; + + if (!channelIds) { + const params = new URLSearchParams({ + types: 'public_channel', + exclude_archived: 'true', + limit: Math.min(limit, 200).toString(), + team_id: config.teamId, + }); + + if (cursor) { + params.append('cursor', cursor); + } + + const response = await fetch(`https://slack.com/api/conversations.list?${params}`, { + headers, + }); + + return JSON.stringify(await response.json()); + } + + const predefinedChannelIdsArray = channelIds.split(',').map(id => id.trim()); + + const channelPromises = predefinedChannelIdsArray.map(async channelId => { + const params = new URLSearchParams({ + channel: channelId, + }); + const response = await fetch(`https://slack.com/api/conversations.info?${params}`, { + headers, + }); + + return response.json(); + }); + + const results = await Promise.all(channelPromises); + const channels = results + .filter(data => data.ok && data.channel && !data.channel.is_archived) + .map(data => data.channel); + + return JSON.stringify({ + ok: true, + channels, + response_metadata: { next_cursor: '' }, + }); + }, + }); +} diff --git a/packages/ai-proxy/src/integrations/slack/tools/post-message.ts b/packages/ai-proxy/src/integrations/slack/tools/post-message.ts new file mode 100644 index 0000000000..9d4265f2a1 --- /dev/null +++ b/packages/ai-proxy/src/integrations/slack/tools/post-message.ts @@ -0,0 +1,27 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +export default function createPostMessageTool( + headers: Record, +): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: 'slack_post_message', + description: 'Post a new message to a Slack channel', + schema: z.object({ + channel_id: z.string().describe('The ID of the channel to post to'), + text: z.string().describe('The message text to post'), + }), + func: async ({ channel_id, text }) => { + const response = await fetch('https://slack.com/api/chat.postMessage', { + method: 'POST', + headers, + body: JSON.stringify({ + channel: channel_id, + text, + }), + }); + + return JSON.stringify(await response.json()); + }, + }); +} diff --git a/packages/ai-proxy/src/integrations/slack/tools/reply-to-thread.ts b/packages/ai-proxy/src/integrations/slack/tools/reply-to-thread.ts new file mode 100644 index 0000000000..9aa805ddea --- /dev/null +++ b/packages/ai-proxy/src/integrations/slack/tools/reply-to-thread.ts @@ -0,0 +1,35 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +export default function createReplyToThreadTool( + headers: Record, +): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: 'slack_reply_to_thread', + description: 'Reply to a specific message thread in Slack', + schema: z.object({ + channel_id: z.string().describe('The ID of the channel containing the thread'), + thread_ts: z + .string() + .describe( + "The timestamp of the parent message in the format '1234567890.123456'. " + + 'Timestamps in the format without the period can be converted by adding the period ' + + 'such that 6 numbers come after it.', + ), + text: z.string().describe('The reply text'), + }), + func: async ({ channel_id, thread_ts, text }) => { + const response = await fetch('https://slack.com/api/chat.postMessage', { + method: 'POST', + headers, + body: JSON.stringify({ + channel: channel_id, + thread_ts, + text, + }), + }); + + return JSON.stringify(await response.json()); + }, + }); +} diff --git a/packages/ai-proxy/src/integrations/tools.ts b/packages/ai-proxy/src/integrations/tools.ts new file mode 100644 index 0000000000..5d6d6d740f --- /dev/null +++ b/packages/ai-proxy/src/integrations/tools.ts @@ -0,0 +1,18 @@ +import type RemoteTool from '../remote-tool'; +import type { BraveConfig } from './brave/tools'; + +import getBraveTools from './brave/tools'; + +export interface IntegrationConfigs { + brave?: BraveConfig; +} + +export default function getIntegratedTools(configs: IntegrationConfigs): RemoteTool[] { + const integratedTools: RemoteTool[] = []; + + if (configs.brave) { + integratedTools.push(...getBraveTools(configs.brave)); + } + + return integratedTools; +} diff --git a/packages/ai-proxy/src/integrations/zendesk/tools.ts b/packages/ai-proxy/src/integrations/zendesk/tools.ts new file mode 100644 index 0000000000..361c8eedbb --- /dev/null +++ b/packages/ai-proxy/src/integrations/zendesk/tools.ts @@ -0,0 +1,38 @@ +import RemoteTool from '../../remote-tool'; +import createCreateTicketCommentTool from './tools/create-ticket-comment'; +import createCreateTicketTool from './tools/create-ticket'; +import createGetTicketCommentsTool from './tools/get-ticket-comments'; +import createGetTicketTool from './tools/get-ticket'; +import createGetTicketsTool from './tools/get-tickets'; +import createUpdateTicketTool from './tools/update-ticket'; + +export interface ZendeskConfig { + subdomain: string; + email: string; + apiToken: string; +} + +export default function getZendeskTools(config: ZendeskConfig): RemoteTool[] { + const baseUrl = `https://${config.subdomain}.zendesk.com/api/v2`; + const auth = Buffer.from(`${config.email}/token:${config.apiToken}`).toString('base64'); + const headers = { + Authorization: `Basic ${auth}`, + 'Content-Type': 'application/json', + }; + + return [ + createGetTicketsTool(headers, baseUrl), + createGetTicketTool(headers, baseUrl), + createGetTicketCommentsTool(headers, baseUrl), + createCreateTicketTool(headers, baseUrl), + createCreateTicketCommentTool(headers, baseUrl), + createUpdateTicketTool(headers, baseUrl), + ].map( + tool => + new RemoteTool({ + sourceId: 'zendesk', + sourceType: 'server', + tool, + }), + ); +} diff --git a/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket-comment.ts b/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket-comment.ts new file mode 100644 index 0000000000..fae108317c --- /dev/null +++ b/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket-comment.ts @@ -0,0 +1,39 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +export default function createCreateTicketCommentTool( + headers: Record, + 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), + }); + + return JSON.stringify(await response.json()); + }, + }); +} diff --git a/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket.ts b/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket.ts new file mode 100644 index 0000000000..d66016d136 --- /dev/null +++ b/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket.ts @@ -0,0 +1,58 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +export default function createCreateTicketTool( + headers: Record, + baseUrl: string, +): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: 'zendesk_create_ticket', + description: 'Create a new Zendesk ticket', + schema: z.object({ + subject: z.string().min(1).describe('The subject/title of the ticket'), + description: z.string().min(1).describe('The description/body of the ticket'), + requester_id: z.number().int().positive().optional().describe('The ID of the requester'), + assignee_id: z.number().int().positive().optional().describe('The ID of the assignee'), + priority: z + .enum(['low', 'normal', 'high', 'urgent']) + .optional() + .describe('The priority level of the ticket'), + type: z + .enum(['problem', 'incident', 'question', 'task']) + .optional() + .describe('The type of the ticket'), + tags: z.array(z.string()).optional().describe('Tags to apply to the ticket'), + custom_fields: z + .array( + z.object({ + id: z.number().describe('The custom field ID'), + value: z.unknown().describe('The custom field value'), + }), + ) + .optional() + .describe('Custom fields to set on the ticket'), + }), + func: async ({ subject, description, requester_id, assignee_id, priority, type, tags, custom_fields }) => { + const ticketData: Record = { + ticket: { + subject, + comment: { body: description }, + ...(requester_id && { requester_id }), + ...(assignee_id && { assignee_id }), + ...(priority && { priority }), + ...(type && { type }), + ...(tags && { tags }), + ...(custom_fields && { custom_fields }), + }, + }; + + const response = await fetch(`${baseUrl}/tickets.json`, { + method: 'POST', + headers, + body: JSON.stringify(ticketData), + }); + + return JSON.stringify(await response.json()); + }, + }); +} diff --git a/packages/ai-proxy/src/integrations/zendesk/tools/get-ticket-comments.ts b/packages/ai-proxy/src/integrations/zendesk/tools/get-ticket-comments.ts new file mode 100644 index 0000000000..4b1e76f454 --- /dev/null +++ b/packages/ai-proxy/src/integrations/zendesk/tools/get-ticket-comments.ts @@ -0,0 +1,22 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +export default function createGetTicketCommentsTool( + headers: Record, + baseUrl: string, +): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: 'zendesk_get_ticket_comments', + description: 'Get all comments for a specific Zendesk ticket', + schema: z.object({ + ticket_id: z.number().int().positive().describe('The ID of the ticket to get comments for'), + }), + func: async ({ ticket_id }) => { + const response = await fetch(`${baseUrl}/tickets/${ticket_id}/comments.json`, { + headers, + }); + + return JSON.stringify(await response.json()); + }, + }); +} diff --git a/packages/ai-proxy/src/integrations/zendesk/tools/get-ticket.ts b/packages/ai-proxy/src/integrations/zendesk/tools/get-ticket.ts new file mode 100644 index 0000000000..f25c60df83 --- /dev/null +++ b/packages/ai-proxy/src/integrations/zendesk/tools/get-ticket.ts @@ -0,0 +1,22 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +export default function createGetTicketTool( + headers: Record, + baseUrl: string, +): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: 'zendesk_get_ticket', + description: 'Retrieve a single Zendesk ticket by its ID', + schema: z.object({ + ticket_id: z.number().int().positive().describe('The ID of the ticket to retrieve'), + }), + func: async ({ ticket_id }) => { + const response = await fetch(`${baseUrl}/tickets/${ticket_id}.json`, { + headers, + }); + + return JSON.stringify(await response.json()); + }, + }); +} diff --git a/packages/ai-proxy/src/integrations/zendesk/tools/get-tickets.ts b/packages/ai-proxy/src/integrations/zendesk/tools/get-tickets.ts new file mode 100644 index 0000000000..92e592c7b1 --- /dev/null +++ b/packages/ai-proxy/src/integrations/zendesk/tools/get-tickets.ts @@ -0,0 +1,43 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +export default function createGetTicketsTool( + headers: Record, + baseUrl: string, +): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: 'zendesk_get_tickets', + description: 'Fetch a paginated list of Zendesk tickets with sorting options', + schema: z.object({ + page: z.number().int().positive().optional().default(1).describe('Page number for pagination'), + per_page: z + .number() + .int() + .positive() + .max(100) + .optional() + .default(25) + .describe('Number of tickets per page (max 100)'), + sort_by: z + .enum(['created_at', 'updated_at', 'priority', 'status']) + .optional() + .default('created_at') + .describe('Field to sort tickets by'), + sort_order: z.enum(['asc', 'desc']).optional().default('desc').describe('Sort order'), + }), + func: async ({ page, per_page, sort_by, sort_order }) => { + const params = new URLSearchParams({ + page: page.toString(), + per_page: per_page.toString(), + sort_by, + sort_order, + }); + + const response = await fetch(`${baseUrl}/tickets.json?${params}`, { + headers, + }); + + return JSON.stringify(await response.json()); + }, + }); +} diff --git a/packages/ai-proxy/src/integrations/zendesk/tools/update-ticket.ts b/packages/ai-proxy/src/integrations/zendesk/tools/update-ticket.ts new file mode 100644 index 0000000000..3721670656 --- /dev/null +++ b/packages/ai-proxy/src/integrations/zendesk/tools/update-ticket.ts @@ -0,0 +1,73 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +export default function createUpdateTicketTool( + headers: Record, + baseUrl: string, +): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: 'zendesk_update_ticket', + description: 'Update fields on an existing Zendesk ticket', + schema: z.object({ + ticket_id: z.number().int().positive().describe('The ID of the ticket to update'), + subject: z.string().min(1).optional().describe('New subject for the ticket'), + status: z + .enum(['new', 'open', 'pending', 'hold', 'solved', 'closed']) + .optional() + .describe('New status for the ticket'), + priority: z + .enum(['low', 'normal', 'high', 'urgent']) + .optional() + .describe('New priority for the ticket'), + type: z + .enum(['problem', 'incident', 'question', 'task']) + .optional() + .describe('New type for the ticket'), + assignee_id: z.number().int().positive().optional().describe('New assignee ID for the ticket'), + requester_id: z.number().int().positive().optional().describe('New requester ID for the ticket'), + tags: z.array(z.string()).optional().describe('New tags for the ticket (replaces existing tags)'), + custom_fields: z + .array( + z.object({ + id: z.number().describe('The custom field ID'), + value: z.unknown().describe('The custom field value'), + }), + ) + .optional() + .describe('Custom fields to update'), + due_at: z.string().optional().describe('Due date in ISO8601 format (for task tickets)'), + }), + func: async ({ + ticket_id, + subject, + status, + priority, + type, + assignee_id, + requester_id, + tags, + custom_fields, + due_at, + }) => { + const ticketUpdate: Record = {}; + + if (subject !== undefined) ticketUpdate.subject = subject; + if (status !== undefined) ticketUpdate.status = status; + if (priority !== undefined) ticketUpdate.priority = priority; + if (type !== undefined) ticketUpdate.type = type; + if (assignee_id !== undefined) ticketUpdate.assignee_id = assignee_id; + if (requester_id !== undefined) ticketUpdate.requester_id = requester_id; + if (tags !== undefined) ticketUpdate.tags = tags; + if (custom_fields !== undefined) ticketUpdate.custom_fields = custom_fields; + if (due_at !== undefined) ticketUpdate.due_at = due_at; + + const response = await fetch(`${baseUrl}/tickets/${ticket_id}.json`, { + method: 'PUT', + headers, + body: JSON.stringify({ ticket: ticketUpdate }), + }); + + return JSON.stringify(await response.json()); + }, + }); +} diff --git a/packages/ai-proxy/src/remote-tools.ts b/packages/ai-proxy/src/remote-tools.ts index 86c172850c..4f4afedc72 100644 --- a/packages/ai-proxy/src/remote-tools.ts +++ b/packages/ai-proxy/src/remote-tools.ts @@ -2,11 +2,10 @@ import type RemoteTool from './remote-tool'; import type { ResponseFormat } from '@langchain/core/tools'; import type { ChatCompletionCreateParamsNonStreaming } from 'openai/resources/chat/completions'; -import { BraveSearch } from '@langchain/community/tools/brave_search'; import { toJsonSchema } from '@langchain/core/utils/json_schema'; import { AIToolNotFoundError, AIToolUnprocessableError } from './errors'; -import ServerRemoteTool from './server-remote-tool'; +import getIntegratedTools from './integrations/tools'; export type Messages = ChatCompletionCreateParamsNonStreaming['messages']; @@ -20,16 +19,16 @@ export class RemoteTools { constructor(apiKeys: RemoteToolsApiKeys, tools?: RemoteTool[]) { this.apiKeys = apiKeys; - this.tools.push(...(tools ?? [])); + + const integrationConfigs: IntegrationConfigs = {}; if (this.apiKeys?.AI_REMOTE_TOOL_BRAVE_SEARCH_API_KEY) { - this.tools.push( - new ServerRemoteTool({ - sourceId: 'brave_search', - tool: new BraveSearch({ apiKey: this.apiKeys.AI_REMOTE_TOOL_BRAVE_SEARCH_API_KEY }), - }), - ); + integrationConfigs.brave = { + apiKey: this.apiKeys.AI_REMOTE_TOOL_BRAVE_SEARCH_API_KEY, + }; } + + this.tools.push(...(tools ?? []), ...getIntegratedTools(integrationConfigs)); } get toolDefinitionsForFrontend() { From b37d1e279d00f6710bfce69eac9807298f7e5310 Mon Sep 17 00:00:00 2001 From: Dogan AY Date: Thu, 15 Jan 2026 17:34:05 +0100 Subject: [PATCH 02/23] feat: add gmail tools --- .../src/examples/run-mcp-gmail-server.ts | 76 +++++++++++++ .../ai-proxy/src/integrations/gmail/tools.ts | 32 ++++++ .../integrations/gmail/tools/create-draft.ts | 50 +++++++++ .../integrations/gmail/tools/get-message.ts | 69 ++++++++++++ .../integrations/gmail/tools/get-thread.ts | 75 +++++++++++++ .../src/integrations/gmail/tools/search.ts | 102 ++++++++++++++++++ .../integrations/gmail/tools/send-message.ts | 48 +++++++++ packages/ai-proxy/src/integrations/tools.ts | 21 ++++ yarn.lock | 26 +++++ 9 files changed, 499 insertions(+) create mode 100644 packages/ai-proxy/src/examples/run-mcp-gmail-server.ts create mode 100644 packages/ai-proxy/src/integrations/gmail/tools.ts create mode 100644 packages/ai-proxy/src/integrations/gmail/tools/create-draft.ts create mode 100644 packages/ai-proxy/src/integrations/gmail/tools/get-message.ts create mode 100644 packages/ai-proxy/src/integrations/gmail/tools/get-thread.ts create mode 100644 packages/ai-proxy/src/integrations/gmail/tools/search.ts create mode 100644 packages/ai-proxy/src/integrations/gmail/tools/send-message.ts diff --git a/packages/ai-proxy/src/examples/run-mcp-gmail-server.ts b/packages/ai-proxy/src/examples/run-mcp-gmail-server.ts new file mode 100644 index 0000000000..90d7755273 --- /dev/null +++ b/packages/ai-proxy/src/examples/run-mcp-gmail-server.ts @@ -0,0 +1,76 @@ +/* eslint-disable import/no-extraneous-dependencies, import/extensions */ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +/* eslint-enable import/no-extraneous-dependencies, import/extensions */ + +import runMcpServer from './simple-mcp-server'; + +const server = new McpServer({ + name: 'gmail', + version: '1.0.0', +}); + +// Gmail API configuration +// To use this, you'll need to: +// 1. Enable Gmail API in Google Cloud Console +// 2. Create OAuth 2.0 credentials +// 3. Get an access token +const GMAIL_ACCESS_TOKEN = 'YOUR_GMAIL_ACCESS_TOKEN'; + +const headers = { + Authorization: `Bearer ${GMAIL_ACCESS_TOKEN}`, + 'Content-Type': 'application/json', +}; + +server.tool('gmail_search', { query: 'string', maxResults: 'number' }, async params => { + const url = new URL('https://gmail.googleapis.com/gmail/v1/users/me/messages'); + url.searchParams.set('q', params.query); + url.searchParams.set('maxResults', (params.maxResults || 10).toString()); + + const response = await fetch(url.toString(), { headers }); + const data = await response.json(); + + return { content: [{ type: 'text', text: JSON.stringify(data) }] }; +}); + +server.tool( + 'gmail_send_message', + { to: 'array', subject: 'string', message: 'string' }, + async params => { + const emailLines = [ + `To: ${params.to.join(', ')}`, + `Subject: ${params.subject}`, + '', + params.message, + ]; + + const email = emailLines.join('\r\n'); + const encodedEmail = Buffer.from(email) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + + const response = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/messages/send', { + method: 'POST', + headers, + body: JSON.stringify({ raw: encodedEmail }), + }); + + const data = await response.json(); + + return { content: [{ type: 'text', text: JSON.stringify(data) }] }; + }, +); + +server.tool('gmail_get_message', { messageId: 'string' }, async params => { + const response = await fetch( + `https://gmail.googleapis.com/gmail/v1/users/me/messages/${params.messageId}?format=full`, + { headers }, + ); + + const data = await response.json(); + + return { content: [{ type: 'text', text: JSON.stringify(data) }] }; +}); + +runMcpServer(server, 3125); diff --git a/packages/ai-proxy/src/integrations/gmail/tools.ts b/packages/ai-proxy/src/integrations/gmail/tools.ts new file mode 100644 index 0000000000..996c186ca6 --- /dev/null +++ b/packages/ai-proxy/src/integrations/gmail/tools.ts @@ -0,0 +1,32 @@ +import RemoteTool from '../../remote-tool'; +import createDraftTool from './tools/create-draft'; +import createGetMessageTool from './tools/get-message'; +import createGetThreadTool from './tools/get-thread'; +import createSearchTool from './tools/search'; +import createSendMessageTool from './tools/send-message'; + +export interface GmailConfig { + accessToken: string; +} + +export default function getGmailTools(config: GmailConfig): RemoteTool[] { + const headers = { + Authorization: `Bearer ${config.accessToken}`, + 'Content-Type': 'application/json', + }; + + return [ + createSendMessageTool(headers), + createGetMessageTool(headers), + createSearchTool(headers), + createDraftTool(headers), + createGetThreadTool(headers), + ].map( + tool => + new RemoteTool({ + sourceId: 'gmail', + sourceType: 'server', + tool, + }), + ); +} diff --git a/packages/ai-proxy/src/integrations/gmail/tools/create-draft.ts b/packages/ai-proxy/src/integrations/gmail/tools/create-draft.ts new file mode 100644 index 0000000000..6274e44c65 --- /dev/null +++ b/packages/ai-proxy/src/integrations/gmail/tools/create-draft.ts @@ -0,0 +1,50 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +export default function createDraftTool( + headers: Record, +): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: 'gmail_create_draft', + description: 'Create a draft email in Gmail', + schema: z.object({ + to: z.array(z.string()).describe('Array of recipient email addresses'), + subject: z.string().describe('Email subject line'), + message: z.string().describe('Email body content'), + cc: z.array(z.string()).optional().describe('Array of CC email addresses'), + bcc: z.array(z.string()).optional().describe('Array of BCC email addresses'), + }), + func: async ({ to, subject, message, cc, bcc }) => { + const emailLines = [ + `To: ${to.join(', ')}`, + ...(cc ? [`Cc: ${cc.join(', ')}`] : []), + ...(bcc ? [`Bcc: ${bcc.join(', ')}`] : []), + `Subject: ${subject}`, + '', + message, + ]; + + const email = emailLines.join('\r\n'); + const encodedEmail = Buffer.from(email) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + + const response = await fetch( + 'https://gmail.googleapis.com/gmail/v1/users/me/drafts', + { + method: 'POST', + headers, + body: JSON.stringify({ + message: { + raw: encodedEmail, + }, + }), + }, + ); + + return JSON.stringify(await response.json()); + }, + }); +} diff --git a/packages/ai-proxy/src/integrations/gmail/tools/get-message.ts b/packages/ai-proxy/src/integrations/gmail/tools/get-message.ts new file mode 100644 index 0000000000..ecf49d784e --- /dev/null +++ b/packages/ai-proxy/src/integrations/gmail/tools/get-message.ts @@ -0,0 +1,69 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +export default function createGetMessageTool( + headers: Record, +): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: 'gmail_get_message', + description: 'Get a specific email message by ID from Gmail', + schema: z.object({ + message_id: z.string().describe('The ID of the message to retrieve'), + }), + func: async ({ message_id }) => { + const response = await fetch( + `https://gmail.googleapis.com/gmail/v1/users/me/messages/${message_id}?format=full`, + { + method: 'GET', + headers, + }, + ); + + const data = await response.json(); + + if (!data.payload) { + return JSON.stringify({ error: 'Message not found or invalid payload' }); + } + + const getHeader = (name: string) => + data.payload.headers?.find( + (h: { name: string; value: string }) => h.name.toLowerCase() === name.toLowerCase(), + )?.value || ''; + + const decodeBody = (body: string) => { + if (!body) return ''; + try { + const sanitized = body.replace(/-/g, '+').replace(/_/g, '/'); + return Buffer.from(sanitized, 'base64').toString('utf-8'); + } catch { + return ''; + } + }; + + const getBody = (payload: any): string => { + if (payload.body?.data) { + return decodeBody(payload.body.data); + } + if (payload.parts) { + for (const part of payload.parts) { + const body = getBody(part); + if (body) return body; + } + } + return ''; + }; + + const result = { + id: data.id, + threadId: data.threadId, + subject: getHeader('Subject'), + from: getHeader('From'), + to: getHeader('To'), + date: getHeader('Date'), + body: getBody(data.payload), + }; + + return JSON.stringify(result); + }, + }); +} diff --git a/packages/ai-proxy/src/integrations/gmail/tools/get-thread.ts b/packages/ai-proxy/src/integrations/gmail/tools/get-thread.ts new file mode 100644 index 0000000000..f186be3d4d --- /dev/null +++ b/packages/ai-proxy/src/integrations/gmail/tools/get-thread.ts @@ -0,0 +1,75 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +export default function createGetThreadTool( + headers: Record, +): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: 'gmail_get_thread', + description: 'Get a complete email thread (conversation) by thread ID from Gmail', + schema: z.object({ + thread_id: z.string().describe('The ID of the thread to retrieve'), + }), + func: async ({ thread_id }) => { + const response = await fetch( + `https://gmail.googleapis.com/gmail/v1/users/me/threads/${thread_id}?format=full`, + { + method: 'GET', + headers, + }, + ); + + const data = await response.json(); + + if (!data.messages) { + return JSON.stringify({ error: 'Thread not found or no messages' }); + } + + const decodeBody = (body: string) => { + if (!body) return ''; + try { + const sanitized = body.replace(/-/g, '+').replace(/_/g, '/'); + return Buffer.from(sanitized, 'base64').toString('utf-8'); + } catch { + return ''; + } + }; + + const getBody = (payload: any): string => { + if (payload.body?.data) { + return decodeBody(payload.body.data); + } + if (payload.parts) { + for (const part of payload.parts) { + const body = getBody(part); + if (body) return body; + } + } + return ''; + }; + + const messages = data.messages.map((msg: any) => { + const getHeader = (name: string) => + msg.payload?.headers?.find( + (h: { name: string; value: string }) => + h.name.toLowerCase() === name.toLowerCase(), + )?.value || ''; + + return { + id: msg.id, + subject: getHeader('Subject'), + from: getHeader('From'), + to: getHeader('To'), + date: getHeader('Date'), + body: getBody(msg.payload), + }; + }); + + return JSON.stringify({ + threadId: data.id, + messageCount: messages.length, + messages, + }); + }, + }); +} diff --git a/packages/ai-proxy/src/integrations/gmail/tools/search.ts b/packages/ai-proxy/src/integrations/gmail/tools/search.ts new file mode 100644 index 0000000000..b0e801bf2a --- /dev/null +++ b/packages/ai-proxy/src/integrations/gmail/tools/search.ts @@ -0,0 +1,102 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +export default function createSearchTool( + headers: Record, +): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: 'gmail_search', + description: + 'Search for Gmail messages or threads using Gmail search query syntax. Returns message/thread IDs that can be used with other tools.', + schema: z.object({ + query: z + .string() + .describe( + 'Gmail search query (e.g., "from:user@example.com", "subject:meeting", "is:unread")', + ), + max_results: z.number().optional().default(10).describe('Maximum number of results (default: 10)'), + resource: z + .enum(['messages', 'threads']) + .optional() + .default('messages') + .describe('Type of resource to search for (messages or threads)'), + }), + func: async ({ query, max_results = 10, resource = 'messages' }) => { + const url = new URL(`https://gmail.googleapis.com/gmail/v1/users/me/${resource}`); + url.searchParams.set('q', query); + url.searchParams.set('maxResults', max_results.toString()); + + const response = await fetch(url.toString(), { + method: 'GET', + headers, + }); + + const data = await response.json(); + + if (resource === 'messages') { + const messages = data.messages || []; + if (messages.length === 0) { + return JSON.stringify({ results: [], count: 0 }); + } + + const detailedMessages = await Promise.all( + messages.map(async (msg: { id: string }) => { + const msgResponse = await fetch( + `https://gmail.googleapis.com/gmail/v1/users/me/messages/${msg.id}?format=full`, + { + method: 'GET', + headers, + }, + ); + const msgData = await msgResponse.json(); + + const getHeader = (name: string) => + msgData.payload?.headers?.find( + (h: { name: string; value: string }) => + h.name.toLowerCase() === name.toLowerCase(), + )?.value || ''; + + const decodeBody = (body: string) => { + if (!body) return ''; + try { + const sanitized = body.replace(/-/g, '+').replace(/_/g, '/'); + return Buffer.from(sanitized, 'base64').toString('utf-8'); + } catch { + return ''; + } + }; + + const getBody = (payload: any): string => { + if (payload.body?.data) { + return decodeBody(payload.body.data); + } + if (payload.parts) { + for (const part of payload.parts) { + const body = getBody(part); + if (body) return body; + } + } + return ''; + }; + + return { + id: msgData.id, + threadId: msgData.threadId, + subject: getHeader('Subject'), + from: getHeader('From'), + to: getHeader('To'), + date: getHeader('Date'), + snippet: msgData.snippet, + body: getBody(msgData.payload)?.substring(0, 500), + }; + }), + ); + + return JSON.stringify({ results: detailedMessages, count: detailedMessages.length }); + } + + const threads = data.threads || []; + return JSON.stringify({ results: threads, count: threads.length }); + }, + }); +} diff --git a/packages/ai-proxy/src/integrations/gmail/tools/send-message.ts b/packages/ai-proxy/src/integrations/gmail/tools/send-message.ts new file mode 100644 index 0000000000..2667817937 --- /dev/null +++ b/packages/ai-proxy/src/integrations/gmail/tools/send-message.ts @@ -0,0 +1,48 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +export default function createSendMessageTool( + headers: Record, +): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: 'gmail_send_message', + description: 'Send an email message via Gmail', + schema: z.object({ + to: z.array(z.string()).describe('Array of recipient email addresses'), + subject: z.string().describe('Email subject line'), + message: z.string().describe('Email body content'), + cc: z.array(z.string()).optional().describe('Array of CC email addresses'), + bcc: z.array(z.string()).optional().describe('Array of BCC email addresses'), + }), + func: async ({ to, subject, message, cc, bcc }) => { + const emailLines = [ + `To: ${to.join(', ')}`, + ...(cc ? [`Cc: ${cc.join(', ')}`] : []), + ...(bcc ? [`Bcc: ${bcc.join(', ')}`] : []), + `Subject: ${subject}`, + '', + message, + ]; + + const email = emailLines.join('\r\n'); + const encodedEmail = Buffer.from(email) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + + const response = await fetch( + 'https://gmail.googleapis.com/gmail/v1/users/me/messages/send', + { + method: 'POST', + headers, + body: JSON.stringify({ + raw: encodedEmail, + }), + }, + ); + + return JSON.stringify(await response.json()); + }, + }); +} diff --git a/packages/ai-proxy/src/integrations/tools.ts b/packages/ai-proxy/src/integrations/tools.ts index 5d6d6d740f..21a7a1a1b7 100644 --- a/packages/ai-proxy/src/integrations/tools.ts +++ b/packages/ai-proxy/src/integrations/tools.ts @@ -1,10 +1,19 @@ import type RemoteTool from '../remote-tool'; import type { BraveConfig } from './brave/tools'; +import type { GmailConfig } from './gmail/tools'; +import type { SlackConfig } from './slack/tools'; +import type { ZendeskConfig } from './zendesk/tools'; import getBraveTools from './brave/tools'; +import getGmailTools from './gmail/tools'; +import getSlackTools from './slack/tools'; +import getZendeskTools from './zendesk/tools'; export interface IntegrationConfigs { brave?: BraveConfig; + gmail?: GmailConfig; + slack?: SlackConfig; + zendesk?: ZendeskConfig; } export default function getIntegratedTools(configs: IntegrationConfigs): RemoteTool[] { @@ -14,5 +23,17 @@ export default function getIntegratedTools(configs: IntegrationConfigs): RemoteT integratedTools.push(...getBraveTools(configs.brave)); } + if (configs.gmail) { + integratedTools.push(...getGmailTools(configs.gmail)); + } + + if (configs.slack) { + integratedTools.push(...getSlackTools(configs.slack)); + } + + if (configs.zendesk) { + integratedTools.push(...getZendeskTools(configs.zendesk)); + } + return integratedTools; } diff --git a/yarn.lock b/yarn.lock index 023ddb64f2..e051460213 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1745,6 +1745,32 @@ object-hash "^3.0.0" uuid "^9.0.0" +"@forestadmin/forestadmin-client@1.37.3": + version "1.37.3" + resolved "https://registry.yarnpkg.com/@forestadmin/forestadmin-client/-/forestadmin-client-1.37.3.tgz#5d9c94ed31cbb5ace99f46c62810ebc4fceae291" + integrity sha512-NIsuUZOv8UuC/8sOCwxO9X+slvkouRO56ot7CXeICBc47SZ9+iFXcC64guxFU/pCFyGvxkFpvjBUlvlEgwYBrw== + dependencies: + eventsource "2.0.2" + json-api-serializer "^2.6.6" + jsonwebtoken "^9.0.0" + object-hash "^3.0.0" + openid-client "^5.7.1" + superagent "^10.2.3" + +"@forestadmin/mcp-server@1.5.5": + version "1.5.5" + resolved "https://registry.yarnpkg.com/@forestadmin/mcp-server/-/mcp-server-1.5.5.tgz#f1d60154fae130b4d0454739b03a86cdbccc5bf5" + integrity sha512-yNGpVoh1g9z7qPgBV+Z5bIeg4RlhkQejEZNrOzLrpP3nseOroG7abozfGAL3R5sgvOwikOzMHOaqdw9B/wrMqw== + dependencies: + "@forestadmin/agent-client" "1.2.3" + "@forestadmin/forestadmin-client" "1.37.3" + "@modelcontextprotocol/sdk" "^1.25.1" + cors "^2.8.5" + express "^5.2.1" + jsonapi-serializer "^3.6.9" + jsonwebtoken "^9.0.3" + zod "^4.2.1" + "@gar/promisify@^1.0.1": version "1.1.3" resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6" From 0aadf8067d22dd60266acec7ff6710ec8baea2d6 Mon Sep 17 00:00:00 2001 From: Enki Pontvianne Date: Mon, 19 Jan 2026 11:30:44 +0100 Subject: [PATCH 03/23] refactor: refactor duplicated methods --- .../integrations/gmail/tools/create-draft.ts | 43 ++++------- .../integrations/gmail/tools/get-message.ts | 38 ++-------- .../integrations/gmail/tools/get-thread.ts | 39 ++-------- .../src/integrations/gmail/tools/search.ts | 51 ++++--------- .../integrations/gmail/tools/send-message.ts | 35 +++------ .../src/integrations/gmail/tools/utils.ts | 76 +++++++++++++++++++ 6 files changed, 125 insertions(+), 157 deletions(-) create mode 100644 packages/ai-proxy/src/integrations/gmail/tools/utils.ts diff --git a/packages/ai-proxy/src/integrations/gmail/tools/create-draft.ts b/packages/ai-proxy/src/integrations/gmail/tools/create-draft.ts index 6274e44c65..6a1282a7c9 100644 --- a/packages/ai-proxy/src/integrations/gmail/tools/create-draft.ts +++ b/packages/ai-proxy/src/integrations/gmail/tools/create-draft.ts @@ -1,9 +1,9 @@ import { DynamicStructuredTool } from '@langchain/core/tools'; import { z } from 'zod'; -export default function createDraftTool( - headers: Record, -): DynamicStructuredTool { +import { encodeEmail } from './utils'; + +export default function createDraftTool(headers: Record): DynamicStructuredTool { return new DynamicStructuredTool({ name: 'gmail_create_draft', description: 'Create a draft email in Gmail', @@ -15,34 +15,17 @@ export default function createDraftTool( bcc: z.array(z.string()).optional().describe('Array of BCC email addresses'), }), func: async ({ to, subject, message, cc, bcc }) => { - const emailLines = [ - `To: ${to.join(', ')}`, - ...(cc ? [`Cc: ${cc.join(', ')}`] : []), - ...(bcc ? [`Bcc: ${bcc.join(', ')}`] : []), - `Subject: ${subject}`, - '', - message, - ]; - - const email = emailLines.join('\r\n'); - const encodedEmail = Buffer.from(email) - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); + const encodedEmail = encodeEmail({ to, subject, message, cc, bcc }); - const response = await fetch( - 'https://gmail.googleapis.com/gmail/v1/users/me/drafts', - { - method: 'POST', - headers, - body: JSON.stringify({ - message: { - raw: encodedEmail, - }, - }), - }, - ); + const response = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/drafts', { + method: 'POST', + headers, + body: JSON.stringify({ + message: { + raw: encodedEmail, + }, + }), + }); return JSON.stringify(await response.json()); }, diff --git a/packages/ai-proxy/src/integrations/gmail/tools/get-message.ts b/packages/ai-proxy/src/integrations/gmail/tools/get-message.ts index ecf49d784e..c0c79500ce 100644 --- a/packages/ai-proxy/src/integrations/gmail/tools/get-message.ts +++ b/packages/ai-proxy/src/integrations/gmail/tools/get-message.ts @@ -1,6 +1,8 @@ import { DynamicStructuredTool } from '@langchain/core/tools'; import { z } from 'zod'; +import { getBody, getHeader } from './utils'; + export default function createGetMessageTool( headers: Record, ): DynamicStructuredTool { @@ -25,41 +27,13 @@ export default function createGetMessageTool( return JSON.stringify({ error: 'Message not found or invalid payload' }); } - const getHeader = (name: string) => - data.payload.headers?.find( - (h: { name: string; value: string }) => h.name.toLowerCase() === name.toLowerCase(), - )?.value || ''; - - const decodeBody = (body: string) => { - if (!body) return ''; - try { - const sanitized = body.replace(/-/g, '+').replace(/_/g, '/'); - return Buffer.from(sanitized, 'base64').toString('utf-8'); - } catch { - return ''; - } - }; - - const getBody = (payload: any): string => { - if (payload.body?.data) { - return decodeBody(payload.body.data); - } - if (payload.parts) { - for (const part of payload.parts) { - const body = getBody(part); - if (body) return body; - } - } - return ''; - }; - const result = { id: data.id, threadId: data.threadId, - subject: getHeader('Subject'), - from: getHeader('From'), - to: getHeader('To'), - date: getHeader('Date'), + subject: getHeader(data.payload, 'Subject'), + from: getHeader(data.payload, 'From'), + to: getHeader(data.payload, 'To'), + date: getHeader(data.payload, 'Date'), body: getBody(data.payload), }; diff --git a/packages/ai-proxy/src/integrations/gmail/tools/get-thread.ts b/packages/ai-proxy/src/integrations/gmail/tools/get-thread.ts index f186be3d4d..0ec93a3209 100644 --- a/packages/ai-proxy/src/integrations/gmail/tools/get-thread.ts +++ b/packages/ai-proxy/src/integrations/gmail/tools/get-thread.ts @@ -1,6 +1,8 @@ import { DynamicStructuredTool } from '@langchain/core/tools'; import { z } from 'zod'; +import { getBody, getHeader } from './utils'; + export default function createGetThreadTool( headers: Record, ): DynamicStructuredTool { @@ -25,42 +27,13 @@ export default function createGetThreadTool( return JSON.stringify({ error: 'Thread not found or no messages' }); } - const decodeBody = (body: string) => { - if (!body) return ''; - try { - const sanitized = body.replace(/-/g, '+').replace(/_/g, '/'); - return Buffer.from(sanitized, 'base64').toString('utf-8'); - } catch { - return ''; - } - }; - - const getBody = (payload: any): string => { - if (payload.body?.data) { - return decodeBody(payload.body.data); - } - if (payload.parts) { - for (const part of payload.parts) { - const body = getBody(part); - if (body) return body; - } - } - return ''; - }; - const messages = data.messages.map((msg: any) => { - const getHeader = (name: string) => - msg.payload?.headers?.find( - (h: { name: string; value: string }) => - h.name.toLowerCase() === name.toLowerCase(), - )?.value || ''; - return { id: msg.id, - subject: getHeader('Subject'), - from: getHeader('From'), - to: getHeader('To'), - date: getHeader('Date'), + subject: getHeader(msg.payload, 'Subject'), + from: getHeader(msg.payload, 'From'), + to: getHeader(msg.payload, 'To'), + date: getHeader(msg.payload, 'Date'), body: getBody(msg.payload), }; }); diff --git a/packages/ai-proxy/src/integrations/gmail/tools/search.ts b/packages/ai-proxy/src/integrations/gmail/tools/search.ts index b0e801bf2a..f99fb4e180 100644 --- a/packages/ai-proxy/src/integrations/gmail/tools/search.ts +++ b/packages/ai-proxy/src/integrations/gmail/tools/search.ts @@ -1,9 +1,9 @@ import { DynamicStructuredTool } from '@langchain/core/tools'; import { z } from 'zod'; -export default function createSearchTool( - headers: Record, -): DynamicStructuredTool { +import { getBody, getHeader } from './utils'; + +export default function createSearchTool(headers: Record): DynamicStructuredTool { return new DynamicStructuredTool({ name: 'gmail_search', description: @@ -14,7 +14,11 @@ export default function createSearchTool( .describe( 'Gmail search query (e.g., "from:user@example.com", "subject:meeting", "is:unread")', ), - max_results: z.number().optional().default(10).describe('Maximum number of results (default: 10)'), + max_results: z + .number() + .optional() + .default(10) + .describe('Maximum number of results (default: 10)'), resource: z .enum(['messages', 'threads']) .optional() @@ -35,6 +39,7 @@ export default function createSearchTool( if (resource === 'messages') { const messages = data.messages || []; + if (messages.length === 0) { return JSON.stringify({ results: [], count: 0 }); } @@ -50,42 +55,13 @@ export default function createSearchTool( ); const msgData = await msgResponse.json(); - const getHeader = (name: string) => - msgData.payload?.headers?.find( - (h: { name: string; value: string }) => - h.name.toLowerCase() === name.toLowerCase(), - )?.value || ''; - - const decodeBody = (body: string) => { - if (!body) return ''; - try { - const sanitized = body.replace(/-/g, '+').replace(/_/g, '/'); - return Buffer.from(sanitized, 'base64').toString('utf-8'); - } catch { - return ''; - } - }; - - const getBody = (payload: any): string => { - if (payload.body?.data) { - return decodeBody(payload.body.data); - } - if (payload.parts) { - for (const part of payload.parts) { - const body = getBody(part); - if (body) return body; - } - } - return ''; - }; - return { id: msgData.id, threadId: msgData.threadId, - subject: getHeader('Subject'), - from: getHeader('From'), - to: getHeader('To'), - date: getHeader('Date'), + subject: getHeader(msgData.payload, 'Subject'), + from: getHeader(msgData.payload, 'From'), + to: getHeader(msgData.payload, 'To'), + date: getHeader(msgData.payload, 'Date'), snippet: msgData.snippet, body: getBody(msgData.payload)?.substring(0, 500), }; @@ -96,6 +72,7 @@ export default function createSearchTool( } const threads = data.threads || []; + return JSON.stringify({ results: threads, count: threads.length }); }, }); diff --git a/packages/ai-proxy/src/integrations/gmail/tools/send-message.ts b/packages/ai-proxy/src/integrations/gmail/tools/send-message.ts index 2667817937..e6d72c049b 100644 --- a/packages/ai-proxy/src/integrations/gmail/tools/send-message.ts +++ b/packages/ai-proxy/src/integrations/gmail/tools/send-message.ts @@ -1,6 +1,8 @@ import { DynamicStructuredTool } from '@langchain/core/tools'; import { z } from 'zod'; +import { encodeEmail } from './utils'; + export default function createSendMessageTool( headers: Record, ): DynamicStructuredTool { @@ -15,32 +17,15 @@ export default function createSendMessageTool( bcc: z.array(z.string()).optional().describe('Array of BCC email addresses'), }), func: async ({ to, subject, message, cc, bcc }) => { - const emailLines = [ - `To: ${to.join(', ')}`, - ...(cc ? [`Cc: ${cc.join(', ')}`] : []), - ...(bcc ? [`Bcc: ${bcc.join(', ')}`] : []), - `Subject: ${subject}`, - '', - message, - ]; - - const email = emailLines.join('\r\n'); - const encodedEmail = Buffer.from(email) - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); + const encodedEmail = encodeEmail({ to, subject, message, cc, bcc }); - const response = await fetch( - 'https://gmail.googleapis.com/gmail/v1/users/me/messages/send', - { - method: 'POST', - headers, - body: JSON.stringify({ - raw: encodedEmail, - }), - }, - ); + const response = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/messages/send', { + method: 'POST', + headers, + body: JSON.stringify({ + raw: encodedEmail, + }), + }); return JSON.stringify(await response.json()); }, diff --git a/packages/ai-proxy/src/integrations/gmail/tools/utils.ts b/packages/ai-proxy/src/integrations/gmail/tools/utils.ts new file mode 100644 index 0000000000..830393eafc --- /dev/null +++ b/packages/ai-proxy/src/integrations/gmail/tools/utils.ts @@ -0,0 +1,76 @@ +/** + * Extract a header value from Gmail message payload + */ +export function getHeader( + payload: { headers?: Array<{ name: string; value: string }> }, + name: string, +): string { + return ( + payload.headers?.find( + (h: { name: string; value: string }) => h.name.toLowerCase() === name.toLowerCase(), + )?.value || '' + ); +} + +/** + * Decode base64url-encoded body data from Gmail API + */ +function decodeBody(body: string): string { + if (!body) return ''; + + try { + const sanitized = body.replace(/-/g, '+').replace(/_/g, '/'); + + return Buffer.from(sanitized, 'base64').toString('utf-8'); + } catch { + return ''; + } +} + +/** + * Extract body content from Gmail message payload (recursively searches parts) + */ +export function getBody(payload: any): string { + if (payload.body?.data) { + return decodeBody(payload.body.data); + } + + if (payload.parts) { + for (const part of payload.parts) { + const body = getBody(part); + if (body) return body; + } + } + + return ''; +} + +/** + * Encode email message for Gmail API (base64url encoding) + */ +export function encodeEmail(params: { + to: string[]; + subject: string; + message: string; + cc?: string[]; + bcc?: string[]; +}): string { + const { to, subject, message, cc, bcc } = params; + + const emailLines = [ + `To: ${to.join(', ')}`, + ...(cc ? [`Cc: ${cc.join(', ')}`] : []), + ...(bcc ? [`Bcc: ${bcc.join(', ')}`] : []), + `Subject: ${subject}`, + '', + message, + ]; + + const email = emailLines.join('\r\n'); + + return Buffer.from(email) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} From 6053615718b4f67ab47b0a6b0c996caee7c84fae Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Thu, 29 Jan 2026 16:58:13 +0100 Subject: [PATCH 04/23] feat: update route and create integration client --- .../src/examples/run-mcp-gmail-server.ts | 25 +- packages/ai-proxy/src/index.ts | 3 + packages/ai-proxy/src/integration-client.ts | 44 ++ .../ai-proxy/src/integrations/brave/tools.ts | 7 +- .../ai-proxy/src/integrations/gmail/tools.ts | 7 +- .../ai-proxy/src/integrations/slack/tools.ts | 7 +- packages/ai-proxy/src/integrations/tools.ts | 2 +- .../src/integrations/zendesk/tools.ts | 11 +- packages/ai-proxy/src/router.ts | 21 +- yarn.lock | 654 +++++++++++------- 10 files changed, 509 insertions(+), 272 deletions(-) create mode 100644 packages/ai-proxy/src/integration-client.ts diff --git a/packages/ai-proxy/src/examples/run-mcp-gmail-server.ts b/packages/ai-proxy/src/examples/run-mcp-gmail-server.ts index 90d7755273..b99defd189 100644 --- a/packages/ai-proxy/src/examples/run-mcp-gmail-server.ts +++ b/packages/ai-proxy/src/examples/run-mcp-gmail-server.ts @@ -1,6 +1,7 @@ /* eslint-disable import/no-extraneous-dependencies, import/extensions */ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; /* eslint-enable import/no-extraneous-dependencies, import/extensions */ +import z from 'zod'; import runMcpServer from './simple-mcp-server'; @@ -21,20 +22,24 @@ const headers = { 'Content-Type': 'application/json', }; -server.tool('gmail_search', { query: 'string', maxResults: 'number' }, async params => { - const url = new URL('https://gmail.googleapis.com/gmail/v1/users/me/messages'); - url.searchParams.set('q', params.query); - url.searchParams.set('maxResults', (params.maxResults || 10).toString()); +server.tool( + 'gmail_search', + { query: z.string(), maxResults: z.number().optional() }, + async params => { + const url = new URL('https://gmail.googleapis.com/gmail/v1/users/me/messages'); + url.searchParams.set('q', params.query); + url.searchParams.set('maxResults', (params.maxResults || 10).toString()); - const response = await fetch(url.toString(), { headers }); - const data = await response.json(); + const response = await fetch(url.toString(), { headers }); + const data = await response.json(); - return { content: [{ type: 'text', text: JSON.stringify(data) }] }; -}); + return { content: [{ type: 'text', text: JSON.stringify(data) }] }; + }, +); server.tool( 'gmail_send_message', - { to: 'array', subject: 'string', message: 'string' }, + { to: z.array(z.string()), subject: z.string(), message: z.string() }, async params => { const emailLines = [ `To: ${params.to.join(', ')}`, @@ -62,7 +67,7 @@ server.tool( }, ); -server.tool('gmail_get_message', { messageId: 'string' }, async params => { +server.tool('gmail_get_message', { messageId: z.string() }, async params => { const response = await fetch( `https://gmail.googleapis.com/gmail/v1/users/me/messages/${params.messageId}?format=full`, { headers }, diff --git a/packages/ai-proxy/src/index.ts b/packages/ai-proxy/src/index.ts index 5dd913d5d4..81ed7cccae 100644 --- a/packages/ai-proxy/src/index.ts +++ b/packages/ai-proxy/src/index.ts @@ -4,6 +4,9 @@ import McpConfigChecker from './mcp-config-checker'; export { createAiProvider } from './create-ai-provider'; export { default as ProviderDispatcher } from './provider-dispatcher'; + +export { ForestIntegrationConfig } from './integration-client'; + export * from './provider-dispatcher'; export * from './remote-tools'; export * from './router'; diff --git a/packages/ai-proxy/src/integration-client.ts b/packages/ai-proxy/src/integration-client.ts new file mode 100644 index 0000000000..9fb79a2a39 --- /dev/null +++ b/packages/ai-proxy/src/integration-client.ts @@ -0,0 +1,44 @@ +import type McpServerRemoteTool from './types/mcp-server-remote-tool'; +import type { Logger } from '@forestadmin/datasource-toolkit'; +import type { MultiServerMCPClient } from '@langchain/mcp-adapters'; + +import getSlackTools, { type SlackConfig } from './integrations/slack/tools'; +import getZendeskTools, { type ZendeskConfig } from './integrations/zendesk/tools'; + +export type McpConfiguration = { + configs: MultiServerMCPClient['config']['mcpServers']; +} & Omit; + +export interface ForestIntegrationConfig { + integrationName: 'slack' | 'zendesk'; + config: SlackConfig | ZendeskConfig; +} + +export default class IntegrationClient { + private readonly logger?: Logger; + + readonly tools: McpServerRemoteTool[] = []; + readonly configs: ForestIntegrationConfig[]; + + constructor(configs: ForestIntegrationConfig[], logger?: Logger) { + this.logger = logger; + this.configs = configs; + } + + loadTools(): McpServerRemoteTool[] { + this.configs.forEach(({ integrationName, config }) => { + switch (integrationName) { + case 'slack': + this.tools.push(...getSlackTools(config as SlackConfig)); + break; + case 'zendesk': + this.tools.push(...getZendeskTools(config as ZendeskConfig)); + break; + default: + this.logger?.('Warn', `Unsupported integration: ${integrationName}`); + } + }); + + return this.tools; + } +} diff --git a/packages/ai-proxy/src/integrations/brave/tools.ts b/packages/ai-proxy/src/integrations/brave/tools.ts index f04fa62a16..3eed7753dc 100644 --- a/packages/ai-proxy/src/integrations/brave/tools.ts +++ b/packages/ai-proxy/src/integrations/brave/tools.ts @@ -1,6 +1,8 @@ +import type RemoteTool from '../../types/remote-tool'; + import { BraveSearch } from '@langchain/community/tools/brave_search'; -import RemoteTool from '../../remote-tool'; +import ServerRemoteTool from '../../types/server-remote-tool'; export interface BraveConfig { apiKey: string; @@ -8,9 +10,8 @@ export interface BraveConfig { export default function getBraveTools(config: BraveConfig): RemoteTool[] { return [ - new RemoteTool({ + new ServerRemoteTool({ sourceId: 'brave_search', - sourceType: 'server', tool: new BraveSearch({ apiKey: config.apiKey }), }), ]; diff --git a/packages/ai-proxy/src/integrations/gmail/tools.ts b/packages/ai-proxy/src/integrations/gmail/tools.ts index 996c186ca6..98d0dbb3f3 100644 --- a/packages/ai-proxy/src/integrations/gmail/tools.ts +++ b/packages/ai-proxy/src/integrations/gmail/tools.ts @@ -1,9 +1,11 @@ -import RemoteTool from '../../remote-tool'; +import type RemoteTool from '../../types/remote-tool'; + import createDraftTool from './tools/create-draft'; import createGetMessageTool from './tools/get-message'; import createGetThreadTool from './tools/get-thread'; import createSearchTool from './tools/search'; import createSendMessageTool from './tools/send-message'; +import ServerRemoteTool from '../../types/server-remote-tool'; export interface GmailConfig { accessToken: string; @@ -23,9 +25,8 @@ export default function getGmailTools(config: GmailConfig): RemoteTool[] { createGetThreadTool(headers), ].map( tool => - new RemoteTool({ + new ServerRemoteTool({ sourceId: 'gmail', - sourceType: 'server', tool, }), ); diff --git a/packages/ai-proxy/src/integrations/slack/tools.ts b/packages/ai-proxy/src/integrations/slack/tools.ts index 44950ee509..9574610f46 100644 --- a/packages/ai-proxy/src/integrations/slack/tools.ts +++ b/packages/ai-proxy/src/integrations/slack/tools.ts @@ -1,4 +1,5 @@ -import RemoteTool from '../../remote-tool'; +import type RemoteTool from '../../types/remote-tool'; + import createAddReactionTool from './tools/add-reaction'; import createGetChannelHistoryTool from './tools/get-channel-history'; import createGetThreadRepliesTool from './tools/get-thread-replies'; @@ -7,6 +8,7 @@ import createGetUsersTool from './tools/get-users'; import createListChannelsTool from './tools/list-channels'; import createPostMessageTool from './tools/post-message'; import createReplyToThreadTool from './tools/reply-to-thread'; +import ServerRemoteTool from '../../types/server-remote-tool'; export interface SlackConfig { authToken: string; @@ -31,9 +33,8 @@ export default function getSlackTools(config: SlackConfig): RemoteTool[] { createGetUserProfileTool(headers), ].map( tool => - new RemoteTool({ + new ServerRemoteTool({ sourceId: 'slack', - sourceType: 'server', tool, }), ); diff --git a/packages/ai-proxy/src/integrations/tools.ts b/packages/ai-proxy/src/integrations/tools.ts index 21a7a1a1b7..320a58562c 100644 --- a/packages/ai-proxy/src/integrations/tools.ts +++ b/packages/ai-proxy/src/integrations/tools.ts @@ -1,4 +1,4 @@ -import type RemoteTool from '../remote-tool'; +import type RemoteTool from '../types/remote-tool'; import type { BraveConfig } from './brave/tools'; import type { GmailConfig } from './gmail/tools'; import type { SlackConfig } from './slack/tools'; diff --git a/packages/ai-proxy/src/integrations/zendesk/tools.ts b/packages/ai-proxy/src/integrations/zendesk/tools.ts index 361c8eedbb..40a5bab450 100644 --- a/packages/ai-proxy/src/integrations/zendesk/tools.ts +++ b/packages/ai-proxy/src/integrations/zendesk/tools.ts @@ -1,10 +1,12 @@ -import RemoteTool from '../../remote-tool'; -import createCreateTicketCommentTool from './tools/create-ticket-comment'; +import type RemoteTool from '../../types/remote-tool'; + import createCreateTicketTool from './tools/create-ticket'; -import createGetTicketCommentsTool from './tools/get-ticket-comments'; +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 ServerRemoteTool from '../../types/server-remote-tool'; export interface ZendeskConfig { subdomain: string; @@ -29,9 +31,8 @@ export default function getZendeskTools(config: ZendeskConfig): RemoteTool[] { createUpdateTicketTool(headers, baseUrl), ].map( tool => - new RemoteTool({ + new ServerRemoteTool({ sourceId: 'zendesk', - sourceType: 'server', tool, }), ); diff --git a/packages/ai-proxy/src/router.ts b/packages/ai-proxy/src/router.ts index 8424a35bcb..549f71d24c 100644 --- a/packages/ai-proxy/src/router.ts +++ b/packages/ai-proxy/src/router.ts @@ -6,6 +6,7 @@ import type { Logger } from '@forestadmin/datasource-toolkit'; import type { z } from 'zod'; import { AIBadRequestError, AIModelNotSupportedError } from './errors'; +import IntegrationClient, { type ForestIntegrationConfig } from './integration-client'; import McpClient from './mcp-client'; import ProviderDispatcher from './provider-dispatcher'; import { RemoteTools } from './remote-tools'; @@ -59,7 +60,12 @@ export class Router { * - invoke-remote-tool: Execute a remote tool by name with the provided inputs * - remote-tools: Return the list of available remote tools definitions */ - async route(args: RouteArgs & { mcpConfigs?: McpConfiguration }) { + async route( + args: RouteArgs & { + mcpConfigs?: McpConfiguration; + integrationConfigs?: ForestIntegrationConfig[]; + }, + ) { // Validate input with Zod schema const result = routeArgsSchema.safeParse(args); @@ -69,16 +75,21 @@ export class Router { const validatedArgs = result.data; let mcpClient: McpClient | undefined; + let integrationClient: IntegrationClient | undefined; try { if (args.mcpConfigs) { mcpClient = new McpClient(args.mcpConfigs, this.logger); } - const remoteTools = new RemoteTools( - this.localToolsApiKeys ?? {}, - await mcpClient?.loadTools(), - ); + if (args.integrationConfigs) { + integrationClient = new IntegrationClient(args.integrationConfigs, this.logger); + } + + const remoteTools = new RemoteTools(this.localToolsApiKeys ?? {}, [ + ...((await mcpClient?.loadTools()) ?? []), + ...(integrationClient?.loadTools() ?? []), + ]); switch (validatedArgs.route) { case 'ai-query': { diff --git a/yarn.lock b/yarn.lock index e051460213..614a6d2e95 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8,12 +8,12 @@ integrity sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA== "@actions/core@^2.0.0": - version "2.0.2" - resolved "https://registry.yarnpkg.com/@actions/core/-/core-2.0.2.tgz#81c59e1f3437660d2148a064c1ba8e99931f2cf7" - integrity sha512-Ast1V7yHbGAhplAsuVlnb/5J8Mtr/Zl6byPPL+Qjq3lmfIgWF1ak1iYfF/079cRERiuTALTXkSuEUdZeDCfGtA== + version "2.0.1" + resolved "https://registry.yarnpkg.com/@actions/core/-/core-2.0.1.tgz#fc4961acb04f6253bcdf83ad356e013ba29fc218" + integrity sha512-oBfqT3GwkvLlo1fjvhQLQxuwZCGTarTE5OuZ2Wg10hvhBj7LRIlF611WT4aZS6fDhO5ZKlY7lCAZTlpmyaHaeg== dependencies: "@actions/exec" "^2.0.0" - "@actions/http-client" "^3.0.1" + "@actions/http-client" "^3.0.0" "@actions/exec@^2.0.0": version "2.0.0" @@ -22,10 +22,10 @@ dependencies: "@actions/io" "^2.0.0" -"@actions/http-client@^3.0.1": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@actions/http-client/-/http-client-3.0.1.tgz#0ac91c3abf179a401e23d40abf0d7caa92324268" - integrity sha512-SbGS8c/vySbNO3kjFgSW77n83C4MQx/Yoe+b1hAdpuvfHxnkHzDq2pWljUpAA56Si1Gae/7zjeZsV0CYjmLo/w== +"@actions/http-client@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@actions/http-client/-/http-client-3.0.0.tgz#6c6058bef29c0580d6683a08c5bf0362c90c2e6e" + integrity sha512-1s3tXAfVMSz9a4ZEBkXXRQD4QhY3+GAsWSbaYpeknPOKEeyRiU3lH+bHiLMZdo2x/fIeQ/hscL1wCkDLVM2DZQ== dependencies: tunnel "^0.0.6" undici "^5.28.5" @@ -919,16 +919,7 @@ "@babel/highlight" "^7.22.13" chalk "^2.4.2" -"@babel/code-frame@^7.26.2", "@babel/code-frame@^7.28.6": - version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.28.6.tgz#72499312ec58b1e2245ba4a4f550c132be4982f7" - integrity sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q== - dependencies: - "@babel/helper-validator-identifier" "^7.28.5" - js-tokens "^4.0.0" - picocolors "^1.1.1" - -"@babel/code-frame@^7.27.1": +"@babel/code-frame@^7.26.2", "@babel/code-frame@^7.27.1": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.27.1.tgz#200f715e66d52a23b221a9435534a91cc13ad5be" integrity sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg== @@ -1131,11 +1122,6 @@ resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz#a7054dcc145a967dd4dc8fee845a57c1316c9df8" integrity sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow== -"@babel/helper-validator-identifier@^7.28.5": - version "7.28.5" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4" - integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q== - "@babel/helper-validator-option@^7.22.15": version "7.22.15" resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.22.15.tgz#694c30dfa1d09a6534cdfcafbe56789d36aba040" @@ -1151,8 +1137,8 @@ resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.28.6.tgz#fca903a313ae675617936e8998b814c415cbf5d7" integrity sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw== dependencies: - "@babel/template" "^7.28.6" - "@babel/types" "^7.28.6" + "@babel/template" "^7.27.2" + "@babel/types" "^7.28.4" "@babel/highlight@^7.22.13": version "7.22.20" @@ -1175,13 +1161,6 @@ dependencies: "@babel/types" "^7.28.4" -"@babel/parser@^7.28.6": - version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.28.6.tgz#f01a8885b7fa1e56dd8a155130226cd698ef13fd" - integrity sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ== - dependencies: - "@babel/types" "^7.28.6" - "@babel/plugin-syntax-async-generators@^7.8.4": version "7.8.4" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d" @@ -1303,15 +1282,6 @@ "@babel/parser" "^7.27.2" "@babel/types" "^7.27.1" -"@babel/template@^7.28.6": - version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.28.6.tgz#0e7e56ecedb78aeef66ce7972b082fce76a23e57" - integrity sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ== - dependencies: - "@babel/code-frame" "^7.28.6" - "@babel/parser" "^7.28.6" - "@babel/types" "^7.28.6" - "@babel/traverse@^7.23.3": version "7.23.3" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.23.3.tgz#26ee5f252e725aa7aca3474aa5b324eaf7908b5b" @@ -1358,14 +1328,6 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.27.1" -"@babel/types@^7.28.6": - version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.6.tgz#c3e9377f1b155005bcc4c46020e7e394e13089df" - integrity sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg== - dependencies: - "@babel/helper-string-parser" "^7.27.1" - "@babel/helper-validator-identifier" "^7.28.5" - "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" @@ -1787,6 +1749,29 @@ "@shikijs/types" "^3.19.0" "@shikijs/vscode-textmate" "^10.0.2" +"@graphql-typed-document-node/core@^3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.2.0.tgz#5f3d96ec6b2354ad6d8a28bf216a1d97b5426861" + integrity sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ== + +"@grpc/grpc-js@^1.14.0": + version "1.14.2" + resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.14.2.tgz#d245069181a1a8057abd35522d6052482730cf19" + integrity sha512-QzVUtEFyu05UNx2xr0fCQmStUO17uVQhGNowtxs00IgTZT6/W2PBLfUkj30s0FKJ29VtTa3ArVNIhNP6akQhqA== + dependencies: + "@grpc/proto-loader" "^0.8.0" + "@js-sdsl/ordered-map" "^4.4.2" + +"@grpc/proto-loader@^0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@grpc/proto-loader/-/proto-loader-0.8.0.tgz#b6c324dd909c458a0e4aa9bfd3d69cf78a4b9bd8" + integrity sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ== + dependencies: + lodash.camelcase "^4.3.0" + long "^5.0.0" + protobufjs "^7.5.3" + yargs "^17.7.2" + "@hapi/bourne@^3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@hapi/bourne/-/bourne-3.0.0.tgz#f11fdf7dda62fe8e336fa7c6642d9041f30356d7" @@ -2259,6 +2244,11 @@ resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-5.6.3.tgz#41ae1c07de1ebe0f6dde1abcbc9700a09b9c6056" integrity sha512-T1rRxzdqkEXcou0ZprN1q9yDRlvzCPLqmlNt5IIsGBzoEVgLCCYrKEwc84+TvsXuAc95VAZwtWD2zVsKPY4bcA== +"@js-sdsl/ordered-map@^4.4.2": + version "4.4.2" + resolved "https://registry.yarnpkg.com/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz#9299f82874bab9e4c7f9c48d865becbfe8d6907c" + integrity sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw== + "@koa/bodyparser@^6.0.0": version "6.0.0" resolved "https://registry.yarnpkg.com/@koa/bodyparser/-/bodyparser-6.0.0.tgz#e362ddb3691276064f36e8cbf79b66f5873360a0" @@ -2318,23 +2308,25 @@ "@langchain/classic" "1.0.20" "@langchain/openai" "1.2.10" binary-extensions "^2.2.0" + expr-eval "^2.0.2" flat "^5.0.2" - js-yaml "^4.1.1" - math-expression-evaluator "^2.0.0" + js-yaml "^4.1.0" + langchain ">=0.2.3 <0.3.0 || >=0.3.4 <0.4.0" + langsmith "^0.3.67" uuid "^10.0.0" - zod "^3.25.76 || ^4" + zod "^3.25.32" -"@langchain/core@1.1.15": - version "1.1.15" - resolved "https://registry.yarnpkg.com/@langchain/core/-/core-1.1.15.tgz#31a933f4b445101ad1aa441b4f29b74e5994986e" - integrity sha512-b8RN5DkWAmDAlMu/UpTZEluYwCLpm63PPWniRKlE8ie3KkkE7IuMQ38pf4kV1iaiI+d99BEQa2vafQHfCujsRA== +"@langchain/core@1.1.4": + version "1.1.4" + resolved "https://registry.yarnpkg.com/@langchain/core/-/core-1.1.4.tgz#73160c719ef99c77301fb66c7cd4a3995dcc790c" + integrity sha512-AZVHVoLJzhHU/jsjeNto1pvfHaPxGT+V3PcVyvUw0kCiWftdu1bxfwhwSsZJ9B9iJeXJdCIUe089+NYd3FsEuw== dependencies: "@cfworker/json-schema" "^4.0.2" ansi-styles "^5.0.0" camelcase "6" decamelize "1.2.0" js-tiktoken "^1.0.12" - langsmith ">=0.4.0 <1.0.0" + langsmith "^0.3.64" mustache "^4.2.0" p-queue "^6.6.2" uuid "^10.0.0" @@ -2347,30 +2339,30 @@ dependencies: uuid "^10.0.0" -"@langchain/langgraph-sdk@~1.5.4": - version "1.5.4" - resolved "https://registry.yarnpkg.com/@langchain/langgraph-sdk/-/langgraph-sdk-1.5.4.tgz#40caf09ebc9a5bcd192610a127e8cad659f1cce6" - integrity sha512-eSYqG875c2qvcPwdvBwQH0niTZxt6roMGc2dAWBqCbWCUiUL0X4ftYHg2OqOelsrNE3SO6faLr/m0LIPc9hDwg== +"@langchain/langgraph-sdk@~1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@langchain/langgraph-sdk/-/langgraph-sdk-1.2.0.tgz#f9db1cd5524efda1e08423b9c10da370e0e2a834" + integrity sha512-nFfNJWc9P2job2uUoL37nXfz0VW9eLEtidP0edrgeHUW7BczIQzLXC9ucJHHHGLjlK0S522kmai0abAULv3pGA== dependencies: - p-queue "^9.0.1" - p-retry "^7.1.1" - uuid "^13.0.0" + p-queue "^6.6.2" + p-retry "4" + uuid "^9.0.0" -"@langchain/langgraph@^1.1.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@langchain/langgraph/-/langgraph-1.1.0.tgz#ef873a69db4a43c25c90fb745392d880d9d2dcbb" - integrity sha512-3n1GL0ZTtr57ZwbYvbi4Th26fwiGogmpFn8OA8UXEpBM2HcpGwcv1+c8YSBJF4XRjlcCzIlXtY+DyrNsvinc6g== +"@langchain/langgraph@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@langchain/langgraph/-/langgraph-1.0.4.tgz#12e472b1462ae944e53d956630166be101916e85" + integrity sha512-EYLyN/uv1ubMBd3RN/y+eAxY0FJWKrnzRw8HuDJdmDcyomgV9btyHK2zDN70sO3QDDuAU9voLNNUZeFBQkBYMQ== dependencies: "@langchain/langgraph-checkpoint" "^1.0.0" - "@langchain/langgraph-sdk" "~1.5.4" + "@langchain/langgraph-sdk" "~1.2.0" uuid "^10.0.0" -"@langchain/mcp-adapters@1.1.1": - version "1.1.1" - resolved "https://registry.yarnpkg.com/@langchain/mcp-adapters/-/mcp-adapters-1.1.1.tgz#8e29a64707abae4abe9dc8c56f6c69fa47942ea3" - integrity sha512-oMnozcaZ9e4SzPIcA8EfrSxT7TForMvPfGrUj+l/a+qHS2zcSMOSUH11joy4YwGiRVQgO/68TK8ceukWaeLbDw== +"@langchain/mcp-adapters@1.0.3": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@langchain/mcp-adapters/-/mcp-adapters-1.0.3.tgz#243f8717e359c27f34c417e9a37ddbda70b9c622" + integrity sha512-tQacqDcqikrli/gH7CGAOUfNJ0pFOwtoKPWq2mRP7BTApOPWyfZ8Okw5nSwPDZ7Eau/QyLAH7DaWfNHmwzXPGw== dependencies: - "@modelcontextprotocol/sdk" "^1.24.0" + "@modelcontextprotocol/sdk" "^1.18.2" debug "^4.4.3" zod "^3.25.76 || ^4" optionalDependencies: @@ -2394,13 +2386,21 @@ openai "^6.18.0" zod "^3.25.76 || ^4" -"@langchain/textsplitters@1.0.1": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@langchain/textsplitters/-/textsplitters-1.0.1.tgz#292f9c93239178c248b3338acf7b68aa47aa9830" - integrity sha512-rheJlB01iVtrOUzttscutRgLybPH9qR79EyzBEbf1u97ljWyuxQfCwIWK+SjoQTM9O8M7GGLLRBSYE26Jmcoww== +"@langchain/textsplitters@>=0.0.0 <0.2.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@langchain/textsplitters/-/textsplitters-0.1.0.tgz#f37620992192df09ecda3dfbd545b36a6bcbae46" + integrity sha512-djI4uw9rlkAb5iMhtLED+xJebDdAG935AdP4eRTB02R7OB/act55Bj9wsskhZsvuyQRpO4O1wQOp85s6T6GWmw== dependencies: js-tiktoken "^1.0.12" +"@langchain/weaviate@^0.2.0": + version "0.2.3" + resolved "https://registry.yarnpkg.com/@langchain/weaviate/-/weaviate-0.2.3.tgz#7b2557ea9a369bb7ce05dfc553f56b6ff062d90b" + integrity sha512-WqNGn1eSrI+ZigJd7kZjCj3fvHBYicKr054qts2nNJ+IyO5dWmY3oFTaVHFq1OLFVZJJxrFeDnxSEOC3JnfP0w== + dependencies: + uuid "^10.0.0" + weaviate-client "^3.5.2" + "@lerna/create@8.2.3": version "8.2.3" resolved "https://registry.yarnpkg.com/@lerna/create/-/create-8.2.3.tgz#8e88fedb60eb699f2f5057e7344d9f980b7f9554" @@ -2502,7 +2502,6 @@ resolved "https://registry.yarnpkg.com/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz#5b35d73062125f126cc70b0be83cbab53bcdde74" integrity sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg== dependencies: - "@hono/node-server" "^1.19.9" ajv "^8.17.1" ajv-formats "^3.0.1" content-type "^1.0.5" @@ -2527,7 +2526,7 @@ dependencies: sparse-bitfield "^3.0.3" -"@mongodb-js/saslprep@^1.3.0": +"@mongodb-js/saslprep@^1.1.5": version "1.4.4" resolved "https://registry.yarnpkg.com/@mongodb-js/saslprep/-/saslprep-1.4.4.tgz#34a946ff6ae142e8f2259b87f2935f8284ba874d" integrity sha512-p7X/ytJDIdwUfFL/CLOhKgdfJe1Fa8uw9seJYvdOmnP9JBWGWHW69HkOixXS6Wy9yvGf1MbhcS6lVmrhy4jm2g== @@ -3359,6 +3358,59 @@ "@pnpm/network.ca-file" "^1.0.1" config-chain "^1.1.11" +"@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf" + integrity sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ== + +"@protobufjs/base64@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/base64/-/base64-1.1.2.tgz#4c85730e59b9a1f1f349047dbf24296034bb2735" + integrity sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg== + +"@protobufjs/codegen@^2.0.4": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@protobufjs/codegen/-/codegen-2.0.4.tgz#7ef37f0d010fb028ad1ad59722e506d9262815cb" + integrity sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg== + +"@protobufjs/eventemitter@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz#355cbc98bafad5978f9ed095f397621f1d066b70" + integrity sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q== + +"@protobufjs/fetch@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/fetch/-/fetch-1.1.0.tgz#ba99fb598614af65700c1619ff06d454b0d84c45" + integrity sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ== + dependencies: + "@protobufjs/aspromise" "^1.1.1" + "@protobufjs/inquire" "^1.1.0" + +"@protobufjs/float@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@protobufjs/float/-/float-1.0.2.tgz#5e9e1abdcb73fc0a7cb8b291df78c8cbd97b87d1" + integrity sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ== + +"@protobufjs/inquire@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/inquire/-/inquire-1.1.0.tgz#ff200e3e7cf2429e2dcafc1140828e8cc638f089" + integrity sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q== + +"@protobufjs/path@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/path/-/path-1.1.2.tgz#6cc2b20c5c9ad6ad0dccfd21ca7673d8d7fbf68d" + integrity sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA== + +"@protobufjs/pool@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/pool/-/pool-1.1.0.tgz#09fd15f2d6d3abfa9b65bc366506d6ad7846ff54" + integrity sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw== + +"@protobufjs/utf8@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" + integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== + "@qiwi/multi-semantic-release@^7.1.2": version "7.1.2" resolved "https://registry.yarnpkg.com/@qiwi/multi-semantic-release/-/multi-semantic-release-7.1.2.tgz#bc1cc8a5ef98160939b39fef01d40d2334eaac4e" @@ -3593,10 +3645,10 @@ resolved "https://registry.yarnpkg.com/@sigstore/core/-/core-1.1.0.tgz#5583d8f7ffe599fa0a89f2bf289301a5af262380" integrity sha512-JzBqdVIyqm2FRQCulY6nbQzMpJJpSiJ8XXWMhtOX9eKgaXXpfNOF53lzQEjIydlStnd/eFtuC1dW4VYdD93oRg== -"@sigstore/core@^3.1.0": - version "3.1.0" - resolved "https://registry.yarnpkg.com/@sigstore/core/-/core-3.1.0.tgz#b418de73f56333ad9e369b915173d8c98e9b96d5" - integrity sha512-o5cw1QYhNQ9IroioJxpzexmPjfCe7gzafd2RY3qnMpxr4ZEja+Jad/U8sgFpaue6bOaF+z7RVkyKVV44FN+N8A== +"@sigstore/core@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@sigstore/core/-/core-3.0.0.tgz#42f42f733596f26eb055348635098fa28676f117" + integrity sha512-NgbJ+aW9gQl/25+GIEGYcCyi8M+ng2/5X04BMuIgoDfgvp18vDcoNHOQjQsG9418HGNYRxG3vfEXaR1ayD37gg== "@sigstore/protobuf-specs@^0.3.2": version "0.3.3" @@ -3620,16 +3672,16 @@ proc-log "^4.2.0" promise-retry "^2.0.1" -"@sigstore/sign@^4.1.0": - version "4.1.0" - resolved "https://registry.yarnpkg.com/@sigstore/sign/-/sign-4.1.0.tgz#63df15a137337b29f463a1d1c51e1f7d4c1db2f1" - integrity sha512-Vx1RmLxLGnSUqx/o5/VsCjkuN5L7y+vxEEwawvc7u+6WtX2W4GNa7b9HEjmcRWohw/d6BpATXmvOwc78m+Swdg== +"@sigstore/sign@^4.0.0": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@sigstore/sign/-/sign-4.0.1.tgz#36ed397d0528e4da880b9060e26234098de5d35b" + integrity sha512-KFNGy01gx9Y3IBPG/CergxR9RZpN43N+lt3EozEfeoyqm8vEiLxwRl3ZO5sPx3Obv1ix/p7FWOlPc2Jgwfp9PA== dependencies: "@sigstore/bundle" "^4.0.0" - "@sigstore/core" "^3.1.0" + "@sigstore/core" "^3.0.0" "@sigstore/protobuf-specs" "^0.5.0" - make-fetch-happen "^15.0.3" - proc-log "^6.1.0" + make-fetch-happen "^15.0.2" + proc-log "^5.0.0" promise-retry "^2.0.1" "@sigstore/tuf@^2.3.4": @@ -3640,13 +3692,13 @@ "@sigstore/protobuf-specs" "^0.3.2" tuf-js "^2.2.1" -"@sigstore/tuf@^4.0.0", "@sigstore/tuf@^4.0.1": - version "4.0.1" - resolved "https://registry.yarnpkg.com/@sigstore/tuf/-/tuf-4.0.1.tgz#9b080390936d79ea3b6a893b64baf3123e92d6d3" - integrity sha512-OPZBg8y5Vc9yZjmWCHrlWPMBqW5yd8+wFNl+thMdtcWz3vjVSoJQutF8YkrzI0SLGnkuFof4HSsWUhXrf219Lw== +"@sigstore/tuf@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@sigstore/tuf/-/tuf-4.0.0.tgz#8b3ae2bd09e401386d5b6842a46839e8ff484e6c" + integrity sha512-0QFuWDHOQmz7t66gfpfNO6aEjoFrdhkJaej/AOqb4kqWZVbPWFZifXZzkxyQBB1OwTbkhdT3LNpMFxwkTvf+2w== dependencies: "@sigstore/protobuf-specs" "^0.5.0" - tuf-js "^4.1.0" + tuf-js "^4.0.0" "@sigstore/verify@^1.2.1": version "1.2.1" @@ -3657,13 +3709,13 @@ "@sigstore/core" "^1.1.0" "@sigstore/protobuf-specs" "^0.3.2" -"@sigstore/verify@^3.1.0": - version "3.1.0" - resolved "https://registry.yarnpkg.com/@sigstore/verify/-/verify-3.1.0.tgz#4046d4186421db779501fe87fa5acaa5d4d21b08" - integrity sha512-mNe0Iigql08YupSOGv197YdHpPPr+EzDZmfCgMc7RPNaZTw5aLN01nBl6CHJOh3BGtnMIj83EeN4butBchc8Ag== +"@sigstore/verify@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@sigstore/verify/-/verify-3.0.0.tgz#59a1ffa98246f8b3f91a17459e3532095ee7fbb7" + integrity sha512-moXtHH33AobOhTZF8xcX1MpOFqdvfCk7v6+teJL8zymBiDXwEsQH6XG9HGx2VIxnJZNm4cNSzflTLDnQLmIdmw== dependencies: "@sigstore/bundle" "^4.0.0" - "@sigstore/core" "^3.1.0" + "@sigstore/core" "^3.0.0" "@sigstore/protobuf-specs" "^0.5.0" "@sinclair/typebox@^0.27.8": @@ -4243,13 +4295,13 @@ "@tufjs/canonical-json" "2.0.0" minimatch "^9.0.4" -"@tufjs/models@4.1.0": - version "4.1.0" - resolved "https://registry.yarnpkg.com/@tufjs/models/-/models-4.1.0.tgz#494b39cf5e2f6855d80031246dd236d8086069b3" - integrity sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww== +"@tufjs/models@4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@tufjs/models/-/models-4.0.0.tgz#91fa6608413bb2d593c87d8aaf8bfbf7f7a79cb8" + integrity sha512-h5x5ga/hh82COe+GoD4+gKUeV4T3iaYOxqLt41GRKApinPI7DMidhCmNVTjKfhCWFJIGXaFJee07XczdT4jdZQ== dependencies: "@tufjs/canonical-json" "2.0.0" - minimatch "^10.1.1" + minimatch "^9.0.5" "@tybys/wasm-util@^0.9.0": version "0.9.0" @@ -4590,6 +4642,14 @@ resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.34.tgz#10964ba0dee6ac4cd462e2795b6bebd407303433" integrity sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g== +"@types/node-fetch@^2.6.4": + version "2.6.13" + resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.13.tgz#e0c9b7b5edbdb1b50ce32c127e85e880872d56ee" + integrity sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw== + dependencies: + "@types/node" "*" + form-data "^4.0.4" + "@types/node@*": version "20.9.0" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.9.0.tgz#bfcdc230583aeb891cf51e73cfdaacdd8deae298" @@ -4602,6 +4662,13 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-20.5.1.tgz#178d58ee7e4834152b0e8b4d30cbfab578b9bb30" integrity sha512-4tT2UrL5LBqDwoed9wZ6N3umC4Yhz3W3FloMmiiG4JwmUJWpie0c7lcnUNd4gtMKuDEO4wRVS8B6Xa0uMRsMKg== +"@types/node@>=13.7.0": + version "25.0.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-25.0.0.tgz#c0e0022c3c7b41635c49322e6b3a0279fffa7d62" + integrity sha512-rl78HwuZlaDIUSeUKkmogkhebA+8K1Hy7tddZuJ3D0xV8pZSfsYGTsliGUol1JPzu9EKnTxPC4L1fiWouStRew== + dependencies: + undici-types "~7.16.0" + "@types/node@>=18": version "22.10.2" resolved "https://registry.yarnpkg.com/@types/node/-/node-22.10.2.tgz#a485426e6d1fdafc7b0d4c7b24e2c78182ddabb9" @@ -4937,6 +5004,16 @@ abbrev@^4.0.0: resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-4.0.0.tgz#ec933f0e27b6cd60e89b5c6b2a304af42209bb05" integrity sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA== +abort-controller-x@^0.4.0: + version "0.4.3" + resolved "https://registry.yarnpkg.com/abort-controller-x/-/abort-controller-x-0.4.3.tgz#ff269788386fabd58a7b6eeaafcb6cf55c2958e0" + integrity sha512-VtUwTNU8fpMwvWGn4xE93ywbogTYsuT+AUxAXOeelbXuQVIwNmC5YLeho9sH4vZ4ITW8414TTAOG1nW6uIVHCA== + +abort-controller-x@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/abort-controller-x/-/abort-controller-x-0.5.0.tgz#2c0531a83c7717eccd47435bfe123bccfd34e2b8" + integrity sha512-yTt9CI0x+nRfX6BFMenEGP8ooPvErGH6AbFz20C2IeOLIlDsrw/VHpgne3GsCEuTA410IiFiaLVFKmgM4bKEPQ== + abort-controller@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" @@ -5016,6 +5093,13 @@ agentkeepalive@^4.1.3: dependencies: humanize-ms "^1.2.1" +agentkeepalive@^4.2.1: + version "4.6.0" + resolved "https://registry.yarnpkg.com/agentkeepalive/-/agentkeepalive-4.6.0.tgz#35f73e94b3f40bf65f105219c623ad19c136ea6a" + integrity sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ== + dependencies: + humanize-ms "^1.2.1" + aggregate-error@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a" @@ -5834,7 +5918,7 @@ bson@^4.7.2: dependencies: buffer "^5.6.0" -bson@^6.10.4: +bson@^6.7.0: version "6.10.4" resolved "https://registry.yarnpkg.com/bson/-/bson-6.10.4.tgz#d530733bb5bb16fb25c162e01a3344fab332fd2b" integrity sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng== @@ -6830,6 +6914,13 @@ croner@^6.0.6: resolved "https://registry.yarnpkg.com/croner/-/croner-6.0.7.tgz#73416ee178626c226a5765e498e1e8454738bdba" integrity sha512-k3Xx3Rcclfr60Yx4TmvsF3Yscuiql8LSvYLaphTsaq5Hk8La4Z/udmUANMOTKpgGGroI2F6/XOr9cU9OFkYluQ== +cross-fetch@^3.1.5: + version "3.2.0" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.2.0.tgz#34e9192f53bc757d6614304d9e5e6fb4edb782e3" + integrity sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q== + dependencies: + node-fetch "^2.7.0" + cross-fetch@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-4.1.0.tgz#8f69355007ee182e47fa692ecbaa37a52e43c3d2" @@ -6951,7 +7042,7 @@ debug@^3.2.6, debug@^3.2.7: dependencies: ms "^2.1.1" -debug@^4.3.1, debug@^4.4.3: +debug@^4.3.1, debug@^4.4.1, debug@^4.4.3: version "4.4.3" resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== @@ -7091,7 +7182,7 @@ deprecation@^2.0.0: resolved "https://registry.yarnpkg.com/deprecation/-/deprecation-2.3.1.tgz#6368cbdb40abf3373b525ac87e4a260c3a700919" integrity sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ== -destroy@1.2.0, destroy@^1.0.4, destroy@^1.2.0, destroy@~1.2.0: +destroy@1.2.0, destroy@^1.0.4, destroy@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== @@ -7140,9 +7231,9 @@ diff@^4.0.1: integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== diff@^8.0.2: - version "8.0.3" - resolved "https://registry.yarnpkg.com/diff/-/diff-8.0.3.tgz#c7da3d9e0e8c283bb548681f8d7174653720c2d5" - integrity sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ== + version "8.0.2" + resolved "https://registry.yarnpkg.com/diff/-/diff-8.0.2.tgz#712156a6dd288e66ebb986864e190c2fc9eddfae" + integrity sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg== dir-glob@^3.0.0, dir-glob@^3.0.1: version "3.0.1" @@ -7860,11 +7951,6 @@ eventemitter3@^4.0.4: resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== -eventemitter3@^5.0.1: - version "5.0.4" - resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.4.tgz#a86d66170433712dde814707ac52b5271ceb1feb" - integrity sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw== - events@^3.0.0, events@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" @@ -8105,32 +8191,32 @@ express@^4.17.1, express@^4.18.2: dependencies: accepts "~1.3.8" array-flatten "1.1.1" - body-parser "~1.20.3" - content-disposition "~0.5.4" + body-parser "1.20.3" + content-disposition "0.5.4" content-type "~1.0.4" - cookie "~0.7.1" - cookie-signature "~1.0.6" + cookie "0.7.1" + cookie-signature "1.0.6" debug "2.6.9" depd "2.0.0" encodeurl "~2.0.0" escape-html "~1.0.3" etag "~1.8.1" - finalhandler "~1.3.1" - fresh "~0.5.2" - http-errors "~2.0.0" + finalhandler "1.3.1" + fresh "0.5.2" + http-errors "2.0.0" merge-descriptors "1.0.3" methods "~1.1.2" - on-finished "~2.4.1" + on-finished "2.4.1" parseurl "~1.3.3" - path-to-regexp "~0.1.12" + path-to-regexp "0.1.12" proxy-addr "~2.0.7" - qs "~6.14.0" + qs "6.13.0" range-parser "~1.2.1" safe-buffer "5.2.1" - send "~0.19.0" - serve-static "~1.16.2" + send "0.19.0" + serve-static "1.16.2" setprototypeof "1.2.0" - statuses "~2.0.1" + statuses "2.0.1" type-is "~1.6.18" utils-merge "1.0.1" vary "~1.1.2" @@ -8570,9 +8656,9 @@ finalhandler@~1.3.1: debug "2.6.9" encodeurl "~2.0.0" escape-html "~1.0.3" - on-finished "~2.4.1" + on-finished "2.4.1" parseurl "~1.3.3" - statuses "~2.0.2" + statuses "2.0.1" unpipe "~1.0.0" find-my-way@^2.2.2: @@ -8742,6 +8828,11 @@ forest-ip-utils@^1.0.1: ip-address "^5.8.9" range_check "^1.4.0" +form-data-encoder@1.7.2: + version "1.7.2" + resolved "https://registry.yarnpkg.com/form-data-encoder/-/form-data-encoder-1.7.2.tgz#1f1ae3dccf58ed4690b86d87e4f57c654fbab040" + integrity sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A== + form-data@^4.0.0, form-data@^4.0.4: version "4.0.4" resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.4.tgz#784cdcce0669a9d68e94d11ac4eea98088edd2c4" @@ -9257,6 +9348,14 @@ graphemer@^1.4.0: resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== +graphql-request@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/graphql-request/-/graphql-request-6.1.0.tgz#f4eb2107967af3c7a5907eb3131c671eac89be4f" + integrity sha512-p+XPfS4q7aIpKVcgmnZKhMNqhltk20hfXtkaIkTfjjmiKMJ5xrt5c743cL03y/K7y1rg3WrIC49xGiEQ4mxdNw== + dependencies: + "@graphql-typed-document-node/core" "^3.2.0" + cross-fetch "^3.1.5" + graphql-tag@^2.12.6: version "2.12.6" resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.12.6.tgz#d441a569c1d2537ef10ca3d1633b48725329b5f1" @@ -9271,6 +9370,11 @@ graphql@14.5.7: dependencies: iterall "^1.2.2" +graphql@^16.12.0: + version "16.12.0" + resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.12.0.tgz#28cc2462435b1ac3fdc6976d030cef83a0c13ac7" + integrity sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ== + handlebars@4.7.7: version "4.7.7" resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.7.tgz#9ce33416aad02dbd6c8fafa8240d5d98004945a1" @@ -9283,7 +9387,7 @@ handlebars@4.7.7: optionalDependencies: uglify-js "^3.1.4" -handlebars@^4.7.7, handlebars@^4.7.8: +handlebars@^4.7.7: version "4.7.8" resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.8.tgz#41c42c18b1be2365439188c77c6afae71c0cd9e9" integrity sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ== @@ -9604,7 +9708,7 @@ hyperlinker@^1.0.0: resolved "https://registry.yarnpkg.com/hyperlinker/-/hyperlinker-1.0.0.tgz#23dc9e38a206b208ee49bc2d6c8ef47027df0c0e" integrity sha512-Ty8UblRWFEcfSuIaajM34LdPXIhbs1ajEX/BBPv24J+enSVaEVY63xQ6lTO9VRYS5LAoghIG0IDJ+p+IPzKUQQ== -iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@~0.4.24: +iconv-lite@0.4.24, iconv-lite@^0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== @@ -10145,11 +10249,6 @@ is-negative-zero@^2.0.3: resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.3.tgz#ced903a027aca6381b777a5743069d7376a49747" integrity sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw== -is-network-error@^1.1.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/is-network-error/-/is-network-error-1.3.0.tgz#2ce62cbca444abd506f8a900f39d20b898d37512" - integrity sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw== - is-number-object@^1.0.4: version "1.0.7" resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.7.tgz#59d50ada4c45251784e9904f5246c742f07a42fc" @@ -11311,10 +11410,27 @@ koa@^3.0.1: type-is "^2.0.1" vary "^1.1.2" -"langsmith@>=0.4.0 <1.0.0": - version "0.4.7" - resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.4.7.tgz#054232706d6b55518b20cff654fc3f91acb07e5f" - integrity sha512-Esv5g/J8wwRwbGQr10PB9+bLsNk0mWbrXc7nnEreQDhh0azbU57I7epSnT7GC4sS4EOWavhbxk+6p8PTXtreHw== +"langchain@>=0.2.3 <0.3.0 || >=0.3.4 <0.4.0": + version "0.3.36" + resolved "https://registry.yarnpkg.com/langchain/-/langchain-0.3.36.tgz#6ab7f4028adae16bf74538aa2127df9fe2fddf02" + integrity sha512-PqC19KChFF0QlTtYDFgfEbIg+SCnCXox29G8tY62QWfj9bOW7ew2kgWmPw5qoHLOTKOdQPvXET20/1Pdq8vAtQ== + dependencies: + "@langchain/openai" ">=0.1.0 <0.7.0" + "@langchain/textsplitters" ">=0.0.0 <0.2.0" + js-tiktoken "^1.0.12" + js-yaml "^4.1.0" + jsonpointer "^5.0.1" + langsmith "^0.3.67" + openapi-types "^12.1.3" + p-retry "4" + uuid "^10.0.0" + yaml "^2.2.1" + zod "^3.25.32" + +langsmith@^0.3.64, langsmith@^0.3.67: + version "0.3.85" + resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.3.85.tgz#5b44287b140c8db1063462ca6243fd1bb3272f31" + integrity sha512-Txuaxnpcra57qld4+hkqHhd9L2D6G6kEAReWXdr/sddxPu6ycBHXStqDciituC642lJmzPdarrYtll2vSwMbnQ== dependencies: "@types/uuid" "^10.0.0" chalk "^4.1.2" @@ -11841,6 +11957,11 @@ loglevel@^1.4.1: resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.8.1.tgz#5c621f83d5b48c54ae93b6156353f555963377b4" integrity sha512-tCRIJM51SHjAayKwC+QAg8hT8vg6z7GSgLJKGvzuPb1Wc+hLzqtuVLxp6/HzSPOozuK+8ErAhy7U/sVzw8Dgfg== +long@^5.0.0, long@^5.3.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/long/-/long-5.3.2.tgz#1d84463095999262d7d7b7f8bfd4a8cc55167f83" + integrity sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA== + long@^5.2.1: version "5.2.3" resolved "https://registry.yarnpkg.com/long/-/long-5.2.3.tgz#a3ba97f3877cf1d778eccbcb048525ebb77499e1" @@ -11963,7 +12084,7 @@ make-fetch-happen@^13.0.0, make-fetch-happen@^13.0.1: promise-retry "^2.0.1" ssri "^10.0.0" -make-fetch-happen@^15.0.0, make-fetch-happen@^15.0.1, make-fetch-happen@^15.0.3: +make-fetch-happen@^15.0.0, make-fetch-happen@^15.0.2, make-fetch-happen@^15.0.3: version "15.0.3" resolved "https://registry.yarnpkg.com/make-fetch-happen/-/make-fetch-happen-15.0.3.tgz#1578d72885f2b3f9e5daa120b36a14fc31a84610" integrity sha512-iyyEpDty1mwW3dGlYXAJqC/azFn5PPvgKVwXayOGBSmKLxhKZ9fg4qIan2ePpp1vJIwfFiO34LAPZgq9SZW9Aw== @@ -12094,11 +12215,6 @@ marked@^4.1.0: resolved "https://registry.yarnpkg.com/marked/-/marked-4.3.0.tgz#796362821b019f734054582038b116481b456cf3" integrity sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A== -math-expression-evaluator@^2.0.0: - version "2.0.7" - resolved "https://registry.yarnpkg.com/math-expression-evaluator/-/math-expression-evaluator-2.0.7.tgz#dc99a80ce2bf7f9b7df878126feb5c506c1fdf5f" - integrity sha512-uwliJZ6BPHRq4eiqNWxZBDzKUiS5RIynFFcgchqhBOloVLVBpZpNG8jRYkedLcBvhph8TnRyWEuxPqiQcwIdog== - math-intrinsics@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" @@ -12624,13 +12740,13 @@ mongodb-connection-string-url@^2.6.0: "@types/whatwg-url" "^8.2.1" whatwg-url "^11.0.0" -mongodb-connection-string-url@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz#e223089dfa0a5fa9bf505f8aedcbc67b077b33e7" - integrity sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA== +mongodb-connection-string-url@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.1.tgz#c13e6ac284ae401752ebafdb8cd7f16c6723b141" + integrity sha512-XqMGwRX0Lgn05TDB4PyG2h2kKO/FfWJyCzYQbIhXUxz7ETt0I/FqHjUeqj37irJ+Dl1ZtU82uYyj14u2XsZKfg== dependencies: "@types/whatwg-url" "^11.0.2" - whatwg-url "^14.1.0 || ^13.0.0" + whatwg-url "^13.0.0" mongodb@4.17.2: version "4.17.2" @@ -12644,23 +12760,23 @@ mongodb@4.17.2: "@aws-sdk/credential-providers" "^3.186.0" "@mongodb-js/saslprep" "^1.1.0" -mongodb@~6.20.0: - version "6.20.0" - resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-6.20.0.tgz#5212dcf512719385287aa4574265352eefb01d8e" - integrity sha512-Tl6MEIU3K4Rq3TSHd+sZQqRBoGlFsOgNrH5ltAcFBV62Re3Fd+FcaVf8uSEQFOJ51SDowDVttBTONMfoYWrWlQ== +mongodb@~6.10.0: + version "6.10.0" + resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-6.10.0.tgz#20a9f1cf3c6829e75fc39e6d8c1c19f164209c2e" + integrity sha512-gP9vduuYWb9ZkDM546M+MP2qKVk5ZG2wPF63OvSRuUbqCR+11ZCAE1mOfllhlAG0wcoJY5yDL/rV3OmYEwXIzg== dependencies: - "@mongodb-js/saslprep" "^1.3.0" - bson "^6.10.4" - mongodb-connection-string-url "^3.0.2" + "@mongodb-js/saslprep" "^1.1.5" + bson "^6.7.0" + mongodb-connection-string-url "^3.0.0" -mongoose@8.21.0: - version "8.21.0" - resolved "https://registry.yarnpkg.com/mongoose/-/mongoose-8.21.0.tgz#e4b940a6b22c2fc176916667766f34656e352906" - integrity sha512-dW2U01gN8EVQT5KAO5AkzjbqWc8A/CsEq15jOzq/M9ISpy8jw3iq7W9ZP135h9zykFOMt3AMxq4+anvt2YNJgw== +mongoose@8.8.4: + version "8.8.4" + resolved "https://registry.yarnpkg.com/mongoose/-/mongoose-8.8.4.tgz#11e3991a7fd03596a79bc9f9b2fe8f3e75b7a30d" + integrity sha512-yJbn695qCsqDO+xyPII29x2R7flzXhxCDv09mMZPSGllf0sm4jKw3E9s9uvQ9hjO6bL2xjU8KKowYqcY9eSTMQ== dependencies: - bson "^6.10.4" + bson "^6.7.0" kareem "2.6.3" - mongodb "~6.20.0" + mongodb "~6.10.0" mpath "0.9.0" mquery "5.0.0" ms "2.1.3" @@ -12860,6 +12976,30 @@ next-tick@1, next-tick@^1.1.0: resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.1.0.tgz#1836ee30ad56d67ef281b22bd199f709449b35eb" integrity sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ== +nice-grpc-client-middleware-retry@^3.1.12: + version "3.1.13" + resolved "https://registry.yarnpkg.com/nice-grpc-client-middleware-retry/-/nice-grpc-client-middleware-retry-3.1.13.tgz#25de76d3ab86328a35e3b5c9093a4cb03d98b2a0" + integrity sha512-Q9I/wm5lYkDTveKFirrTHBkBY137yavXZ4xQDXTPIycUp7aLXD8xPTHFhqtAFWUw05aS91uffZZRgdv3HS0y/g== + dependencies: + abort-controller-x "^0.4.0" + nice-grpc-common "^2.0.2" + +nice-grpc-common@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/nice-grpc-common/-/nice-grpc-common-2.0.2.tgz#e6aeebb2bd19d87114b351e291e30d79dd38acf7" + integrity sha512-7RNWbls5kAL1QVUOXvBsv1uO0wPQK3lHv+cY1gwkTzirnG1Nop4cBJZubpgziNbaVc/bl9QJcyvsf/NQxa3rjQ== + dependencies: + ts-error "^1.0.6" + +nice-grpc@^2.1.13: + version "2.1.14" + resolved "https://registry.yarnpkg.com/nice-grpc/-/nice-grpc-2.1.14.tgz#ce598d52a8218e4312b9f8ac0f179e20049154e3" + integrity sha512-GK9pKNxlvnU5FAdaw7i2FFuR9CqBspcE+if2tqnKXBcE0R8525wj4BZvfcwj7FjvqbssqKxRHt2nwedalbJlww== + dependencies: + "@grpc/grpc-js" "^1.14.0" + abort-controller-x "^0.4.0" + nice-grpc-common "^2.0.2" + nice-try@^1.0.4: version "1.0.5" resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" @@ -12891,6 +13031,11 @@ node-addon-api@^7.0.0: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-7.1.1.tgz#1aba6693b0f255258a049d621329329322aad558" integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ== +node-domexception@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" + integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== + node-emoji@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/node-emoji/-/node-emoji-2.2.0.tgz#1d000e3c76e462577895be1b436f4aa2d6760eb0" @@ -13536,7 +13681,7 @@ on-exit-leak-free@^2.1.0: resolved "https://registry.yarnpkg.com/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz#fed195c9ebddb7d9e4c3842f93f281ac8dadd3b8" integrity sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA== -on-finished@2.4.1, on-finished@^2.3.0, on-finished@^2.4.1, on-finished@~2.4.1: +on-finished@2.4.1, on-finished@^2.3.0, on-finished@^2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== @@ -13812,14 +13957,6 @@ p-queue@6.6.2, p-queue@^6.6.2: eventemitter3 "^4.0.4" p-timeout "^3.2.0" -p-queue@^9.0.1: - version "9.1.0" - resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-9.1.0.tgz#846e517c461fb6e3cf8fc09904e57d342ac448b2" - integrity sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw== - dependencies: - eventemitter3 "^5.0.1" - p-timeout "^7.0.0" - p-reduce@2.1.0, p-reduce@^2.0.0, p-reduce@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/p-reduce/-/p-reduce-2.1.0.tgz#09408da49507c6c274faa31f28df334bc712b64a" @@ -13830,12 +13967,13 @@ p-reduce@^3.0.0: resolved "https://registry.yarnpkg.com/p-reduce/-/p-reduce-3.0.0.tgz#f11773794792974bd1f7a14c72934248abff4160" integrity sha512-xsrIUgI0Kn6iyDYm9StOpOeK29XM1aboGji26+QEortiFST1hGZaUQOLhtEbqHErPpGW/aSz6allwK2qcptp0Q== -p-retry@^7.1.1: - version "7.1.1" - resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-7.1.1.tgz#7470fdecb1152ba50f1334e48378c9e401330e24" - integrity sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w== +p-retry@4: + version "4.6.2" + resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-4.6.2.tgz#9baae7184057edd4e17231cee04264106e092a16" + integrity sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ== dependencies: - is-network-error "^1.1.0" + "@types/retry" "0.12.0" + retry "^0.13.1" p-some@^4.0.0: version "4.1.0" @@ -13857,11 +13995,6 @@ p-timeout@^6.1.2: resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-6.1.4.tgz#418e1f4dd833fa96a2e3f532547dd2abdb08dbc2" integrity sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg== -p-timeout@^7.0.0: - version "7.0.1" - resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-7.0.1.tgz#95680a6aa693c530f14ac337b8bd32d4ec6ae4f0" - integrity sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg== - p-try@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3" @@ -14495,6 +14628,11 @@ proc-log@^4.0.0, proc-log@^4.1.0, proc-log@^4.2.0: resolved "https://registry.yarnpkg.com/proc-log/-/proc-log-4.2.0.tgz#b6f461e4026e75fdfe228b265e9f7a00779d7034" integrity sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA== +proc-log@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/proc-log/-/proc-log-5.0.0.tgz#e6c93cf37aef33f835c53485f314f50ea906a9d8" + integrity sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ== + proc-log@^6.0.0, proc-log@^6.1.0: version "6.1.0" resolved "https://registry.yarnpkg.com/proc-log/-/proc-log-6.1.0.tgz#18519482a37d5198e231133a70144a50f21f0215" @@ -14605,6 +14743,24 @@ proto-list@~1.2.1: resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" integrity sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA== +protobufjs@^7.5.3: + version "7.5.4" + resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-7.5.4.tgz#885d31fe9c4b37f25d1bb600da30b1c5b37d286a" + integrity sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg== + dependencies: + "@protobufjs/aspromise" "^1.1.2" + "@protobufjs/base64" "^1.1.2" + "@protobufjs/codegen" "^2.0.4" + "@protobufjs/eventemitter" "^1.1.0" + "@protobufjs/fetch" "^1.1.0" + "@protobufjs/float" "^1.0.2" + "@protobufjs/inquire" "^1.1.0" + "@protobufjs/path" "^1.1.2" + "@protobufjs/pool" "^1.1.0" + "@protobufjs/utf8" "^1.1.0" + "@types/node" ">=13.7.0" + long "^5.0.0" + protocols@^2.0.0, protocols@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/protocols/-/protocols-2.0.1.tgz#8f155da3fc0f32644e83c5782c8e8212ccf70a86" @@ -14641,7 +14797,7 @@ punycode.js@^2.3.1: resolved "https://registry.yarnpkg.com/punycode.js/-/punycode.js-2.3.1.tgz#6b53e56ad75588234e79f4affa90972c7dd8cdb7" integrity sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA== -punycode@2.x.x, punycode@^2.1.0, punycode@^2.1.1, punycode@^2.3.1: +punycode@2.x.x, punycode@^2.1.0, punycode@^2.1.1, punycode@^2.3.0: version "2.3.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== @@ -14723,16 +14879,6 @@ raw-body@^3.0.0, raw-body@^3.0.1: iconv-lite "~0.7.0" unpipe "~1.0.0" -raw-body@~2.5.3: - version "2.5.3" - resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.3.tgz#11c6650ee770a7de1b494f197927de0c923822e2" - integrity sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA== - dependencies: - bytes "~3.1.2" - http-errors "~2.0.1" - iconv-lite "~0.4.24" - unpipe "~1.0.0" - rc@^1.2.7, rc@^1.2.8: version "1.2.8" resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" @@ -15098,6 +15244,11 @@ retry@^0.12.0: resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" integrity sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow== +retry@^0.13.1: + version "0.13.1" + resolved "https://registry.yarnpkg.com/retry/-/retry-0.13.1.tgz#185b1587acf67919d63b357349e03537b2484658" + integrity sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg== + reusify@^1.0.2, reusify@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" @@ -15390,16 +15541,16 @@ send@~0.19.0, send@~0.19.1: debug "2.6.9" depd "2.0.0" destroy "1.2.0" - encodeurl "~2.0.0" + encodeurl "~1.0.2" escape-html "~1.0.3" etag "~1.8.1" - fresh "~0.5.2" - http-errors "~2.0.1" + fresh "0.5.2" + http-errors "2.0.0" mime "1.6.0" ms "2.1.3" - on-finished "~2.4.1" + on-finished "2.4.1" range-parser "~1.2.1" - statuses "~2.0.2" + statuses "2.0.1" seq-queue@^0.0.5: version "0.0.5" @@ -15483,7 +15634,7 @@ serve-static@~1.16.2: encodeurl "~2.0.0" escape-html "~1.0.3" parseurl "~1.3.3" - send "~0.19.1" + send "0.19.0" set-blocking@^2.0.0: version "2.0.0" @@ -15689,16 +15840,16 @@ sigstore@^2.2.0: "@sigstore/verify" "^1.2.1" sigstore@^4.0.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/sigstore/-/sigstore-4.1.0.tgz#d34b92a544a05e003a2430209d26d8dfafd805a0" - integrity sha512-/fUgUhYghuLzVT/gaJoeVehLCgZiUxPCPMcyVNY0lIf/cTCz58K/WTI7PefDarXxp9nUKpEwg1yyz3eSBMTtgA== + version "4.0.0" + resolved "https://registry.yarnpkg.com/sigstore/-/sigstore-4.0.0.tgz#cc260814a95a6027c5da24b819d5c11334af60f9" + integrity sha512-Gw/FgHtrLM9WP8P5lLcSGh9OQcrTruWCELAiS48ik1QbL0cH+dfjomiRTUE9zzz+D1N6rOLkwXUvVmXZAsNE0Q== dependencies: "@sigstore/bundle" "^4.0.0" - "@sigstore/core" "^3.1.0" + "@sigstore/core" "^3.0.0" "@sigstore/protobuf-specs" "^0.5.0" - "@sigstore/sign" "^4.1.0" - "@sigstore/tuf" "^4.0.1" - "@sigstore/verify" "^3.1.0" + "@sigstore/sign" "^4.0.0" + "@sigstore/tuf" "^4.0.0" + "@sigstore/verify" "^3.0.0" simple-concat@^1.0.0: version "1.0.1" @@ -16655,12 +16806,12 @@ tr46@^3.0.0: dependencies: punycode "^2.1.1" -tr46@^5.1.0: - version "5.1.1" - resolved "https://registry.yarnpkg.com/tr46/-/tr46-5.1.1.tgz#96ae867cddb8fdb64a49cc3059a8d428bcf238ca" - integrity sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw== +tr46@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-4.1.1.tgz#281a758dcc82aeb4fe38c7dfe4d11a395aac8469" + integrity sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw== dependencies: - punycode "^2.3.1" + punycode "^2.3.0" tr46@~0.0.3: version "0.0.3" @@ -16801,14 +16952,14 @@ tuf-js@^2.2.1: debug "^4.3.4" make-fetch-happen "^13.0.1" -tuf-js@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/tuf-js/-/tuf-js-4.1.0.tgz#ae4ef9afa456fcb4af103dc50a43bc031f066603" - integrity sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ== +tuf-js@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/tuf-js/-/tuf-js-4.0.0.tgz#dbfc7df8b4e04fd6a0c598678a8c789a3e5f9c27" + integrity sha512-Lq7ieeGvXDXwpoSmOSgLWVdsGGV9J4a77oDTAPe/Ltrqnnm/ETaRlBAQTH5JatEh8KXuE6sddf9qAv1Q2282Hg== dependencies: - "@tufjs/models" "4.1.0" - debug "^4.4.3" - make-fetch-happen "^15.0.1" + "@tufjs/models" "4.0.0" + debug "^4.4.1" + make-fetch-happen "^15.0.0" tunnel-agent@^0.6.0: version "0.6.0" @@ -16897,9 +17048,9 @@ type-fest@^4.39.1, type-fest@^4.6.0: integrity sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA== type-fest@^5.2.0: - version "5.4.1" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-5.4.1.tgz#aa9eaadcdc0acb0b5bd52e54f966ee3e38e125d2" - integrity sha512-xygQcmneDyzsEuKZrFbRMne5HDqMs++aFzefrJTgEIKjQ3rekM+RPfFCVq2Gp1VIDqddoYeppCj4Pcb+RZW0GQ== + version "5.3.1" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-5.3.1.tgz#251b8d0a813c1dbccf1f9450ba5adcdf7072adc2" + integrity sha512-VCn+LMHbd4t6sF3wfU/+HKT63C9OoyrSIf4b+vtWHpt2U7/4InZG467YDNMFMR70DdHjAdpPWmw2lzRdg0Xqqg== dependencies: tagged-tag "^1.0.0" @@ -17112,6 +17263,11 @@ undici-types@~6.21.0: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb" integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ== +undici-types@~7.16.0: + version "7.16.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.16.0.tgz#ffccdff36aea4884cbfce9a750a0580224f58a46" + integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw== + undici@^5.28.5: version "5.29.0" resolved "https://registry.yarnpkg.com/undici/-/undici-5.29.0.tgz#419595449ae3f2cdcba3580a2e8903399bd1f5a3" @@ -17120,9 +17276,9 @@ undici@^5.28.5: "@fastify/busboy" "^2.0.0" undici@^7.0.0: - version "7.18.2" - resolved "https://registry.yarnpkg.com/undici/-/undici-7.18.2.tgz#6cf724ef799a67d94fd55adf66b1e184176efcdf" - integrity sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw== + version "7.16.0" + resolved "https://registry.yarnpkg.com/undici/-/undici-7.16.0.tgz#cb2a1e957726d458b536e3f076bf51f066901c1a" + integrity sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g== unicode-emoji-modifier-base@^1.0.0: version "1.0.0" @@ -17314,11 +17470,6 @@ uuid@^10.0.0: resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294" integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ== -uuid@^13.0.0: - version "13.0.0" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-13.0.0.tgz#263dc341b19b4d755eb8fe36b78d95a6b65707e8" - integrity sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w== - uuid@^8.3.0, uuid@^8.3.2: version "8.3.2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" @@ -17371,9 +17522,9 @@ validate-npm-package-name@^5.0.0: builtins "^5.0.0" validate-npm-package-name@^7.0.0: - version "7.0.2" - resolved "https://registry.yarnpkg.com/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz#e57c3d721a4c8bbff454a246e7f7da811559ea0d" - integrity sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A== + version "7.0.0" + resolved "https://registry.yarnpkg.com/validate-npm-package-name/-/validate-npm-package-name-7.0.0.tgz#3b4fe12b4abfb8b0be010d0e75b1fe2b52295bc6" + integrity sha512-bwVk/OK+Qu108aJcMAEiU4yavHUI7aN20TgZNBj9MR2iU1zPUl1Z1Otr7771ExfYTPTvfN8ZJ1pbr5Iklgt4xg== validator@^13.9.0: version "13.15.26" @@ -17440,6 +17591,25 @@ wcwidth@^1.0.0, wcwidth@^1.0.1: dependencies: defaults "^1.0.3" +weaviate-client@^3.5.2: + version "3.10.0" + resolved "https://registry.yarnpkg.com/weaviate-client/-/weaviate-client-3.10.0.tgz#a6a9d04ad29cb8f77eeb721f1509caea0f93a6ea" + integrity sha512-PB338DjIwUus1Mq1dxhCc6fEp+yA+aY4H4sSFDS0No/GguEufd6SDhHHVLOYMy2cPgX35dWgEx5jUbG5o3aPZA== + dependencies: + abort-controller-x "^0.5.0" + graphql "^16.12.0" + graphql-request "^6.1.0" + long "^5.3.2" + nice-grpc "^2.1.13" + nice-grpc-client-middleware-retry "^3.1.12" + nice-grpc-common "^2.0.2" + uuid "^9.0.1" + +web-streams-polyfill@4.0.0-beta.3: + version "4.0.0-beta.3" + resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz#2898486b74f5156095e473efe989dcf185047a38" + integrity sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug== + web-worker@1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/web-worker/-/web-worker-1.2.0.tgz#5d85a04a7fbc1e7db58f66595d7a3ac7c9c180da" @@ -17463,12 +17633,12 @@ whatwg-url@^11.0.0: tr46 "^3.0.0" webidl-conversions "^7.0.0" -"whatwg-url@^14.1.0 || ^13.0.0": - version "14.2.0" - resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-14.2.0.tgz#4ee02d5d725155dae004f6ae95c73e7ef5d95663" - integrity sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw== +whatwg-url@^13.0.0: + version "13.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-13.0.0.tgz#b7b536aca48306394a34e44bda8e99f332410f8f" + integrity sha512-9WWbymnqj57+XEuqADHrCJ2eSXzn8WXIW/YSGaZtb2WKAInQ6CHfaUUcTyyver0p8BDg5StLQq8h1vtZuwmOig== dependencies: - tr46 "^5.1.0" + tr46 "^4.1.1" webidl-conversions "^7.0.0" whatwg-url@^5.0.0: @@ -17770,7 +17940,7 @@ yargs-parser@^22.0.0: resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-22.0.0.tgz#87b82094051b0567717346ecd00fd14804b357c8" integrity sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw== -yargs@17.7.2, yargs@^17.0.0, yargs@^17.3.1, yargs@^17.6.2: +yargs@17.7.2, yargs@^17.0.0, yargs@^17.3.1, yargs@^17.6.2, yargs@^17.7.2: version "17.7.2" resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== From b548ec5861176eb87eb5de97feda4edbecf64640 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Fri, 20 Mar 2026 14:36:36 +0100 Subject: [PATCH 05/23] fix(temp): zendesk auth --- packages/ai-proxy/src/index.ts | 2 +- packages/ai-proxy/src/integration-client.ts | 8 +- .../ai-proxy/src/integrations/brave/tools.ts | 4 +- .../ai-proxy/src/integrations/gmail/tools.ts | 4 +- .../ai-proxy/src/integrations/slack/tools.ts | 4 +- packages/ai-proxy/src/integrations/tools.ts | 2 +- .../src/integrations/zendesk/tools.ts | 11 +- .../zendesk/tools/create-ticket-comment.ts | 4 +- .../zendesk/tools/create-ticket.ts | 11 +- .../integrations/zendesk/tools/get-tickets.ts | 8 +- .../zendesk/tools/update-ticket.ts | 19 +- packages/ai-proxy/src/remote-tools.ts | 2 +- yarn.lock | 589 ++++++------------ 13 files changed, 238 insertions(+), 430 deletions(-) diff --git a/packages/ai-proxy/src/index.ts b/packages/ai-proxy/src/index.ts index 81ed7cccae..5ed97ef090 100644 --- a/packages/ai-proxy/src/index.ts +++ b/packages/ai-proxy/src/index.ts @@ -5,7 +5,7 @@ import McpConfigChecker from './mcp-config-checker'; export { createAiProvider } from './create-ai-provider'; export { default as ProviderDispatcher } from './provider-dispatcher'; -export { ForestIntegrationConfig } from './integration-client'; +export { ForestIntegrationConfig, CustomConfig } from './integration-client'; export * from './provider-dispatcher'; export * from './remote-tools'; diff --git a/packages/ai-proxy/src/integration-client.ts b/packages/ai-proxy/src/integration-client.ts index 9fb79a2a39..ffa78ae1d0 100644 --- a/packages/ai-proxy/src/integration-client.ts +++ b/packages/ai-proxy/src/integration-client.ts @@ -1,4 +1,4 @@ -import type McpServerRemoteTool from './types/mcp-server-remote-tool'; +import type McpServerRemoteTool from './mcp-server-remote-tool'; import type { Logger } from '@forestadmin/datasource-toolkit'; import type { MultiServerMCPClient } from '@langchain/mcp-adapters'; @@ -9,9 +9,11 @@ export type McpConfiguration = { configs: MultiServerMCPClient['config']['mcpServers']; } & Omit; +export type CustomConfig = SlackConfig | ZendeskConfig; + export interface ForestIntegrationConfig { - integrationName: 'slack' | 'zendesk'; - config: SlackConfig | ZendeskConfig; + integrationName: string; + config: CustomConfig; } export default class IntegrationClient { diff --git a/packages/ai-proxy/src/integrations/brave/tools.ts b/packages/ai-proxy/src/integrations/brave/tools.ts index 3eed7753dc..eedcb48e3f 100644 --- a/packages/ai-proxy/src/integrations/brave/tools.ts +++ b/packages/ai-proxy/src/integrations/brave/tools.ts @@ -1,8 +1,8 @@ -import type RemoteTool from '../../types/remote-tool'; +import type RemoteTool from '../../remote-tool'; import { BraveSearch } from '@langchain/community/tools/brave_search'; -import ServerRemoteTool from '../../types/server-remote-tool'; +import ServerRemoteTool from '../../server-remote-tool'; export interface BraveConfig { apiKey: string; diff --git a/packages/ai-proxy/src/integrations/gmail/tools.ts b/packages/ai-proxy/src/integrations/gmail/tools.ts index 98d0dbb3f3..4333a2670c 100644 --- a/packages/ai-proxy/src/integrations/gmail/tools.ts +++ b/packages/ai-proxy/src/integrations/gmail/tools.ts @@ -1,11 +1,11 @@ -import type RemoteTool from '../../types/remote-tool'; +import type RemoteTool from '../../remote-tool'; +import ServerRemoteTool from '../../server-remote-tool'; import createDraftTool from './tools/create-draft'; import createGetMessageTool from './tools/get-message'; import createGetThreadTool from './tools/get-thread'; import createSearchTool from './tools/search'; import createSendMessageTool from './tools/send-message'; -import ServerRemoteTool from '../../types/server-remote-tool'; export interface GmailConfig { accessToken: string; diff --git a/packages/ai-proxy/src/integrations/slack/tools.ts b/packages/ai-proxy/src/integrations/slack/tools.ts index 9574610f46..0683fa792d 100644 --- a/packages/ai-proxy/src/integrations/slack/tools.ts +++ b/packages/ai-proxy/src/integrations/slack/tools.ts @@ -1,4 +1,4 @@ -import type RemoteTool from '../../types/remote-tool'; +import type RemoteTool from '../../remote-tool'; import createAddReactionTool from './tools/add-reaction'; import createGetChannelHistoryTool from './tools/get-channel-history'; @@ -8,7 +8,7 @@ import createGetUsersTool from './tools/get-users'; import createListChannelsTool from './tools/list-channels'; import createPostMessageTool from './tools/post-message'; import createReplyToThreadTool from './tools/reply-to-thread'; -import ServerRemoteTool from '../../types/server-remote-tool'; +import ServerRemoteTool from '../../server-remote-tool'; export interface SlackConfig { authToken: string; diff --git a/packages/ai-proxy/src/integrations/tools.ts b/packages/ai-proxy/src/integrations/tools.ts index 320a58562c..21a7a1a1b7 100644 --- a/packages/ai-proxy/src/integrations/tools.ts +++ b/packages/ai-proxy/src/integrations/tools.ts @@ -1,4 +1,4 @@ -import type RemoteTool from '../types/remote-tool'; +import type RemoteTool from '../remote-tool'; import type { BraveConfig } from './brave/tools'; import type { GmailConfig } from './gmail/tools'; import type { SlackConfig } from './slack/tools'; diff --git a/packages/ai-proxy/src/integrations/zendesk/tools.ts b/packages/ai-proxy/src/integrations/zendesk/tools.ts index 40a5bab450..7340c7ce17 100644 --- a/packages/ai-proxy/src/integrations/zendesk/tools.ts +++ b/packages/ai-proxy/src/integrations/zendesk/tools.ts @@ -1,4 +1,4 @@ -import type RemoteTool from '../../types/remote-tool'; +import type RemoteTool from '../../remote-tool'; import createCreateTicketTool from './tools/create-ticket'; import createCreateTicketCommentTool from './tools/create-ticket-comment'; @@ -6,17 +6,22 @@ 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 ServerRemoteTool from '../../types/server-remote-tool'; +import ServerRemoteTool from '../../server-remote-tool'; export interface ZendeskConfig { subdomain: string; email: string; apiToken: string; + + // TODO: remove - for now the front sends auth instead of apiToken + authorization: string; } export default function getZendeskTools(config: ZendeskConfig): RemoteTool[] { const baseUrl = `https://${config.subdomain}.zendesk.com/api/v2`; - const auth = Buffer.from(`${config.email}/token:${config.apiToken}`).toString('base64'); + // TODO: this is a hack for now, the config should have the good props + const apiToken = config.authorization.split(' ')[1]; + const auth = Buffer.from(`${config.email}/token:${apiToken}`).toString('base64'); const headers = { Authorization: `Basic ${auth}`, 'Content-Type': 'application/json', diff --git a/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket-comment.ts b/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket-comment.ts index fae108317c..5267a9f215 100644 --- a/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket-comment.ts +++ b/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket-comment.ts @@ -15,7 +15,9 @@ export default function createCreateTicketCommentTool( .boolean() .optional() .default(true) - .describe('Whether the comment is visible to the requester (true) or internal only (false)'), + .describe( + 'Whether the comment is visible to the requester (true) or internal only (false)', + ), }), func: async ({ ticket_id, comment, public: isPublic }) => { const updateData = { diff --git a/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket.ts b/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket.ts index d66016d136..41f92e3530 100644 --- a/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket.ts +++ b/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket.ts @@ -32,7 +32,16 @@ export default function createCreateTicketTool( .optional() .describe('Custom fields to set on the ticket'), }), - func: async ({ subject, description, requester_id, assignee_id, priority, type, tags, custom_fields }) => { + func: async ({ + subject, + description, + requester_id, + assignee_id, + priority, + type, + tags, + custom_fields, + }) => { const ticketData: Record = { ticket: { subject, diff --git a/packages/ai-proxy/src/integrations/zendesk/tools/get-tickets.ts b/packages/ai-proxy/src/integrations/zendesk/tools/get-tickets.ts index 92e592c7b1..a478e3dd57 100644 --- a/packages/ai-proxy/src/integrations/zendesk/tools/get-tickets.ts +++ b/packages/ai-proxy/src/integrations/zendesk/tools/get-tickets.ts @@ -9,7 +9,13 @@ export default function createGetTicketsTool( name: 'zendesk_get_tickets', description: 'Fetch a paginated list of Zendesk tickets with sorting options', schema: z.object({ - page: z.number().int().positive().optional().default(1).describe('Page number for pagination'), + page: z + .number() + .int() + .positive() + .optional() + .default(1) + .describe('Page number for pagination'), per_page: z .number() .int() diff --git a/packages/ai-proxy/src/integrations/zendesk/tools/update-ticket.ts b/packages/ai-proxy/src/integrations/zendesk/tools/update-ticket.ts index 3721670656..902088547c 100644 --- a/packages/ai-proxy/src/integrations/zendesk/tools/update-ticket.ts +++ b/packages/ai-proxy/src/integrations/zendesk/tools/update-ticket.ts @@ -23,9 +23,22 @@ export default function createUpdateTicketTool( .enum(['problem', 'incident', 'question', 'task']) .optional() .describe('New type for the ticket'), - assignee_id: z.number().int().positive().optional().describe('New assignee ID for the ticket'), - requester_id: z.number().int().positive().optional().describe('New requester ID for the ticket'), - tags: z.array(z.string()).optional().describe('New tags for the ticket (replaces existing tags)'), + assignee_id: z + .number() + .int() + .positive() + .optional() + .describe('New assignee ID for the ticket'), + requester_id: z + .number() + .int() + .positive() + .optional() + .describe('New requester ID for the ticket'), + tags: z + .array(z.string()) + .optional() + .describe('New tags for the ticket (replaces existing tags)'), custom_fields: z .array( z.object({ diff --git a/packages/ai-proxy/src/remote-tools.ts b/packages/ai-proxy/src/remote-tools.ts index 4f4afedc72..018e3d29a0 100644 --- a/packages/ai-proxy/src/remote-tools.ts +++ b/packages/ai-proxy/src/remote-tools.ts @@ -5,7 +5,7 @@ import type { ChatCompletionCreateParamsNonStreaming } from 'openai/resources/ch import { toJsonSchema } from '@langchain/core/utils/json_schema'; import { AIToolNotFoundError, AIToolUnprocessableError } from './errors'; -import getIntegratedTools from './integrations/tools'; +import getIntegratedTools, { type IntegrationConfigs } from './integrations/tools'; export type Messages = ChatCompletionCreateParamsNonStreaming['messages']; diff --git a/yarn.lock b/yarn.lock index 614a6d2e95..9f66ce41aa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1137,8 +1137,8 @@ resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.28.6.tgz#fca903a313ae675617936e8998b814c415cbf5d7" integrity sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw== dependencies: - "@babel/template" "^7.27.2" - "@babel/types" "^7.28.4" + "@babel/template" "^7.28.6" + "@babel/types" "^7.28.6" "@babel/highlight@^7.22.13": version "7.22.20" @@ -1707,32 +1707,6 @@ object-hash "^3.0.0" uuid "^9.0.0" -"@forestadmin/forestadmin-client@1.37.3": - version "1.37.3" - resolved "https://registry.yarnpkg.com/@forestadmin/forestadmin-client/-/forestadmin-client-1.37.3.tgz#5d9c94ed31cbb5ace99f46c62810ebc4fceae291" - integrity sha512-NIsuUZOv8UuC/8sOCwxO9X+slvkouRO56ot7CXeICBc47SZ9+iFXcC64guxFU/pCFyGvxkFpvjBUlvlEgwYBrw== - dependencies: - eventsource "2.0.2" - json-api-serializer "^2.6.6" - jsonwebtoken "^9.0.0" - object-hash "^3.0.0" - openid-client "^5.7.1" - superagent "^10.2.3" - -"@forestadmin/mcp-server@1.5.5": - version "1.5.5" - resolved "https://registry.yarnpkg.com/@forestadmin/mcp-server/-/mcp-server-1.5.5.tgz#f1d60154fae130b4d0454739b03a86cdbccc5bf5" - integrity sha512-yNGpVoh1g9z7qPgBV+Z5bIeg4RlhkQejEZNrOzLrpP3nseOroG7abozfGAL3R5sgvOwikOzMHOaqdw9B/wrMqw== - dependencies: - "@forestadmin/agent-client" "1.2.3" - "@forestadmin/forestadmin-client" "1.37.3" - "@modelcontextprotocol/sdk" "^1.25.1" - cors "^2.8.5" - express "^5.2.1" - jsonapi-serializer "^3.6.9" - jsonwebtoken "^9.0.3" - zod "^4.2.1" - "@gar/promisify@^1.0.1": version "1.1.3" resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6" @@ -1749,29 +1723,6 @@ "@shikijs/types" "^3.19.0" "@shikijs/vscode-textmate" "^10.0.2" -"@graphql-typed-document-node/core@^3.2.0": - version "3.2.0" - resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.2.0.tgz#5f3d96ec6b2354ad6d8a28bf216a1d97b5426861" - integrity sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ== - -"@grpc/grpc-js@^1.14.0": - version "1.14.2" - resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.14.2.tgz#d245069181a1a8057abd35522d6052482730cf19" - integrity sha512-QzVUtEFyu05UNx2xr0fCQmStUO17uVQhGNowtxs00IgTZT6/W2PBLfUkj30s0FKJ29VtTa3ArVNIhNP6akQhqA== - dependencies: - "@grpc/proto-loader" "^0.8.0" - "@js-sdsl/ordered-map" "^4.4.2" - -"@grpc/proto-loader@^0.8.0": - version "0.8.0" - resolved "https://registry.yarnpkg.com/@grpc/proto-loader/-/proto-loader-0.8.0.tgz#b6c324dd909c458a0e4aa9bfd3d69cf78a4b9bd8" - integrity sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ== - dependencies: - lodash.camelcase "^4.3.0" - long "^5.0.0" - protobufjs "^7.5.3" - yargs "^17.7.2" - "@hapi/bourne@^3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@hapi/bourne/-/bourne-3.0.0.tgz#f11fdf7dda62fe8e336fa7c6642d9041f30356d7" @@ -1789,11 +1740,6 @@ dependencies: "@hapi/hoek" "^9.0.0" -"@hono/node-server@^1.19.9": - version "1.19.9" - resolved "https://registry.yarnpkg.com/@hono/node-server/-/node-server-1.19.9.tgz#8f37119b1acf283fd3f6035f3d1356fdb97a09ac" - integrity sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw== - "@humanwhocodes/config-array@^0.11.13": version "0.11.13" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.13.tgz#075dc9684f40a531d9b26b0822153c1e832ee297" @@ -2244,11 +2190,6 @@ resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-5.6.3.tgz#41ae1c07de1ebe0f6dde1abcbc9700a09b9c6056" integrity sha512-T1rRxzdqkEXcou0ZprN1q9yDRlvzCPLqmlNt5IIsGBzoEVgLCCYrKEwc84+TvsXuAc95VAZwtWD2zVsKPY4bcA== -"@js-sdsl/ordered-map@^4.4.2": - version "4.4.2" - resolved "https://registry.yarnpkg.com/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz#9299f82874bab9e4c7f9c48d865becbfe8d6907c" - integrity sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw== - "@koa/bodyparser@^6.0.0": version "6.0.0" resolved "https://registry.yarnpkg.com/@koa/bodyparser/-/bodyparser-6.0.0.tgz#e362ddb3691276064f36e8cbf79b66f5873360a0" @@ -2308,61 +2249,61 @@ "@langchain/classic" "1.0.20" "@langchain/openai" "1.2.10" binary-extensions "^2.2.0" - expr-eval "^2.0.2" flat "^5.0.2" - js-yaml "^4.1.0" - langchain ">=0.2.3 <0.3.0 || >=0.3.4 <0.4.0" - langsmith "^0.3.67" + js-yaml "^4.1.1" + math-expression-evaluator "^2.0.0" uuid "^10.0.0" - zod "^3.25.32" + zod "^3.25.76 || ^4" -"@langchain/core@1.1.4": - version "1.1.4" - resolved "https://registry.yarnpkg.com/@langchain/core/-/core-1.1.4.tgz#73160c719ef99c77301fb66c7cd4a3995dcc790c" - integrity sha512-AZVHVoLJzhHU/jsjeNto1pvfHaPxGT+V3PcVyvUw0kCiWftdu1bxfwhwSsZJ9B9iJeXJdCIUe089+NYd3FsEuw== +"@langchain/core@1.1.15": + version "1.1.15" + resolved "https://registry.yarnpkg.com/@langchain/core/-/core-1.1.15.tgz#31a933f4b445101ad1aa441b4f29b74e5994986e" + integrity sha512-b8RN5DkWAmDAlMu/UpTZEluYwCLpm63PPWniRKlE8ie3KkkE7IuMQ38pf4kV1iaiI+d99BEQa2vafQHfCujsRA== dependencies: "@cfworker/json-schema" "^4.0.2" ansi-styles "^5.0.0" camelcase "6" decamelize "1.2.0" js-tiktoken "^1.0.12" - langsmith "^0.3.64" + langsmith ">=0.4.0 <1.0.0" mustache "^4.2.0" p-queue "^6.6.2" uuid "^10.0.0" zod "^3.25.76 || ^4" -"@langchain/langgraph-checkpoint@^1.0.0": - version "1.0.0" - resolved "https://registry.yarnpkg.com/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.0.0.tgz#ece2ede439d0d0b0b532c4be7817fd5029afe4f8" - integrity sha512-xrclBGvNCXDmi0Nz28t3vjpxSH6UYx6w5XAXSiiB1WEdc2xD2iY/a913I3x3a31XpInUW/GGfXXfePfaghV54A== +"@langchain/langgraph-checkpoint@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.0.1.tgz#0ee5bdfeb9878d8d1d99347f23e199e5afd52995" + integrity sha512-HM0cJLRpIsSlWBQ/xuDC67l52SqZ62Bh2Y61DX+Xorqwoh5e1KxYvfCD7GnSTbWWhjBOutvnR0vPhu4orFkZfw== dependencies: uuid "^10.0.0" -"@langchain/langgraph-sdk@~1.2.0": - version "1.2.0" - resolved "https://registry.yarnpkg.com/@langchain/langgraph-sdk/-/langgraph-sdk-1.2.0.tgz#f9db1cd5524efda1e08423b9c10da370e0e2a834" - integrity sha512-nFfNJWc9P2job2uUoL37nXfz0VW9eLEtidP0edrgeHUW7BczIQzLXC9ucJHHHGLjlK0S522kmai0abAULv3pGA== +"@langchain/langgraph-sdk@~1.7.3": + version "1.7.4" + resolved "https://registry.yarnpkg.com/@langchain/langgraph-sdk/-/langgraph-sdk-1.7.4.tgz#d922299c850fb4eed379b56b0dbdd2ff4b7b5fd3" + integrity sha512-SuQyFvL9Q/eBJdSAHLaM1mmfKoh5JAmRF4PdIokX9pyVYBvJqUpvsOcUYtkC3zniHOh/65y1eqvojt/WgPvN8Q== dependencies: - p-queue "^6.6.2" - p-retry "4" - uuid "^9.0.0" + "@types/json-schema" "^7.0.15" + p-queue "^9.0.1" + p-retry "^7.1.1" + uuid "^13.0.0" -"@langchain/langgraph@1.0.4": - version "1.0.4" - resolved "https://registry.yarnpkg.com/@langchain/langgraph/-/langgraph-1.0.4.tgz#12e472b1462ae944e53d956630166be101916e85" - integrity sha512-EYLyN/uv1ubMBd3RN/y+eAxY0FJWKrnzRw8HuDJdmDcyomgV9btyHK2zDN70sO3QDDuAU9voLNNUZeFBQkBYMQ== +"@langchain/langgraph@^1.1.0": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@langchain/langgraph/-/langgraph-1.2.3.tgz#6b070f362ca05cd714f7a88730d8e5a674c79005" + integrity sha512-wvc7cQ4t6aLmI3PtVvvpN7VTqEmQunrlVnuR6t7z/1l98bj6TnQg8uS+NiJ+gF2TkVC5YXkfqY8Z4EpdD6FlcQ== dependencies: - "@langchain/langgraph-checkpoint" "^1.0.0" - "@langchain/langgraph-sdk" "~1.2.0" + "@langchain/langgraph-checkpoint" "^1.0.1" + "@langchain/langgraph-sdk" "~1.7.3" + "@standard-schema/spec" "1.1.0" uuid "^10.0.0" -"@langchain/mcp-adapters@1.0.3": - version "1.0.3" - resolved "https://registry.yarnpkg.com/@langchain/mcp-adapters/-/mcp-adapters-1.0.3.tgz#243f8717e359c27f34c417e9a37ddbda70b9c622" - integrity sha512-tQacqDcqikrli/gH7CGAOUfNJ0pFOwtoKPWq2mRP7BTApOPWyfZ8Okw5nSwPDZ7Eau/QyLAH7DaWfNHmwzXPGw== +"@langchain/mcp-adapters@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@langchain/mcp-adapters/-/mcp-adapters-1.1.1.tgz#8e29a64707abae4abe9dc8c56f6c69fa47942ea3" + integrity sha512-oMnozcaZ9e4SzPIcA8EfrSxT7TForMvPfGrUj+l/a+qHS2zcSMOSUH11joy4YwGiRVQgO/68TK8ceukWaeLbDw== dependencies: - "@modelcontextprotocol/sdk" "^1.18.2" + "@modelcontextprotocol/sdk" "^1.24.0" debug "^4.4.3" zod "^3.25.76 || ^4" optionalDependencies: @@ -2386,6 +2327,22 @@ openai "^6.18.0" zod "^3.25.76 || ^4" +"@langchain/openai@>=0.1.0 <0.7.0": + version "0.6.17" + resolved "https://registry.yarnpkg.com/@langchain/openai/-/openai-0.6.17.tgz#6e87064316d6834b7cfb88a2df2ea7ee2a4b0325" + integrity sha512-JVSzD+FL5v/2UQxKd+ikB1h4PQOtn0VlK8nqW2kPp0fshItCv4utrjBKXC/rubBnSXoRTyonBINe8QRZ6OojVQ== + dependencies: + js-tiktoken "^1.0.12" + openai "5.12.2" + zod "^3.25.32" + +"@langchain/textsplitters@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@langchain/textsplitters/-/textsplitters-1.0.1.tgz#292f9c93239178c248b3338acf7b68aa47aa9830" + integrity sha512-rheJlB01iVtrOUzttscutRgLybPH9qR79EyzBEbf1u97ljWyuxQfCwIWK+SjoQTM9O8M7GGLLRBSYE26Jmcoww== + dependencies: + js-tiktoken "^1.0.12" + "@langchain/textsplitters@>=0.0.0 <0.2.0": version "0.1.0" resolved "https://registry.yarnpkg.com/@langchain/textsplitters/-/textsplitters-0.1.0.tgz#f37620992192df09ecda3dfbd545b36a6bcbae46" @@ -2393,14 +2350,6 @@ dependencies: js-tiktoken "^1.0.12" -"@langchain/weaviate@^0.2.0": - version "0.2.3" - resolved "https://registry.yarnpkg.com/@langchain/weaviate/-/weaviate-0.2.3.tgz#7b2557ea9a369bb7ce05dfc553f56b6ff062d90b" - integrity sha512-WqNGn1eSrI+ZigJd7kZjCj3fvHBYicKr054qts2nNJ+IyO5dWmY3oFTaVHFq1OLFVZJJxrFeDnxSEOC3JnfP0w== - dependencies: - uuid "^10.0.0" - weaviate-client "^3.5.2" - "@lerna/create@8.2.3": version "8.2.3" resolved "https://registry.yarnpkg.com/@lerna/create/-/create-8.2.3.tgz#8e88fedb60eb699f2f5057e7344d9f980b7f9554" @@ -2502,6 +2451,7 @@ resolved "https://registry.yarnpkg.com/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz#5b35d73062125f126cc70b0be83cbab53bcdde74" integrity sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg== dependencies: + "@hono/node-server" "^1.19.9" ajv "^8.17.1" ajv-formats "^3.0.1" content-type "^1.0.5" @@ -2526,10 +2476,10 @@ dependencies: sparse-bitfield "^3.0.3" -"@mongodb-js/saslprep@^1.1.5": - version "1.4.4" - resolved "https://registry.yarnpkg.com/@mongodb-js/saslprep/-/saslprep-1.4.4.tgz#34a946ff6ae142e8f2259b87f2935f8284ba874d" - integrity sha512-p7X/ytJDIdwUfFL/CLOhKgdfJe1Fa8uw9seJYvdOmnP9JBWGWHW69HkOixXS6Wy9yvGf1MbhcS6lVmrhy4jm2g== +"@mongodb-js/saslprep@^1.3.0": + version "1.4.6" + resolved "https://registry.yarnpkg.com/@mongodb-js/saslprep/-/saslprep-1.4.6.tgz#2edf5819fa0e69d86059f44d1fe57ae9d7817c12" + integrity sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g== dependencies: sparse-bitfield "^3.0.3" @@ -3358,59 +3308,6 @@ "@pnpm/network.ca-file" "^1.0.1" config-chain "^1.1.11" -"@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf" - integrity sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ== - -"@protobufjs/base64@^1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@protobufjs/base64/-/base64-1.1.2.tgz#4c85730e59b9a1f1f349047dbf24296034bb2735" - integrity sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg== - -"@protobufjs/codegen@^2.0.4": - version "2.0.4" - resolved "https://registry.yarnpkg.com/@protobufjs/codegen/-/codegen-2.0.4.tgz#7ef37f0d010fb028ad1ad59722e506d9262815cb" - integrity sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg== - -"@protobufjs/eventemitter@^1.1.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz#355cbc98bafad5978f9ed095f397621f1d066b70" - integrity sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q== - -"@protobufjs/fetch@^1.1.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@protobufjs/fetch/-/fetch-1.1.0.tgz#ba99fb598614af65700c1619ff06d454b0d84c45" - integrity sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ== - dependencies: - "@protobufjs/aspromise" "^1.1.1" - "@protobufjs/inquire" "^1.1.0" - -"@protobufjs/float@^1.0.2": - version "1.0.2" - resolved "https://registry.yarnpkg.com/@protobufjs/float/-/float-1.0.2.tgz#5e9e1abdcb73fc0a7cb8b291df78c8cbd97b87d1" - integrity sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ== - -"@protobufjs/inquire@^1.1.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@protobufjs/inquire/-/inquire-1.1.0.tgz#ff200e3e7cf2429e2dcafc1140828e8cc638f089" - integrity sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q== - -"@protobufjs/path@^1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@protobufjs/path/-/path-1.1.2.tgz#6cc2b20c5c9ad6ad0dccfd21ca7673d8d7fbf68d" - integrity sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA== - -"@protobufjs/pool@^1.1.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@protobufjs/pool/-/pool-1.1.0.tgz#09fd15f2d6d3abfa9b65bc366506d6ad7846ff54" - integrity sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw== - -"@protobufjs/utf8@^1.1.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" - integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== - "@qiwi/multi-semantic-release@^7.1.2": version "7.1.2" resolved "https://registry.yarnpkg.com/@qiwi/multi-semantic-release/-/multi-semantic-release-7.1.2.tgz#bc1cc8a5ef98160939b39fef01d40d2334eaac4e" @@ -4247,6 +4144,11 @@ dependencies: tslib "^2.6.2" +"@standard-schema/spec@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8" + integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== + "@tokenizer/token@^0.3.0": version "0.3.0" resolved "https://registry.yarnpkg.com/@tokenizer/token/-/token-0.3.0.tgz#fe98a93fe789247e998c75e74e9c7c63217aa276" @@ -4543,7 +4445,7 @@ resolved "https://registry.yarnpkg.com/@types/json-api-serializer/-/json-api-serializer-2.6.6.tgz#26b5381214aa19bb98a6931fe41c3a336fc7f169" integrity sha512-8XVIVyMNoFMz3pfR3tPHnJ9YlgUQDEWvTxajVakmOjSxWekJvmi2GRFbtaREQiOGtffnHImD0jbR80NQtpib9g== -"@types/json-schema@*", "@types/json-schema@^7.0.9": +"@types/json-schema@*", "@types/json-schema@^7.0.15", "@types/json-schema@^7.0.9": version "7.0.15" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== @@ -4642,14 +4544,6 @@ resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.34.tgz#10964ba0dee6ac4cd462e2795b6bebd407303433" integrity sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g== -"@types/node-fetch@^2.6.4": - version "2.6.13" - resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.13.tgz#e0c9b7b5edbdb1b50ce32c127e85e880872d56ee" - integrity sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw== - dependencies: - "@types/node" "*" - form-data "^4.0.4" - "@types/node@*": version "20.9.0" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.9.0.tgz#bfcdc230583aeb891cf51e73cfdaacdd8deae298" @@ -4662,13 +4556,6 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-20.5.1.tgz#178d58ee7e4834152b0e8b4d30cbfab578b9bb30" integrity sha512-4tT2UrL5LBqDwoed9wZ6N3umC4Yhz3W3FloMmiiG4JwmUJWpie0c7lcnUNd4gtMKuDEO4wRVS8B6Xa0uMRsMKg== -"@types/node@>=13.7.0": - version "25.0.0" - resolved "https://registry.yarnpkg.com/@types/node/-/node-25.0.0.tgz#c0e0022c3c7b41635c49322e6b3a0279fffa7d62" - integrity sha512-rl78HwuZlaDIUSeUKkmogkhebA+8K1Hy7tddZuJ3D0xV8pZSfsYGTsliGUol1JPzu9EKnTxPC4L1fiWouStRew== - dependencies: - undici-types "~7.16.0" - "@types/node@>=18": version "22.10.2" resolved "https://registry.yarnpkg.com/@types/node/-/node-22.10.2.tgz#a485426e6d1fdafc7b0d4c7b24e2c78182ddabb9" @@ -4740,6 +4627,11 @@ "@types/node" "*" safe-buffer "~5.1.1" +"@types/retry@0.12.0": + version "0.12.0" + resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" + integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA== + "@types/semver@^7.3.12": version "7.5.5" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.5.tgz#deed5ab7019756c9c90ea86139106b0346223f35" @@ -5004,16 +4896,6 @@ abbrev@^4.0.0: resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-4.0.0.tgz#ec933f0e27b6cd60e89b5c6b2a304af42209bb05" integrity sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA== -abort-controller-x@^0.4.0: - version "0.4.3" - resolved "https://registry.yarnpkg.com/abort-controller-x/-/abort-controller-x-0.4.3.tgz#ff269788386fabd58a7b6eeaafcb6cf55c2958e0" - integrity sha512-VtUwTNU8fpMwvWGn4xE93ywbogTYsuT+AUxAXOeelbXuQVIwNmC5YLeho9sH4vZ4ITW8414TTAOG1nW6uIVHCA== - -abort-controller-x@^0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/abort-controller-x/-/abort-controller-x-0.5.0.tgz#2c0531a83c7717eccd47435bfe123bccfd34e2b8" - integrity sha512-yTt9CI0x+nRfX6BFMenEGP8ooPvErGH6AbFz20C2IeOLIlDsrw/VHpgne3GsCEuTA410IiFiaLVFKmgM4bKEPQ== - abort-controller@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" @@ -5093,13 +4975,6 @@ agentkeepalive@^4.1.3: dependencies: humanize-ms "^1.2.1" -agentkeepalive@^4.2.1: - version "4.6.0" - resolved "https://registry.yarnpkg.com/agentkeepalive/-/agentkeepalive-4.6.0.tgz#35f73e94b3f40bf65f105219c623ad19c136ea6a" - integrity sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ== - dependencies: - humanize-ms "^1.2.1" - aggregate-error@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a" @@ -5814,24 +5689,6 @@ body-parser@^2.2.0, body-parser@^2.2.1: raw-body "^3.0.1" type-is "^2.0.1" -body-parser@~1.20.3: - version "1.20.4" - resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.4.tgz#f8e20f4d06ca8a50a71ed329c15dccad1cdc547f" - integrity sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA== - dependencies: - bytes "~3.1.2" - content-type "~1.0.5" - debug "2.6.9" - depd "2.0.0" - destroy "~1.2.0" - http-errors "~2.0.1" - iconv-lite "~0.4.24" - on-finished "~2.4.1" - qs "~6.14.0" - raw-body "~2.5.3" - type-is "~1.6.18" - unpipe "~1.0.0" - boolbase@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" @@ -5918,7 +5775,7 @@ bson@^4.7.2: dependencies: buffer "^5.6.0" -bson@^6.7.0: +bson@^6.10.4: version "6.10.4" resolved "https://registry.yarnpkg.com/bson/-/bson-6.10.4.tgz#d530733bb5bb16fb25c162e01a3344fab332fd2b" integrity sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng== @@ -6649,7 +6506,7 @@ console-table-printer@^2.12.1: dependencies: simple-wcswidth "^1.1.2" -content-disposition@0.5.4, content-disposition@^0.5.3, content-disposition@~0.5.2, content-disposition@~0.5.4: +content-disposition@0.5.4, content-disposition@^0.5.3, content-disposition@~0.5.2: version "0.5.4" resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== @@ -6802,11 +6659,6 @@ cookie-signature@^1.2.1: resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.2.2.tgz#57c7fc3cc293acab9fec54d73e15690ebe4a1793" integrity sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg== -cookie-signature@~1.0.6: - version "1.0.7" - resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.7.tgz#ab5dd7ab757c54e60f37ef6550f481c426d10454" - integrity sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA== - cookie@0.7.1: version "0.7.1" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.1.tgz#2f73c42142d5d5cf71310a74fc4ae61670e5dbc9" @@ -6822,7 +6674,7 @@ cookie@^0.5.0: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== -cookie@^0.7.0, cookie@^0.7.1, cookie@~0.7.1: +cookie@^0.7.0, cookie@^0.7.1: version "0.7.2" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== @@ -6914,13 +6766,6 @@ croner@^6.0.6: resolved "https://registry.yarnpkg.com/croner/-/croner-6.0.7.tgz#73416ee178626c226a5765e498e1e8454738bdba" integrity sha512-k3Xx3Rcclfr60Yx4TmvsF3Yscuiql8LSvYLaphTsaq5Hk8La4Z/udmUANMOTKpgGGroI2F6/XOr9cU9OFkYluQ== -cross-fetch@^3.1.5: - version "3.2.0" - resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.2.0.tgz#34e9192f53bc757d6614304d9e5e6fb4edb782e3" - integrity sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q== - dependencies: - node-fetch "^2.7.0" - cross-fetch@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-4.1.0.tgz#8f69355007ee182e47fa692ecbaa37a52e43c3d2" @@ -7951,6 +7796,11 @@ eventemitter3@^4.0.4: resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== +eventemitter3@^5.0.1: + version "5.0.4" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.4.tgz#a86d66170433712dde814707ac52b5271ceb1feb" + integrity sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw== + events@^3.0.0, events@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" @@ -8107,6 +7957,11 @@ exponential-backoff@^3.1.1: resolved "https://registry.yarnpkg.com/exponential-backoff/-/exponential-backoff-3.1.1.tgz#64ac7526fe341ab18a39016cd22c787d01e00bf6" integrity sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw== +expr-eval@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/expr-eval/-/expr-eval-2.0.2.tgz#fa6f044a7b0c93fde830954eb9c5b0f7fbc7e201" + integrity sha512-4EMSHGOPSwAfBiibw3ndnP0AvjDWLsMvGOvWEZ2F96IGk0bIVdjQisOHxReSkE13mHcfbuCiXw+G4y0zv6N8Eg== + express-rate-limit@^8.2.1: version "8.2.1" resolved "https://registry.yarnpkg.com/express-rate-limit/-/express-rate-limit-8.2.1.tgz#ec75fdfe280ecddd762b8da8784c61bae47d7f7f" @@ -8191,32 +8046,32 @@ express@^4.17.1, express@^4.18.2: dependencies: accepts "~1.3.8" array-flatten "1.1.1" - body-parser "1.20.3" - content-disposition "0.5.4" + body-parser "~1.20.3" + content-disposition "~0.5.4" content-type "~1.0.4" - cookie "0.7.1" - cookie-signature "1.0.6" + cookie "~0.7.1" + cookie-signature "~1.0.6" debug "2.6.9" depd "2.0.0" encodeurl "~2.0.0" escape-html "~1.0.3" etag "~1.8.1" - finalhandler "1.3.1" - fresh "0.5.2" - http-errors "2.0.0" + finalhandler "~1.3.1" + fresh "~0.5.2" + http-errors "~2.0.0" merge-descriptors "1.0.3" methods "~1.1.2" - on-finished "2.4.1" + on-finished "~2.4.1" parseurl "~1.3.3" - path-to-regexp "0.1.12" + path-to-regexp "~0.1.12" proxy-addr "~2.0.7" - qs "6.13.0" + qs "~6.14.0" range-parser "~1.2.1" safe-buffer "5.2.1" - send "0.19.0" - serve-static "1.16.2" + send "~0.19.0" + serve-static "~1.16.2" setprototypeof "1.2.0" - statuses "2.0.1" + statuses "~2.0.1" type-is "~1.6.18" utils-merge "1.0.1" vary "~1.1.2" @@ -8648,19 +8503,6 @@ finalhandler@^2.1.0: parseurl "^1.3.3" statuses "^2.0.1" -finalhandler@~1.3.1: - version "1.3.2" - resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.3.2.tgz#1ebc2228fc7673aac4a472c310cc05b77d852b88" - integrity sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg== - dependencies: - debug "2.6.9" - encodeurl "~2.0.0" - escape-html "~1.0.3" - on-finished "2.4.1" - parseurl "~1.3.3" - statuses "2.0.1" - unpipe "~1.0.0" - find-my-way@^2.2.2: version "2.2.5" resolved "https://registry.yarnpkg.com/find-my-way/-/find-my-way-2.2.5.tgz#86ce825266fa28cd962e538a45ec2aaa84c3d514" @@ -8828,11 +8670,6 @@ forest-ip-utils@^1.0.1: ip-address "^5.8.9" range_check "^1.4.0" -form-data-encoder@1.7.2: - version "1.7.2" - resolved "https://registry.yarnpkg.com/form-data-encoder/-/form-data-encoder-1.7.2.tgz#1f1ae3dccf58ed4690b86d87e4f57c654fbab040" - integrity sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A== - form-data@^4.0.0, form-data@^4.0.4: version "4.0.4" resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.4.tgz#784cdcce0669a9d68e94d11ac4eea98088edd2c4" @@ -9348,14 +9185,6 @@ graphemer@^1.4.0: resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== -graphql-request@^6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/graphql-request/-/graphql-request-6.1.0.tgz#f4eb2107967af3c7a5907eb3131c671eac89be4f" - integrity sha512-p+XPfS4q7aIpKVcgmnZKhMNqhltk20hfXtkaIkTfjjmiKMJ5xrt5c743cL03y/K7y1rg3WrIC49xGiEQ4mxdNw== - dependencies: - "@graphql-typed-document-node/core" "^3.2.0" - cross-fetch "^3.1.5" - graphql-tag@^2.12.6: version "2.12.6" resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.12.6.tgz#d441a569c1d2537ef10ca3d1633b48725329b5f1" @@ -9370,11 +9199,6 @@ graphql@14.5.7: dependencies: iterall "^1.2.2" -graphql@^16.12.0: - version "16.12.0" - resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.12.0.tgz#28cc2462435b1ac3fdc6976d030cef83a0c13ac7" - integrity sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ== - handlebars@4.7.7: version "4.7.7" resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.7.tgz#9ce33416aad02dbd6c8fafa8240d5d98004945a1" @@ -9387,7 +9211,7 @@ handlebars@4.7.7: optionalDependencies: uglify-js "^3.1.4" -handlebars@^4.7.7: +handlebars@^4.7.7, handlebars@^4.7.8: version "4.7.8" resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.8.tgz#41c42c18b1be2365439188c77c6afae71c0cd9e9" integrity sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ== @@ -9610,7 +9434,7 @@ http-errors@^1.6.3, http-errors@~1.8.0: statuses ">= 1.5.0 < 2" toidentifier "1.0.1" -http-errors@^2.0.1, http-errors@~2.0.0, http-errors@~2.0.1: +http-errors@^2.0.1, http-errors@~2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.1.tgz#36d2f65bc909c8790018dd36fb4d93da6caae06b" integrity sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ== @@ -10249,6 +10073,11 @@ is-negative-zero@^2.0.3: resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.3.tgz#ced903a027aca6381b777a5743069d7376a49747" integrity sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw== +is-network-error@^1.1.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/is-network-error/-/is-network-error-1.3.1.tgz#a2a86b80ffd6b05b774755c73c8aaab16597e58d" + integrity sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw== + is-number-object@^1.0.4: version "1.0.7" resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.7.tgz#59d50ada4c45251784e9904f5246c742f07a42fc" @@ -11427,7 +11256,19 @@ koa@^3.0.1: yaml "^2.2.1" zod "^3.25.32" -langsmith@^0.3.64, langsmith@^0.3.67: +"langsmith@>=0.4.0 <1.0.0": + version "0.5.11" + resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.5.11.tgz#98994aaa051b0c807c31731ac3664f9415174f51" + integrity sha512-Yio502Ow2vbVt16P1sybNMNpMsr5BMqoeonoi4flrcDsP55No/aCe2zydtBNOv0+kjKQw4WSKAzTsNwenDeD5w== + dependencies: + "@types/uuid" "^10.0.0" + chalk "^5.6.2" + console-table-printer "^2.12.1" + p-queue "^6.6.2" + semver "^7.6.3" + uuid "^10.0.0" + +langsmith@^0.3.67: version "0.3.85" resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.3.85.tgz#5b44287b140c8db1063462ca6243fd1bb3272f31" integrity sha512-Txuaxnpcra57qld4+hkqHhd9L2D6G6kEAReWXdr/sddxPu6ycBHXStqDciituC642lJmzPdarrYtll2vSwMbnQ== @@ -11957,11 +11798,6 @@ loglevel@^1.4.1: resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.8.1.tgz#5c621f83d5b48c54ae93b6156353f555963377b4" integrity sha512-tCRIJM51SHjAayKwC+QAg8hT8vg6z7GSgLJKGvzuPb1Wc+hLzqtuVLxp6/HzSPOozuK+8ErAhy7U/sVzw8Dgfg== -long@^5.0.0, long@^5.3.2: - version "5.3.2" - resolved "https://registry.yarnpkg.com/long/-/long-5.3.2.tgz#1d84463095999262d7d7b7f8bfd4a8cc55167f83" - integrity sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA== - long@^5.2.1: version "5.2.3" resolved "https://registry.yarnpkg.com/long/-/long-5.2.3.tgz#a3ba97f3877cf1d778eccbcb048525ebb77499e1" @@ -12740,13 +12576,13 @@ mongodb-connection-string-url@^2.6.0: "@types/whatwg-url" "^8.2.1" whatwg-url "^11.0.0" -mongodb-connection-string-url@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.1.tgz#c13e6ac284ae401752ebafdb8cd7f16c6723b141" - integrity sha512-XqMGwRX0Lgn05TDB4PyG2h2kKO/FfWJyCzYQbIhXUxz7ETt0I/FqHjUeqj37irJ+Dl1ZtU82uYyj14u2XsZKfg== +mongodb-connection-string-url@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz#e223089dfa0a5fa9bf505f8aedcbc67b077b33e7" + integrity sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA== dependencies: "@types/whatwg-url" "^11.0.2" - whatwg-url "^13.0.0" + whatwg-url "^14.1.0 || ^13.0.0" mongodb@4.17.2: version "4.17.2" @@ -12760,23 +12596,23 @@ mongodb@4.17.2: "@aws-sdk/credential-providers" "^3.186.0" "@mongodb-js/saslprep" "^1.1.0" -mongodb@~6.10.0: - version "6.10.0" - resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-6.10.0.tgz#20a9f1cf3c6829e75fc39e6d8c1c19f164209c2e" - integrity sha512-gP9vduuYWb9ZkDM546M+MP2qKVk5ZG2wPF63OvSRuUbqCR+11ZCAE1mOfllhlAG0wcoJY5yDL/rV3OmYEwXIzg== +mongodb@~6.20.0: + version "6.20.0" + resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-6.20.0.tgz#5212dcf512719385287aa4574265352eefb01d8e" + integrity sha512-Tl6MEIU3K4Rq3TSHd+sZQqRBoGlFsOgNrH5ltAcFBV62Re3Fd+FcaVf8uSEQFOJ51SDowDVttBTONMfoYWrWlQ== dependencies: - "@mongodb-js/saslprep" "^1.1.5" - bson "^6.7.0" - mongodb-connection-string-url "^3.0.0" + "@mongodb-js/saslprep" "^1.3.0" + bson "^6.10.4" + mongodb-connection-string-url "^3.0.2" -mongoose@8.8.4: - version "8.8.4" - resolved "https://registry.yarnpkg.com/mongoose/-/mongoose-8.8.4.tgz#11e3991a7fd03596a79bc9f9b2fe8f3e75b7a30d" - integrity sha512-yJbn695qCsqDO+xyPII29x2R7flzXhxCDv09mMZPSGllf0sm4jKw3E9s9uvQ9hjO6bL2xjU8KKowYqcY9eSTMQ== +mongoose@8.21.0: + version "8.21.0" + resolved "https://registry.yarnpkg.com/mongoose/-/mongoose-8.21.0.tgz#e4b940a6b22c2fc176916667766f34656e352906" + integrity sha512-dW2U01gN8EVQT5KAO5AkzjbqWc8A/CsEq15jOzq/M9ISpy8jw3iq7W9ZP135h9zykFOMt3AMxq4+anvt2YNJgw== dependencies: - bson "^6.7.0" + bson "^6.10.4" kareem "2.6.3" - mongodb "~6.10.0" + mongodb "~6.20.0" mpath "0.9.0" mquery "5.0.0" ms "2.1.3" @@ -12976,30 +12812,6 @@ next-tick@1, next-tick@^1.1.0: resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.1.0.tgz#1836ee30ad56d67ef281b22bd199f709449b35eb" integrity sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ== -nice-grpc-client-middleware-retry@^3.1.12: - version "3.1.13" - resolved "https://registry.yarnpkg.com/nice-grpc-client-middleware-retry/-/nice-grpc-client-middleware-retry-3.1.13.tgz#25de76d3ab86328a35e3b5c9093a4cb03d98b2a0" - integrity sha512-Q9I/wm5lYkDTveKFirrTHBkBY137yavXZ4xQDXTPIycUp7aLXD8xPTHFhqtAFWUw05aS91uffZZRgdv3HS0y/g== - dependencies: - abort-controller-x "^0.4.0" - nice-grpc-common "^2.0.2" - -nice-grpc-common@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/nice-grpc-common/-/nice-grpc-common-2.0.2.tgz#e6aeebb2bd19d87114b351e291e30d79dd38acf7" - integrity sha512-7RNWbls5kAL1QVUOXvBsv1uO0wPQK3lHv+cY1gwkTzirnG1Nop4cBJZubpgziNbaVc/bl9QJcyvsf/NQxa3rjQ== - dependencies: - ts-error "^1.0.6" - -nice-grpc@^2.1.13: - version "2.1.14" - resolved "https://registry.yarnpkg.com/nice-grpc/-/nice-grpc-2.1.14.tgz#ce598d52a8218e4312b9f8ac0f179e20049154e3" - integrity sha512-GK9pKNxlvnU5FAdaw7i2FFuR9CqBspcE+if2tqnKXBcE0R8525wj4BZvfcwj7FjvqbssqKxRHt2nwedalbJlww== - dependencies: - "@grpc/grpc-js" "^1.14.0" - abort-controller-x "^0.4.0" - nice-grpc-common "^2.0.2" - nice-try@^1.0.4: version "1.0.5" resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" @@ -13031,11 +12843,6 @@ node-addon-api@^7.0.0: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-7.1.1.tgz#1aba6693b0f255258a049d621329329322aad558" integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ== -node-domexception@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" - integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== - node-emoji@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/node-emoji/-/node-emoji-2.2.0.tgz#1d000e3c76e462577895be1b436f4aa2d6760eb0" @@ -13738,6 +13545,11 @@ open@^8.0.0, open@^8.4.0: is-docker "^2.1.1" is-wsl "^2.2.0" +openai@5.12.2: + version "5.12.2" + resolved "https://registry.yarnpkg.com/openai/-/openai-5.12.2.tgz#512ab6b80eb8414837436e208f1b951442b97761" + integrity sha512-xqzHHQch5Tws5PcKR2xsZGX9xtch+JQFz5zb14dGqlshmmDAFBFEWmeIpf7wVqWV+w7Emj7jRgkNJakyKE0tYQ== + openai@^6.18.0: version "6.18.0" resolved "https://registry.yarnpkg.com/openai/-/openai-6.18.0.tgz#bd6c0bdb1aebf93375d324de51756280f7e85c6f" @@ -13957,6 +13769,14 @@ p-queue@6.6.2, p-queue@^6.6.2: eventemitter3 "^4.0.4" p-timeout "^3.2.0" +p-queue@^9.0.1: + version "9.1.0" + resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-9.1.0.tgz#846e517c461fb6e3cf8fc09904e57d342ac448b2" + integrity sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw== + dependencies: + eventemitter3 "^5.0.1" + p-timeout "^7.0.0" + p-reduce@2.1.0, p-reduce@^2.0.0, p-reduce@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/p-reduce/-/p-reduce-2.1.0.tgz#09408da49507c6c274faa31f28df334bc712b64a" @@ -13975,6 +13795,13 @@ p-retry@4: "@types/retry" "0.12.0" retry "^0.13.1" +p-retry@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-7.1.1.tgz#7470fdecb1152ba50f1334e48378c9e401330e24" + integrity sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w== + dependencies: + is-network-error "^1.1.0" + p-some@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/p-some/-/p-some-4.1.0.tgz#28e73bc1e0d62db54c2ed513acd03acba30d5c04" @@ -13995,6 +13822,11 @@ p-timeout@^6.1.2: resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-6.1.4.tgz#418e1f4dd833fa96a2e3f532547dd2abdb08dbc2" integrity sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg== +p-timeout@^7.0.0: + version "7.0.1" + resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-7.0.1.tgz#95680a6aa693c530f14ac337b8bd32d4ec6ae4f0" + integrity sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg== + p-try@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3" @@ -14239,7 +14071,7 @@ path-scurry@^2.0.0: lru-cache "^11.0.0" minipass "^7.1.2" -path-to-regexp@0.1.12, path-to-regexp@~0.1.12: +path-to-regexp@0.1.12: version "0.1.12" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.12.tgz#d5e1a12e478a976d432ef3c58d534b9923164bb7" integrity sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ== @@ -14743,24 +14575,6 @@ proto-list@~1.2.1: resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" integrity sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA== -protobufjs@^7.5.3: - version "7.5.4" - resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-7.5.4.tgz#885d31fe9c4b37f25d1bb600da30b1c5b37d286a" - integrity sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg== - dependencies: - "@protobufjs/aspromise" "^1.1.2" - "@protobufjs/base64" "^1.1.2" - "@protobufjs/codegen" "^2.0.4" - "@protobufjs/eventemitter" "^1.1.0" - "@protobufjs/fetch" "^1.1.0" - "@protobufjs/float" "^1.0.2" - "@protobufjs/inquire" "^1.1.0" - "@protobufjs/path" "^1.1.2" - "@protobufjs/pool" "^1.1.0" - "@protobufjs/utf8" "^1.1.0" - "@types/node" ">=13.7.0" - long "^5.0.0" - protocols@^2.0.0, protocols@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/protocols/-/protocols-2.0.1.tgz#8f155da3fc0f32644e83c5782c8e8212ccf70a86" @@ -14797,7 +14611,7 @@ punycode.js@^2.3.1: resolved "https://registry.yarnpkg.com/punycode.js/-/punycode.js-2.3.1.tgz#6b53e56ad75588234e79f4affa90972c7dd8cdb7" integrity sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA== -punycode@2.x.x, punycode@^2.1.0, punycode@^2.1.1, punycode@^2.3.0: +punycode@2.x.x, punycode@^2.1.0, punycode@^2.1.1, punycode@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== @@ -14812,7 +14626,7 @@ qrcode-terminal@^0.12.0: resolved "https://registry.yarnpkg.com/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz#bb5b699ef7f9f0505092a3748be4464fe71b5819" integrity sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ== -qs@6.13.0, qs@>=6.14.1, qs@^6.11.2, qs@^6.14.0, qs@^6.14.1, qs@^6.5.2, qs@~6.14.0: +qs@6.13.0, qs@>=6.14.1, qs@^6.11.2, qs@^6.14.0, qs@^6.14.1, qs@^6.5.2: version "6.14.2" resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.2.tgz#b5634cf9d9ad9898e31fba3504e866e8efb6798c" integrity sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q== @@ -15533,25 +15347,6 @@ send@^1.1.0, send@^1.2.0: range-parser "^1.2.1" statuses "^2.0.2" -send@~0.19.0, send@~0.19.1: - version "0.19.2" - resolved "https://registry.yarnpkg.com/send/-/send-0.19.2.tgz#59bc0da1b4ea7ad42736fd642b1c4294e114ff29" - integrity sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg== - dependencies: - debug "2.6.9" - depd "2.0.0" - destroy "1.2.0" - encodeurl "~1.0.2" - escape-html "~1.0.3" - etag "~1.8.1" - fresh "0.5.2" - http-errors "2.0.0" - mime "1.6.0" - ms "2.1.3" - on-finished "2.4.1" - range-parser "~1.2.1" - statuses "2.0.1" - seq-queue@^0.0.5: version "0.0.5" resolved "https://registry.yarnpkg.com/seq-queue/-/seq-queue-0.0.5.tgz#d56812e1c017a6e4e7c3e3a37a1da6d78dd3c93e" @@ -15626,16 +15421,6 @@ serve-static@^2.2.0: parseurl "^1.3.3" send "^1.2.0" -serve-static@~1.16.2: - version "1.16.3" - resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.16.3.tgz#a97b74d955778583f3862a4f0b841eb4d5d78cf9" - integrity sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA== - dependencies: - encodeurl "~2.0.0" - escape-html "~1.0.3" - parseurl "~1.3.3" - send "0.19.0" - set-blocking@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" @@ -16188,7 +15973,7 @@ statuses@2.0.1: resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== -statuses@^2.0.1, statuses@^2.0.2, statuses@~2.0.1, statuses@~2.0.2: +statuses@^2.0.1, statuses@^2.0.2, statuses@~2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382" integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== @@ -16806,12 +16591,12 @@ tr46@^3.0.0: dependencies: punycode "^2.1.1" -tr46@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/tr46/-/tr46-4.1.1.tgz#281a758dcc82aeb4fe38c7dfe4d11a395aac8469" - integrity sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw== +tr46@^5.1.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-5.1.1.tgz#96ae867cddb8fdb64a49cc3059a8d428bcf238ca" + integrity sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw== dependencies: - punycode "^2.3.0" + punycode "^2.3.1" tr46@~0.0.3: version "0.0.3" @@ -17263,11 +17048,6 @@ undici-types@~6.21.0: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb" integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ== -undici-types@~7.16.0: - version "7.16.0" - resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.16.0.tgz#ffccdff36aea4884cbfce9a750a0580224f58a46" - integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw== - undici@^5.28.5: version "5.29.0" resolved "https://registry.yarnpkg.com/undici/-/undici-5.29.0.tgz#419595449ae3f2cdcba3580a2e8903399bd1f5a3" @@ -17470,6 +17250,11 @@ uuid@^10.0.0: resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294" integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ== +uuid@^13.0.0: + version "13.0.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-13.0.0.tgz#263dc341b19b4d755eb8fe36b78d95a6b65707e8" + integrity sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w== + uuid@^8.3.0, uuid@^8.3.2: version "8.3.2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" @@ -17591,25 +17376,6 @@ wcwidth@^1.0.0, wcwidth@^1.0.1: dependencies: defaults "^1.0.3" -weaviate-client@^3.5.2: - version "3.10.0" - resolved "https://registry.yarnpkg.com/weaviate-client/-/weaviate-client-3.10.0.tgz#a6a9d04ad29cb8f77eeb721f1509caea0f93a6ea" - integrity sha512-PB338DjIwUus1Mq1dxhCc6fEp+yA+aY4H4sSFDS0No/GguEufd6SDhHHVLOYMy2cPgX35dWgEx5jUbG5o3aPZA== - dependencies: - abort-controller-x "^0.5.0" - graphql "^16.12.0" - graphql-request "^6.1.0" - long "^5.3.2" - nice-grpc "^2.1.13" - nice-grpc-client-middleware-retry "^3.1.12" - nice-grpc-common "^2.0.2" - uuid "^9.0.1" - -web-streams-polyfill@4.0.0-beta.3: - version "4.0.0-beta.3" - resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz#2898486b74f5156095e473efe989dcf185047a38" - integrity sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug== - web-worker@1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/web-worker/-/web-worker-1.2.0.tgz#5d85a04a7fbc1e7db58f66595d7a3ac7c9c180da" @@ -17633,12 +17399,12 @@ whatwg-url@^11.0.0: tr46 "^3.0.0" webidl-conversions "^7.0.0" -whatwg-url@^13.0.0: - version "13.0.0" - resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-13.0.0.tgz#b7b536aca48306394a34e44bda8e99f332410f8f" - integrity sha512-9WWbymnqj57+XEuqADHrCJ2eSXzn8WXIW/YSGaZtb2WKAInQ6CHfaUUcTyyver0p8BDg5StLQq8h1vtZuwmOig== +"whatwg-url@^14.1.0 || ^13.0.0": + version "14.2.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-14.2.0.tgz#4ee02d5d725155dae004f6ae95c73e7ef5d95663" + integrity sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw== dependencies: - tr46 "^4.1.1" + tr46 "^5.1.0" webidl-conversions "^7.0.0" whatwg-url@^5.0.0: @@ -17940,7 +17706,7 @@ yargs-parser@^22.0.0: resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-22.0.0.tgz#87b82094051b0567717346ecd00fd14804b357c8" integrity sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw== -yargs@17.7.2, yargs@^17.0.0, yargs@^17.3.1, yargs@^17.6.2, yargs@^17.7.2: +yargs@17.7.2, yargs@^17.0.0, yargs@^17.3.1, yargs@^17.6.2: version "17.7.2" resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== @@ -18026,6 +17792,11 @@ zod-to-json-schema@^3.25.1: resolved "https://registry.yarnpkg.com/zod/-/zod-4.3.5.tgz#aeb269a6f9fc259b1212c348c7c5432aaa474d2a" integrity sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g== +zod@^3.25.32: + version "3.25.76" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34" + integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ== + zwitch@^1.0.0: version "1.0.5" resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-1.0.5.tgz#d11d7381ffed16b742f6af7b3f223d5cd9fe9920" From 2558f8415a0be6e0407a4275bd19e82673322072 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Fri, 20 Mar 2026 15:58:41 +0100 Subject: [PATCH 06/23] fix(zendesk): use apiToken from config --- packages/ai-proxy/src/integrations/zendesk/tools.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/ai-proxy/src/integrations/zendesk/tools.ts b/packages/ai-proxy/src/integrations/zendesk/tools.ts index 7340c7ce17..723c201445 100644 --- a/packages/ai-proxy/src/integrations/zendesk/tools.ts +++ b/packages/ai-proxy/src/integrations/zendesk/tools.ts @@ -12,16 +12,11 @@ export interface ZendeskConfig { subdomain: string; email: string; apiToken: string; - - // TODO: remove - for now the front sends auth instead of apiToken - authorization: string; } export default function getZendeskTools(config: ZendeskConfig): RemoteTool[] { const baseUrl = `https://${config.subdomain}.zendesk.com/api/v2`; - // TODO: this is a hack for now, the config should have the good props - const apiToken = config.authorization.split(' ')[1]; - const auth = Buffer.from(`${config.email}/token:${apiToken}`).toString('base64'); + const auth = Buffer.from(`${config.email}/token:${config.apiToken}`).toString('base64'); const headers = { Authorization: `Basic ${auth}`, 'Content-Type': 'application/json', From 31a80107dbeb46e6e7fcba3abca46327f78fb93b Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Tue, 24 Mar 2026 15:32:06 +0100 Subject: [PATCH 07/23] chore: remove gmail and slack tools --- packages/ai-proxy/src/integration-client.ts | 6 +- .../ai-proxy/src/integrations/gmail/tools.ts | 33 -------- .../integrations/gmail/tools/create-draft.ts | 33 -------- .../integrations/gmail/tools/get-message.ts | 43 ---------- .../integrations/gmail/tools/get-thread.ts | 48 ----------- .../src/integrations/gmail/tools/search.ts | 79 ------------------- .../integrations/gmail/tools/send-message.ts | 33 -------- .../src/integrations/gmail/tools/utils.ts | 76 ------------------ .../ai-proxy/src/integrations/slack/tools.ts | 41 ---------- .../integrations/slack/tools/add-reaction.ts | 29 ------- .../slack/tools/get-channel-history.ts | 30 ------- .../slack/tools/get-thread-replies.ts | 32 -------- .../slack/tools/get-user-profile.ts | 25 ------ .../src/integrations/slack/tools/get-users.ts | 36 --------- .../integrations/slack/tools/list-channels.ts | 68 ---------------- .../integrations/slack/tools/post-message.ts | 27 ------- .../slack/tools/reply-to-thread.ts | 35 -------- packages/ai-proxy/src/integrations/tools.ts | 14 ---- 18 files changed, 1 insertion(+), 687 deletions(-) delete mode 100644 packages/ai-proxy/src/integrations/gmail/tools.ts delete mode 100644 packages/ai-proxy/src/integrations/gmail/tools/create-draft.ts delete mode 100644 packages/ai-proxy/src/integrations/gmail/tools/get-message.ts delete mode 100644 packages/ai-proxy/src/integrations/gmail/tools/get-thread.ts delete mode 100644 packages/ai-proxy/src/integrations/gmail/tools/search.ts delete mode 100644 packages/ai-proxy/src/integrations/gmail/tools/send-message.ts delete mode 100644 packages/ai-proxy/src/integrations/gmail/tools/utils.ts delete mode 100644 packages/ai-proxy/src/integrations/slack/tools.ts delete mode 100644 packages/ai-proxy/src/integrations/slack/tools/add-reaction.ts delete mode 100644 packages/ai-proxy/src/integrations/slack/tools/get-channel-history.ts delete mode 100644 packages/ai-proxy/src/integrations/slack/tools/get-thread-replies.ts delete mode 100644 packages/ai-proxy/src/integrations/slack/tools/get-user-profile.ts delete mode 100644 packages/ai-proxy/src/integrations/slack/tools/get-users.ts delete mode 100644 packages/ai-proxy/src/integrations/slack/tools/list-channels.ts delete mode 100644 packages/ai-proxy/src/integrations/slack/tools/post-message.ts delete mode 100644 packages/ai-proxy/src/integrations/slack/tools/reply-to-thread.ts diff --git a/packages/ai-proxy/src/integration-client.ts b/packages/ai-proxy/src/integration-client.ts index ffa78ae1d0..7adf472fab 100644 --- a/packages/ai-proxy/src/integration-client.ts +++ b/packages/ai-proxy/src/integration-client.ts @@ -2,14 +2,13 @@ import type McpServerRemoteTool from './mcp-server-remote-tool'; import type { Logger } from '@forestadmin/datasource-toolkit'; import type { MultiServerMCPClient } from '@langchain/mcp-adapters'; -import getSlackTools, { type SlackConfig } from './integrations/slack/tools'; import getZendeskTools, { type ZendeskConfig } from './integrations/zendesk/tools'; export type McpConfiguration = { configs: MultiServerMCPClient['config']['mcpServers']; } & Omit; -export type CustomConfig = SlackConfig | ZendeskConfig; +export type CustomConfig = ZendeskConfig; export interface ForestIntegrationConfig { integrationName: string; @@ -30,9 +29,6 @@ export default class IntegrationClient { loadTools(): McpServerRemoteTool[] { this.configs.forEach(({ integrationName, config }) => { switch (integrationName) { - case 'slack': - this.tools.push(...getSlackTools(config as SlackConfig)); - break; case 'zendesk': this.tools.push(...getZendeskTools(config as ZendeskConfig)); break; diff --git a/packages/ai-proxy/src/integrations/gmail/tools.ts b/packages/ai-proxy/src/integrations/gmail/tools.ts deleted file mode 100644 index 4333a2670c..0000000000 --- a/packages/ai-proxy/src/integrations/gmail/tools.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type RemoteTool from '../../remote-tool'; - -import ServerRemoteTool from '../../server-remote-tool'; -import createDraftTool from './tools/create-draft'; -import createGetMessageTool from './tools/get-message'; -import createGetThreadTool from './tools/get-thread'; -import createSearchTool from './tools/search'; -import createSendMessageTool from './tools/send-message'; - -export interface GmailConfig { - accessToken: string; -} - -export default function getGmailTools(config: GmailConfig): RemoteTool[] { - const headers = { - Authorization: `Bearer ${config.accessToken}`, - 'Content-Type': 'application/json', - }; - - return [ - createSendMessageTool(headers), - createGetMessageTool(headers), - createSearchTool(headers), - createDraftTool(headers), - createGetThreadTool(headers), - ].map( - tool => - new ServerRemoteTool({ - sourceId: 'gmail', - tool, - }), - ); -} diff --git a/packages/ai-proxy/src/integrations/gmail/tools/create-draft.ts b/packages/ai-proxy/src/integrations/gmail/tools/create-draft.ts deleted file mode 100644 index 6a1282a7c9..0000000000 --- a/packages/ai-proxy/src/integrations/gmail/tools/create-draft.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { DynamicStructuredTool } from '@langchain/core/tools'; -import { z } from 'zod'; - -import { encodeEmail } from './utils'; - -export default function createDraftTool(headers: Record): DynamicStructuredTool { - return new DynamicStructuredTool({ - name: 'gmail_create_draft', - description: 'Create a draft email in Gmail', - schema: z.object({ - to: z.array(z.string()).describe('Array of recipient email addresses'), - subject: z.string().describe('Email subject line'), - message: z.string().describe('Email body content'), - cc: z.array(z.string()).optional().describe('Array of CC email addresses'), - bcc: z.array(z.string()).optional().describe('Array of BCC email addresses'), - }), - func: async ({ to, subject, message, cc, bcc }) => { - const encodedEmail = encodeEmail({ to, subject, message, cc, bcc }); - - const response = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/drafts', { - method: 'POST', - headers, - body: JSON.stringify({ - message: { - raw: encodedEmail, - }, - }), - }); - - return JSON.stringify(await response.json()); - }, - }); -} diff --git a/packages/ai-proxy/src/integrations/gmail/tools/get-message.ts b/packages/ai-proxy/src/integrations/gmail/tools/get-message.ts deleted file mode 100644 index c0c79500ce..0000000000 --- a/packages/ai-proxy/src/integrations/gmail/tools/get-message.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { DynamicStructuredTool } from '@langchain/core/tools'; -import { z } from 'zod'; - -import { getBody, getHeader } from './utils'; - -export default function createGetMessageTool( - headers: Record, -): DynamicStructuredTool { - return new DynamicStructuredTool({ - name: 'gmail_get_message', - description: 'Get a specific email message by ID from Gmail', - schema: z.object({ - message_id: z.string().describe('The ID of the message to retrieve'), - }), - func: async ({ message_id }) => { - const response = await fetch( - `https://gmail.googleapis.com/gmail/v1/users/me/messages/${message_id}?format=full`, - { - method: 'GET', - headers, - }, - ); - - const data = await response.json(); - - if (!data.payload) { - return JSON.stringify({ error: 'Message not found or invalid payload' }); - } - - const result = { - id: data.id, - threadId: data.threadId, - subject: getHeader(data.payload, 'Subject'), - from: getHeader(data.payload, 'From'), - to: getHeader(data.payload, 'To'), - date: getHeader(data.payload, 'Date'), - body: getBody(data.payload), - }; - - return JSON.stringify(result); - }, - }); -} diff --git a/packages/ai-proxy/src/integrations/gmail/tools/get-thread.ts b/packages/ai-proxy/src/integrations/gmail/tools/get-thread.ts deleted file mode 100644 index 0ec93a3209..0000000000 --- a/packages/ai-proxy/src/integrations/gmail/tools/get-thread.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { DynamicStructuredTool } from '@langchain/core/tools'; -import { z } from 'zod'; - -import { getBody, getHeader } from './utils'; - -export default function createGetThreadTool( - headers: Record, -): DynamicStructuredTool { - return new DynamicStructuredTool({ - name: 'gmail_get_thread', - description: 'Get a complete email thread (conversation) by thread ID from Gmail', - schema: z.object({ - thread_id: z.string().describe('The ID of the thread to retrieve'), - }), - func: async ({ thread_id }) => { - const response = await fetch( - `https://gmail.googleapis.com/gmail/v1/users/me/threads/${thread_id}?format=full`, - { - method: 'GET', - headers, - }, - ); - - const data = await response.json(); - - if (!data.messages) { - return JSON.stringify({ error: 'Thread not found or no messages' }); - } - - const messages = data.messages.map((msg: any) => { - return { - id: msg.id, - subject: getHeader(msg.payload, 'Subject'), - from: getHeader(msg.payload, 'From'), - to: getHeader(msg.payload, 'To'), - date: getHeader(msg.payload, 'Date'), - body: getBody(msg.payload), - }; - }); - - return JSON.stringify({ - threadId: data.id, - messageCount: messages.length, - messages, - }); - }, - }); -} diff --git a/packages/ai-proxy/src/integrations/gmail/tools/search.ts b/packages/ai-proxy/src/integrations/gmail/tools/search.ts deleted file mode 100644 index f99fb4e180..0000000000 --- a/packages/ai-proxy/src/integrations/gmail/tools/search.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { DynamicStructuredTool } from '@langchain/core/tools'; -import { z } from 'zod'; - -import { getBody, getHeader } from './utils'; - -export default function createSearchTool(headers: Record): DynamicStructuredTool { - return new DynamicStructuredTool({ - name: 'gmail_search', - description: - 'Search for Gmail messages or threads using Gmail search query syntax. Returns message/thread IDs that can be used with other tools.', - schema: z.object({ - query: z - .string() - .describe( - 'Gmail search query (e.g., "from:user@example.com", "subject:meeting", "is:unread")', - ), - max_results: z - .number() - .optional() - .default(10) - .describe('Maximum number of results (default: 10)'), - resource: z - .enum(['messages', 'threads']) - .optional() - .default('messages') - .describe('Type of resource to search for (messages or threads)'), - }), - func: async ({ query, max_results = 10, resource = 'messages' }) => { - const url = new URL(`https://gmail.googleapis.com/gmail/v1/users/me/${resource}`); - url.searchParams.set('q', query); - url.searchParams.set('maxResults', max_results.toString()); - - const response = await fetch(url.toString(), { - method: 'GET', - headers, - }); - - const data = await response.json(); - - if (resource === 'messages') { - const messages = data.messages || []; - - if (messages.length === 0) { - return JSON.stringify({ results: [], count: 0 }); - } - - const detailedMessages = await Promise.all( - messages.map(async (msg: { id: string }) => { - const msgResponse = await fetch( - `https://gmail.googleapis.com/gmail/v1/users/me/messages/${msg.id}?format=full`, - { - method: 'GET', - headers, - }, - ); - const msgData = await msgResponse.json(); - - return { - id: msgData.id, - threadId: msgData.threadId, - subject: getHeader(msgData.payload, 'Subject'), - from: getHeader(msgData.payload, 'From'), - to: getHeader(msgData.payload, 'To'), - date: getHeader(msgData.payload, 'Date'), - snippet: msgData.snippet, - body: getBody(msgData.payload)?.substring(0, 500), - }; - }), - ); - - return JSON.stringify({ results: detailedMessages, count: detailedMessages.length }); - } - - const threads = data.threads || []; - - return JSON.stringify({ results: threads, count: threads.length }); - }, - }); -} diff --git a/packages/ai-proxy/src/integrations/gmail/tools/send-message.ts b/packages/ai-proxy/src/integrations/gmail/tools/send-message.ts deleted file mode 100644 index e6d72c049b..0000000000 --- a/packages/ai-proxy/src/integrations/gmail/tools/send-message.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { DynamicStructuredTool } from '@langchain/core/tools'; -import { z } from 'zod'; - -import { encodeEmail } from './utils'; - -export default function createSendMessageTool( - headers: Record, -): DynamicStructuredTool { - return new DynamicStructuredTool({ - name: 'gmail_send_message', - description: 'Send an email message via Gmail', - schema: z.object({ - to: z.array(z.string()).describe('Array of recipient email addresses'), - subject: z.string().describe('Email subject line'), - message: z.string().describe('Email body content'), - cc: z.array(z.string()).optional().describe('Array of CC email addresses'), - bcc: z.array(z.string()).optional().describe('Array of BCC email addresses'), - }), - func: async ({ to, subject, message, cc, bcc }) => { - const encodedEmail = encodeEmail({ to, subject, message, cc, bcc }); - - const response = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/messages/send', { - method: 'POST', - headers, - body: JSON.stringify({ - raw: encodedEmail, - }), - }); - - return JSON.stringify(await response.json()); - }, - }); -} diff --git a/packages/ai-proxy/src/integrations/gmail/tools/utils.ts b/packages/ai-proxy/src/integrations/gmail/tools/utils.ts deleted file mode 100644 index 830393eafc..0000000000 --- a/packages/ai-proxy/src/integrations/gmail/tools/utils.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Extract a header value from Gmail message payload - */ -export function getHeader( - payload: { headers?: Array<{ name: string; value: string }> }, - name: string, -): string { - return ( - payload.headers?.find( - (h: { name: string; value: string }) => h.name.toLowerCase() === name.toLowerCase(), - )?.value || '' - ); -} - -/** - * Decode base64url-encoded body data from Gmail API - */ -function decodeBody(body: string): string { - if (!body) return ''; - - try { - const sanitized = body.replace(/-/g, '+').replace(/_/g, '/'); - - return Buffer.from(sanitized, 'base64').toString('utf-8'); - } catch { - return ''; - } -} - -/** - * Extract body content from Gmail message payload (recursively searches parts) - */ -export function getBody(payload: any): string { - if (payload.body?.data) { - return decodeBody(payload.body.data); - } - - if (payload.parts) { - for (const part of payload.parts) { - const body = getBody(part); - if (body) return body; - } - } - - return ''; -} - -/** - * Encode email message for Gmail API (base64url encoding) - */ -export function encodeEmail(params: { - to: string[]; - subject: string; - message: string; - cc?: string[]; - bcc?: string[]; -}): string { - const { to, subject, message, cc, bcc } = params; - - const emailLines = [ - `To: ${to.join(', ')}`, - ...(cc ? [`Cc: ${cc.join(', ')}`] : []), - ...(bcc ? [`Bcc: ${bcc.join(', ')}`] : []), - `Subject: ${subject}`, - '', - message, - ]; - - const email = emailLines.join('\r\n'); - - return Buffer.from(email) - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); -} diff --git a/packages/ai-proxy/src/integrations/slack/tools.ts b/packages/ai-proxy/src/integrations/slack/tools.ts deleted file mode 100644 index 0683fa792d..0000000000 --- a/packages/ai-proxy/src/integrations/slack/tools.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type RemoteTool from '../../remote-tool'; - -import createAddReactionTool from './tools/add-reaction'; -import createGetChannelHistoryTool from './tools/get-channel-history'; -import createGetThreadRepliesTool from './tools/get-thread-replies'; -import createGetUserProfileTool from './tools/get-user-profile'; -import createGetUsersTool from './tools/get-users'; -import createListChannelsTool from './tools/list-channels'; -import createPostMessageTool from './tools/post-message'; -import createReplyToThreadTool from './tools/reply-to-thread'; -import ServerRemoteTool from '../../server-remote-tool'; - -export interface SlackConfig { - authToken: string; - teamId: string; - channelIds?: string; -} - -export default function getSlackTools(config: SlackConfig): RemoteTool[] { - const headers = { - Authorization: `Bearer ${config.authToken}`, - 'Content-Type': 'application/json', - }; - - return [ - createListChannelsTool(headers, config), - createPostMessageTool(headers), - createReplyToThreadTool(headers), - createAddReactionTool(headers), - createGetChannelHistoryTool(headers), - createGetThreadRepliesTool(headers), - createGetUsersTool(headers, config.teamId), - createGetUserProfileTool(headers), - ].map( - tool => - new ServerRemoteTool({ - sourceId: 'slack', - tool, - }), - ); -} diff --git a/packages/ai-proxy/src/integrations/slack/tools/add-reaction.ts b/packages/ai-proxy/src/integrations/slack/tools/add-reaction.ts deleted file mode 100644 index 924f554750..0000000000 --- a/packages/ai-proxy/src/integrations/slack/tools/add-reaction.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { DynamicStructuredTool } from '@langchain/core/tools'; -import { z } from 'zod'; - -export default function createAddReactionTool( - headers: Record, -): DynamicStructuredTool { - return new DynamicStructuredTool({ - name: 'slack_add_reaction', - description: 'Add a reaction emoji to a message', - schema: z.object({ - channel_id: z.string().describe('The ID of the channel containing the message'), - timestamp: z.string().describe('The timestamp of the message to react to'), - reaction: z.string().describe('The name of the emoji reaction (without ::)'), - }), - func: async ({ channel_id, timestamp, reaction }) => { - const response = await fetch('https://slack.com/api/reactions.add', { - method: 'POST', - headers, - body: JSON.stringify({ - channel: channel_id, - timestamp, - name: reaction, - }), - }); - - return JSON.stringify(await response.json()); - }, - }); -} diff --git a/packages/ai-proxy/src/integrations/slack/tools/get-channel-history.ts b/packages/ai-proxy/src/integrations/slack/tools/get-channel-history.ts deleted file mode 100644 index f2be20806e..0000000000 --- a/packages/ai-proxy/src/integrations/slack/tools/get-channel-history.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { DynamicStructuredTool } from '@langchain/core/tools'; -import { z } from 'zod'; - -export default function createGetChannelHistoryTool( - headers: Record, -): DynamicStructuredTool { - return new DynamicStructuredTool({ - name: 'slack_get_channel_history', - description: 'Get recent messages from a channel', - schema: z.object({ - channel_id: z.string().describe('The ID of the channel'), - limit: z - .number() - .optional() - .default(10) - .describe('Number of messages to retrieve (default 10)'), - }), - func: async ({ channel_id, limit }) => { - const params = new URLSearchParams({ - channel: channel_id, - limit: limit.toString(), - }); - const response = await fetch(`https://slack.com/api/conversations.history?${params}`, { - headers, - }); - - return JSON.stringify(await response.json()); - }, - }); -} diff --git a/packages/ai-proxy/src/integrations/slack/tools/get-thread-replies.ts b/packages/ai-proxy/src/integrations/slack/tools/get-thread-replies.ts deleted file mode 100644 index 3f2c6da0d6..0000000000 --- a/packages/ai-proxy/src/integrations/slack/tools/get-thread-replies.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { DynamicStructuredTool } from '@langchain/core/tools'; -import { z } from 'zod'; - -export default function createGetThreadRepliesTool( - headers: Record, -): DynamicStructuredTool { - return new DynamicStructuredTool({ - name: 'slack_get_thread_replies', - description: 'Get all replies in a message thread', - schema: z.object({ - channel_id: z.string().describe('The ID of the channel containing the thread'), - thread_ts: z - .string() - .describe( - "The timestamp of the parent message in the format '1234567890.123456'. " + - 'Timestamps in the format without the period can be converted by adding the period ' + - 'such that 6 numbers come after it.', - ), - }), - func: async ({ channel_id, thread_ts }) => { - const params = new URLSearchParams({ - channel: channel_id, - ts: thread_ts, - }); - const response = await fetch(`https://slack.com/api/conversations.replies?${params}`, { - headers, - }); - - return JSON.stringify(await response.json()); - }, - }); -} diff --git a/packages/ai-proxy/src/integrations/slack/tools/get-user-profile.ts b/packages/ai-proxy/src/integrations/slack/tools/get-user-profile.ts deleted file mode 100644 index df6d4f5d92..0000000000 --- a/packages/ai-proxy/src/integrations/slack/tools/get-user-profile.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { DynamicStructuredTool } from '@langchain/core/tools'; -import { z } from 'zod'; - -export default function createGetUserProfileTool( - headers: Record, -): DynamicStructuredTool { - return new DynamicStructuredTool({ - name: 'slack_get_user_profile', - description: 'Get detailed profile information for a specific user', - schema: z.object({ - user_id: z.string().describe('The ID of the user'), - }), - func: async ({ user_id }) => { - const params = new URLSearchParams({ - user: user_id, - include_labels: 'true', - }); - const response = await fetch(`https://slack.com/api/users.profile.get?${params}`, { - headers, - }); - - return JSON.stringify(await response.json()); - }, - }); -} diff --git a/packages/ai-proxy/src/integrations/slack/tools/get-users.ts b/packages/ai-proxy/src/integrations/slack/tools/get-users.ts deleted file mode 100644 index 9ab30cdca4..0000000000 --- a/packages/ai-proxy/src/integrations/slack/tools/get-users.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { DynamicStructuredTool } from '@langchain/core/tools'; -import { z } from 'zod'; - -export default function createGetUsersTool( - headers: Record, - teamId: string, -): DynamicStructuredTool { - return new DynamicStructuredTool({ - name: 'slack_get_users', - description: 'Get a list of all users in the workspace with their basic profile information', - schema: z.object({ - cursor: z.string().optional().describe('Pagination cursor for next page of results'), - limit: z - .number() - .optional() - .default(100) - .describe('Maximum number of users to return (default 100, max 200)'), - }), - func: async ({ cursor, limit }) => { - const params = new URLSearchParams({ - limit: Math.min(limit, 200).toString(), - team_id: teamId, - }); - - if (cursor) { - params.append('cursor', cursor); - } - - const response = await fetch(`https://slack.com/api/users.list?${params}`, { - headers, - }); - - return JSON.stringify(await response.json()); - }, - }); -} diff --git a/packages/ai-proxy/src/integrations/slack/tools/list-channels.ts b/packages/ai-proxy/src/integrations/slack/tools/list-channels.ts deleted file mode 100644 index 008016962c..0000000000 --- a/packages/ai-proxy/src/integrations/slack/tools/list-channels.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { SlackConfig } from '../tools'; - -import { DynamicStructuredTool } from '@langchain/core/tools'; -import { z } from 'zod'; - -export default function createListChannelsTool( - headers: Record, - config: SlackConfig, -): DynamicStructuredTool { - return new DynamicStructuredTool({ - name: 'slack_list_channels', - description: 'List public or pre-defined channels in the workspace with pagination', - schema: z.object({ - limit: z - .number() - .optional() - .default(100) - .describe('Maximum number of channels to return (default 100, max 200)'), - cursor: z.string().optional().describe('Pagination cursor for next page of results'), - }), - func: async ({ limit, cursor }) => { - const { channelIds } = config; - - if (!channelIds) { - const params = new URLSearchParams({ - types: 'public_channel', - exclude_archived: 'true', - limit: Math.min(limit, 200).toString(), - team_id: config.teamId, - }); - - if (cursor) { - params.append('cursor', cursor); - } - - const response = await fetch(`https://slack.com/api/conversations.list?${params}`, { - headers, - }); - - return JSON.stringify(await response.json()); - } - - const predefinedChannelIdsArray = channelIds.split(',').map(id => id.trim()); - - const channelPromises = predefinedChannelIdsArray.map(async channelId => { - const params = new URLSearchParams({ - channel: channelId, - }); - const response = await fetch(`https://slack.com/api/conversations.info?${params}`, { - headers, - }); - - return response.json(); - }); - - const results = await Promise.all(channelPromises); - const channels = results - .filter(data => data.ok && data.channel && !data.channel.is_archived) - .map(data => data.channel); - - return JSON.stringify({ - ok: true, - channels, - response_metadata: { next_cursor: '' }, - }); - }, - }); -} diff --git a/packages/ai-proxy/src/integrations/slack/tools/post-message.ts b/packages/ai-proxy/src/integrations/slack/tools/post-message.ts deleted file mode 100644 index 9d4265f2a1..0000000000 --- a/packages/ai-proxy/src/integrations/slack/tools/post-message.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { DynamicStructuredTool } from '@langchain/core/tools'; -import { z } from 'zod'; - -export default function createPostMessageTool( - headers: Record, -): DynamicStructuredTool { - return new DynamicStructuredTool({ - name: 'slack_post_message', - description: 'Post a new message to a Slack channel', - schema: z.object({ - channel_id: z.string().describe('The ID of the channel to post to'), - text: z.string().describe('The message text to post'), - }), - func: async ({ channel_id, text }) => { - const response = await fetch('https://slack.com/api/chat.postMessage', { - method: 'POST', - headers, - body: JSON.stringify({ - channel: channel_id, - text, - }), - }); - - return JSON.stringify(await response.json()); - }, - }); -} diff --git a/packages/ai-proxy/src/integrations/slack/tools/reply-to-thread.ts b/packages/ai-proxy/src/integrations/slack/tools/reply-to-thread.ts deleted file mode 100644 index 9aa805ddea..0000000000 --- a/packages/ai-proxy/src/integrations/slack/tools/reply-to-thread.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { DynamicStructuredTool } from '@langchain/core/tools'; -import { z } from 'zod'; - -export default function createReplyToThreadTool( - headers: Record, -): DynamicStructuredTool { - return new DynamicStructuredTool({ - name: 'slack_reply_to_thread', - description: 'Reply to a specific message thread in Slack', - schema: z.object({ - channel_id: z.string().describe('The ID of the channel containing the thread'), - thread_ts: z - .string() - .describe( - "The timestamp of the parent message in the format '1234567890.123456'. " + - 'Timestamps in the format without the period can be converted by adding the period ' + - 'such that 6 numbers come after it.', - ), - text: z.string().describe('The reply text'), - }), - func: async ({ channel_id, thread_ts, text }) => { - const response = await fetch('https://slack.com/api/chat.postMessage', { - method: 'POST', - headers, - body: JSON.stringify({ - channel: channel_id, - thread_ts, - text, - }), - }); - - return JSON.stringify(await response.json()); - }, - }); -} diff --git a/packages/ai-proxy/src/integrations/tools.ts b/packages/ai-proxy/src/integrations/tools.ts index 21a7a1a1b7..a22703f97e 100644 --- a/packages/ai-proxy/src/integrations/tools.ts +++ b/packages/ai-proxy/src/integrations/tools.ts @@ -1,18 +1,12 @@ import type RemoteTool from '../remote-tool'; import type { BraveConfig } from './brave/tools'; -import type { GmailConfig } from './gmail/tools'; -import type { SlackConfig } from './slack/tools'; import type { ZendeskConfig } from './zendesk/tools'; import getBraveTools from './brave/tools'; -import getGmailTools from './gmail/tools'; -import getSlackTools from './slack/tools'; import getZendeskTools from './zendesk/tools'; export interface IntegrationConfigs { brave?: BraveConfig; - gmail?: GmailConfig; - slack?: SlackConfig; zendesk?: ZendeskConfig; } @@ -23,14 +17,6 @@ export default function getIntegratedTools(configs: IntegrationConfigs): RemoteT integratedTools.push(...getBraveTools(configs.brave)); } - if (configs.gmail) { - integratedTools.push(...getGmailTools(configs.gmail)); - } - - if (configs.slack) { - integratedTools.push(...getSlackTools(configs.slack)); - } - if (configs.zendesk) { integratedTools.push(...getZendeskTools(configs.zendesk)); } From 3ba8763f62a3b8feb4104567e72e2cefdfec3a43 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Tue, 24 Mar 2026 15:39:43 +0100 Subject: [PATCH 08/23] chore: remove gmail example --- .../src/examples/run-mcp-gmail-server.ts | 81 ------------------- 1 file changed, 81 deletions(-) delete mode 100644 packages/ai-proxy/src/examples/run-mcp-gmail-server.ts diff --git a/packages/ai-proxy/src/examples/run-mcp-gmail-server.ts b/packages/ai-proxy/src/examples/run-mcp-gmail-server.ts deleted file mode 100644 index b99defd189..0000000000 --- a/packages/ai-proxy/src/examples/run-mcp-gmail-server.ts +++ /dev/null @@ -1,81 +0,0 @@ -/* eslint-disable import/no-extraneous-dependencies, import/extensions */ -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -/* eslint-enable import/no-extraneous-dependencies, import/extensions */ -import z from 'zod'; - -import runMcpServer from './simple-mcp-server'; - -const server = new McpServer({ - name: 'gmail', - version: '1.0.0', -}); - -// Gmail API configuration -// To use this, you'll need to: -// 1. Enable Gmail API in Google Cloud Console -// 2. Create OAuth 2.0 credentials -// 3. Get an access token -const GMAIL_ACCESS_TOKEN = 'YOUR_GMAIL_ACCESS_TOKEN'; - -const headers = { - Authorization: `Bearer ${GMAIL_ACCESS_TOKEN}`, - 'Content-Type': 'application/json', -}; - -server.tool( - 'gmail_search', - { query: z.string(), maxResults: z.number().optional() }, - async params => { - const url = new URL('https://gmail.googleapis.com/gmail/v1/users/me/messages'); - url.searchParams.set('q', params.query); - url.searchParams.set('maxResults', (params.maxResults || 10).toString()); - - const response = await fetch(url.toString(), { headers }); - const data = await response.json(); - - return { content: [{ type: 'text', text: JSON.stringify(data) }] }; - }, -); - -server.tool( - 'gmail_send_message', - { to: z.array(z.string()), subject: z.string(), message: z.string() }, - async params => { - const emailLines = [ - `To: ${params.to.join(', ')}`, - `Subject: ${params.subject}`, - '', - params.message, - ]; - - const email = emailLines.join('\r\n'); - const encodedEmail = Buffer.from(email) - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); - - const response = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/messages/send', { - method: 'POST', - headers, - body: JSON.stringify({ raw: encodedEmail }), - }); - - const data = await response.json(); - - return { content: [{ type: 'text', text: JSON.stringify(data) }] }; - }, -); - -server.tool('gmail_get_message', { messageId: z.string() }, async params => { - const response = await fetch( - `https://gmail.googleapis.com/gmail/v1/users/me/messages/${params.messageId}?format=full`, - { headers }, - ); - - const data = await response.json(); - - return { content: [{ type: 'text', text: JSON.stringify(data) }] }; -}); - -runMcpServer(server, 3125); From c0c5324c0e3f02f137b4d66a3a8a4399c879cee0 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Tue, 24 Mar 2026 16:11:03 +0100 Subject: [PATCH 09/23] test: add test --- .../ai-proxy/test/integration-client.test.ts | 62 ++++++++++++++++ .../ai-proxy/test/integrations/tools.test.ts | 45 ++++++++++++ .../test/integrations/zendesk/tools.test.ts | 31 ++++++++ .../tools/create-ticket-comment.test.ts | 43 +++++++++++ .../zendesk/tools/create-ticket.test.ts | 59 +++++++++++++++ .../zendesk/tools/get-ticket-comments.test.ts | 23 ++++++ .../zendesk/tools/get-ticket.test.ts | 23 ++++++ .../zendesk/tools/get-tickets.test.ts | 43 +++++++++++ .../zendesk/tools/update-ticket.test.ts | 72 +++++++++++++++++++ 9 files changed, 401 insertions(+) create mode 100644 packages/ai-proxy/test/integration-client.test.ts create mode 100644 packages/ai-proxy/test/integrations/tools.test.ts create mode 100644 packages/ai-proxy/test/integrations/zendesk/tools.test.ts create mode 100644 packages/ai-proxy/test/integrations/zendesk/tools/create-ticket-comment.test.ts create mode 100644 packages/ai-proxy/test/integrations/zendesk/tools/create-ticket.test.ts create mode 100644 packages/ai-proxy/test/integrations/zendesk/tools/get-ticket-comments.test.ts create mode 100644 packages/ai-proxy/test/integrations/zendesk/tools/get-ticket.test.ts create mode 100644 packages/ai-proxy/test/integrations/zendesk/tools/get-tickets.test.ts create mode 100644 packages/ai-proxy/test/integrations/zendesk/tools/update-ticket.test.ts diff --git a/packages/ai-proxy/test/integration-client.test.ts b/packages/ai-proxy/test/integration-client.test.ts new file mode 100644 index 0000000000..9343b68b0b --- /dev/null +++ b/packages/ai-proxy/test/integration-client.test.ts @@ -0,0 +1,62 @@ +import IntegrationClient from '../src/integration-client'; + +const mockZendeskTools = [{ name: 'zendesk_get_tickets' }, { name: 'zendesk_get_ticket' }]; + +jest.mock('../src/integrations/zendesk/tools', () => ({ + __esModule: true, + default: jest.fn(() => mockZendeskTools), +})); + +describe('IntegrationClient', () => { + beforeEach(() => jest.clearAllMocks()); + + describe('loadTools', () => { + it('should load zendesk tools when integration is zendesk', () => { + const client = new IntegrationClient([ + { + integrationName: 'zendesk', + config: { subdomain: 'test', email: 'a@b.com', apiToken: 'tok' }, + }, + ]); + + const tools = client.loadTools(); + + expect(tools).toEqual(mockZendeskTools); + }); + + it('should log warning for unsupported integration', () => { + const logger = jest.fn(); + const client = new IntegrationClient( + [{ integrationName: 'unknown', config: {} as any }], + logger, + ); + + client.loadTools(); + + expect(logger).toHaveBeenCalledWith('Warn', 'Unsupported integration: unknown'); + }); + + it('should return empty array when no configs', () => { + const client = new IntegrationClient([]); + + expect(client.loadTools()).toEqual([]); + }); + + it('should load tools from multiple configs', () => { + const client = new IntegrationClient([ + { + integrationName: 'zendesk', + config: { subdomain: 'a', email: 'a@b.com', apiToken: 'tok' }, + }, + { + integrationName: 'zendesk', + config: { subdomain: 'b', email: 'c@d.com', apiToken: 'tok2' }, + }, + ]); + + const tools = client.loadTools(); + + expect(tools).toHaveLength(4); + }); + }); +}); diff --git a/packages/ai-proxy/test/integrations/tools.test.ts b/packages/ai-proxy/test/integrations/tools.test.ts new file mode 100644 index 0000000000..fbe2108328 --- /dev/null +++ b/packages/ai-proxy/test/integrations/tools.test.ts @@ -0,0 +1,45 @@ +import getIntegratedTools from '../../src/integrations/tools'; + +const mockBraveTools = [{ name: 'brave_search' }]; +const mockZendeskTools = [{ name: 'zendesk_get_tickets' }]; + +jest.mock('../../src/integrations/brave/tools', () => ({ + __esModule: true, + default: jest.fn(() => mockBraveTools), +})); + +jest.mock('../../src/integrations/zendesk/tools', () => ({ + __esModule: true, + default: jest.fn(() => mockZendeskTools), +})); + +describe('getIntegratedTools', () => { + beforeEach(() => jest.clearAllMocks()); + + it('should return empty array when no configs provided', () => { + expect(getIntegratedTools({})).toEqual([]); + }); + + it('should return brave tools when brave config provided', () => { + const tools = getIntegratedTools({ brave: { apiKey: 'key' } }); + + expect(tools).toEqual(mockBraveTools); + }); + + it('should return zendesk tools when zendesk config provided', () => { + const tools = getIntegratedTools({ + zendesk: { subdomain: 'test', email: 'a@b.com', apiToken: 'tok' }, + }); + + expect(tools).toEqual(mockZendeskTools); + }); + + it('should return all tools when all configs provided', () => { + const tools = getIntegratedTools({ + brave: { apiKey: 'key' }, + zendesk: { subdomain: 'test', email: 'a@b.com', apiToken: 'tok' }, + }); + + expect(tools).toEqual([...mockBraveTools, ...mockZendeskTools]); + }); +}); diff --git a/packages/ai-proxy/test/integrations/zendesk/tools.test.ts b/packages/ai-proxy/test/integrations/zendesk/tools.test.ts new file mode 100644 index 0000000000..b446bcb7ef --- /dev/null +++ b/packages/ai-proxy/test/integrations/zendesk/tools.test.ts @@ -0,0 +1,31 @@ +import getZendeskTools from '../../../src/integrations/zendesk/tools'; +import ServerRemoteTool from '../../../src/server-remote-tool'; + +describe('getZendeskTools', () => { + const config = { subdomain: 'mycompany', email: 'agent@test.com', apiToken: 'secret-token' }; + + it('should return 6 tools wrapped in ServerRemoteTool', () => { + const tools = getZendeskTools(config); + + expect(tools).toHaveLength(6); + tools.forEach(tool => { + expect(tool).toBeInstanceOf(ServerRemoteTool); + expect(tool.sourceId).toBe('zendesk'); + expect(tool.sourceType).toBe('server'); + }); + }); + + it('should return tools with expected names', () => { + const tools = getZendeskTools(config); + const names = tools.map(t => t.base.name); + + expect(names).toEqual([ + 'zendesk_get_tickets', + 'zendesk_get_ticket', + 'zendesk_get_ticket_comments', + 'zendesk_create_ticket', + 'zendesk_create_ticket_comment', + 'zendesk_update_ticket', + ]); + }); +}); diff --git a/packages/ai-proxy/test/integrations/zendesk/tools/create-ticket-comment.test.ts b/packages/ai-proxy/test/integrations/zendesk/tools/create-ticket-comment.test.ts new file mode 100644 index 0000000000..554adf0417 --- /dev/null +++ b/packages/ai-proxy/test/integrations/zendesk/tools/create-ticket-comment.test.ts @@ -0,0 +1,43 @@ +import createCreateTicketCommentTool from '../../../../src/integrations/zendesk/tools/create-ticket-comment'; + +const mockResponse = { ticket: { id: 5 } }; + +global.fetch = jest.fn().mockResolvedValue({ + json: () => Promise.resolve(mockResponse), +}) as jest.Mock; + +describe('createCreateTicketCommentTool', () => { + const headers = { Authorization: 'Basic abc', 'Content-Type': 'application/json' }; + const baseUrl = 'https://test.zendesk.com/api/v2'; + + beforeEach(() => jest.clearAllMocks()); + + it('should add a public comment by default', async () => { + const tool = createCreateTicketCommentTool(headers, baseUrl); + + const result = await tool.invoke({ ticket_id: 5, comment: 'Looks good' }); + + expect(fetch).toHaveBeenCalledWith(`${baseUrl}/tickets/5.json`, { + method: 'PUT', + headers, + body: JSON.stringify({ + ticket: { comment: { body: 'Looks good', public: true } }, + }), + }); + expect(result).toBe(JSON.stringify(mockResponse)); + }); + + it('should add an internal comment when public is false', async () => { + const tool = createCreateTicketCommentTool(headers, baseUrl); + + await tool.invoke({ ticket_id: 5, comment: 'Internal note', public: false }); + + expect(fetch).toHaveBeenCalledWith(`${baseUrl}/tickets/5.json`, { + method: 'PUT', + headers, + body: JSON.stringify({ + ticket: { comment: { body: 'Internal note', public: false } }, + }), + }); + }); +}); diff --git a/packages/ai-proxy/test/integrations/zendesk/tools/create-ticket.test.ts b/packages/ai-proxy/test/integrations/zendesk/tools/create-ticket.test.ts new file mode 100644 index 0000000000..0370899e06 --- /dev/null +++ b/packages/ai-proxy/test/integrations/zendesk/tools/create-ticket.test.ts @@ -0,0 +1,59 @@ +import createCreateTicketTool from '../../../../src/integrations/zendesk/tools/create-ticket'; + +const mockResponse = { ticket: { id: 99, subject: 'New ticket' } }; + +global.fetch = jest.fn().mockResolvedValue({ + json: () => Promise.resolve(mockResponse), +}) as jest.Mock; + +describe('createCreateTicketTool', () => { + const headers = { Authorization: 'Basic abc', 'Content-Type': 'application/json' }; + const baseUrl = 'https://test.zendesk.com/api/v2'; + + beforeEach(() => jest.clearAllMocks()); + + it('should create a ticket with required fields', async () => { + const tool = createCreateTicketTool(headers, baseUrl); + + const result = await tool.invoke({ subject: 'Bug', description: 'It broke' }); + + expect(fetch).toHaveBeenCalledWith(`${baseUrl}/tickets.json`, { + method: 'POST', + headers, + body: JSON.stringify({ ticket: { subject: 'Bug', comment: { body: 'It broke' } } }), + }); + expect(result).toBe(JSON.stringify(mockResponse)); + }); + + it('should create a ticket with all optional fields', async () => { + const tool = createCreateTicketTool(headers, baseUrl); + + await tool.invoke({ + subject: 'Bug', + description: 'Broken', + requester_id: 1, + assignee_id: 2, + priority: 'high', + type: 'incident', + tags: ['urgent'], + custom_fields: [{ id: 100, value: 'foo' }], + }); + + expect(fetch).toHaveBeenCalledWith(`${baseUrl}/tickets.json`, { + method: 'POST', + headers, + body: JSON.stringify({ + ticket: { + subject: 'Bug', + comment: { body: 'Broken' }, + requester_id: 1, + assignee_id: 2, + priority: 'high', + type: 'incident', + tags: ['urgent'], + custom_fields: [{ id: 100, value: 'foo' }], + }, + }), + }); + }); +}); diff --git a/packages/ai-proxy/test/integrations/zendesk/tools/get-ticket-comments.test.ts b/packages/ai-proxy/test/integrations/zendesk/tools/get-ticket-comments.test.ts new file mode 100644 index 0000000000..c1eaa1ee29 --- /dev/null +++ b/packages/ai-proxy/test/integrations/zendesk/tools/get-ticket-comments.test.ts @@ -0,0 +1,23 @@ +import createGetTicketCommentsTool from '../../../../src/integrations/zendesk/tools/get-ticket-comments'; + +const mockResponse = { comments: [{ id: 1, body: 'Hello' }] }; + +global.fetch = jest.fn().mockResolvedValue({ + json: () => Promise.resolve(mockResponse), +}) as jest.Mock; + +describe('createGetTicketCommentsTool', () => { + const headers = { Authorization: 'Basic abc' }; + const baseUrl = 'https://test.zendesk.com/api/v2'; + + beforeEach(() => jest.clearAllMocks()); + + it('should fetch comments for a ticket', async () => { + const tool = createGetTicketCommentsTool(headers, baseUrl); + + const result = await tool.invoke({ ticket_id: 10 }); + + expect(fetch).toHaveBeenCalledWith(`${baseUrl}/tickets/10/comments.json`, { headers }); + expect(result).toBe(JSON.stringify(mockResponse)); + }); +}); diff --git a/packages/ai-proxy/test/integrations/zendesk/tools/get-ticket.test.ts b/packages/ai-proxy/test/integrations/zendesk/tools/get-ticket.test.ts new file mode 100644 index 0000000000..bfea4791b6 --- /dev/null +++ b/packages/ai-proxy/test/integrations/zendesk/tools/get-ticket.test.ts @@ -0,0 +1,23 @@ +import createGetTicketTool from '../../../../src/integrations/zendesk/tools/get-ticket'; + +const mockResponse = { ticket: { id: 42, subject: 'Help' } }; + +global.fetch = jest.fn().mockResolvedValue({ + json: () => Promise.resolve(mockResponse), +}) as jest.Mock; + +describe('createGetTicketTool', () => { + const headers = { Authorization: 'Basic abc' }; + const baseUrl = 'https://test.zendesk.com/api/v2'; + + beforeEach(() => jest.clearAllMocks()); + + it('should fetch the ticket by id', async () => { + const tool = createGetTicketTool(headers, baseUrl); + + const result = await tool.invoke({ ticket_id: 42 }); + + expect(fetch).toHaveBeenCalledWith(`${baseUrl}/tickets/42.json`, { headers }); + expect(result).toBe(JSON.stringify(mockResponse)); + }); +}); diff --git a/packages/ai-proxy/test/integrations/zendesk/tools/get-tickets.test.ts b/packages/ai-proxy/test/integrations/zendesk/tools/get-tickets.test.ts new file mode 100644 index 0000000000..05acf5d8ee --- /dev/null +++ b/packages/ai-proxy/test/integrations/zendesk/tools/get-tickets.test.ts @@ -0,0 +1,43 @@ +import createGetTicketsTool from '../../../../src/integrations/zendesk/tools/get-tickets'; + +const mockResponse = { tickets: [{ id: 1 }, { id: 2 }] }; + +global.fetch = jest.fn().mockResolvedValue({ + json: () => Promise.resolve(mockResponse), +}) as jest.Mock; + +describe('createGetTicketsTool', () => { + const headers = { Authorization: 'Basic abc' }; + const baseUrl = 'https://test.zendesk.com/api/v2'; + + beforeEach(() => jest.clearAllMocks()); + + it('should fetch tickets with default params', async () => { + const tool = createGetTicketsTool(headers, baseUrl); + + const result = await tool.invoke({}); + + const expectedParams = new URLSearchParams({ + page: '1', + per_page: '25', + sort_by: 'created_at', + sort_order: 'desc', + }); + expect(fetch).toHaveBeenCalledWith(`${baseUrl}/tickets.json?${expectedParams}`, { headers }); + expect(result).toBe(JSON.stringify(mockResponse)); + }); + + it('should fetch tickets with custom params', async () => { + const tool = createGetTicketsTool(headers, baseUrl); + + await tool.invoke({ page: 3, per_page: 10, sort_by: 'updated_at', sort_order: 'asc' }); + + const expectedParams = new URLSearchParams({ + page: '3', + per_page: '10', + sort_by: 'updated_at', + sort_order: 'asc', + }); + expect(fetch).toHaveBeenCalledWith(`${baseUrl}/tickets.json?${expectedParams}`, { headers }); + }); +}); diff --git a/packages/ai-proxy/test/integrations/zendesk/tools/update-ticket.test.ts b/packages/ai-proxy/test/integrations/zendesk/tools/update-ticket.test.ts new file mode 100644 index 0000000000..309e67b713 --- /dev/null +++ b/packages/ai-proxy/test/integrations/zendesk/tools/update-ticket.test.ts @@ -0,0 +1,72 @@ +import createUpdateTicketTool from '../../../../src/integrations/zendesk/tools/update-ticket'; + +const mockResponse = { ticket: { id: 7, status: 'solved' } }; + +global.fetch = jest.fn().mockResolvedValue({ + json: () => Promise.resolve(mockResponse), +}) as jest.Mock; + +describe('createUpdateTicketTool', () => { + const headers = { Authorization: 'Basic abc', 'Content-Type': 'application/json' }; + const baseUrl = 'https://test.zendesk.com/api/v2'; + + beforeEach(() => jest.clearAllMocks()); + + it('should update a ticket with a single field', async () => { + const tool = createUpdateTicketTool(headers, baseUrl); + + const result = await tool.invoke({ ticket_id: 7, status: 'solved' }); + + expect(fetch).toHaveBeenCalledWith(`${baseUrl}/tickets/7.json`, { + method: 'PUT', + headers, + body: JSON.stringify({ ticket: { status: 'solved' } }), + }); + expect(result).toBe(JSON.stringify(mockResponse)); + }); + + it('should update a ticket with multiple fields', async () => { + const tool = createUpdateTicketTool(headers, baseUrl); + + await tool.invoke({ + ticket_id: 7, + subject: 'Updated', + priority: 'urgent', + type: 'task', + assignee_id: 3, + requester_id: 4, + tags: ['vip'], + custom_fields: [{ id: 10, value: 'bar' }], + due_at: '2026-04-01T00:00:00Z', + }); + + expect(fetch).toHaveBeenCalledWith(`${baseUrl}/tickets/7.json`, { + method: 'PUT', + headers, + body: JSON.stringify({ + ticket: { + subject: 'Updated', + priority: 'urgent', + type: 'task', + assignee_id: 3, + requester_id: 4, + tags: ['vip'], + custom_fields: [{ id: 10, value: 'bar' }], + due_at: '2026-04-01T00:00:00Z', + }, + }), + }); + }); + + it('should send empty ticket object when no optional fields provided', async () => { + const tool = createUpdateTicketTool(headers, baseUrl); + + await tool.invoke({ ticket_id: 7 }); + + expect(fetch).toHaveBeenCalledWith(`${baseUrl}/tickets/7.json`, { + method: 'PUT', + headers, + body: JSON.stringify({ ticket: {} }), + }); + }); +}); From b5348d77056e4517a4f7dc4db29042bb06e9d9d2 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Wed, 25 Mar 2026 17:30:48 +0100 Subject: [PATCH 10/23] chore(code review): merge received configs and allow validation for forest integrations --- packages/ai-proxy/src/index.ts | 7 +- packages/ai-proxy/src/integration-client.ts | 11 +-- .../src/integrations/zendesk/tools.ts | 8 +- .../src/integrations/zendesk/utils.ts | 28 ++++++ packages/ai-proxy/src/mcp-client.ts | 5 +- packages/ai-proxy/src/mcp-config-checker.ts | 19 +++- packages/ai-proxy/src/router.ts | 20 +++- .../test/integrations/zendesk/utils.test.ts | 65 +++++++++++++ .../ai-proxy/test/mcp-config-checker.test.ts | 66 +++++++++++++ packages/ai-proxy/test/router.test.ts | 96 ++++++++++++++++++- 10 files changed, 300 insertions(+), 25 deletions(-) create mode 100644 packages/ai-proxy/src/integrations/zendesk/utils.ts create mode 100644 packages/ai-proxy/test/integrations/zendesk/utils.test.ts create mode 100644 packages/ai-proxy/test/mcp-config-checker.test.ts diff --git a/packages/ai-proxy/src/index.ts b/packages/ai-proxy/src/index.ts index 5ed97ef090..102f766c6e 100644 --- a/packages/ai-proxy/src/index.ts +++ b/packages/ai-proxy/src/index.ts @@ -1,3 +1,4 @@ +import type { ForestIntegrationConfig } from './integration-client'; import type { McpConfiguration } from './mcp-client'; import McpConfigChecker from './mcp-config-checker'; @@ -5,7 +6,7 @@ import McpConfigChecker from './mcp-config-checker'; export { createAiProvider } from './create-ai-provider'; export { default as ProviderDispatcher } from './provider-dispatcher'; -export { ForestIntegrationConfig, CustomConfig } from './integration-client'; +export { ForestIntegrationConfig, CustomConfig, ForestIntegrationName } from './integration-client'; export * from './provider-dispatcher'; export * from './remote-tools'; @@ -14,6 +15,8 @@ export * from './mcp-client'; export * from './oauth-token-injector'; export * from './errors'; -export function validMcpConfigurationOrThrow(mcpConfig: McpConfiguration) { +export function validMcpConfigurationOrThrow( + mcpConfig: McpConfiguration | ForestIntegrationConfig, +) { return McpConfigChecker.check(mcpConfig); } diff --git a/packages/ai-proxy/src/integration-client.ts b/packages/ai-proxy/src/integration-client.ts index 7adf472fab..aa4453016e 100644 --- a/packages/ai-proxy/src/integration-client.ts +++ b/packages/ai-proxy/src/integration-client.ts @@ -1,18 +1,15 @@ import type McpServerRemoteTool from './mcp-server-remote-tool'; import type { Logger } from '@forestadmin/datasource-toolkit'; -import type { MultiServerMCPClient } from '@langchain/mcp-adapters'; import getZendeskTools, { type ZendeskConfig } from './integrations/zendesk/tools'; -export type McpConfiguration = { - configs: MultiServerMCPClient['config']['mcpServers']; -} & Omit; - export type CustomConfig = ZendeskConfig; +export type ForestIntegrationName = 'Zendesk'; export interface ForestIntegrationConfig { - integrationName: string; + integrationName: ForestIntegrationName; config: CustomConfig; + isForestConnector: true; } export default class IntegrationClient { @@ -29,7 +26,7 @@ export default class IntegrationClient { loadTools(): McpServerRemoteTool[] { this.configs.forEach(({ integrationName, config }) => { switch (integrationName) { - case 'zendesk': + case 'Zendesk': this.tools.push(...getZendeskTools(config as ZendeskConfig)); break; default: diff --git a/packages/ai-proxy/src/integrations/zendesk/tools.ts b/packages/ai-proxy/src/integrations/zendesk/tools.ts index 723c201445..9886f86c49 100644 --- a/packages/ai-proxy/src/integrations/zendesk/tools.ts +++ b/packages/ai-proxy/src/integrations/zendesk/tools.ts @@ -6,6 +6,7 @@ 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 { @@ -15,12 +16,7 @@ export interface ZendeskConfig { } export default function getZendeskTools(config: ZendeskConfig): RemoteTool[] { - const baseUrl = `https://${config.subdomain}.zendesk.com/api/v2`; - const auth = Buffer.from(`${config.email}/token:${config.apiToken}`).toString('base64'); - const headers = { - Authorization: `Basic ${auth}`, - 'Content-Type': 'application/json', - }; + const { baseUrl, headers } = getZendeskConfig(config); return [ createGetTicketsTool(headers, baseUrl), diff --git a/packages/ai-proxy/src/integrations/zendesk/utils.ts b/packages/ai-proxy/src/integrations/zendesk/utils.ts new file mode 100644 index 0000000000..b7e4353491 --- /dev/null +++ b/packages/ai-proxy/src/integrations/zendesk/utils.ts @@ -0,0 +1,28 @@ +import type { ZendeskConfig } from './tools'; + +import { McpConnectionError } from '../../errors'; + +export function getZendeskConfig(config: ZendeskConfig) { + const baseUrl = `https://${config.subdomain}.zendesk.com/api/v2`; + const auth = Buffer.from(`${config.email}/token:${config.apiToken}`).toString('base64'); + const headers = { + Authorization: `Basic ${auth}`, + 'Content-Type': 'application/json', + }; + + return { baseUrl, headers }; +} + +export async function validateZendeskConfig(config: ZendeskConfig) { + const { baseUrl, headers } = getZendeskConfig(config); + + const response = await fetch(`${baseUrl}/users/me`, { headers }); + + const json = await response.json(); + + if (!response.ok) { + throw new McpConnectionError( + `Failed to validate Zendesk config: ${json.title || json.error.title}`, + ); + } +} diff --git a/packages/ai-proxy/src/mcp-client.ts b/packages/ai-proxy/src/mcp-client.ts index 945a2a9535..f50246b323 100644 --- a/packages/ai-proxy/src/mcp-client.ts +++ b/packages/ai-proxy/src/mcp-client.ts @@ -1,3 +1,4 @@ +import type { ForestIntegrationConfig } from './integration-client'; import type { Logger } from '@forestadmin/datasource-toolkit'; import { MultiServerMCPClient } from '@langchain/mcp-adapters'; @@ -5,10 +6,12 @@ import { MultiServerMCPClient } from '@langchain/mcp-adapters'; import { McpConnectionError } from './errors'; import McpServerRemoteTool from './mcp-server-remote-tool'; +export type McpServers = MultiServerMCPClient['config']['mcpServers']; + export type McpServerConfig = MultiServerMCPClient['config']['mcpServers'][string]; export type McpConfiguration = { - configs: MultiServerMCPClient['config']['mcpServers']; + configs: McpServers | Record; } & Omit; export default class McpClient { diff --git a/packages/ai-proxy/src/mcp-config-checker.ts b/packages/ai-proxy/src/mcp-config-checker.ts index 95433c5f7d..8615d38c82 100644 --- a/packages/ai-proxy/src/mcp-config-checker.ts +++ b/packages/ai-proxy/src/mcp-config-checker.ts @@ -1,9 +1,26 @@ +import type { ForestIntegrationConfig } from './integration-client'; import type { McpConfiguration } from './mcp-client'; +import { validateZendeskConfig } from './integrations/zendesk/utils'; import McpClient from './mcp-client'; export default class McpConfigChecker { - static check(mcpConfig: McpConfiguration) { + static isForestIntegrationConfig( + config: McpConfiguration | ForestIntegrationConfig, + ): config is ForestIntegrationConfig { + return 'integrationName' in config; + } + + static check(mcpConfig: McpConfiguration | ForestIntegrationConfig) { + if (McpConfigChecker.isForestIntegrationConfig(mcpConfig)) { + switch (mcpConfig.integrationName) { + case 'Zendesk': + return validateZendeskConfig(mcpConfig.config); + default: + throw new Error(`Unsupported integration: ${mcpConfig.integrationName}`); + } + } + return new McpClient(mcpConfig).testConnections(); } } diff --git a/packages/ai-proxy/src/router.ts b/packages/ai-proxy/src/router.ts index 549f71d24c..de0a7a7976 100644 --- a/packages/ai-proxy/src/router.ts +++ b/packages/ai-proxy/src/router.ts @@ -63,7 +63,6 @@ export class Router { async route( args: RouteArgs & { mcpConfigs?: McpConfiguration; - integrationConfigs?: ForestIntegrationConfig[]; }, ) { // Validate input with Zod schema @@ -77,13 +76,24 @@ export class Router { let mcpClient: McpClient | undefined; let integrationClient: IntegrationClient | undefined; + const mcpConfigs: McpConfiguration = { configs: {} }; + const integrationConfigs: ForestIntegrationConfig[] = []; + + Object.entries(args.mcpConfigs.configs).forEach(([name, config]) => { + if (config.isForestConnector) { + integrationConfigs.push(config as ForestIntegrationConfig); + } else { + mcpConfigs.configs[name] = config; + } + }); + try { - if (args.mcpConfigs) { - mcpClient = new McpClient(args.mcpConfigs, this.logger); + if (mcpConfigs && Object.keys(mcpConfigs.configs).length > 0) { + mcpClient = new McpClient(mcpConfigs, this.logger); } - if (args.integrationConfigs) { - integrationClient = new IntegrationClient(args.integrationConfigs, this.logger); + if (integrationConfigs.length > 0) { + integrationClient = new IntegrationClient(integrationConfigs, this.logger); } const remoteTools = new RemoteTools(this.localToolsApiKeys ?? {}, [ diff --git a/packages/ai-proxy/test/integrations/zendesk/utils.test.ts b/packages/ai-proxy/test/integrations/zendesk/utils.test.ts new file mode 100644 index 0000000000..4c6897313a --- /dev/null +++ b/packages/ai-proxy/test/integrations/zendesk/utils.test.ts @@ -0,0 +1,65 @@ +import { McpConnectionError } from '../../../src/errors'; +import { getZendeskConfig, validateZendeskConfig } from '../../../src/integrations/zendesk/utils'; + +describe('zendesk/utils', () => { + describe('getZendeskConfig', () => { + it('should return baseUrl and headers with basic auth', () => { + const config = { subdomain: 'mycompany', email: 'agent@test.com', apiToken: 'secret123' }; + + const result = getZendeskConfig(config); + + const expectedAuth = Buffer.from('agent@test.com/token:secret123').toString('base64'); + expect(result).toEqual({ + baseUrl: 'https://mycompany.zendesk.com/api/v2', + headers: { + Authorization: `Basic ${expectedAuth}`, + 'Content-Type': 'application/json', + }, + }); + }); + }); + + describe('validateZendeskConfig', () => { + const config = { subdomain: 'mycompany', email: 'agent@test.com', apiToken: 'secret123' }; + + beforeEach(() => jest.restoreAllMocks()); + + it('should not throw when response is ok', async () => { + jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ user: { id: 1 } }), + } as Response); + + await expect(validateZendeskConfig(config)).resolves.toBeUndefined(); + expect(fetch).toHaveBeenCalledWith( + 'https://mycompany.zendesk.com/api/v2/users/me', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: expect.stringContaining('Basic ') }), + }), + ); + }); + + it('should throw McpConnectionError when response has title', async () => { + jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: false, + json: async () => ({ title: 'Unauthorized' }), + } as Response); + + await expect(validateZendeskConfig(config)).rejects.toThrow(McpConnectionError); + await expect(validateZendeskConfig(config)).rejects.toThrow( + 'Failed to validate Zendesk config: Unauthorized', + ); + }); + + it('should throw McpConnectionError using error.title as fallback', async () => { + jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: false, + json: async () => ({ error: { title: 'Bad Request' } }), + } as Response); + + await expect(validateZendeskConfig(config)).rejects.toThrow( + 'Failed to validate Zendesk config: Bad Request', + ); + }); + }); +}); diff --git a/packages/ai-proxy/test/mcp-config-checker.test.ts b/packages/ai-proxy/test/mcp-config-checker.test.ts new file mode 100644 index 0000000000..a02976bea1 --- /dev/null +++ b/packages/ai-proxy/test/mcp-config-checker.test.ts @@ -0,0 +1,66 @@ +import McpConfigChecker from '../src/mcp-config-checker'; +import * as zendeskUtils from '../src/integrations/zendesk/utils'; +import McpClient from '../src/mcp-client'; + +jest.mock('../src/integrations/zendesk/utils'); +jest.mock('../src/mcp-client'); + +describe('McpConfigChecker', () => { + beforeEach(() => jest.clearAllMocks()); + + describe('isForestIntegrationConfig', () => { + it('should return true when config has integrationName', () => { + const config = { + integrationName: 'Zendesk' as const, + config: { subdomain: 'test', email: 'a@b.com', apiToken: 'tok' }, + isForestConnector: true as const, + }; + + expect(McpConfigChecker.isForestIntegrationConfig(config)).toBe(true); + }); + + it('should return false when config is an MCP configuration', () => { + const config = { url: 'http://localhost:3000' }; + + expect(McpConfigChecker.isForestIntegrationConfig(config as any)).toBe(false); + }); + }); + + describe('check', () => { + it('should call validateZendeskConfig for Zendesk integration', async () => { + const zendeskConfig = { subdomain: 'test', email: 'a@b.com', apiToken: 'tok' }; + const config = { + integrationName: 'Zendesk' as const, + config: zendeskConfig, + isForestConnector: true as const, + }; + + await McpConfigChecker.check(config); + + expect(zendeskUtils.validateZendeskConfig).toHaveBeenCalledWith(zendeskConfig); + }); + + it('should throw for unsupported integration', () => { + const config = { + integrationName: 'Unknown', + config: { subdomain: 'x', email: 'x', apiToken: 'x' }, + isForestConnector: true, + } as any; + + expect(() => McpConfigChecker.check(config)).toThrow('Unsupported integration: Unknown'); + }); + + it('should call McpClient.testConnections for MCP config', async () => { + const testConnectionsMock = jest.fn().mockResolvedValue(undefined); + (McpClient as jest.Mock).mockImplementation(() => ({ + testConnections: testConnectionsMock, + })); + const mcpConfig = { url: 'http://localhost:3000' }; + + await McpConfigChecker.check(mcpConfig as any); + + expect(McpClient).toHaveBeenCalledWith(mcpConfig); + expect(testConnectionsMock).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/ai-proxy/test/router.test.ts b/packages/ai-proxy/test/router.test.ts index 0a6d255876..5307ec15ae 100644 --- a/packages/ai-proxy/test/router.test.ts +++ b/packages/ai-proxy/test/router.test.ts @@ -2,6 +2,7 @@ import type { DispatchBody, InvokeRemoteToolArgs } from '../src'; import type { Logger } from '@forestadmin/datasource-toolkit'; import { AIModelNotSupportedError, Router } from '../src'; +import IntegrationClient from '../src/integration-client'; import McpClient from '../src/mcp-client'; import ProviderDispatcher from '../src/provider-dispatcher'; @@ -39,6 +40,18 @@ jest.mock('../src/mcp-client', () => { const MockedMcpClient = McpClient as jest.MockedClass; +const loadToolsMock = jest.fn().mockReturnValue([]); +jest.mock('../src/integration-client', () => { + return { + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + loadTools: loadToolsMock, + })), + }; +}); + +const MockedIntegrationClient = IntegrationClient as jest.MockedClass; + describe('route', () => { beforeEach(() => { jest.clearAllMocks(); @@ -60,6 +73,7 @@ describe('route', () => { await router.route({ route: 'ai-query', body: { tools: [], tool_choice: 'required', messages: [] } as unknown as DispatchBody, + mcpConfigs: { configs: {} }, }); expect(dispatchMock).toHaveBeenCalledWith({ @@ -90,6 +104,7 @@ describe('route', () => { route: 'ai-query', query: { 'ai-name': 'gpt4mini' }, body: { tools: [], tool_choice: 'required', messages: [] } as unknown as DispatchBody, + mcpConfigs: { configs: {} }, }); expect(ProviderDispatcherMock).toHaveBeenCalledWith(gpt4MiniConfig, expect.anything()); @@ -115,6 +130,7 @@ describe('route', () => { await router.route({ route: 'ai-query', body: { tools: [], tool_choice: 'required', messages: [] } as unknown as DispatchBody, + mcpConfigs: { configs: {} }, }); expect(ProviderDispatcherMock).toHaveBeenCalledWith(gpt4Config, expect.anything()); @@ -137,6 +153,7 @@ describe('route', () => { route: 'ai-query', query: { 'ai-name': 'non-existent' }, body: { tools: [], tool_choice: 'required', messages: [] } as unknown as DispatchBody, + mcpConfigs: { configs: {} }, }); expect(mockLogger).toHaveBeenCalledWith( @@ -155,6 +172,7 @@ describe('route', () => { route: 'invoke-remote-tool', query: { 'tool-name': 'tool-name' }, body: { inputs: [] }, + mcpConfigs: { configs: {} }, }); expect(invokeToolMock).toHaveBeenCalledWith('tool-name', []); @@ -168,6 +186,7 @@ describe('route', () => { route: 'invoke-remote-tool', query: {}, body: { inputs: [] }, + mcpConfigs: { configs: {} }, } as any), ).rejects.toThrow('query.tool-name: Missing required query parameter: tool-name'); }); @@ -180,6 +199,7 @@ describe('route', () => { route: 'invoke-remote-tool', query: { 'tool-name': 'tool-name' }, body: {} as InvokeRemoteToolArgs['body'], + mcpConfigs: { configs: {} }, }), ).rejects.toThrow('body.inputs: Missing required body parameter: inputs'); }); @@ -192,6 +212,7 @@ describe('route', () => { route: 'invoke-remote-tool', query: {}, body: {}, + mcpConfigs: { configs: {} }, } as any), ).rejects.toThrow(/tool-name.*;.*inputs|inputs.*;.*tool-name/); }); @@ -201,7 +222,7 @@ describe('route', () => { it('returns the remote tools definitions', async () => { const router = new Router({}); - const result = await router.route({ route: 'remote-tools' }); + const result = await router.route({ route: 'remote-tools', mcpConfigs: { configs: {} } }); expect(result).toEqual(toolDefinitionsForFrontend); }); @@ -211,7 +232,9 @@ describe('route', () => { it('throws a validation error with helpful message', async () => { const router = new Router({}); - await expect(router.route({ route: 'unknown' } as any)).rejects.toThrow( + await expect( + router.route({ route: 'unknown', mcpConfigs: { configs: {} } } as any), + ).rejects.toThrow( "Invalid route. Expected: 'ai-query', 'invoke-remote-tool', 'remote-tools'", ); }); @@ -264,11 +287,12 @@ describe('route', () => { expect(mcpClientInstance.closeConnections).toHaveBeenCalledTimes(1); }); - it('does not call closeConnections when no mcpConfigs provided', async () => { + it('does not call closeConnections when no mcp configs provided', async () => { const router = new Router({}); await router.route({ route: 'remote-tools', + mcpConfigs: { configs: {} }, }); expect(MockedMcpClient).not.toHaveBeenCalled(); @@ -385,6 +409,72 @@ describe('route', () => { }); }); + describe('config splitting between MCP and integration configs', () => { + const zendeskIntegration = { + isForestConnector: true, + integrationName: 'zendesk', + config: { subdomain: 'test', apiToken: 'token', email: 'a@b.c' }, + }; + + it('passes only non-forest configs to McpClient', async () => { + const router = new Router({}); + + await router.route({ + route: 'remote-tools', + mcpConfigs: { + configs: { + server1: { command: 'test', args: [] }, + zendesk: zendeskIntegration, + } as any, + }, + }); + + expect(MockedMcpClient).toHaveBeenCalledWith( + { configs: { server1: { command: 'test', args: [] } } }, + undefined, + ); + }); + + it('passes forest configs to IntegrationClient', async () => { + const router = new Router({}); + + await router.route({ + route: 'remote-tools', + mcpConfigs: { + configs: { zendesk: zendeskIntegration } as any, + }, + }); + + expect(MockedIntegrationClient).toHaveBeenCalledWith([zendeskIntegration], undefined); + }); + + it('does not create IntegrationClient when no forest configs', async () => { + const router = new Router({}); + + await router.route({ + route: 'remote-tools', + mcpConfigs: { + configs: { server1: { command: 'test', args: [] } }, + }, + }); + + expect(MockedIntegrationClient).not.toHaveBeenCalled(); + }); + + it('does not create McpClient when only forest configs', async () => { + const router = new Router({}); + + await router.route({ + route: 'remote-tools', + mcpConfigs: { + configs: { zendesk: zendeskIntegration } as any, + }, + }); + + expect(MockedMcpClient).not.toHaveBeenCalled(); + }); + }); + describe('Model validation', () => { it('throws AIModelNotSupportedError when model does not support tools', () => { expect( From f4aa7a304b5321312d835cd33125be94a723ef04 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Wed, 25 Mar 2026 17:49:33 +0100 Subject: [PATCH 11/23] chore: code review --- packages/ai-proxy/src/integration-client.ts | 1 + packages/ai-proxy/src/router.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ai-proxy/src/integration-client.ts b/packages/ai-proxy/src/integration-client.ts index aa4453016e..007b1edf5b 100644 --- a/packages/ai-proxy/src/integration-client.ts +++ b/packages/ai-proxy/src/integration-client.ts @@ -24,6 +24,7 @@ export default class IntegrationClient { } loadTools(): McpServerRemoteTool[] { + this.tools.length = 0; this.configs.forEach(({ integrationName, config }) => { switch (integrationName) { case 'Zendesk': diff --git a/packages/ai-proxy/src/router.ts b/packages/ai-proxy/src/router.ts index de0a7a7976..e5918ae8a1 100644 --- a/packages/ai-proxy/src/router.ts +++ b/packages/ai-proxy/src/router.ts @@ -79,7 +79,7 @@ export class Router { const mcpConfigs: McpConfiguration = { configs: {} }; const integrationConfigs: ForestIntegrationConfig[] = []; - Object.entries(args.mcpConfigs.configs).forEach(([name, config]) => { + Object.entries(args.mcpConfigs?.configs ?? {}).forEach(([name, config]) => { if (config.isForestConnector) { integrationConfigs.push(config as ForestIntegrationConfig); } else { From 5df4cd3742ec4cf1f029aa5ff2b2ce75c4fc4366 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Thu, 26 Mar 2026 10:59:40 +0100 Subject: [PATCH 12/23] fix: error --- packages/ai-proxy/src/integrations/zendesk/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ai-proxy/src/integrations/zendesk/utils.ts b/packages/ai-proxy/src/integrations/zendesk/utils.ts index b7e4353491..43d0b43fb4 100644 --- a/packages/ai-proxy/src/integrations/zendesk/utils.ts +++ b/packages/ai-proxy/src/integrations/zendesk/utils.ts @@ -22,7 +22,7 @@ export async function validateZendeskConfig(config: ZendeskConfig) { if (!response.ok) { throw new McpConnectionError( - `Failed to validate Zendesk config: ${json.title || json.error.title}`, + `Failed to validate Zendesk config: ${json.title || json.error?.title || 'Unknown error'}`, ); } } From 7ee4b1c8e1fbf3324e314c2cd3c885e97a545070 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Thu, 26 Mar 2026 14:16:56 +0100 Subject: [PATCH 13/23] fix: ts --- packages/ai-proxy/test/integration-client.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/ai-proxy/test/integration-client.test.ts b/packages/ai-proxy/test/integration-client.test.ts index 9343b68b0b..428c3331af 100644 --- a/packages/ai-proxy/test/integration-client.test.ts +++ b/packages/ai-proxy/test/integration-client.test.ts @@ -14,8 +14,9 @@ describe('IntegrationClient', () => { it('should load zendesk tools when integration is zendesk', () => { const client = new IntegrationClient([ { - integrationName: 'zendesk', + integrationName: 'Zendesk', config: { subdomain: 'test', email: 'a@b.com', apiToken: 'tok' }, + isForestConnector: true, }, ]); @@ -27,7 +28,8 @@ describe('IntegrationClient', () => { it('should log warning for unsupported integration', () => { const logger = jest.fn(); const client = new IntegrationClient( - [{ integrationName: 'unknown', config: {} as any }], + // @ts-expect-error Testing unsupported integration + [{ integrationName: 'unknown', config: {} as any, isForestConnector: true }], logger, ); @@ -45,12 +47,14 @@ describe('IntegrationClient', () => { it('should load tools from multiple configs', () => { const client = new IntegrationClient([ { - integrationName: 'zendesk', + integrationName: 'Zendesk', config: { subdomain: 'a', email: 'a@b.com', apiToken: 'tok' }, + isForestConnector: true, }, { - integrationName: 'zendesk', + integrationName: 'Zendesk', config: { subdomain: 'b', email: 'c@d.com', apiToken: 'tok2' }, + isForestConnector: true, }, ]); From 60b8a0ceb96c5f1bad38b7d4974be005bf5f5df6 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Fri, 27 Mar 2026 11:38:41 +0100 Subject: [PATCH 14/23] refactor(ai-proxy): introduce ToolProvider abstraction Decouple Router from McpClient/IntegrationClient by introducing a ToolProvider interface. Router now receives ToolProvider[] instead of raw configs, and a factory handles the MCP vs integration split. - Add ToolProvider interface (loadTools, checkConnection, dispose) - Add createToolProviders factory to split configs - McpClient/IntegrationClient implement ToolProvider - Router accepts toolProviders[] instead of mcpConfigs - McpConfigChecker uses factory instead of manual type guard - Fix McpClient.loadTools() tool accumulation bug - Fix validateZendeskConfig response.json() before response.ok check - Clean McpConfiguration type (remove ForestIntegrationConfig union) Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/ai-proxy/src/create-ai-provider.ts | 36 ++- packages/ai-proxy/src/index.ts | 17 +- packages/ai-proxy/src/integration-client.ts | 38 ++- .../src/integrations/zendesk/utils.ts | 15 +- packages/ai-proxy/src/mcp-client.ts | 23 +- packages/ai-proxy/src/mcp-config-checker.ts | 30 +- packages/ai-proxy/src/oauth-token-injector.ts | 36 ++- packages/ai-proxy/src/router.ts | 53 +--- packages/ai-proxy/src/schemas/route.ts | 4 +- .../ai-proxy/src/tool-provider-factory.ts | 39 +++ packages/ai-proxy/src/tool-provider.ts | 7 + .../ai-proxy/test/create-ai-provider.test.ts | 86 +++--- .../ai-proxy/test/index.integration.test.ts | 16 +- .../ai-proxy/test/integration-client.test.ts | 65 ++++- .../ai-proxy/test/llm.integration.test.ts | 17 +- packages/ai-proxy/test/mcp-client.test.ts | 121 ++++---- .../ai-proxy/test/mcp-config-checker.test.ts | 90 +++--- packages/ai-proxy/test/router.test.ts | 265 +++--------------- .../test/tool-provider-factory.test.ts | 96 +++++++ 19 files changed, 531 insertions(+), 523 deletions(-) create mode 100644 packages/ai-proxy/src/tool-provider-factory.ts create mode 100644 packages/ai-proxy/src/tool-provider.ts create mode 100644 packages/ai-proxy/test/tool-provider-factory.test.ts diff --git a/packages/ai-proxy/src/create-ai-provider.ts b/packages/ai-proxy/src/create-ai-provider.ts index fe73b6411e..3440c78073 100644 --- a/packages/ai-proxy/src/create-ai-provider.ts +++ b/packages/ai-proxy/src/create-ai-provider.ts @@ -1,20 +1,40 @@ -import type { McpConfiguration } from './mcp-client'; import type { AiConfiguration } from './provider'; import type { RouterRouteArgs } from './schemas/route'; -import type { AiProviderDefinition, AiRouter } from '@forestadmin/agent-toolkit'; +import type { ToolProvider } from './tool-provider'; +import type { ToolSourceConfig } from './tool-provider-factory'; +import type { AiProviderDefinition } from '@forestadmin/agent-toolkit'; +import type { Logger } from '@forestadmin/datasource-toolkit'; import { extractMcpOauthTokensFromHeaders, injectOauthTokens } from './oauth-token-injector'; import { Router } from './router'; +import { createToolProviders } from './tool-provider-factory'; + +interface AiRouterRouteArgs { + route: string; + body?: unknown; + query?: Record; + mcpServerConfigs?: unknown; + headers?: Record; +} + +function resolveToolProviders(args: AiRouterRouteArgs, logger?: Logger): ToolProvider[] { + const mcpServerConfigs = args.mcpServerConfigs as + | { configs: Record } + | undefined; + + if (!mcpServerConfigs) return []; + + const { configs } = mcpServerConfigs; -function resolveMcpConfigs(args: Parameters[0]): McpConfiguration | undefined { const tokensByMcpServerName = args.headers ? extractMcpOauthTokensFromHeaders(args.headers) : undefined; - return injectOauthTokens({ - mcpConfigs: args.mcpServerConfigs as McpConfiguration | undefined, - tokensByMcpServerName, - }); + const configsWithTokens = injectOauthTokens({ configs, tokensByMcpServerName }); + + if (!configsWithTokens) return []; + + return createToolProviders(configsWithTokens, logger); } // eslint-disable-next-line import/prefer-default-export @@ -32,7 +52,7 @@ export function createAiProvider(config: AiConfiguration): AiProviderDefinition route: args.route, body: args.body, query: args.query, - mcpConfigs: resolveMcpConfigs(args), + toolProviders: resolveToolProviders(args, logger), } as RouterRouteArgs), }; }, diff --git a/packages/ai-proxy/src/index.ts b/packages/ai-proxy/src/index.ts index 102f766c6e..58654075bd 100644 --- a/packages/ai-proxy/src/index.ts +++ b/packages/ai-proxy/src/index.ts @@ -1,5 +1,5 @@ -import type { ForestIntegrationConfig } from './integration-client'; -import type { McpConfiguration } from './mcp-client'; +import type { ToolSourceConfig } from './tool-provider-factory'; +import type { Logger } from '@forestadmin/datasource-toolkit'; import McpConfigChecker from './mcp-config-checker'; @@ -10,13 +10,20 @@ export { ForestIntegrationConfig, CustomConfig, ForestIntegrationName } from './ 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 | ForestIntegrationConfig, +export function validToolConfigurationOrThrow( + configs: Record, + logger?: Logger, ) { - return McpConfigChecker.check(mcpConfig); + return McpConfigChecker.check(configs, logger); } + +/** @deprecated Use validToolConfigurationOrThrow instead */ +export const validMcpConfigurationOrThrow = validToolConfigurationOrThrow; diff --git a/packages/ai-proxy/src/integration-client.ts b/packages/ai-proxy/src/integration-client.ts index 007b1edf5b..2b2bfe459b 100644 --- a/packages/ai-proxy/src/integration-client.ts +++ b/packages/ai-proxy/src/integration-client.ts @@ -1,7 +1,9 @@ -import type McpServerRemoteTool from './mcp-server-remote-tool'; +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'; @@ -12,29 +14,47 @@ export interface ForestIntegrationConfig { isForestConnector: true; } -export default class IntegrationClient { +export default class IntegrationClient implements ToolProvider { private readonly logger?: Logger; - - readonly tools: McpServerRemoteTool[] = []; - readonly configs: ForestIntegrationConfig[]; + private readonly configs: ForestIntegrationConfig[]; constructor(configs: ForestIntegrationConfig[], logger?: Logger) { this.logger = logger; this.configs = configs; } - loadTools(): McpServerRemoteTool[] { - this.tools.length = 0; + async loadTools(): Promise { + const tools: RemoteTool[] = []; + this.configs.forEach(({ integrationName, config }) => { switch (integrationName) { case 'Zendesk': - this.tools.push(...getZendeskTools(config as ZendeskConfig)); + tools.push(...getZendeskTools(config as ZendeskConfig)); break; default: this.logger?.('Warn', `Unsupported integration: ${integrationName}`); } }); - return this.tools; + return tools; + } + + async checkConnection(): Promise { + 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 { + // No-op: integrations don't hold persistent connections } } diff --git a/packages/ai-proxy/src/integrations/zendesk/utils.ts b/packages/ai-proxy/src/integrations/zendesk/utils.ts index 43d0b43fb4..e02e7db52b 100644 --- a/packages/ai-proxy/src/integrations/zendesk/utils.ts +++ b/packages/ai-proxy/src/integrations/zendesk/utils.ts @@ -18,11 +18,16 @@ export async function validateZendeskConfig(config: ZendeskConfig) { const response = await fetch(`${baseUrl}/users/me`, { headers }); - const json = await response.json(); - if (!response.ok) { - throw new McpConnectionError( - `Failed to validate Zendesk config: ${json.title || json.error?.title || 'Unknown error'}`, - ); + let errorMessage = response.statusText || 'Unknown error'; + + try { + const json = await response.json(); + errorMessage = json.title || json.error?.title || errorMessage; + } catch { + // Response body is not JSON (e.g. HTML from gateway timeout) + } + + throw new McpConnectionError(`Failed to validate Zendesk config: ${errorMessage}`); } } diff --git a/packages/ai-proxy/src/mcp-client.ts b/packages/ai-proxy/src/mcp-client.ts index f50246b323..71f2868fc6 100644 --- a/packages/ai-proxy/src/mcp-client.ts +++ b/packages/ai-proxy/src/mcp-client.ts @@ -1,4 +1,4 @@ -import type { ForestIntegrationConfig } from './integration-client'; +import type { ToolProvider } from './tool-provider'; import type { Logger } from '@forestadmin/datasource-toolkit'; import { MultiServerMCPClient } from '@langchain/mcp-adapters'; @@ -11,15 +11,13 @@ export type McpServers = MultiServerMCPClient['config']['mcpServers']; export type McpServerConfig = MultiServerMCPClient['config']['mcpServers'][string]; export type McpConfiguration = { - configs: McpServers | Record; + configs: McpServers; } & Omit; -export default class McpClient { +export default class McpClient implements ToolProvider { private readonly mcpClients: Record = {}; private readonly logger?: Logger; - readonly tools: McpServerRemoteTool[] = []; - constructor(config: McpConfiguration, logger?: Logger) { this.logger = logger; // split the config into several clients to be more resilient @@ -33,16 +31,17 @@ export default class McpClient { } async loadTools(): Promise { + const tools: McpServerRemoteTool[] = []; const errors: Array<{ server: string; error: Error }> = []; await Promise.all( Object.entries(this.mcpClients).map(async ([name, client]) => { try { - const tools = (await client.getTools()) ?? []; - const extendedTools = tools.map( + const loadedTools = (await client.getTools()) ?? []; + const extendedTools = loadedTools.map( tool => new McpServerRemoteTool({ tool, sourceId: name }), ); - this.tools.push(...extendedTools); + tools.push(...extendedTools); } catch (error) { this.logger?.('Error', `Error loading tools for ${name}`, error as Error); errors.push({ server: name, error: error as Error }); @@ -60,10 +59,10 @@ export default class McpClient { ); } - return this.tools; + return tools; } - async testConnections(): Promise { + async checkConnection(): Promise { try { await Promise.all( Object.values(this.mcpClients).map(client => client.initializeConnections()), @@ -74,7 +73,7 @@ export default class McpClient { throw new McpConnectionError((error as Error).message); } finally { try { - await this.closeConnections(); + await this.dispose(); } catch (cleanupError) { // Log but don't throw - we don't want to mask the original connection error this.logger?.('Error', 'Error during test connection cleanup', cleanupError as Error); @@ -82,7 +81,7 @@ export default class McpClient { } } - async closeConnections(): Promise { + async dispose(): Promise { const entries = Object.entries(this.mcpClients); const results = await Promise.allSettled(entries.map(([, client]) => client.close())); diff --git a/packages/ai-proxy/src/mcp-config-checker.ts b/packages/ai-proxy/src/mcp-config-checker.ts index 8615d38c82..50dd4f814b 100644 --- a/packages/ai-proxy/src/mcp-config-checker.ts +++ b/packages/ai-proxy/src/mcp-config-checker.ts @@ -1,26 +1,18 @@ -import type { ForestIntegrationConfig } from './integration-client'; -import type { McpConfiguration } from './mcp-client'; +import type { ToolSourceConfig } from './tool-provider-factory'; +import type { Logger } from '@forestadmin/datasource-toolkit'; -import { validateZendeskConfig } from './integrations/zendesk/utils'; -import McpClient from './mcp-client'; +import { createToolProviders } from './tool-provider-factory'; export default class McpConfigChecker { - static isForestIntegrationConfig( - config: McpConfiguration | ForestIntegrationConfig, - ): config is ForestIntegrationConfig { - return 'integrationName' in config; - } + static async check(configs: Record, logger?: Logger): Promise { + const providers = createToolProviders(configs, logger); - static check(mcpConfig: McpConfiguration | ForestIntegrationConfig) { - if (McpConfigChecker.isForestIntegrationConfig(mcpConfig)) { - switch (mcpConfig.integrationName) { - case 'Zendesk': - return validateZendeskConfig(mcpConfig.config); - default: - throw new Error(`Unsupported integration: ${mcpConfig.integrationName}`); - } - } + try { + await Promise.all(providers.map(p => p.checkConnection())); - return new McpClient(mcpConfig).testConnections(); + return true; + } finally { + await Promise.allSettled(providers.map(p => p.dispose())); + } } } diff --git a/packages/ai-proxy/src/oauth-token-injector.ts b/packages/ai-proxy/src/oauth-token-injector.ts index be8b08fef0..ed82c2cf65 100644 --- a/packages/ai-proxy/src/oauth-token-injector.ts +++ b/packages/ai-proxy/src/oauth-token-injector.ts @@ -1,4 +1,5 @@ -import type { McpConfiguration, McpServerConfig } from './mcp-client'; +import type { McpServerConfig } from './mcp-client'; +import type { ToolSourceConfig } from './tool-provider-factory'; import { AIBadRequestError } from './errors'; @@ -57,25 +58,30 @@ export function injectOauthToken({ } /** - * Injects OAuth tokens into all server configurations. - * Returns a new McpConfiguration with tokens injected, or undefined if no configs provided. + * Injects OAuth tokens into tool source configurations. + * Only MCP server configs receive token injection; Forest integration configs are passed through. */ export function injectOauthTokens({ - mcpConfigs, + configs, tokensByMcpServerName, }: { - mcpConfigs: McpConfiguration | undefined; + configs: Record | undefined; tokensByMcpServerName: Record | undefined; -}): McpConfiguration | undefined { - if (!mcpConfigs) return undefined; - if (!tokensByMcpServerName) return mcpConfigs; +}): Record | undefined { + if (!configs) return undefined; + if (!tokensByMcpServerName) return configs; - const configsWithTokens = Object.fromEntries( - Object.entries(mcpConfigs.configs).map(([name, serverConfig]) => [ - name, - injectOauthToken({ serverConfig, token: tokensByMcpServerName[name] }), - ]), - ); + return Object.fromEntries( + Object.entries(configs).map(([name, config]) => { + if ('isForestConnector' in config) return [name, config]; - return { ...mcpConfigs, configs: configsWithTokens }; + return [ + name, + injectOauthToken({ + serverConfig: config as McpServerConfig, + token: tokensByMcpServerName[name], + }), + ]; + }), + ); } diff --git a/packages/ai-proxy/src/router.ts b/packages/ai-proxy/src/router.ts index e5918ae8a1..74953e8269 100644 --- a/packages/ai-proxy/src/router.ts +++ b/packages/ai-proxy/src/router.ts @@ -1,13 +1,11 @@ -import type { McpConfiguration } from './mcp-client'; import type { AiConfiguration } from './provider'; import type { RemoteToolsApiKeys } from './remote-tools'; import type { RouteArgs } from './schemas/route'; +import type { ToolProvider } from './tool-provider'; import type { Logger } from '@forestadmin/datasource-toolkit'; import type { z } from 'zod'; import { AIBadRequestError, AIModelNotSupportedError } from './errors'; -import IntegrationClient, { type ForestIntegrationConfig } from './integration-client'; -import McpClient from './mcp-client'; import ProviderDispatcher from './provider-dispatcher'; import { RemoteTools } from './remote-tools'; import { routeArgsSchema } from './schemas/route'; @@ -60,11 +58,7 @@ export class Router { * - invoke-remote-tool: Execute a remote tool by name with the provided inputs * - remote-tools: Return the list of available remote tools definitions */ - async route( - args: RouteArgs & { - mcpConfigs?: McpConfiguration; - }, - ) { + async route(args: RouteArgs & { toolProviders?: ToolProvider[] }) { // Validate input with Zod schema const result = routeArgsSchema.safeParse(args); @@ -73,33 +67,11 @@ export class Router { } const validatedArgs = result.data; - let mcpClient: McpClient | undefined; - let integrationClient: IntegrationClient | undefined; - - const mcpConfigs: McpConfiguration = { configs: {} }; - const integrationConfigs: ForestIntegrationConfig[] = []; - - Object.entries(args.mcpConfigs?.configs ?? {}).forEach(([name, config]) => { - if (config.isForestConnector) { - integrationConfigs.push(config as ForestIntegrationConfig); - } else { - mcpConfigs.configs[name] = config; - } - }); + const providers = args.toolProviders ?? []; try { - if (mcpConfigs && Object.keys(mcpConfigs.configs).length > 0) { - mcpClient = new McpClient(mcpConfigs, this.logger); - } - - if (integrationConfigs.length > 0) { - integrationClient = new IntegrationClient(integrationConfigs, this.logger); - } - - const remoteTools = new RemoteTools(this.localToolsApiKeys ?? {}, [ - ...((await mcpClient?.loadTools()) ?? []), - ...(integrationClient?.loadTools() ?? []), - ]); + const allTools = (await Promise.all(providers.map(p => p.loadTools()))).flat(); + const remoteTools = new RemoteTools(this.localToolsApiKeys ?? {}, allTools); switch (validatedArgs.route) { case 'ai-query': { @@ -121,26 +93,13 @@ export class Router { /* istanbul ignore next */ default: { - // Exhaustive type check - this code never runs at runtime because Zod validation - // catches unknown routes earlier. However, it provides compile-time safety: - // if a new route is added to routeArgsSchema, TypeScript will error here with - // "Type 'NewRouteArgs' is not assignable to type 'never'", forcing the developer - // to add a corresponding case handler. const exhaustiveCheck: never = validatedArgs; return exhaustiveCheck; } } } finally { - if (mcpClient) { - try { - await mcpClient.closeConnections(); - } catch (cleanupError) { - const error = - cleanupError instanceof Error ? cleanupError : new Error(String(cleanupError)); - this.logger?.('Error', 'Error during MCP connection cleanup', error); - } - } + await Promise.allSettled(providers.map(p => p.dispose())); } } diff --git a/packages/ai-proxy/src/schemas/route.ts b/packages/ai-proxy/src/schemas/route.ts index 2baa234fad..a2896dc974 100644 --- a/packages/ai-proxy/src/schemas/route.ts +++ b/packages/ai-proxy/src/schemas/route.ts @@ -1,4 +1,4 @@ -import type { McpConfiguration } from '../mcp-client'; +import type { ToolProvider } from '../tool-provider'; import { z } from 'zod'; @@ -85,7 +85,7 @@ export type RemoteToolsArgs = z.infer; // Derived types for consumers export type DispatchBody = AiQueryArgs['body']; -export type RouterRouteArgs = RouteArgs & { mcpConfigs?: McpConfiguration }; +export type RouterRouteArgs = RouteArgs & { toolProviders?: ToolProvider[] }; // Backward compatibility types export type InvokeRemoteToolBody = InvokeRemoteToolArgs['body']; diff --git a/packages/ai-proxy/src/tool-provider-factory.ts b/packages/ai-proxy/src/tool-provider-factory.ts new file mode 100644 index 0000000000..69948c97ea --- /dev/null +++ b/packages/ai-proxy/src/tool-provider-factory.ts @@ -0,0 +1,39 @@ +import type { ToolProvider } from './tool-provider'; +import type { Logger } from '@forestadmin/datasource-toolkit'; + +import IntegrationClient, { type ForestIntegrationConfig } from './integration-client'; +import McpClient, { type McpConfiguration, type McpServerConfig } from './mcp-client'; + +export type ToolSourceConfig = McpServerConfig | ForestIntegrationConfig; + +function isForestIntegrationConfig(config: ToolSourceConfig): config is ForestIntegrationConfig { + return 'isForestConnector' in config && config.isForestConnector === true; +} + +export function createToolProviders( + configs: Record, + logger?: Logger, +): ToolProvider[] { + const mcpConfigs: McpConfiguration['configs'] = {}; + const integrationConfigs: ForestIntegrationConfig[] = []; + + for (const [name, config] of Object.entries(configs)) { + if (isForestIntegrationConfig(config)) { + integrationConfigs.push(config); + } else { + mcpConfigs[name] = config; + } + } + + const providers: ToolProvider[] = []; + + if (Object.keys(mcpConfigs).length > 0) { + providers.push(new McpClient({ configs: mcpConfigs }, logger)); + } + + if (integrationConfigs.length > 0) { + providers.push(new IntegrationClient(integrationConfigs, logger)); + } + + return providers; +} diff --git a/packages/ai-proxy/src/tool-provider.ts b/packages/ai-proxy/src/tool-provider.ts new file mode 100644 index 0000000000..ed2a2408fe --- /dev/null +++ b/packages/ai-proxy/src/tool-provider.ts @@ -0,0 +1,7 @@ +import type RemoteTool from './remote-tool'; + +export interface ToolProvider { + loadTools(): Promise; + checkConnection(): Promise; + dispose(): Promise; +} diff --git a/packages/ai-proxy/test/create-ai-provider.test.ts b/packages/ai-proxy/test/create-ai-provider.test.ts index 419d4413ee..3b4d7d4256 100644 --- a/packages/ai-proxy/test/create-ai-provider.test.ts +++ b/packages/ai-proxy/test/create-ai-provider.test.ts @@ -2,8 +2,10 @@ import type { AiConfiguration } from '../src/provider'; import { createAiProvider } from '../src/create-ai-provider'; import { Router } from '../src/router'; +import { createToolProviders } from '../src/tool-provider-factory'; jest.mock('../src/router'); +jest.mock('../src/tool-provider-factory'); const routeMock = jest.fn(); jest.mocked(Router).mockImplementation(() => ({ route: routeMock } as any)); @@ -11,6 +13,7 @@ jest.mocked(Router).mockImplementation(() => ({ route: routeMock } as any)); describe('createAiProvider', () => { beforeEach(() => { jest.clearAllMocks(); + jest.mocked(createToolProviders).mockReturnValue([]); }); const config: AiConfiguration = { @@ -49,16 +52,20 @@ describe('createAiProvider', () => { query: { 'ai-name': 'my-ai' }, }); - expect(routeMock).toHaveBeenCalledWith({ - route: 'ai-query', - body: { messages: [] }, - query: { 'ai-name': 'my-ai' }, - mcpConfigs: undefined, - }); + expect(routeMock).toHaveBeenCalledWith( + expect.objectContaining({ + route: 'ai-query', + body: { messages: [] }, + query: { 'ai-name': 'my-ai' }, + toolProviders: [], + }), + ); expect(result).toEqual({ result: 'ok' }); }); - test('should pass mcpServerConfigs as mcpConfigs to Router when no headers', async () => { + test('should create tool providers from mcpServerConfigs', async () => { + const mockProviders = [{ loadTools: jest.fn(), checkConnection: jest.fn(), dispose: jest.fn() }]; + jest.mocked(createToolProviders).mockReturnValue(mockProviders); routeMock.mockResolvedValue({}); const provider = createAiProvider(config); const aiRouter = provider.init(jest.fn()); @@ -68,15 +75,16 @@ describe('createAiProvider', () => { mcpServerConfigs: { configs: { server1: { command: 'test', args: [] } } }, }); - expect(routeMock).toHaveBeenCalledWith({ - route: 'remote-tools', - body: undefined, - query: undefined, - mcpConfigs: { configs: { server1: { command: 'test', args: [] } } }, - }); + expect(createToolProviders).toHaveBeenCalledWith( + { server1: { command: 'test', args: [] } }, + expect.any(Function), + ); + expect(routeMock).toHaveBeenCalledWith( + expect.objectContaining({ toolProviders: mockProviders }), + ); }); - test('should inject OAuth tokens from headers into mcpConfigs', async () => { + test('should inject OAuth tokens before creating tool providers', async () => { routeMock.mockResolvedValue({}); const provider = createAiProvider(config); const aiRouter = provider.init(jest.fn()); @@ -90,20 +98,16 @@ describe('createAiProvider', () => { headers: { 'x-mcp-oauth-tokens': oauthTokens }, }); - expect(routeMock).toHaveBeenCalledWith({ - route: 'remote-tools', - body: undefined, - query: undefined, - mcpConfigs: { - configs: { - server1: { - type: 'http', - url: 'https://server1.com', - headers: { Authorization: 'Bearer token123' }, - }, + expect(createToolProviders).toHaveBeenCalledWith( + { + server1: { + type: 'http', + url: 'https://server1.com', + headers: { Authorization: 'Bearer token123' }, }, }, - }); + expect.any(Function), + ); }); test('should throw AIBadRequestError when x-mcp-oauth-tokens header contains invalid JSON', () => { @@ -121,38 +125,16 @@ describe('createAiProvider', () => { ).toThrow('Invalid JSON in x-mcp-oauth-tokens header'); }); - test('should pass mcpConfigs unchanged when headers are present but x-mcp-oauth-tokens is absent', async () => { - routeMock.mockResolvedValue({}); - const provider = createAiProvider(config); - const aiRouter = provider.init(jest.fn()); - - await aiRouter.route({ - route: 'remote-tools', - mcpServerConfigs: { configs: { server1: { command: 'test', args: [] } } }, - headers: { 'content-type': 'application/json' }, - }); - - expect(routeMock).toHaveBeenCalledWith({ - route: 'remote-tools', - body: undefined, - query: undefined, - mcpConfigs: { configs: { server1: { command: 'test', args: [] } } }, - }); - }); - - test('should pass mcpConfigs as undefined when no mcpServerConfigs provided', async () => { + test('should pass empty tool providers when no mcpServerConfigs provided', async () => { routeMock.mockResolvedValue({}); const provider = createAiProvider(config); const aiRouter = provider.init(jest.fn()); await aiRouter.route({ route: 'remote-tools' }); - expect(routeMock).toHaveBeenCalledWith({ - route: 'remote-tools', - body: undefined, - query: undefined, - mcpConfigs: undefined, - }); + expect(routeMock).toHaveBeenCalledWith( + expect.objectContaining({ toolProviders: [] }), + ); }); }); }); diff --git a/packages/ai-proxy/test/index.integration.test.ts b/packages/ai-proxy/test/index.integration.test.ts index 559b3168c0..e10f4ae1cd 100644 --- a/packages/ai-proxy/test/index.integration.test.ts +++ b/packages/ai-proxy/test/index.integration.test.ts @@ -22,13 +22,11 @@ describe('Simple MCP Server', () => { describe('validMcpConfigurationOrThrow', () => { it('should return true when the config is right', async () => { const result = await validMcpConfigurationOrThrow({ - configs: { - simpleServer: { - url: 'http://localhost:3123/mcp', - type: 'http', - headers: { - Authorization: `Bearer your-secure-token-here`, - }, + simpleServer: { + url: 'http://localhost:3123/mcp', + type: 'http', + headers: { + Authorization: `Bearer your-secure-token-here`, }, }, }); @@ -39,9 +37,7 @@ describe('Simple MCP Server', () => { it('should throw an error when the config is wrong', async () => { await expect( validMcpConfigurationOrThrow({ - configs: { - simpleServer: { url: 'http://localhost:3123/wrong', type: 'http' }, - }, + simpleServer: { url: 'http://localhost:3123/wrong', type: 'http' }, }), ).rejects.toThrow('Failed to connect to streamable HTTP'); }); diff --git a/packages/ai-proxy/test/integration-client.test.ts b/packages/ai-proxy/test/integration-client.test.ts index 428c3331af..65eba39223 100644 --- a/packages/ai-proxy/test/integration-client.test.ts +++ b/packages/ai-proxy/test/integration-client.test.ts @@ -1,4 +1,5 @@ import IntegrationClient from '../src/integration-client'; +import { validateZendeskConfig } from '../src/integrations/zendesk/utils'; const mockZendeskTools = [{ name: 'zendesk_get_tickets' }, { name: 'zendesk_get_ticket' }]; @@ -7,11 +8,13 @@ jest.mock('../src/integrations/zendesk/tools', () => ({ default: jest.fn(() => mockZendeskTools), })); +jest.mock('../src/integrations/zendesk/utils'); + describe('IntegrationClient', () => { beforeEach(() => jest.clearAllMocks()); describe('loadTools', () => { - it('should load zendesk tools when integration is zendesk', () => { + it('should load zendesk tools when integration is zendesk', async () => { const client = new IntegrationClient([ { integrationName: 'Zendesk', @@ -20,12 +23,12 @@ describe('IntegrationClient', () => { }, ]); - const tools = client.loadTools(); + const tools = await client.loadTools(); expect(tools).toEqual(mockZendeskTools); }); - it('should log warning for unsupported integration', () => { + it('should log warning for unsupported integration', async () => { const logger = jest.fn(); const client = new IntegrationClient( // @ts-expect-error Testing unsupported integration @@ -33,18 +36,18 @@ describe('IntegrationClient', () => { logger, ); - client.loadTools(); + await client.loadTools(); expect(logger).toHaveBeenCalledWith('Warn', 'Unsupported integration: unknown'); }); - it('should return empty array when no configs', () => { + it('should return empty array when no configs', async () => { const client = new IntegrationClient([]); - expect(client.loadTools()).toEqual([]); + expect(await client.loadTools()).toEqual([]); }); - it('should load tools from multiple configs', () => { + it('should load tools from multiple configs', async () => { const client = new IntegrationClient([ { integrationName: 'Zendesk', @@ -58,9 +61,55 @@ describe('IntegrationClient', () => { }, ]); - const tools = client.loadTools(); + const tools = await client.loadTools(); expect(tools).toHaveLength(4); }); }); + + describe('checkConnection', () => { + it('should call validateZendeskConfig for Zendesk integration', async () => { + const zendeskConfig = { subdomain: 'test', email: 'a@b.com', apiToken: 'tok' }; + const client = new IntegrationClient([ + { integrationName: 'Zendesk', config: zendeskConfig, isForestConnector: true }, + ]); + + await client.checkConnection(); + + expect(validateZendeskConfig).toHaveBeenCalledWith(zendeskConfig); + }); + + it('should return true on success', async () => { + const client = new IntegrationClient([ + { + integrationName: 'Zendesk', + config: { subdomain: 'test', email: 'a@b.com', apiToken: 'tok' }, + isForestConnector: true, + }, + ]); + + const result = await client.checkConnection(); + + expect(result).toBe(true); + }); + + it('should throw for unsupported integration', async () => { + const client = new IntegrationClient([ + // @ts-expect-error Testing unsupported integration + { integrationName: 'Unknown', config: {}, isForestConnector: true }, + ]); + + await expect(client.checkConnection()).rejects.toThrow( + 'Unsupported integration: Unknown', + ); + }); + }); + + describe('dispose', () => { + it('should resolve without error', async () => { + const client = new IntegrationClient([]); + + await expect(client.dispose()).resolves.toBeUndefined(); + }); + }); }); diff --git a/packages/ai-proxy/test/llm.integration.test.ts b/packages/ai-proxy/test/llm.integration.test.ts index c68efee762..aa17f0799d 100644 --- a/packages/ai-proxy/test/llm.integration.test.ts +++ b/packages/ai-proxy/test/llm.integration.test.ts @@ -21,6 +21,7 @@ import { z } from 'zod'; import { Router } from '../src'; import runMcpServer from '../src/examples/simple-mcp-server'; import isModelSupportingTools from '../src/supported-models'; +import { createToolProviders } from '../src/tool-provider-factory'; const { OPENAI_API_KEY, ANTHROPIC_API_KEY } = process.env; const describeWithOpenAI = OPENAI_API_KEY ? describe : describe.skip; @@ -643,7 +644,7 @@ describeWithOpenAI('Router integration tests', () => { it('should return MCP tools in the list', async () => { const response = (await router.route({ route: 'remote-tools', - mcpConfigs: mcpConfig, + toolProviders: createToolProviders(mcpConfig.configs), })) as Array<{ name: string; sourceType: string; sourceId: string }>; const toolNames = response.map(t => t.name); @@ -689,7 +690,7 @@ describeWithOpenAI('Router integration tests', () => { const response = (await routerWithLogger.route({ route: 'remote-tools', - mcpConfigs: mixedConfig, + toolProviders: createToolProviders(mixedConfig.configs), })) as Array<{ name: string; sourceId: string }>; const toolNames = response.map(t => t.name); @@ -726,7 +727,7 @@ describeWithOpenAI('Router integration tests', () => { const response = (await routerWithLogger.route({ route: 'remote-tools', - mcpConfigs: badAuthConfig, + toolProviders: createToolProviders(badAuthConfig.configs), })) as Array<{ name: string }>; expect(response).toEqual([]); @@ -753,7 +754,7 @@ describeWithOpenAI('Router integration tests', () => { body: { messages: [{ role: 'user', content: 'Say "hello"' }], }, - mcpConfigs: brokenMcpConfig, + toolProviders: createToolProviders(brokenMcpConfig.configs), })) as ChatCompletionResponse; expect(response.choices[0].message.content).toBeDefined(); @@ -768,7 +769,7 @@ describeWithOpenAI('Router integration tests', () => { body: { inputs: { a: 5, b: 3 } as any, }, - mcpConfigs: mcpConfig, + toolProviders: createToolProviders(mcpConfig.configs), }); expect(response).toBe('8'); @@ -781,7 +782,7 @@ describeWithOpenAI('Router integration tests', () => { body: { inputs: { a: 6, b: 7 } as any, }, - mcpConfigs: mcpConfig, + toolProviders: createToolProviders(mcpConfig.configs), }); expect(response).toBe('42'); @@ -819,7 +820,7 @@ describeWithOpenAI('Router integration tests', () => { ], tool_choice: 'required', }, - mcpConfigs: mcpConfig, + toolProviders: createToolProviders(mcpConfig.configs), })) as ChatCompletionResponse; expect(response.choices[0].finish_reason).toBe('tool_calls'); @@ -847,7 +848,7 @@ describeWithOpenAI('Router integration tests', () => { ], tool_choice: 'required', }, - mcpConfigs: mcpConfig, + toolProviders: createToolProviders(mcpConfig.configs), })) as ChatCompletionResponse; expect(response.choices[0].finish_reason).toBe('tool_calls'); diff --git a/packages/ai-proxy/test/mcp-client.test.ts b/packages/ai-proxy/test/mcp-client.test.ts index 22487dc4f5..ccaeead8a5 100644 --- a/packages/ai-proxy/test/mcp-client.test.ts +++ b/packages/ai-proxy/test/mcp-client.test.ts @@ -54,9 +54,9 @@ describe('McpClient', () => { const mcpClient = new McpClient(aConfig); getToolsMock.mockResolvedValue([tool1, tool2]); - await mcpClient.loadTools(); + const tools = await mcpClient.loadTools(); - expect(mcpClient.tools).toEqual([ + expect(tools).toEqual([ new McpServerRemoteTool({ tool: tool1, sourceId: 'slack', @@ -74,9 +74,9 @@ describe('McpClient', () => { const mcpClient = new McpClient(aConfig); getToolsMock.mockResolvedValue(undefined); - await mcpClient.loadTools(); + const tools = await mcpClient.loadTools(); - expect(mcpClient.tools.length).toEqual(0); + expect(tools.length).toEqual(0); }); }); @@ -102,9 +102,9 @@ describe('McpClient', () => { .mockRejectedValueOnce(new Error('Error loading tools')) .mockResolvedValueOnce(['tool1', 'tool2']); - await mcpClient.loadTools(); + const tools = await mcpClient.loadTools(); - expect(mcpClient.tools.length).toEqual(2); + expect(tools.length).toEqual(2); }); }); }); @@ -113,7 +113,7 @@ describe('McpClient', () => { it('should close the connection', async () => { const mcpClient = new McpClient(aConfig); - await mcpClient.closeConnections(); + await mcpClient.dispose(); expect(closeMock).toHaveBeenCalled(); }); @@ -137,7 +137,7 @@ describe('McpClient', () => { }); closeMock.mockResolvedValue(undefined); - await mcpClient.closeConnections(); + await mcpClient.dispose(); expect(closeMock).toHaveBeenCalledTimes(2); }); @@ -167,7 +167,7 @@ describe('McpClient', () => { .mockRejectedValueOnce(new Error('Slack close failed')) .mockResolvedValueOnce(undefined); - await mcpClient.closeConnections(); + await mcpClient.dispose(); // Should attempt to close both connections expect(closeMock).toHaveBeenCalledTimes(2); @@ -183,23 +183,23 @@ describe('McpClient', () => { ); }); - it('should not throw when closeConnections fails', async () => { + it('should not throw when dispose fails', async () => { const loggerMock = jest.fn(); const mcpClient = new McpClient(aConfig, loggerMock); closeMock.mockRejectedValue(new Error('Close failed')); // Should not throw - await mcpClient.closeConnections(); + await mcpClient.dispose(); expect(loggerMock).toHaveBeenCalled(); }); }); - describe('testConnections', () => { + describe('checkConnection', () => { it('should init the connections & close the connections even if there is no error', async () => { const mcpClient = new McpClient(aConfig); - await mcpClient.testConnections(); + await mcpClient.checkConnection(); expect(closeMock).toHaveBeenCalled(); expect(initializeConnectionsMock).toHaveBeenCalled(); @@ -211,7 +211,7 @@ describe('McpClient', () => { const errorMessage = 'Connection error'; initializeConnectionsMock.mockRejectedValue(new Error(errorMessage)); - await expect(mcpClient.testConnections()).rejects.toThrow( + await expect(mcpClient.checkConnection()).rejects.toThrow( new McpConnectionError(errorMessage), ); expect(closeMock).toHaveBeenCalled(); @@ -225,10 +225,10 @@ describe('McpClient', () => { closeMock.mockRejectedValue(new Error('Cleanup failed')); // Original connection error should be thrown, not the cleanup error - await expect(mcpClient.testConnections()).rejects.toThrow( + await expect(mcpClient.checkConnection()).rejects.toThrow( new McpConnectionError(connectionError), ); - // Cleanup failure should be logged via closeConnections internal logging + // Cleanup failure should be logged via dispose internal logging expect(loggerMock).toHaveBeenCalledWith( 'Error', expect.stringContaining('Failed to close MCP connection for'), @@ -332,74 +332,87 @@ describe('McpClient', () => { describe('injectOauthTokens', () => { it('should inject tokens into all matching server configs', () => { - const mcpConfigs = { - configs: { - server1: { type: 'http' as const, url: 'https://server1.com' }, - server2: { type: 'http' as const, url: 'https://server2.com' }, - }, + const configs = { + server1: { type: 'http' as const, url: 'https://server1.com' }, + server2: { type: 'http' as const, url: 'https://server2.com' }, }; const tokens = { server1: 'Bearer token1', server2: 'Bearer token2' }; - const result = injectOauthTokens({ mcpConfigs, tokensByMcpServerName: tokens }); + const result = injectOauthTokens({ configs, tokensByMcpServerName: tokens }); expect(result).toEqual({ - configs: { - server1: { - type: 'http', - url: 'https://server1.com', - headers: { Authorization: 'Bearer token1' }, - }, - server2: { - type: 'http', - url: 'https://server2.com', - headers: { Authorization: 'Bearer token2' }, - }, + server1: { + type: 'http', + url: 'https://server1.com', + headers: { Authorization: 'Bearer token1' }, + }, + server2: { + type: 'http', + url: 'https://server2.com', + headers: { Authorization: 'Bearer token2' }, }, }); }); it('should only inject tokens for servers that have matching tokens', () => { - const mcpConfigs = { - configs: { - server1: { type: 'http' as const, url: 'https://server1.com' }, - server2: { type: 'http' as const, url: 'https://server2.com' }, - }, + const configs = { + server1: { type: 'http' as const, url: 'https://server1.com' }, + server2: { type: 'http' as const, url: 'https://server2.com' }, }; const tokens = { server1: 'Bearer token1' }; - const result = injectOauthTokens({ mcpConfigs, tokensByMcpServerName: tokens }); + const result = injectOauthTokens({ configs, tokensByMcpServerName: tokens }); expect(result).toEqual({ - configs: { - server1: { - type: 'http', - url: 'https://server1.com', - headers: { Authorization: 'Bearer token1' }, - }, - server2: { type: 'http', url: 'https://server2.com' }, + server1: { + type: 'http', + url: 'https://server1.com', + headers: { Authorization: 'Bearer token1' }, }, + server2: { type: 'http', url: 'https://server2.com' }, }); }); - it('should return undefined when mcpConfigs is undefined', () => { + it('should return undefined when configs is undefined', () => { const result = injectOauthTokens({ - mcpConfigs: undefined, + configs: undefined, tokensByMcpServerName: { server1: 'Bearer token1' }, }); expect(result).toBeUndefined(); }); - it('should return mcpConfigs unchanged when tokens is undefined', () => { - const mcpConfigs = { - configs: { - server1: { type: 'http' as const, url: 'https://server1.com' }, + it('should return configs unchanged when tokens is undefined', () => { + const configs = { + server1: { type: 'http' as const, url: 'https://server1.com' }, + }; + + const result = injectOauthTokens({ configs, tokensByMcpServerName: undefined }); + + expect(result).toBe(configs); + }); + + it('should pass through ForestIntegration configs without injecting tokens', () => { + const configs = { + server1: { type: 'http' as const, url: 'https://server1.com' }, + zendesk: { + isForestConnector: true as const, + integrationName: 'Zendesk' as const, + config: { subdomain: 'test', email: 'a@b.com', apiToken: 'tok' }, }, }; + const tokens = { server1: 'Bearer token1' }; - const result = injectOauthTokens({ mcpConfigs, tokensByMcpServerName: undefined }); + const result = injectOauthTokens({ configs, tokensByMcpServerName: tokens }); - expect(result).toBe(mcpConfigs); + expect(result).toEqual({ + server1: { + type: 'http', + url: 'https://server1.com', + headers: { Authorization: 'Bearer token1' }, + }, + zendesk: configs.zendesk, + }); }); }); }); diff --git a/packages/ai-proxy/test/mcp-config-checker.test.ts b/packages/ai-proxy/test/mcp-config-checker.test.ts index a02976bea1..2b4708d3aa 100644 --- a/packages/ai-proxy/test/mcp-config-checker.test.ts +++ b/packages/ai-proxy/test/mcp-config-checker.test.ts @@ -1,66 +1,72 @@ import McpConfigChecker from '../src/mcp-config-checker'; -import * as zendeskUtils from '../src/integrations/zendesk/utils'; -import McpClient from '../src/mcp-client'; +import { createToolProviders } from '../src/tool-provider-factory'; -jest.mock('../src/integrations/zendesk/utils'); -jest.mock('../src/mcp-client'); +jest.mock('../src/tool-provider-factory'); describe('McpConfigChecker', () => { beforeEach(() => jest.clearAllMocks()); - describe('isForestIntegrationConfig', () => { - it('should return true when config has integrationName', () => { - const config = { - integrationName: 'Zendesk' as const, - config: { subdomain: 'test', email: 'a@b.com', apiToken: 'tok' }, - isForestConnector: true as const, + describe('check', () => { + it('should call checkConnection on all providers and return true', async () => { + const mockProvider1 = { + checkConnection: jest.fn().mockResolvedValue(true), + dispose: jest.fn().mockResolvedValue(undefined), + loadTools: jest.fn(), }; + const mockProvider2 = { + checkConnection: jest.fn().mockResolvedValue(true), + dispose: jest.fn().mockResolvedValue(undefined), + loadTools: jest.fn(), + }; + jest.mocked(createToolProviders).mockReturnValue([mockProvider1, mockProvider2]); - expect(McpConfigChecker.isForestIntegrationConfig(config)).toBe(true); - }); - - it('should return false when config is an MCP configuration', () => { - const config = { url: 'http://localhost:3000' }; + const result = await McpConfigChecker.check({ + server1: { command: 'test', args: [] }, + }); - expect(McpConfigChecker.isForestIntegrationConfig(config as any)).toBe(false); + expect(result).toBe(true); + expect(mockProvider1.checkConnection).toHaveBeenCalled(); + expect(mockProvider2.checkConnection).toHaveBeenCalled(); }); - }); - describe('check', () => { - it('should call validateZendeskConfig for Zendesk integration', async () => { - const zendeskConfig = { subdomain: 'test', email: 'a@b.com', apiToken: 'tok' }; - const config = { - integrationName: 'Zendesk' as const, - config: zendeskConfig, - isForestConnector: true as const, + it('should dispose all providers after check', async () => { + const mockProvider = { + checkConnection: jest.fn().mockResolvedValue(true), + dispose: jest.fn().mockResolvedValue(undefined), + loadTools: jest.fn(), }; + jest.mocked(createToolProviders).mockReturnValue([mockProvider]); - await McpConfigChecker.check(config); + await McpConfigChecker.check({ server1: { command: 'test', args: [] } }); - expect(zendeskUtils.validateZendeskConfig).toHaveBeenCalledWith(zendeskConfig); + expect(mockProvider.dispose).toHaveBeenCalled(); }); - it('should throw for unsupported integration', () => { - const config = { - integrationName: 'Unknown', - config: { subdomain: 'x', email: 'x', apiToken: 'x' }, - isForestConnector: true, - } as any; + it('should dispose providers even when checkConnection fails', async () => { + const mockProvider = { + checkConnection: jest.fn().mockRejectedValue(new Error('Connection failed')), + dispose: jest.fn().mockResolvedValue(undefined), + loadTools: jest.fn(), + }; + jest.mocked(createToolProviders).mockReturnValue([mockProvider]); + + await expect( + McpConfigChecker.check({ server1: { command: 'test', args: [] } }), + ).rejects.toThrow('Connection failed'); - expect(() => McpConfigChecker.check(config)).toThrow('Unsupported integration: Unknown'); + expect(mockProvider.dispose).toHaveBeenCalled(); }); - it('should call McpClient.testConnections for MCP config', async () => { - const testConnectionsMock = jest.fn().mockResolvedValue(undefined); - (McpClient as jest.Mock).mockImplementation(() => ({ - testConnections: testConnectionsMock, - })); - const mcpConfig = { url: 'http://localhost:3000' }; + it('should pass logger to createToolProviders', async () => { + jest.mocked(createToolProviders).mockReturnValue([]); + const logger = jest.fn(); - await McpConfigChecker.check(mcpConfig as any); + await McpConfigChecker.check({ server1: { command: 'test', args: [] } }, logger); - expect(McpClient).toHaveBeenCalledWith(mcpConfig); - expect(testConnectionsMock).toHaveBeenCalled(); + expect(createToolProviders).toHaveBeenCalledWith( + { server1: { command: 'test', args: [] } }, + logger, + ); }); }); }); diff --git a/packages/ai-proxy/test/router.test.ts b/packages/ai-proxy/test/router.test.ts index 5307ec15ae..dd2f18cd0a 100644 --- a/packages/ai-proxy/test/router.test.ts +++ b/packages/ai-proxy/test/router.test.ts @@ -1,9 +1,8 @@ import type { DispatchBody, InvokeRemoteToolArgs } from '../src'; +import type { ToolProvider } from '../src/tool-provider'; import type { Logger } from '@forestadmin/datasource-toolkit'; import { AIModelNotSupportedError, Router } from '../src'; -import IntegrationClient from '../src/integration-client'; -import McpClient from '../src/mcp-client'; import ProviderDispatcher from '../src/provider-dispatcher'; const invokeToolMock = jest.fn(); @@ -31,26 +30,14 @@ jest.mock('../src/provider-dispatcher', () => { const ProviderDispatcherMock = ProviderDispatcher as jest.MockedClass; -jest.mock('../src/mcp-client', () => { - return jest.fn().mockImplementation(() => ({ - loadTools: jest.fn().mockResolvedValue([]), - closeConnections: jest.fn(), - })); -}); - -const MockedMcpClient = McpClient as jest.MockedClass; - -const loadToolsMock = jest.fn().mockReturnValue([]); -jest.mock('../src/integration-client', () => { +function createMockToolProvider(overrides?: Partial): ToolProvider { return { - __esModule: true, - default: jest.fn().mockImplementation(() => ({ - loadTools: loadToolsMock, - })), + loadTools: jest.fn().mockResolvedValue([]), + checkConnection: jest.fn().mockResolvedValue(true), + dispose: jest.fn().mockResolvedValue(undefined), + ...overrides, }; -}); - -const MockedIntegrationClient = IntegrationClient as jest.MockedClass; +} describe('route', () => { beforeEach(() => { @@ -73,7 +60,6 @@ describe('route', () => { await router.route({ route: 'ai-query', body: { tools: [], tool_choice: 'required', messages: [] } as unknown as DispatchBody, - mcpConfigs: { configs: {} }, }); expect(dispatchMock).toHaveBeenCalledWith({ @@ -104,7 +90,6 @@ describe('route', () => { route: 'ai-query', query: { 'ai-name': 'gpt4mini' }, body: { tools: [], tool_choice: 'required', messages: [] } as unknown as DispatchBody, - mcpConfigs: { configs: {} }, }); expect(ProviderDispatcherMock).toHaveBeenCalledWith(gpt4MiniConfig, expect.anything()); @@ -130,7 +115,6 @@ describe('route', () => { await router.route({ route: 'ai-query', body: { tools: [], tool_choice: 'required', messages: [] } as unknown as DispatchBody, - mcpConfigs: { configs: {} }, }); expect(ProviderDispatcherMock).toHaveBeenCalledWith(gpt4Config, expect.anything()); @@ -153,7 +137,6 @@ describe('route', () => { route: 'ai-query', query: { 'ai-name': 'non-existent' }, body: { tools: [], tool_choice: 'required', messages: [] } as unknown as DispatchBody, - mcpConfigs: { configs: {} }, }); expect(mockLogger).toHaveBeenCalledWith( @@ -172,7 +155,6 @@ describe('route', () => { route: 'invoke-remote-tool', query: { 'tool-name': 'tool-name' }, body: { inputs: [] }, - mcpConfigs: { configs: {} }, }); expect(invokeToolMock).toHaveBeenCalledWith('tool-name', []); @@ -186,7 +168,6 @@ describe('route', () => { route: 'invoke-remote-tool', query: {}, body: { inputs: [] }, - mcpConfigs: { configs: {} }, } as any), ).rejects.toThrow('query.tool-name: Missing required query parameter: tool-name'); }); @@ -199,7 +180,6 @@ describe('route', () => { route: 'invoke-remote-tool', query: { 'tool-name': 'tool-name' }, body: {} as InvokeRemoteToolArgs['body'], - mcpConfigs: { configs: {} }, }), ).rejects.toThrow('body.inputs: Missing required body parameter: inputs'); }); @@ -212,7 +192,6 @@ describe('route', () => { route: 'invoke-remote-tool', query: {}, body: {}, - mcpConfigs: { configs: {} }, } as any), ).rejects.toThrow(/tool-name.*;.*inputs|inputs.*;.*tool-name/); }); @@ -222,7 +201,7 @@ describe('route', () => { it('returns the remote tools definitions', async () => { const router = new Router({}); - const result = await router.route({ route: 'remote-tools', mcpConfigs: { configs: {} } }); + const result = await router.route({ route: 'remote-tools' }); expect(result).toEqual(toolDefinitionsForFrontend); }); @@ -232,246 +211,78 @@ describe('route', () => { it('throws a validation error with helpful message', async () => { const router = new Router({}); - await expect( - router.route({ route: 'unknown', mcpConfigs: { configs: {} } } as any), - ).rejects.toThrow( + await expect(router.route({ route: 'unknown' } as any)).rejects.toThrow( "Invalid route. Expected: 'ai-query', 'invoke-remote-tool', 'remote-tools'", ); }); + }); - it('does not include mcpConfigs in the error message', async () => { + describe('ToolProvider lifecycle', () => { + it('calls loadTools on all provided tool providers', async () => { + const provider1 = createMockToolProvider(); + const provider2 = createMockToolProvider(); const router = new Router({}); - await expect( - router.route({ - route: 'unknown', - mcpConfigs: { configs: {} }, - } as any), - ).rejects.toThrow( - "Invalid route. Expected: 'ai-query', 'invoke-remote-tool', 'remote-tools'", - ); + await router.route({ + route: 'remote-tools', + toolProviders: [provider1, provider2], + }); + + expect(provider1.loadTools).toHaveBeenCalledTimes(1); + expect(provider2.loadTools).toHaveBeenCalledTimes(1); }); - }); - describe('MCP connection cleanup', () => { - it('closes the MCP connection after successful route handling', async () => { + it('disposes all providers after successful route handling', async () => { + const provider = createMockToolProvider(); const router = new Router({}); await router.route({ route: 'remote-tools', - mcpConfigs: { configs: { server1: { command: 'test', args: [] } } }, + toolProviders: [provider], }); - expect(MockedMcpClient).toHaveBeenCalledTimes(1); - const mcpClientInstance = MockedMcpClient.mock.results[0].value as jest.Mocked; - expect(mcpClientInstance.closeConnections).toHaveBeenCalledTimes(1); + expect(provider.dispose).toHaveBeenCalledTimes(1); }); - it('closes the MCP connection even when an error occurs', async () => { + it('disposes all providers even when an error occurs', async () => { + const provider = createMockToolProvider(); const router = new Router({}); - - // Validation errors happen before MCP client creation, so we test with a valid route - // that causes an error after MCP client is created dispatchMock.mockRejectedValue(new Error('AI dispatch error')); await expect( router.route({ route: 'ai-query', body: { messages: [] }, - mcpConfigs: { configs: { server1: { command: 'test', args: [] } } }, + toolProviders: [provider], } as any), ).rejects.toThrow(); - expect(MockedMcpClient).toHaveBeenCalledTimes(1); - const mcpClientInstance = MockedMcpClient.mock.results[0].value as jest.Mocked; - expect(mcpClientInstance.closeConnections).toHaveBeenCalledTimes(1); + expect(provider.dispose).toHaveBeenCalledTimes(1); }); - it('does not call closeConnections when no mcp configs provided', async () => { + it('works with no tool providers', async () => { const router = new Router({}); - await router.route({ - route: 'remote-tools', - mcpConfigs: { configs: {} }, - }); + const result = await router.route({ route: 'remote-tools' }); - expect(MockedMcpClient).not.toHaveBeenCalled(); - }); - - it('does not throw when closeConnections fails during successful route', async () => { - const mockLogger = jest.fn(); - const router = new Router({ - logger: mockLogger, - }); - const closeError = new Error('Cleanup failed'); - - jest.mocked(McpClient).mockImplementation( - () => - ({ - loadTools: jest.fn().mockResolvedValue([]), - closeConnections: jest.fn().mockRejectedValue(closeError), - } as unknown as McpClient), - ); - - // Should not throw even though cleanup fails - const result = await router.route({ - route: 'remote-tools', - mcpConfigs: { configs: { server1: { command: 'test', args: [] } } }, - }); - - expect(result).toBeDefined(); - expect(mockLogger).toHaveBeenCalledWith( - 'Error', - 'Error during MCP connection cleanup', - closeError, - ); + expect(result).toEqual(toolDefinitionsForFrontend); }); - it('preserves original error when both route and cleanup fail', async () => { - const mockLogger = jest.fn(); - const router = new Router({ - logger: mockLogger, - }); - const closeError = new Error('Cleanup failed'); + it('preserves original error when dispose fails', async () => { const dispatchError = new Error('Dispatch failed'); - - jest.mocked(McpClient).mockImplementation( - () => - ({ - loadTools: jest.fn().mockResolvedValue([]), - closeConnections: jest.fn().mockRejectedValue(closeError), - } as unknown as McpClient), - ); + const provider = createMockToolProvider({ + dispose: jest.fn().mockRejectedValue(new Error('Dispose failed')), + }); + const router = new Router({}); dispatchMock.mockRejectedValue(dispatchError); - // Should throw the original route error, not the cleanup error await expect( router.route({ route: 'ai-query', body: { messages: [] }, - mcpConfigs: { configs: { server1: { command: 'test', args: [] } } }, + toolProviders: [provider], } as any), ).rejects.toThrow(dispatchError); - - // Cleanup error should be logged - expect(mockLogger).toHaveBeenCalledWith( - 'Error', - 'Error during MCP connection cleanup', - closeError, - ); - }); - }); - - describe('Logger injection', () => { - it('uses the injected logger instead of console', async () => { - const customLogger: Logger = jest.fn(); - const router = new Router({ - logger: customLogger, - }); - const closeError = new Error('Cleanup failed'); - - jest.mocked(McpClient).mockImplementation( - () => - ({ - loadTools: jest.fn().mockResolvedValue([]), - closeConnections: jest.fn().mockRejectedValue(closeError), - } as unknown as McpClient), - ); - - await router.route({ - route: 'remote-tools', - mcpConfigs: { configs: { server1: { command: 'test', args: [] } } }, - }); - - // Custom logger should be called - expect(customLogger).toHaveBeenCalledWith( - 'Error', - 'Error during MCP connection cleanup', - closeError, - ); - }); - - it('passes logger to McpClient', async () => { - const customLogger: Logger = jest.fn(); - const router = new Router({ - logger: customLogger, - }); - - await router.route({ - route: 'remote-tools', - mcpConfigs: { configs: { server1: { command: 'test', args: [] } } }, - }); - - expect(MockedMcpClient).toHaveBeenCalledWith( - { configs: { server1: { command: 'test', args: [] } } }, - customLogger, - ); - }); - }); - - describe('config splitting between MCP and integration configs', () => { - const zendeskIntegration = { - isForestConnector: true, - integrationName: 'zendesk', - config: { subdomain: 'test', apiToken: 'token', email: 'a@b.c' }, - }; - - it('passes only non-forest configs to McpClient', async () => { - const router = new Router({}); - - await router.route({ - route: 'remote-tools', - mcpConfigs: { - configs: { - server1: { command: 'test', args: [] }, - zendesk: zendeskIntegration, - } as any, - }, - }); - - expect(MockedMcpClient).toHaveBeenCalledWith( - { configs: { server1: { command: 'test', args: [] } } }, - undefined, - ); - }); - - it('passes forest configs to IntegrationClient', async () => { - const router = new Router({}); - - await router.route({ - route: 'remote-tools', - mcpConfigs: { - configs: { zendesk: zendeskIntegration } as any, - }, - }); - - expect(MockedIntegrationClient).toHaveBeenCalledWith([zendeskIntegration], undefined); - }); - - it('does not create IntegrationClient when no forest configs', async () => { - const router = new Router({}); - - await router.route({ - route: 'remote-tools', - mcpConfigs: { - configs: { server1: { command: 'test', args: [] } }, - }, - }); - - expect(MockedIntegrationClient).not.toHaveBeenCalled(); - }); - - it('does not create McpClient when only forest configs', async () => { - const router = new Router({}); - - await router.route({ - route: 'remote-tools', - mcpConfigs: { - configs: { zendesk: zendeskIntegration } as any, - }, - }); - - expect(MockedMcpClient).not.toHaveBeenCalled(); }); }); diff --git a/packages/ai-proxy/test/tool-provider-factory.test.ts b/packages/ai-proxy/test/tool-provider-factory.test.ts new file mode 100644 index 0000000000..db20bdd4ad --- /dev/null +++ b/packages/ai-proxy/test/tool-provider-factory.test.ts @@ -0,0 +1,96 @@ +import IntegrationClient from '../src/integration-client'; +import McpClient from '../src/mcp-client'; +import { createToolProviders } from '../src/tool-provider-factory'; + +jest.mock('../src/mcp-client', () => { + return jest.fn().mockImplementation(() => ({ + loadTools: jest.fn(), + checkConnection: jest.fn(), + dispose: jest.fn(), + })); +}); + +jest.mock('../src/integration-client', () => { + return { + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + loadTools: jest.fn(), + checkConnection: jest.fn(), + dispose: jest.fn(), + })), + }; +}); + +describe('createToolProviders', () => { + beforeEach(() => jest.clearAllMocks()); + + it('should create McpClient for MCP configs', () => { + const configs = { + slack: { command: 'npx', args: ['-y', '@modelcontextprotocol/server-slack'] }, + }; + + const providers = createToolProviders(configs as any); + + expect(providers).toHaveLength(1); + expect(McpClient).toHaveBeenCalledWith( + { configs: { slack: configs.slack } }, + undefined, + ); + }); + + it('should create IntegrationClient for ForestIntegration configs', () => { + const zendeskConfig = { + isForestConnector: true as const, + integrationName: 'Zendesk' as const, + config: { subdomain: 'test', email: 'a@b.com', apiToken: 'tok' }, + }; + + const providers = createToolProviders({ zendesk: zendeskConfig }); + + expect(providers).toHaveLength(1); + expect(IntegrationClient).toHaveBeenCalledWith([zendeskConfig], undefined); + }); + + it('should split mixed configs into MCP and integration providers', () => { + const configs = { + slack: { command: 'npx', args: [] }, + zendesk: { + isForestConnector: true as const, + integrationName: 'Zendesk' as const, + config: { subdomain: 'test', email: 'a@b.com', apiToken: 'tok' }, + }, + }; + + const providers = createToolProviders(configs as any); + + expect(providers).toHaveLength(2); + expect(McpClient).toHaveBeenCalledWith( + { configs: { slack: configs.slack } }, + undefined, + ); + expect(IntegrationClient).toHaveBeenCalledWith([configs.zendesk], undefined); + }); + + it('should return empty array when no configs', () => { + const providers = createToolProviders({}); + + expect(providers).toHaveLength(0); + }); + + it('should pass logger to both clients', () => { + const logger = jest.fn(); + const configs = { + slack: { command: 'npx', args: [] }, + zendesk: { + isForestConnector: true as const, + integrationName: 'Zendesk' as const, + config: { subdomain: 'test', email: 'a@b.com', apiToken: 'tok' }, + }, + }; + + createToolProviders(configs as any, logger); + + expect(McpClient).toHaveBeenCalledWith(expect.anything(), logger); + expect(IntegrationClient).toHaveBeenCalledWith(expect.anything(), logger); + }); +}); From 0f37a161037717397c81607b6095abcf28b1861c Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Fri, 27 Mar 2026 11:50:25 +0100 Subject: [PATCH 15/23] refactor(ai-proxy): rename McpConfigChecker to ToolSourceChecker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The class no longer has any MCP-specific logic — it delegates to the ToolProvider factory. The new name reflects its actual responsibility. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/ai-proxy/src/index.ts | 4 ++-- ...{mcp-config-checker.ts => tool-source-checker.ts} | 2 +- ...g-checker.test.ts => tool-source-checker.test.ts} | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) rename packages/ai-proxy/src/{mcp-config-checker.ts => tool-source-checker.ts} (92%) rename packages/ai-proxy/test/{mcp-config-checker.test.ts => tool-source-checker.test.ts} (84%) diff --git a/packages/ai-proxy/src/index.ts b/packages/ai-proxy/src/index.ts index 58654075bd..d6f1e5e400 100644 --- a/packages/ai-proxy/src/index.ts +++ b/packages/ai-proxy/src/index.ts @@ -1,7 +1,7 @@ import type { ToolSourceConfig } 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'; @@ -22,7 +22,7 @@ export function validToolConfigurationOrThrow( configs: Record, logger?: Logger, ) { - return McpConfigChecker.check(configs, logger); + return ToolSourceChecker.check(configs, logger); } /** @deprecated Use validToolConfigurationOrThrow instead */ diff --git a/packages/ai-proxy/src/mcp-config-checker.ts b/packages/ai-proxy/src/tool-source-checker.ts similarity index 92% rename from packages/ai-proxy/src/mcp-config-checker.ts rename to packages/ai-proxy/src/tool-source-checker.ts index 50dd4f814b..1bbc76636d 100644 --- a/packages/ai-proxy/src/mcp-config-checker.ts +++ b/packages/ai-proxy/src/tool-source-checker.ts @@ -3,7 +3,7 @@ import type { Logger } from '@forestadmin/datasource-toolkit'; import { createToolProviders } from './tool-provider-factory'; -export default class McpConfigChecker { +export default class ToolSourceChecker { static async check(configs: Record, logger?: Logger): Promise { const providers = createToolProviders(configs, logger); diff --git a/packages/ai-proxy/test/mcp-config-checker.test.ts b/packages/ai-proxy/test/tool-source-checker.test.ts similarity index 84% rename from packages/ai-proxy/test/mcp-config-checker.test.ts rename to packages/ai-proxy/test/tool-source-checker.test.ts index 2b4708d3aa..54e2ba8685 100644 --- a/packages/ai-proxy/test/mcp-config-checker.test.ts +++ b/packages/ai-proxy/test/tool-source-checker.test.ts @@ -1,9 +1,9 @@ -import McpConfigChecker from '../src/mcp-config-checker'; +import ToolSourceChecker from '../src/tool-source-checker'; import { createToolProviders } from '../src/tool-provider-factory'; jest.mock('../src/tool-provider-factory'); -describe('McpConfigChecker', () => { +describe('ToolSourceChecker', () => { beforeEach(() => jest.clearAllMocks()); describe('check', () => { @@ -20,7 +20,7 @@ describe('McpConfigChecker', () => { }; jest.mocked(createToolProviders).mockReturnValue([mockProvider1, mockProvider2]); - const result = await McpConfigChecker.check({ + const result = await ToolSourceChecker.check({ server1: { command: 'test', args: [] }, }); @@ -37,7 +37,7 @@ describe('McpConfigChecker', () => { }; jest.mocked(createToolProviders).mockReturnValue([mockProvider]); - await McpConfigChecker.check({ server1: { command: 'test', args: [] } }); + await ToolSourceChecker.check({ server1: { command: 'test', args: [] } }); expect(mockProvider.dispose).toHaveBeenCalled(); }); @@ -51,7 +51,7 @@ describe('McpConfigChecker', () => { jest.mocked(createToolProviders).mockReturnValue([mockProvider]); await expect( - McpConfigChecker.check({ server1: { command: 'test', args: [] } }), + ToolSourceChecker.check({ server1: { command: 'test', args: [] } }), ).rejects.toThrow('Connection failed'); expect(mockProvider.dispose).toHaveBeenCalled(); @@ -61,7 +61,7 @@ describe('McpConfigChecker', () => { jest.mocked(createToolProviders).mockReturnValue([]); const logger = jest.fn(); - await McpConfigChecker.check({ server1: { command: 'test', args: [] } }, logger); + await ToolSourceChecker.check({ server1: { command: 'test', args: [] } }, logger); expect(createToolProviders).toHaveBeenCalledWith( { server1: { command: 'test', args: [] } }, From a891b598cce2f1741806116f791efaafa5ec67a3 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Fri, 27 Mar 2026 11:54:55 +0100 Subject: [PATCH 16/23] refactor(ai-proxy): remove duplicate AiRouterRouteArgs type Use Parameters[0] from agent-toolkit instead of maintaining a local copy that can diverge silently. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/ai-proxy/src/create-ai-provider.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/packages/ai-proxy/src/create-ai-provider.ts b/packages/ai-proxy/src/create-ai-provider.ts index 3440c78073..0abe07c38f 100644 --- a/packages/ai-proxy/src/create-ai-provider.ts +++ b/packages/ai-proxy/src/create-ai-provider.ts @@ -2,22 +2,17 @@ import type { AiConfiguration } from './provider'; import type { RouterRouteArgs } from './schemas/route'; import type { ToolProvider } from './tool-provider'; import type { ToolSourceConfig } from './tool-provider-factory'; -import type { AiProviderDefinition } from '@forestadmin/agent-toolkit'; +import type { AiProviderDefinition, AiRouter } from '@forestadmin/agent-toolkit'; import type { Logger } from '@forestadmin/datasource-toolkit'; import { extractMcpOauthTokensFromHeaders, injectOauthTokens } from './oauth-token-injector'; import { Router } from './router'; import { createToolProviders } from './tool-provider-factory'; -interface AiRouterRouteArgs { - route: string; - body?: unknown; - query?: Record; - mcpServerConfigs?: unknown; - headers?: Record; -} - -function resolveToolProviders(args: AiRouterRouteArgs, logger?: Logger): ToolProvider[] { +function resolveToolProviders( + args: Parameters[0], + logger?: Logger, +): ToolProvider[] { const mcpServerConfigs = args.mcpServerConfigs as | { configs: Record } | undefined; From 50f00375a860735633544cfc1960877c99bcdd2a Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Fri, 27 Mar 2026 11:58:31 +0100 Subject: [PATCH 17/23] refactor(ai-proxy): remove deprecated validMcpConfigurationOrThrow alias Internal lib, no need for backward compat shim. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/ai-proxy/src/index.ts | 3 --- packages/ai-proxy/test/index.integration.test.ts | 8 ++++---- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/ai-proxy/src/index.ts b/packages/ai-proxy/src/index.ts index d6f1e5e400..36c09e2ab3 100644 --- a/packages/ai-proxy/src/index.ts +++ b/packages/ai-proxy/src/index.ts @@ -24,6 +24,3 @@ export function validToolConfigurationOrThrow( ) { return ToolSourceChecker.check(configs, logger); } - -/** @deprecated Use validToolConfigurationOrThrow instead */ -export const validMcpConfigurationOrThrow = validToolConfigurationOrThrow; diff --git a/packages/ai-proxy/test/index.integration.test.ts b/packages/ai-proxy/test/index.integration.test.ts index e10f4ae1cd..4b2dce3fc2 100644 --- a/packages/ai-proxy/test/index.integration.test.ts +++ b/packages/ai-proxy/test/index.integration.test.ts @@ -1,7 +1,7 @@ // eslint-disable-next-line import/extensions import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { validMcpConfigurationOrThrow } from '../src'; +import { validToolConfigurationOrThrow } from '../src'; import runMcpServer from '../src/examples/simple-mcp-server'; describe('Simple MCP Server', () => { @@ -19,9 +19,9 @@ describe('Simple MCP Server', () => { server.close(); }); - describe('validMcpConfigurationOrThrow', () => { + describe('validToolConfigurationOrThrow', () => { it('should return true when the config is right', async () => { - const result = await validMcpConfigurationOrThrow({ + const result = await validToolConfigurationOrThrow({ simpleServer: { url: 'http://localhost:3123/mcp', type: 'http', @@ -36,7 +36,7 @@ describe('Simple MCP Server', () => { it('should throw an error when the config is wrong', async () => { await expect( - validMcpConfigurationOrThrow({ + validToolConfigurationOrThrow({ simpleServer: { url: 'http://localhost:3123/wrong', type: 'http' }, }), ).rejects.toThrow('Failed to connect to streamable HTTP'); From ab6b929c345179f80c7007c947717dab25534d51 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Fri, 27 Mar 2026 12:19:18 +0100 Subject: [PATCH 18/23] refactor(ai-proxy): extract Brave from RemoteTools into BraveToolProvider RemoteTools was both an aggregator and a Brave tool factory. Now Brave is a ToolProvider like McpClient and IntegrationClient. RemoteTools becomes a pure container. - Add BraveToolProvider implementing ToolProvider - Router creates BraveToolProvider from localToolsApiKeys - Simplify RemoteTools constructor (no more apiKeys) - Remove getIntegratedTools dispatcher (no longer needed) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../integrations/brave/brave-tool-provider.ts | 25 ++++ packages/ai-proxy/src/integrations/tools.ts | 25 ---- packages/ai-proxy/src/remote-tools.ts | 22 +--- packages/ai-proxy/src/router.ts | 25 ++-- .../brave/brave-tool-provider.test.ts | 40 ++++++ .../ai-proxy/test/integrations/tools.test.ts | 45 ------- .../ai-proxy/test/provider-dispatcher.test.ts | 26 ++-- packages/ai-proxy/test/remote-tools.test.ts | 119 ++++++++---------- packages/ai-proxy/test/router.test.ts | 30 +++++ 9 files changed, 183 insertions(+), 174 deletions(-) create mode 100644 packages/ai-proxy/src/integrations/brave/brave-tool-provider.ts delete mode 100644 packages/ai-proxy/src/integrations/tools.ts create mode 100644 packages/ai-proxy/test/integrations/brave/brave-tool-provider.test.ts delete mode 100644 packages/ai-proxy/test/integrations/tools.test.ts diff --git a/packages/ai-proxy/src/integrations/brave/brave-tool-provider.ts b/packages/ai-proxy/src/integrations/brave/brave-tool-provider.ts new file mode 100644 index 0000000000..abaf993ede --- /dev/null +++ b/packages/ai-proxy/src/integrations/brave/brave-tool-provider.ts @@ -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 { + return getBraveTools(this.config); + } + + async checkConnection(): Promise { + return true; + } + + // eslint-disable-next-line class-methods-use-this + async dispose(): Promise { + // No-op: Brave search has no persistent connections + } +} diff --git a/packages/ai-proxy/src/integrations/tools.ts b/packages/ai-proxy/src/integrations/tools.ts deleted file mode 100644 index a22703f97e..0000000000 --- a/packages/ai-proxy/src/integrations/tools.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type RemoteTool from '../remote-tool'; -import type { BraveConfig } from './brave/tools'; -import type { ZendeskConfig } from './zendesk/tools'; - -import getBraveTools from './brave/tools'; -import getZendeskTools from './zendesk/tools'; - -export interface IntegrationConfigs { - brave?: BraveConfig; - zendesk?: ZendeskConfig; -} - -export default function getIntegratedTools(configs: IntegrationConfigs): RemoteTool[] { - const integratedTools: RemoteTool[] = []; - - if (configs.brave) { - integratedTools.push(...getBraveTools(configs.brave)); - } - - if (configs.zendesk) { - integratedTools.push(...getZendeskTools(configs.zendesk)); - } - - return integratedTools; -} diff --git a/packages/ai-proxy/src/remote-tools.ts b/packages/ai-proxy/src/remote-tools.ts index 018e3d29a0..2fd369bf2e 100644 --- a/packages/ai-proxy/src/remote-tools.ts +++ b/packages/ai-proxy/src/remote-tools.ts @@ -5,30 +5,14 @@ import type { ChatCompletionCreateParamsNonStreaming } from 'openai/resources/ch import { toJsonSchema } from '@langchain/core/utils/json_schema'; import { AIToolNotFoundError, AIToolUnprocessableError } from './errors'; -import getIntegratedTools, { type IntegrationConfigs } from './integrations/tools'; export type Messages = ChatCompletionCreateParamsNonStreaming['messages']; -export type RemoteToolsApiKeys = - | { ['AI_REMOTE_TOOL_BRAVE_SEARCH_API_KEY']: string } - | Record; // To avoid to cast the object because env is not always well typed from the caller - export class RemoteTools { - private readonly apiKeys?: RemoteToolsApiKeys; - readonly tools: RemoteTool[] = []; - - constructor(apiKeys: RemoteToolsApiKeys, tools?: RemoteTool[]) { - this.apiKeys = apiKeys; - - const integrationConfigs: IntegrationConfigs = {}; - - if (this.apiKeys?.AI_REMOTE_TOOL_BRAVE_SEARCH_API_KEY) { - integrationConfigs.brave = { - apiKey: this.apiKeys.AI_REMOTE_TOOL_BRAVE_SEARCH_API_KEY, - }; - } + readonly tools: RemoteTool[]; - this.tools.push(...(tools ?? []), ...getIntegratedTools(integrationConfigs)); + constructor(tools?: RemoteTool[]) { + this.tools = tools ?? []; } get toolDefinitionsForFrontend() { diff --git a/packages/ai-proxy/src/router.ts b/packages/ai-proxy/src/router.ts index 74953e8269..96758ddfef 100644 --- a/packages/ai-proxy/src/router.ts +++ b/packages/ai-proxy/src/router.ts @@ -1,11 +1,11 @@ import type { AiConfiguration } from './provider'; -import type { RemoteToolsApiKeys } from './remote-tools'; import type { RouteArgs } from './schemas/route'; import type { ToolProvider } from './tool-provider'; import type { Logger } from '@forestadmin/datasource-toolkit'; import type { z } from 'zod'; import { AIBadRequestError, AIModelNotSupportedError } from './errors'; +import BraveToolProvider from './integrations/brave/brave-tool-provider'; import ProviderDispatcher from './provider-dispatcher'; import { RemoteTools } from './remote-tools'; import { routeArgsSchema } from './schemas/route'; @@ -23,25 +23,36 @@ export type { // Keep these for backward compatibility export type Route = RouteArgs['route']; -export type ApiKeys = RemoteToolsApiKeys; export class Router { - private readonly localToolsApiKeys?: ApiKeys; + private readonly localToolProviders: ToolProvider[]; private readonly aiConfigurations: AiConfiguration[]; private readonly logger?: Logger; constructor(params?: { aiConfigurations?: AiConfiguration[]; - localToolsApiKeys?: ApiKeys; + localToolsApiKeys?: Record; logger?: Logger; }) { this.aiConfigurations = params?.aiConfigurations ?? []; - this.localToolsApiKeys = params?.localToolsApiKeys; + this.localToolProviders = Router.createLocalToolProviders(params?.localToolsApiKeys); this.logger = params?.logger; this.validateConfigurations(); } + private static createLocalToolProviders(apiKeys?: Record): ToolProvider[] { + const providers: ToolProvider[] = []; + + if (apiKeys?.AI_REMOTE_TOOL_BRAVE_SEARCH_API_KEY) { + providers.push( + new BraveToolProvider({ apiKey: apiKeys.AI_REMOTE_TOOL_BRAVE_SEARCH_API_KEY }), + ); + } + + return providers; + } + private validateConfigurations(): void { for (const config of this.aiConfigurations) { if (!isModelSupportingTools(config.model, config.provider)) { @@ -67,11 +78,11 @@ export class Router { } const validatedArgs = result.data; - const providers = args.toolProviders ?? []; + const providers = [...this.localToolProviders, ...(args.toolProviders ?? [])]; try { const allTools = (await Promise.all(providers.map(p => p.loadTools()))).flat(); - const remoteTools = new RemoteTools(this.localToolsApiKeys ?? {}, allTools); + const remoteTools = new RemoteTools(allTools); switch (validatedArgs.route) { case 'ai-query': { diff --git a/packages/ai-proxy/test/integrations/brave/brave-tool-provider.test.ts b/packages/ai-proxy/test/integrations/brave/brave-tool-provider.test.ts new file mode 100644 index 0000000000..a1b54ff074 --- /dev/null +++ b/packages/ai-proxy/test/integrations/brave/brave-tool-provider.test.ts @@ -0,0 +1,40 @@ +import BraveToolProvider from '../../../src/integrations/brave/brave-tool-provider'; + +const mockBraveTools = [{ name: 'brave_search' }]; + +jest.mock('../../../src/integrations/brave/tools', () => ({ + __esModule: true, + default: jest.fn(() => mockBraveTools), +})); + +describe('BraveToolProvider', () => { + beforeEach(() => jest.clearAllMocks()); + + describe('loadTools', () => { + it('should return brave tools', async () => { + const provider = new BraveToolProvider({ apiKey: 'test-key' }); + + const tools = await provider.loadTools(); + + expect(tools).toEqual(mockBraveTools); + }); + }); + + describe('checkConnection', () => { + it('should return true', async () => { + const provider = new BraveToolProvider({ apiKey: 'test-key' }); + + const result = await provider.checkConnection(); + + expect(result).toBe(true); + }); + }); + + describe('dispose', () => { + it('should resolve without error', async () => { + const provider = new BraveToolProvider({ apiKey: 'test-key' }); + + await expect(provider.dispose()).resolves.toBeUndefined(); + }); + }); +}); diff --git a/packages/ai-proxy/test/integrations/tools.test.ts b/packages/ai-proxy/test/integrations/tools.test.ts deleted file mode 100644 index fbe2108328..0000000000 --- a/packages/ai-proxy/test/integrations/tools.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import getIntegratedTools from '../../src/integrations/tools'; - -const mockBraveTools = [{ name: 'brave_search' }]; -const mockZendeskTools = [{ name: 'zendesk_get_tickets' }]; - -jest.mock('../../src/integrations/brave/tools', () => ({ - __esModule: true, - default: jest.fn(() => mockBraveTools), -})); - -jest.mock('../../src/integrations/zendesk/tools', () => ({ - __esModule: true, - default: jest.fn(() => mockZendeskTools), -})); - -describe('getIntegratedTools', () => { - beforeEach(() => jest.clearAllMocks()); - - it('should return empty array when no configs provided', () => { - expect(getIntegratedTools({})).toEqual([]); - }); - - it('should return brave tools when brave config provided', () => { - const tools = getIntegratedTools({ brave: { apiKey: 'key' } }); - - expect(tools).toEqual(mockBraveTools); - }); - - it('should return zendesk tools when zendesk config provided', () => { - const tools = getIntegratedTools({ - zendesk: { subdomain: 'test', email: 'a@b.com', apiToken: 'tok' }, - }); - - expect(tools).toEqual(mockZendeskTools); - }); - - it('should return all tools when all configs provided', () => { - const tools = getIntegratedTools({ - brave: { apiKey: 'key' }, - zendesk: { subdomain: 'test', email: 'a@b.com', apiToken: 'tok' }, - }); - - expect(tools).toEqual([...mockBraveTools, ...mockZendeskTools]); - }); -}); diff --git a/packages/ai-proxy/test/provider-dispatcher.test.ts b/packages/ai-proxy/test/provider-dispatcher.test.ts index 7cefcf3dc4..c478b44ca6 100644 --- a/packages/ai-proxy/test/provider-dispatcher.test.ts +++ b/packages/ai-proxy/test/provider-dispatcher.test.ts @@ -1,4 +1,5 @@ import type { DispatchBody } from '../src'; +import type { Tool } from '@langchain/core/tools'; import { AIMessage } from '@langchain/core/messages'; import { convertToOpenAIFunction } from '@langchain/core/utils/function_calling'; @@ -15,6 +16,7 @@ import { ProviderDispatcher, RemoteTools, } from '../src'; +import ServerRemoteTool from '../src/server-remote-tool'; // Mock raw OpenAI response (returned via __includeRawResponse: true) const mockOpenAIResponse = { @@ -83,8 +85,6 @@ function mockAnthropicResponse( } describe('ProviderDispatcher', () => { - const apiKeys = { AI_REMOTE_TOOL_BRAVE_SEARCH_API_KEY: 'api-key' }; - const openaiConfig = { name: 'gpt4', provider: 'openai' as const, @@ -105,7 +105,7 @@ describe('ProviderDispatcher', () => { describe('dispatch', () => { it('should throw AINotConfiguredError when no provider is configured', async () => { - const dispatcher = new ProviderDispatcher(null, new RemoteTools(apiKeys)); + const dispatcher = new ProviderDispatcher(null, new RemoteTools()); await expect(dispatcher.dispatch(buildBody())).rejects.toThrow(AINotConfiguredError); await expect(dispatcher.dispatch(buildBody())).rejects.toThrow('AI is not configured'); @@ -116,7 +116,7 @@ describe('ProviderDispatcher', () => { () => new ProviderDispatcher( { provider: 'unknown', name: 'test', model: 'x' } as any, - new RemoteTools(apiKeys), + new RemoteTools(), ), ).toThrow(new AIBadRequestError("Unsupported AI provider 'unknown'.")); }); @@ -126,7 +126,7 @@ describe('ProviderDispatcher', () => { let dispatcher: ProviderDispatcher; beforeEach(() => { - dispatcher = new ProviderDispatcher(openaiConfig, new RemoteTools(apiKeys)); + dispatcher = new ProviderDispatcher(openaiConfig, new RemoteTools()); }); it('should return the raw OpenAI response', async () => { @@ -137,7 +137,7 @@ describe('ProviderDispatcher', () => { it('should not forward user-supplied model or arbitrary properties to the LLM', async () => { const customConfig = { ...openaiConfig, name: 'base', model: 'BASE MODEL' }; - const customDispatcher = new ProviderDispatcher(customConfig, new RemoteTools(apiKeys)); + const customDispatcher = new ProviderDispatcher(customConfig, new RemoteTools()); await customDispatcher.dispatch( buildBody({ @@ -253,7 +253,10 @@ describe('ProviderDispatcher', () => { describe('remote tools', () => { it('should enhance remote tools definition with full schema', async () => { - const remoteTools = new RemoteTools(apiKeys); + const mockTool = new ServerRemoteTool({ + tool: { name: 'test_tool', description: 'A test tool', schema: {} } as Tool, + }); + const remoteTools = new RemoteTools([mockTool]); const remoteDispatcher = new ProviderDispatcher(openaiConfig, remoteTools); await remoteDispatcher.dispatch( @@ -281,7 +284,7 @@ describe('ProviderDispatcher', () => { }); it('should not modify non-remote tools', async () => { - const remoteDispatcher = new ProviderDispatcher(openaiConfig, new RemoteTools(apiKeys)); + const remoteDispatcher = new ProviderDispatcher(openaiConfig, new RemoteTools()); await remoteDispatcher.dispatch( buildBody({ @@ -335,7 +338,7 @@ describe('ProviderDispatcher', () => { let dispatcher: ProviderDispatcher; beforeEach(() => { - dispatcher = new ProviderDispatcher(anthropicConfig, new RemoteTools(apiKeys)); + dispatcher = new ProviderDispatcher(anthropicConfig, new RemoteTools()); }); it('should not forward user-supplied model from body to the LLM', async () => { @@ -423,7 +426,10 @@ describe('ProviderDispatcher', () => { it('should enhance remote tools definition with full schema', async () => { mockAnthropicResponse(); - const remoteTools = new RemoteTools(apiKeys); + const mockTool = new ServerRemoteTool({ + tool: { name: 'test_tool', description: 'A test tool', schema: {} } as Tool, + }); + const remoteTools = new RemoteTools([mockTool]); const remoteDispatcher = new ProviderDispatcher(anthropicConfig, remoteTools); await remoteDispatcher.dispatch( diff --git a/packages/ai-proxy/test/remote-tools.test.ts b/packages/ai-proxy/test/remote-tools.test.ts index 40192d6133..40d142a055 100644 --- a/packages/ai-proxy/test/remote-tools.test.ts +++ b/packages/ai-proxy/test/remote-tools.test.ts @@ -6,105 +6,88 @@ import { toJsonSchema } from '@langchain/core/utils/json_schema'; import { RemoteTools } from '../src'; import ServerRemoteTool from '../src/server-remote-tool'; -describe('RemoteTools', () => { - const apiKeys = { AI_REMOTE_TOOL_BRAVE_SEARCH_API_KEY: 'api-key' }; - - describe('when AI_REMOTE_TOOL_BRAVE_SEARCH_API_KEY is not set', () => { - it('should not add the tool', () => { - const remoteTools = new RemoteTools({}); - expect(remoteTools.tools.length).toEqual(0); - }); +function createMockTool(name = 'tool1', description = 'description1'): ServerRemoteTool { + return new ServerRemoteTool({ + tool: { + name, + description, + responseFormat: 'content', + schema: {}, + } as Tool, }); +} - describe('when envs is null', () => { - it('should init the remote tool instance without error', () => { - expect(() => new RemoteTools(null)).not.toThrow(); - }); - }); - - describe('tools', () => { - it('should return the tools', () => { - const remoteTools = new RemoteTools(apiKeys); - expect(remoteTools.tools.length).toEqual(1); +describe('RemoteTools', () => { + describe('constructor', () => { + it('should have no tools when constructed without arguments', () => { + const remoteTools = new RemoteTools(); + expect(remoteTools.tools).toEqual([]); }); - describe('when tools are passed in the constructor', () => { - it('should return the tools', () => { - const tools = [ - new ServerRemoteTool({ - tool: { - name: 'tool1', - description: 'description1', - responseFormat: 'content', - schema: {}, - } as Tool, - }), - ]; - const remoteTools = new RemoteTools(apiKeys, tools); - expect(remoteTools.tools.length).toEqual(2); - expect(remoteTools.tools[0].base.name).toEqual('tool1'); - }); + it('should store provided tools', () => { + const tools = [createMockTool()]; + const remoteTools = new RemoteTools(tools); + expect(remoteTools.tools).toHaveLength(1); + expect(remoteTools.tools[0].base.name).toEqual('tool1'); }); }); describe('toolDefinitionsForFrontend', () => { it('should return the tools with extended definitions', () => { - const remoteTools = new RemoteTools(apiKeys); + const tool = createMockTool(); + const remoteTools = new RemoteTools([tool]); + expect(remoteTools.toolDefinitionsForFrontend).toEqual([ { - name: remoteTools.tools[0].sanitizedName, - description: remoteTools.tools[0].base.description, + name: tool.sanitizedName, + description: tool.base.description, responseFormat: 'content', - schema: toJsonSchema(remoteTools.tools[0].base.schema as JSONSchema), - sourceId: remoteTools.tools[0].sourceId, - sourceType: remoteTools.tools[0].sourceType, + schema: toJsonSchema(tool.base.schema as JSONSchema), + sourceId: tool.sourceId, + sourceType: tool.sourceType, }, ]); }); }); describe('invokeTool', () => { - it('should call invokeTool', async () => { - const remoteTools = new RemoteTools(apiKeys); - remoteTools.invokeTool = jest.fn().mockResolvedValue('response'); + it('should invoke the tool and return its response', async () => { + const tool = createMockTool(); + tool.base.invoke = jest.fn().mockResolvedValue('response'); + const remoteTools = new RemoteTools([tool]); - const response = await remoteTools.invokeTool('tool-name', []); + const response = await remoteTools.invokeTool(tool.sanitizedName, []); expect(response).toEqual('response'); + expect(tool.base.invoke).toHaveBeenCalledWith([]); }); - describe('when the tool is not found', () => { - it('should throw an error', async () => { - const remoteTools = new RemoteTools(apiKeys); + it('should throw when the tool is not found', async () => { + const remoteTools = new RemoteTools(); - await expect(() => remoteTools.invokeTool('not-found', [])).rejects.toThrow( - 'Tool not-found not found', - ); - }); + await expect(() => remoteTools.invokeTool('not-found', [])).rejects.toThrow( + 'Tool not-found not found', + ); }); - describe('when the tool throws an error', () => { - it('should throw an error', async () => { - const remoteTools = new RemoteTools(apiKeys); - remoteTools.tools[0].base.invoke = jest.fn().mockRejectedValue(new Error('error')); + it('should wrap tool errors with tool name', async () => { + const tool = createMockTool(); + tool.base.invoke = jest.fn().mockRejectedValue(new Error('error')); + const remoteTools = new RemoteTools([tool]); - await expect(() => - remoteTools.invokeTool(remoteTools.tools[0].base.name, []), - ).rejects.toThrow(`Error while calling tool ${remoteTools.tools[0].base.name}: error`); - }); + await expect(() => remoteTools.invokeTool(tool.sanitizedName, [])).rejects.toThrow( + `Error while calling tool ${tool.base.name}: error`, + ); }); - describe('when the tool name is sanitized', () => { - it('should find the right tool to invoke', async () => { - const remoteTools = new RemoteTools(apiKeys); - - remoteTools.tools[0].base.name = 'brave search'; - remoteTools.tools[0].base.invoke = jest.fn().mockResolvedValue('response'); + it('should find tool by sanitized name', async () => { + const tool = createMockTool('brave search'); + tool.base.invoke = jest.fn().mockResolvedValue('response'); + const remoteTools = new RemoteTools([tool]); - await remoteTools.invokeTool(remoteTools.tools[0].sanitizedName, []); + await remoteTools.invokeTool(tool.sanitizedName, []); - expect(remoteTools.tools[0].base.invoke).toHaveBeenCalledWith([]); - }); + expect(tool.base.invoke).toHaveBeenCalledWith([]); }); }); }); diff --git a/packages/ai-proxy/test/router.test.ts b/packages/ai-proxy/test/router.test.ts index dd2f18cd0a..3f5cb1bb6a 100644 --- a/packages/ai-proxy/test/router.test.ts +++ b/packages/ai-proxy/test/router.test.ts @@ -3,6 +3,7 @@ import type { ToolProvider } from '../src/tool-provider'; import type { Logger } from '@forestadmin/datasource-toolkit'; import { AIModelNotSupportedError, Router } from '../src'; +import BraveToolProvider from '../src/integrations/brave/brave-tool-provider'; import ProviderDispatcher from '../src/provider-dispatcher'; const invokeToolMock = jest.fn(); @@ -18,6 +19,17 @@ jest.mock('../src/remote-tools', () => { }; }); +jest.mock('../src/integrations/brave/brave-tool-provider', () => { + return { + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + loadTools: jest.fn().mockResolvedValue([]), + checkConnection: jest.fn().mockResolvedValue(true), + dispose: jest.fn().mockResolvedValue(undefined), + })), + }; +}); + const dispatchMock = jest.fn(); jest.mock('../src/provider-dispatcher', () => { return { @@ -286,6 +298,24 @@ describe('route', () => { }); }); + describe('Local tool providers', () => { + it('creates a BraveToolProvider when API key is provided', () => { + // eslint-disable-next-line no-new + new Router({ + localToolsApiKeys: { AI_REMOTE_TOOL_BRAVE_SEARCH_API_KEY: 'test-key' }, + }); + + expect(BraveToolProvider).toHaveBeenCalledWith({ apiKey: 'test-key' }); + }); + + it('does not create BraveToolProvider when no API key', () => { + // eslint-disable-next-line no-new + new Router({}); + + expect(BraveToolProvider).not.toHaveBeenCalled(); + }); + }); + describe('Model validation', () => { it('throws AIModelNotSupportedError when model does not support tools', () => { expect( From 1195b74fa5211b5d125865ef13694ec79daf5f97 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Mon, 30 Mar 2026 11:26:46 +0200 Subject: [PATCH 19/23] fix: build tool providers in route --- packages/ai-proxy/src/create-ai-provider.ts | 27 +++-------- packages/ai-proxy/src/router.ts | 6 ++- packages/ai-proxy/src/schemas/route.ts | 6 ++- .../ai-proxy/test/create-ai-provider.test.ts | 46 ++++++++----------- .../ai-proxy/test/llm.integration.test.ts | 17 ++++--- packages/ai-proxy/test/router.test.ts | 24 +++++++--- 6 files changed, 58 insertions(+), 68 deletions(-) diff --git a/packages/ai-proxy/src/create-ai-provider.ts b/packages/ai-proxy/src/create-ai-provider.ts index 0abe07c38f..ddd70c34b1 100644 --- a/packages/ai-proxy/src/create-ai-provider.ts +++ b/packages/ai-proxy/src/create-ai-provider.ts @@ -1,35 +1,22 @@ import type { AiConfiguration } from './provider'; import type { RouterRouteArgs } from './schemas/route'; -import type { ToolProvider } from './tool-provider'; import type { ToolSourceConfig } from './tool-provider-factory'; import type { AiProviderDefinition, AiRouter } from '@forestadmin/agent-toolkit'; -import type { Logger } from '@forestadmin/datasource-toolkit'; import { extractMcpOauthTokensFromHeaders, injectOauthTokens } from './oauth-token-injector'; import { Router } from './router'; -import { createToolProviders } from './tool-provider-factory'; -function resolveToolProviders( +function resolveMcpConfigs( args: Parameters[0], - logger?: Logger, -): ToolProvider[] { - const mcpServerConfigs = args.mcpServerConfigs as - | { configs: Record } - | undefined; - - if (!mcpServerConfigs) return []; - - const { configs } = mcpServerConfigs; - +): Record | undefined { const tokensByMcpServerName = args.headers ? extractMcpOauthTokensFromHeaders(args.headers) : undefined; - const configsWithTokens = injectOauthTokens({ configs, tokensByMcpServerName }); - - if (!configsWithTokens) return []; - - return createToolProviders(configsWithTokens, logger); + return injectOauthTokens({ + configs: args.mcpServerConfigs as Record | undefined, + tokensByMcpServerName, + }); } // eslint-disable-next-line import/prefer-default-export @@ -47,7 +34,7 @@ export function createAiProvider(config: AiConfiguration): AiProviderDefinition route: args.route, body: args.body, query: args.query, - toolProviders: resolveToolProviders(args, logger), + mcpServerConfigs: resolveMcpConfigs(args), } as RouterRouteArgs), }; }, diff --git a/packages/ai-proxy/src/router.ts b/packages/ai-proxy/src/router.ts index 96758ddfef..64394359c6 100644 --- a/packages/ai-proxy/src/router.ts +++ b/packages/ai-proxy/src/router.ts @@ -10,6 +10,7 @@ import ProviderDispatcher from './provider-dispatcher'; import { RemoteTools } from './remote-tools'; import { routeArgsSchema } from './schemas/route'; import isModelSupportingTools from './supported-models'; +import { type ToolSourceConfig, createToolProviders } from './tool-provider-factory'; export type { AiQueryArgs, @@ -69,16 +70,17 @@ export class Router { * - invoke-remote-tool: Execute a remote tool by name with the provided inputs * - remote-tools: Return the list of available remote tools definitions */ - async route(args: RouteArgs & { toolProviders?: ToolProvider[] }) { + async route(args: RouteArgs & { mcpServerConfigs?: Record }) { // Validate input with Zod schema const result = routeArgsSchema.safeParse(args); + const remoteToolProviders = createToolProviders(args.mcpServerConfigs ?? {}, this.logger); if (!result.success) { throw new AIBadRequestError(Router.formatZodError(result.error)); } const validatedArgs = result.data; - const providers = [...this.localToolProviders, ...(args.toolProviders ?? [])]; + const providers = [...this.localToolProviders, ...remoteToolProviders]; try { const allTools = (await Promise.all(providers.map(p => p.loadTools()))).flat(); diff --git a/packages/ai-proxy/src/schemas/route.ts b/packages/ai-proxy/src/schemas/route.ts index a2896dc974..5868a2d70f 100644 --- a/packages/ai-proxy/src/schemas/route.ts +++ b/packages/ai-proxy/src/schemas/route.ts @@ -1,4 +1,4 @@ -import type { ToolProvider } from '../tool-provider'; +import type { ToolSourceConfig } from '../tool-provider-factory'; import { z } from 'zod'; @@ -85,7 +85,9 @@ export type RemoteToolsArgs = z.infer; // Derived types for consumers export type DispatchBody = AiQueryArgs['body']; -export type RouterRouteArgs = RouteArgs & { toolProviders?: ToolProvider[] }; +export type RouterRouteArgs = RouteArgs & { + mcpServerConfigs?: Record; +}; // Backward compatibility types export type InvokeRemoteToolBody = InvokeRemoteToolArgs['body']; diff --git a/packages/ai-proxy/test/create-ai-provider.test.ts b/packages/ai-proxy/test/create-ai-provider.test.ts index 3b4d7d4256..483dbc5d21 100644 --- a/packages/ai-proxy/test/create-ai-provider.test.ts +++ b/packages/ai-proxy/test/create-ai-provider.test.ts @@ -2,10 +2,8 @@ import type { AiConfiguration } from '../src/provider'; import { createAiProvider } from '../src/create-ai-provider'; import { Router } from '../src/router'; -import { createToolProviders } from '../src/tool-provider-factory'; jest.mock('../src/router'); -jest.mock('../src/tool-provider-factory'); const routeMock = jest.fn(); jest.mocked(Router).mockImplementation(() => ({ route: routeMock } as any)); @@ -13,7 +11,6 @@ jest.mocked(Router).mockImplementation(() => ({ route: routeMock } as any)); describe('createAiProvider', () => { beforeEach(() => { jest.clearAllMocks(); - jest.mocked(createToolProviders).mockReturnValue([]); }); const config: AiConfiguration = { @@ -57,30 +54,26 @@ describe('createAiProvider', () => { route: 'ai-query', body: { messages: [] }, query: { 'ai-name': 'my-ai' }, - toolProviders: [], + mcpServerConfigs: undefined, }), ); expect(result).toEqual({ result: 'ok' }); }); - test('should create tool providers from mcpServerConfigs', async () => { - const mockProviders = [{ loadTools: jest.fn(), checkConnection: jest.fn(), dispose: jest.fn() }]; - jest.mocked(createToolProviders).mockReturnValue(mockProviders); + test('should pass mcpServerConfigs to router', async () => { routeMock.mockResolvedValue({}); const provider = createAiProvider(config); const aiRouter = provider.init(jest.fn()); await aiRouter.route({ route: 'remote-tools', - mcpServerConfigs: { configs: { server1: { command: 'test', args: [] } } }, + mcpServerConfigs: { server1: { command: 'test', args: [] } }, }); - expect(createToolProviders).toHaveBeenCalledWith( - { server1: { command: 'test', args: [] } }, - expect.any(Function), - ); expect(routeMock).toHaveBeenCalledWith( - expect.objectContaining({ toolProviders: mockProviders }), + expect.objectContaining({ + mcpServerConfigs: { server1: { command: 'test', args: [] } }, + }), ); }); @@ -92,21 +85,20 @@ describe('createAiProvider', () => { await aiRouter.route({ route: 'remote-tools', - mcpServerConfigs: { - configs: { server1: { type: 'http', url: 'https://server1.com' } }, - }, + mcpServerConfigs: { server1: { type: 'http', url: 'https://server1.com' } }, headers: { 'x-mcp-oauth-tokens': oauthTokens }, }); - expect(createToolProviders).toHaveBeenCalledWith( - { - server1: { - type: 'http', - url: 'https://server1.com', - headers: { Authorization: 'Bearer token123' }, + expect(routeMock).toHaveBeenCalledWith( + expect.objectContaining({ + mcpServerConfigs: { + server1: { + type: 'http', + url: 'https://server1.com', + headers: { Authorization: 'Bearer token123' }, + }, }, - }, - expect.any(Function), + }), ); }); @@ -117,9 +109,7 @@ describe('createAiProvider', () => { expect(() => aiRouter.route({ route: 'remote-tools', - mcpServerConfigs: { - configs: { server1: { type: 'http', url: 'https://server1.com' } }, - }, + mcpServerConfigs: { server1: { type: 'http', url: 'https://server1.com' } }, headers: { 'x-mcp-oauth-tokens': '{ invalid json }' }, }), ).toThrow('Invalid JSON in x-mcp-oauth-tokens header'); @@ -133,7 +123,7 @@ describe('createAiProvider', () => { await aiRouter.route({ route: 'remote-tools' }); expect(routeMock).toHaveBeenCalledWith( - expect.objectContaining({ toolProviders: [] }), + expect.objectContaining({ mcpServerConfigs: undefined }), ); }); }); diff --git a/packages/ai-proxy/test/llm.integration.test.ts b/packages/ai-proxy/test/llm.integration.test.ts index aa17f0799d..321d4ce5e7 100644 --- a/packages/ai-proxy/test/llm.integration.test.ts +++ b/packages/ai-proxy/test/llm.integration.test.ts @@ -21,7 +21,6 @@ import { z } from 'zod'; import { Router } from '../src'; import runMcpServer from '../src/examples/simple-mcp-server'; import isModelSupportingTools from '../src/supported-models'; -import { createToolProviders } from '../src/tool-provider-factory'; const { OPENAI_API_KEY, ANTHROPIC_API_KEY } = process.env; const describeWithOpenAI = OPENAI_API_KEY ? describe : describe.skip; @@ -644,7 +643,7 @@ describeWithOpenAI('Router integration tests', () => { it('should return MCP tools in the list', async () => { const response = (await router.route({ route: 'remote-tools', - toolProviders: createToolProviders(mcpConfig.configs), + mcpServerConfigs: mcpConfig.configs, })) as Array<{ name: string; sourceType: string; sourceId: string }>; const toolNames = response.map(t => t.name); @@ -690,7 +689,7 @@ describeWithOpenAI('Router integration tests', () => { const response = (await routerWithLogger.route({ route: 'remote-tools', - toolProviders: createToolProviders(mixedConfig.configs), + mcpServerConfigs: mixedConfig.configs, })) as Array<{ name: string; sourceId: string }>; const toolNames = response.map(t => t.name); @@ -727,7 +726,7 @@ describeWithOpenAI('Router integration tests', () => { const response = (await routerWithLogger.route({ route: 'remote-tools', - toolProviders: createToolProviders(badAuthConfig.configs), + mcpServerConfigs: badAuthConfig.configs, })) as Array<{ name: string }>; expect(response).toEqual([]); @@ -754,7 +753,7 @@ describeWithOpenAI('Router integration tests', () => { body: { messages: [{ role: 'user', content: 'Say "hello"' }], }, - toolProviders: createToolProviders(brokenMcpConfig.configs), + mcpServerConfigs: brokenMcpConfig.configs, })) as ChatCompletionResponse; expect(response.choices[0].message.content).toBeDefined(); @@ -769,7 +768,7 @@ describeWithOpenAI('Router integration tests', () => { body: { inputs: { a: 5, b: 3 } as any, }, - toolProviders: createToolProviders(mcpConfig.configs), + mcpServerConfigs: mcpConfig.configs, }); expect(response).toBe('8'); @@ -782,7 +781,7 @@ describeWithOpenAI('Router integration tests', () => { body: { inputs: { a: 6, b: 7 } as any, }, - toolProviders: createToolProviders(mcpConfig.configs), + mcpServerConfigs: mcpConfig.configs, }); expect(response).toBe('42'); @@ -820,7 +819,7 @@ describeWithOpenAI('Router integration tests', () => { ], tool_choice: 'required', }, - toolProviders: createToolProviders(mcpConfig.configs), + mcpServerConfigs: mcpConfig.configs, })) as ChatCompletionResponse; expect(response.choices[0].finish_reason).toBe('tool_calls'); @@ -848,7 +847,7 @@ describeWithOpenAI('Router integration tests', () => { ], tool_choice: 'required', }, - toolProviders: createToolProviders(mcpConfig.configs), + mcpServerConfigs: mcpConfig.configs, })) as ChatCompletionResponse; expect(response.choices[0].finish_reason).toBe('tool_calls'); diff --git a/packages/ai-proxy/test/router.test.ts b/packages/ai-proxy/test/router.test.ts index 3f5cb1bb6a..727566e738 100644 --- a/packages/ai-proxy/test/router.test.ts +++ b/packages/ai-proxy/test/router.test.ts @@ -1,10 +1,10 @@ import type { DispatchBody, InvokeRemoteToolArgs } from '../src'; import type { ToolProvider } from '../src/tool-provider'; -import type { Logger } from '@forestadmin/datasource-toolkit'; import { AIModelNotSupportedError, Router } from '../src'; import BraveToolProvider from '../src/integrations/brave/brave-tool-provider'; import ProviderDispatcher from '../src/provider-dispatcher'; +import { createToolProviders } from '../src/tool-provider-factory'; const invokeToolMock = jest.fn(); const toolDefinitionsForFrontend = [{ name: 'tool-name', description: 'tool-description' }]; @@ -19,6 +19,8 @@ jest.mock('../src/remote-tools', () => { }; }); +jest.mock('../src/tool-provider-factory'); + jest.mock('../src/integrations/brave/brave-tool-provider', () => { return { __esModule: true, @@ -54,6 +56,7 @@ function createMockToolProvider(overrides?: Partial): ToolProvider describe('route', () => { beforeEach(() => { jest.clearAllMocks(); + jest.mocked(createToolProviders).mockReturnValue([]); }); describe('when the route is /ai-query', () => { @@ -230,14 +233,17 @@ describe('route', () => { }); describe('ToolProvider lifecycle', () => { + const dummyMcpServerConfigs = { server: { url: 'http://localhost', type: 'http' as const } }; + it('calls loadTools on all provided tool providers', async () => { const provider1 = createMockToolProvider(); const provider2 = createMockToolProvider(); + jest.mocked(createToolProviders).mockReturnValue([provider1, provider2]); const router = new Router({}); await router.route({ route: 'remote-tools', - toolProviders: [provider1, provider2], + mcpServerConfigs: dummyMcpServerConfigs, }); expect(provider1.loadTools).toHaveBeenCalledTimes(1); @@ -246,11 +252,12 @@ describe('route', () => { it('disposes all providers after successful route handling', async () => { const provider = createMockToolProvider(); + jest.mocked(createToolProviders).mockReturnValue([provider]); const router = new Router({}); await router.route({ route: 'remote-tools', - toolProviders: [provider], + mcpServerConfigs: dummyMcpServerConfigs, }); expect(provider.dispose).toHaveBeenCalledTimes(1); @@ -258,6 +265,7 @@ describe('route', () => { it('disposes all providers even when an error occurs', async () => { const provider = createMockToolProvider(); + jest.mocked(createToolProviders).mockReturnValue([provider]); const router = new Router({}); dispatchMock.mockRejectedValue(new Error('AI dispatch error')); @@ -265,14 +273,15 @@ describe('route', () => { router.route({ route: 'ai-query', body: { messages: [] }, - toolProviders: [provider], - } as any), + mcpServerConfigs: dummyMcpServerConfigs, + }), ).rejects.toThrow(); expect(provider.dispose).toHaveBeenCalledTimes(1); }); it('works with no tool providers', async () => { + jest.mocked(createToolProviders).mockReturnValue([]); const router = new Router({}); const result = await router.route({ route: 'remote-tools' }); @@ -285,6 +294,7 @@ describe('route', () => { const provider = createMockToolProvider({ dispose: jest.fn().mockRejectedValue(new Error('Dispose failed')), }); + jest.mocked(createToolProviders).mockReturnValue([provider]); const router = new Router({}); dispatchMock.mockRejectedValue(dispatchError); @@ -292,8 +302,8 @@ describe('route', () => { router.route({ route: 'ai-query', body: { messages: [] }, - toolProviders: [provider], - } as any), + mcpServerConfigs: dummyMcpServerConfigs, + }), ).rejects.toThrow(dispatchError); }); }); From 0d9dd2307cd472e2c8542d17d452d26ab829577a Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Mon, 30 Mar 2026 14:20:17 +0200 Subject: [PATCH 20/23] chore(code review): macroscope --- packages/ai-proxy/src/router.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ai-proxy/src/router.ts b/packages/ai-proxy/src/router.ts index 64394359c6..7f05e70f33 100644 --- a/packages/ai-proxy/src/router.ts +++ b/packages/ai-proxy/src/router.ts @@ -73,12 +73,12 @@ export class Router { async route(args: RouteArgs & { mcpServerConfigs?: Record }) { // Validate input with Zod schema const result = routeArgsSchema.safeParse(args); - const remoteToolProviders = createToolProviders(args.mcpServerConfigs ?? {}, this.logger); if (!result.success) { throw new AIBadRequestError(Router.formatZodError(result.error)); } + const remoteToolProviders = createToolProviders(args.mcpServerConfigs ?? {}, this.logger); const validatedArgs = result.data; const providers = [...this.localToolProviders, ...remoteToolProviders]; From 24503bb70aef039c2406bc2d5e2baaf1fac9f8d8 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Mon, 30 Mar 2026 15:08:37 +0200 Subject: [PATCH 21/23] refactor: rename mcpServerConfigs to toolConfigs --- packages/agent-toolkit/src/interfaces/ai.ts | 2 +- packages/agent/src/routes/ai/ai-proxy.ts | 4 ++-- packages/agent/test/routes/ai/ai-proxy.test.ts | 8 ++++---- packages/ai-proxy/src/create-ai-provider.ts | 8 ++++---- packages/ai-proxy/src/index.ts | 4 ++-- packages/ai-proxy/src/oauth-token-injector.ts | 6 +++--- packages/ai-proxy/src/router.ts | 9 +++++---- packages/ai-proxy/src/schemas/route.ts | 4 ++-- packages/ai-proxy/src/tool-provider-factory.ts | 6 +++--- packages/ai-proxy/src/tool-source-checker.ts | 4 ++-- .../ai-proxy/test/create-ai-provider.test.ts | 18 +++++++++--------- packages/ai-proxy/test/llm.integration.test.ts | 16 ++++++++-------- packages/ai-proxy/test/router.test.ts | 8 ++++---- 13 files changed, 49 insertions(+), 48 deletions(-) diff --git a/packages/agent-toolkit/src/interfaces/ai.ts b/packages/agent-toolkit/src/interfaces/ai.ts index bb43869dc5..b8c82eddae 100644 --- a/packages/agent-toolkit/src/interfaces/ai.ts +++ b/packages/agent-toolkit/src/interfaces/ai.ts @@ -24,7 +24,7 @@ export interface AiRouter { route: string; body?: unknown; query?: Record; - mcpServerConfigs?: unknown; + toolConfigs?: unknown; headers?: Record; }): Promise; } diff --git a/packages/agent/src/routes/ai/ai-proxy.ts b/packages/agent/src/routes/ai/ai-proxy.ts index 7ed5217e9e..bbb746f0b6 100644 --- a/packages/agent/src/routes/ai/ai-proxy.ts +++ b/packages/agent/src/routes/ai/ai-proxy.ts @@ -25,7 +25,7 @@ export default class AiProxyRoute extends BaseRoute { } private async handleAiProxy(context: Context): Promise { - const mcpServerConfigs = + const toolConfigs = await this.options.forestAdminClient.mcpServerConfigService.getConfiguration(); context.response.body = await this.aiRouter.route({ @@ -33,7 +33,7 @@ export default class AiProxyRoute extends BaseRoute { body: context.request.body, query: context.query, headers: context.request.headers, - mcpServerConfigs, + toolConfigs, }); context.response.status = HttpCode.Ok; } diff --git a/packages/agent/test/routes/ai/ai-proxy.test.ts b/packages/agent/test/routes/ai/ai-proxy.test.ts index 13fb8bbdcf..f4283fee50 100644 --- a/packages/agent/test/routes/ai/ai-proxy.test.ts +++ b/packages/agent/test/routes/ai/ai-proxy.test.ts @@ -57,7 +57,7 @@ describe('AiProxyRoute', () => { 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({}); @@ -75,12 +75,12 @@ describe('AiProxyRoute', () => { 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({}); @@ -105,7 +105,7 @@ describe('AiProxyRoute', () => { expect(mockRoute).toHaveBeenCalledWith( expect.objectContaining({ - mcpServerConfigs: mcpConfigs, + toolConfigs: mcpConfigs, headers: context.request.headers, }), ); diff --git a/packages/ai-proxy/src/create-ai-provider.ts b/packages/ai-proxy/src/create-ai-provider.ts index ddd70c34b1..0a2e013bf9 100644 --- a/packages/ai-proxy/src/create-ai-provider.ts +++ b/packages/ai-proxy/src/create-ai-provider.ts @@ -1,6 +1,6 @@ import type { AiConfiguration } from './provider'; import type { RouterRouteArgs } from './schemas/route'; -import type { ToolSourceConfig } from './tool-provider-factory'; +import type { ToolConfig } from './tool-provider-factory'; import type { AiProviderDefinition, AiRouter } from '@forestadmin/agent-toolkit'; import { extractMcpOauthTokensFromHeaders, injectOauthTokens } from './oauth-token-injector'; @@ -8,13 +8,13 @@ import { Router } from './router'; function resolveMcpConfigs( args: Parameters[0], -): Record | undefined { +): Record | undefined { const tokensByMcpServerName = args.headers ? extractMcpOauthTokensFromHeaders(args.headers) : undefined; return injectOauthTokens({ - configs: args.mcpServerConfigs as Record | undefined, + configs: args.toolConfigs as Record | undefined, tokensByMcpServerName, }); } @@ -34,7 +34,7 @@ export function createAiProvider(config: AiConfiguration): AiProviderDefinition route: args.route, body: args.body, query: args.query, - mcpServerConfigs: resolveMcpConfigs(args), + toolConfigs: resolveMcpConfigs(args), } as RouterRouteArgs), }; }, diff --git a/packages/ai-proxy/src/index.ts b/packages/ai-proxy/src/index.ts index 36c09e2ab3..9957d27562 100644 --- a/packages/ai-proxy/src/index.ts +++ b/packages/ai-proxy/src/index.ts @@ -1,4 +1,4 @@ -import type { ToolSourceConfig } from './tool-provider-factory'; +import type { ToolConfig } from './tool-provider-factory'; import type { Logger } from '@forestadmin/datasource-toolkit'; import ToolSourceChecker from './tool-source-checker'; @@ -19,7 +19,7 @@ export * from './tool-provider'; export * from './tool-provider-factory'; export function validToolConfigurationOrThrow( - configs: Record, + configs: Record, logger?: Logger, ) { return ToolSourceChecker.check(configs, logger); diff --git a/packages/ai-proxy/src/oauth-token-injector.ts b/packages/ai-proxy/src/oauth-token-injector.ts index ed82c2cf65..39ecb6aed0 100644 --- a/packages/ai-proxy/src/oauth-token-injector.ts +++ b/packages/ai-proxy/src/oauth-token-injector.ts @@ -1,5 +1,5 @@ import type { McpServerConfig } from './mcp-client'; -import type { ToolSourceConfig } from './tool-provider-factory'; +import type { ToolConfig } from './tool-provider-factory'; import { AIBadRequestError } from './errors'; @@ -65,9 +65,9 @@ export function injectOauthTokens({ configs, tokensByMcpServerName, }: { - configs: Record | undefined; + configs: Record | undefined; tokensByMcpServerName: Record | undefined; -}): Record | undefined { +}): Record | undefined { if (!configs) return undefined; if (!tokensByMcpServerName) return configs; diff --git a/packages/ai-proxy/src/router.ts b/packages/ai-proxy/src/router.ts index 7f05e70f33..7c3f59d873 100644 --- a/packages/ai-proxy/src/router.ts +++ b/packages/ai-proxy/src/router.ts @@ -1,5 +1,5 @@ import type { AiConfiguration } from './provider'; -import type { RouteArgs } from './schemas/route'; +import type { RouteArgs, RouterRouteArgs } from './schemas/route'; import type { ToolProvider } from './tool-provider'; import type { Logger } from '@forestadmin/datasource-toolkit'; import type { z } from 'zod'; @@ -10,7 +10,7 @@ import ProviderDispatcher from './provider-dispatcher'; import { RemoteTools } from './remote-tools'; import { routeArgsSchema } from './schemas/route'; import isModelSupportingTools from './supported-models'; -import { type ToolSourceConfig, createToolProviders } from './tool-provider-factory'; +import { createToolProviders } from './tool-provider-factory'; export type { AiQueryArgs, @@ -20,6 +20,7 @@ export type { Query, RemoteToolsArgs, RouteArgs, + RouterRouteArgs, } from './schemas/route'; // Keep these for backward compatibility @@ -70,7 +71,7 @@ export class Router { * - invoke-remote-tool: Execute a remote tool by name with the provided inputs * - remote-tools: Return the list of available remote tools definitions */ - async route(args: RouteArgs & { mcpServerConfigs?: Record }) { + async route(args: RouterRouteArgs) { // Validate input with Zod schema const result = routeArgsSchema.safeParse(args); @@ -78,7 +79,7 @@ export class Router { throw new AIBadRequestError(Router.formatZodError(result.error)); } - const remoteToolProviders = createToolProviders(args.mcpServerConfigs ?? {}, this.logger); + const remoteToolProviders = createToolProviders(args.toolConfigs ?? {}, this.logger); const validatedArgs = result.data; const providers = [...this.localToolProviders, ...remoteToolProviders]; diff --git a/packages/ai-proxy/src/schemas/route.ts b/packages/ai-proxy/src/schemas/route.ts index 5868a2d70f..be8a618fbf 100644 --- a/packages/ai-proxy/src/schemas/route.ts +++ b/packages/ai-proxy/src/schemas/route.ts @@ -1,4 +1,4 @@ -import type { ToolSourceConfig } from '../tool-provider-factory'; +import type { ToolConfig } from '../tool-provider-factory'; import { z } from 'zod'; @@ -86,7 +86,7 @@ export type RemoteToolsArgs = z.infer; // Derived types for consumers export type DispatchBody = AiQueryArgs['body']; export type RouterRouteArgs = RouteArgs & { - mcpServerConfigs?: Record; + toolConfigs?: Record; }; // Backward compatibility types diff --git a/packages/ai-proxy/src/tool-provider-factory.ts b/packages/ai-proxy/src/tool-provider-factory.ts index 69948c97ea..f72dd33f2b 100644 --- a/packages/ai-proxy/src/tool-provider-factory.ts +++ b/packages/ai-proxy/src/tool-provider-factory.ts @@ -4,14 +4,14 @@ import type { Logger } from '@forestadmin/datasource-toolkit'; import IntegrationClient, { type ForestIntegrationConfig } from './integration-client'; import McpClient, { type McpConfiguration, type McpServerConfig } from './mcp-client'; -export type ToolSourceConfig = McpServerConfig | ForestIntegrationConfig; +export type ToolConfig = McpServerConfig | ForestIntegrationConfig; -function isForestIntegrationConfig(config: ToolSourceConfig): config is ForestIntegrationConfig { +function isForestIntegrationConfig(config: ToolConfig): config is ForestIntegrationConfig { return 'isForestConnector' in config && config.isForestConnector === true; } export function createToolProviders( - configs: Record, + configs: Record, logger?: Logger, ): ToolProvider[] { const mcpConfigs: McpConfiguration['configs'] = {}; diff --git a/packages/ai-proxy/src/tool-source-checker.ts b/packages/ai-proxy/src/tool-source-checker.ts index 1bbc76636d..1ff77e2571 100644 --- a/packages/ai-proxy/src/tool-source-checker.ts +++ b/packages/ai-proxy/src/tool-source-checker.ts @@ -1,10 +1,10 @@ -import type { ToolSourceConfig } from './tool-provider-factory'; +import type { ToolConfig } from './tool-provider-factory'; import type { Logger } from '@forestadmin/datasource-toolkit'; import { createToolProviders } from './tool-provider-factory'; export default class ToolSourceChecker { - static async check(configs: Record, logger?: Logger): Promise { + static async check(configs: Record, logger?: Logger): Promise { const providers = createToolProviders(configs, logger); try { diff --git a/packages/ai-proxy/test/create-ai-provider.test.ts b/packages/ai-proxy/test/create-ai-provider.test.ts index 483dbc5d21..b845e99814 100644 --- a/packages/ai-proxy/test/create-ai-provider.test.ts +++ b/packages/ai-proxy/test/create-ai-provider.test.ts @@ -54,25 +54,25 @@ describe('createAiProvider', () => { route: 'ai-query', body: { messages: [] }, query: { 'ai-name': 'my-ai' }, - mcpServerConfigs: undefined, + toolConfigs: undefined, }), ); expect(result).toEqual({ result: 'ok' }); }); - test('should pass mcpServerConfigs to router', async () => { + test('should pass toolConfigs to router', async () => { routeMock.mockResolvedValue({}); const provider = createAiProvider(config); const aiRouter = provider.init(jest.fn()); await aiRouter.route({ route: 'remote-tools', - mcpServerConfigs: { server1: { command: 'test', args: [] } }, + toolConfigs: { server1: { command: 'test', args: [] } }, }); expect(routeMock).toHaveBeenCalledWith( expect.objectContaining({ - mcpServerConfigs: { server1: { command: 'test', args: [] } }, + toolConfigs: { server1: { command: 'test', args: [] } }, }), ); }); @@ -85,13 +85,13 @@ describe('createAiProvider', () => { await aiRouter.route({ route: 'remote-tools', - mcpServerConfigs: { server1: { type: 'http', url: 'https://server1.com' } }, + toolConfigs: { server1: { type: 'http', url: 'https://server1.com' } }, headers: { 'x-mcp-oauth-tokens': oauthTokens }, }); expect(routeMock).toHaveBeenCalledWith( expect.objectContaining({ - mcpServerConfigs: { + toolConfigs: { server1: { type: 'http', url: 'https://server1.com', @@ -109,13 +109,13 @@ describe('createAiProvider', () => { expect(() => aiRouter.route({ route: 'remote-tools', - mcpServerConfigs: { server1: { type: 'http', url: 'https://server1.com' } }, + toolConfigs: { server1: { type: 'http', url: 'https://server1.com' } }, headers: { 'x-mcp-oauth-tokens': '{ invalid json }' }, }), ).toThrow('Invalid JSON in x-mcp-oauth-tokens header'); }); - test('should pass empty tool providers when no mcpServerConfigs provided', async () => { + test('should pass empty tool providers when no toolConfigs provided', async () => { routeMock.mockResolvedValue({}); const provider = createAiProvider(config); const aiRouter = provider.init(jest.fn()); @@ -123,7 +123,7 @@ describe('createAiProvider', () => { await aiRouter.route({ route: 'remote-tools' }); expect(routeMock).toHaveBeenCalledWith( - expect.objectContaining({ mcpServerConfigs: undefined }), + expect.objectContaining({ toolConfigs: undefined }), ); }); }); diff --git a/packages/ai-proxy/test/llm.integration.test.ts b/packages/ai-proxy/test/llm.integration.test.ts index 321d4ce5e7..f10ea211dc 100644 --- a/packages/ai-proxy/test/llm.integration.test.ts +++ b/packages/ai-proxy/test/llm.integration.test.ts @@ -643,7 +643,7 @@ describeWithOpenAI('Router integration tests', () => { it('should return MCP tools in the list', async () => { const response = (await router.route({ route: 'remote-tools', - mcpServerConfigs: mcpConfig.configs, + toolConfigs: mcpConfig.configs, })) as Array<{ name: string; sourceType: string; sourceId: string }>; const toolNames = response.map(t => t.name); @@ -689,7 +689,7 @@ describeWithOpenAI('Router integration tests', () => { const response = (await routerWithLogger.route({ route: 'remote-tools', - mcpServerConfigs: mixedConfig.configs, + toolConfigs: mixedConfig.configs, })) as Array<{ name: string; sourceId: string }>; const toolNames = response.map(t => t.name); @@ -726,7 +726,7 @@ describeWithOpenAI('Router integration tests', () => { const response = (await routerWithLogger.route({ route: 'remote-tools', - mcpServerConfigs: badAuthConfig.configs, + toolConfigs: badAuthConfig.configs, })) as Array<{ name: string }>; expect(response).toEqual([]); @@ -753,7 +753,7 @@ describeWithOpenAI('Router integration tests', () => { body: { messages: [{ role: 'user', content: 'Say "hello"' }], }, - mcpServerConfigs: brokenMcpConfig.configs, + toolConfigs: brokenMcpConfig.configs, })) as ChatCompletionResponse; expect(response.choices[0].message.content).toBeDefined(); @@ -768,7 +768,7 @@ describeWithOpenAI('Router integration tests', () => { body: { inputs: { a: 5, b: 3 } as any, }, - mcpServerConfigs: mcpConfig.configs, + toolConfigs: mcpConfig.configs, }); expect(response).toBe('8'); @@ -781,7 +781,7 @@ describeWithOpenAI('Router integration tests', () => { body: { inputs: { a: 6, b: 7 } as any, }, - mcpServerConfigs: mcpConfig.configs, + toolConfigs: mcpConfig.configs, }); expect(response).toBe('42'); @@ -819,7 +819,7 @@ describeWithOpenAI('Router integration tests', () => { ], tool_choice: 'required', }, - mcpServerConfigs: mcpConfig.configs, + toolConfigs: mcpConfig.configs, })) as ChatCompletionResponse; expect(response.choices[0].finish_reason).toBe('tool_calls'); @@ -847,7 +847,7 @@ describeWithOpenAI('Router integration tests', () => { ], tool_choice: 'required', }, - mcpServerConfigs: mcpConfig.configs, + toolConfigs: mcpConfig.configs, })) as ChatCompletionResponse; expect(response.choices[0].finish_reason).toBe('tool_calls'); diff --git a/packages/ai-proxy/test/router.test.ts b/packages/ai-proxy/test/router.test.ts index 727566e738..1456bac1d7 100644 --- a/packages/ai-proxy/test/router.test.ts +++ b/packages/ai-proxy/test/router.test.ts @@ -243,7 +243,7 @@ describe('route', () => { await router.route({ route: 'remote-tools', - mcpServerConfigs: dummyMcpServerConfigs, + toolConfigs: dummyMcpServerConfigs, }); expect(provider1.loadTools).toHaveBeenCalledTimes(1); @@ -257,7 +257,7 @@ describe('route', () => { await router.route({ route: 'remote-tools', - mcpServerConfigs: dummyMcpServerConfigs, + toolConfigs: dummyMcpServerConfigs, }); expect(provider.dispose).toHaveBeenCalledTimes(1); @@ -273,7 +273,7 @@ describe('route', () => { router.route({ route: 'ai-query', body: { messages: [] }, - mcpServerConfigs: dummyMcpServerConfigs, + toolConfigs: dummyMcpServerConfigs, }), ).rejects.toThrow(); @@ -302,7 +302,7 @@ describe('route', () => { router.route({ route: 'ai-query', body: { messages: [] }, - mcpServerConfigs: dummyMcpServerConfigs, + toolConfigs: dummyMcpServerConfigs, }), ).rejects.toThrow(dispatchError); }); From 641bf573ea9e367c4ee181c8dccefe4e2511e020 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Mon, 30 Mar 2026 15:42:36 +0200 Subject: [PATCH 22/23] chore(review): handle properly http errors and add tests --- .../zendesk/tools/create-ticket-comment.ts | 4 ++ .../zendesk/tools/create-ticket.ts | 4 ++ .../zendesk/tools/get-ticket-comments.ts | 4 ++ .../integrations/zendesk/tools/get-ticket.ts | 4 ++ .../integrations/zendesk/tools/get-tickets.ts | 4 ++ .../zendesk/tools/update-ticket.ts | 4 ++ .../src/integrations/zendesk/utils.ts | 15 ++++ packages/ai-proxy/src/oauth-token-injector.ts | 4 +- .../ai-proxy/src/tool-provider-factory.ts | 2 +- .../tools/create-ticket-comment.test.ts | 16 +++++ .../zendesk/tools/create-ticket.test.ts | 16 +++++ .../zendesk/tools/get-ticket-comments.test.ts | 16 +++++ .../zendesk/tools/get-ticket.test.ts | 16 +++++ .../zendesk/tools/get-tickets.test.ts | 16 +++++ .../zendesk/tools/update-ticket.test.ts | 16 +++++ .../test/integrations/zendesk/utils.test.ts | 68 ++++++++++++++++++- packages/ai-proxy/test/mcp-client.test.ts | 25 +++++++ 17 files changed, 230 insertions(+), 4 deletions(-) diff --git a/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket-comment.ts b/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket-comment.ts index 5267a9f215..6007f59336 100644 --- a/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket-comment.ts +++ b/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket-comment.ts @@ -1,6 +1,8 @@ import { DynamicStructuredTool } from '@langchain/core/tools'; import { z } from 'zod'; +import { assertResponseOk } from '../utils'; + export default function createCreateTicketCommentTool( headers: Record, baseUrl: string, @@ -35,6 +37,8 @@ export default function createCreateTicketCommentTool( body: JSON.stringify(updateData), }); + await assertResponseOk(response, 'create ticket comment'); + return JSON.stringify(await response.json()); }, }); diff --git a/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket.ts b/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket.ts index 41f92e3530..ce3201d961 100644 --- a/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket.ts +++ b/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket.ts @@ -1,6 +1,8 @@ import { DynamicStructuredTool } from '@langchain/core/tools'; import { z } from 'zod'; +import { assertResponseOk } from '../utils'; + export default function createCreateTicketTool( headers: Record, baseUrl: string, @@ -61,6 +63,8 @@ export default function createCreateTicketTool( body: JSON.stringify(ticketData), }); + await assertResponseOk(response, 'create ticket'); + return JSON.stringify(await response.json()); }, }); diff --git a/packages/ai-proxy/src/integrations/zendesk/tools/get-ticket-comments.ts b/packages/ai-proxy/src/integrations/zendesk/tools/get-ticket-comments.ts index 4b1e76f454..81019a0cec 100644 --- a/packages/ai-proxy/src/integrations/zendesk/tools/get-ticket-comments.ts +++ b/packages/ai-proxy/src/integrations/zendesk/tools/get-ticket-comments.ts @@ -1,6 +1,8 @@ import { DynamicStructuredTool } from '@langchain/core/tools'; import { z } from 'zod'; +import { assertResponseOk } from '../utils'; + export default function createGetTicketCommentsTool( headers: Record, baseUrl: string, @@ -16,6 +18,8 @@ export default function createGetTicketCommentsTool( headers, }); + await assertResponseOk(response, 'get ticket comments'); + return JSON.stringify(await response.json()); }, }); diff --git a/packages/ai-proxy/src/integrations/zendesk/tools/get-ticket.ts b/packages/ai-proxy/src/integrations/zendesk/tools/get-ticket.ts index f25c60df83..96546a6674 100644 --- a/packages/ai-proxy/src/integrations/zendesk/tools/get-ticket.ts +++ b/packages/ai-proxy/src/integrations/zendesk/tools/get-ticket.ts @@ -1,6 +1,8 @@ import { DynamicStructuredTool } from '@langchain/core/tools'; import { z } from 'zod'; +import { assertResponseOk } from '../utils'; + export default function createGetTicketTool( headers: Record, baseUrl: string, @@ -16,6 +18,8 @@ export default function createGetTicketTool( headers, }); + await assertResponseOk(response, 'get ticket'); + return JSON.stringify(await response.json()); }, }); diff --git a/packages/ai-proxy/src/integrations/zendesk/tools/get-tickets.ts b/packages/ai-proxy/src/integrations/zendesk/tools/get-tickets.ts index a478e3dd57..feed9aef9e 100644 --- a/packages/ai-proxy/src/integrations/zendesk/tools/get-tickets.ts +++ b/packages/ai-proxy/src/integrations/zendesk/tools/get-tickets.ts @@ -1,6 +1,8 @@ import { DynamicStructuredTool } from '@langchain/core/tools'; import { z } from 'zod'; +import { assertResponseOk } from '../utils'; + export default function createGetTicketsTool( headers: Record, baseUrl: string, @@ -43,6 +45,8 @@ export default function createGetTicketsTool( headers, }); + await assertResponseOk(response, 'get tickets'); + return JSON.stringify(await response.json()); }, }); diff --git a/packages/ai-proxy/src/integrations/zendesk/tools/update-ticket.ts b/packages/ai-proxy/src/integrations/zendesk/tools/update-ticket.ts index 902088547c..906cda8a74 100644 --- a/packages/ai-proxy/src/integrations/zendesk/tools/update-ticket.ts +++ b/packages/ai-proxy/src/integrations/zendesk/tools/update-ticket.ts @@ -1,6 +1,8 @@ import { DynamicStructuredTool } from '@langchain/core/tools'; import { z } from 'zod'; +import { assertResponseOk } from '../utils'; + export default function createUpdateTicketTool( headers: Record, baseUrl: string, @@ -80,6 +82,8 @@ export default function createUpdateTicketTool( body: JSON.stringify({ ticket: ticketUpdate }), }); + await assertResponseOk(response, 'update ticket'); + return JSON.stringify(await response.json()); }, }); diff --git a/packages/ai-proxy/src/integrations/zendesk/utils.ts b/packages/ai-proxy/src/integrations/zendesk/utils.ts index e02e7db52b..b3402bd496 100644 --- a/packages/ai-proxy/src/integrations/zendesk/utils.ts +++ b/packages/ai-proxy/src/integrations/zendesk/utils.ts @@ -13,6 +13,21 @@ export function getZendeskConfig(config: ZendeskConfig) { return { baseUrl, headers }; } +export async function assertResponseOk(response: Response, action: string) { + if (!response.ok) { + let errorMessage = response.statusText || 'Unknown error'; + + try { + const json = await response.json(); + errorMessage = json.error || json.title || json.description || errorMessage; + } catch { + // Response body is not JSON + } + + throw new Error(`Zendesk ${action} failed (${response.status}): ${errorMessage}`); + } +} + export async function validateZendeskConfig(config: ZendeskConfig) { const { baseUrl, headers } = getZendeskConfig(config); diff --git a/packages/ai-proxy/src/oauth-token-injector.ts b/packages/ai-proxy/src/oauth-token-injector.ts index 39ecb6aed0..81a5c56b6d 100644 --- a/packages/ai-proxy/src/oauth-token-injector.ts +++ b/packages/ai-proxy/src/oauth-token-injector.ts @@ -1,7 +1,7 @@ import type { McpServerConfig } from './mcp-client'; -import type { ToolConfig } from './tool-provider-factory'; import { AIBadRequestError } from './errors'; +import { type ToolConfig, isForestIntegrationConfig } from './tool-provider-factory'; export const MCP_OAUTH_TOKENS_HEADER = 'x-mcp-oauth-tokens'; @@ -73,7 +73,7 @@ export function injectOauthTokens({ return Object.fromEntries( Object.entries(configs).map(([name, config]) => { - if ('isForestConnector' in config) return [name, config]; + if (isForestIntegrationConfig(config)) return [name, config]; return [ name, diff --git a/packages/ai-proxy/src/tool-provider-factory.ts b/packages/ai-proxy/src/tool-provider-factory.ts index f72dd33f2b..e4e6b4f096 100644 --- a/packages/ai-proxy/src/tool-provider-factory.ts +++ b/packages/ai-proxy/src/tool-provider-factory.ts @@ -6,7 +6,7 @@ import McpClient, { type McpConfiguration, type McpServerConfig } from './mcp-cl export type ToolConfig = McpServerConfig | ForestIntegrationConfig; -function isForestIntegrationConfig(config: ToolConfig): config is ForestIntegrationConfig { +export function isForestIntegrationConfig(config: ToolConfig): config is ForestIntegrationConfig { return 'isForestConnector' in config && config.isForestConnector === true; } diff --git a/packages/ai-proxy/test/integrations/zendesk/tools/create-ticket-comment.test.ts b/packages/ai-proxy/test/integrations/zendesk/tools/create-ticket-comment.test.ts index 554adf0417..ab591c5efa 100644 --- a/packages/ai-proxy/test/integrations/zendesk/tools/create-ticket-comment.test.ts +++ b/packages/ai-proxy/test/integrations/zendesk/tools/create-ticket-comment.test.ts @@ -3,6 +3,7 @@ import createCreateTicketCommentTool from '../../../../src/integrations/zendesk/ const mockResponse = { ticket: { id: 5 } }; global.fetch = jest.fn().mockResolvedValue({ + ok: true, json: () => Promise.resolve(mockResponse), }) as jest.Mock; @@ -12,6 +13,21 @@ describe('createCreateTicketCommentTool', () => { beforeEach(() => jest.clearAllMocks()); + it('should throw on HTTP error', async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 403, + statusText: 'Forbidden', + json: async () => ({ error: 'Insufficient permissions' }), + }); + + const tool = createCreateTicketCommentTool(headers, baseUrl); + + await expect(tool.invoke({ ticket_id: 5, comment: 'Test' })).rejects.toThrow( + 'Zendesk create ticket comment failed (403): Insufficient permissions', + ); + }); + it('should add a public comment by default', async () => { const tool = createCreateTicketCommentTool(headers, baseUrl); diff --git a/packages/ai-proxy/test/integrations/zendesk/tools/create-ticket.test.ts b/packages/ai-proxy/test/integrations/zendesk/tools/create-ticket.test.ts index 0370899e06..6981813a92 100644 --- a/packages/ai-proxy/test/integrations/zendesk/tools/create-ticket.test.ts +++ b/packages/ai-proxy/test/integrations/zendesk/tools/create-ticket.test.ts @@ -3,6 +3,7 @@ import createCreateTicketTool from '../../../../src/integrations/zendesk/tools/c const mockResponse = { ticket: { id: 99, subject: 'New ticket' } }; global.fetch = jest.fn().mockResolvedValue({ + ok: true, json: () => Promise.resolve(mockResponse), }) as jest.Mock; @@ -12,6 +13,21 @@ describe('createCreateTicketTool', () => { beforeEach(() => jest.clearAllMocks()); + it('should throw on HTTP error', async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 422, + statusText: 'Unprocessable Entity', + json: async () => ({ error: 'Validation failed' }), + }); + + const tool = createCreateTicketTool(headers, baseUrl); + + await expect(tool.invoke({ subject: 'Bug', description: 'It broke' })).rejects.toThrow( + 'Zendesk create ticket failed (422): Validation failed', + ); + }); + it('should create a ticket with required fields', async () => { const tool = createCreateTicketTool(headers, baseUrl); diff --git a/packages/ai-proxy/test/integrations/zendesk/tools/get-ticket-comments.test.ts b/packages/ai-proxy/test/integrations/zendesk/tools/get-ticket-comments.test.ts index c1eaa1ee29..cf1e71be16 100644 --- a/packages/ai-proxy/test/integrations/zendesk/tools/get-ticket-comments.test.ts +++ b/packages/ai-proxy/test/integrations/zendesk/tools/get-ticket-comments.test.ts @@ -3,6 +3,7 @@ import createGetTicketCommentsTool from '../../../../src/integrations/zendesk/to const mockResponse = { comments: [{ id: 1, body: 'Hello' }] }; global.fetch = jest.fn().mockResolvedValue({ + ok: true, json: () => Promise.resolve(mockResponse), }) as jest.Mock; @@ -12,6 +13,21 @@ describe('createGetTicketCommentsTool', () => { beforeEach(() => jest.clearAllMocks()); + it('should throw on HTTP error', async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + json: async () => ({ error: 'RecordNotFound' }), + }); + + const tool = createGetTicketCommentsTool(headers, baseUrl); + + await expect(tool.invoke({ ticket_id: 999 })).rejects.toThrow( + 'Zendesk get ticket comments failed (404): RecordNotFound', + ); + }); + it('should fetch comments for a ticket', async () => { const tool = createGetTicketCommentsTool(headers, baseUrl); diff --git a/packages/ai-proxy/test/integrations/zendesk/tools/get-ticket.test.ts b/packages/ai-proxy/test/integrations/zendesk/tools/get-ticket.test.ts index bfea4791b6..6c7307c3bc 100644 --- a/packages/ai-proxy/test/integrations/zendesk/tools/get-ticket.test.ts +++ b/packages/ai-proxy/test/integrations/zendesk/tools/get-ticket.test.ts @@ -3,6 +3,7 @@ import createGetTicketTool from '../../../../src/integrations/zendesk/tools/get- const mockResponse = { ticket: { id: 42, subject: 'Help' } }; global.fetch = jest.fn().mockResolvedValue({ + ok: true, json: () => Promise.resolve(mockResponse), }) as jest.Mock; @@ -12,6 +13,21 @@ describe('createGetTicketTool', () => { beforeEach(() => jest.clearAllMocks()); + it('should throw on HTTP error', async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + json: async () => ({ error: 'RecordNotFound' }), + }); + + const tool = createGetTicketTool(headers, baseUrl); + + await expect(tool.invoke({ ticket_id: 999 })).rejects.toThrow( + 'Zendesk get ticket failed (404): RecordNotFound', + ); + }); + it('should fetch the ticket by id', async () => { const tool = createGetTicketTool(headers, baseUrl); diff --git a/packages/ai-proxy/test/integrations/zendesk/tools/get-tickets.test.ts b/packages/ai-proxy/test/integrations/zendesk/tools/get-tickets.test.ts index 05acf5d8ee..c19b7bbb92 100644 --- a/packages/ai-proxy/test/integrations/zendesk/tools/get-tickets.test.ts +++ b/packages/ai-proxy/test/integrations/zendesk/tools/get-tickets.test.ts @@ -3,6 +3,7 @@ import createGetTicketsTool from '../../../../src/integrations/zendesk/tools/get const mockResponse = { tickets: [{ id: 1 }, { id: 2 }] }; global.fetch = jest.fn().mockResolvedValue({ + ok: true, json: () => Promise.resolve(mockResponse), }) as jest.Mock; @@ -12,6 +13,21 @@ describe('createGetTicketsTool', () => { beforeEach(() => jest.clearAllMocks()); + it('should throw on HTTP error', async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: 'Unauthorized', + json: async () => ({ error: 'Invalid credentials' }), + }); + + const tool = createGetTicketsTool(headers, baseUrl); + + await expect(tool.invoke({})).rejects.toThrow( + 'Zendesk get tickets failed (401): Invalid credentials', + ); + }); + it('should fetch tickets with default params', async () => { const tool = createGetTicketsTool(headers, baseUrl); diff --git a/packages/ai-proxy/test/integrations/zendesk/tools/update-ticket.test.ts b/packages/ai-proxy/test/integrations/zendesk/tools/update-ticket.test.ts index 309e67b713..fd47e14cf9 100644 --- a/packages/ai-proxy/test/integrations/zendesk/tools/update-ticket.test.ts +++ b/packages/ai-proxy/test/integrations/zendesk/tools/update-ticket.test.ts @@ -3,6 +3,7 @@ import createUpdateTicketTool from '../../../../src/integrations/zendesk/tools/u const mockResponse = { ticket: { id: 7, status: 'solved' } }; global.fetch = jest.fn().mockResolvedValue({ + ok: true, json: () => Promise.resolve(mockResponse), }) as jest.Mock; @@ -12,6 +13,21 @@ describe('createUpdateTicketTool', () => { beforeEach(() => jest.clearAllMocks()); + it('should throw on HTTP error', async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + json: async () => ({ error: 'RecordNotFound' }), + }); + + const tool = createUpdateTicketTool(headers, baseUrl); + + await expect(tool.invoke({ ticket_id: 999, status: 'solved' })).rejects.toThrow( + 'Zendesk update ticket failed (404): RecordNotFound', + ); + }); + it('should update a ticket with a single field', async () => { const tool = createUpdateTicketTool(headers, baseUrl); diff --git a/packages/ai-proxy/test/integrations/zendesk/utils.test.ts b/packages/ai-proxy/test/integrations/zendesk/utils.test.ts index 4c6897313a..a083aa6709 100644 --- a/packages/ai-proxy/test/integrations/zendesk/utils.test.ts +++ b/packages/ai-proxy/test/integrations/zendesk/utils.test.ts @@ -1,5 +1,9 @@ import { McpConnectionError } from '../../../src/errors'; -import { getZendeskConfig, validateZendeskConfig } from '../../../src/integrations/zendesk/utils'; +import { + assertResponseOk, + getZendeskConfig, + validateZendeskConfig, +} from '../../../src/integrations/zendesk/utils'; describe('zendesk/utils', () => { describe('getZendeskConfig', () => { @@ -19,6 +23,54 @@ describe('zendesk/utils', () => { }); }); + describe('assertResponseOk', () => { + it('should not throw when response is ok', async () => { + const response = { ok: true } as Response; + await expect(assertResponseOk(response, 'test')).resolves.toBeUndefined(); + }); + + it('should throw with error field from JSON body', async () => { + const response = { + ok: false, + status: 401, + statusText: 'Unauthorized', + json: async () => ({ error: 'Invalid credentials' }), + } as unknown as Response; + + await expect(assertResponseOk(response, 'get ticket')).rejects.toThrow( + 'Zendesk get ticket failed (401): Invalid credentials', + ); + }); + + it('should throw with title from JSON body', async () => { + const response = { + ok: false, + status: 404, + statusText: 'Not Found', + json: async () => ({ title: 'RecordNotFound' }), + } as unknown as Response; + + await expect(assertResponseOk(response, 'get ticket')).rejects.toThrow( + 'Zendesk get ticket failed (404): RecordNotFound', + ); + }); + + it('should fall back to statusText when JSON parsing fails', async () => { + const response = { + ok: false, + status: 502, + statusText: 'Bad Gateway', + json: async () => { + throw new Error('not json'); + }, + } as unknown as Response; + + await expect(assertResponseOk(response, 'create ticket')).rejects.toThrow( + 'Zendesk create ticket failed (502): Bad Gateway', + ); + }); + }); + describe('validateZendeskConfig', () => { const config = { subdomain: 'mycompany', email: 'agent@test.com', apiToken: 'secret123' }; @@ -51,6 +103,20 @@ describe('zendesk/utils', () => { ); }); + it('should fall back to statusText when response body is not JSON', async () => { + jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: false, + statusText: 'Bad Gateway', + json: async () => { + throw new Error('not json'); + }, + } as unknown as Response); + + await expect(validateZendeskConfig(config)).rejects.toThrow( + 'Failed to validate Zendesk config: Bad Gateway', + ); + }); + it('should throw McpConnectionError using error.title as fallback', async () => { jest.spyOn(global, 'fetch').mockResolvedValue({ ok: false, diff --git a/packages/ai-proxy/test/mcp-client.test.ts b/packages/ai-proxy/test/mcp-client.test.ts index ccaeead8a5..c4b4ccdf93 100644 --- a/packages/ai-proxy/test/mcp-client.test.ts +++ b/packages/ai-proxy/test/mcp-client.test.ts @@ -414,5 +414,30 @@ describe('McpClient', () => { zendesk: configs.zendesk, }); }); + + it('should inject token when isForestConnector is false', () => { + const configs = { + server1: { + type: 'http' as const, + url: 'https://server1.com', + isForestConnector: false, + }, + }; + const tokens = { server1: 'Bearer token1' }; + + const result = injectOauthTokens({ + configs: configs as any, + tokensByMcpServerName: tokens, + }); + + expect(result).toEqual({ + server1: { + type: 'http', + url: 'https://server1.com', + isForestConnector: false, + headers: { Authorization: 'Bearer token1' }, + }, + }); + }); }); }); From 6479240e62c302342dba0b34935cc720b3e12fee Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Mon, 30 Mar 2026 15:44:39 +0200 Subject: [PATCH 23/23] chore(code review): only dispose remote tools --- packages/ai-proxy/src/router.ts | 2 +- packages/ai-proxy/test/router.test.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/ai-proxy/src/router.ts b/packages/ai-proxy/src/router.ts index 7c3f59d873..0345c1f60d 100644 --- a/packages/ai-proxy/src/router.ts +++ b/packages/ai-proxy/src/router.ts @@ -113,7 +113,7 @@ export class Router { } } } finally { - await Promise.allSettled(providers.map(p => p.dispose())); + await Promise.allSettled(remoteToolProviders.map(p => p.dispose())); } } diff --git a/packages/ai-proxy/test/router.test.ts b/packages/ai-proxy/test/router.test.ts index 1456bac1d7..3d8c7fe9ee 100644 --- a/packages/ai-proxy/test/router.test.ts +++ b/packages/ai-proxy/test/router.test.ts @@ -324,6 +324,18 @@ describe('route', () => { expect(BraveToolProvider).not.toHaveBeenCalled(); }); + + it('does not dispose local tool providers after a request', async () => { + const router = new Router({ + localToolsApiKeys: { AI_REMOTE_TOOL_BRAVE_SEARCH_API_KEY: 'test-key' }, + }); + + const braveInstance = jest.mocked(BraveToolProvider).mock.results[0].value; + + await router.route({ route: 'remote-tools' }); + + expect(braveInstance.dispose).not.toHaveBeenCalled(); + }); }); describe('Model validation', () => {