diff --git a/packages/agent-toolkit/src/interfaces/ai.ts b/packages/agent-toolkit/src/interfaces/ai.ts index bb43869dc5..b8c82eddae 100644 --- a/packages/agent-toolkit/src/interfaces/ai.ts +++ b/packages/agent-toolkit/src/interfaces/ai.ts @@ -24,7 +24,7 @@ export interface AiRouter { route: string; body?: unknown; query?: Record; - mcpServerConfigs?: unknown; + toolConfigs?: unknown; headers?: Record; }): Promise; } diff --git a/packages/agent/src/routes/ai/ai-proxy.ts b/packages/agent/src/routes/ai/ai-proxy.ts index 7ed5217e9e..bbb746f0b6 100644 --- a/packages/agent/src/routes/ai/ai-proxy.ts +++ b/packages/agent/src/routes/ai/ai-proxy.ts @@ -25,7 +25,7 @@ export default class AiProxyRoute extends BaseRoute { } private async handleAiProxy(context: Context): Promise { - const mcpServerConfigs = + const toolConfigs = await this.options.forestAdminClient.mcpServerConfigService.getConfiguration(); context.response.body = await this.aiRouter.route({ @@ -33,7 +33,7 @@ export default class AiProxyRoute extends BaseRoute { body: context.request.body, query: context.query, headers: context.request.headers, - mcpServerConfigs, + toolConfigs, }); context.response.status = HttpCode.Ok; } diff --git a/packages/agent/test/routes/ai/ai-proxy.test.ts b/packages/agent/test/routes/ai/ai-proxy.test.ts index 13fb8bbdcf..f4283fee50 100644 --- a/packages/agent/test/routes/ai/ai-proxy.test.ts +++ b/packages/agent/test/routes/ai/ai-proxy.test.ts @@ -57,7 +57,7 @@ describe('AiProxyRoute', () => { expect(context.response.body).toEqual(expectedResponse); }); - test('should pass route, body, query, mcpServerConfigs and headers to router', async () => { + test('should pass route, body, query, toolConfigs and headers to router', async () => { const route = new AiProxyRoute(services, options, aiRouter); mockRoute.mockResolvedValueOnce({}); @@ -75,12 +75,12 @@ describe('AiProxyRoute', () => { route: 'ai-query', body: { messages: [{ role: 'user', content: 'Hello' }] }, query: { 'ai-name': 'gpt4' }, - mcpServerConfigs: undefined, + toolConfigs: undefined, headers: context.request.headers, }); }); - test('should pass mcpServerConfigs from forestAdminClient to router', async () => { + test('should pass toolConfigs from forestAdminClient to router', async () => { const route = new AiProxyRoute(services, options, aiRouter); mockRoute.mockResolvedValueOnce({}); @@ -105,7 +105,7 @@ describe('AiProxyRoute', () => { expect(mockRoute).toHaveBeenCalledWith( expect.objectContaining({ - mcpServerConfigs: mcpConfigs, + toolConfigs: mcpConfigs, headers: context.request.headers, }), ); diff --git a/packages/ai-proxy/src/create-ai-provider.ts b/packages/ai-proxy/src/create-ai-provider.ts index fe73b6411e..0a2e013bf9 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/index.ts b/packages/ai-proxy/src/index.ts index 5dd913d5d4..9957d27562 100644 --- a/packages/ai-proxy/src/index.ts +++ b/packages/ai-proxy/src/index.ts @@ -1,16 +1,26 @@ -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 './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/integration-client.ts b/packages/ai-proxy/src/integration-client.ts new file mode 100644 index 0000000000..2b2bfe459b --- /dev/null +++ b/packages/ai-proxy/src/integration-client.ts @@ -0,0 +1,60 @@ +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 default class IntegrationClient 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/integrations/brave/brave-tool-provider.ts b/packages/ai-proxy/src/integrations/brave/brave-tool-provider.ts new file mode 100644 index 0000000000..abaf993ede --- /dev/null +++ b/packages/ai-proxy/src/integrations/brave/brave-tool-provider.ts @@ -0,0 +1,25 @@ +import type RemoteTool from '../../remote-tool'; +import type { ToolProvider } from '../../tool-provider'; + +import getBraveTools, { type BraveConfig } from './tools'; + +export default class BraveToolProvider implements ToolProvider { + private readonly config: BraveConfig; + + constructor(config: BraveConfig) { + this.config = config; + } + + async loadTools(): Promise { + return getBraveTools(this.config); + } + + async checkConnection(): Promise { + return true; + } + + // eslint-disable-next-line class-methods-use-this + async dispose(): Promise { + // No-op: Brave search has no persistent connections + } +} diff --git a/packages/ai-proxy/src/integrations/brave/tools.ts b/packages/ai-proxy/src/integrations/brave/tools.ts new file mode 100644 index 0000000000..eedcb48e3f --- /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 0000000000..9886f86c49 --- /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 0000000000..6007f59336 --- /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 0000000000..ce3201d961 --- /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 0000000000..81019a0cec --- /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 0000000000..96546a6674 --- /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 0000000000..feed9aef9e --- /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 0000000000..906cda8a74 --- /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 0000000000..b3402bd496 --- /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 945a2a9535..71f2868fc6 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 95433c5f7d..0000000000 --- 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 be8b08fef0..81a5c56b6d 100644 --- a/packages/ai-proxy/src/oauth-token-injector.ts +++ b/packages/ai-proxy/src/oauth-token-injector.ts @@ -1,6 +1,7 @@ -import type { McpConfiguration, McpServerConfig } from './mcp-client'; +import type { McpServerConfig } from './mcp-client'; import { AIBadRequestError } from './errors'; +import { type ToolConfig, isForestIntegrationConfig } from './tool-provider-factory'; export const MCP_OAUTH_TOKENS_HEADER = 'x-mcp-oauth-tokens'; @@ -57,25 +58,30 @@ export function injectOauthToken({ } /** - * Injects OAuth tokens into all server configurations. - * Returns a new McpConfiguration with tokens injected, or undefined if no configs provided. + * Injects OAuth tokens into tool source configurations. + * Only MCP server configs receive token injection; Forest integration configs are passed through. */ export function injectOauthTokens({ - mcpConfigs, + configs, tokensByMcpServerName, }: { - mcpConfigs: McpConfiguration | undefined; + configs: Record | undefined; tokensByMcpServerName: Record | undefined; -}): McpConfiguration | undefined { - if (!mcpConfigs) return undefined; - if (!tokensByMcpServerName) return mcpConfigs; +}): Record | undefined { + if (!configs) return undefined; + if (!tokensByMcpServerName) return configs; - const configsWithTokens = Object.fromEntries( - Object.entries(mcpConfigs.configs).map(([name, serverConfig]) => [ - name, - injectOauthToken({ serverConfig, token: tokensByMcpServerName[name] }), - ]), - ); + return Object.fromEntries( + Object.entries(configs).map(([name, config]) => { + if (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 86c172850c..2fd369bf2e 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 8424a35bcb..0345c1f60d 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,26 +107,13 @@ export class Router { /* istanbul ignore next */ default: { - // Exhaustive type check - this code never runs at runtime because Zod validation - // catches unknown routes earlier. However, it provides compile-time safety: - // if a new route is added to routeArgsSchema, TypeScript will error here with - // "Type 'NewRouteArgs' is not assignable to type 'never'", forcing the developer - // to add a corresponding case handler. const exhaustiveCheck: never = validatedArgs; return exhaustiveCheck; } } } finally { - if (mcpClient) { - try { - await mcpClient.closeConnections(); - } catch (cleanupError) { - const error = - cleanupError instanceof Error ? cleanupError : new Error(String(cleanupError)); - this.logger?.('Error', 'Error during MCP connection cleanup', error); - } - } + await Promise.allSettled(remoteToolProviders.map(p => p.dispose())); } } diff --git a/packages/ai-proxy/src/schemas/route.ts b/packages/ai-proxy/src/schemas/route.ts index 2baa234fad..be8a618fbf 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 0000000000..e4e6b4f096 --- /dev/null +++ b/packages/ai-proxy/src/tool-provider-factory.ts @@ -0,0 +1,39 @@ +import type { ToolProvider } from './tool-provider'; +import type { Logger } from '@forestadmin/datasource-toolkit'; + +import IntegrationClient, { type ForestIntegrationConfig } from './integration-client'; +import McpClient, { type McpConfiguration, type McpServerConfig } from './mcp-client'; + +export type ToolConfig = McpServerConfig | ForestIntegrationConfig; + +export function isForestIntegrationConfig(config: ToolConfig): config is ForestIntegrationConfig { + return 'isForestConnector' in config && config.isForestConnector === true; +} + +export function createToolProviders( + configs: Record, + logger?: Logger, +): ToolProvider[] { + const mcpConfigs: McpConfiguration['configs'] = {}; + const integrationConfigs: ForestIntegrationConfig[] = []; + + for (const [name, config] of Object.entries(configs)) { + if (isForestIntegrationConfig(config)) { + integrationConfigs.push(config); + } else { + mcpConfigs[name] = config; + } + } + + const providers: ToolProvider[] = []; + + if (Object.keys(mcpConfigs).length > 0) { + providers.push(new McpClient({ configs: mcpConfigs }, logger)); + } + + if (integrationConfigs.length > 0) { + providers.push(new IntegrationClient(integrationConfigs, logger)); + } + + return providers; +} diff --git a/packages/ai-proxy/src/tool-provider.ts b/packages/ai-proxy/src/tool-provider.ts new file mode 100644 index 0000000000..ed2a2408fe --- /dev/null +++ b/packages/ai-proxy/src/tool-provider.ts @@ -0,0 +1,7 @@ +import type RemoteTool from './remote-tool'; + +export interface ToolProvider { + loadTools(): Promise; + checkConnection(): Promise; + dispose(): Promise; +} diff --git a/packages/ai-proxy/src/tool-source-checker.ts b/packages/ai-proxy/src/tool-source-checker.ts new file mode 100644 index 0000000000..1ff77e2571 --- /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 419d4413ee..b845e99814 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/index.integration.test.ts b/packages/ai-proxy/test/index.integration.test.ts index 559b3168c0..4b2dce3fc2 100644 --- a/packages/ai-proxy/test/index.integration.test.ts +++ b/packages/ai-proxy/test/index.integration.test.ts @@ -1,7 +1,7 @@ // eslint-disable-next-line import/extensions import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { validMcpConfigurationOrThrow } from '../src'; +import { validToolConfigurationOrThrow } from '../src'; import runMcpServer from '../src/examples/simple-mcp-server'; describe('Simple MCP Server', () => { @@ -19,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/integration-client.test.ts b/packages/ai-proxy/test/integration-client.test.ts new file mode 100644 index 0000000000..65eba39223 --- /dev/null +++ b/packages/ai-proxy/test/integration-client.test.ts @@ -0,0 +1,115 @@ +import IntegrationClient from '../src/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('IntegrationClient', () => { + beforeEach(() => jest.clearAllMocks()); + + describe('loadTools', () => { + it('should load zendesk tools when integration is zendesk', async () => { + const client = new IntegrationClient([ + { + 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 IntegrationClient( + // @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 IntegrationClient([]); + + expect(await client.loadTools()).toEqual([]); + }); + + it('should load tools from multiple configs', async () => { + const client = new IntegrationClient([ + { + 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 IntegrationClient([ + { integrationName: 'Zendesk', config: zendeskConfig, isForestConnector: true }, + ]); + + await client.checkConnection(); + + expect(validateZendeskConfig).toHaveBeenCalledWith(zendeskConfig); + }); + + it('should return true on success', async () => { + const client = new IntegrationClient([ + { + integrationName: 'Zendesk', + config: { subdomain: 'test', email: 'a@b.com', apiToken: 'tok' }, + isForestConnector: true, + }, + ]); + + const result = await client.checkConnection(); + + expect(result).toBe(true); + }); + + it('should throw for unsupported integration', async () => { + const client = new IntegrationClient([ + // @ts-expect-error Testing unsupported integration + { integrationName: 'Unknown', config: {}, isForestConnector: true }, + ]); + + await expect(client.checkConnection()).rejects.toThrow( + 'Unsupported integration: Unknown', + ); + }); + }); + + describe('dispose', () => { + it('should resolve without error', async () => { + const client = new IntegrationClient([]); + + await expect(client.dispose()).resolves.toBeUndefined(); + }); + }); +}); diff --git a/packages/ai-proxy/test/integrations/brave/brave-tool-provider.test.ts b/packages/ai-proxy/test/integrations/brave/brave-tool-provider.test.ts new file mode 100644 index 0000000000..a1b54ff074 --- /dev/null +++ b/packages/ai-proxy/test/integrations/brave/brave-tool-provider.test.ts @@ -0,0 +1,40 @@ +import BraveToolProvider from '../../../src/integrations/brave/brave-tool-provider'; + +const mockBraveTools = [{ name: 'brave_search' }]; + +jest.mock('../../../src/integrations/brave/tools', () => ({ + __esModule: true, + default: jest.fn(() => mockBraveTools), +})); + +describe('BraveToolProvider', () => { + beforeEach(() => jest.clearAllMocks()); + + describe('loadTools', () => { + it('should return brave tools', async () => { + const provider = new BraveToolProvider({ apiKey: 'test-key' }); + + const tools = await provider.loadTools(); + + expect(tools).toEqual(mockBraveTools); + }); + }); + + describe('checkConnection', () => { + it('should return true', async () => { + const provider = new BraveToolProvider({ apiKey: 'test-key' }); + + const result = await provider.checkConnection(); + + expect(result).toBe(true); + }); + }); + + describe('dispose', () => { + it('should resolve without error', async () => { + const provider = new BraveToolProvider({ apiKey: 'test-key' }); + + await expect(provider.dispose()).resolves.toBeUndefined(); + }); + }); +}); diff --git a/packages/ai-proxy/test/integrations/zendesk/tools.test.ts b/packages/ai-proxy/test/integrations/zendesk/tools.test.ts new file mode 100644 index 0000000000..b446bcb7ef --- /dev/null +++ b/packages/ai-proxy/test/integrations/zendesk/tools.test.ts @@ -0,0 +1,31 @@ +import getZendeskTools from '../../../src/integrations/zendesk/tools'; +import ServerRemoteTool from '../../../src/server-remote-tool'; + +describe('getZendeskTools', () => { + const config = { subdomain: 'mycompany', email: 'agent@test.com', apiToken: 'secret-token' }; + + it('should return 6 tools wrapped in ServerRemoteTool', () => { + const tools = getZendeskTools(config); + + expect(tools).toHaveLength(6); + tools.forEach(tool => { + expect(tool).toBeInstanceOf(ServerRemoteTool); + expect(tool.sourceId).toBe('zendesk'); + expect(tool.sourceType).toBe('server'); + }); + }); + + it('should return tools with expected names', () => { + const tools = getZendeskTools(config); + const names = tools.map(t => t.base.name); + + expect(names).toEqual([ + 'zendesk_get_tickets', + 'zendesk_get_ticket', + 'zendesk_get_ticket_comments', + 'zendesk_create_ticket', + 'zendesk_create_ticket_comment', + 'zendesk_update_ticket', + ]); + }); +}); diff --git a/packages/ai-proxy/test/integrations/zendesk/tools/create-ticket-comment.test.ts b/packages/ai-proxy/test/integrations/zendesk/tools/create-ticket-comment.test.ts new file mode 100644 index 0000000000..ab591c5efa --- /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 0000000000..6981813a92 --- /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 0000000000..cf1e71be16 --- /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 0000000000..6c7307c3bc --- /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 0000000000..c19b7bbb92 --- /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 0000000000..fd47e14cf9 --- /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 0000000000..a083aa6709 --- /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 c68efee762..f10ea211dc 100644 --- a/packages/ai-proxy/test/llm.integration.test.ts +++ b/packages/ai-proxy/test/llm.integration.test.ts @@ -643,7 +643,7 @@ describeWithOpenAI('Router integration tests', () => { it('should return MCP tools in the list', async () => { const response = (await router.route({ route: 'remote-tools', - 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 22487dc4f5..c4b4ccdf93 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 7cefcf3dc4..c478b44ca6 100644 --- a/packages/ai-proxy/test/provider-dispatcher.test.ts +++ b/packages/ai-proxy/test/provider-dispatcher.test.ts @@ -1,4 +1,5 @@ import type { DispatchBody } from '../src'; +import type { Tool } from '@langchain/core/tools'; import { AIMessage } from '@langchain/core/messages'; import { convertToOpenAIFunction } from '@langchain/core/utils/function_calling'; @@ -15,6 +16,7 @@ import { ProviderDispatcher, RemoteTools, } from '../src'; +import ServerRemoteTool from '../src/server-remote-tool'; // Mock raw OpenAI response (returned via __includeRawResponse: true) const mockOpenAIResponse = { @@ -83,8 +85,6 @@ function mockAnthropicResponse( } describe('ProviderDispatcher', () => { - const apiKeys = { AI_REMOTE_TOOL_BRAVE_SEARCH_API_KEY: 'api-key' }; - const openaiConfig = { name: 'gpt4', provider: 'openai' as const, @@ -105,7 +105,7 @@ describe('ProviderDispatcher', () => { describe('dispatch', () => { it('should throw AINotConfiguredError when no provider is configured', async () => { - const dispatcher = new ProviderDispatcher(null, new RemoteTools(apiKeys)); + const dispatcher = new ProviderDispatcher(null, new RemoteTools()); await expect(dispatcher.dispatch(buildBody())).rejects.toThrow(AINotConfiguredError); await expect(dispatcher.dispatch(buildBody())).rejects.toThrow('AI is not configured'); @@ -116,7 +116,7 @@ describe('ProviderDispatcher', () => { () => new ProviderDispatcher( { provider: 'unknown', name: 'test', model: 'x' } as any, - new RemoteTools(apiKeys), + new RemoteTools(), ), ).toThrow(new AIBadRequestError("Unsupported AI provider 'unknown'.")); }); @@ -126,7 +126,7 @@ describe('ProviderDispatcher', () => { let dispatcher: ProviderDispatcher; beforeEach(() => { - dispatcher = new ProviderDispatcher(openaiConfig, new RemoteTools(apiKeys)); + dispatcher = new ProviderDispatcher(openaiConfig, new RemoteTools()); }); it('should return the raw OpenAI response', async () => { @@ -137,7 +137,7 @@ describe('ProviderDispatcher', () => { it('should not forward user-supplied model or arbitrary properties to the LLM', async () => { const customConfig = { ...openaiConfig, name: 'base', model: 'BASE MODEL' }; - const customDispatcher = new ProviderDispatcher(customConfig, new RemoteTools(apiKeys)); + const customDispatcher = new ProviderDispatcher(customConfig, new RemoteTools()); await customDispatcher.dispatch( buildBody({ @@ -253,7 +253,10 @@ describe('ProviderDispatcher', () => { describe('remote tools', () => { it('should enhance remote tools definition with full schema', async () => { - const remoteTools = new RemoteTools(apiKeys); + const mockTool = new ServerRemoteTool({ + tool: { name: 'test_tool', description: 'A test tool', schema: {} } as Tool, + }); + const remoteTools = new RemoteTools([mockTool]); const remoteDispatcher = new ProviderDispatcher(openaiConfig, remoteTools); await remoteDispatcher.dispatch( @@ -281,7 +284,7 @@ describe('ProviderDispatcher', () => { }); it('should not modify non-remote tools', async () => { - const remoteDispatcher = new ProviderDispatcher(openaiConfig, new RemoteTools(apiKeys)); + const remoteDispatcher = new ProviderDispatcher(openaiConfig, new RemoteTools()); await remoteDispatcher.dispatch( buildBody({ @@ -335,7 +338,7 @@ describe('ProviderDispatcher', () => { let dispatcher: ProviderDispatcher; beforeEach(() => { - dispatcher = new ProviderDispatcher(anthropicConfig, new RemoteTools(apiKeys)); + dispatcher = new ProviderDispatcher(anthropicConfig, new RemoteTools()); }); it('should not forward user-supplied model from body to the LLM', async () => { @@ -423,7 +426,10 @@ describe('ProviderDispatcher', () => { it('should enhance remote tools definition with full schema', async () => { mockAnthropicResponse(); - const remoteTools = new RemoteTools(apiKeys); + const mockTool = new ServerRemoteTool({ + tool: { name: 'test_tool', description: 'A test tool', schema: {} } as Tool, + }); + const remoteTools = new RemoteTools([mockTool]); const remoteDispatcher = new ProviderDispatcher(anthropicConfig, remoteTools); await remoteDispatcher.dispatch( diff --git a/packages/ai-proxy/test/remote-tools.test.ts b/packages/ai-proxy/test/remote-tools.test.ts index 40192d6133..40d142a055 100644 --- a/packages/ai-proxy/test/remote-tools.test.ts +++ b/packages/ai-proxy/test/remote-tools.test.ts @@ -6,105 +6,88 @@ import { toJsonSchema } from '@langchain/core/utils/json_schema'; import { RemoteTools } from '../src'; import ServerRemoteTool from '../src/server-remote-tool'; -describe('RemoteTools', () => { - const apiKeys = { AI_REMOTE_TOOL_BRAVE_SEARCH_API_KEY: 'api-key' }; - - describe('when AI_REMOTE_TOOL_BRAVE_SEARCH_API_KEY is not set', () => { - it('should not add the tool', () => { - const remoteTools = new RemoteTools({}); - expect(remoteTools.tools.length).toEqual(0); - }); +function createMockTool(name = 'tool1', description = 'description1'): ServerRemoteTool { + return new ServerRemoteTool({ + tool: { + name, + description, + responseFormat: 'content', + schema: {}, + } as Tool, }); +} - describe('when envs is null', () => { - it('should init the remote tool instance without error', () => { - expect(() => new RemoteTools(null)).not.toThrow(); - }); - }); - - describe('tools', () => { - it('should return the tools', () => { - const remoteTools = new RemoteTools(apiKeys); - expect(remoteTools.tools.length).toEqual(1); +describe('RemoteTools', () => { + describe('constructor', () => { + it('should have no tools when constructed without arguments', () => { + const remoteTools = new RemoteTools(); + expect(remoteTools.tools).toEqual([]); }); - describe('when tools are passed in the constructor', () => { - it('should return the tools', () => { - const tools = [ - new ServerRemoteTool({ - tool: { - name: 'tool1', - description: 'description1', - responseFormat: 'content', - schema: {}, - } as Tool, - }), - ]; - const remoteTools = new RemoteTools(apiKeys, tools); - expect(remoteTools.tools.length).toEqual(2); - expect(remoteTools.tools[0].base.name).toEqual('tool1'); - }); + it('should store provided tools', () => { + const tools = [createMockTool()]; + const remoteTools = new RemoteTools(tools); + expect(remoteTools.tools).toHaveLength(1); + expect(remoteTools.tools[0].base.name).toEqual('tool1'); }); }); describe('toolDefinitionsForFrontend', () => { it('should return the tools with extended definitions', () => { - const remoteTools = new RemoteTools(apiKeys); + const tool = createMockTool(); + const remoteTools = new RemoteTools([tool]); + expect(remoteTools.toolDefinitionsForFrontend).toEqual([ { - name: remoteTools.tools[0].sanitizedName, - description: remoteTools.tools[0].base.description, + name: tool.sanitizedName, + description: tool.base.description, responseFormat: 'content', - schema: toJsonSchema(remoteTools.tools[0].base.schema as JSONSchema), - sourceId: remoteTools.tools[0].sourceId, - sourceType: remoteTools.tools[0].sourceType, + schema: toJsonSchema(tool.base.schema as JSONSchema), + sourceId: tool.sourceId, + sourceType: tool.sourceType, }, ]); }); }); describe('invokeTool', () => { - it('should call invokeTool', async () => { - const remoteTools = new RemoteTools(apiKeys); - remoteTools.invokeTool = jest.fn().mockResolvedValue('response'); + it('should invoke the tool and return its response', async () => { + const tool = createMockTool(); + tool.base.invoke = jest.fn().mockResolvedValue('response'); + const remoteTools = new RemoteTools([tool]); - const response = await remoteTools.invokeTool('tool-name', []); + const response = await remoteTools.invokeTool(tool.sanitizedName, []); expect(response).toEqual('response'); + expect(tool.base.invoke).toHaveBeenCalledWith([]); }); - describe('when the tool is not found', () => { - it('should throw an error', async () => { - const remoteTools = new RemoteTools(apiKeys); + it('should throw when the tool is not found', async () => { + const remoteTools = new RemoteTools(); - await expect(() => remoteTools.invokeTool('not-found', [])).rejects.toThrow( - 'Tool not-found not found', - ); - }); + await expect(() => remoteTools.invokeTool('not-found', [])).rejects.toThrow( + 'Tool not-found not found', + ); }); - describe('when the tool throws an error', () => { - it('should throw an error', async () => { - const remoteTools = new RemoteTools(apiKeys); - remoteTools.tools[0].base.invoke = jest.fn().mockRejectedValue(new Error('error')); + it('should wrap tool errors with tool name', async () => { + const tool = createMockTool(); + tool.base.invoke = jest.fn().mockRejectedValue(new Error('error')); + const remoteTools = new RemoteTools([tool]); - await expect(() => - remoteTools.invokeTool(remoteTools.tools[0].base.name, []), - ).rejects.toThrow(`Error while calling tool ${remoteTools.tools[0].base.name}: error`); - }); + await expect(() => remoteTools.invokeTool(tool.sanitizedName, [])).rejects.toThrow( + `Error while calling tool ${tool.base.name}: error`, + ); }); - describe('when the tool name is sanitized', () => { - it('should find the right tool to invoke', async () => { - const remoteTools = new RemoteTools(apiKeys); - - remoteTools.tools[0].base.name = 'brave search'; - remoteTools.tools[0].base.invoke = jest.fn().mockResolvedValue('response'); + it('should find tool by sanitized name', async () => { + const tool = createMockTool('brave search'); + tool.base.invoke = jest.fn().mockResolvedValue('response'); + const remoteTools = new RemoteTools([tool]); - await remoteTools.invokeTool(remoteTools.tools[0].sanitizedName, []); + await remoteTools.invokeTool(tool.sanitizedName, []); - expect(remoteTools.tools[0].base.invoke).toHaveBeenCalledWith([]); - }); + expect(tool.base.invoke).toHaveBeenCalledWith([]); }); }); }); diff --git a/packages/ai-proxy/test/router.test.ts b/packages/ai-proxy/test/router.test.ts index 0a6d255876..3d8c7fe9ee 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 0000000000..db20bdd4ad --- /dev/null +++ b/packages/ai-proxy/test/tool-provider-factory.test.ts @@ -0,0 +1,96 @@ +import IntegrationClient from '../src/integration-client'; +import McpClient from '../src/mcp-client'; +import { createToolProviders } from '../src/tool-provider-factory'; + +jest.mock('../src/mcp-client', () => { + return jest.fn().mockImplementation(() => ({ + loadTools: jest.fn(), + checkConnection: jest.fn(), + dispose: jest.fn(), + })); +}); + +jest.mock('../src/integration-client', () => { + return { + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + loadTools: jest.fn(), + checkConnection: jest.fn(), + dispose: jest.fn(), + })), + }; +}); + +describe('createToolProviders', () => { + beforeEach(() => jest.clearAllMocks()); + + it('should create McpClient for MCP configs', () => { + const configs = { + slack: { command: 'npx', args: ['-y', '@modelcontextprotocol/server-slack'] }, + }; + + const providers = createToolProviders(configs as any); + + expect(providers).toHaveLength(1); + expect(McpClient).toHaveBeenCalledWith( + { configs: { slack: configs.slack } }, + undefined, + ); + }); + + it('should create IntegrationClient for ForestIntegration configs', () => { + const zendeskConfig = { + isForestConnector: true as const, + integrationName: 'Zendesk' as const, + config: { subdomain: 'test', email: 'a@b.com', apiToken: 'tok' }, + }; + + const providers = createToolProviders({ zendesk: zendeskConfig }); + + expect(providers).toHaveLength(1); + expect(IntegrationClient).toHaveBeenCalledWith([zendeskConfig], undefined); + }); + + it('should split mixed configs into MCP and integration providers', () => { + const configs = { + slack: { command: 'npx', args: [] }, + zendesk: { + isForestConnector: true as const, + integrationName: 'Zendesk' as const, + config: { subdomain: 'test', email: 'a@b.com', apiToken: 'tok' }, + }, + }; + + const providers = createToolProviders(configs as any); + + expect(providers).toHaveLength(2); + expect(McpClient).toHaveBeenCalledWith( + { configs: { slack: configs.slack } }, + undefined, + ); + expect(IntegrationClient).toHaveBeenCalledWith([configs.zendesk], undefined); + }); + + it('should return empty array when no configs', () => { + const providers = createToolProviders({}); + + expect(providers).toHaveLength(0); + }); + + it('should pass logger to both clients', () => { + const logger = jest.fn(); + const configs = { + slack: { command: 'npx', args: [] }, + zendesk: { + isForestConnector: true as const, + integrationName: 'Zendesk' as const, + config: { subdomain: 'test', email: 'a@b.com', apiToken: 'tok' }, + }, + }; + + createToolProviders(configs as any, logger); + + expect(McpClient).toHaveBeenCalledWith(expect.anything(), logger); + expect(IntegrationClient).toHaveBeenCalledWith(expect.anything(), logger); + }); +}); 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 0000000000..54e2ba8685 --- /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/yarn.lock b/yarn.lock index 023ddb64f2..9f66ce41aa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8,12 +8,12 @@ integrity sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA== "@actions/core@^2.0.0": - version "2.0.2" - resolved "https://registry.yarnpkg.com/@actions/core/-/core-2.0.2.tgz#81c59e1f3437660d2148a064c1ba8e99931f2cf7" - integrity sha512-Ast1V7yHbGAhplAsuVlnb/5J8Mtr/Zl6byPPL+Qjq3lmfIgWF1ak1iYfF/079cRERiuTALTXkSuEUdZeDCfGtA== + version "2.0.1" + resolved "https://registry.yarnpkg.com/@actions/core/-/core-2.0.1.tgz#fc4961acb04f6253bcdf83ad356e013ba29fc218" + integrity sha512-oBfqT3GwkvLlo1fjvhQLQxuwZCGTarTE5OuZ2Wg10hvhBj7LRIlF611WT4aZS6fDhO5ZKlY7lCAZTlpmyaHaeg== dependencies: "@actions/exec" "^2.0.0" - "@actions/http-client" "^3.0.1" + "@actions/http-client" "^3.0.0" "@actions/exec@^2.0.0": version "2.0.0" @@ -22,10 +22,10 @@ dependencies: "@actions/io" "^2.0.0" -"@actions/http-client@^3.0.1": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@actions/http-client/-/http-client-3.0.1.tgz#0ac91c3abf179a401e23d40abf0d7caa92324268" - integrity sha512-SbGS8c/vySbNO3kjFgSW77n83C4MQx/Yoe+b1hAdpuvfHxnkHzDq2pWljUpAA56Si1Gae/7zjeZsV0CYjmLo/w== +"@actions/http-client@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@actions/http-client/-/http-client-3.0.0.tgz#6c6058bef29c0580d6683a08c5bf0362c90c2e6e" + integrity sha512-1s3tXAfVMSz9a4ZEBkXXRQD4QhY3+GAsWSbaYpeknPOKEeyRiU3lH+bHiLMZdo2x/fIeQ/hscL1wCkDLVM2DZQ== dependencies: tunnel "^0.0.6" undici "^5.28.5" @@ -919,16 +919,7 @@ "@babel/highlight" "^7.22.13" chalk "^2.4.2" -"@babel/code-frame@^7.26.2", "@babel/code-frame@^7.28.6": - version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.28.6.tgz#72499312ec58b1e2245ba4a4f550c132be4982f7" - integrity sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q== - dependencies: - "@babel/helper-validator-identifier" "^7.28.5" - js-tokens "^4.0.0" - picocolors "^1.1.1" - -"@babel/code-frame@^7.27.1": +"@babel/code-frame@^7.26.2", "@babel/code-frame@^7.27.1": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.27.1.tgz#200f715e66d52a23b221a9435534a91cc13ad5be" integrity sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg== @@ -1131,11 +1122,6 @@ resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz#a7054dcc145a967dd4dc8fee845a57c1316c9df8" integrity sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow== -"@babel/helper-validator-identifier@^7.28.5": - version "7.28.5" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4" - integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q== - "@babel/helper-validator-option@^7.22.15": version "7.22.15" resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.22.15.tgz#694c30dfa1d09a6534cdfcafbe56789d36aba040" @@ -1175,13 +1161,6 @@ dependencies: "@babel/types" "^7.28.4" -"@babel/parser@^7.28.6": - version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.28.6.tgz#f01a8885b7fa1e56dd8a155130226cd698ef13fd" - integrity sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ== - dependencies: - "@babel/types" "^7.28.6" - "@babel/plugin-syntax-async-generators@^7.8.4": version "7.8.4" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d" @@ -1303,15 +1282,6 @@ "@babel/parser" "^7.27.2" "@babel/types" "^7.27.1" -"@babel/template@^7.28.6": - version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.28.6.tgz#0e7e56ecedb78aeef66ce7972b082fce76a23e57" - integrity sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ== - dependencies: - "@babel/code-frame" "^7.28.6" - "@babel/parser" "^7.28.6" - "@babel/types" "^7.28.6" - "@babel/traverse@^7.23.3": version "7.23.3" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.23.3.tgz#26ee5f252e725aa7aca3474aa5b324eaf7908b5b" @@ -1358,14 +1328,6 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.27.1" -"@babel/types@^7.28.6": - version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.6.tgz#c3e9377f1b155005bcc4c46020e7e394e13089df" - integrity sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg== - dependencies: - "@babel/helper-string-parser" "^7.27.1" - "@babel/helper-validator-identifier" "^7.28.5" - "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" @@ -1778,11 +1740,6 @@ dependencies: "@hapi/hoek" "^9.0.0" -"@hono/node-server@^1.19.9": - version "1.19.9" - resolved "https://registry.yarnpkg.com/@hono/node-server/-/node-server-1.19.9.tgz#8f37119b1acf283fd3f6035f3d1356fdb97a09ac" - integrity sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw== - "@humanwhocodes/config-array@^0.11.13": version "0.11.13" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.13.tgz#075dc9684f40a531d9b26b0822153c1e832ee297" @@ -2314,29 +2271,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": @@ -2368,6 +2327,15 @@ openai "^6.18.0" zod "^3.25.76 || ^4" +"@langchain/openai@>=0.1.0 <0.7.0": + version "0.6.17" + resolved "https://registry.yarnpkg.com/@langchain/openai/-/openai-0.6.17.tgz#6e87064316d6834b7cfb88a2df2ea7ee2a4b0325" + integrity sha512-JVSzD+FL5v/2UQxKd+ikB1h4PQOtn0VlK8nqW2kPp0fshItCv4utrjBKXC/rubBnSXoRTyonBINe8QRZ6OojVQ== + dependencies: + js-tiktoken "^1.0.12" + openai "5.12.2" + zod "^3.25.32" + "@langchain/textsplitters@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@langchain/textsplitters/-/textsplitters-1.0.1.tgz#292f9c93239178c248b3338acf7b68aa47aa9830" @@ -2375,6 +2343,13 @@ dependencies: js-tiktoken "^1.0.12" +"@langchain/textsplitters@>=0.0.0 <0.2.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@langchain/textsplitters/-/textsplitters-0.1.0.tgz#f37620992192df09ecda3dfbd545b36a6bcbae46" + integrity sha512-djI4uw9rlkAb5iMhtLED+xJebDdAG935AdP4eRTB02R7OB/act55Bj9wsskhZsvuyQRpO4O1wQOp85s6T6GWmw== + dependencies: + js-tiktoken "^1.0.12" + "@lerna/create@8.2.3": version "8.2.3" resolved "https://registry.yarnpkg.com/@lerna/create/-/create-8.2.3.tgz#8e88fedb60eb699f2f5057e7344d9f980b7f9554" @@ -2502,9 +2477,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" @@ -3567,10 +3542,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" @@ -3594,16 +3569,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": @@ -3614,13 +3589,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" @@ -3631,13 +3606,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": @@ -4169,6 +4144,11 @@ dependencies: tslib "^2.6.2" +"@standard-schema/spec@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8" + integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== + "@tokenizer/token@^0.3.0": version "0.3.0" resolved "https://registry.yarnpkg.com/@tokenizer/token/-/token-0.3.0.tgz#fe98a93fe789247e998c75e74e9c7c63217aa276" @@ -4217,13 +4197,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" @@ -4465,7 +4445,7 @@ resolved "https://registry.yarnpkg.com/@types/json-api-serializer/-/json-api-serializer-2.6.6.tgz#26b5381214aa19bb98a6931fe41c3a336fc7f169" integrity sha512-8XVIVyMNoFMz3pfR3tPHnJ9YlgUQDEWvTxajVakmOjSxWekJvmi2GRFbtaREQiOGtffnHImD0jbR80NQtpib9g== -"@types/json-schema@*", "@types/json-schema@^7.0.9": +"@types/json-schema@*", "@types/json-schema@^7.0.15", "@types/json-schema@^7.0.9": version "7.0.15" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== @@ -4647,6 +4627,11 @@ "@types/node" "*" safe-buffer "~5.1.1" +"@types/retry@0.12.0": + version "0.12.0" + resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" + integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA== + "@types/semver@^7.3.12": version "7.5.5" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.5.tgz#deed5ab7019756c9c90ea86139106b0346223f35" @@ -5704,24 +5689,6 @@ body-parser@^2.2.0, body-parser@^2.2.1: raw-body "^3.0.1" type-is "^2.0.1" -body-parser@~1.20.3: - version "1.20.4" - resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.4.tgz#f8e20f4d06ca8a50a71ed329c15dccad1cdc547f" - integrity sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA== - dependencies: - bytes "~3.1.2" - content-type "~1.0.5" - debug "2.6.9" - depd "2.0.0" - destroy "~1.2.0" - http-errors "~2.0.1" - iconv-lite "~0.4.24" - on-finished "~2.4.1" - qs "~6.14.0" - raw-body "~2.5.3" - type-is "~1.6.18" - unpipe "~1.0.0" - boolbase@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" @@ -6539,7 +6506,7 @@ console-table-printer@^2.12.1: dependencies: simple-wcswidth "^1.1.2" -content-disposition@0.5.4, content-disposition@^0.5.3, content-disposition@~0.5.2, content-disposition@~0.5.4: +content-disposition@0.5.4, content-disposition@^0.5.3, content-disposition@~0.5.2: version "0.5.4" resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== @@ -6692,11 +6659,6 @@ cookie-signature@^1.2.1: resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.2.2.tgz#57c7fc3cc293acab9fec54d73e15690ebe4a1793" integrity sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg== -cookie-signature@~1.0.6: - version "1.0.7" - resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.7.tgz#ab5dd7ab757c54e60f37ef6550f481c426d10454" - integrity sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA== - cookie@0.7.1: version "0.7.1" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.1.tgz#2f73c42142d5d5cf71310a74fc4ae61670e5dbc9" @@ -6712,7 +6674,7 @@ cookie@^0.5.0: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== -cookie@^0.7.0, cookie@^0.7.1, cookie@~0.7.1: +cookie@^0.7.0, cookie@^0.7.1: version "0.7.2" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== @@ -6925,7 +6887,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== @@ -7065,7 +7027,7 @@ deprecation@^2.0.0: resolved "https://registry.yarnpkg.com/deprecation/-/deprecation-2.3.1.tgz#6368cbdb40abf3373b525ac87e4a260c3a700919" integrity sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ== -destroy@1.2.0, destroy@^1.0.4, destroy@^1.2.0, destroy@~1.2.0: +destroy@1.2.0, destroy@^1.0.4, destroy@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== @@ -7114,9 +7076,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" @@ -7995,6 +7957,11 @@ exponential-backoff@^3.1.1: resolved "https://registry.yarnpkg.com/exponential-backoff/-/exponential-backoff-3.1.1.tgz#64ac7526fe341ab18a39016cd22c787d01e00bf6" integrity sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw== +expr-eval@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/expr-eval/-/expr-eval-2.0.2.tgz#fa6f044a7b0c93fde830954eb9c5b0f7fbc7e201" + integrity sha512-4EMSHGOPSwAfBiibw3ndnP0AvjDWLsMvGOvWEZ2F96IGk0bIVdjQisOHxReSkE13mHcfbuCiXw+G4y0zv6N8Eg== + express-rate-limit@^8.2.1: version "8.2.1" resolved "https://registry.yarnpkg.com/express-rate-limit/-/express-rate-limit-8.2.1.tgz#ec75fdfe280ecddd762b8da8784c61bae47d7f7f" @@ -8536,19 +8503,6 @@ finalhandler@^2.1.0: parseurl "^1.3.3" statuses "^2.0.1" -finalhandler@~1.3.1: - version "1.3.2" - resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.3.2.tgz#1ebc2228fc7673aac4a472c310cc05b77d852b88" - integrity sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg== - dependencies: - debug "2.6.9" - encodeurl "~2.0.0" - escape-html "~1.0.3" - on-finished "~2.4.1" - parseurl "~1.3.3" - statuses "~2.0.2" - unpipe "~1.0.0" - find-my-way@^2.2.2: version "2.2.5" resolved "https://registry.yarnpkg.com/find-my-way/-/find-my-way-2.2.5.tgz#86ce825266fa28cd962e538a45ec2aaa84c3d514" @@ -9480,7 +9434,7 @@ http-errors@^1.6.3, http-errors@~1.8.0: statuses ">= 1.5.0 < 2" toidentifier "1.0.1" -http-errors@^2.0.1, http-errors@~2.0.0, http-errors@~2.0.1: +http-errors@^2.0.1, http-errors@~2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.1.tgz#36d2f65bc909c8790018dd36fb4d93da6caae06b" integrity sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ== @@ -9578,7 +9532,7 @@ hyperlinker@^1.0.0: resolved "https://registry.yarnpkg.com/hyperlinker/-/hyperlinker-1.0.0.tgz#23dc9e38a206b208ee49bc2d6c8ef47027df0c0e" integrity sha512-Ty8UblRWFEcfSuIaajM34LdPXIhbs1ajEX/BBPv24J+enSVaEVY63xQ6lTO9VRYS5LAoghIG0IDJ+p+IPzKUQQ== -iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@~0.4.24: +iconv-lite@0.4.24, iconv-lite@^0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== @@ -10120,9 +10074,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" @@ -11285,10 +11239,39 @@ koa@^3.0.1: type-is "^2.0.1" vary "^1.1.2" +"langchain@>=0.2.3 <0.3.0 || >=0.3.4 <0.4.0": + version "0.3.36" + resolved "https://registry.yarnpkg.com/langchain/-/langchain-0.3.36.tgz#6ab7f4028adae16bf74538aa2127df9fe2fddf02" + integrity sha512-PqC19KChFF0QlTtYDFgfEbIg+SCnCXox29G8tY62QWfj9bOW7ew2kgWmPw5qoHLOTKOdQPvXET20/1Pdq8vAtQ== + dependencies: + "@langchain/openai" ">=0.1.0 <0.7.0" + "@langchain/textsplitters" ">=0.0.0 <0.2.0" + js-tiktoken "^1.0.12" + js-yaml "^4.1.0" + jsonpointer "^5.0.1" + langsmith "^0.3.67" + openapi-types "^12.1.3" + p-retry "4" + uuid "^10.0.0" + yaml "^2.2.1" + zod "^3.25.32" + "langsmith@>=0.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 "^5.6.2" + console-table-printer "^2.12.1" + p-queue "^6.6.2" + semver "^7.6.3" + uuid "^10.0.0" + +langsmith@^0.3.67: + version "0.3.85" + resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.3.85.tgz#5b44287b140c8db1063462ca6243fd1bb3272f31" + integrity sha512-Txuaxnpcra57qld4+hkqHhd9L2D6G6kEAReWXdr/sddxPu6ycBHXStqDciituC642lJmzPdarrYtll2vSwMbnQ== dependencies: "@types/uuid" "^10.0.0" chalk "^4.1.2" @@ -11937,7 +11920,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== @@ -12068,11 +12051,6 @@ marked@^4.1.0: resolved "https://registry.yarnpkg.com/marked/-/marked-4.3.0.tgz#796362821b019f734054582038b116481b456cf3" integrity sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A== -math-expression-evaluator@^2.0.0: - version "2.0.7" - resolved "https://registry.yarnpkg.com/math-expression-evaluator/-/math-expression-evaluator-2.0.7.tgz#dc99a80ce2bf7f9b7df878126feb5c506c1fdf5f" - integrity sha512-uwliJZ6BPHRq4eiqNWxZBDzKUiS5RIynFFcgchqhBOloVLVBpZpNG8jRYkedLcBvhph8TnRyWEuxPqiQcwIdog== - math-intrinsics@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" @@ -13510,7 +13488,7 @@ on-exit-leak-free@^2.1.0: resolved "https://registry.yarnpkg.com/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz#fed195c9ebddb7d9e4c3842f93f281ac8dadd3b8" integrity sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA== -on-finished@2.4.1, on-finished@^2.3.0, on-finished@^2.4.1, on-finished@~2.4.1: +on-finished@2.4.1, on-finished@^2.3.0, on-finished@^2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== @@ -13567,6 +13545,11 @@ open@^8.0.0, open@^8.4.0: is-docker "^2.1.1" is-wsl "^2.2.0" +openai@5.12.2: + version "5.12.2" + resolved "https://registry.yarnpkg.com/openai/-/openai-5.12.2.tgz#512ab6b80eb8414837436e208f1b951442b97761" + integrity sha512-xqzHHQch5Tws5PcKR2xsZGX9xtch+JQFz5zb14dGqlshmmDAFBFEWmeIpf7wVqWV+w7Emj7jRgkNJakyKE0tYQ== + openai@^6.18.0: version "6.18.0" resolved "https://registry.yarnpkg.com/openai/-/openai-6.18.0.tgz#bd6c0bdb1aebf93375d324de51756280f7e85c6f" @@ -13804,6 +13787,14 @@ p-reduce@^3.0.0: resolved "https://registry.yarnpkg.com/p-reduce/-/p-reduce-3.0.0.tgz#f11773794792974bd1f7a14c72934248abff4160" integrity sha512-xsrIUgI0Kn6iyDYm9StOpOeK29XM1aboGji26+QEortiFST1hGZaUQOLhtEbqHErPpGW/aSz6allwK2qcptp0Q== +p-retry@4: + version "4.6.2" + resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-4.6.2.tgz#9baae7184057edd4e17231cee04264106e092a16" + integrity sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ== + dependencies: + "@types/retry" "0.12.0" + retry "^0.13.1" + p-retry@^7.1.1: version "7.1.1" resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-7.1.1.tgz#7470fdecb1152ba50f1334e48378c9e401330e24" @@ -14080,7 +14071,7 @@ path-scurry@^2.0.0: lru-cache "^11.0.0" minipass "^7.1.2" -path-to-regexp@0.1.12, path-to-regexp@~0.1.12: +path-to-regexp@0.1.12: version "0.1.12" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.12.tgz#d5e1a12e478a976d432ef3c58d534b9923164bb7" integrity sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ== @@ -14469,6 +14460,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" @@ -14630,7 +14626,7 @@ qrcode-terminal@^0.12.0: resolved "https://registry.yarnpkg.com/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz#bb5b699ef7f9f0505092a3748be4464fe71b5819" integrity sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ== -qs@6.13.0, qs@>=6.14.1, qs@^6.11.2, qs@^6.14.0, qs@^6.14.1, qs@^6.5.2, qs@~6.14.0: +qs@6.13.0, qs@>=6.14.1, qs@^6.11.2, qs@^6.14.0, qs@^6.14.1, qs@^6.5.2: version "6.14.2" resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.2.tgz#b5634cf9d9ad9898e31fba3504e866e8efb6798c" integrity sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q== @@ -14697,16 +14693,6 @@ raw-body@^3.0.0, raw-body@^3.0.1: iconv-lite "~0.7.0" unpipe "~1.0.0" -raw-body@~2.5.3: - version "2.5.3" - resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.3.tgz#11c6650ee770a7de1b494f197927de0c923822e2" - integrity sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA== - dependencies: - bytes "~3.1.2" - http-errors "~2.0.1" - iconv-lite "~0.4.24" - unpipe "~1.0.0" - rc@^1.2.7, rc@^1.2.8: version "1.2.8" resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" @@ -15072,6 +15058,11 @@ retry@^0.12.0: resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" integrity sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow== +retry@^0.13.1: + version "0.13.1" + resolved "https://registry.yarnpkg.com/retry/-/retry-0.13.1.tgz#185b1587acf67919d63b357349e03537b2484658" + integrity sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg== + reusify@^1.0.2, reusify@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" @@ -15356,25 +15347,6 @@ send@^1.1.0, send@^1.2.0: range-parser "^1.2.1" statuses "^2.0.2" -send@~0.19.0, send@~0.19.1: - version "0.19.2" - resolved "https://registry.yarnpkg.com/send/-/send-0.19.2.tgz#59bc0da1b4ea7ad42736fd642b1c4294e114ff29" - integrity sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg== - dependencies: - debug "2.6.9" - depd "2.0.0" - destroy "1.2.0" - encodeurl "~2.0.0" - escape-html "~1.0.3" - etag "~1.8.1" - fresh "~0.5.2" - http-errors "~2.0.1" - mime "1.6.0" - ms "2.1.3" - on-finished "~2.4.1" - range-parser "~1.2.1" - statuses "~2.0.2" - seq-queue@^0.0.5: version "0.0.5" resolved "https://registry.yarnpkg.com/seq-queue/-/seq-queue-0.0.5.tgz#d56812e1c017a6e4e7c3e3a37a1da6d78dd3c93e" @@ -15449,16 +15421,6 @@ serve-static@^2.2.0: parseurl "^1.3.3" send "^1.2.0" -serve-static@~1.16.2: - version "1.16.3" - resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.16.3.tgz#a97b74d955778583f3862a4f0b841eb4d5d78cf9" - integrity sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA== - dependencies: - encodeurl "~2.0.0" - escape-html "~1.0.3" - parseurl "~1.3.3" - send "~0.19.1" - set-blocking@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" @@ -15663,16 +15625,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" @@ -16011,7 +15973,7 @@ statuses@2.0.1: resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== -statuses@^2.0.1, statuses@^2.0.2, statuses@~2.0.1, statuses@~2.0.2: +statuses@^2.0.1, statuses@^2.0.2, statuses@~2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382" integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== @@ -16775,14 +16737,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" @@ -16871,9 +16833,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" @@ -17094,9 +17056,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" @@ -17345,9 +17307,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" @@ -17830,6 +17792,11 @@ zod-to-json-schema@^3.25.1: resolved "https://registry.yarnpkg.com/zod/-/zod-4.3.5.tgz#aeb269a6f9fc259b1212c348c7c5432aaa474d2a" integrity sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g== +zod@^3.25.32: + version "3.25.76" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34" + integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ== + zwitch@^1.0.0: version "1.0.5" resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-1.0.5.tgz#d11d7381ffed16b742f6af7b3f223d5cd9fe9920"