diff --git a/packages/agent-testing/src/forest-admin-client-mock.ts b/packages/agent-testing/src/forest-admin-client-mock.ts index 1e49de559..3d195bec4 100644 --- a/packages/agent-testing/src/forest-admin-client-mock.ts +++ b/packages/agent-testing/src/forest-admin-client-mock.ts @@ -49,7 +49,7 @@ export default class ForestAdminClientMock implements ForestAdminClient { readonly modelCustomizationService: ModelCustomizationService; readonly mcpServerConfigService: ForestAdminClient['mcpServerConfigService'] = { - getConfiguration: () => Promise.resolve({ configs: {} }), + getConfiguration: () => Promise.resolve({}), }; readonly schemaService: ForestAdminClient['schemaService'] = { diff --git a/packages/agent-toolkit/src/interfaces/ai.ts b/packages/agent-toolkit/src/interfaces/ai.ts index bb43869dc..b8c82edda 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 7ed5217e9..bbb746f0b 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 13fb8bbdc..8e42f7fc9 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,19 +75,17 @@ 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({}); const mcpConfigs = { - configs: { - server1: { type: 'http' as const, url: 'https://server1.com' }, - }, + server1: { type: 'http' as const, url: 'https://server1.com' }, }; jest .spyOn(options.forestAdminClient.mcpServerConfigService, 'getConfiguration') @@ -105,7 +103,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 fe73b6411..0a2e013bf 100644 --- a/packages/ai-proxy/src/create-ai-provider.ts +++ b/packages/ai-proxy/src/create-ai-provider.ts @@ -1,18 +1,20 @@ -import type { McpConfiguration } from './mcp-client'; import type { AiConfiguration } from './provider'; import type { RouterRouteArgs } from './schemas/route'; +import type { ToolConfig } from './tool-provider-factory'; import type { AiProviderDefinition, AiRouter } from '@forestadmin/agent-toolkit'; import { extractMcpOauthTokensFromHeaders, injectOauthTokens } from './oauth-token-injector'; import { Router } from './router'; -function resolveMcpConfigs(args: Parameters[0]): McpConfiguration | undefined { +function resolveMcpConfigs( + args: Parameters[0], +): Record | undefined { const tokensByMcpServerName = args.headers ? extractMcpOauthTokensFromHeaders(args.headers) : undefined; return injectOauthTokens({ - mcpConfigs: args.mcpServerConfigs as McpConfiguration | undefined, + configs: args.toolConfigs as Record | undefined, tokensByMcpServerName, }); } @@ -32,7 +34,7 @@ export function createAiProvider(config: AiConfiguration): AiProviderDefinition route: args.route, body: args.body, query: args.query, - mcpConfigs: resolveMcpConfigs(args), + toolConfigs: resolveMcpConfigs(args), } as RouterRouteArgs), }; }, diff --git a/packages/ai-proxy/src/forest-integration-client.ts b/packages/ai-proxy/src/forest-integration-client.ts new file mode 100644 index 000000000..4ec361d62 --- /dev/null +++ b/packages/ai-proxy/src/forest-integration-client.ts @@ -0,0 +1,68 @@ +import type RemoteTool from './remote-tool'; +import type { ToolProvider } from './tool-provider'; +import type { Logger } from '@forestadmin/datasource-toolkit'; + +import getZendeskTools, { type ZendeskConfig } from './integrations/zendesk/tools'; +import { validateZendeskConfig } from './integrations/zendesk/utils'; + +export type CustomConfig = ZendeskConfig; +export type ForestIntegrationName = 'Zendesk'; + +export interface ForestIntegrationConfig { + integrationName: ForestIntegrationName; + config: CustomConfig; + isForestConnector: true; +} + +export function isForestIntegrationConfig( + config: ForestIntegrationConfig | Record, +): config is ForestIntegrationConfig { + return ( + 'isForestConnector' in config && (config as ForestIntegrationConfig).isForestConnector === true + ); +} + +export default class ForestIntegrationClient implements ToolProvider { + private readonly logger?: Logger; + private readonly configs: ForestIntegrationConfig[]; + + constructor(configs: ForestIntegrationConfig[], logger?: Logger) { + this.logger = logger; + this.configs = configs; + } + + async loadTools(): Promise { + const tools: RemoteTool[] = []; + + this.configs.forEach(({ integrationName, config }) => { + switch (integrationName) { + case 'Zendesk': + tools.push(...getZendeskTools(config as ZendeskConfig)); + break; + default: + this.logger?.('Warn', `Unsupported integration: ${integrationName}`); + } + }); + + return tools; + } + + async checkConnection(): Promise { + 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/index.ts b/packages/ai-proxy/src/index.ts index 5dd913d5d..a80c2e49f 100644 --- a/packages/ai-proxy/src/index.ts +++ b/packages/ai-proxy/src/index.ts @@ -1,16 +1,30 @@ -import type { McpConfiguration } from './mcp-client'; +import type { ToolConfig } from './tool-provider-factory'; +import type { Logger } from '@forestadmin/datasource-toolkit'; -import McpConfigChecker from './mcp-config-checker'; +import ToolSourceChecker from './tool-source-checker'; export { createAiProvider } from './create-ai-provider'; export { default as ProviderDispatcher } from './provider-dispatcher'; + +export { + ForestIntegrationConfig, + CustomConfig, + ForestIntegrationName, +} from './forest-integration-client'; + export * from './provider-dispatcher'; export * from './remote-tools'; +export { default as RemoteTool } from './remote-tool'; export * from './router'; export * from './mcp-client'; export * from './oauth-token-injector'; export * from './errors'; +export * from './tool-provider'; +export * from './tool-provider-factory'; -export function validMcpConfigurationOrThrow(mcpConfig: McpConfiguration) { - return McpConfigChecker.check(mcpConfig); +export function validToolConfigurationOrThrow( + configs: Record, + logger?: Logger, +) { + return ToolSourceChecker.check(configs, logger); } 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 000000000..abaf993ed --- /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/brave/tools.ts b/packages/ai-proxy/src/integrations/brave/tools.ts new file mode 100644 index 000000000..eedcb48e3 --- /dev/null +++ b/packages/ai-proxy/src/integrations/brave/tools.ts @@ -0,0 +1,18 @@ +import type RemoteTool from '../../remote-tool'; + +import { BraveSearch } from '@langchain/community/tools/brave_search'; + +import ServerRemoteTool from '../../server-remote-tool'; + +export interface BraveConfig { + apiKey: string; +} + +export default function getBraveTools(config: BraveConfig): RemoteTool[] { + return [ + new ServerRemoteTool({ + sourceId: 'brave_search', + tool: new BraveSearch({ apiKey: config.apiKey }), + }), + ]; +} 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 000000000..9886f86c4 --- /dev/null +++ b/packages/ai-proxy/src/integrations/zendesk/tools.ts @@ -0,0 +1,35 @@ +import type RemoteTool from '../../remote-tool'; + +import createCreateTicketTool from './tools/create-ticket'; +import createCreateTicketCommentTool from './tools/create-ticket-comment'; +import createGetTicketTool from './tools/get-ticket'; +import createGetTicketCommentsTool from './tools/get-ticket-comments'; +import createGetTicketsTool from './tools/get-tickets'; +import createUpdateTicketTool from './tools/update-ticket'; +import { getZendeskConfig } from './utils'; +import ServerRemoteTool from '../../server-remote-tool'; + +export interface ZendeskConfig { + subdomain: string; + email: string; + apiToken: string; +} + +export default function getZendeskTools(config: ZendeskConfig): RemoteTool[] { + const { baseUrl, headers } = getZendeskConfig(config); + + return [ + createGetTicketsTool(headers, baseUrl), + createGetTicketTool(headers, baseUrl), + createGetTicketCommentsTool(headers, baseUrl), + createCreateTicketTool(headers, baseUrl), + createCreateTicketCommentTool(headers, baseUrl), + createUpdateTicketTool(headers, baseUrl), + ].map( + tool => + new ServerRemoteTool({ + sourceId: 'zendesk', + tool, + }), + ); +} 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 000000000..6007f5933 --- /dev/null +++ b/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket-comment.ts @@ -0,0 +1,45 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +import { assertResponseOk } from '../utils'; + +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), + }); + + 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 new file mode 100644 index 000000000..ce3201d96 --- /dev/null +++ b/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket.ts @@ -0,0 +1,71 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +import { assertResponseOk } from '../utils'; + +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), + }); + + 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 new file mode 100644 index 000000000..81019a0ce --- /dev/null +++ b/packages/ai-proxy/src/integrations/zendesk/tools/get-ticket-comments.ts @@ -0,0 +1,26 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +import { assertResponseOk } from '../utils'; + +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, + }); + + 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 new file mode 100644 index 000000000..96546a667 --- /dev/null +++ b/packages/ai-proxy/src/integrations/zendesk/tools/get-ticket.ts @@ -0,0 +1,26 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +import { assertResponseOk } from '../utils'; + +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, + }); + + 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 new file mode 100644 index 000000000..feed9aef9 --- /dev/null +++ b/packages/ai-proxy/src/integrations/zendesk/tools/get-tickets.ts @@ -0,0 +1,53 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +import { assertResponseOk } from '../utils'; + +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, + }); + + 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 new file mode 100644 index 000000000..906cda8a7 --- /dev/null +++ b/packages/ai-proxy/src/integrations/zendesk/tools/update-ticket.ts @@ -0,0 +1,90 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +import { assertResponseOk } from '../utils'; + +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 }), + }); + + 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 new file mode 100644 index 000000000..b3402bd49 --- /dev/null +++ b/packages/ai-proxy/src/integrations/zendesk/utils.ts @@ -0,0 +1,48 @@ +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 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); + + const response = await fetch(`${baseUrl}/users/me`, { headers }); + + if (!response.ok) { + 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 945a2a953..71f2868fc 100644 --- a/packages/ai-proxy/src/mcp-client.ts +++ b/packages/ai-proxy/src/mcp-client.ts @@ -1,3 +1,4 @@ +import type { ToolProvider } from './tool-provider'; import type { Logger } from '@forestadmin/datasource-toolkit'; import { MultiServerMCPClient } from '@langchain/mcp-adapters'; @@ -5,18 +6,18 @@ 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; } & 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 @@ -30,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 }); @@ -57,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()), @@ -71,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); @@ -79,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 deleted file mode 100644 index 95433c5f7..000000000 --- a/packages/ai-proxy/src/mcp-config-checker.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { McpConfiguration } from './mcp-client'; - -import McpClient from './mcp-client'; - -export default class McpConfigChecker { - static check(mcpConfig: McpConfiguration) { - return new McpClient(mcpConfig).testConnections(); - } -} diff --git a/packages/ai-proxy/src/oauth-token-injector.ts b/packages/ai-proxy/src/oauth-token-injector.ts index be8b08fef..b4e8b0359 100644 --- a/packages/ai-proxy/src/oauth-token-injector.ts +++ b/packages/ai-proxy/src/oauth-token-injector.ts @@ -1,6 +1,8 @@ -import type { McpConfiguration, McpServerConfig } from './mcp-client'; +import type { McpServerConfig } from './mcp-client'; +import type { ToolConfig } from './tool-provider-factory'; import { AIBadRequestError } from './errors'; +import { isForestIntegrationConfig } from './forest-integration-client'; export const MCP_OAUTH_TOKENS_HEADER = 'x-mcp-oauth-tokens'; @@ -57,25 +59,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 (isForestIntegrationConfig(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/remote-tools.ts b/packages/ai-proxy/src/remote-tools.ts index 86c172850..2fd369bf2 100644 --- a/packages/ai-proxy/src/remote-tools.ts +++ b/packages/ai-proxy/src/remote-tools.ts @@ -2,34 +2,17 @@ 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'; 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; - this.tools.push(...(tools ?? [])); - - 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 }), - }), - ); - } + readonly tools: RemoteTool[]; + + 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 8424a35bc..ef471ba9d 100644 --- a/packages/ai-proxy/src/router.ts +++ b/packages/ai-proxy/src/router.ts @@ -1,16 +1,16 @@ -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 { RouteArgs, RouterRouteArgs } 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 McpClient from './mcp-client'; +import BraveToolProvider from './integrations/brave/brave-tool-provider'; import ProviderDispatcher from './provider-dispatcher'; import { RemoteTools } from './remote-tools'; import { routeArgsSchema } from './schemas/route'; import isModelSupportingTools from './supported-models'; +import { createToolProviders } from './tool-provider-factory'; export type { AiQueryArgs, @@ -20,29 +20,41 @@ export type { Query, RemoteToolsArgs, RouteArgs, + RouterRouteArgs, } from './schemas/route'; // 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)) { @@ -59,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 & { mcpConfigs?: McpConfiguration }) { + async route(args: RouterRouteArgs) { // Validate input with Zod schema const result = routeArgsSchema.safeParse(args); @@ -67,18 +79,13 @@ export class Router { throw new AIBadRequestError(Router.formatZodError(result.error)); } + const remoteToolProviders = createToolProviders(args.toolConfigs ?? {}, this.logger); const validatedArgs = result.data; - let mcpClient: McpClient | undefined; + const providers = [...this.localToolProviders, ...remoteToolProviders]; try { - if (args.mcpConfigs) { - mcpClient = new McpClient(args.mcpConfigs, this.logger); - } - - const remoteTools = new RemoteTools( - this.localToolsApiKeys ?? {}, - await mcpClient?.loadTools(), - ); + const allTools = (await Promise.all(providers.map(p => p.loadTools()))).flat(); + const remoteTools = new RemoteTools(allTools); switch (validatedArgs.route) { case 'ai-query': { @@ -100,24 +107,21 @@ 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); + const disposeResults = await Promise.allSettled(remoteToolProviders.map(p => p.dispose())); + + for (const disposeResult of disposeResults) { + if (disposeResult.status === 'rejected') { + const disposeError = + disposeResult.reason instanceof Error + ? disposeResult.reason + : new Error(String(disposeResult.reason)); + this.logger?.('Error', 'Error during tool provider cleanup', disposeError); } } } diff --git a/packages/ai-proxy/src/schemas/route.ts b/packages/ai-proxy/src/schemas/route.ts index 2baa234fa..be8a618fb 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 { ToolConfig } 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 & { mcpConfigs?: McpConfiguration }; +export type RouterRouteArgs = RouteArgs & { + toolConfigs?: Record; +}; // 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 000000000..2954b933e --- /dev/null +++ b/packages/ai-proxy/src/tool-provider-factory.ts @@ -0,0 +1,40 @@ +import type { ToolProvider } from './tool-provider'; +import type { Logger } from '@forestadmin/datasource-toolkit'; + +import ForestIntegrationClient, { + type ForestIntegrationConfig, + isForestIntegrationConfig, +} from './forest-integration-client'; +import McpClient, { type McpConfiguration, type McpServerConfig } from './mcp-client'; + +export type ToolConfig = McpServerConfig | ForestIntegrationConfig; + +export { isForestIntegrationConfig }; + +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 ForestIntegrationClient(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 000000000..ed2a2408f --- /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/src/tool-source-checker.ts b/packages/ai-proxy/src/tool-source-checker.ts new file mode 100644 index 000000000..1ff77e257 --- /dev/null +++ b/packages/ai-proxy/src/tool-source-checker.ts @@ -0,0 +1,18 @@ +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 { + const providers = createToolProviders(configs, logger); + + try { + await Promise.all(providers.map(p => p.checkConnection())); + + return true; + } finally { + await Promise.allSettled(providers.map(p => p.dispose())); + } + } +} diff --git a/packages/ai-proxy/test/create-ai-provider.test.ts b/packages/ai-proxy/test/create-ai-provider.test.ts index 419d4413e..b845e9981 100644 --- a/packages/ai-proxy/test/create-ai-provider.test.ts +++ b/packages/ai-proxy/test/create-ai-provider.test.ts @@ -49,34 +49,35 @@ 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' }, + toolConfigs: undefined, + }), + ); expect(result).toEqual({ result: 'ok' }); }); - test('should pass mcpServerConfigs as mcpConfigs to Router when no headers', 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: { configs: { server1: { command: 'test', args: [] } } }, + toolConfigs: { server1: { command: 'test', args: [] } }, }); - expect(routeMock).toHaveBeenCalledWith({ - route: 'remote-tools', - body: undefined, - query: undefined, - mcpConfigs: { configs: { server1: { command: 'test', args: [] } } }, - }); + expect(routeMock).toHaveBeenCalledWith( + expect.objectContaining({ + toolConfigs: { server1: { command: 'test', args: [] } }, + }), + ); }); - 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()); @@ -84,26 +85,21 @@ describe('createAiProvider', () => { await aiRouter.route({ route: 'remote-tools', - mcpServerConfigs: { - configs: { server1: { type: 'http', url: 'https://server1.com' } }, - }, + toolConfigs: { server1: { type: 'http', url: 'https://server1.com' } }, headers: { 'x-mcp-oauth-tokens': oauthTokens }, }); - expect(routeMock).toHaveBeenCalledWith({ - route: 'remote-tools', - body: undefined, - query: undefined, - mcpConfigs: { - configs: { + expect(routeMock).toHaveBeenCalledWith( + expect.objectContaining({ + toolConfigs: { server1: { type: 'http', url: 'https://server1.com', headers: { Authorization: 'Bearer token123' }, }, }, - }, - }); + }), + ); }); test('should throw AIBadRequestError when x-mcp-oauth-tokens header contains invalid JSON', () => { @@ -113,46 +109,22 @@ describe('createAiProvider', () => { expect(() => aiRouter.route({ route: 'remote-tools', - mcpServerConfigs: { - configs: { 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 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 toolConfigs 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({ toolConfigs: undefined }), + ); }); }); }); diff --git a/packages/ai-proxy/test/forest-integration-client.test.ts b/packages/ai-proxy/test/forest-integration-client.test.ts new file mode 100644 index 000000000..a4d2601f4 --- /dev/null +++ b/packages/ai-proxy/test/forest-integration-client.test.ts @@ -0,0 +1,115 @@ +import ForestIntegrationClient from '../src/forest-integration-client'; +import { validateZendeskConfig } from '../src/integrations/zendesk/utils'; + +const mockZendeskTools = [{ name: 'zendesk_get_tickets' }, { name: 'zendesk_get_ticket' }]; + +jest.mock('../src/integrations/zendesk/tools', () => ({ + __esModule: true, + default: jest.fn(() => mockZendeskTools), +})); + +jest.mock('../src/integrations/zendesk/utils'); + +describe('ForestIntegrationClient', () => { + beforeEach(() => jest.clearAllMocks()); + + describe('loadTools', () => { + it('should load zendesk tools when integration is zendesk', async () => { + const client = new ForestIntegrationClient([ + { + integrationName: 'Zendesk', + config: { subdomain: 'test', email: 'a@b.com', apiToken: 'tok' }, + isForestConnector: true, + }, + ]); + + const tools = await client.loadTools(); + + expect(tools).toEqual(mockZendeskTools); + }); + + it('should log warning for unsupported integration', async () => { + const logger = jest.fn(); + const client = new ForestIntegrationClient( + // @ts-expect-error Testing unsupported integration + [{ integrationName: 'unknown', config: {} as any, isForestConnector: true }], + logger, + ); + + await client.loadTools(); + + expect(logger).toHaveBeenCalledWith('Warn', 'Unsupported integration: unknown'); + }); + + it('should return empty array when no configs', async () => { + const client = new ForestIntegrationClient([]); + + expect(await client.loadTools()).toEqual([]); + }); + + it('should load tools from multiple configs', async () => { + const client = new ForestIntegrationClient([ + { + integrationName: 'Zendesk', + config: { subdomain: 'a', email: 'a@b.com', apiToken: 'tok' }, + isForestConnector: true, + }, + { + integrationName: 'Zendesk', + config: { subdomain: 'b', email: 'c@d.com', apiToken: 'tok2' }, + isForestConnector: true, + }, + ]); + + 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 ForestIntegrationClient([ + { integrationName: 'Zendesk', config: zendeskConfig, isForestConnector: true }, + ]); + + await client.checkConnection(); + + expect(validateZendeskConfig).toHaveBeenCalledWith(zendeskConfig); + }); + + it('should return true on success', async () => { + const client = new ForestIntegrationClient([ + { + 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 ForestIntegrationClient([ + // @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 ForestIntegrationClient([]); + + await expect(client.dispose()).resolves.toBeUndefined(); + }); + }); +}); diff --git a/packages/ai-proxy/test/index.integration.test.ts b/packages/ai-proxy/test/index.integration.test.ts index 559b3168c..4b2dce3fc 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,16 +19,14 @@ describe('Simple MCP Server', () => { server.close(); }); - describe('validMcpConfigurationOrThrow', () => { + describe('validToolConfigurationOrThrow', () => { 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`, - }, + const result = await validToolConfigurationOrThrow({ + simpleServer: { + url: 'http://localhost:3123/mcp', + type: 'http', + headers: { + Authorization: `Bearer your-secure-token-here`, }, }, }); @@ -38,10 +36,8 @@ 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' }, - }, + validToolConfigurationOrThrow({ + simpleServer: { url: 'http://localhost:3123/wrong', type: 'http' }, }), ).rejects.toThrow('Failed to connect to streamable HTTP'); }); 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 000000000..a1b54ff07 --- /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/zendesk/tools.test.ts b/packages/ai-proxy/test/integrations/zendesk/tools.test.ts new file mode 100644 index 000000000..b446bcb7e --- /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 000000000..ab591c5ef --- /dev/null +++ b/packages/ai-proxy/test/integrations/zendesk/tools/create-ticket-comment.test.ts @@ -0,0 +1,59 @@ +import createCreateTicketCommentTool from '../../../../src/integrations/zendesk/tools/create-ticket-comment'; + +const mockResponse = { ticket: { id: 5 } }; + +global.fetch = jest.fn().mockResolvedValue({ + ok: true, + 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 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); + + 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 000000000..6981813a9 --- /dev/null +++ b/packages/ai-proxy/test/integrations/zendesk/tools/create-ticket.test.ts @@ -0,0 +1,75 @@ +import createCreateTicketTool from '../../../../src/integrations/zendesk/tools/create-ticket'; + +const mockResponse = { ticket: { id: 99, subject: 'New ticket' } }; + +global.fetch = jest.fn().mockResolvedValue({ + ok: true, + 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 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); + + 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 000000000..cf1e71be1 --- /dev/null +++ b/packages/ai-proxy/test/integrations/zendesk/tools/get-ticket-comments.test.ts @@ -0,0 +1,39 @@ +import createGetTicketCommentsTool from '../../../../src/integrations/zendesk/tools/get-ticket-comments'; + +const mockResponse = { comments: [{ id: 1, body: 'Hello' }] }; + +global.fetch = jest.fn().mockResolvedValue({ + ok: true, + 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 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); + + 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 000000000..6c7307c3b --- /dev/null +++ b/packages/ai-proxy/test/integrations/zendesk/tools/get-ticket.test.ts @@ -0,0 +1,39 @@ +import createGetTicketTool from '../../../../src/integrations/zendesk/tools/get-ticket'; + +const mockResponse = { ticket: { id: 42, subject: 'Help' } }; + +global.fetch = jest.fn().mockResolvedValue({ + ok: true, + 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 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); + + 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 000000000..c19b7bbb9 --- /dev/null +++ b/packages/ai-proxy/test/integrations/zendesk/tools/get-tickets.test.ts @@ -0,0 +1,59 @@ +import createGetTicketsTool from '../../../../src/integrations/zendesk/tools/get-tickets'; + +const mockResponse = { tickets: [{ id: 1 }, { id: 2 }] }; + +global.fetch = jest.fn().mockResolvedValue({ + ok: true, + 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 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); + + 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 000000000..fd47e14cf --- /dev/null +++ b/packages/ai-proxy/test/integrations/zendesk/tools/update-ticket.test.ts @@ -0,0 +1,88 @@ +import createUpdateTicketTool from '../../../../src/integrations/zendesk/tools/update-ticket'; + +const mockResponse = { ticket: { id: 7, status: 'solved' } }; + +global.fetch = jest.fn().mockResolvedValue({ + ok: true, + 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 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); + + 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: {} }), + }); + }); +}); 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 000000000..a083aa670 --- /dev/null +++ b/packages/ai-proxy/test/integrations/zendesk/utils.test.ts @@ -0,0 +1,131 @@ +import { McpConnectionError } from '../../../src/errors'; +import { + assertResponseOk, + 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('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' }; + + 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 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, + 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/llm.integration.test.ts b/packages/ai-proxy/test/llm.integration.test.ts index c68efee76..f10ea211d 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', - mcpConfigs: mcpConfig, + 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', - mcpConfigs: mixedConfig, + 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', - mcpConfigs: badAuthConfig, + 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"' }], }, - mcpConfigs: brokenMcpConfig, + 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, }, - mcpConfigs: mcpConfig, + toolConfigs: mcpConfig.configs, }); expect(response).toBe('8'); @@ -781,7 +781,7 @@ describeWithOpenAI('Router integration tests', () => { body: { inputs: { a: 6, b: 7 } as any, }, - mcpConfigs: mcpConfig, + toolConfigs: mcpConfig.configs, }); expect(response).toBe('42'); @@ -819,7 +819,7 @@ describeWithOpenAI('Router integration tests', () => { ], tool_choice: 'required', }, - mcpConfigs: mcpConfig, + 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', }, - mcpConfigs: mcpConfig, + toolConfigs: 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 22487dc4f..c4b4ccdf9 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,112 @@ 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({ configs, tokensByMcpServerName: tokens }); + + expect(result).toEqual({ + server1: { + type: 'http', + url: 'https://server1.com', + headers: { Authorization: 'Bearer token1' }, + }, + 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({ mcpConfigs, tokensByMcpServerName: undefined }); + const result = injectOauthTokens({ + configs: configs as any, + tokensByMcpServerName: tokens, + }); - expect(result).toBe(mcpConfigs); + expect(result).toEqual({ + server1: { + type: 'http', + url: 'https://server1.com', + isForestConnector: false, + headers: { Authorization: 'Bearer token1' }, + }, + }); }); }); }); diff --git a/packages/ai-proxy/test/provider-dispatcher.test.ts b/packages/ai-proxy/test/provider-dispatcher.test.ts index 7cefcf3dc..c478b44ca 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 40192d613..40d142a05 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 0a6d25587..3d8c7fe9e 100644 --- a/packages/ai-proxy/test/router.test.ts +++ b/packages/ai-proxy/test/router.test.ts @@ -1,9 +1,10 @@ import type { DispatchBody, InvokeRemoteToolArgs } from '../src'; -import type { Logger } from '@forestadmin/datasource-toolkit'; +import type { ToolProvider } from '../src/tool-provider'; import { AIModelNotSupportedError, Router } from '../src'; -import McpClient from '../src/mcp-client'; +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' }]; @@ -18,6 +19,19 @@ jest.mock('../src/remote-tools', () => { }; }); +jest.mock('../src/tool-provider-factory'); + +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 { @@ -30,18 +44,19 @@ jest.mock('../src/provider-dispatcher', () => { const ProviderDispatcherMock = ProviderDispatcher as jest.MockedClass; -jest.mock('../src/mcp-client', () => { - return jest.fn().mockImplementation(() => ({ +function createMockToolProvider(overrides?: Partial): ToolProvider { + return { loadTools: jest.fn().mockResolvedValue([]), - closeConnections: jest.fn(), - })); -}); - -const MockedMcpClient = McpClient as jest.MockedClass; + checkConnection: jest.fn().mockResolvedValue(true), + dispose: jest.fn().mockResolvedValue(undefined), + ...overrides, + }; +} describe('route', () => { beforeEach(() => { jest.clearAllMocks(); + jest.mocked(createToolProviders).mockReturnValue([]); }); describe('when the route is /ai-query', () => { @@ -215,173 +230,111 @@ describe('route', () => { "Invalid route. Expected: 'ai-query', 'invoke-remote-tool', 'remote-tools'", ); }); + }); - it('does not include mcpConfigs in the error message', async () => { + 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 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', + toolConfigs: dummyMcpServerConfigs, + }); + + 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(); + jest.mocked(createToolProviders).mockReturnValue([provider]); const router = new Router({}); await router.route({ route: 'remote-tools', - mcpConfigs: { configs: { server1: { command: 'test', args: [] } } }, + toolConfigs: dummyMcpServerConfigs, }); - 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(); + jest.mocked(createToolProviders).mockReturnValue([provider]); 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: [] } } }, - } as any), + toolConfigs: dummyMcpServerConfigs, + }), ).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 mcpConfigs provided', async () => { + it('works with no tool providers', async () => { + jest.mocked(createToolProviders).mockReturnValue([]); const router = new Router({}); - 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: [] } } }, - }); + const result = await router.route({ route: 'remote-tools' }); - 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')), + }); + jest.mocked(createToolProviders).mockReturnValue([provider]); + 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: [] } } }, - } as any), + toolConfigs: dummyMcpServerConfigs, + }), ).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, + 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' }, }); - const closeError = new Error('Cleanup failed'); - jest.mocked(McpClient).mockImplementation( - () => - ({ - loadTools: jest.fn().mockResolvedValue([]), - closeConnections: jest.fn().mockRejectedValue(closeError), - } as unknown as McpClient), - ); + expect(BraveToolProvider).toHaveBeenCalledWith({ apiKey: 'test-key' }); + }); - await router.route({ - route: 'remote-tools', - mcpConfigs: { configs: { server1: { command: 'test', args: [] } } }, - }); + it('does not create BraveToolProvider when no API key', () => { + // eslint-disable-next-line no-new + new Router({}); - // Custom logger should be called - expect(customLogger).toHaveBeenCalledWith( - 'Error', - 'Error during MCP connection cleanup', - closeError, - ); + expect(BraveToolProvider).not.toHaveBeenCalled(); }); - it('passes logger to McpClient', async () => { - const customLogger: Logger = jest.fn(); + it('does not dispose local tool providers after a request', async () => { const router = new Router({ - logger: customLogger, + localToolsApiKeys: { AI_REMOTE_TOOL_BRAVE_SEARCH_API_KEY: 'test-key' }, }); - await router.route({ - route: 'remote-tools', - mcpConfigs: { configs: { server1: { command: 'test', args: [] } } }, - }); + const braveInstance = jest.mocked(BraveToolProvider).mock.results[0].value; - expect(MockedMcpClient).toHaveBeenCalledWith( - { configs: { server1: { command: 'test', args: [] } } }, - customLogger, - ); + await router.route({ route: 'remote-tools' }); + + expect(braveInstance.dispose).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 000000000..a3fbe44d7 --- /dev/null +++ b/packages/ai-proxy/test/tool-provider-factory.test.ts @@ -0,0 +1,99 @@ +import ForestIntegrationClient from '../src/forest-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/forest-integration-client', () => { + const actual = jest.requireActual('../src/forest-integration-client'); + + return { + __esModule: true, + ...actual, + 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 ForestIntegrationClient 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(ForestIntegrationClient).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(ForestIntegrationClient).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(ForestIntegrationClient).toHaveBeenCalledWith(expect.anything(), logger); + }); +}); diff --git a/packages/ai-proxy/test/tool-source-checker.test.ts b/packages/ai-proxy/test/tool-source-checker.test.ts new file mode 100644 index 000000000..54e2ba868 --- /dev/null +++ b/packages/ai-proxy/test/tool-source-checker.test.ts @@ -0,0 +1,72 @@ +import ToolSourceChecker from '../src/tool-source-checker'; +import { createToolProviders } from '../src/tool-provider-factory'; + +jest.mock('../src/tool-provider-factory'); + +describe('ToolSourceChecker', () => { + beforeEach(() => jest.clearAllMocks()); + + 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]); + + const result = await ToolSourceChecker.check({ + server1: { command: 'test', args: [] }, + }); + + expect(result).toBe(true); + expect(mockProvider1.checkConnection).toHaveBeenCalled(); + expect(mockProvider2.checkConnection).toHaveBeenCalled(); + }); + + 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 ToolSourceChecker.check({ server1: { command: 'test', args: [] } }); + + expect(mockProvider.dispose).toHaveBeenCalled(); + }); + + 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( + ToolSourceChecker.check({ server1: { command: 'test', args: [] } }), + ).rejects.toThrow('Connection failed'); + + expect(mockProvider.dispose).toHaveBeenCalled(); + }); + + it('should pass logger to createToolProviders', async () => { + jest.mocked(createToolProviders).mockReturnValue([]); + const logger = jest.fn(); + + await ToolSourceChecker.check({ server1: { command: 'test', args: [] } }, logger); + + expect(createToolProviders).toHaveBeenCalledWith( + { server1: { command: 'test', args: [] } }, + logger, + ); + }); + }); +}); diff --git a/packages/forestadmin-client/src/mcp-server-config/index.ts b/packages/forestadmin-client/src/mcp-server-config/index.ts index d96059e9f..0c85a1a80 100644 --- a/packages/forestadmin-client/src/mcp-server-config/index.ts +++ b/packages/forestadmin-client/src/mcp-server-config/index.ts @@ -1,6 +1,6 @@ import type { McpServerConfigService } from './types'; import type { ForestAdminClientOptionsWithDefaults, ForestAdminServerInterface } from '../types'; -import type { McpConfiguration } from '@forestadmin/ai-proxy'; +import type { ToolConfig } from '@forestadmin/ai-proxy'; export default class McpServerConfigFromApiService implements McpServerConfigService { constructor( @@ -8,7 +8,7 @@ export default class McpServerConfigFromApiService implements McpServerConfigSer private readonly options: ForestAdminClientOptionsWithDefaults, ) {} - async getConfiguration(): Promise { + async getConfiguration(): Promise> { return this.forestadminServerInterface.getMcpServerConfigs(this.options); } } diff --git a/packages/forestadmin-client/src/mcp-server-config/types.ts b/packages/forestadmin-client/src/mcp-server-config/types.ts index b4b52c775..297790682 100644 --- a/packages/forestadmin-client/src/mcp-server-config/types.ts +++ b/packages/forestadmin-client/src/mcp-server-config/types.ts @@ -1,5 +1,5 @@ -import type { McpConfiguration } from '@forestadmin/ai-proxy'; +import type { ToolConfig } from '@forestadmin/ai-proxy'; export interface McpServerConfigService { - getConfiguration(): Promise; + getConfiguration(): Promise>; } diff --git a/packages/forestadmin-client/src/permissions/forest-http-api.ts b/packages/forestadmin-client/src/permissions/forest-http-api.ts index 1b5487f8d..5bb9f34a6 100644 --- a/packages/forestadmin-client/src/permissions/forest-http-api.ts +++ b/packages/forestadmin-client/src/permissions/forest-http-api.ts @@ -10,7 +10,7 @@ import type { IpWhitelistRulesResponse, } from '../types'; import type { HttpOptions } from '../utils/http-options'; -import type { McpConfiguration } from '@forestadmin/ai-proxy'; +import type { ToolConfig } from '@forestadmin/ai-proxy'; import JSONAPISerializer from 'json-api-serializer'; @@ -37,8 +37,8 @@ export default class ForestHttpApi implements ForestAdminServerInterface { return ServerUtils.query(options, 'get', '/liana/model-customizations'); } - async getMcpServerConfigs(options: HttpOptions): Promise { - return ServerUtils.query( + async getMcpServerConfigs(options: HttpOptions): Promise> { + return ServerUtils.query>( options, 'get', '/liana/mcp-server-configs-with-details', diff --git a/packages/forestadmin-client/src/types.ts b/packages/forestadmin-client/src/types.ts index 6f36e4070..41c9cdede 100644 --- a/packages/forestadmin-client/src/types.ts +++ b/packages/forestadmin-client/src/types.ts @@ -16,7 +16,7 @@ import type { ForestSchema } from './schema/types'; import type { RequestContextVariables } from './utils/context-variables'; import type ContextVariables from './utils/context-variables'; import type { HttpOptions } from './utils/http-options'; -import type { McpConfiguration } from '@forestadmin/ai-proxy'; +import type { ToolConfig } from '@forestadmin/ai-proxy'; import type { ParsedUrlQuery } from 'querystring'; export type { CollectionActionEvent, RawTree, RawTreeWithSources } from './permissions/types'; @@ -271,7 +271,7 @@ export interface ForestAdminServerInterface { getUsers?: (...args) => Promise; getRenderingPermissions?: (renderingId: number, ...args) => Promise; getModelCustomizations?: (options: HttpOptions) => Promise; - getMcpServerConfigs?: (options: HttpOptions) => Promise; + getMcpServerConfigs?: (options: HttpOptions) => Promise>; makeAuthService?(options: ForestAdminClientOptionsWithDefaults): ForestAdminAuthServiceInterface; // Schema operations diff --git a/packages/forestadmin-client/test/mcp-server-config/index.test.ts b/packages/forestadmin-client/test/mcp-server-config/index.test.ts index 62fc736b1..5a0fcc0a8 100644 --- a/packages/forestadmin-client/test/mcp-server-config/index.test.ts +++ b/packages/forestadmin-client/test/mcp-server-config/index.test.ts @@ -1,12 +1,12 @@ +import type { McpServerConfig } from '@forestadmin/ai-proxy'; + import McpServerConfigFromApiService from '../../src/mcp-server-config'; import * as factories from '../__factories__'; describe('McpServerConfigFromApiService', () => { describe('getConfiguration', () => { it('should call getMcpServerConfigs on the server interface', async () => { - const mcpConfig = { - configs: { server1: { transport: 'sse', url: 'http://localhost:3000' } }, - }; + const mcpConfig = { server1: { transport: 'sse', url: 'http://localhost:3000' } }; const serverInterface = factories.forestAdminServerInterface.build(); (serverInterface.getMcpServerConfigs as jest.Mock).mockResolvedValue(mcpConfig); @@ -20,7 +20,7 @@ describe('McpServerConfigFromApiService', () => { }); it('should return empty configs when server returns empty config', async () => { - const mcpConfig = { configs: {} }; + const mcpConfig = {}; const serverInterface = factories.forestAdminServerInterface.build(); (serverInterface.getMcpServerConfigs as jest.Mock).mockResolvedValue(mcpConfig); @@ -29,16 +29,14 @@ describe('McpServerConfigFromApiService', () => { const result = await service.getConfiguration(); - expect(result).toEqual({ configs: {} }); + expect(result).toEqual({}); }); it('should return config with multiple SSE servers', async () => { const mcpConfig = { - configs: { - zendesk: { transport: 'sse', url: 'http://localhost:3001/sse' }, - slack: { transport: 'sse', url: 'http://localhost:3002/sse' }, - github: { transport: 'sse', url: 'http://localhost:3003/sse' }, - }, + zendesk: { transport: 'sse', url: 'http://localhost:3001/sse' }, + slack: { transport: 'sse', url: 'http://localhost:3002/sse' }, + github: { transport: 'sse', url: 'http://localhost:3003/sse' }, }; const serverInterface = factories.forestAdminServerInterface.build(); (serverInterface.getMcpServerConfigs as jest.Mock).mockResolvedValue(mcpConfig); @@ -49,17 +47,15 @@ describe('McpServerConfigFromApiService', () => { const result = await service.getConfiguration(); expect(result).toEqual(mcpConfig); - expect(Object.keys(result.configs)).toHaveLength(3); + expect(Object.keys(result)).toHaveLength(3); }); it('should return config with stdio transport server', async () => { const mcpConfig = { - configs: { - localServer: { - transport: 'stdio', - command: 'node', - args: ['./server.js'], - }, + localServer: { + transport: 'stdio', + command: 'node', + args: ['./server.js'], }, }; const serverInterface = factories.forestAdminServerInterface.build(); @@ -71,7 +67,7 @@ describe('McpServerConfigFromApiService', () => { const result = await service.getConfiguration(); expect(result).toEqual(mcpConfig); - expect(result.configs.localServer).toMatchObject({ + expect(result.localServer).toMatchObject({ transport: 'stdio', command: 'node', args: ['./server.js'], @@ -80,14 +76,12 @@ describe('McpServerConfigFromApiService', () => { it('should return config with mixed transport types', async () => { const mcpConfig = { - configs: { - remoteServer: { transport: 'sse', url: 'http://remote.example.com/sse' }, - localServer: { - transport: 'stdio', - command: 'python', - args: ['-m', 'mcp_server'], - env: { DEBUG: 'true' }, - }, + remoteServer: { transport: 'sse', url: 'http://remote.example.com/sse' }, + localServer: { + transport: 'stdio', + command: 'python', + args: ['-m', 'mcp_server'], + env: { DEBUG: 'true' }, }, }; const serverInterface = factories.forestAdminServerInterface.build(); @@ -99,8 +93,8 @@ describe('McpServerConfigFromApiService', () => { const result = await service.getConfiguration(); expect(result).toEqual(mcpConfig); - expect(result.configs.remoteServer.transport).toBe('sse'); - expect(result.configs.localServer.transport).toBe('stdio'); + expect((result.remoteServer as McpServerConfig).transport).toBe('sse'); + expect((result.localServer as McpServerConfig).transport).toBe('stdio'); }); }); }); diff --git a/yarn.lock b/yarn.lock index 626c528de..1a74d496f 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== @@ -937,6 +928,15 @@ js-tokens "^4.0.0" picocolors "^1.1.1" +"@babel/code-frame@^7.28.6": + version "7.29.0" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.0.tgz#7cd7a59f15b3cc0dcd803038f7792712a7d0b15c" + integrity sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw== + dependencies: + "@babel/helper-validator-identifier" "^7.28.5" + js-tokens "^4.0.0" + picocolors "^1.1.1" + "@babel/compat-data@^7.22.9": version "7.23.3" resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.23.3.tgz#3febd552541e62b5e883a25eb3effd7c7379db11" @@ -1176,11 +1176,11 @@ "@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== + version "7.29.2" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.2.tgz#58bd50b9a7951d134988a1ae177a35ef9a703ba1" + integrity sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA== dependencies: - "@babel/types" "^7.28.6" + "@babel/types" "^7.29.0" "@babel/plugin-syntax-async-generators@^7.8.4": version "7.8.4" @@ -1358,10 +1358,10 @@ "@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== +"@babel/types@^7.28.6", "@babel/types@^7.29.0": + version "7.29.0" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.0.tgz#9f5b1e838c446e72cf3cd4b918152b8c605e37c7" + integrity sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A== dependencies: "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.28.5" @@ -2314,29 +2314,31 @@ 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.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.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: + "@types/json-schema" "^7.0.15" p-queue "^9.0.1" p-retry "^7.1.1" uuid "^13.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== + 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.5.4" + "@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.1.1": @@ -2525,9 +2527,9 @@ sparse-bitfield "^3.0.3" "@mongodb-js/saslprep@^1.3.0": - version "1.4.4" - resolved "https://registry.yarnpkg.com/@mongodb-js/saslprep/-/saslprep-1.4.4.tgz#34a946ff6ae142e8f2259b87f2935f8284ba874d" - integrity sha512-p7X/ytJDIdwUfFL/CLOhKgdfJe1Fa8uw9seJYvdOmnP9JBWGWHW69HkOixXS6Wy9yvGf1MbhcS6lVmrhy4jm2g== + 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" @@ -3590,10 +3592,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" @@ -3617,16 +3619,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": @@ -3637,13 +3639,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" @@ -3654,13 +3656,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": @@ -4199,6 +4201,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" @@ -4247,13 +4254,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" @@ -4495,7 +4502,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== @@ -6955,7 +6962,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== @@ -7144,9 +7151,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" @@ -10158,9 +10165,9 @@ is-negative-zero@^2.0.3: 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== + 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" @@ -11324,12 +11331,12 @@ koa@^3.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== + 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 "^4.1.2" + chalk "^5.6.2" console-table-printer "^2.12.1" p-queue "^6.6.2" semver "^7.6.3" @@ -11975,7 +11982,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== @@ -14123,7 +14130,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== @@ -14148,6 +14155,11 @@ path-to-regexp@^8.0.0: resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.3.0.tgz#aa818a6981f99321003a08987d3cec9c3474cd1f" integrity sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA== +path-to-regexp@~0.1.12: + version "0.1.13" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.13.tgz#9b22ec16bc3ab88d05a0c7e369869421401ab17d" + integrity sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA== + path-type@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-3.0.0.tgz#cef31dc8e0a1a3bb0d105c0cd97cf3bf47f4e36f" @@ -14512,6 +14524,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" @@ -15706,16 +15723,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" @@ -16818,14 +16835,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" @@ -16914,9 +16931,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" @@ -17137,9 +17154,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" @@ -17388,9 +17405,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"