diff --git a/packages/ai-proxy/package.json b/packages/ai-proxy/package.json index 0457c0fc3c..f490f0e564 100644 --- a/packages/ai-proxy/package.json +++ b/packages/ai-proxy/package.json @@ -12,17 +12,18 @@ "directory": "packages/ai-proxy" }, "dependencies": { - "openai": "4.95.0", "@forestadmin/datasource-toolkit": "1.50.1", "@langchain/community": "0.3.57", "@langchain/core": "1.1.4", "@langchain/langgraph": "1.0.4", - "@langchain/mcp-adapters": "1.0.3" + "@langchain/mcp-adapters": "1.0.3", + "openai": "4.95.0", + "zod": "^4.3.5" }, "devDependencies": { "@modelcontextprotocol/sdk": "1.20.0", - "express": "5.1.0", "@types/express": "5.0.1", + "express": "5.1.0", "node-zendesk": "6.0.1" }, "files": [ diff --git a/packages/ai-proxy/src/examples/run-mcp-gmail-server.ts b/packages/ai-proxy/src/examples/run-mcp-gmail-server.ts new file mode 100644 index 0000000000..90d7755273 --- /dev/null +++ b/packages/ai-proxy/src/examples/run-mcp-gmail-server.ts @@ -0,0 +1,76 @@ +/* eslint-disable import/no-extraneous-dependencies, import/extensions */ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +/* eslint-enable import/no-extraneous-dependencies, import/extensions */ + +import runMcpServer from './simple-mcp-server'; + +const server = new McpServer({ + name: 'gmail', + version: '1.0.0', +}); + +// Gmail API configuration +// To use this, you'll need to: +// 1. Enable Gmail API in Google Cloud Console +// 2. Create OAuth 2.0 credentials +// 3. Get an access token +const GMAIL_ACCESS_TOKEN = 'YOUR_GMAIL_ACCESS_TOKEN'; + +const headers = { + Authorization: `Bearer ${GMAIL_ACCESS_TOKEN}`, + 'Content-Type': 'application/json', +}; + +server.tool('gmail_search', { query: 'string', maxResults: 'number' }, async params => { + const url = new URL('https://gmail.googleapis.com/gmail/v1/users/me/messages'); + url.searchParams.set('q', params.query); + url.searchParams.set('maxResults', (params.maxResults || 10).toString()); + + const response = await fetch(url.toString(), { headers }); + const data = await response.json(); + + return { content: [{ type: 'text', text: JSON.stringify(data) }] }; +}); + +server.tool( + 'gmail_send_message', + { to: 'array', subject: 'string', message: 'string' }, + async params => { + const emailLines = [ + `To: ${params.to.join(', ')}`, + `Subject: ${params.subject}`, + '', + params.message, + ]; + + const email = emailLines.join('\r\n'); + const encodedEmail = Buffer.from(email) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + + const response = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/messages/send', { + method: 'POST', + headers, + body: JSON.stringify({ raw: encodedEmail }), + }); + + const data = await response.json(); + + return { content: [{ type: 'text', text: JSON.stringify(data) }] }; + }, +); + +server.tool('gmail_get_message', { messageId: 'string' }, async params => { + const response = await fetch( + `https://gmail.googleapis.com/gmail/v1/users/me/messages/${params.messageId}?format=full`, + { headers }, + ); + + const data = await response.json(); + + return { content: [{ type: 'text', text: JSON.stringify(data) }] }; +}); + +runMcpServer(server, 3125); diff --git a/packages/ai-proxy/src/integrations/brave/tools.ts b/packages/ai-proxy/src/integrations/brave/tools.ts new file mode 100644 index 0000000000..f04fa62a16 --- /dev/null +++ b/packages/ai-proxy/src/integrations/brave/tools.ts @@ -0,0 +1,17 @@ +import { BraveSearch } from '@langchain/community/tools/brave_search'; + +import RemoteTool from '../../remote-tool'; + +export interface BraveConfig { + apiKey: string; +} + +export default function getBraveTools(config: BraveConfig): RemoteTool[] { + return [ + new RemoteTool({ + sourceId: 'brave_search', + sourceType: 'server', + tool: new BraveSearch({ apiKey: config.apiKey }), + }), + ]; +} diff --git a/packages/ai-proxy/src/integrations/gmail/tools.ts b/packages/ai-proxy/src/integrations/gmail/tools.ts new file mode 100644 index 0000000000..996c186ca6 --- /dev/null +++ b/packages/ai-proxy/src/integrations/gmail/tools.ts @@ -0,0 +1,32 @@ +import RemoteTool from '../../remote-tool'; +import createDraftTool from './tools/create-draft'; +import createGetMessageTool from './tools/get-message'; +import createGetThreadTool from './tools/get-thread'; +import createSearchTool from './tools/search'; +import createSendMessageTool from './tools/send-message'; + +export interface GmailConfig { + accessToken: string; +} + +export default function getGmailTools(config: GmailConfig): RemoteTool[] { + const headers = { + Authorization: `Bearer ${config.accessToken}`, + 'Content-Type': 'application/json', + }; + + return [ + createSendMessageTool(headers), + createGetMessageTool(headers), + createSearchTool(headers), + createDraftTool(headers), + createGetThreadTool(headers), + ].map( + tool => + new RemoteTool({ + sourceId: 'gmail', + sourceType: 'server', + tool, + }), + ); +} diff --git a/packages/ai-proxy/src/integrations/gmail/tools/create-draft.ts b/packages/ai-proxy/src/integrations/gmail/tools/create-draft.ts new file mode 100644 index 0000000000..6a1282a7c9 --- /dev/null +++ b/packages/ai-proxy/src/integrations/gmail/tools/create-draft.ts @@ -0,0 +1,33 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +import { encodeEmail } from './utils'; + +export default function createDraftTool(headers: Record): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: 'gmail_create_draft', + description: 'Create a draft email in Gmail', + schema: z.object({ + to: z.array(z.string()).describe('Array of recipient email addresses'), + subject: z.string().describe('Email subject line'), + message: z.string().describe('Email body content'), + cc: z.array(z.string()).optional().describe('Array of CC email addresses'), + bcc: z.array(z.string()).optional().describe('Array of BCC email addresses'), + }), + func: async ({ to, subject, message, cc, bcc }) => { + const encodedEmail = encodeEmail({ to, subject, message, cc, bcc }); + + const response = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/drafts', { + method: 'POST', + headers, + body: JSON.stringify({ + message: { + raw: encodedEmail, + }, + }), + }); + + return JSON.stringify(await response.json()); + }, + }); +} diff --git a/packages/ai-proxy/src/integrations/gmail/tools/get-message.ts b/packages/ai-proxy/src/integrations/gmail/tools/get-message.ts new file mode 100644 index 0000000000..c0c79500ce --- /dev/null +++ b/packages/ai-proxy/src/integrations/gmail/tools/get-message.ts @@ -0,0 +1,43 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +import { getBody, getHeader } from './utils'; + +export default function createGetMessageTool( + headers: Record, +): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: 'gmail_get_message', + description: 'Get a specific email message by ID from Gmail', + schema: z.object({ + message_id: z.string().describe('The ID of the message to retrieve'), + }), + func: async ({ message_id }) => { + const response = await fetch( + `https://gmail.googleapis.com/gmail/v1/users/me/messages/${message_id}?format=full`, + { + method: 'GET', + headers, + }, + ); + + const data = await response.json(); + + if (!data.payload) { + return JSON.stringify({ error: 'Message not found or invalid payload' }); + } + + const result = { + id: data.id, + threadId: data.threadId, + subject: getHeader(data.payload, 'Subject'), + from: getHeader(data.payload, 'From'), + to: getHeader(data.payload, 'To'), + date: getHeader(data.payload, 'Date'), + body: getBody(data.payload), + }; + + return JSON.stringify(result); + }, + }); +} diff --git a/packages/ai-proxy/src/integrations/gmail/tools/get-thread.ts b/packages/ai-proxy/src/integrations/gmail/tools/get-thread.ts new file mode 100644 index 0000000000..0ec93a3209 --- /dev/null +++ b/packages/ai-proxy/src/integrations/gmail/tools/get-thread.ts @@ -0,0 +1,48 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +import { getBody, getHeader } from './utils'; + +export default function createGetThreadTool( + headers: Record, +): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: 'gmail_get_thread', + description: 'Get a complete email thread (conversation) by thread ID from Gmail', + schema: z.object({ + thread_id: z.string().describe('The ID of the thread to retrieve'), + }), + func: async ({ thread_id }) => { + const response = await fetch( + `https://gmail.googleapis.com/gmail/v1/users/me/threads/${thread_id}?format=full`, + { + method: 'GET', + headers, + }, + ); + + const data = await response.json(); + + if (!data.messages) { + return JSON.stringify({ error: 'Thread not found or no messages' }); + } + + const messages = data.messages.map((msg: any) => { + return { + id: msg.id, + subject: getHeader(msg.payload, 'Subject'), + from: getHeader(msg.payload, 'From'), + to: getHeader(msg.payload, 'To'), + date: getHeader(msg.payload, 'Date'), + body: getBody(msg.payload), + }; + }); + + return JSON.stringify({ + threadId: data.id, + messageCount: messages.length, + messages, + }); + }, + }); +} diff --git a/packages/ai-proxy/src/integrations/gmail/tools/search.ts b/packages/ai-proxy/src/integrations/gmail/tools/search.ts new file mode 100644 index 0000000000..f99fb4e180 --- /dev/null +++ b/packages/ai-proxy/src/integrations/gmail/tools/search.ts @@ -0,0 +1,79 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +import { getBody, getHeader } from './utils'; + +export default function createSearchTool(headers: Record): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: 'gmail_search', + description: + 'Search for Gmail messages or threads using Gmail search query syntax. Returns message/thread IDs that can be used with other tools.', + schema: z.object({ + query: z + .string() + .describe( + 'Gmail search query (e.g., "from:user@example.com", "subject:meeting", "is:unread")', + ), + max_results: z + .number() + .optional() + .default(10) + .describe('Maximum number of results (default: 10)'), + resource: z + .enum(['messages', 'threads']) + .optional() + .default('messages') + .describe('Type of resource to search for (messages or threads)'), + }), + func: async ({ query, max_results = 10, resource = 'messages' }) => { + const url = new URL(`https://gmail.googleapis.com/gmail/v1/users/me/${resource}`); + url.searchParams.set('q', query); + url.searchParams.set('maxResults', max_results.toString()); + + const response = await fetch(url.toString(), { + method: 'GET', + headers, + }); + + const data = await response.json(); + + if (resource === 'messages') { + const messages = data.messages || []; + + if (messages.length === 0) { + return JSON.stringify({ results: [], count: 0 }); + } + + const detailedMessages = await Promise.all( + messages.map(async (msg: { id: string }) => { + const msgResponse = await fetch( + `https://gmail.googleapis.com/gmail/v1/users/me/messages/${msg.id}?format=full`, + { + method: 'GET', + headers, + }, + ); + const msgData = await msgResponse.json(); + + return { + id: msgData.id, + threadId: msgData.threadId, + subject: getHeader(msgData.payload, 'Subject'), + from: getHeader(msgData.payload, 'From'), + to: getHeader(msgData.payload, 'To'), + date: getHeader(msgData.payload, 'Date'), + snippet: msgData.snippet, + body: getBody(msgData.payload)?.substring(0, 500), + }; + }), + ); + + return JSON.stringify({ results: detailedMessages, count: detailedMessages.length }); + } + + const threads = data.threads || []; + + return JSON.stringify({ results: threads, count: threads.length }); + }, + }); +} diff --git a/packages/ai-proxy/src/integrations/gmail/tools/send-message.ts b/packages/ai-proxy/src/integrations/gmail/tools/send-message.ts new file mode 100644 index 0000000000..e6d72c049b --- /dev/null +++ b/packages/ai-proxy/src/integrations/gmail/tools/send-message.ts @@ -0,0 +1,33 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +import { encodeEmail } from './utils'; + +export default function createSendMessageTool( + headers: Record, +): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: 'gmail_send_message', + description: 'Send an email message via Gmail', + schema: z.object({ + to: z.array(z.string()).describe('Array of recipient email addresses'), + subject: z.string().describe('Email subject line'), + message: z.string().describe('Email body content'), + cc: z.array(z.string()).optional().describe('Array of CC email addresses'), + bcc: z.array(z.string()).optional().describe('Array of BCC email addresses'), + }), + func: async ({ to, subject, message, cc, bcc }) => { + const encodedEmail = encodeEmail({ to, subject, message, cc, bcc }); + + const response = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/messages/send', { + method: 'POST', + headers, + body: JSON.stringify({ + raw: encodedEmail, + }), + }); + + return JSON.stringify(await response.json()); + }, + }); +} diff --git a/packages/ai-proxy/src/integrations/gmail/tools/utils.ts b/packages/ai-proxy/src/integrations/gmail/tools/utils.ts new file mode 100644 index 0000000000..830393eafc --- /dev/null +++ b/packages/ai-proxy/src/integrations/gmail/tools/utils.ts @@ -0,0 +1,76 @@ +/** + * Extract a header value from Gmail message payload + */ +export function getHeader( + payload: { headers?: Array<{ name: string; value: string }> }, + name: string, +): string { + return ( + payload.headers?.find( + (h: { name: string; value: string }) => h.name.toLowerCase() === name.toLowerCase(), + )?.value || '' + ); +} + +/** + * Decode base64url-encoded body data from Gmail API + */ +function decodeBody(body: string): string { + if (!body) return ''; + + try { + const sanitized = body.replace(/-/g, '+').replace(/_/g, '/'); + + return Buffer.from(sanitized, 'base64').toString('utf-8'); + } catch { + return ''; + } +} + +/** + * Extract body content from Gmail message payload (recursively searches parts) + */ +export function getBody(payload: any): string { + if (payload.body?.data) { + return decodeBody(payload.body.data); + } + + if (payload.parts) { + for (const part of payload.parts) { + const body = getBody(part); + if (body) return body; + } + } + + return ''; +} + +/** + * Encode email message for Gmail API (base64url encoding) + */ +export function encodeEmail(params: { + to: string[]; + subject: string; + message: string; + cc?: string[]; + bcc?: string[]; +}): string { + const { to, subject, message, cc, bcc } = params; + + const emailLines = [ + `To: ${to.join(', ')}`, + ...(cc ? [`Cc: ${cc.join(', ')}`] : []), + ...(bcc ? [`Bcc: ${bcc.join(', ')}`] : []), + `Subject: ${subject}`, + '', + message, + ]; + + const email = emailLines.join('\r\n'); + + return Buffer.from(email) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} diff --git a/packages/ai-proxy/src/integrations/slack/tools.ts b/packages/ai-proxy/src/integrations/slack/tools.ts new file mode 100644 index 0000000000..44950ee509 --- /dev/null +++ b/packages/ai-proxy/src/integrations/slack/tools.ts @@ -0,0 +1,40 @@ +import RemoteTool from '../../remote-tool'; +import createAddReactionTool from './tools/add-reaction'; +import createGetChannelHistoryTool from './tools/get-channel-history'; +import createGetThreadRepliesTool from './tools/get-thread-replies'; +import createGetUserProfileTool from './tools/get-user-profile'; +import createGetUsersTool from './tools/get-users'; +import createListChannelsTool from './tools/list-channels'; +import createPostMessageTool from './tools/post-message'; +import createReplyToThreadTool from './tools/reply-to-thread'; + +export interface SlackConfig { + authToken: string; + teamId: string; + channelIds?: string; +} + +export default function getSlackTools(config: SlackConfig): RemoteTool[] { + const headers = { + Authorization: `Bearer ${config.authToken}`, + 'Content-Type': 'application/json', + }; + + return [ + createListChannelsTool(headers, config), + createPostMessageTool(headers), + createReplyToThreadTool(headers), + createAddReactionTool(headers), + createGetChannelHistoryTool(headers), + createGetThreadRepliesTool(headers), + createGetUsersTool(headers, config.teamId), + createGetUserProfileTool(headers), + ].map( + tool => + new RemoteTool({ + sourceId: 'slack', + sourceType: 'server', + tool, + }), + ); +} diff --git a/packages/ai-proxy/src/integrations/slack/tools/add-reaction.ts b/packages/ai-proxy/src/integrations/slack/tools/add-reaction.ts new file mode 100644 index 0000000000..924f554750 --- /dev/null +++ b/packages/ai-proxy/src/integrations/slack/tools/add-reaction.ts @@ -0,0 +1,29 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +export default function createAddReactionTool( + headers: Record, +): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: 'slack_add_reaction', + description: 'Add a reaction emoji to a message', + schema: z.object({ + channel_id: z.string().describe('The ID of the channel containing the message'), + timestamp: z.string().describe('The timestamp of the message to react to'), + reaction: z.string().describe('The name of the emoji reaction (without ::)'), + }), + func: async ({ channel_id, timestamp, reaction }) => { + const response = await fetch('https://slack.com/api/reactions.add', { + method: 'POST', + headers, + body: JSON.stringify({ + channel: channel_id, + timestamp, + name: reaction, + }), + }); + + return JSON.stringify(await response.json()); + }, + }); +} diff --git a/packages/ai-proxy/src/integrations/slack/tools/get-channel-history.ts b/packages/ai-proxy/src/integrations/slack/tools/get-channel-history.ts new file mode 100644 index 0000000000..f2be20806e --- /dev/null +++ b/packages/ai-proxy/src/integrations/slack/tools/get-channel-history.ts @@ -0,0 +1,30 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +export default function createGetChannelHistoryTool( + headers: Record, +): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: 'slack_get_channel_history', + description: 'Get recent messages from a channel', + schema: z.object({ + channel_id: z.string().describe('The ID of the channel'), + limit: z + .number() + .optional() + .default(10) + .describe('Number of messages to retrieve (default 10)'), + }), + func: async ({ channel_id, limit }) => { + const params = new URLSearchParams({ + channel: channel_id, + limit: limit.toString(), + }); + const response = await fetch(`https://slack.com/api/conversations.history?${params}`, { + headers, + }); + + return JSON.stringify(await response.json()); + }, + }); +} diff --git a/packages/ai-proxy/src/integrations/slack/tools/get-thread-replies.ts b/packages/ai-proxy/src/integrations/slack/tools/get-thread-replies.ts new file mode 100644 index 0000000000..3f2c6da0d6 --- /dev/null +++ b/packages/ai-proxy/src/integrations/slack/tools/get-thread-replies.ts @@ -0,0 +1,32 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +export default function createGetThreadRepliesTool( + headers: Record, +): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: 'slack_get_thread_replies', + description: 'Get all replies in a message thread', + schema: z.object({ + channel_id: z.string().describe('The ID of the channel containing the thread'), + thread_ts: z + .string() + .describe( + "The timestamp of the parent message in the format '1234567890.123456'. " + + 'Timestamps in the format without the period can be converted by adding the period ' + + 'such that 6 numbers come after it.', + ), + }), + func: async ({ channel_id, thread_ts }) => { + const params = new URLSearchParams({ + channel: channel_id, + ts: thread_ts, + }); + const response = await fetch(`https://slack.com/api/conversations.replies?${params}`, { + headers, + }); + + return JSON.stringify(await response.json()); + }, + }); +} diff --git a/packages/ai-proxy/src/integrations/slack/tools/get-user-profile.ts b/packages/ai-proxy/src/integrations/slack/tools/get-user-profile.ts new file mode 100644 index 0000000000..df6d4f5d92 --- /dev/null +++ b/packages/ai-proxy/src/integrations/slack/tools/get-user-profile.ts @@ -0,0 +1,25 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +export default function createGetUserProfileTool( + headers: Record, +): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: 'slack_get_user_profile', + description: 'Get detailed profile information for a specific user', + schema: z.object({ + user_id: z.string().describe('The ID of the user'), + }), + func: async ({ user_id }) => { + const params = new URLSearchParams({ + user: user_id, + include_labels: 'true', + }); + const response = await fetch(`https://slack.com/api/users.profile.get?${params}`, { + headers, + }); + + return JSON.stringify(await response.json()); + }, + }); +} diff --git a/packages/ai-proxy/src/integrations/slack/tools/get-users.ts b/packages/ai-proxy/src/integrations/slack/tools/get-users.ts new file mode 100644 index 0000000000..9ab30cdca4 --- /dev/null +++ b/packages/ai-proxy/src/integrations/slack/tools/get-users.ts @@ -0,0 +1,36 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +export default function createGetUsersTool( + headers: Record, + teamId: string, +): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: 'slack_get_users', + description: 'Get a list of all users in the workspace with their basic profile information', + schema: z.object({ + cursor: z.string().optional().describe('Pagination cursor for next page of results'), + limit: z + .number() + .optional() + .default(100) + .describe('Maximum number of users to return (default 100, max 200)'), + }), + func: async ({ cursor, limit }) => { + const params = new URLSearchParams({ + limit: Math.min(limit, 200).toString(), + team_id: teamId, + }); + + if (cursor) { + params.append('cursor', cursor); + } + + const response = await fetch(`https://slack.com/api/users.list?${params}`, { + headers, + }); + + return JSON.stringify(await response.json()); + }, + }); +} diff --git a/packages/ai-proxy/src/integrations/slack/tools/list-channels.ts b/packages/ai-proxy/src/integrations/slack/tools/list-channels.ts new file mode 100644 index 0000000000..008016962c --- /dev/null +++ b/packages/ai-proxy/src/integrations/slack/tools/list-channels.ts @@ -0,0 +1,68 @@ +import type { SlackConfig } from '../tools'; + +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +export default function createListChannelsTool( + headers: Record, + config: SlackConfig, +): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: 'slack_list_channels', + description: 'List public or pre-defined channels in the workspace with pagination', + schema: z.object({ + limit: z + .number() + .optional() + .default(100) + .describe('Maximum number of channels to return (default 100, max 200)'), + cursor: z.string().optional().describe('Pagination cursor for next page of results'), + }), + func: async ({ limit, cursor }) => { + const { channelIds } = config; + + if (!channelIds) { + const params = new URLSearchParams({ + types: 'public_channel', + exclude_archived: 'true', + limit: Math.min(limit, 200).toString(), + team_id: config.teamId, + }); + + if (cursor) { + params.append('cursor', cursor); + } + + const response = await fetch(`https://slack.com/api/conversations.list?${params}`, { + headers, + }); + + return JSON.stringify(await response.json()); + } + + const predefinedChannelIdsArray = channelIds.split(',').map(id => id.trim()); + + const channelPromises = predefinedChannelIdsArray.map(async channelId => { + const params = new URLSearchParams({ + channel: channelId, + }); + const response = await fetch(`https://slack.com/api/conversations.info?${params}`, { + headers, + }); + + return response.json(); + }); + + const results = await Promise.all(channelPromises); + const channels = results + .filter(data => data.ok && data.channel && !data.channel.is_archived) + .map(data => data.channel); + + return JSON.stringify({ + ok: true, + channels, + response_metadata: { next_cursor: '' }, + }); + }, + }); +} diff --git a/packages/ai-proxy/src/integrations/slack/tools/post-message.ts b/packages/ai-proxy/src/integrations/slack/tools/post-message.ts new file mode 100644 index 0000000000..9d4265f2a1 --- /dev/null +++ b/packages/ai-proxy/src/integrations/slack/tools/post-message.ts @@ -0,0 +1,27 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +export default function createPostMessageTool( + headers: Record, +): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: 'slack_post_message', + description: 'Post a new message to a Slack channel', + schema: z.object({ + channel_id: z.string().describe('The ID of the channel to post to'), + text: z.string().describe('The message text to post'), + }), + func: async ({ channel_id, text }) => { + const response = await fetch('https://slack.com/api/chat.postMessage', { + method: 'POST', + headers, + body: JSON.stringify({ + channel: channel_id, + text, + }), + }); + + return JSON.stringify(await response.json()); + }, + }); +} diff --git a/packages/ai-proxy/src/integrations/slack/tools/reply-to-thread.ts b/packages/ai-proxy/src/integrations/slack/tools/reply-to-thread.ts new file mode 100644 index 0000000000..9aa805ddea --- /dev/null +++ b/packages/ai-proxy/src/integrations/slack/tools/reply-to-thread.ts @@ -0,0 +1,35 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +export default function createReplyToThreadTool( + headers: Record, +): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: 'slack_reply_to_thread', + description: 'Reply to a specific message thread in Slack', + schema: z.object({ + channel_id: z.string().describe('The ID of the channel containing the thread'), + thread_ts: z + .string() + .describe( + "The timestamp of the parent message in the format '1234567890.123456'. " + + 'Timestamps in the format without the period can be converted by adding the period ' + + 'such that 6 numbers come after it.', + ), + text: z.string().describe('The reply text'), + }), + func: async ({ channel_id, thread_ts, text }) => { + const response = await fetch('https://slack.com/api/chat.postMessage', { + method: 'POST', + headers, + body: JSON.stringify({ + channel: channel_id, + thread_ts, + text, + }), + }); + + return JSON.stringify(await response.json()); + }, + }); +} diff --git a/packages/ai-proxy/src/integrations/tools.ts b/packages/ai-proxy/src/integrations/tools.ts new file mode 100644 index 0000000000..21a7a1a1b7 --- /dev/null +++ b/packages/ai-proxy/src/integrations/tools.ts @@ -0,0 +1,39 @@ +import type RemoteTool from '../remote-tool'; +import type { BraveConfig } from './brave/tools'; +import type { GmailConfig } from './gmail/tools'; +import type { SlackConfig } from './slack/tools'; +import type { ZendeskConfig } from './zendesk/tools'; + +import getBraveTools from './brave/tools'; +import getGmailTools from './gmail/tools'; +import getSlackTools from './slack/tools'; +import getZendeskTools from './zendesk/tools'; + +export interface IntegrationConfigs { + brave?: BraveConfig; + gmail?: GmailConfig; + slack?: SlackConfig; + zendesk?: ZendeskConfig; +} + +export default function getIntegratedTools(configs: IntegrationConfigs): RemoteTool[] { + const integratedTools: RemoteTool[] = []; + + if (configs.brave) { + integratedTools.push(...getBraveTools(configs.brave)); + } + + if (configs.gmail) { + integratedTools.push(...getGmailTools(configs.gmail)); + } + + if (configs.slack) { + integratedTools.push(...getSlackTools(configs.slack)); + } + + if (configs.zendesk) { + integratedTools.push(...getZendeskTools(configs.zendesk)); + } + + return integratedTools; +} diff --git a/packages/ai-proxy/src/integrations/zendesk/tools.ts b/packages/ai-proxy/src/integrations/zendesk/tools.ts new file mode 100644 index 0000000000..361c8eedbb --- /dev/null +++ b/packages/ai-proxy/src/integrations/zendesk/tools.ts @@ -0,0 +1,38 @@ +import RemoteTool from '../../remote-tool'; +import createCreateTicketCommentTool from './tools/create-ticket-comment'; +import createCreateTicketTool from './tools/create-ticket'; +import createGetTicketCommentsTool from './tools/get-ticket-comments'; +import createGetTicketTool from './tools/get-ticket'; +import createGetTicketsTool from './tools/get-tickets'; +import createUpdateTicketTool from './tools/update-ticket'; + +export interface ZendeskConfig { + subdomain: string; + email: string; + apiToken: string; +} + +export default function getZendeskTools(config: ZendeskConfig): RemoteTool[] { + const baseUrl = `https://${config.subdomain}.zendesk.com/api/v2`; + const auth = Buffer.from(`${config.email}/token:${config.apiToken}`).toString('base64'); + const headers = { + Authorization: `Basic ${auth}`, + 'Content-Type': 'application/json', + }; + + return [ + createGetTicketsTool(headers, baseUrl), + createGetTicketTool(headers, baseUrl), + createGetTicketCommentsTool(headers, baseUrl), + createCreateTicketTool(headers, baseUrl), + createCreateTicketCommentTool(headers, baseUrl), + createUpdateTicketTool(headers, baseUrl), + ].map( + tool => + new RemoteTool({ + sourceId: 'zendesk', + sourceType: 'server', + tool, + }), + ); +} diff --git a/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket-comment.ts b/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket-comment.ts new file mode 100644 index 0000000000..fae108317c --- /dev/null +++ b/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket-comment.ts @@ -0,0 +1,39 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +export default function createCreateTicketCommentTool( + headers: Record, + baseUrl: string, +): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: 'zendesk_create_ticket_comment', + description: 'Add a new comment to an existing Zendesk ticket', + schema: z.object({ + ticket_id: z.number().int().positive().describe('The ID of the ticket to add a comment to'), + comment: z.string().min(1).describe('The comment text to add'), + public: z + .boolean() + .optional() + .default(true) + .describe('Whether the comment is visible to the requester (true) or internal only (false)'), + }), + func: async ({ ticket_id, comment, public: isPublic }) => { + const updateData = { + ticket: { + comment: { + body: comment, + public: isPublic, + }, + }, + }; + + const response = await fetch(`${baseUrl}/tickets/${ticket_id}.json`, { + method: 'PUT', + headers, + body: JSON.stringify(updateData), + }); + + return JSON.stringify(await response.json()); + }, + }); +} diff --git a/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket.ts b/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket.ts new file mode 100644 index 0000000000..d66016d136 --- /dev/null +++ b/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket.ts @@ -0,0 +1,58 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +export default function createCreateTicketTool( + headers: Record, + baseUrl: string, +): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: 'zendesk_create_ticket', + description: 'Create a new Zendesk ticket', + schema: z.object({ + subject: z.string().min(1).describe('The subject/title of the ticket'), + description: z.string().min(1).describe('The description/body of the ticket'), + requester_id: z.number().int().positive().optional().describe('The ID of the requester'), + assignee_id: z.number().int().positive().optional().describe('The ID of the assignee'), + priority: z + .enum(['low', 'normal', 'high', 'urgent']) + .optional() + .describe('The priority level of the ticket'), + type: z + .enum(['problem', 'incident', 'question', 'task']) + .optional() + .describe('The type of the ticket'), + tags: z.array(z.string()).optional().describe('Tags to apply to the ticket'), + custom_fields: z + .array( + z.object({ + id: z.number().describe('The custom field ID'), + value: z.unknown().describe('The custom field value'), + }), + ) + .optional() + .describe('Custom fields to set on the ticket'), + }), + func: async ({ subject, description, requester_id, assignee_id, priority, type, tags, custom_fields }) => { + const ticketData: Record = { + ticket: { + subject, + comment: { body: description }, + ...(requester_id && { requester_id }), + ...(assignee_id && { assignee_id }), + ...(priority && { priority }), + ...(type && { type }), + ...(tags && { tags }), + ...(custom_fields && { custom_fields }), + }, + }; + + const response = await fetch(`${baseUrl}/tickets.json`, { + method: 'POST', + headers, + body: JSON.stringify(ticketData), + }); + + return JSON.stringify(await response.json()); + }, + }); +} diff --git a/packages/ai-proxy/src/integrations/zendesk/tools/get-ticket-comments.ts b/packages/ai-proxy/src/integrations/zendesk/tools/get-ticket-comments.ts new file mode 100644 index 0000000000..4b1e76f454 --- /dev/null +++ b/packages/ai-proxy/src/integrations/zendesk/tools/get-ticket-comments.ts @@ -0,0 +1,22 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +export default function createGetTicketCommentsTool( + headers: Record, + baseUrl: string, +): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: 'zendesk_get_ticket_comments', + description: 'Get all comments for a specific Zendesk ticket', + schema: z.object({ + ticket_id: z.number().int().positive().describe('The ID of the ticket to get comments for'), + }), + func: async ({ ticket_id }) => { + const response = await fetch(`${baseUrl}/tickets/${ticket_id}/comments.json`, { + headers, + }); + + return JSON.stringify(await response.json()); + }, + }); +} diff --git a/packages/ai-proxy/src/integrations/zendesk/tools/get-ticket.ts b/packages/ai-proxy/src/integrations/zendesk/tools/get-ticket.ts new file mode 100644 index 0000000000..f25c60df83 --- /dev/null +++ b/packages/ai-proxy/src/integrations/zendesk/tools/get-ticket.ts @@ -0,0 +1,22 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +export default function createGetTicketTool( + headers: Record, + baseUrl: string, +): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: 'zendesk_get_ticket', + description: 'Retrieve a single Zendesk ticket by its ID', + schema: z.object({ + ticket_id: z.number().int().positive().describe('The ID of the ticket to retrieve'), + }), + func: async ({ ticket_id }) => { + const response = await fetch(`${baseUrl}/tickets/${ticket_id}.json`, { + headers, + }); + + return JSON.stringify(await response.json()); + }, + }); +} diff --git a/packages/ai-proxy/src/integrations/zendesk/tools/get-tickets.ts b/packages/ai-proxy/src/integrations/zendesk/tools/get-tickets.ts new file mode 100644 index 0000000000..92e592c7b1 --- /dev/null +++ b/packages/ai-proxy/src/integrations/zendesk/tools/get-tickets.ts @@ -0,0 +1,43 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +export default function createGetTicketsTool( + headers: Record, + baseUrl: string, +): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: 'zendesk_get_tickets', + description: 'Fetch a paginated list of Zendesk tickets with sorting options', + schema: z.object({ + page: z.number().int().positive().optional().default(1).describe('Page number for pagination'), + per_page: z + .number() + .int() + .positive() + .max(100) + .optional() + .default(25) + .describe('Number of tickets per page (max 100)'), + sort_by: z + .enum(['created_at', 'updated_at', 'priority', 'status']) + .optional() + .default('created_at') + .describe('Field to sort tickets by'), + sort_order: z.enum(['asc', 'desc']).optional().default('desc').describe('Sort order'), + }), + func: async ({ page, per_page, sort_by, sort_order }) => { + const params = new URLSearchParams({ + page: page.toString(), + per_page: per_page.toString(), + sort_by, + sort_order, + }); + + const response = await fetch(`${baseUrl}/tickets.json?${params}`, { + headers, + }); + + return JSON.stringify(await response.json()); + }, + }); +} diff --git a/packages/ai-proxy/src/integrations/zendesk/tools/update-ticket.ts b/packages/ai-proxy/src/integrations/zendesk/tools/update-ticket.ts new file mode 100644 index 0000000000..3721670656 --- /dev/null +++ b/packages/ai-proxy/src/integrations/zendesk/tools/update-ticket.ts @@ -0,0 +1,73 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +export default function createUpdateTicketTool( + headers: Record, + baseUrl: string, +): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: 'zendesk_update_ticket', + description: 'Update fields on an existing Zendesk ticket', + schema: z.object({ + ticket_id: z.number().int().positive().describe('The ID of the ticket to update'), + subject: z.string().min(1).optional().describe('New subject for the ticket'), + status: z + .enum(['new', 'open', 'pending', 'hold', 'solved', 'closed']) + .optional() + .describe('New status for the ticket'), + priority: z + .enum(['low', 'normal', 'high', 'urgent']) + .optional() + .describe('New priority for the ticket'), + type: z + .enum(['problem', 'incident', 'question', 'task']) + .optional() + .describe('New type for the ticket'), + assignee_id: z.number().int().positive().optional().describe('New assignee ID for the ticket'), + requester_id: z.number().int().positive().optional().describe('New requester ID for the ticket'), + tags: z.array(z.string()).optional().describe('New tags for the ticket (replaces existing tags)'), + custom_fields: z + .array( + z.object({ + id: z.number().describe('The custom field ID'), + value: z.unknown().describe('The custom field value'), + }), + ) + .optional() + .describe('Custom fields to update'), + due_at: z.string().optional().describe('Due date in ISO8601 format (for task tickets)'), + }), + func: async ({ + ticket_id, + subject, + status, + priority, + type, + assignee_id, + requester_id, + tags, + custom_fields, + due_at, + }) => { + const ticketUpdate: Record = {}; + + if (subject !== undefined) ticketUpdate.subject = subject; + if (status !== undefined) ticketUpdate.status = status; + if (priority !== undefined) ticketUpdate.priority = priority; + if (type !== undefined) ticketUpdate.type = type; + if (assignee_id !== undefined) ticketUpdate.assignee_id = assignee_id; + if (requester_id !== undefined) ticketUpdate.requester_id = requester_id; + if (tags !== undefined) ticketUpdate.tags = tags; + if (custom_fields !== undefined) ticketUpdate.custom_fields = custom_fields; + if (due_at !== undefined) ticketUpdate.due_at = due_at; + + const response = await fetch(`${baseUrl}/tickets/${ticket_id}.json`, { + method: 'PUT', + headers, + body: JSON.stringify({ ticket: ticketUpdate }), + }); + + return JSON.stringify(await response.json()); + }, + }); +} diff --git a/packages/ai-proxy/src/remote-tools.ts b/packages/ai-proxy/src/remote-tools.ts index b16dacb06e..a8925ae89e 100644 --- a/packages/ai-proxy/src/remote-tools.ts +++ b/packages/ai-proxy/src/remote-tools.ts @@ -1,11 +1,12 @@ +import type { IntegrationConfigs } from './integrations/tools'; +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 RemoteTool from './remote-tool'; +import getIntegratedTools from './integrations/tools'; export type Messages = ChatCompletionCreateParamsNonStreaming['messages']; @@ -19,17 +20,16 @@ export class RemoteTools { constructor(apiKeys: RemoteToolsApiKeys, tools?: RemoteTool[]) { this.apiKeys = apiKeys; - this.tools.push(...(tools ?? [])); + + const integrationConfigs: IntegrationConfigs = {}; if (this.apiKeys?.AI_REMOTE_TOOL_BRAVE_SEARCH_API_KEY) { - this.tools.push( - new RemoteTool({ - sourceId: 'brave_search', - sourceType: 'server', - tool: new BraveSearch({ apiKey: this.apiKeys.AI_REMOTE_TOOL_BRAVE_SEARCH_API_KEY }), - }), - ); + integrationConfigs.brave = { + apiKey: this.apiKeys.AI_REMOTE_TOOL_BRAVE_SEARCH_API_KEY, + }; } + + this.tools.push(...(tools ?? []), ...getIntegratedTools(integrationConfigs)); } get toolDefinitionsForFrontend() { diff --git a/yarn.lock b/yarn.lock index 1c16f7170f..da1ed7089f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1794,6 +1794,42 @@ path-to-regexp "^6.3.0" reusify "^1.0.4" +"@forestadmin/agent-client@1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@forestadmin/agent-client/-/agent-client-1.2.3.tgz#9f48ab0a74c7fb38cd42573a0770a2b7f9cd4b05" + integrity sha512-R6HVdNhgmjv4BVLe9jROL3G9Ta2+rufUEE2IC0M90C4/ZXN546HiHFe8O5092EplQMDyr0XEsRlzoEAdi12fyg== + dependencies: + "@forestadmin/datasource-toolkit" "1.50.1" + "@forestadmin/forestadmin-client" "1.37.3" + jsonapi-serializer "^3.6.9" + superagent "^10.2.3" + +"@forestadmin/agent@1.70.6": + version "1.70.6" + resolved "https://registry.yarnpkg.com/@forestadmin/agent/-/agent-1.70.6.tgz#31d891793b64b23c9a9f027e18e8f5c15a9186ce" + integrity sha512-24sJ7dmXvsTIzq44Z5YzGAQfJVzpsLAqc4yFehbLkGeR+RQSCMb3e329ctoBQNkKws26QNLyef+IeaEcTzw7HQ== + dependencies: + "@fast-csv/format" "^4.3.5" + "@forestadmin/datasource-customizer" "1.67.2" + "@forestadmin/datasource-toolkit" "1.50.1" + "@forestadmin/forestadmin-client" "1.37.3" + "@forestadmin/mcp-server" "1.5.5" + "@koa/bodyparser" "^6.0.0" + "@koa/cors" "^5.0.0" + "@koa/router" "^13.1.0" + "@paralleldrive/cuid2" "2.2.2" + "@types/koa__router" "^12.0.4" + forest-ip-utils "^1.0.1" + json-api-serializer "^2.6.6" + json-stringify-pretty-compact "^3.0.0" + jsonwebtoken "^9.0.0" + koa "^3.0.1" + koa-jwt "^4.0.4" + luxon "^3.2.1" + object-hash "^3.0.0" + superagent "^10.2.3" + uuid "11.0.2" + "@forestadmin/context@1.37.1": version "1.37.1" resolved "https://registry.yarnpkg.com/@forestadmin/context/-/context-1.37.1.tgz#301486c456061d43cb653b3e8be60644edb3f71a" @@ -1828,6 +1864,32 @@ object-hash "^3.0.0" uuid "^9.0.0" +"@forestadmin/forestadmin-client@1.37.3": + version "1.37.3" + resolved "https://registry.yarnpkg.com/@forestadmin/forestadmin-client/-/forestadmin-client-1.37.3.tgz#5d9c94ed31cbb5ace99f46c62810ebc4fceae291" + integrity sha512-NIsuUZOv8UuC/8sOCwxO9X+slvkouRO56ot7CXeICBc47SZ9+iFXcC64guxFU/pCFyGvxkFpvjBUlvlEgwYBrw== + dependencies: + eventsource "2.0.2" + json-api-serializer "^2.6.6" + jsonwebtoken "^9.0.0" + object-hash "^3.0.0" + openid-client "^5.7.1" + superagent "^10.2.3" + +"@forestadmin/mcp-server@1.5.5": + version "1.5.5" + resolved "https://registry.yarnpkg.com/@forestadmin/mcp-server/-/mcp-server-1.5.5.tgz#f1d60154fae130b4d0454739b03a86cdbccc5bf5" + integrity sha512-yNGpVoh1g9z7qPgBV+Z5bIeg4RlhkQejEZNrOzLrpP3nseOroG7abozfGAL3R5sgvOwikOzMHOaqdw9B/wrMqw== + dependencies: + "@forestadmin/agent-client" "1.2.3" + "@forestadmin/forestadmin-client" "1.37.3" + "@modelcontextprotocol/sdk" "^1.25.1" + cors "^2.8.5" + express "^5.2.1" + jsonapi-serializer "^3.6.9" + jsonwebtoken "^9.0.3" + zod "^4.2.1" + "@gar/promisify@^1.0.1": version "1.1.3" resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6" @@ -18661,6 +18723,11 @@ zod@^4.2.1: resolved "https://registry.yarnpkg.com/zod/-/zod-4.2.1.tgz#07f0388c7edbfd5f5a2466181cb4adf5b5dbd57b" integrity sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw== +zod@^4.3.5: + version "4.3.5" + resolved "https://registry.yarnpkg.com/zod/-/zod-4.3.5.tgz#aeb269a6f9fc259b1212c348c7c5432aaa474d2a" + integrity sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g== + zwitch@^1.0.0: version "1.0.5" resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-1.0.5.tgz#d11d7381ffed16b742f6af7b3f223d5cd9fe9920"