UNPKG

@feedmob/liftoff-reporting

Version:

MCP Server for Liftoff Reporting API

298 lines (297 loc) 14.2 kB
#!/usr/bin/env node import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import axios from "axios"; import dotenv from "dotenv"; import * as process from 'process'; dotenv.config(); // Load environment variables from .env file // Liftoff API Configuration const LIFTOFF_API_BASE = "https://data.liftoff.io/api/v1"; const LIFTOFF_API_KEY = process.env.LIFTOFF_API_KEY; const LIFTOFF_API_SECRET = process.env.LIFTOFF_API_SECRET; if (!LIFTOFF_API_KEY || !LIFTOFF_API_SECRET) { console.error("Error: LIFTOFF_API_KEY or LIFTOFF_API_SECRET environment variable is not set."); process.exit(1); } // Create server instance const server = new McpServer({ name: "liftoff-reporting", version: "0.0.3", // Updated version capabilities: { tools: {}, // Only tools capability needed for now }, }); async function makeLiftoffApiRequest(method, endpoint, params, data) { const url = `${LIFTOFF_API_BASE}${endpoint}`; const auth = { username: LIFTOFF_API_KEY, password: LIFTOFF_API_SECRET, }; const headers = { 'Content-Type': 'application/json', 'Accept': 'application/json', }; try { const response = await axios({ method: method, url: url, auth: auth, headers: headers, params: params, // GET request parameters data: data, // POST request body timeout: 60000, // 60 second timeout for potentially long reports }); // For data download, response might not be JSON if format=csv if (endpoint.endsWith('/data') && response.headers['content-type']?.includes('text/csv')) { return response.data; // Return raw CSV string } return response.data; } catch (error) { console.error(`Error making Liftoff API request to ${method.toUpperCase()} ${url}:`, error); if (axios.isAxiosError(error)) { const axiosError = error; console.error("Axios error details:", { message: axiosError.message, code: axiosError.code, status: axiosError.response?.status, data: axiosError.response?.data, }); const errorData = axiosError.response?.data; const errorType = errorData?.error_type || 'Unknown Error'; const errorMessage = errorData?.message || axiosError.message; const errorDetails = errorData?.errors?.join(', ') || 'No details provided.'; throw new Error(`Liftoff API Error (${axiosError.response?.status} ${errorType}): ${errorMessage} Details: ${errorDetails}`); } throw new Error(`Failed to call Liftoff API: ${error}`); } } // --- Tool Definitions --- const reportGroupBySchema = z.array(z.string()).optional().default(["apps", "campaigns", "country"]).describe("Group metrics by one of the available presets. e.g., [\"apps\", \"campaigns\"], [\"apps\", \"campaigns\", \"country\"]"); const reportFormatSchema = z.enum(["csv", "json"]).optional().default("csv").describe("Format of the report data"); const createReportInputSchema = z.object({ start_time: z.string().regex(/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}Z)?$/).describe("Start date (YYYY-MM-DD) or timestamp (YYYY-MM-DDTHH:mm:ssZ) in UTC."), end_time: z.string().regex(/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}Z)?$/).describe("End date (YYYY-MM-DD) or timestamp (YYYY-MM-DDTHH:mm:ssZ) in UTC."), group_by: reportGroupBySchema, app_ids: z.array(z.string()).optional().describe("Optional. Filter by specific app IDs."), campaign_ids: z.array(z.string()).optional().describe("Optional. Filter by specific campaign IDs."), event_ids: z.array(z.string()).optional().describe("Optional. Filter by specific event IDs."), cohort_window: z.number().int().min(1).max(90).optional().describe("Optional. Number of days since install (1-90)."), format: reportFormatSchema, callback_url: z.string().url().optional().describe("Optional. URL to receive POST when report is done."), timezone: z.string().optional().default("UTC").describe("Optional. TZ database name (e.g., 'America/Los_Angeles')."), include_repeat_events: z.boolean().optional().default(true), remove_zero_rows: z.boolean().optional().default(false), use_two_letter_country: z.boolean().optional().default(false), }); // 1. Create Report Tool server.tool("create_liftoff_report", "Generate a report via the Liftoff Reporting API.", createReportInputSchema.shape, async (reportParams) => { try { const response = await makeLiftoffApiRequest('post', '/reports', undefined, reportParams); return { content: [{ type: "text", text: `Report creation initiated successfully. Report ID: ${response.id}, State: ${response.state}`, }], }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "An unknown error occurred creating the report."; console.error("Error in create_liftoff_report tool:", errorMessage); return { content: [{ type: "text", text: `Error creating report: ${errorMessage}` }], isError: true, }; } }); // 2. Check Report Status Tool const checkStatusInputSchema = z.object({ report_id: z.string().describe("The ID of the report to check."), }); server.tool("check_liftoff_report_status", "Get the status of a previously created Liftoff report once every minute until it is completed.", checkStatusInputSchema.shape, async ({ report_id }) => { try { const response = await makeLiftoffApiRequest('get', `/reports/${report_id}/status`); return { content: [{ type: "text", text: `Report Status for ID ${report_id}: ${response.state}. Created at: ${response.created_at}. Parameters: ${JSON.stringify(response.parameters)}`, }], }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "An unknown error occurred checking report status."; console.error("Error in check_liftoff_report_status tool:", errorMessage); return { content: [{ type: "text", text: `Error checking report status: ${errorMessage}` }], isError: true, }; } }); // 3. Download Report Data Tool const downloadDataInputSchema = z.object({ report_id: z.string().describe("The ID of the completed report to download."), // Format is determined at creation time, not download time according to docs. // If download format override was possible, add 'format' here. }); server.tool("download_liftoff_report_data", "Download the data for a completed Liftoff report.", downloadDataInputSchema.shape, async ({ report_id }) => { try { // Note: The API might return CSV text or JSON based on creation 'format'. // This tool returns the raw response text. The LLM might need to parse it. const reportData = await makeLiftoffApiRequest('get', `/reports/${report_id}/data`); let outputText = ''; if (typeof reportData === 'string') { // Likely CSV data outputText = `Report data (CSV) for ID ${report_id}:\n\`\`\`csv\n${reportData}\n\`\`\``; } else if (typeof reportData === 'object') { // Likely JSON data outputText = `Report data (JSON) for ID ${report_id}:\n\`\`\`json\n${JSON.stringify(reportData, null, 2)}\n\`\`\``; } else { outputText = `Received unexpected data format for report ID ${report_id}.`; } return { content: [{ type: "text", text: outputText }], }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "An unknown error occurred downloading report data."; console.error("Error in download_liftoff_report_data tool:", errorMessage); return { content: [{ type: "text", text: `Error downloading report data: ${errorMessage}` }], isError: true, }; } }); // 4. List Apps Tool const listAppsInputSchema = z.object({}); // Keep the object for inference server.tool("list_liftoff_apps", "Fetch app details from the Liftoff Reporting API.", listAppsInputSchema.shape, async () => { try { const apps = await makeLiftoffApiRequest('get', '/apps'); return { content: [{ type: "text", text: `Available Liftoff Apps:\n\`\`\`json\n${JSON.stringify(apps, null, 2)}\n\`\`\``, }], }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "An unknown error occurred listing apps."; console.error("Error in list_liftoff_apps tool:", errorMessage); return { content: [{ type: "text", text: `Error listing apps: ${errorMessage}` }], isError: true, }; } }); // 5. List Campaigns Tool const listCampaignsInputSchema = z.object({}); // Keep the object for inference server.tool("list_liftoff_campaigns", "Fetch campaign details from the Liftoff Reporting API.", listCampaignsInputSchema.shape, async () => { try { const campaigns = await makeLiftoffApiRequest('get', '/campaigns'); return { content: [{ type: "text", text: `Available Liftoff Campaigns:\n\`\`\`json\n${JSON.stringify(campaigns, null, 2)}\n\`\`\``, }], }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "An unknown error occurred listing campaigns."; console.error("Error in list_liftoff_campaigns tool:", errorMessage); return { content: [{ type: "text", text: `Error listing campaigns: ${errorMessage}` }], isError: true, }; } }); // 6. Download Report Data with Campaign Names Tool server.tool("download_liftoff_report_with_names", "Download the data for a completed Liftoff report with campaign names.", downloadDataInputSchema.shape, async ({ report_id }) => { try { // Get report data const reportData = await makeLiftoffApiRequest('get', `/reports/${report_id}/data`); // Get campaign information const campaignsResponse = await makeLiftoffApiRequest('get', '/campaigns'); if (!Array.isArray(campaignsResponse)) { throw new Error("Failed to retrieve campaign information"); } // Create map of campaign IDs to names const campaignMap = new Map(); campaignsResponse.forEach((campaign) => { campaignMap.set(campaign.id, campaign.name); }); // Process the report data to include campaign names let outputData; if (typeof reportData === 'string') { // If CSV format, need to parse and modify const rows = reportData.split('\n'); const headers = rows[0].split(','); // Add campaign_name header const campaignIdIndex = headers.indexOf('campaign_id'); if (campaignIdIndex > -1) { headers.push('campaign_name'); rows[0] = headers.join(','); // Add campaign name to each data row for (let i = 1; i < rows.length; i++) { if (rows[i].trim()) { const values = rows[i].split(','); const campaignId = values[campaignIdIndex]; const campaignName = campaignMap.get(campaignId) || 'Unknown Campaign'; values.push(`"${campaignName}"`); rows[i] = values.join(','); } } } outputData = rows.join('\n'); return { content: [{ type: "text", text: `Report data (CSV) with campaign names for ID ${report_id}:\n\`\`\`csv\n${outputData}\n\`\`\`` }], }; } else if (typeof reportData === 'object') { // If JSON format if (reportData.columns && reportData.rows && Array.isArray(reportData.rows)) { // Add campaign_name to columns const campaignIdIndex = reportData.columns.indexOf('campaign_id'); if (campaignIdIndex > -1) { reportData.columns.push('campaign_name'); // Add campaign name to each row reportData.rows.forEach((row) => { const campaignId = row[campaignIdIndex]; const campaignName = campaignMap.get(campaignId) || 'Unknown Campaign'; row.push(campaignName); }); } } return { content: [{ type: "text", text: `Report data (JSON) with campaign names for ID ${report_id}:\n\`\`\`json\n${JSON.stringify(reportData, null, 2)}\n\`\`\`` }], }; } else { return { content: [{ type: "text", text: `Received unexpected data format for report ID ${report_id}.` }], }; } } catch (error) { const errorMessage = error instanceof Error ? error.message : "An unknown error occurred downloading report data with campaign names."; console.error("Error in download_liftoff_report_with_names tool:", errorMessage); return { content: [{ type: "text", text: `Error downloading report data with campaign names: ${errorMessage}` }], isError: true, }; } }); // --- Run the Server --- async function main() { const transport = new StdioServerTransport(); try { await server.connect(transport); console.error("Liftoff Reporting MCP Server running on stdio..."); // Updated message } catch (error) { console.error("Failed to start Liftoff Reporting MCP Server:", error); // Updated message process.exit(1); } } main();