Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions packages/ai-proxy/src/create-ai-provider.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import type { McpConfiguration } from './mcp-client';
import type { AiConfiguration } from './provider';
import type { RouterRouteArgs } from './schemas/route';
import type { ToolSourceConfig } from './tool-provider-factory';
import type { AiProviderDefinition, AiRouter } from '@forestadmin/agent-toolkit';

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

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

return injectOauthTokens({
mcpConfigs: args.mcpServerConfigs as McpConfiguration | undefined,
configs: args.mcpServerConfigs as Record<string, ToolSourceConfig> | undefined,
tokensByMcpServerName,
});
}
Expand All @@ -32,7 +34,7 @@ export function createAiProvider(config: AiConfiguration): AiProviderDefinition
route: args.route,
body: args.body,
query: args.query,
mcpConfigs: resolveMcpConfigs(args),
mcpServerConfigs: resolveMcpConfigs(args),
} as RouterRouteArgs),
};
},
Expand Down
18 changes: 14 additions & 4 deletions packages/ai-proxy/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
import type { McpConfiguration } from './mcp-client';
import type { ToolSourceConfig } from './tool-provider-factory';
import type { Logger } from '@forestadmin/datasource-toolkit';

import McpConfigChecker from './mcp-config-checker';
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<string, ToolSourceConfig>,
logger?: Logger,
) {
return ToolSourceChecker.check(configs, logger);
}
60 changes: 60 additions & 0 deletions packages/ai-proxy/src/integration-client.ts
Original file line number Diff line number Diff line change
@@ -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<RemoteTool[]> {
const tools: RemoteTool[] = [];

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

return tools;
}

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

return true;
}

async dispose(): Promise<void> {
// No-op: integrations don't hold persistent connections
}
}
25 changes: 25 additions & 0 deletions packages/ai-proxy/src/integrations/brave/brave-tool-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type RemoteTool from '../../remote-tool';
import type { ToolProvider } from '../../tool-provider';

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

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

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

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

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

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

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

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

export interface BraveConfig {
apiKey: string;
}

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

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

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

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

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

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

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

return JSON.stringify(await response.json());
},
});
}
67 changes: 67 additions & 0 deletions packages/ai-proxy/src/integrations/zendesk/tools/create-ticket.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { DynamicStructuredTool } from '@langchain/core/tools';
import { z } from 'zod';

export default function createCreateTicketTool(
headers: Record<string, string>,
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<string, unknown> = {
ticket: {
subject,
comment: { body: description },
...(requester_id && { requester_id }),
...(assignee_id && { assignee_id }),
...(priority && { priority }),
...(type && { type }),
...(tags && { tags }),
...(custom_fields && { custom_fields }),
},
};

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

return JSON.stringify(await response.json());
},
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { DynamicStructuredTool } from '@langchain/core/tools';
import { z } from 'zod';

export default function createGetTicketCommentsTool(
headers: Record<string, string>,
baseUrl: string,
): DynamicStructuredTool {
return new DynamicStructuredTool({
name: 'zendesk_get_ticket_comments',
description: 'Get all comments for a specific Zendesk ticket',
schema: z.object({
ticket_id: z.number().int().positive().describe('The ID of the ticket to get comments for'),
}),
func: async ({ ticket_id }) => {
const response = await fetch(`${baseUrl}/tickets/${ticket_id}/comments.json`, {
headers,
});

return JSON.stringify(await response.json());
},
});
}
22 changes: 22 additions & 0 deletions packages/ai-proxy/src/integrations/zendesk/tools/get-ticket.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { DynamicStructuredTool } from '@langchain/core/tools';
import { z } from 'zod';

export default function createGetTicketTool(
headers: Record<string, string>,
baseUrl: string,
): DynamicStructuredTool {
return new DynamicStructuredTool({
name: 'zendesk_get_ticket',
description: 'Retrieve a single Zendesk ticket by its ID',
schema: z.object({
ticket_id: z.number().int().positive().describe('The ID of the ticket to retrieve'),
}),
func: async ({ ticket_id }) => {
const response = await fetch(`${baseUrl}/tickets/${ticket_id}.json`, {
headers,
});

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