UNPKG

seatalk-mcp-server

Version:

A Model Context Protocol server for SeaTalk Open Platform integration

1,239 lines (1,229 loc) 39.7 kB
#!/usr/bin/env node // src/index.ts import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; // src/constants.ts var SERVER_INFO = { name: "seatalk-server", version: "0.1.0" }; var SERVER_CAPABILITIES = { capabilities: { tools: { get_employee_profile: true, get_employee_code_with_email: true, check_employee_existence: true, get_user_language_preference: true, get_joined_group_chat_list: true, get_thread_by_thread_id: true, get_message_by_message_id: true, get_chat_history: true, get_group_info: true, send_message_to_group_chat: true, send_message_to_bot_user: true } } }; // src/index.ts import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; // src/tools.ts function getToolDefinitions() { return [ { name: "get_employee_profile", description: "Get an employee's profile by employee ID", inputSchema: { type: "object", properties: { employee_code: { type: "string", description: "The employee code of the employee" } }, required: ["employee_code"] }, examples: [ { description: "Get profile for employee with code 'EMP123'", input: { employee_code: "EMP123" }, output: { code: 0, employee: { employee_code: "EMP123", name: "John Doe", company_email: "john.doe@company.com", department: { department_code: "DEP001", department_name: "Engineering" } } } } ] }, { name: "get_employee_code_with_email", description: "Get an employee's code by email address", inputSchema: { type: "object", properties: { emails: { type: "array", items: { type: "string", format: "email" }, minItems: 1, maxItems: 500, description: "List of employee email addresses (between 1 and 500 items)" } }, required: ["emails"] }, examples: [ { description: "Get employee codes for email addresses", input: { emails: ["john.doe@company.com", "jane.smith@company.com"] }, output: { code: 0, results: [ { email: "john.doe@company.com", employee_code: "EMP123", exists: true }, { email: "jane.smith@company.com", employee_code: "EMP456", exists: true } ] } } ] }, { name: "check_employee_existence", description: "Verify whether employees exist in the organization via SeaTalk ID", inputSchema: { type: "object", properties: { id: { type: "string", description: "One or more SeaTalk ID(s)" } }, required: ["id"] }, examples: [ { description: "Check if employee with ID 'ST12345' exists", input: { id: "ST12345" }, output: { code: 0, exists: true } } ] }, { name: "get_user_language_preference", description: "Get a user's language preference", inputSchema: { type: "object", properties: { employee_code: { type: "string", description: "The employee code of the user" } }, required: ["employee_code"] }, examples: [ { description: "Get language preference for employee with code 'EMP123'", input: { employee_code: "EMP123" }, output: { code: 0, language: "en" } } ] }, { name: "get_joined_group_chat_list", description: "Obtain group chats the bot joined", inputSchema: { type: "object", properties: { cursor: { type: "string", description: "Cursor for pagination" }, page_size: { type: "number", description: "Number of items included in one response (1-100)" } } }, examples: [ { description: "Get the first 10 group chats the bot has joined", input: { page_size: 10 }, output: { code: 0, groups: [ { group_id: "group123", group_name: "Engineering Team", member_count: 15 }, { group_id: "group456", group_name: "Project Alpha", member_count: 8 } ], has_more: true, next_cursor: "cursor_token_for_next_page" } } ] }, { name: "get_thread_by_thread_id", description: "Retrieve all messages within a thread of a group chat", inputSchema: { type: "object", properties: { group_id: { type: "string", description: "The ID of the group chat" }, thread_id: { type: "string", description: "The ID of the thread" }, cursor: { type: "string", description: "Cursor for pagination" }, page_size: { type: "number", description: "Number of messages included in one response (1-100)" } }, required: ["group_id", "thread_id"] }, examples: [ { description: "Get messages in a thread with ID 'thread123' in group 'group456'", input: { group_id: "group456", thread_id: "thread123", page_size: 20 }, output: { code: 0, messages: [ { message_id: "msg001", sender: { employee_code: "EMP123", name: "John Doe" }, tag: "text", text: { plain_text: "Hello team!" }, created_at: 1615456789 } ], has_more: false } } ] }, { name: "get_message_by_message_id", description: "Retrieve a message by its message ID within a group chat thread", inputSchema: { type: "object", properties: { message_id: { type: "string", description: 'The ID of the target message, which can be obtained via the event "New Mentioned Message From Group Chat"' } }, required: ["message_id"] }, examples: [ { description: "Get message with ID 'msg001'", input: { message_id: "msg001" }, output: { code: 0, message_id: "msg001", sender: { employee_code: "EMP123", name: "John Doe" }, tag: "text", text: { plain_text: "Hello team!" }, created_at: 1615456789 } } ] }, { name: "get_chat_history", description: "Obtain the group chat history (messages sent within 7 days)", inputSchema: { type: "object", properties: { group_id: { type: "string", description: "The ID of the group chat" }, cursor: { type: "string", description: "Cursor for pagination" }, page_size: { type: "number", description: "Number of messages included in one response (1-100)" } }, required: ["group_id"] }, examples: [ { description: "Get chat history for group 'group456'", input: { group_id: "group456", page_size: 50 }, output: { code: 0, messages: [ { message_id: "msg001", sender: { employee_code: "EMP123", name: "John Doe" }, tag: "text", text: { plain_text: "Hello team!" }, created_at: 1615456789 }, { message_id: "msg002", sender: { employee_code: "EMP456", name: "Jane Smith" }, tag: "image", image: { content: "https://example.com/image.jpg" }, created_at: 1615456890 } ], has_more: true, next_cursor: "next_page_cursor" } } ] }, { name: "get_group_info", description: "Get information about a group chat, including member list", inputSchema: { type: "object", properties: { group_id: { type: "string", description: "The ID of the group chat" }, cursor: { type: "string", description: "Pagination cursor for members" }, page_size: { type: "number", description: "Number of members per page (1-100)" } }, required: ["group_id"] }, examples: [ { description: "Get information about group 'group456'", input: { group_id: "group456" }, output: { code: 0, group_info: { group_id: "group456", group_name: "Project Alpha", description: "Group for Project Alpha discussion", created_at: 1615e6, owner: { employee_code: "EMP123", name: "John Doe" } }, members: [ { employee_code: "EMP123", name: "John Doe", is_admin: true }, { employee_code: "EMP456", name: "Jane Smith", is_admin: false } ], has_more: false } } ] }, { name: "send_message_to_group_chat", description: "Send a message to a group chat which the bot has been added to", inputSchema: { type: "object", properties: { group_id: { type: "string", description: "The ID of the group chat" }, message: { type: "object", properties: { tag: { type: "string", enum: ["text", "image", "file"], description: "The type of message to send" }, text: { type: "object", properties: { content: { type: "string", description: "The content of the text message" }, format: { type: "string", enum: ["1", "2"], description: "1: Formatted text (Markdown), 2: Plain text" } } }, image: { type: "object", properties: { content: { type: "string", description: "Base64-encoded image file" } } }, file: { type: "object", properties: { filename: { type: "string", description: "The file name with extension" }, content: { type: "string", description: "Base64-encoded file" } } } }, required: ["tag"] }, quoted_message_id: { type: "string", description: "The ID of the message to quote" }, thread_id: { type: "string", description: "The ID of the thread to send the message to" } }, required: ["group_id", "message"] }, examples: [ { description: "Send a text message to group 'group456'", input: { group_id: "group456", message: { tag: "text", text: { content: "Hello everyone! This is an announcement.", format: "1" } } }, output: { code: 0, message_id: "msg123" } }, { description: "Send an image to group 'group456'", input: { group_id: "group456", message: { tag: "image", image: { content: "base64_encoded_image_data" } } }, output: { code: 0, message_id: "msg124" } } ] }, { name: "send_message_to_bot_user", description: "Send a message to a user via the bot", inputSchema: { type: "object", properties: { employee_code: { type: "string", description: "The employee code of the recipient." }, message: { type: "object", properties: { tag: { type: "string", enum: [ "text", "image", "file", "interactive_message", "markdown" ], description: "The type of message to send" }, text: { type: "object", properties: { content: { type: "string", description: "The content of the text message (Markdown supported)" }, format: { type: "string", enum: ["1", "2"], description: "1: Formatted text (Markdown), 2: Plain text" } } }, image: { type: "object", properties: { content: { type: "string", description: "Base64-encoded image file (PNG, JPG, GIF), max 5MB after encoding" } } }, file: { type: "object", properties: { filename: { type: "string", description: "The file name with extension" }, content: { type: "string", description: "Base64-encoded file, max 5MB after encoding, min 10B" } } }, interactive_message: { type: "object", properties: { elements: { type: "array", description: "Array of interactive message card elements", items: { oneOf: [ { type: "object", properties: { element_type: { type: "string", enum: ["title"] }, title: { type: "object", properties: { text: { type: "string", description: "The title text content" } }, required: ["text"] } }, required: ["element_type", "title"] }, { type: "object", properties: { element_type: { type: "string", enum: ["description"] }, description: { type: "object", properties: { format: { type: "string", enum: ["1", "2"], description: "1: Formatted text (Markdown), 2: Plain text" }, text: { type: "string", description: "The description text content" } }, required: ["text"] } }, required: ["element_type", "description"] }, { type: "object", properties: { element_type: { type: "string", enum: ["button"] }, button: { oneOf: [ { type: "object", properties: { button_type: { type: "string", enum: ["callback"] }, text: { type: "string", description: "The button text" }, value: { type: "string", description: "The callback value returned when button is clicked" } }, required: ["button_type", "text", "value"] }, { type: "object", properties: { button_type: { type: "string", enum: ["redirect"] }, text: { type: "string", description: "The button text" }, mobile_link: { type: "object", properties: { type: { type: "string", enum: ["rn", "web"], description: 'The type of the mobile link. Can be "rn" or "web". Must be filled if a mobile_link object is provided' }, path: { type: "string", description: `The path of the RN App link, or the full URL of the web app URL, or the full URL of an external website. Must be filled if a mobile_link object is provide. For RN app paths, they must start with "/".` }, params: { type: "object", description: "Additional parameters" } }, required: ["type", "path"] }, desktop_link: { type: "object", properties: { type: { type: "string", enum: ["rn", "web"], description: 'The type of the mobile link. Can be "rn" or "web". Must be filled if a mobile_link object is provided' }, path: { type: "string", description: `The path of the RN App link, or the full URL of the web app URL, or the full URL of an external website. Must be filled if a mobile_link object is provide. For RN app paths, they must start with "/".` }, params: { type: "object", description: "Additional parameters" } }, required: ["type", "path"] } }, required: [ "button_type", "text", "mobile_link", "desktop_link" ] } ] } }, required: ["element_type", "button"] } ] } } }, required: ["elements"] }, markdown: { type: "object", properties: { content: { type: "string", description: "The content of the message using Markdown syntax" } } } }, required: ["tag"] } }, required: ["employee_code", "message"] }, examples: [ { description: "Send a text message to employee 'EMP123'", input: { employee_code: "EMP123", message: { tag: "text", text: { content: "Hi there! Just checking in.", format: "1" } } }, output: { code: 0, message_id: "msg125" } }, { description: "Send an interactive message card to employee 'EMP123'", input: { employee_code: "EMP123", message: { tag: "interactive_message", interactive_message: { elements: [ { element_type: "title", title: { text: "Task Assignment" } }, { element_type: "description", description: { format: 1, text: "You have been assigned a new task." } }, { element_type: "button", button: { button_type: "callback", text: "Accept", value: "accept_task" } }, { element_type: "button", button: { button_type: "callback", text: "Decline", value: "decline_task" } } ] } } }, output: { code: 0, message_id: "msg126" } } ] } ]; } // src/errors.ts var SEATALK_ERROR_CODES = { // Common errors 0: "Success", 2: "Server error", 5: "Resource not found", 8: "Server error", 100: "App access token is expired or invalid", 101: "API is rejected due to rate limit control", 102: "Request body contains invalid input", 103: "App permission denied", 104: "Bot capability is not turned on", 105: "App is not online", // Auth-specific errors 1e3: "App Secret is invalid", 2e3: "Single Sign-On Token is expired or invalid", 2001: "User is not an employee of the current company", 2002: "Token belongs to another app", 2003: "Cursor invalid", 2004: "Cursor expired", // User-specific errors 3e3: "User not found with the current email", 3001: "User not found with the current code", 3002: "User is not a subscriber of the bot", 3003: "User is not signed in to SeaTalk", 3004: "Invalid custom field name", // Message-specific errors 4e3: "Message type is invalid", 4001: "Message exceeds the maximum length", 4002: "Message sending failed", 4003: "Message cannot be empty", 4004: "Fail to fetch the quoted message due to SeaTalk's internal error", 4005: "The quoted message cannot be found", 4009: "Message cannot be found via the message id provided", 4010: "The thread cannot be found", 4011: "Mention everyone (@all) is not allowed in thread replies", 4012: "No permission to update this message", // App-specific errors 5e3: "appID mismatch", 5001: "linkID expired", 5002: "App not released yet", 5003: "App link amount has reached the upper limit", // Group chat errors 7e3: "Group chat not found with the current code", 7001: "Bot is not a member of the group chat" }; var SeaTalkAPIError = class extends Error { code; constructor(code, message) { const errorMessage = message || SEATALK_ERROR_CODES[code] || "Unknown error"; super(`SeaTalk API Error (${code}): ${errorMessage}`); this.code = code; this.name = "SeaTalkAPIError"; } }; function handleAPIError(response) { if (response.code !== 0) { throw new SeaTalkAPIError(response.code, response.message); } } // src/auth.ts import NodeCache from "node-cache"; import axios from "axios"; import dotenv from "dotenv"; dotenv.config(); var SEATALK_APP_ID = process.env.SEATALK_APP_ID || "your_app_id"; var SEATALK_APP_SECRET = process.env.SEATALK_APP_SECRET || "your_app_secret"; var SEATALK_API_BASE_URL = "https://openapi.seatalk.io"; var tokenCache = new NodeCache({ stdTTL: 7e3 }); async function getAccessToken() { const cacheKey = "seatalk_access_token"; const cachedToken = tokenCache.get(cacheKey); if (cachedToken) { console.error("[Auth] Using cached access token"); return cachedToken; } console.error("[Auth] Fetching new access token"); console.error("[Auth] Using APP_ID:", SEATALK_APP_ID ? "Available" : "Not available"); try { const response = await axios.post(`${SEATALK_API_BASE_URL}/auth/app_access_token`, { app_id: SEATALK_APP_ID, app_secret: SEATALK_APP_SECRET }); if (response.data.code !== 0 || !response.data.app_access_token) { throw new Error(`Failed to get access token: ${JSON.stringify(response.data)}`); } const token = response.data.app_access_token; tokenCache.set(cacheKey, token); return token; } catch (error) { console.error("[Auth] Error getting access token:", error); throw new Error("Failed to authenticate with SeaTalk API"); } } async function getAuthorizedClient() { const token = await getAccessToken(); return axios.create({ baseURL: SEATALK_API_BASE_URL, headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json" } }); } // src/api.ts var SeaTalkAPI = class { /** * Makes an API call to the SeaTalk API with error handling * * @param method HTTP method (get, post, etc.) * @param endpoint API endpoint path * @param params Request parameters or body * @param options Additional options * @returns API response data */ async makeAPICall(method, endpoint, params, options) { try { console.error(`[API] ${options.logDescription}:`, JSON.stringify(params, null, 2)); const client = await getAuthorizedClient(); let response; if (method === "get") { response = await client.get(endpoint, { params }); } else { response = await client.post(endpoint, params); } console.error("[API] Response from SeaTalk API:", JSON.stringify(response.data)); handleAPIError(response.data); if (options.extraLogging) { options.extraLogging(response.data); } return response.data; } catch (error) { console.error(`[Error] ${options.logDescription} failed:`, error); if (error instanceof SeaTalkAPIError) { throw error; } if (error instanceof Error) { throw new Error(`${options.logDescription} failed: ${error.message}`); } throw new Error(`${options.logDescription} failed: Unknown error`); } } async getEmployeeProfile(params) { return this.makeAPICall( "get", "/contacts/v2/profile", { employee_code: params.employee_code }, { logDescription: "Getting employee profile" } ); } async getEmployeeCodeWithEmail(params) { if (!params.emails || !Array.isArray(params.emails) || params.emails.length === 0) { throw new Error("emails parameter must be a non-empty array (between 1 and 500 items)"); } return this.makeAPICall( "post", "/contacts/v2/get_employee_code_with_email", { emails: params.emails }, { logDescription: "Getting employee codes with emails" } ); } async checkEmployeeExistence(params) { return this.makeAPICall( "get", "/contacts/v2/check_employees", { id: params.id }, { logDescription: "Checking employee existence" } ); } async getUserLanguagePreference(params) { return this.makeAPICall( "get", "/contacts/v2/language_preference", { employee_code: params.employee_code }, { logDescription: "Getting user language preference" } ); } async getJoinedGroupChatList(params = {}) { return this.makeAPICall( "get", "/messaging/v2/group_chat/joined", params, { logDescription: "Getting joined group chat list" } ); } async getThreadByThreadId(params) { return this.makeAPICall( "get", "/messaging/v2/group_chat/get_thread_by_thread_id", params, { logDescription: "Getting thread by thread ID" } ); } /** * Retrieve a message by its message ID within a group chat * * The message can be of different types: * - text: Contains text content * - image: Contains an image URL * - video: Contains a video URL * - file: Contains a file URL and filename * - combined_forwarded_chat_history: Contains forwarded messages */ async getMessageByMessageId(params) { return this.makeAPICall( "get", "/messaging/v2/get_message_by_message_id", { message_id: params.message_id }, { logDescription: "Getting message by message ID", extraLogging: (data) => { var _a, _b, _c, _d, _e; if (data.tag) { console.error(`[API] Retrieved message of type: ${data.tag}`); switch (data.tag) { case "text": if ((_a = data.text) == null ? void 0 : _a.plain_text) { console.error(`[API] Text message content: "${data.text.plain_text.substring(0, 50)}${data.text.plain_text.length > 50 ? "..." : ""}"`); } break; case "image": if ((_b = data.image) == null ? void 0 : _b.content) { console.error("[API] Retrieved image URL"); } break; case "video": if ((_c = data.video) == null ? void 0 : _c.content) { console.error("[API] Retrieved video URL"); } break; case "file": if ((_d = data.file) == null ? void 0 : _d.content) { console.error(`[API] Retrieved file URL, filename: ${data.file.filename || "unknown"}`); } break; case "combined_forwarded_chat_history": if ((_e = data.combined_forwarded_chat_history) == null ? void 0 : _e.content) { console.error(`[API] Retrieved ${data.combined_forwarded_chat_history.content.length} forwarded messages`); } break; default: console.error(`[API] Unknown message type: ${data.tag}`); } } } } ); } async getChatHistory(params) { return this.makeAPICall( "get", "/messaging/v2/group_chat/history", params, { logDescription: "Getting chat history" } ); } async getGroupInfo(params) { return this.makeAPICall( "get", "/messaging/v2/group_chat/info", params, { logDescription: "Getting group info" } ); } async sendMessageToGroupChat(params) { return this.makeAPICall( "post", "/messaging/v2/group_chat", params, { logDescription: "Sending message to group chat" } ); } /** * Send a message to a user via the bot * * The message can be of different types: * - text: Text message with optional formatting * - image: Image message (Base64-encoded) * - interactive_message: Interactive card * - file: File attachment (Base64-encoded) * - markdown: Markdown message (phasing out) */ async sendMessageToBotUser(params) { return this.makeAPICall( "post", "/messaging/v2/single_chat", params, { logDescription: "Sending message to bot user", extraLogging: (data) => { console.error(`[API] Sending message of type: ${params.message.tag}`); } } ); } }; // src/handlers.ts var seatalkAPI = new SeaTalkAPI(); async function handleToolCall(handler, params, toolName) { try { console.error(`[Tool] Handling ${toolName} with params:`, JSON.stringify(params, null, 2)); const result = await handler(params); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; } catch (error) { console.error(`[Error] Tool execution failed for ${toolName}:`, error.message); return { content: [{ type: "text", text: `Error: ${error.message}` }], isError: true }; } } async function processToolCall(toolName, toolArguments) { console.error(`[Tool] Processing tool call: ${toolName}`); if (!toolArguments) { throw new Error("Missing required arguments"); } switch (toolName) { case "get_employee_profile" /* GET_EMPLOYEE_PROFILE */: return handleToolCall( seatalkAPI.getEmployeeProfile.bind(seatalkAPI), toolArguments, toolName ); case "get_employee_code_with_email" /* GET_EMPLOYEE_CODE_WITH_EMAIL */: if (!toolArguments.emails || !Array.isArray(toolArguments.emails) || toolArguments.emails.length === 0) { throw new Error("emails parameter must be a non-empty array (between 1 and 500 items)"); } return handleToolCall( seatalkAPI.getEmployeeCodeWithEmail.bind(seatalkAPI), toolArguments, toolName ); case "check_employee_existence" /* CHECK_EMPLOYEE_EXISTENCE */: return handleToolCall( seatalkAPI.checkEmployeeExistence.bind(seatalkAPI), toolArguments, toolName ); case "get_user_language_preference" /* GET_USER_LANGUAGE_PREFERENCE */: return handleToolCall( seatalkAPI.getUserLanguagePreference.bind(seatalkAPI), toolArguments, toolName ); case "get_joined_group_chat_list" /* GET_JOINED_GROUP_CHAT_LIST */: return handleToolCall( seatalkAPI.getJoinedGroupChatList.bind(seatalkAPI), toolArguments || {}, toolName ); case "get_thread_by_thread_id" /* GET_THREAD_BY_THREAD_ID */: return handleToolCall( seatalkAPI.getThreadByThreadId.bind(seatalkAPI), toolArguments, toolName ); case "get_message_by_message_id" /* GET_MESSAGE_BY_MESSAGE_ID */: return handleToolCall( seatalkAPI.getMessageByMessageId.bind(seatalkAPI), toolArguments, toolName ); case "get_chat_history" /* GET_CHAT_HISTORY */: return handleToolCall( seatalkAPI.getChatHistory.bind(seatalkAPI), toolArguments, toolName ); case "get_group_info" /* GET_GROUP_INFO */: return handleToolCall( seatalkAPI.getGroupInfo.bind(seatalkAPI), toolArguments, toolName ); case "send_message_to_group_chat" /* SEND_MESSAGE_TO_GROUP_CHAT */: return handleToolCall( seatalkAPI.sendMessageToGroupChat.bind(seatalkAPI), toolArguments, toolName ); case "send_message_to_bot_user" /* SEND_MESSAGE_TO_BOT_USER */: return handleToolCall( seatalkAPI.sendMessageToBotUser.bind(seatalkAPI), toolArguments, toolName ); default: throw new Error(`Unknown tool: ${toolName}`); } } // src/index.ts var server = new Server( SERVER_INFO, SERVER_CAPABILITIES ); server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: getToolDefinitions() }; }); server.setRequestHandler(CallToolRequestSchema, async (request) => { console.error(`[Tool] Calling tool: ${request.params.name}`); try { return await processToolCall(request.params.name, request.params.arguments); } catch (error) { console.error(`[Error] Tool execution failed: ${error.message}`); return { content: [{ type: "text", text: `Error: ${error.message}` }], isError: true }; } }); async function main() { console.error("[Setup] Initializing SeaTalk MCP server..."); const transport = new StdioServerTransport(); await server.connect(transport); } main().catch((error) => { console.error("[Error] Server startup failed:", error); process.exit(1); });