UNPKG

@ably/cli

Version:

Ably CLI for Pub/Sub, Chat and Spaces

896 lines (895 loc) 39.2 kB
import { McpServer, ResourceTemplate, } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport as StdioConnection } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import ChannelsHistory from "../commands/channels/history.js"; import ChannelsList from "../commands/channels/list.js"; import ChannelsPresenceSubscribe from "../commands/channels/presence/subscribe.js"; import ChannelsPublish from "../commands/channels/publish.js"; import ChannelsSubscribe from "../commands/channels/subscribe.js"; import { getCliVersion } from "../utils/version.js"; // Maximum execution time for long-running operations (15 seconds) const MAX_EXECUTION_TIME = 15_000; export class AblyMcpServer { activeOperations = new Set(); configManager; controlHost; server; constructor(configManager, options) { this.configManager = configManager; this.controlHost = options?.controlHost; // Initialize the MCP server this.server = new McpServer({ name: "Ably CLI", version: process.env.npm_package_version || "1.0.0", }); } async start() { console.error("Initializing MCP server..."); // Set up client ID if not provided this.setupClientId(); // Set up tools and resources this.setupTools(); this.setupResources(); // Create a stdio transport const transport = new StdioConnection(); try { // Connect the server to the transport await this.server.connect(transport); console.error("MCP server ready, waiting for requests..."); // Register signal handlers for graceful shutdown process.on("SIGINT", () => this.shutdown()); process.on("SIGTERM", () => this.shutdown()); } catch (error) { console.error("Error starting MCP server:", error); throw error; } } async executeChannelsHistoryCommand(args) { try { // Parse arguments const channelName = args.find((arg) => !arg.startsWith("-")) || ""; if (!channelName || channelName === "--json") { throw new Error("Channel name is required"); } const limit = Number.parseInt(this.getArgValue(args, "--limit") || "100"); const direction = this.getArgValue(args, "--direction") || "backwards"; // Get Ably client const ably = await this.getAblyClient(); // Get channel const channel = ably.channels.get(channelName); // Get history const historyPage = await channel.history({ direction: direction, limit, }); return historyPage.items.map((msg) => ({ clientId: msg.clientId, connectionId: msg.connectionId, data: msg.data, id: msg.id ?? `no-id-${Date.now()}`, name: msg.name ?? "no-name", timestamp: msg.timestamp ?? Date.now(), })); } catch (error) { console.error("Error getting channel history:", error); throw new Error(`Failed to get channel history: ${error instanceof Error ? error.message : String(error)}`); } } async executeChannelsListCommand(args) { try { // Parse arguments const prefix = this.getArgValue(args, "--prefix"); const limit = Number.parseInt(this.getArgValue(args, "--limit") || "100"); // Get Ably client const ably = await this.getAblyClient(); // Build params const params = { limit }; if (prefix) params.prefix = prefix; // Make the API request const response = await ably.request("get", "/channels", params); if (response.statusCode !== 200) { throw new Error(`Failed to list channels: ${response.statusCode}`); } // Ensure response.items is an array before mapping const items = Array.isArray(response.items) ? response.items : []; // Map response to simplified format return items.map((channel) => ({ name: channel.channelId, occupancy: channel.occupancy, status: channel.status || {}, })); } catch (error) { console.error("Error listing channels:", error); throw new Error(`Failed to list channels: ${error instanceof Error ? error.message : String(error)}`); } } async executeChannelsPresenceCommand(args) { try { // Parse arguments const channelName = args.find((arg) => !arg.startsWith("-") && arg !== "--json") || ""; if (!channelName) { throw new Error("Channel name is required"); } // Get Ably client const ably = await this.getAblyClient(); // Get channel const channel = ably.channels.get(channelName); // Get presence members const presenceMembers = await channel.presence.get(); return presenceMembers.map((member) => ({ action: member.action === "present" || member.action === "enter" ? 1 : 0, clientId: member.clientId, connectionId: member.connectionId, data: member.data, id: member.id, timestamp: member.timestamp, })); } catch (error) { console.error("Error getting channel presence:", error); throw new Error(`Failed to get presence: ${error instanceof Error ? error.message : String(error)}`); } } async executeChannelsPublishCommand(args) { try { // Check if we're dealing with an array of arguments or an object let channelName; let message; let name; if (Array.isArray(args)) { // Parse arguments from command line channelName = args.find((arg) => !arg.startsWith("-") && arg !== "--json") || ""; if (!channelName) { throw new Error("Channel name is required"); } // Get message argument (next non-flag after channel name) const channelIndex = args.indexOf(channelName); message = args[channelIndex + 1]; if (!message || (typeof message === "string" && message.startsWith("-"))) { throw new Error("Message is required"); } // Try to parse as JSON if possible if (typeof message === "string") { try { message = JSON.parse(message); } catch { // Keep as string if not valid JSON } } name = this.getArgValue(args, "--name"); } else if (typeof args === "object" && args !== null) { // Handle direct object parameters (from MCP tool) channelName = args.channel; message = args.message; name = args.name; if (!channelName) { throw new Error("Channel name is required"); } if (message === undefined) { throw new Error("Message is required"); } } else { throw new Error("Invalid arguments format"); } // Get Ably client const ably = await this.getAblyClient(); // Get channel and publish const channel = ably.channels.get(channelName); if (name) { await channel.publish(name, message); return { data: message, name }; } // If message is already an object with name/data, use that if (typeof message === "object" && message !== null && "name" in message && "data" in message) { const msgName = String(message.name); await channel.publish(msgName, message.data); return { data: message.data, name: msgName }; } // Default event name await channel.publish("message", message); return { data: message, name: "message" }; } catch (error) { console.error("Error publishing to channel:", error); throw new Error(`Failed to publish message: ${error instanceof Error ? error.message : String(error)}`); } } async executeChannelsSubscribeCommand(args, signal) { try { // Parse arguments const channelName = args.find((arg) => !arg.startsWith("-") && arg !== "--json") || ""; if (!channelName) { throw new Error("Channel name is required"); } const rewind = Number.parseInt(this.getArgValue(args, "--rewind") || "0"); // Get Ably client const ably = await this.getAblyClient(); // Get channel const channel = ably.channels.get(channelName); // Subscribe for messages const messages = []; // Create a promise that resolves when signal is aborted or timeout const abortPromise = new Promise((resolve) => { if (signal) { signal.addEventListener("abort", () => resolve()); } // Also set a timeout setTimeout(() => resolve(), MAX_EXECUTION_TIME); }); // Create a promise for subscription const _subscribePromise = new Promise((_resolve) => { // Handle rewind if specified if (rewind > 0) { void (async () => { try { let currentPage = await channel.history({ direction: "backwards", limit: rewind, }); while (currentPage) { currentPage.items.forEach((msg) => { messages.push({ clientId: msg.clientId, connectionId: msg.connectionId, data: msg.data, id: msg.id ?? `no-id-${Date.now()}`, name: msg.name ?? "no-name", timestamp: msg.timestamp ?? Date.now(), }); }); if (!currentPage.hasNext()) { break; } currentPage = await currentPage.next(); } } catch (historyError) { console.error("Error fetching history during rewind:", historyError); } })(); } // Subscribe to new messages const _subscription = channel.subscribe((msg) => { messages.push({ clientId: msg.clientId, connectionId: msg.connectionId, data: msg.data, id: msg.id, name: msg.name, timestamp: msg.timestamp, }); }); }); // Wait for abort or timeout await abortPromise; // Unsubscribe await channel.unsubscribe(); return messages; } catch (error) { console.error("Error subscribing to channel:", error); throw new Error(`Failed to subscribe: ${error instanceof Error ? error.message : String(error)}`); } } async executeCommand(CommandClass, args, signal) { try { // Create direct execution functions for each command type if (CommandClass === ChannelsList) { return this.executeChannelsListCommand(Array.isArray(args) ? args : ["--json", args.channel]); } if (CommandClass === ChannelsHistory) { return this.executeChannelsHistoryCommand(Array.isArray(args) ? args : ["--json", args.channel]); } if (CommandClass === ChannelsPublish) { return this.executeChannelsPublishCommand(args); } if (CommandClass === ChannelsSubscribe) { return this.executeChannelsSubscribeCommand(Array.isArray(args) ? args : ["--json", args.channel], signal); } if (CommandClass === ChannelsPresenceSubscribe) { return this.executeChannelsPresenceCommand(Array.isArray(args) ? args : ["--json", args.channel]); } throw new Error(`Unsupported command class: ${CommandClass.name}`); } catch (error) { console.error("Error executing command:", error); throw error; } } async getAblyClient() { try { // Assign the imported module to a variable first const AblyModule = await import("ably"); const Ably = AblyModule.default; // Get API key from config const apiKey = this.configManager.getApiKey() || process.env.ABLY_API_KEY; if (!apiKey) { throw new Error('No API key configured. Please run "ably login" or set ABLY_API_KEY environment variable'); } const clientOptions = { clientId: process.env.ABLY_CLIENT_ID, key: apiKey, agents: { 'ably-cli': getCliVersion() }, }; // Create Ably REST client (not Realtime, to avoid connections) // Note: We can't use createAblyRestClient here since this class doesn't extend AblyBaseCommand const client = new Ably.Rest(clientOptions); // Type assertion to ensure compatibility with our interface return client; } catch (error) { console.error("Error creating Ably client:", error); throw new Error(`Failed to create Ably client: ${error instanceof Error ? error.message : String(error)}`); } } getArgValue(args, flag) { const index = args.indexOf(flag); if (index !== -1 && index < args.length - 1) { return args[index + 1]; } return undefined; } // Helper method to get a Control API instance async getControlApi() { try { const { ControlApi } = await import("../services/control-api.js"); const accessToken = process.env.ABLY_ACCESS_TOKEN || this.configManager.getAccessToken(); if (!accessToken) { throw new Error('No access token configured. Please run "ably login" to authenticate.'); } return new ControlApi({ accessToken, controlHost: this.controlHost || process.env.ABLY_CONTROL_HOST, }); } catch (error) { console.error("Error creating Control API client:", error); throw new Error(`Failed to create Control API client: ${error instanceof Error ? error.message : String(error)}`); } } setupClientId() { // If client ID not provided, generate one with mcp prefix if (!process.env.ABLY_CLIENT_ID) { process.env.ABLY_CLIENT_ID = `mcp-${Math.random().toString(36).slice(2, 10)}`; console.error(`Generated client ID: ${process.env.ABLY_CLIENT_ID}`); } } setupResources() { const resourceMethod = this.server.resource; // Channels resource resourceMethod("channels", new ResourceTemplate("ably://channels/{prefix?}", { list: async (extra) => { try { const args = ["--json"]; const params = extra?.variables || {}; const prefixParam = params.prefix; const prefix = typeof prefixParam === "string" ? prefixParam : Array.isArray(prefixParam) ? prefixParam[0] : undefined; if (prefix) args.push("--prefix", prefix); const commandResult = await this.executeCommand(ChannelsList, args); const channels = Array.isArray(commandResult) ? commandResult : []; return { resources: channels.map((channel) => ({ name: String(channel.name), uri: `ably://channels/${channel.name}`, })), }; } catch (error) { console.error("Error listing channels:", error); throw new Error(`Failed to list channels: ${error instanceof Error ? error.message : String(error)}`); } }, }), async (uri, params) => { try { const args = ["--json"]; const prefixParam = params.prefix; const prefix = typeof prefixParam === "string" ? prefixParam : Array.isArray(prefixParam) ? prefixParam[0] : undefined; if (prefix) args.push("--prefix", prefix); const commandResult = await this.executeCommand(ChannelsList, args); const channels = Array.isArray(commandResult) ? commandResult : []; return { contents: channels.map((channel) => ({ text: JSON.stringify(channel, null, 2), title: channel.name, uri: `ably://channels/${channel.name}`, })), }; } catch (error) { console.error("Error fetching channels resource:", error); throw new Error(`Failed to fetch channels: ${error instanceof Error ? error.message : String(error)}`); } }); // Channel History Resource resourceMethod("channel_history", new ResourceTemplate("ably://channel_history/{channel}", { list: undefined, }), async (uri, params) => { try { const args = ["--json"]; const channelParam = params.channel; const channel = typeof channelParam === "string" ? channelParam : Array.isArray(channelParam) ? channelParam[0] : undefined; if (channel) args.push(channel); const history = await this.executeCommand(ChannelsHistory, args); return { contents: [ { text: JSON.stringify(history, null, 2), title: `Message history for ${params.channel}`, uri: uri.href, }, ], }; } catch (error) { console.error("Error fetching channel history resource:", error); throw new Error(`Failed to fetch channel history: ${error instanceof Error ? error.message : String(error)}`); } }); // Channel Presence Resource resourceMethod("channel_presence", new ResourceTemplate("ably://channel_presence/{channel}", { list: undefined, }), async (uri, params) => { try { const args = ["--json"]; const channelParam = params.channel; const channel = typeof channelParam === "string" ? channelParam : Array.isArray(channelParam) ? channelParam[0] : undefined; if (channel) args.push(channel); const presence = await this.executeCommand(ChannelsPresenceSubscribe, args); return { contents: [ { text: JSON.stringify(presence, null, 2), title: `Presence members for ${params.channel}`, uri: uri.href, }, ], }; } catch (error) { console.error("Error fetching channel presence resource:", error); throw new Error(`Failed to fetch channel presence: ${error instanceof Error ? error.message : String(error)}`); } }); // Apps Resource resourceMethod("apps", new ResourceTemplate("ably://apps", { list: async () => { try { const controlApi = await this.getControlApi(); const apps = await controlApi.listApps(); // Add the current app indicator const currentAppId = this.configManager.getCurrentAppId(); return { resources: apps.map((app) => ({ current: app.id === currentAppId, name: app.name, uri: `ably://apps/${app.id}`, })), }; } catch (error) { console.error("Error listing apps:", error); throw new Error(`Failed to list apps: ${error instanceof Error ? error.message : String(error)}`); } }, }), async (uri) => { try { const controlApi = await this.getControlApi(); const apps = await controlApi.listApps(); // Add the current app indicator const currentAppId = this.configManager.getCurrentAppId(); const appsWithCurrent = apps.map((app) => ({ ...app, current: app.id === currentAppId, })); return { contents: [ { text: JSON.stringify(appsWithCurrent, null, 2), title: "Ably Apps", uri: uri.href, }, ], }; } catch (error) { console.error("Error fetching apps resource:", error); throw new Error(`Failed to fetch apps: ${error instanceof Error ? error.message : String(error)}`); } }); // App Stats Resource resourceMethod("app_stats", new ResourceTemplate("ably://apps/{appId}/stats", { list: undefined }), async (uri, params) => { try { // Use the app ID from the URI or fall back to default const appIdParam = params.appId; const appId = (typeof appIdParam === "string" ? appIdParam : Array.isArray(appIdParam) ? appIdParam[0] : undefined) || this.configManager.getCurrentAppId(); if (!appId) { throw new Error("No app ID provided and no default app selected"); } const controlApi = await this.getControlApi(); // Get stats for the last 24 hours const now = new Date(); const start = now.getTime() - 24 * 60 * 60 * 1000; // 24 hours ago const end = now.getTime(); const stats = await controlApi.getAppStats(appId, { end, limit: 10, start, unit: "minute", }); return { contents: [ { text: JSON.stringify(stats, null, 2), title: `Statistics for app ${appId}`, uri: uri.href, }, ], }; } catch (error) { console.error("Error fetching app stats resource:", error); throw new Error(`Failed to fetch app stats: ${error instanceof Error ? error.message : String(error)}`); } }); // App Keys Resource resourceMethod("app_keys", new ResourceTemplate("ably://apps/{appId}/keys", { list: undefined }), async (uri, params) => { try { // Use the app ID from the URI or fall back to default const appIdParam = params.appId; const appId = (typeof appIdParam === "string" ? appIdParam : Array.isArray(appIdParam) ? appIdParam[0] : undefined) || this.configManager.getCurrentAppId(); if (!appId) { throw new Error("No app ID provided and no default app selected"); } const controlApi = await this.getControlApi(); const keys = await controlApi.listKeys(appId); // Add the current key indicator const currentKeyId = this.configManager.getKeyId(appId); const currentKeyName = currentKeyId && currentKeyId.includes(".") ? currentKeyId : currentKeyId ? `${appId}.${currentKeyId}` : undefined; const keysWithCurrent = keys.map((key) => { const keyName = `${key.appId}.${key.id}`; return { ...key, current: keyName === currentKeyName, keyName, capabilities: {}, // Add missing capabilities property }; }); return { contents: [ { text: JSON.stringify(keysWithCurrent, null, 2), title: `API Keys for app ${appId}`, uri: uri.href, }, ], }; } catch (error) { console.error("Error fetching app keys resource:", error); throw new Error(`Failed to fetch app keys: ${error instanceof Error ? error.message : String(error)}`); } }); } setupTools() { // List Channels tool this.server.tool("list_channels", "List active channels using the channel enumeration API", { limit: z .number() .optional() .describe("Maximum number of channels to return"), prefix: z.string().optional().describe("Filter channels by prefix"), }, async (_params) => { try { const result = await this.executeCommand(ChannelsList, [ "--json", ...(_params.prefix ? ["--prefix", _params.prefix] : []), ...(_params.limit ? ["--limit", _params.limit.toString()] : []), ]); return { content: [ { text: JSON.stringify(result, null, 2), type: "text", }, ], }; } catch (error) { console.error("Error listing channels:", error); throw new Error(`Failed to list channels: ${error instanceof Error ? error.message : String(error)}`); } }); // Channel History tool this.server.tool("get_channel_history", "Retrieve message history for a channel", { channel: z.string().describe("Name of the channel to get history for"), direction: z .enum(["forwards", "backwards"]) .optional() .describe("Direction of message history"), limit: z .number() .optional() .describe("Maximum number of messages to retrieve"), }, async (_params) => { try { const args = ["--json", _params.channel]; if (_params.limit) args.push("--limit", _params.limit.toString()); if (_params.direction) args.push("--direction", _params.direction); const result = await this.executeCommand(ChannelsHistory, args); return { content: [ { text: JSON.stringify(result, null, 2), type: "text", }, ], }; } catch (error) { console.error("Error getting channel history:", error); throw new Error(`Failed to get channel history: ${error instanceof Error ? error.message : String(error)}`); } }); // Publish to Channel tool this.server.tool("publish_to_channel", "Publish a message to an Ably channel", { channel: z.string().describe("Name of the channel to publish to"), message: z .string() .describe("Message content to publish (can be string or JSON)"), name: z .string() .optional() .describe("Event name (optional, defaults to 'message')"), }, async (_params) => { try { // Try to parse message as JSON if it's a string let messageContent = _params.message; if (typeof messageContent === "string") { try { messageContent = JSON.parse(messageContent); } catch { // Keep as string if not valid JSON } } // Create parameters object with parsed message const paramsWithParsedMessage = { ..._params, message: messageContent, }; // Pass parameters in the format expected by executeChannelsPublishCommand const result = await this.executeChannelsPublishCommand(paramsWithParsedMessage); return { content: [ { text: JSON.stringify(result, null, 2), type: "text", }, ], }; } catch (error) { console.error("Error publishing to channel:", error); throw new Error(`Failed to publish to channel: ${error instanceof Error ? error.message : String(error)}`); } }); // Channel Presence tool this.server.tool("get_channel_presence", "Get presence members for a channel", { channel: z.string().describe("Name of the channel to get presence for"), }, async (_params) => { try { const args = ["--json", _params.channel]; const result = await this.executeCommand(ChannelsPresenceSubscribe, args); return { content: [ { text: JSON.stringify(result, null, 2), type: "text", }, ], }; } catch (error) { console.error("Error getting channel presence:", error); throw new Error(`Failed to get channel presence: ${error instanceof Error ? error.message : String(error)}`); } }); // Apps List tool this.server.tool("list_apps", "List Ably apps within the current account", { format: z .enum(["json", "pretty"]) .optional() .default("json") .describe("Output format (json or pretty)"), }, async (_params) => { try { // Create a Control API instance const controlApi = await this.getControlApi(); // Get the apps const apps = await controlApi.listApps(); // Add the current app indicator const currentAppId = this.configManager.getCurrentAppId(); const appsWithCurrent = apps.map((app) => ({ ...app, current: app.id === currentAppId, })); return { content: [ { text: JSON.stringify(appsWithCurrent, null, 2), type: "text", }, ], }; } catch (error) { console.error("Error listing apps:", error); throw new Error(`Failed to list apps: ${error instanceof Error ? error.message : String(error)}`); } }); // Apps Stats tool this.server.tool("get_app_stats", "Get statistics for an Ably app", { app: z .string() .optional() .describe("App ID to get stats for (uses current app if not provided)"), end: z .number() .optional() .describe("End time in milliseconds since epoch"), limit: z .number() .optional() .default(10) .describe("Maximum number of stats records to return"), start: z .number() .optional() .describe("Start time in milliseconds since epoch"), unit: z .enum(["minute", "hour", "day", "month"]) .optional() .default("minute") .describe("Time unit for stats"), }, async (_params) => { try { // Use provided app ID or fall back to default app ID const appId = _params.app || this.configManager.getCurrentAppId(); if (!appId) { throw new Error("No app ID provided and no default app selected"); } // Create a Control API instance const controlApi = await this.getControlApi(); // If no start/end time provided, use the last 24 hours const now = new Date(); const start = _params.start || now.getTime() - 24 * 60 * 60 * 1000; // 24 hours ago const end = _params.end || now.getTime(); // Get the stats const stats = await controlApi.getAppStats(appId, { end, limit: _params.limit, start, unit: _params.unit, }); return { content: [ { text: JSON.stringify(stats, null, 2), type: "text", }, ], }; } catch (error) { console.error("Error getting app stats:", error); throw new Error(`Failed to get app stats: ${error instanceof Error ? error.message : String(error)}`); } }); // Auth Keys List tool this.server.tool("list_auth_keys", "List API keys for an Ably app", { app: z .string() .optional() .describe("App ID to list keys for (uses current app if not provided)"), }, async (_params) => { try { // Get app ID from parameter or current config const appId = _params.app || this.configManager.getCurrentAppId(); if (!appId) { throw new Error("No app specified"); } // Create a Control API instance const controlApi = await this.getControlApi(); // Get the keys const keys = await controlApi.listKeys(appId); // Add the current key indicator const currentKeyId = this.configManager.getKeyId(appId); const currentKeyName = currentKeyId && currentKeyId.includes(".") ? currentKeyId : currentKeyId ? `${appId}.${currentKeyId}` : undefined; const keysWithCurrent = keys.map((key) => { const keyName = `${key.appId}.${key.id}`; return { ...key, current: keyName === currentKeyName, keyName, // Add the full key name capabilities: {}, // Add missing capabilities property }; }); return { content: [ { text: JSON.stringify(keysWithCurrent, null, 2), type: "text", }, ], }; } catch (error) { console.error("Error listing keys:", error); throw new Error(`Failed to list keys: ${error instanceof Error ? error.message : String(error)}`); } }); } shutdown() { console.error("MCP server shutting down..."); // Abort any active operations for (const controller of this.activeOperations) { controller.abort(); } // Exit process process.exit(0); } }