UNPKG

@pubnub/mcp

Version:

PubNub Model Context Protocol MCP Server for Cursor and Claude

1,389 lines (1,242 loc) 56.1 kB
#!/usr/bin/env node import { fileURLToPath, URL } from 'url'; import { dirname, join as pathJoin, extname, basename } from 'path'; import fs from 'fs'; import PubNub from 'pubnub'; import { HtmlToMarkdown } from './lib/html-to-markdown.js'; import { parse } from 'node-html-parser'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; import express from 'express'; import { randomUUID } from 'node:crypto'; import { z } from 'zod'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const pkg = JSON.parse(fs.readFileSync(pathJoin(__dirname, 'package.json'), 'utf8')); // Parse command line arguments const args = process.argv.slice(2); const isChatSdkMode = args.includes('--chat-sdk'); // Create and configure PubNub instance with userId = 'pubnub_mcp' const pubnub = new PubNub({ publishKey: process.env.PUBNUB_PUBLISH_KEY || 'demo', subscribeKey: process.env.PUBNUB_SUBSCRIBE_KEY || 'demo', userId: 'pubnub_mcp', }); // MCP server setup const server = new McpServer({ name: 'pubnub_mcp_server', version: pkg.version, }); // Store tool handlers for reuse in HTTP sessions const toolHandlers = {}; // Tool definitions - defined once and reused const toolDefinitions = {}; // Tool: "read_pubnub_sdk_docs" (PubNub SDK docs for a given language) const languages = [ 'javascript', 'python', 'java', 'go', 'ruby', 'swift', 'objective-c', 'c-sharp', 'php', 'dart', 'rust', 'unity', 'kotlin', 'unreal', 'c-core', 'rest-api', ]; const apiReferences = [ 'configuration', 'publish-and-subscribe', 'presence', 'access-manager', 'channel-groups', 'storage-and-playback', 'mobile-push', 'objects', 'App Context', 'files', 'message-actions', 'misc', // Special section for PubNub Functions; loads from local resources/pubnub_functions.md 'functions', ]; // Define tool metadata for read_pubnub_sdk_docs toolDefinitions['read_pubnub_sdk_docs'] = { name: 'read_pubnub_sdk_docs', description: 'Retrieves official PubNub SDK documentation for a given programming language and API reference section. Call this tool for low-level API details, code examples, and usage patterns. Returns documentation in markdown format. For conceptual guides, best practices, and how-tos, also call the read_pubnub_resources tool.', parameters: { language: z.enum(languages).describe('Programming language of the PubNub SDK to retrieve documentation for (e.g. javascript, python)'), apiReference: z.enum(apiReferences).optional().default('configuration').describe('API reference section to retrieve (e.g. configuration, publish-and-subscribe, objects (App Context); defaults to configuration)'), } }; // Define the handler for read_pubnub_sdk_docs toolHandlers['read_pubnub_sdk_docs'] = async ({ language, apiReference }) => { const apiRefKey = apiReference === 'App Context' ? 'objects' : apiReference; // Early return for PubNub Functions documentation if (apiRefKey === 'functions') { try { const functionsDoc = fs.readFileSync( pathJoin(__dirname, 'resources', 'pubnub_functions.md'), 'utf8' ); return { content: [ { type: 'text', text: functionsDoc } ] }; } catch (err) { return { content: [ { type: 'text', text: `Error loading functions documentation: ${err}` } ], isError: true }; } } // Special case for rest-api - load only the three specific files if (language === 'rest-api') { const restApiFiles = [ 'rest-api_publish-message-to-channel.md', 'rest-api_subscribe-v-2.md', 'rest-api_get-message-history.md' ]; let combinedRestApiContent = ''; for (const filename of restApiFiles) { try { const filePath = pathJoin(__dirname, 'resources', 'sdk_docs', filename); const content = fs.readFileSync(filePath, 'utf8'); combinedRestApiContent += content + '\n\n'; } catch (err) { //console.error(`Error loading ${filename}: ${err}`); combinedRestApiContent += `Error loading ${filename}: ${err.message}\n\n`; } } return { content: [ { type: 'text', text: combinedRestApiContent + getPubNubInitSDKInstructions(), }, ], }; } // Regular processing for other languages // Try to load from cached files first (with version checking), fallback to API calls if not available or outdated let sdkResponse = loadCachedSDKDoc(language, 'overview'); if (!sdkResponse) { const sdkURL = `https://www.pubnub.com/docs/sdks/${language}`; sdkResponse = await loadArticle(sdkURL); } let apiRefResponse = loadCachedSDKDoc(language, apiRefKey); if (!apiRefResponse) { const apiRefURL = `https://www.pubnub.com/docs/sdks/${language}/api-reference/${apiRefKey}`; apiRefResponse = await loadArticle(apiRefURL); // Apply "(old)" section removal logic for dynamically loaded content if (apiRefResponse && !apiRefResponse.startsWith('Error fetching')) { const lines = apiRefResponse.split('\n'); const oldIndex = lines.findIndex((line) => /^##\s.*\(old\)/i.test(line)); if (oldIndex !== -1) { apiRefResponse = lines.slice(0, oldIndex).join('\n'); } } } const context7Response = loadLanguageFile(language); const presenceBestPracticesResponse = loadPresenceBestPracticesFile(language); // Combine the content of both responses const combinedContent = [sdkResponse, apiRefResponse, context7Response, presenceBestPracticesResponse].join('\n\n'); // Return the combined content return { content: [ { type: 'text', text: combinedContent + getPubNubInitSDKInstructions(), }, ], }; }; // Function that loads a file from resources directory function loadLanguageFile(file) { // Java Github repository does not have a specific language file, so we return an empty string if (file==='java') { return ''; } try { const content = fs.readFileSync(pathJoin(__dirname, 'resources', 'languages', `${file}.md`), 'utf8'); return content; } catch (err) { //console.error(`Error loading specific langauge file ${file}: ${err}`); return ''; } } // Function that loads presence best practices for JavaScript requests function loadPresenceBestPracticesFile(language) { if (language === 'javascript') { try { const content = fs.readFileSync(pathJoin(__dirname, 'resources', 'how_to_use_pubnub_presence_best_practices.md'), 'utf8'); return content; } catch (err) { //console.error(`Error loading presence best practices file: ${err}`); return ''; } } return ''; } // Function to sanitize filenames for cached SDK docs function sanitizeFilename(str) { return str.replace(/[^a-z0-9-]/gi, '_'); } // Function that loads cached SDK documentation from local files with version checking function loadCachedSDKDoc(language, type, forceRefresh = false) { try { const filename = `${sanitizeFilename(language)}_${sanitizeFilename(type)}.md`; const filePath = pathJoin(__dirname, 'resources', 'sdk_docs', filename); // Check if file exists if (!fs.existsSync(filePath)) { return null; } const content = fs.readFileSync(filePath, 'utf8'); // Skip version checking if force refresh is requested or for REST API if (forceRefresh || language === 'rest-api') { return content; } // Extract version from cached content const cachedVersion = extractVersionFromCachedDoc(content); const currentVersion = sdkVersions[language]; // If we have both versions and they match, return cached content if (cachedVersion && currentVersion && isVersionMatch(cachedVersion, currentVersion)) { return content; } // If versions don't match or we can't determine versions, return null to trigger fresh fetch if (cachedVersion && currentVersion && !isVersionMatch(cachedVersion, currentVersion)) { //console.log(`Version mismatch for ${language}: cached=${cachedVersion}, current=${currentVersion}. Will fetch fresh content.`); } return null; } catch (err) { //console.error(`Error loading cached SDK doc ${language}_${type}: ${err}`); return null; } } // Utility function that fetches the article content from the PubNub SDK documentation async function loadArticle(url) { try { const response = await fetch(url); if (!response.ok) { return `Error fetching ${url}: ${response.status} ${response.statusText}`; } const html = await response.text(); const root = parse(html); const article = root.querySelector('article'); const converter = new HtmlToMarkdown(); return converter.turndown(article?.innerHTML || ''); } catch (err) { return `Error fetching ${url}: ${err.message}`; } } // SDK Version Management System const sdkVersions = {}; // Store current SDK versions: { language: version } // Extract version from cached SDK documentation content function extractVersionFromCachedDoc(content) { if (!content) return null; // Look for version patterns in the first few lines const lines = content.split('\n').slice(0, 10); for (const line of lines) { // Pattern: "# Language API & SDK Docs X.Y.Z" const versionMatch = line.match(/^#\s+\w+\s+.*?(\d+\.\d+\.\d+)/i); if (versionMatch) { return versionMatch[1]; } } return null; } // Fetch current SDK version from PubNub documentation async function fetchCurrentSDKVersion(language) { try { const sdkURL = `https://www.pubnub.com/docs/sdks/${language}`; const response = await fetch(sdkURL); if (!response.ok) { //console.error(`Failed to fetch version for ${language}: ${response.status}`); return null; } const html = await response.text(); const root = parse(html); const article = root.querySelector('article'); if (article) { const text = article.text; // Look for version patterns in the content const versionMatch = text.match(/(\d+\.\d+\.\d+)/); if (versionMatch) { return versionMatch[1]; } } return null; } catch (err) { //console.error(`Error fetching current SDK version for ${language}: ${err.message}`); return null; } } // Compare versions and determine if cached version is current function isVersionMatch(cachedVersion, currentVersion) { if (!cachedVersion || !currentVersion) return false; return cachedVersion === currentVersion; } // Check and update SDK versions for all supported languages async function checkAndUpdateVersions() { //console.log('Checking SDK versions...'); for (const language of languages) { if (language === 'rest-api') continue; // Skip REST API as it doesn't have versions try { // Get current version from web const currentVersion = await fetchCurrentSDKVersion(language); if (currentVersion) { sdkVersions[language] = currentVersion; //console.log(`${language}: current version ${currentVersion}`); } else { //console.log(`${language}: could not determine current version`); } // Add small delay between requests to avoid overwhelming the server await new Promise(resolve => setTimeout(resolve, 100)); } catch (err) { //console.error(`Error checking version for ${language}: ${err.message}`); } } } // Parse chat_sdk_urls.txt to generate mapping of Chat SDK documentation URLs per language and topic const chatSdkUrlsPath = pathJoin(__dirname, 'chat_sdk_urls.txt'); let chatSdkDocsUrlMap = {}; try { const chatSdkUrlsContent = fs.readFileSync(chatSdkUrlsPath, 'utf8'); const lines = chatSdkUrlsContent.split(/\n/); let currentLang = null; for (const rawLine of lines) { const line = rawLine.trim(); if (!line) { currentLang = null; continue; } if (!line.startsWith('http')) { currentLang = line.toLowerCase(); chatSdkDocsUrlMap[currentLang] = {}; } else if (currentLang) { const url = line; const urlObj = new URL(url); const pathSegments = urlObj.pathname.split('/').filter(Boolean); let topicKey; const buildIndex = pathSegments.indexOf('build'); const learnIndex = pathSegments.indexOf('learn'); if (buildIndex !== -1) { topicKey = pathSegments[pathSegments.length - 1]; } else if (learnIndex !== -1) { const remaining = pathSegments.slice(learnIndex + 1); topicKey = remaining.length > 1 ? remaining[remaining.length - 1] : remaining[0]; } else { topicKey = pathSegments[pathSegments.length - 1]; } chatSdkDocsUrlMap[currentLang][topicKey] = url; } } } catch (err) { //console.error(`Error loading chat_sdk_urls.txt: ${err}`); } const chatSdkLanguages = Object.keys(chatSdkDocsUrlMap); const chatSdkTopics = chatSdkLanguages.length > 0 ? Object.keys(chatSdkDocsUrlMap[chatSdkLanguages[0]]) : []; // Define the handler for read_pubnub_chat_sdk_docs toolHandlers['read_pubnub_chat_sdk_docs'] = async ({ language, topic }) => { const url = chatSdkDocsUrlMap[language]?.[topic]; if (!url) { return { content: [ { type: 'text', text: `Documentation URL not found for language '${language}' and topic '${topic}'.` }, ], isError: true, }; } try { const markdown = await loadArticle(url); return { content: [{ type: 'text', text: markdown }], }; } catch (err) { return { content: [{ type: 'text', text: `Error fetching ${url}: ${err.message || err}` }], isError: true, }; } }; // Define tool metadata for read_pubnub_chat_sdk_docs toolDefinitions['read_pubnub_chat_sdk_docs'] = { name: 'read_pubnub_chat_sdk_docs', description: 'Retrieves official PubNub Chat SDK documentation for a given Chat SDK language and topic section. Call this tool whenever you need detailed Chat SDK docs, code examples, or usage patterns. Returns documentation in markdown format.', parameters: { language: z.enum(chatSdkLanguages).describe('Chat SDK language to retrieve documentation for'), topic: z.enum(chatSdkTopics).describe('Chat SDK documentation topic to retrieve'), } }; // Tool: "read_pubnub_resources" (fetch PubNub conceptual guides and how-to documentation from markdown files) // Dynamically generate available resource names based on markdown files in the resources directory and languages subdirectory const resourcesDir = pathJoin(__dirname, 'resources'); const languagesDir = pathJoin(resourcesDir, 'languages'); const pubnubResourceOptions = (() => { try { // Top-level markdown files in resources directory const files = fs.readdirSync(resourcesDir); const topLevel = files .filter((file) => fs.statSync(pathJoin(resourcesDir, file)).isFile()) .filter((file) => extname(file).toLowerCase() === '.md') .map((file) => basename(file, extname(file))); // Markdown files in resources/languages directory let langFiles = []; if (fs.existsSync(languagesDir)) { langFiles = fs.readdirSync(languagesDir) .filter((file) => fs.statSync(pathJoin(languagesDir, file)).isFile()) .filter((file) => extname(file).toLowerCase() === '.md') .map((file) => basename(file, extname(file))); } return [...topLevel, ...langFiles]; } catch (err) { //console.error(`Error reading resources directories: ${err}`); return []; } })(); // Define the handler for read_pubnub_resources toolHandlers['read_pubnub_resources'] = async ({ document }) => { try { // determine the file path for the requested resource (top-level or languages) let filePath = pathJoin(resourcesDir, `${document}.md`); if (!fs.existsSync(filePath)) { // fallback to languages directory filePath = pathJoin(languagesDir, `${document}.md`); if (!fs.existsSync(filePath)) { return { content: [ { type: 'text', text: `Documentation file not found: ${document}.md`, }, ], isError: true, }; } } const content = fs.readFileSync(filePath, 'utf8'); return { content: [ { type: 'text', text: content + getPubNubInitSDKInstructions(), }, ], }; } catch (err) { return { content: [ { type: 'text', text: `Error reading pubnub documentation for '${document}.md': ${err.message || err}`, }, ], isError: true, }; } }; // Define tool metadata for read_pubnub_resources toolDefinitions['read_pubnub_resources'] = { name: 'read_pubnub_resources', description: 'Retrieves PubNub conceptual guides and how-to documentation from markdown files in the resources directory. Call this tool for overviews, integration instructions, best practices, and troubleshooting tips. Returns documentation in markdown format. For detailed API reference and SDK code samples, also call the read_pubnub_sdk_docs tool.', parameters: { document: z.enum(pubnubResourceOptions).describe('Resource name to fetch (file name without .md under resources directory, e.g., pubnub_concepts, how_to_send_receive_json, how_to_encrypt_messages_files)'), } }; // Define the handler for publish_pubnub_message toolHandlers['publish_pubnub_message'] = async ({ channel, message }) => { try { const result = await pubnub.publish({ channel, message, }); return { content: [ { type: 'text', text: `Message published successfully. Timetoken: ${result.timetoken}`, }, ], }; } catch (err) { return { content: [ { type: 'text', text: `Error publishing message: ${err}`, }, ], isError: true, }; } }; // Define tool metadata for publish_pubnub_message toolDefinitions['publish_pubnub_message'] = { name: 'publish_pubnub_message', description: 'Publishes a message to a specified PubNub channel. Call this tool whenever you need to send data through PubNub. Provide the channel name and message payload. Returns a timetoken confirming successful publication.', parameters: { channel: z.string().describe('Name of the PubNub channel (string) to publish the message to'), message: z.string().describe('Message payload as a string'), } }; // Define the handler for signal_pubnub_message toolHandlers['signal_pubnub_message'] = async ({ channel, message }) => { try { const result = await pubnub.signal({ channel, message, }); return { content: [ { type: 'text', text: `Signal sent successfully. Timetoken: ${result.timetoken}`, }, ], }; } catch (err) { return { content: [ { type: 'text', text: `Error sending signal: ${err}`, }, ], isError: true, }; } }; // Define tool metadata for signal_pubnub_message toolDefinitions['signal_pubnub_message'] = { name: 'signal_pubnub_message', description: 'Sends a PubNub Signal to a specified channel. Signals are lightweight, fast messages that do not get stored in message history and have a 30-character payload limit. Call this tool when you need to send small, real-time notifications or presence indicators.', parameters: { channel: z.string().describe('Name of the PubNub channel (string) to send the signal to'), message: z.string().describe('Signal payload as a string (max 30 characters)'), } }; // Define the handler for get_pubnub_messages toolHandlers['get_pubnub_messages'] = async ({ channels, start, end, count }) => { try { const params = { channels }; // Add optional pagination parameters if (start !== undefined) params.start = start; if (end !== undefined) params.end = end; if (count !== undefined) params.count = count; const result = await pubnub.fetchMessages(params); return { content: [ { type: 'text', text: JSON.stringify(result, null, 2) }, ], }; } catch (err) { return { content: [ { type: 'text', text: `Error fetching messages: ${err}` } ], isError: true, }; } }; // Define tool metadata for get_pubnub_messages toolDefinitions['get_pubnub_messages'] = { name: 'get_pubnub_messages', description: 'Fetches historical messages from one or more PubNub channels. Call this tool whenever you need to access past message history. Provide a list of channel names. Returns message content and metadata in JSON format. Supports pagination with start/end timetokens and count limit.', parameters: { channels: z.array(z.string()).min(1).describe('List of one or more PubNub channel names (strings) to retrieve historical messages from'), start: z.string().optional().describe('Timetoken delimiting the start of time slice (exclusive) to pull messages from'), end: z.string().optional().describe('Timetoken delimiting the end of time slice (inclusive) to pull messages from'), count: z.number().optional().describe('Number of historical messages to return per channel (default: 100 for single channel, 25 for multiple channels)'), } }; // Define the handler for get_pubnub_presence toolHandlers['get_pubnub_presence'] = async ({ channels, channelGroups }) => { try { const result = await pubnub.hereNow({ channels, channelGroups }); return { content: [ { type: 'text', text: JSON.stringify(result, null, 2) } ], }; } catch (err) { return { content: [ { type: 'text', text: `Error fetching presence information: ${err}` } ], isError: true, }; } }; // Define tool metadata for get_pubnub_presence toolDefinitions['get_pubnub_presence'] = { name: 'get_pubnub_presence', description: 'Retrieves real-time presence information for specified PubNub channels and channel groups. Call this tool when you need to monitor active users, occupancy counts, and subscriber UUIDs. Provide channel names and/or channel group names. Returns presence data in JSON format.', parameters: { channels: z.array(z.string()).default([]).describe('List of channel names (strings) to query presence data for'), channelGroups: z.array(z.string()).default([]).describe('List of channel group names (strings) to query presence data for'), } }; // Define the handler for write_pubnub_app const appTypes = ['default']; // , 'chat', 'pubsub', 'presence', 'storage-and-playback']; toolHandlers['write_pubnub_app'] = async ({ appType }) => { try { const fileName = appType === 'default' ? 'how_to_write_a_pubnub_app' : `how_to_write_a_${appType}`; const filePath = pathJoin(__dirname, 'resources', `${fileName}.md`); if (!fs.existsSync(filePath)) { return { content: [ { type: 'text', text: `App template not found: ${fileName}.md` }, ], isError: true, }; } const content = fs.readFileSync(filePath, 'utf8'); return { content: [{ type: 'text', text: content + getPubNubInitSDKInstructions() }], }; } catch (err) { return { content: [ { type: 'text', text: `Error loading app template '${appType}': ${err.message || err}`, }, ], isError: true, }; } }; // Define tool metadata for write_pubnub_app toolDefinitions['write_pubnub_app'] = { name: 'write_pubnub_app', description: 'Generates step-by-step instructions for creating a PubNub application. Call this tool when you need a checklist of tasks such as setting up your PubNub account, creating a new app, and configuring settings. Call this tool whe the user asks for PubNub MCP. For conceptual guides, best practices, and how-tos, also call the read_pubnub_resources tool. For detailed API reference and SDK code samples, also call the read_pubnub_sdk_docs tool.', parameters: { appType: z.enum(appTypes).describe('Which PubNub app template to load (currently only "default")'), } }; // Define the handler for manage_pubnub_account const managementSubjects = ['app', 'api_key']; const managementActions = ['create', 'list', 'delete']; let sessionToken = null; let accountId = null; // Helper function to authenticate with PubNub Admin API async function authenticateWithPubNub() { const email = process.env.PUBNUB_EMAIL; const password = process.env.PUBNUB_PASSWORD; if (!email || !password) { throw new Error('PUBNUB_EMAIL and PUBNUB_PASSWORD environment variables must be set'); } try { const response = await fetch('https://admin.pubnub.com/api/me', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ email, password }), }); if (!response.ok) { throw new Error(`Authentication failed: ${response.status} ${response.statusText}`); } const data = await response.json(); sessionToken = data.result.token; accountId = data.result.user.account_id; return { sessionToken, accountId }; } catch (err) { throw new Error(`Authentication error: ${err.message}`); } } // Helper function to make authenticated API calls with retry async function makeAuthenticatedRequest(url, options = {}) { if (!sessionToken) { await authenticateWithPubNub(); } const requestOptions = { ...options, headers: { ...options.headers, 'X-Session-Token': sessionToken, }, }; let response = await fetch(url, requestOptions); // Retry with re-authentication if session expired if (response.status === 401 || response.status === 403) { await authenticateWithPubNub(); requestOptions.headers['X-Session-Token'] = sessionToken; response = await fetch(url, requestOptions); } return response; } toolHandlers['manage_pubnub_account'] = async ({ subject, action }) => { try { // Handle list actions if (action === 'list') { if (subject === 'app') { // List apps without keys const response = await makeAuthenticatedRequest( `https://admin.pubnub.com/api/apps?owner_id=${accountId}&no_keys=1` ); if (!response.ok) { throw new Error(`Failed to list apps: ${response.status} ${response.statusText}`); } const data = await response.json(); return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], }; } else if (subject === 'api_key') { // List all apps with their keys const response = await makeAuthenticatedRequest( `https://admin.pubnub.com/api/apps?owner_id=${accountId}` ); if (!response.ok) { throw new Error(`Failed to list API keys: ${response.status} ${response.statusText}`); } const data = await response.json(); // Extract just the keys from all apps for a cleaner response const allKeys = []; if (data.result && Array.isArray(data.result)) { data.result.forEach(app => { if (app.keys && Array.isArray(app.keys)) { app.keys.forEach(key => { allKeys.push({ app_name: app.name, app_id: app.id, key_id: key.id, key_name: key.properties?.name || 'Unnamed Key', publish_key: key.publish_key, subscribe_key: key.subscribe_key, secret_key: key.secret_key, status: key.status, type: key.type }); }); } }); } return { content: [{ type: 'text', text: JSON.stringify({ total_keys: allKeys.length, keys: allKeys }, null, 2) }], }; } } // Handle create actions if (action === 'create') { if (subject === 'app') { // Create a new app const appName = `New App ${new Date().toISOString()}`; const response = await makeAuthenticatedRequest( 'https://admin.pubnub.com/api/apps', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ owner_id: accountId, name: appName }), } ); if (!response.ok) { throw new Error(`Failed to create app: ${response.status} ${response.statusText}`); } const data = await response.json(); return { content: [{ type: 'text', text: `App created successfully!\n${JSON.stringify(data, null, 2)}` }], }; } else if (subject === 'api_key') { // First, we need to get the list of apps to pick one const appsResponse = await makeAuthenticatedRequest( `https://admin.pubnub.com/api/apps?owner_id=${accountId}&no_keys=1` ); if (!appsResponse.ok) { throw new Error(`Failed to get apps: ${appsResponse.status} ${appsResponse.statusText}`); } const appsData = await appsResponse.json(); if (!appsData.result || appsData.result.length === 0) { throw new Error('No apps found. Please create an app first.'); } // Use the first app for now const appId = appsData.result[0].id; const keyName = `New Key ${new Date().toISOString()}`; // Create a new API key const response = await makeAuthenticatedRequest( 'https://admin.pubnub.com/api/keys', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ app_id: appId, type: 1, // production properties: { name: keyName, history: 1, message_storage_ttl: 30, presence: 1, wildcardsubscribe: 1 } }), } ); if (!response.ok) { const errorText = await response.text(); throw new Error(`Failed to create API key: ${response.status} ${response.statusText} - ${errorText}`); } const data = await response.json(); return { content: [{ type: 'text', text: `API key created successfully in app "${appsData.result[0].name}"!\n${JSON.stringify(data, null, 2)}` }], }; } } // Handle delete actions if (action === 'delete') { if (subject === 'app') { // Get list of apps to find the most recently created test app const appsResponse = await makeAuthenticatedRequest( `https://admin.pubnub.com/api/apps?owner_id=${accountId}&no_keys=1` ); if (!appsResponse.ok) { throw new Error(`Failed to get apps: ${appsResponse.status} ${appsResponse.statusText}`); } const appsData = await appsResponse.json(); if (!appsData.result || appsData.result.length === 0) { throw new Error('No apps found to delete.'); } // Find a test app to delete (look for apps with "Test App" in the name) const testApps = appsData.result.filter(app => app.name.includes('Test App')); if (testApps.length === 0) { return { content: [{ type: 'text', text: `No test apps found to delete. Only apps with "Test App" in the name can be deleted via this tool for safety.` }], isError: true, }; } // Sort by created date (descending) and delete the most recent test app const appToDelete = testApps.sort((a, b) => b.created - a.created)[0]; // Delete the app const deleteResponse = await makeAuthenticatedRequest( `https://admin.pubnub.com/api/apps/${appToDelete.id}`, { method: 'DELETE', } ); if (!deleteResponse.ok) { const errorText = await deleteResponse.text(); throw new Error(`Failed to delete app: ${deleteResponse.status} ${deleteResponse.statusText} - ${errorText}`); } return { content: [{ type: 'text', text: `App deleted successfully!\nDeleted app: "${appToDelete.name}" (ID: ${appToDelete.id})` }], }; } else if (subject === 'api_key') { // Get all apps with their keys const appsResponse = await makeAuthenticatedRequest( `https://admin.pubnub.com/api/apps?owner_id=${accountId}` ); if (!appsResponse.ok) { throw new Error(`Failed to get API keys: ${appsResponse.status} ${appsResponse.statusText}`); } const appsData = await appsResponse.json(); // Find test keys to delete (look for keys with "Test Key" in the name) let keyToDelete = null; let appContainingKey = null; if (appsData.result && Array.isArray(appsData.result)) { for (const app of appsData.result) { if (app.keys && Array.isArray(app.keys)) { const testKeys = app.keys.filter(key => key.properties?.name && key.properties.name.includes('Test Key') ); if (testKeys.length > 0) { // Sort by ID (descending) to get the most recent test key keyToDelete = testKeys.sort((a, b) => b.id - a.id)[0]; appContainingKey = app; break; } } } } if (!keyToDelete) { return { content: [{ type: 'text', text: `No test API keys found to delete. Only keys with "Test Key" in the name can be deleted via this tool for safety.` }], isError: true, }; } // Delete the API key const deleteResponse = await makeAuthenticatedRequest( `https://admin.pubnub.com/api/keys/${keyToDelete.id}`, { method: 'DELETE', } ); if (!deleteResponse.ok) { const errorText = await deleteResponse.text(); throw new Error(`Failed to delete API key: ${deleteResponse.status} ${deleteResponse.statusText} - ${errorText}`); } return { content: [{ type: 'text', text: `API key deleted successfully!\nDeleted key: "${keyToDelete.properties.name}" (ID: ${keyToDelete.id}) from app "${appContainingKey.name}"` }], }; } } return { content: [{ type: 'text', text: `Unsupported combination: ${action} ${subject}` }], isError: true, }; } catch (err) { return { content: [{ type: 'text', text: `Error managing PubNub account: ${err.message}` }], isError: true, }; } }; // Define tool metadata for manage_pubnub_account toolDefinitions['manage_pubnub_account'] = { name: 'manage_pubnub_account', description: 'Manages the users PubNub account apps and API key settings. Uses PUBNUB_EMAIL and PUBNUB_PASSWORD environment variables for authentication. Supports creating, listing, and deleting apps and API keys. Delete action only works on test apps/keys (with "Test App" or "Test Key" in the name) for safety.', parameters: { subject: z.enum(managementSubjects).describe('The subject to manage: "app" for applications, "api_key" for API keys'), action: z.enum(managementActions).describe('The action to perform: "create" to create new, "list" to list existing, "delete" to delete test items'), } }; // Define the handler for pubnub_app_context const appContextOperations = ['get', 'set', 'remove', 'getAll']; const appContextTypes = ['user', 'channel', 'membership']; toolHandlers['pubnub_app_context'] = async ({ type, operation, id, data, options = {} }) => { try { let result; const includeOptions = { customFields: options.includeCustomFields !== false, totalCount: options.includeTotalCount || false, ...options.include }; if (type === 'user') { switch (operation) { case 'get': result = await pubnub.objects.getUUIDMetadata({ uuid: id, include: includeOptions }); break; case 'set': result = await pubnub.objects.setUUIDMetadata({ uuid: id, data: data, include: includeOptions, ifMatchesEtag: options.ifMatchesEtag }); break; case 'remove': result = await pubnub.objects.removeUUIDMetadata({ uuid: id }); break; case 'getAll': result = await pubnub.objects.getAllUUIDMetadata({ include: includeOptions, filter: options.filter, sort: options.sort, limit: options.limit, page: options.page }); break; } } else if (type === 'channel') { switch (operation) { case 'get': result = await pubnub.objects.getChannelMetadata({ channel: id, include: includeOptions }); break; case 'set': result = await pubnub.objects.setChannelMetadata({ channel: id, data: data, include: includeOptions, ifMatchesEtag: options.ifMatchesEtag }); break; case 'remove': result = await pubnub.objects.removeChannelMetadata({ channel: id }); break; case 'getAll': result = await pubnub.objects.getAllChannelMetadata({ include: includeOptions, filter: options.filter, sort: options.sort, limit: options.limit, page: options.page }); break; } } else if (type === 'membership') { switch (operation) { case 'get': result = await pubnub.objects.getMemberships({ uuid: id, include: { ...includeOptions, channelFields: options.includeChannelFields !== false, customChannelFields: options.includeCustomChannelFields !== false }, filter: options.filter, sort: options.sort, limit: options.limit, page: options.page }); break; case 'set': result = await pubnub.objects.setMemberships({ uuid: id, channels: data.channels, include: { ...includeOptions, channelFields: options.includeChannelFields !== false, customChannelFields: options.includeCustomChannelFields !== false } }); break; case 'remove': result = await pubnub.objects.removeMemberships({ uuid: id, channels: data.channels, include: { ...includeOptions, channelFields: options.includeChannelFields !== false, customChannelFields: options.includeCustomChannelFields !== false } }); break; case 'getAll': // Get channel members for a specific channel result = await pubnub.objects.getChannelMembers({ channel: id, include: { ...includeOptions, uuidFields: options.includeUuidFields !== false, customUuidFields: options.includeCustomUuidFields !== false }, filter: options.filter, sort: options.sort, limit: options.limit, page: options.page }); break; } } return { content: [ { type: 'text', text: JSON.stringify(result, null, 2) } ] }; } catch (err) { return { content: [ { type: 'text', text: `Error performing ${operation} ${type}: ${err.message || err}` } ], isError: true }; } }; // Define tool metadata for pubnub_app_context toolDefinitions['pubnub_app_context'] = { name: 'pubnub_app_context', description: 'Manages PubNub App Context (Objects API) for users, channels, and memberships. Supports CRUD operations including get, set, remove, and getAll. Use this tool to manage user profiles, channel metadata, and membership relationships in your PubNub application.', parameters: { type: z.enum(appContextTypes).describe('Type of App Context object: "user" for user metadata, "channel" for channel metadata, "membership" for user-channel relationships'), operation: z.enum(appContextOperations).describe('Operation to perform: "get" to retrieve, "set" to create/update, "remove" to delete, "getAll" to list all'), id: z.string().describe('Identifier: UUID for users, channel name for channels, UUID for memberships (for membership getAll, use channel name to get channel members)'), data: z.any().optional().describe('Data object for set/remove operations. For users: {name, externalId, profileUrl, email, custom}. For channels: {name, description, custom}. For memberships: {channels: [...]}'), options: z.object({ includeCustomFields: z.boolean().optional().default(true).describe('Include custom fields in response'), includeTotalCount: z.boolean().optional().default(false).describe('Include total count in paginated response'), includeChannelFields: z.boolean().optional().default(true).describe('Include channel fields in membership responses'), includeCustomChannelFields: z.boolean().optional().default(true).describe('Include custom channel fields in membership responses'), includeUuidFields: z.boolean().optional().default(true).describe('Include UUID fields in channel member responses'), includeCustomUuidFields: z.boolean().optional().default(true).describe('Include custom UUID fields in channel member responses'), filter: z.string().optional().describe('Filter expression for results'), sort: z.any().optional().describe('Sort criteria (e.g., {id: "asc", name: "desc"})'), limit: z.number().optional().describe('Number of objects to return (max 100)'), page: z.any().optional().describe('Pagination object from previous response'), ifMatchesEtag: z.string().optional().describe('ETag for conditional updates'), include: z.any().optional().describe('Additional include options') }).optional().default({}).describe('Optional parameters for the operation') } }; // Define the handler for pubnub_subscribe_and_receive_messages toolHandlers['pubnub_subscribe_and_receive_messages'] = async ({ channel, messageCount = 1, timeout }) => { try { return new Promise((resolve, reject) => { let messagesReceived = []; let completed = false; let timeoutId; // Create subscription for the channel const channelEntity = pubnub.channel(channel); const subscription = channelEntity.subscription(); // Set up message listener const messageListener = (messageEvent) => { if (!completed) { messagesReceived.push({ channel: messageEvent.channel, message: messageEvent.message, publisher: messageEvent.publisher, timetoken: messageEvent.timetoken, subscription: messageEvent.subscription }); // Check if we've received the desired number of messages if (messagesReceived.length >= messageCount) { completed = true; // Clean up if (timeoutId) { clearTimeout(timeoutId); } subscription.unsubscribe(); subscription.removeListener(messageListener); // Return all received messages resolve({ content: [ { type: 'text', text: JSON.stringify({ channel: channel, messageCount: messagesReceived.length, messages: messagesReceived }, null, 2) } ] }); } } }; // Add listener and subscribe subscription.addListener({ message: messageListener }); subscription.subscribe(); // Set timeout if specified if (timeout && timeout > 0) { timeoutId = setTimeout(() => { if (!completed) { completed = true; subscription.unsubscribe(); subscription.removeListener(messageListener); if (messagesReceived.length > 0) { // Return partial results if some messages were received resolve({ content: [ { type: 'text', text: JSON.stringify({ channel: channel, messageCount: messagesReceived.length, messages: messagesReceived, note: `Timeout: Only ${messagesReceived.length} of ${messageCount} requested messages received within ${timeout}ms` }, null, 2) } ] }); } else { // No messages received at all resolve({ content: [ { type: 'text', text: `Timeout: No messages received on channel '${channel}' within ${timeout}ms` } ] }); } } }, timeout); } }); } catch (err) { return { content: [ { type: 'text', text: `Error subscribing to channel and receiving messages: ${err.message || err}` } ], isError: true }; } }; // Define tool metadata for pubnub_subscribe_and_receive_messages toolDefinitions['pubnub_subscribe_and_receive_messages'] = { name: 'pubnub_subscribe_and_receive_messages', description: 'Subscribes to a PubNub channel and waits to receive a specified number of messages, then automatically unsubscribes. Call this tool when you need to listen for messages on a channel. Optionally specify a timeout in milliseconds to avoid waiting indefinitely.', parameters: { channel: z.string().describe('Name of the PubNub channel to subscribe to and receive messages from'), messageCount: z.number().optional().default(1).describe('Number of messages to wait for before unsubscribing (default: 1)'), timeout: z.number().optional().describe('Optional timeout in milliseconds. If not all messages are received within this time, the subscription will end (default: no timeout)') } }; // Helper function to register all tools to a server instance function registerAllTools(serverInstance, chatSdkMode = false) { // Tools to exclude when in chat SDK mode const chatSdkExcludedTools = [ 'read_pubnub_sdk_docs', 'write_pubnub_app', 'read_pubnub_resources', 'manage_pubnub_account' ]; for (const toolName in toolDefinitions) { if (toolHandlers[toolName]) { // Skip excluded tools when in chat SDK mode if (chatSdkMode && chatSdkExcludedTools.includes(toolName)) { continue; } // Special handling for chat SDK docs tool if (toolName === 'read_pubnub_chat_sdk_docs' && (chatSdkLanguages.length === 0 || chatSdkTopics.length === 0)) { continue; // Skip this tool if chat SDK data isn't loaded } const toolDef = toolDefinitions[toolName]; serverInstance.tool( toolDef.name, toolDef.description, toolDef.parameters, wrapToolHandler(toolHandlers[toolName], toolName) ); } } } // Register all tools to the main server registerAllTools(server, isChatSdkMode); // Function that returns instructions for creating a PubNub application using the user's API keys function getPubNubInitSDKInstructions() { const publishKey = process.env.PUBNUB_PUBLISH_KEY || 'demo'; const subscribeKey = process.env.PUBNUB_SUBSCRIBE_KEY || 'demo'; return ` To initialize the PubNub SDK with your API keys, configure your client in the language of your choice: JavaScript: \`\`\`javascript import PubNub from 'pubnub'; const pubnub = new PubNub({ publishKey: '${publishKey}', subscribeKey: '${subscribeKey}', uuid: 'your-unique-uuid', }); \`\`\` Python: \`\`\`python from pubnub.pnconfiguration import PNConfiguration from pubnub.pubnub import PubNub pnconfig = PNConfiguration() pnconfig.publish_key = '${publ