Zulip MCP Server

#!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequest, CallToolRequestSchema, ListToolsRequestSchema, Tool, } from "@modelcontextprotocol/sdk/types.js"; import zulipInit from "zulip-js"; // Type definitions for tool arguments interface ListChannelsArgs { include_private?: boolean; include_web_public?: boolean; include_subscribed?: boolean; } interface PostMessageArgs { channel_name: string; topic: string; content: string; } interface SendDirectMessageArgs { recipients: string[]; content: string; } interface AddReactionArgs { message_id: number; emoji_name: string; } interface GetChannelHistoryArgs { channel_name: string; topic: string; limit?: number; anchor?: string; } interface GetTopicsArgs { channel_id: number; } interface SubscribeToChannelArgs { channel_name: string; } // Tool definitions const listChannelsTool: Tool = { name: "zulip_list_channels", description: "List available channels (streams) in the Zulip organization", inputSchema: { type: "object", properties: { include_private: { type: "boolean", description: "Whether to include private streams", default: false, }, include_web_public: { type: "boolean", description: "Whether to include web-public streams", default: true, }, include_subscribed: { type: "boolean", description: "Whether to include streams the bot is subscribed to", default: true, }, }, }, }; const postMessageTool: Tool = { name: "zulip_post_message", description: "Post a new message to a Zulip channel (stream)", inputSchema: { type: "object", properties: { channel_name: { type: "string", description: "The name of the stream to post to", }, topic: { type: "string", description: "The topic within the stream", }, content: { type: "string", description: "The message content to post", }, }, required: ["channel_name", "topic", "content"], }, }; const sendDirectMessageTool: Tool = { name: "zulip_send_direct_message", description: "Send a direct message to one or more users", inputSchema: { type: "object", properties: { recipients: { type: "array", items: { type: "string", }, description: "Email addresses or user IDs of recipients", }, content: { type: "string", description: "The message content to send", }, }, required: ["recipients", "content"], }, }; const addReactionTool: Tool = { name: "zulip_add_reaction", description: "Add an emoji reaction to a message", inputSchema: { type: "object", properties: { message_id: { type: "number", description: "The ID of the message to react to", }, emoji_name: { type: "string", description: "Emoji name without colons", }, }, required: ["message_id", "emoji_name"], }, }; const getChannelHistoryTool: Tool = { name: "zulip_get_channel_history", description: "Get recent messages from a channel (stream) and topic", inputSchema: { type: "object", properties: { channel_name: { type: "string", description: "The name of the stream", }, topic: { type: "string", description: "The topic name", }, limit: { type: "number", description: "Number of messages to retrieve (default 20)", default: 20, }, anchor: { type: "string", description: "Message ID to start from (default 'newest')", default: "newest", }, }, required: ["channel_name", "topic"], }, }; const getTopicsTool: Tool = { name: "zulip_get_topics", description: "Get topics in a channel (stream)", inputSchema: { type: "object", properties: { channel_id: { type: "number", description: "The ID of the stream", }, }, required: ["channel_id"], }, }; const subscribeToChannelTool: Tool = { name: "zulip_subscribe_to_channel", description: "Subscribe the bot to a channel (stream)", inputSchema: { type: "object", properties: { channel_name: { type: "string", description: "The name of the stream to subscribe to", }, }, required: ["channel_name"], }, }; const getUsersTool: Tool = { name: "zulip_get_users", description: "Get list of users in the Zulip organization", inputSchema: { type: "object", properties: {}, }, }; class ZulipClient { private client: any; constructor(config: any) { this.initClient(config); } private async initClient(config: any) { try { this.client = await zulipInit(config); } catch (error) { console.error("Error initializing Zulip client:", error); throw error; } } async getStreams(includePrivate = false, includeWebPublic = true, includeSubscribed = true) { try { const params: any = {}; if (includePrivate) { params.include_private = true; } if (!includeWebPublic) { params.include_web_public = false; } if (!includeSubscribed) { params.include_subscribed = false; } return await this.client.streams.retrieve(params); } catch (error) { console.error("Error getting streams:", error); throw error; } } async sendStreamMessage(streamName: string, topic: string, content: string) { try { const params = { to: streamName, type: "stream", topic: topic, content: content, }; return await this.client.messages.send(params); } catch (error) { console.error("Error sending stream message:", error); throw error; } } async sendDirectMessage(recipients: string[], content: string) { try { const params = { to: recipients, type: "private", content: content, }; return await this.client.messages.send(params); } catch (error) { console.error("Error sending direct message:", error); throw error; } } async addReaction(messageId: number, emojiName: string) { try { return await this.client.reactions.add({ message_id: messageId, emoji_name: emojiName, }); } catch (error) { console.error("Error adding reaction:", error); throw error; } } async getMessages(streamName: string, topic: string, limit = 20, anchor = "newest") { try { // First, need to find the stream ID const streamsResponse = await this.getStreams(true, true, true); const stream = streamsResponse.streams.find((s: any) => s.name === streamName); if (!stream) { throw new Error(`Stream "${streamName}" not found`); } // Construct narrow to filter by stream and topic const narrow = [ { operator: "stream", operand: streamName }, { operator: "topic", operand: topic }, ]; const params = { narrow: JSON.stringify(narrow), num_before: Math.floor(limit / 2), num_after: Math.floor(limit / 2), anchor: anchor, }; return await this.client.messages.retrieve(params); } catch (error) { console.error("Error getting messages:", error); throw error; } } async getTopics(streamId: number) { try { return await this.client.streams.topics.retrieve({ stream_id: streamId }); } catch (error) { console.error("Error getting topics:", error); throw error; } } async subscribeToStream(streamName: string) { try { const subscriptions = [{ name: streamName }]; return await this.client.streams.subscribe({ subscriptions: JSON.stringify(subscriptions) }); } catch (error) { console.error("Error subscribing to stream:", error); throw error; } } async getUsers() { try { return await this.client.users.retrieve(); } catch (error) { console.error("Error getting users:", error); throw error; } } } async function main() { const zulipEmail = process.env.ZULIP_EMAIL; const zulipApiKey = process.env.ZULIP_API_KEY; const zulipUrl = process.env.ZULIP_URL; if (!zulipEmail || !zulipApiKey || !zulipUrl) { console.error( "Please set ZULIP_EMAIL, ZULIP_API_KEY, and ZULIP_URL environment variables" ); process.exit(1); } console.error("Starting Zulip MCP Server..."); const server = new Server( { name: "Zulip MCP Server", version: "0.1.0", }, { capabilities: { tools: {}, }, } ); const zulipConfig = { username: zulipEmail, apiKey: zulipApiKey, realm: zulipUrl }; const zulipClient = new ZulipClient(zulipConfig); server.setRequestHandler( CallToolRequestSchema, async (request: CallToolRequest) => { console.error("Received CallToolRequest:", request); try { if (!request.params.arguments) { throw new Error("No arguments provided"); } switch (request.params.name) { case "zulip_list_channels": { const args = request.params.arguments as unknown as ListChannelsArgs; const response = await zulipClient.getStreams( args.include_private, args.include_web_public, args.include_subscribed ); return { content: [{ type: "text", text: JSON.stringify(response) }], }; } case "zulip_post_message": { const args = request.params.arguments as unknown as PostMessageArgs; if (!args.channel_name || !args.topic || !args.content) { throw new Error( "Missing required arguments: channel_name, topic, and content" ); } const response = await zulipClient.sendStreamMessage( args.channel_name, args.topic, args.content ); return { content: [{ type: "text", text: JSON.stringify(response) }], }; } case "zulip_send_direct_message": { const args = request.params.arguments as unknown as SendDirectMessageArgs; if (!args.recipients || !args.content) { throw new Error( "Missing required arguments: recipients and content" ); } const response = await zulipClient.sendDirectMessage( args.recipients, args.content ); return { content: [{ type: "text", text: JSON.stringify(response) }], }; } case "zulip_add_reaction": { const args = request.params.arguments as unknown as AddReactionArgs; if (args.message_id === undefined || !args.emoji_name) { throw new Error( "Missing required arguments: message_id and emoji_name" ); } const response = await zulipClient.addReaction( args.message_id, args.emoji_name ); return { content: [{ type: "text", text: JSON.stringify(response) }], }; } case "zulip_get_channel_history": { const args = request.params.arguments as unknown as GetChannelHistoryArgs; if (!args.channel_name || !args.topic) { throw new Error( "Missing required arguments: channel_name and topic" ); } const response = await zulipClient.getMessages( args.channel_name, args.topic, args.limit, args.anchor ); return { content: [{ type: "text", text: JSON.stringify(response) }], }; } case "zulip_get_topics": { const args = request.params.arguments as unknown as GetTopicsArgs; if (args.channel_id === undefined) { throw new Error("Missing required argument: channel_id"); } const response = await zulipClient.getTopics(args.channel_id); return { content: [{ type: "text", text: JSON.stringify(response) }], }; } case "zulip_subscribe_to_channel": { const args = request.params.arguments as unknown as SubscribeToChannelArgs; if (!args.channel_name) { throw new Error("Missing required argument: channel_name"); } const response = await zulipClient.subscribeToStream(args.channel_name); return { content: [{ type: "text", text: JSON.stringify(response) }], }; } case "zulip_get_users": { const response = await zulipClient.getUsers(); return { content: [{ type: "text", text: JSON.stringify(response) }], }; } default: throw new Error(`Unknown tool: ${request.params.name}`); } } catch (error) { console.error("Error executing tool:", error); return { content: [ { type: "text", text: JSON.stringify({ error: error instanceof Error ? error.message : String(error), }), }, ], }; } } ); server.setRequestHandler(ListToolsRequestSchema, async () => { console.error("Received ListToolsRequest"); return { tools: [ listChannelsTool, postMessageTool, sendDirectMessageTool, addReactionTool, getChannelHistoryTool, getTopicsTool, subscribeToChannelTool, getUsersTool, ], }; }); const transport = new StdioServerTransport(); console.error("Connecting server to transport..."); await server.connect(transport); console.error("Zulip MCP Server running on stdio"); } main().catch((error) => { console.error("Fatal error in main():", error); process.exit(1); });