#
tokens: 43143/50000 9/180 files (page 4/5)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 4 of 5. Use http://codebase.md/feed-mob/fm-mcp-servers?lines=true&page={x} to view the full context.

# Directory Structure

```
├── .claude
│   └── settings.local.json
├── .cursor
│   └── rules
│       └── typescript-indentation.mdc
├── .github
│   └── workflows
│       ├── publish-civitai-records.yml
│       ├── publish-github-issues.yml
│       ├── publish-imagekit.yml
│       ├── publish-n8n-nodes-feedmob-direct-spend-visualizer.yml
│       ├── publish-reporting-packages.yml
│       └── publish-work-journals.yml
├── .gitignore
├── AGENTS.md
├── package-lock.json
├── README.md
├── src
│   ├── applovin-reporting
│   │   ├── package-lock.json
│   │   ├── package.json
│   │   ├── README.md
│   │   ├── src
│   │   │   └── index.ts
│   │   └── tsconfig.json
│   ├── appsamurai-reporting
│   │   ├── .env.example
│   │   ├── package-lock.json
│   │   ├── package.json
│   │   ├── README.md
│   │   ├── src
│   │   │   └── index.ts
│   │   └── tsconfig.json
│   ├── civitai-records
│   │   ├── .gitignore
│   │   ├── build.sh
│   │   ├── docker-compose.yml
│   │   ├── docs
│   │   │   └── civitai-owner-createrole.md
│   │   ├── env.sample
│   │   ├── infra
│   │   │   └── db-init
│   │   │       ├── 01-roles.sql
│   │   │       ├── 02_functions.sql
│   │   │       ├── 03_init.sql
│   │   │       ├── 04_create_prompts.sql
│   │   │       ├── 05_create_assets.sql
│   │   │       ├── 06_create_civitai_posts.sql
│   │   │       ├── 07_add_constraints_and_input_assets.sql
│   │   │       ├── 08_migration_add_on_behalf_of.sql
│   │   │       ├── 09_migration_update_functions_for_on_behalf_of.sql
│   │   │       ├── 10_update_on_behalf_of_for_existing_records.sql
│   │   │       ├── 11_create_asset_stats.sql
│   │   │       ├── 12_fix_trigger_for_tables_without_on_behalf_of.sql
│   │   │       └── 13_add_columns_to_asset_stats.sql
│   │   ├── package-lock.json
│   │   ├── package.json
│   │   ├── prisma
│   │   │   └── schema.prisma
│   │   ├── README.md
│   │   ├── src
│   │   │   ├── lib
│   │   │   │   ├── __tests__
│   │   │   │   │   ├── detectRemoteAssetType.test.ts
│   │   │   │   │   └── sha256.test.ts
│   │   │   │   ├── civitaiApi.ts
│   │   │   │   ├── detectRemoteAssetType.ts
│   │   │   │   ├── handleDatabaseError.ts
│   │   │   │   ├── prisma.ts
│   │   │   │   └── sha256.ts
│   │   │   ├── prompts
│   │   │   │   ├── civitai-media-engagement.md
│   │   │   │   ├── civitaiMediaEngagement.ts
│   │   │   │   ├── record-civitai-workflow.md
│   │   │   │   └── recordCivitaiWorkflow.ts
│   │   │   ├── server.ts
│   │   │   └── tools
│   │   │       ├── calculateSha256.ts
│   │   │       ├── createAsset.ts
│   │   │       ├── createCivitaiPost.ts
│   │   │       ├── createPrompt.ts
│   │   │       ├── fetchCivitaiPostAssets.ts
│   │   │       ├── findAsset.ts
│   │   │       ├── getMediaEngagementGuide.ts
│   │   │       ├── getWorkflowGuide.ts
│   │   │       ├── listCivitaiPosts.ts
│   │   │       ├── syncPostAssetStats.ts
│   │   │       └── updateAsset.ts
│   │   └── tsconfig.json
│   ├── feedmob-reporting
│   │   ├── .env.example
│   │   ├── package-lock.json
│   │   ├── package.json
│   │   ├── README.md
│   │   ├── src
│   │   │   ├── api.ts
│   │   │   └── index.ts
│   │   └── tsconfig.json
│   ├── femini-reporting
│   │   ├── Dockerfile
│   │   ├── femini_mcp_guide.md
│   │   ├── package-lock.json
│   │   ├── package.json
│   │   ├── README.md
│   │   ├── src
│   │   │   └── index.ts
│   │   └── tsconfig.json
│   ├── github-issues
│   │   ├── common
│   │   │   ├── errors.ts
│   │   │   ├── types.ts
│   │   │   ├── utils.ts
│   │   │   └── version.ts
│   │   ├── index.ts
│   │   ├── operations
│   │   │   ├── issues.ts
│   │   │   └── search.ts
│   │   ├── package-lock.json
│   │   ├── package.json
│   │   ├── README.md
│   │   └── tsconfig.json
│   ├── imagekit
│   │   ├── env.sample
│   │   ├── package-lock.json
│   │   ├── package.json
│   │   ├── README.md
│   │   ├── src
│   │   │   ├── server.ts
│   │   │   ├── services
│   │   │   │   ├── imageKitUpload.ts
│   │   │   │   └── imageUploader.ts
│   │   │   └── tools
│   │   │       ├── cropAndWatermark.ts
│   │   │       └── uploadFile.ts
│   │   └── tsconfig.json
│   ├── impact-radius-reporting
│   │   ├── package-lock.json
│   │   ├── package.json
│   │   ├── README.md
│   │   ├── src
│   │   │   ├── fm_impact_radius_mapping.ts
│   │   │   └── index.ts
│   │   ├── tsconfig.json
│   │   └── yarn.lock
│   ├── inmobi-reporting
│   │   ├── package-lock.json
│   │   ├── package.json
│   │   ├── README.md
│   │   ├── src
│   │   │   └── index.ts
│   │   └── tsconfig.json
│   ├── ironsource-aura-reporting
│   │   ├── package-lock.json
│   │   ├── package.json
│   │   ├── README.md
│   │   ├── src
│   │   │   └── index.ts
│   │   └── tsconfig.json
│   ├── ironsource-reporting
│   │   ├── package-lock.json
│   │   ├── package.json
│   │   ├── README.md
│   │   ├── src
│   │   │   └── index.ts
│   │   └── tsconfig.json
│   ├── jampp-reporting
│   │   ├── package-lock.json
│   │   ├── package.json
│   │   ├── README.md
│   │   ├── src
│   │   │   └── index.ts
│   │   └── tsconfig.json
│   ├── kayzen-reporting
│   │   ├── package-lock.json
│   │   ├── package.json
│   │   ├── README.md
│   │   ├── src
│   │   │   ├── index.ts
│   │   │   └── kayzen-client.ts
│   │   └── tsconfig.json
│   ├── liftoff-reporting
│   │   ├── .env.example
│   │   ├── package-lock.json
│   │   ├── package.json
│   │   ├── README.md
│   │   ├── src
│   │   │   └── index.ts
│   │   └── tsconfig.json
│   ├── mintegral-reporting
│   │   ├── package-lock.json
│   │   ├── package.json
│   │   ├── README.md
│   │   ├── src
│   │   │   └── index.ts
│   │   └── tsconfig.json
│   ├── n8n-nodes-feedmob-direct-spend-visualizer
│   │   ├── AGENTS.md
│   │   ├── credentials
│   │   │   └── FeedmobDirectSpendVisualizerApi.credentials.ts
│   │   ├── index.ts
│   │   ├── nodes
│   │   │   └── FeedmobDirectSpendVisualizer
│   │   │       ├── FeedmobDirectSpendVisualizer.node.ts
│   │   │       └── logo.svg
│   │   ├── package-lock.json
│   │   ├── package.json
│   │   ├── README.md
│   │   ├── scripts
│   │   │   ├── build-assets.js
│   │   │   └── setup-plugin.js
│   │   ├── tsconfig.json
│   │   └── types.d.ts
│   ├── n8n-nodes-sensor-tower
│   │   ├── credentials
│   │   │   └── SensorTowerApi.credentials.ts
│   │   ├── index.ts
│   │   ├── MCP_to_n8n_Guide_zh.md
│   │   ├── nodes
│   │   │   └── SensorTower
│   │   │       ├── logo.svg
│   │   │       └── SensorTower.node.ts
│   │   ├── package-lock.json
│   │   ├── package.json
│   │   ├── README.md
│   │   ├── tsconfig.json
│   │   └── types.d.ts
│   ├── rtb-house-reporting
│   │   ├── package-lock.json
│   │   ├── package.json
│   │   ├── README.md
│   │   ├── src
│   │   │   └── index.ts
│   │   └── tsconfig.json
│   ├── samsung-reporting
│   │   ├── package-lock.json
│   │   ├── package.json
│   │   ├── README.md
│   │   ├── src
│   │   │   └── index.ts
│   │   └── tsconfig.json
│   ├── sensor-tower-reporting
│   │   ├── package-lock.json
│   │   ├── package.json
│   │   ├── README.md
│   │   ├── src
│   │   │   └── index.ts
│   │   └── tsconfig.json
│   ├── singular-reporting
│   │   ├── package-lock.json
│   │   ├── package.json
│   │   ├── README.md
│   │   ├── src
│   │   │   └── index.ts
│   │   └── tsconfig.json
│   ├── smadex-reporting
│   │   ├── package-lock.json
│   │   ├── package.json
│   │   ├── README.md
│   │   ├── src
│   │   │   └── index.ts
│   │   └── tsconfig.json
│   ├── tapjoy-reporting
│   │   ├── package-lock.json
│   │   ├── package.json
│   │   ├── README.md
│   │   ├── src
│   │   │   └── index.ts
│   │   └── tsconfig.json
│   ├── user-activity-reporting
│   │   ├── .env.example
│   │   ├── package-lock.json
│   │   ├── package.json
│   │   ├── README.md
│   │   ├── src
│   │   │   ├── api.ts
│   │   │   └── index.ts
│   │   └── tsconfig.json
│   └── work-journals
│       ├── package-lock.json
│       ├── package.json
│       ├── README.md
│       ├── src
│       │   └── index.ts
│       └── tsconfig.json
└── tsconfig.json
```

# Files

--------------------------------------------------------------------------------
/src/kayzen-reporting/src/index.ts:
--------------------------------------------------------------------------------

```typescript
  1 | #!/usr/bin/env node
  2 | 
  3 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
  4 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
  5 | import { z } from "zod";
  6 | import { KayzenClient } from "./kayzen-client.js";
  7 | 
  8 | // Create an MCP server
  9 | const server = new McpServer({
 10 |   name: "Kayzen Reporting",
 11 |   version: "0.1.0"
 12 | });
 13 | 
 14 | // Initialize Kayzen client
 15 | const kayzenClient = new KayzenClient();
 16 | 
 17 | interface ReportListResponse {
 18 |   data: Array<{
 19 |     id: number;
 20 |     advertiser_id: number;
 21 |     report_type_id: string;
 22 |     name: string;
 23 |     start_date: string;
 24 |     end_date: string;
 25 |     date_macro: string;
 26 |     time_zone_id: number;
 27 |     created_at: string;
 28 |     report_schedule_frequency: string | null;
 29 |     report_schedule_end_date: string | null;
 30 |     report_schedule_recipients: string | null;
 31 |     report_schedule_status: string | null;
 32 |     report_schedule_flag_reason: string | null;
 33 |     report_schedule_last_sent: string | null;
 34 |     time_zone_name: string;
 35 |   }>;
 36 |   meta: {
 37 |     current_page: number;
 38 |     total_pages: number;
 39 |     total_entries: number;
 40 |   };
 41 | }
 42 | 
 43 | interface ReportResultsResponse {
 44 |   data: Array<Record<string, unknown>>;
 45 |   metadata?: Record<string, unknown>;
 46 | }
 47 | 
 48 | // Helper function to parse and normalize dates with smart defaults
 49 | function parseDateWithDefaults(dateString: string): string {
 50 |   const currentYear = new Date().getFullYear();
 51 | 
 52 |   // Already in YYYY-MM-DD format
 53 |   if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
 54 |     return dateString;
 55 |   }
 56 | 
 57 |   // MM-DD format (add current year)
 58 |   if (/^\d{1,2}-\d{1,2}$/.test(dateString)) {
 59 |     const [month, day] = dateString.split('-');
 60 |     return `${currentYear}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
 61 |   }
 62 | 
 63 |   // MM/DD format (add current year)
 64 |   if (/^\d{1,2}\/\d{1,2}$/.test(dateString)) {
 65 |     const [month, day] = dateString.split('/');
 66 |     return `${currentYear}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
 67 |   }
 68 | 
 69 |   // Month name patterns (January, Jan, 1月)
 70 |   const monthNames = {
 71 |     'january': '01', 'jan': '01', '1月': '01', '一月': '01',
 72 |     'february': '02', 'feb': '02', '2月': '02', '二月': '02',
 73 |     'march': '03', 'mar': '03', '3月': '03', '三月': '03',
 74 |     'april': '04', 'apr': '04', '4月': '04', '四月': '04',
 75 |     'may': '05', '5月': '05', '五月': '05',
 76 |     'june': '06', 'jun': '06', '6月': '06', '六月': '06',
 77 |     'july': '07', 'jul': '07', '7月': '07', '七月': '07',
 78 |     'august': '08', 'aug': '08', '8月': '08', '八月': '08',
 79 |     'september': '09', 'sep': '09', '9月': '09', '九月': '09',
 80 |     'october': '10', 'oct': '10', '10月': '10', '十月': '10',
 81 |     'november': '11', 'nov': '11', '11月': '11', '十一月': '11',
 82 |     'december': '12', 'dec': '12', '12月': '12', '十二月': '12'
 83 |   };
 84 | 
 85 |   const lowerDate = dateString.toLowerCase();
 86 |   for (const [name, monthNum] of Object.entries(monthNames)) {
 87 |     if (lowerDate.includes(name)) {
 88 |       return `${currentYear}-${monthNum}-01`;
 89 |     }
 90 |   }
 91 | 
 92 |   // Single number (assume month, use first day)
 93 |   if (/^\d{1,2}$/.test(dateString)) {
 94 |     const month = parseInt(dateString);
 95 |     if (month >= 1 && month <= 12) {
 96 |       return `${currentYear}-${month.toString().padStart(2, '0')}-01`;
 97 |     }
 98 |   }
 99 | 
100 |   // Return original if no pattern matched
101 |   return dateString;
102 | }
103 | 
104 | // Helper function to get month end date
105 | function getMonthEnd(year: number, month: number): string {
106 |   const lastDay = new Date(year, month, 0).getDate();
107 |   return `${year}-${month.toString().padStart(2, '0')}-${lastDay.toString().padStart(2, '0')}`;
108 | }
109 | 
110 | // Helper function to parse date ranges with smart defaults
111 | function parseDateRange(startDate: string, endDate: string): { start: string; end: string } {
112 |   const currentYear = new Date().getFullYear();
113 |   let parsedStart = parseDateWithDefaults(startDate);
114 |   let parsedEnd = parseDateWithDefaults(endDate);
115 | 
116 |   // If start date is just a month (ends with -01), set end date to month end
117 |   if (parsedStart.endsWith('-01') && parsedEnd === parsedStart) {
118 |     const [year, month] = parsedStart.split('-').map(Number);
119 |     parsedEnd = getMonthEnd(year, month);
120 |   }
121 | 
122 |   return { start: parsedStart, end: parsedEnd };
123 | }
124 | 
125 | // Helper function to get report details by ID
126 | async function getReportDetails(reportId: string): Promise<{
127 |   id: number;
128 |   start_date: string;
129 |   end_date: string;
130 |   name: string;
131 | } | null> {
132 |   try {
133 |     const result = await kayzenClient.listReports({ q: reportId }) as ReportListResponse;
134 |     const report = result.data.find(r => r.id.toString() === reportId);
135 |     return report ? {
136 |       id: report.id,
137 |       start_date: report.start_date,
138 |       end_date: report.end_date,
139 |       name: report.name
140 |     } : null;
141 |   } catch (error) {
142 |     console.error('Error getting report details:', error);
143 |     return null;
144 |   }
145 | }
146 | 
147 | // Add list reports tool
148 | server.tool(
149 |   "list_reports",
150 |   "Get a list of all the existing reports from Kayzen Reporting API with filtering, pagination, and sorting options",
151 |   {
152 |     advertiser_id: z.number().optional().describe("Filter reports by advertiser ID"),
153 |     q: z.string().optional().describe("Search reports by name or ID"),
154 |     page: z.number().min(1).default(1).describe("Page number (default: 1)"),
155 |     per_page: z.number().min(1).max(100).default(30).describe("Number of rows per page (default: 30, max: 100)"),
156 |     sort_field: z.enum([
157 |       "id",
158 |       "advertiser_id",
159 |       "name",
160 |       "report_type",
161 |       "time_range",
162 |       "report_schedule_frequency",
163 |       "report_schedule_status",
164 |       "report_schedule_last_sent"
165 |     ]).optional().describe("Sort reports by this field"),
166 |     sort_direction: z.enum(["asc", "desc"]).optional().describe("Sort direction (asc or desc)")
167 |   },
168 |   async (params: {
169 |     advertiser_id?: number;
170 |     q?: string;
171 |     page?: number;
172 |     per_page?: number;
173 |     sort_field?: string;
174 |     sort_direction?: 'asc' | 'desc';
175 |   }) => {
176 |     try {
177 |       const result = await kayzenClient.listReports(params) as ReportListResponse;
178 | 
179 |       const summary = {
180 |         pagination: {
181 |           current_page: result.meta.current_page,
182 |           total_pages: result.meta.total_pages,
183 |           total_entries: result.meta.total_entries,
184 |           per_page: params.per_page || 30
185 |         },
186 |         filters_applied: {
187 |           advertiser_id: params.advertiser_id,
188 |           search_query: params.q,
189 |           sort_field: params.sort_field,
190 |           sort_direction: params.sort_direction
191 |         }
192 |       };
193 | 
194 |       return {
195 |         content: [
196 |           {
197 |             type: "text",
198 |             text: `## Reports Summary
199 | ${summary.pagination.total_entries} total reports found
200 | Page ${summary.pagination.current_page} of ${summary.pagination.total_pages}
201 | 
202 | ### Applied Filters
203 | ${JSON.stringify(summary.filters_applied, null, 2)}
204 | 
205 | ### Reports Data
206 | ${JSON.stringify(result.data, null, 2)}
207 | 
208 | ---
209 | **Note for LLM**: When the user asks for report results for specific date ranges, remember to use those dates as start_date and end_date parameters in get_report_results calls. Each report above shows its original date range (start_date/end_date), but you can override these with user-specified dates if needed.
210 | 
211 | **Date Format Guidelines for LLM**:
212 | - If user only mentions month/date without year, assume current year (${new Date().getFullYear()})
213 | - Supported formats: "January", "Jan", "1月", "1", "01-15", "1/15", "2024-01-15"
214 | - Examples: "January" → "${new Date().getFullYear()}-01-01 to ${new Date().getFullYear()}-01-31", "3" → "${new Date().getFullYear()}-03-01 to ${new Date().getFullYear()}-03-31"
215 | - Use the smart date parsing by passing flexible date strings to get_report_results`
216 |           }
217 |         ]
218 |       };
219 |     } catch (error) {
220 |       const errorMessage = error instanceof Error ? error.message : String(error);
221 |       return {
222 |         content: [{
223 |           type: "text",
224 |           text: `Error listing reports: ${errorMessage}`
225 |         }],
226 |         isError: true
227 |       };
228 |     }
229 |   }
230 | );
231 | 
232 | // Add get report results tool
233 | server.tool(
234 |   "get_report_results",
235 |   "Get the results of a report from Kayzen Reporting API. Supports flexible date formats with smart defaults. If start_date and end_date are not provided, uses the report's original date range.",
236 |   {
237 |     report_id: z.string().describe("ID of the report to fetch results for"),
238 |     start_date: z.string().optional().describe("Start date - supports flexible formats: YYYY-MM-DD, MM-DD, MM/DD, month names (January, Jan, 1月), or month numbers (1-12). Defaults to current year if year not specified."),
239 |     end_date: z.string().optional().describe("End date - supports same flexible formats as start_date. If same as start_date for month-only queries, automatically sets to month end.")
240 |   },
241 |   async (params: { report_id: string; start_date?: string; end_date?: string }) => {
242 |     try {
243 |       let actualStartDate = params.start_date;
244 |       let actualEndDate = params.end_date;
245 |       let dateSource = 'user_specified';
246 | 
247 |       // If dates provided, parse them with smart defaults
248 |       if (actualStartDate && actualEndDate) {
249 |         const parsed = parseDateRange(actualStartDate, actualEndDate);
250 |         actualStartDate = parsed.start;
251 |         actualEndDate = parsed.end;
252 |       } else if (actualStartDate && !actualEndDate) {
253 |         // If only start date provided, try to infer end date
254 |         const parsedStart = parseDateWithDefaults(actualStartDate);
255 |         if (parsedStart.endsWith('-01')) {
256 |           // Month-only query, set end to month end
257 |           const [year, month] = parsedStart.split('-').map(Number);
258 |           actualStartDate = parsedStart;
259 |           actualEndDate = getMonthEnd(year, month);
260 |         } else {
261 |           actualEndDate = parsedStart; // Same day
262 |           actualStartDate = parsedStart;
263 |         }
264 |       } else if (!actualStartDate && actualEndDate) {
265 |         actualStartDate = parseDateWithDefaults(actualEndDate);
266 |         actualEndDate = parseDateWithDefaults(actualEndDate);
267 |       }
268 | 
269 |       // If no dates provided, get from report details
270 |       if (!actualStartDate || !actualEndDate) {
271 |         const reportDetails = await getReportDetails(params.report_id);
272 |         if (!reportDetails) {
273 |           throw new Error(`Report with ID ${params.report_id} not found`);
274 |         }
275 | 
276 |         actualStartDate = actualStartDate || reportDetails.start_date;
277 |         actualEndDate = actualEndDate || reportDetails.end_date;
278 |         dateSource = 'report_original';
279 |       }
280 | 
281 |       const result = await kayzenClient.getReportResults(
282 |         params.report_id,
283 |         actualStartDate,
284 |         actualEndDate
285 |       ) as ReportResultsResponse;
286 | 
287 |       const response = {
288 |         ...result,
289 |         time_range: {
290 |           start_date: actualStartDate,
291 |           end_date: actualEndDate,
292 |           source: dateSource
293 |         }
294 |       };
295 | 
296 |       return {
297 |         content: [{
298 |           type: "text",
299 |           text: JSON.stringify(response, null, 2)
300 |         }]
301 |       };
302 |     } catch (error) {
303 |       const errorMessage = error instanceof Error ? error.message : String(error);
304 |       return {
305 |         content: [{
306 |           type: "text",
307 |           text: `Error getting report results: ${errorMessage}`
308 |         }],
309 |         isError: true
310 |       };
311 |     }
312 |   }
313 | );
314 | 
315 | // Start the server
316 | const transport = new StdioServerTransport();
317 | await server.connect(transport);
318 | 
```

--------------------------------------------------------------------------------
/src/n8n-nodes-feedmob-direct-spend-visualizer/nodes/FeedmobDirectSpendVisualizer/FeedmobDirectSpendVisualizer.node.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { existsSync } from 'fs';
  2 | import { resolve } from 'path';
  3 | import {
  4 |   query,
  5 |   type SDKAssistantMessage,
  6 |   type SDKMessage,
  7 |   type SDKResultMessage,
  8 | } from '@anthropic-ai/claude-agent-sdk';
  9 | import type {
 10 |   IExecuteFunctions,
 11 |   INodeExecutionData,
 12 |   INodeType,
 13 |   INodeTypeDescription,
 14 | } from 'n8n-workflow';
 15 | 
 16 | type FeedmobDirectSpendVisualizerCredentials = {
 17 |   provider?: 'aws_bedrock' | 'glm';
 18 |   feedmobKey?: string;
 19 |   feedmobSecret?: string;
 20 |   feedmobApiBase?: string;
 21 |   // AWS
 22 |   awsRegion?: string;
 23 |   awsAccessKeyId?: string;
 24 |   awsSecretAccessKey?: string;
 25 |   anthropicModel?: string; // used for AWS
 26 |   anthropicSmallModel?: string; // used for AWS
 27 |   // GLM
 28 |   anthropicBaseUrl?: string; // used for GLM
 29 |   anthropicAuthToken?: string; // used for GLM
 30 |   glmModel?: string; // used for GLM
 31 |   glmSmallModel?: string; // used for GLM
 32 | };
 33 | 
 34 | type AgentRunResult = {
 35 |   text: string;
 36 |   structuredOutput?: unknown;
 37 |   usage?: SDKResultMessage['usage'];
 38 | };
 39 | 
 40 | const PLUGIN_SEGMENTS = ['vendor', 'claude-code-marketplace', 'plugins', 'direct-spend-visualizer'];
 41 | 
 42 | const buildPrompt = (clickUrlId: string, startDate: string, endDate: string) => `
 43 | Use the Claude agent skill "direct-spend-visualizer" to visualize FeedMob direct spend.
 44 | Click URL ID: ${clickUrlId}
 45 | Start date: ${startDate}
 46 | End date: ${endDate}
 47 | Return any ASCII output plus JSON with keys status, summary, and data.
 48 | `.trim();
 49 | 
 50 | export class FeedmobDirectSpendVisualizer implements INodeType {
 51 |   description: INodeTypeDescription = {
 52 |     displayName: 'FeedMob Direct Spend Visualizer',
 53 |     name: 'feedmobDirectSpendVisualizer',
 54 |     icon: 'file:logo.svg',
 55 |     group: ['transform'],
 56 |     version: 1,
 57 |     description: 'Ask the Claude Agent SDK to run the FeedMob direct-spend visualizer plugin',
 58 |     defaults: { name: 'Direct Spend Visualizer' },
 59 |     inputs: ['main'],
 60 |     outputs: ['main'],
 61 |     credentials: [
 62 |       { name: 'feedmobDirectSpendVisualizerApi', required: true },
 63 |     ],
 64 |     properties: [
 65 |       {
 66 |         displayName: 'Start Date',
 67 |         name: 'startDate',
 68 |         type: 'string',
 69 |         required: true,
 70 |         default: '',
 71 |         description: 'Start date in YYYY-MM-DD format.',
 72 |       },
 73 |       {
 74 |         displayName: 'End Date',
 75 |         name: 'endDate',
 76 |         type: 'string',
 77 |         required: true,
 78 |         default: '',
 79 |         description: 'End date in YYYY-MM-DD format.',
 80 |       },
 81 |       {
 82 |         displayName: 'Click URL ID',
 83 |         name: 'clickUrlId',
 84 |         type: 'string',
 85 |         required: true,
 86 |         default: '',
 87 |         description: 'Single FeedMob click_url_id to visualize.',
 88 |       },
 89 |       {
 90 |         displayName: 'Max Turns',
 91 |         name: 'maxTurns',
 92 |         type: 'number',
 93 |         default: 50,
 94 |         typeOptions: { minValue: 1, maxValue: 100, numberStepSize: 1 },
 95 |         description: 'Number of reasoning turns allowed for the Claude Agent.',
 96 |       },
 97 |     ],
 98 |   };
 99 | 
100 |   async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
101 |     const credentials = (await this.getCredentials('feedmobDirectSpendVisualizerApi')) as FeedmobDirectSpendVisualizerCredentials;
102 |     const provider = credentials.provider || 'aws_bedrock';
103 | 
104 |     // Validate provider-specific credentials
105 |     if (provider === 'aws_bedrock') {
106 |       if (!credentials.awsAccessKeyId || !credentials.awsSecretAccessKey) {
107 |         throw new Error('Missing AWS credentials in the FeedMob Direct Spend Visualizer credential.');
108 |       }
109 |     } else if (provider === 'glm') {
110 |       if (!credentials.anthropicAuthToken) {
111 |         throw new Error('Missing GLM API Key (anthropicAuthToken) in the FeedMob Direct Spend Visualizer credential.');
112 |       }
113 |     }
114 | 
115 |     const items = this.getInputData();
116 |     const results: INodeExecutionData[] = [];
117 |     const execContext = this as IExecuteFunctions & { continueOnFail?: () => boolean };
118 | 
119 |     for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
120 |       try {
121 |         const pluginPath = resolvePluginPath();
122 | 
123 |         const startDate = this.getNodeParameter('startDate', itemIndex) as string;
124 |         const endDate = this.getNodeParameter('endDate', itemIndex) as string;
125 |         const clickUrlId = this.getNodeParameter('clickUrlId', itemIndex) as string;
126 |         const prompt = buildPrompt(clickUrlId, startDate, endDate);
127 |         const maxTurns = this.getNodeParameter('maxTurns', itemIndex, 50) as number;
128 | 
129 |         const agentResult = await runAgentWithPlugin(
130 |           prompt,
131 |           {
132 |             plugins: [{ type: 'local', path: pluginPath }],
133 |             allowedTools: ['Skill', 'mcp__plugin_direct-spend-visualizer_feedmob__get_direct_spends'],
134 |             maxTurns,
135 |             env: buildRuntimeEnv(credentials),
136 |           },
137 |         );
138 | 
139 |         results.push({
140 |           json: {
141 |             clickUrlId,
142 |             startDate,
143 |             endDate,
144 |             pluginPath,
145 |             prompt,
146 |             responseText: agentResult.text,
147 |             structuredOutput: agentResult.structuredOutput,
148 |             usage: agentResult.usage,
149 |             parsed: normalizeResult(agentResult),
150 |           },
151 |         });
152 |       } catch (error) {
153 |         const errorMessage = error instanceof Error ? error.message : 'Unknown Claude Agent SDK error';
154 |         if (execContext.continueOnFail && execContext.continueOnFail()) {
155 |           results.push({ json: { error: errorMessage } });
156 |           continue;
157 |         }
158 |         throw error;
159 |       }
160 |     }
161 | 
162 |     return [results];
163 |   }
164 | }
165 | 
166 | async function runAgentWithPlugin(
167 |   prompt: string,
168 |   options: Parameters<typeof query>[0]['options'],
169 | ): Promise<AgentRunResult> {
170 |   const assistantSnippets: string[] = [];
171 |   let resultMessage: SDKResultMessage | undefined;
172 | 
173 |   for await (const message of query({ prompt, options })) {
174 |     if (message.type === 'assistant') {
175 |       const text = extractAssistantText(message);
176 |       if (text) assistantSnippets.push(text);
177 |     } else if (message.type === 'result') {
178 |       resultMessage = message;
179 |     }
180 |   }
181 | 
182 |   if (!resultMessage) throw new Error('Claude Agent SDK returned no result message.');
183 |   if (resultMessage.subtype !== 'success') {
184 |     const reason = Array.isArray(resultMessage.errors) && resultMessage.errors.length
185 |       ? resultMessage.errors.join('; ')
186 |       : `subtype ${resultMessage.subtype}`;
187 |     throw new Error(`Claude Agent run failed: ${reason}`);
188 |   }
189 | 
190 |   const responseText = (resultMessage.result || assistantSnippets.join('\n')).trim();
191 |   return {
192 |     text: responseText,
193 |     structuredOutput: resultMessage.structured_output,
194 |     usage: resultMessage.usage,
195 |   };
196 | }
197 | 
198 | function extractAssistantText(message: Extract<SDKMessage, { type: 'assistant' }>): string {
199 |   const assistantMessage = message.message as SDKAssistantMessage['message'];
200 |   const content = Array.isArray((assistantMessage as any).content) ? (assistantMessage as any).content : [];
201 |   const textParts: string[] = [];
202 | 
203 |   for (const block of content) {
204 |     if (typeof block === 'string') {
205 |       textParts.push(block);
206 |       continue;
207 |     }
208 |     if (block?.type === 'text' && typeof block.text === 'string') {
209 |       textParts.push(block.text);
210 |       continue;
211 |     }
212 |     if (block?.type === 'tool_result' && Array.isArray(block.content)) {
213 |       for (const nested of block.content) {
214 |         if (typeof nested === 'string') textParts.push(nested);
215 |         else if (nested?.type === 'text' && typeof nested.text === 'string') textParts.push(nested.text);
216 |       }
217 |     }
218 |   }
219 | 
220 |   return textParts.join('\n').trim();
221 | }
222 | 
223 | function normalizeResult(agentResult: AgentRunResult) {
224 |   const structured =
225 |     (typeof agentResult.structuredOutput === 'object' && agentResult.structuredOutput !== null
226 |       ? agentResult.structuredOutput
227 |       : undefined) ?? tryParseJson(agentResult.text);
228 |   if (structured) return structured;
229 |   return { raw: agentResult.text };
230 | }
231 | 
232 | function tryParseJson(text?: string): unknown | undefined {
233 |   if (!text) return undefined;
234 |   const trimmed = text.trim();
235 |   if (!trimmed) return undefined;
236 |   const codeBlockMatch = trimmed.match(/```json([\s\S]*?)```/i) || trimmed.match(/```([\s\S]*?)```/i);
237 |   const candidate = codeBlockMatch ? codeBlockMatch[1] : trimmed;
238 |   try {
239 |     return JSON.parse(candidate);
240 |   } catch {
241 |     return undefined;
242 |   }
243 | }
244 | 
245 | function buildRuntimeEnv(credentials: FeedmobDirectSpendVisualizerCredentials): NodeJS.ProcessEnv {
246 |   const baseEnv = { ...process.env };
247 |   const provider = credentials.provider || 'aws_bedrock';
248 | 
249 |   // Common Validations
250 |   const feedmobKey = credentials.feedmobKey ?? baseEnv.FEEDMOB_KEY;
251 |   const feedmobSecret = credentials.feedmobSecret ?? baseEnv.FEEDMOB_SECRET;
252 |   const feedmobApiBase = credentials.feedmobApiBase ?? baseEnv.FEEDMOB_API_BASE;
253 | 
254 |   if (!feedmobKey || !feedmobSecret || !feedmobApiBase) {
255 |     throw new Error('FeedMob API env vars (FEEDMOB_KEY/SECRET/API_BASE) are required for the plugin.');
256 |   }
257 | 
258 |   const env: NodeJS.ProcessEnv = {
259 |     ...baseEnv,
260 |     FEEDMOB_KEY: feedmobKey,
261 |     FEEDMOB_SECRET: feedmobSecret,
262 |     FEEDMOB_API_BASE: feedmobApiBase,
263 |   };
264 | 
265 |   if (provider === 'aws_bedrock') {
266 |     const awsAccessKeyId = credentials.awsAccessKeyId ?? baseEnv.AWS_ACCESS_KEY_ID;
267 |     const awsSecretAccessKey = credentials.awsSecretAccessKey ?? baseEnv.AWS_SECRET_ACCESS_KEY;
268 |     if (!awsAccessKeyId || !awsSecretAccessKey) {
269 |       throw new Error('AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY missing.');
270 |     }
271 | 
272 |     env.AWS_REGION = credentials.awsRegion ?? baseEnv.AWS_REGION ?? 'us-east-1';
273 |     env.AWS_ACCESS_KEY_ID = awsAccessKeyId;
274 |     env.AWS_SECRET_ACCESS_KEY = awsSecretAccessKey;
275 |     env.AWS_RETRY_MODE = baseEnv.AWS_RETRY_MODE ?? 'adaptive';
276 |     env.AWS_MAX_ATTEMPTS = baseEnv.AWS_MAX_ATTEMPTS ?? '20';
277 |     env.CLAUDE_CODE_USE_BEDROCK = '1';
278 |     env.ANTHROPIC_MODEL = credentials.anthropicModel ?? baseEnv.ANTHROPIC_MODEL ?? 'us.anthropic.claude-sonnet-4-5-20250929-v1:0';
279 |     env.ANTHROPIC_SMALL_FAST_MODEL = credentials.anthropicSmallModel ?? baseEnv.ANTHROPIC_SMALL_FAST_MODEL ?? 'us.anthropic.claude-haiku-4-5-20251001-v1:0';
280 | 
281 |   } else if (provider === 'glm') {
282 |     // Force Unset Bedrock flag if it exists in baseEnv to avoid confusion
283 |     if (env.CLAUDE_CODE_USE_BEDROCK) {
284 |       delete env.CLAUDE_CODE_USE_BEDROCK;
285 |     }
286 | 
287 |     const auth = credentials.anthropicAuthToken ?? baseEnv.ANTHROPIC_AUTH_TOKEN;
288 |     if (!auth) {
289 |       throw new Error('ANTHROPIC_AUTH_TOKEN missing for GLM provider.');
290 |     }
291 | 
292 |     env.ANTHROPIC_BASE_URL = credentials.anthropicBaseUrl ?? 'https://open.bigmodel.cn/api/anthropic';
293 |     env.ANTHROPIC_AUTH_TOKEN = auth;
294 |     env.ANTHROPIC_MODEL = credentials.glmModel ?? 'glm-4.6';
295 |     env.ANTHROPIC_SMALL_FAST_MODEL = credentials.glmSmallModel ?? 'glm-4.6';
296 |   }
297 | 
298 |   return env;
299 | }
300 | 
301 | function resolvePluginPath(): string {
302 |   const manualPath = process.env.CLAUDE_MARKETPLACE_PLUGIN_PATH?.trim();
303 |   const candidatePaths = [
304 |     manualPath ? resolve(manualPath) : undefined,
305 |     resolve(__dirname, '..', '..', ...PLUGIN_SEGMENTS),
306 |     resolve(__dirname, '..', '..', '..', ...PLUGIN_SEGMENTS),
307 |   ].filter((candidate): candidate is string => Boolean(candidate));
308 | 
309 |   for (const candidate of candidatePaths) {
310 |     if (existsSync(candidate)) return candidate;
311 |   }
312 | 
313 |   throw new Error(
314 |     'Claude marketplace plugin directory not found. Ensure the package build copied vendor plugins or set CLAUDE_MARKETPLACE_PLUGIN_PATH.',
315 |   );
316 | }
317 | 
```

--------------------------------------------------------------------------------
/src/civitai-records/src/prompts/record-civitai-workflow.md:
--------------------------------------------------------------------------------

```markdown
  1 | # Civitai Content Recording Guide
  2 | 
  3 | You are assisting the Civitai tracking pipeline. Follow this guide to capture prompts, assets, and posts consistently across our tools.
  4 | 
  5 | ## Related Guides
  6 | - **For analyzing engagement metrics** (likes, hearts, comments, etc.), see the `civitai_media_engagement` prompt or use `get_media_engagement_guide` tool.
  7 | 
  8 | ## Goal & Mindset
  9 | - Keep a canonical, duplicate-free record that links prompts, assets, and posts.
 10 | - Record dependencies before references (e.g., prompt before asset, post before linking).
 11 | - Store every ID that a tool returns so it can be reused in subsequent calls.
 12 | 
 13 | ## Tool Quick Reference
 14 | - `calculate_sha256`
 15 |   - Purpose: Generate a SHA256 hash for a local file or remote URL to prevent duplicates and map assets.
 16 |   - Input: `path` (local file path or HTTPS/HTTP URL).
 17 |   - Returns: `{ sha256sum }`.
 18 |   - Use before creating assets and when matching local media to Civitai content.
 19 |   - Example (local):
 20 |     ```json
 21 |     {"path": "/local/path/to/image.jpg"}
 22 |     ```
 23 |   - Example (URL):
 24 |     ```json
 25 |     {"path": "https://example.com/image.jpg"}
 26 |     ```
 27 | - `find_asset`
 28 |   - Purpose: Check if an asset already exists or fetch full asset details.
 29 |   - Input: At least one of `asset_id` or `sha256sum`.
 30 |   - Returns: `{found: boolean, asset?: {...}}`.
 31 |   - Use the SHA256 from `calculate_sha256` before creating new assets.
 32 |   - Example:
 33 |     ```json
 34 |     {"sha256sum": "abc123def456"}
 35 |     ```
 36 | - `create_prompt`
 37 |   - Purpose: Save the text prompt used to generate an asset.
 38 |   - Required: `prompt_text`.
 39 |   - Optional: `llm_model_provider`, `llm_model`, `purpose`, `metadata`.
 40 |   - Returns: `{prompt_id}`.
 41 |   - Example:
 42 |     ```json
 43 |     {
 44 |       "prompt_text": "A serene mountain landscape at sunset",
 45 |       "llm_model_provider": "openai",
 46 |       "llm_model": "dall-e-3",
 47 |       "purpose": "image_generation"
 48 |     }
 49 |     ```
 50 | - `create_asset`
 51 |   - Purpose: Register generated or uploaded media.
 52 |   - Required: `asset_url`, `asset_type` (`image` | `video`), `asset_source` (`generated` | `upload`).
 53 |   - Optional: `input_prompt_id`, `output_prompt_id`, `post_id`, `civitai_id`, `civitai_url`, `metadata`.
 54 |   - Returns: `{asset_id}`.
 55 |   - Tip: Link `input_prompt_id` to the prompt that produced the asset. The tool automatically hashes `asset_url` and returns `sha256sum` in the response.
 56 |   - Example:
 57 |     ```json
 58 |     {
 59 |       "asset_url": "s3://bucket/images/mountain.jpg",
 60 |       "asset_type": "image",
 61 |       "asset_source": "generated",
 62 |       "input_prompt_id": "123"
 63 |     }
 64 |     ```
 65 | - `create_civitai_post`
 66 |   - Purpose: Record a published post on Civitai.
 67 |   - Required: `civitai_id`, `civitai_url`.
 68 |   - Optional: `status` (`pending` | `published` | `failed`), `title`, `description`, `metadata`.
 69 |   - Returns: `{post_id}`.
 70 |   - Note: `civitai_account` is inferred from the `CIVITAI_ACCOUNT` env var (default `c29`).
 71 |   - Example:
 72 |     ```json
 73 |     {
 74 |       "civitai_id": "23602354",
 75 |       "civitai_url": "https://civitai.com/posts/23602354",
 76 |       "status": "published",
 77 |       "title": "Sunset Mountain Landscape",
 78 |       "description": "AI-generated mountain scene",
 79 |       "metadata": {
 80 |         "views": 0,
 81 |         "likes": 0,
 82 |         "tags": ["landscape", "ai-art"],
 83 |         "workflow": "flux-1-pro"
 84 |       }
 85 |     }
 86 |     ```
 87 | - `fetch_civitai_post_assets`
 88 |   - Purpose: Retrieve live media assets and engagement stats for a Civitai post without writing to the database.
 89 |   - Required: `post_id` (numeric string extracted from the post URL).
 90 |   - Optional: `limit` (default 50, max 100), `page` (default 1).
 91 |   - Returns: `{asset_count, assets:[{civitai_image_id, asset_url, engagement_stats, ...}], metadata}`.
 92 |   - Use this to inspect performance or pull the authoritative asset URLs before creating/updating local records.
 93 |   - Example:
 94 |     ```json
 95 |     {
 96 |       "post_id": "23683656",
 97 |       "limit": 20
 98 |     }
 99 |     ```
100 | - `update_asset`
101 |   - Purpose: Link assets to posts, adjust prompt associations, or update metadata.
102 |   - Required: `asset_id`.
103 |   - Optional: `post_id`, `input_prompt_id`, `output_prompt_id`, `civitai_id`, `civitai_url`, `metadata`.
104 |   - Returns: Updated asset payload.
105 |   - Guidance: Omit a field or send `undefined` to keep its value; send `null` to clear it.
106 |   - Examples:
107 |     ```json
108 |     {"asset_id": "456", "post_id": "789"}
109 |     ```
110 |     ```json
111 |     {"asset_id": "456", "post_id": null}
112 |     ```
113 | - `list_civitai_posts`
114 |   - Purpose: Browse posts and their related assets/prompts.
115 |   - Filters: `civitai_id`, `status`, `created_by`, `start_time`, `end_time`.
116 |   - Pagination: `limit`, `offset`.
117 |   - Extras: `include_details: true` returns assets with nested prompt data.
118 |   - Example:
119 |     ```json
120 |     {"include_details": true, "limit": 10}
121 |     ```
122 | 
123 | ## Canonical Workflow
124 | 1. (Optional) `calculate_sha256` → hash local file or remote URL.
125 | 2. (Optional) `find_asset` → skip creation if the SHA already exists.
126 | 3. (Optional) `create_prompt` → store the prompt before recording the asset.
127 | 4. `create_asset` → register the media (include `input_prompt_id` when available).
128 | 5. `create_civitai_post` → save the post metadata.
129 | 6. `update_asset` → link the asset to the post (if not done during creation) and enrich metadata.
130 | 
131 | ## Step-by-Step Details
132 | 
133 | ### 0. Calculate SHA256 (Duplicate Prevention)
134 | - Use when you have a file/URL and need to avoid duplicate assets or confirm matches.
135 | - Call `calculate_sha256` with the file path or download URL.
136 | - Use the returned `sha256sum` with `find_asset` to check for existing records or to map media to existing records.
137 | 
138 | ### 1. Record the Prompt
139 | - Capture prompts before the associated asset is created.
140 | - Required input: `prompt_text`.
141 | - Optional metadata: model provider, model name, purpose, custom `metadata`.
142 | - Keep the returned `prompt_id` to set `input_prompt_id` or `output_prompt_id` later.
143 | 
144 | ### 2. Record the Asset
145 | - Required inputs: `asset_url`, `asset_type`, `asset_source`.
146 | - Optional relationship fields:
147 |   - `input_prompt_id`: Prompt that generated the asset.
148 |   - `output_prompt_id`: Prompt derived from the asset (e.g., captioning).
149 |   - `post_id`: Civitai post containing the asset (if you already recorded it).
150 |   - `civitai_id` / `civitai_url`: Identifiers returned from `fetch_civitai_post_assets` for each media item.
151 |   - `metadata`: Any structured data you want to retain (API response, tags, metrics).
152 | - Tip: When the Civitai post already exists, call `fetch_civitai_post_assets` first to pull the authoritative `asset_url`, set `civitai_id`/`civitai_url`, and capture engagement stats in `metadata` for downstream reporting.
153 | - The tool automatically calculates `sha256sum` from `asset_url` and includes it in the response.
154 | - Save the returned `asset_id` for linking or future updates.
155 | 
156 | ### 3. Record the Civitai Post
157 | - Extract the numeric ID from the post URL (`https://civitai.com/posts/23602354` → `23602354`).
158 | - Provide `civitai_id` and `civitai_url`; include optional `status`, `title`, `description`, `metadata`.
159 | - Store the returned `post_id`. Assets point to posts (one post can have many assets).
160 | 
161 | ### 4. Link Assets and Maintain Metadata
162 | - Use `update_asset` to:
163 |   - Attach `post_id` once the post exists.
164 |   - Set or change `input_prompt_id` / `output_prompt_id`.
165 |   - Add or refresh `civitai_id` / `civitai_url`.
166 |   - Clear values by sending `null`.
167 | - Remember: assets own the link to posts; posts do not store asset IDs.
168 | 
169 | ## Supporting Queries
170 | - `find_asset`:
171 |   - Use `sha256sum` to prevent duplicates or locate existing records.
172 |   - Use `asset_id` to fetch the complete asset payload for auditing.
173 |   - Examples:
174 |     ```json
175 |     {"sha256sum": "abc123def456"}
176 |     ```
177 |     ```json
178 |     {"asset_id": "456"}
179 |     ```
180 | - `list_civitai_posts`:
181 |   - Filter by `status`, `created_by`, time window, or specific `civitai_id`.
182 |   - Include `include_details: true` to retrieve each post’s assets and nested prompt metadata for verification or reporting.
183 |   - Example:
184 |     ```json
185 |     {
186 |       "status": "published",
187 |       "include_details": true,
188 |       "limit": 5,
189 |       "offset": 0
190 |     }
191 |     ```
192 | - `fetch_civitai_post_assets`:
193 |   - Supply `post_id` to get the post’s current media assets directly from Civitai along with engagement stats (likes, hearts, comments, etc.).
194 |   - Use when reconciling posts, validating asset URLs, or gauging performance before recording updates.
195 |   - Example:
196 |     ```json
197 |     {
198 |       "post_id": "23683656",
199 |       "page": 2,
200 |       "limit": 25
201 |     }
202 |     ```
203 | 
204 | ## Best Practices & Validation
205 | - Always capture and reuse the IDs returned from each tool.
206 | - Follow the dependency order: hash → prompt → asset → post → link.
207 | - Prevent duplicates: hash first, look up with `find_asset`, reuse the existing `asset_id` instead of creating a new record.
208 | - Assets automatically store a SHA256 hash of their `asset_url`; keep the returned value handy for auditing.
209 | - Respect field constraints:
210 |   - `asset_type`: `image` or `video`.
211 |   - `asset_source`: `generated` or `upload`.
212 |   - `status`: `pending`, `published`, or `failed`.
213 |   - IDs: strings containing only digits.
214 |   - Timestamps: ISO 8601 (`2025-01-15T10:00:00Z`).
215 | - Use the `metadata` field for API responses, engagement metrics, tags, workflows, or other tracking data.
216 | - When linking prompts: `input_prompt_id` represents the prompt that created the asset, `output_prompt_id` represents a prompt derived from it.
217 | 
218 | ## Troubleshooting Checklist
219 | - **“asset_id must be a valid integer ID”**: Ensure you are sending the ID as a string containing digits only.
220 | - **“Record not found”**: Confirm the ID via `list_civitai_posts` or ensure you saved the correct ID from the previous call.
221 | - **Cannot link a prompt**: Create the prompt first, then supply the returned `prompt_id` when creating/updating the asset.
222 | - **Multiple assets per post**: Record each asset separately and link them all to the same `post_id` using `update_asset`.
223 | 
224 | ## Playbooks
225 | 
226 | ### Standard End-to-End Flow
227 | 1. `calculate_sha256` on the file or URL.
228 | 2. `find_asset` with that `sha256sum`; stop if `found` is true.
229 | 3. `create_prompt` (if a prompt exists) → keep `prompt_id`.
230 | 4. `create_asset` with `input_prompt_id`.
231 | 5. `create_civitai_post` → keep `post_id`.
232 | 6. `update_asset` to attach `post_id`.
233 | 
234 | ### Asset + Post Without a Prompt
235 | 1. `create_asset` → keep `asset_id`.
236 | 2. `create_civitai_post` → keep `post_id`.
237 | 3. `update_asset` with both IDs to link.
238 | 
239 | ### Assets First, Then Post
240 | 1. Create each asset without `post_id`.
241 | 2. Once the post is recorded, call `fetch_civitai_post_assets` to reconcile the canonical asset list.
242 | 3. For each matching item:
243 |    - `update_asset({asset_id, post_id})` to link.
244 |    - Optionally add engagement stats or update `civitai_id`/`civitai_url` from the API response.
245 | 
246 | ### Match Local Media to a Civitai URL
247 | 1. Hash the local file with `calculate_sha256`.
248 | 2. Visit candidate Civitai image pages to grab the download URLs.
249 | 3. Hash each remote file with `calculate_sha256`.
250 | 4. When hashes match, call `update_asset` to store the corresponding `civitai_id` and `civitai_url`.
251 | 
252 | ## Extracting Asset URLs from Civitai Posts
253 | 1. Call `fetch_civitai_post_assets` with the post’s numeric ID to retrieve the authoritative list of media along with their direct `asset_url`, `civitai_image_id`, and engagement stats.
254 | 2. Use the returned payload to populate `asset_url`, `civitai_url` (if present), and any performance metadata when creating or updating assets locally.
255 | 3. Hash the returned `asset_url` values with `calculate_sha256` when you need duplicate detection before persisting assets.
256 | 
257 | ### Example: Multi-Asset Post
258 | For `https://civitai.com/posts/23604281` containing three images:
259 | 
260 | 1. `create_civitai_post({ "civitai_id": "23604281", "civitai_url": "https://civitai.com/posts/23604281" })` → `post_id: "1"`.
261 | 2. Call `fetch_civitai_post_assets({ "post_id": "23604281" })` to pull the live asset list.
262 | 3. For each item in the response:
263 |    - If you already have a matching asset (by SHA or URL), reuse the `asset_id`. Otherwise `create_asset` with the `asset_url`, `civitai_image_id`, and `civitai_url` (if present).
264 |    - Capture `engagement_stats` and other metadata in the asset record if it’s useful for reporting.
265 |    - Link the asset to the post with `update_asset({ "asset_id": "...", "post_id": "1" })`.
266 | 
267 | ### Video-Specific Notes
268 | - `fetch_civitai_post_assets` returns direct download URLs for videos in `asset_url`. Use those when creating or updating assets.
269 | - Hash the `asset_url` if you need dedupe guarantees before persisting.
270 | 
271 | Following this guide keeps the Civitai dataset consistent, deduplicated, and fully linked across prompts, assets, and posts.
272 | 
```

--------------------------------------------------------------------------------
/src/feedmob-reporting/src/index.ts:
--------------------------------------------------------------------------------

```typescript
  1 | #!/usr/bin/env node
  2 | 
  3 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
  4 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
  5 | import { z } from "zod";
  6 | import { fetchDirectSpendsData, getInmobiReportIds, checkInmobiReportStatus, getInmobiReports, createDirectSpend, getAppsflyerReports, getAdopsReports, getAgencyConversionMetrics, getClickUrlHistories, getPossibleFinanceSingularReports } from "./api.js";
  7 | 
  8 | // Create server instance
  9 | const server = new McpServer({
 10 |   name: "feedmob-reporting",
 11 |   version: "0.0.7",
 12 |   capabilities: {
 13 |     tools: {},
 14 |     prompts: {},
 15 |   },
 16 | });
 17 | 
 18 | // Tool Definition for Creating Direct Spend
 19 | server.tool(
 20 |   "create_direct_spend",
 21 |   "Create Or Update a direct spend via FeedMob API.",
 22 |   {
 23 |     click_url_id: z.number().describe("Click URL ID"),
 24 |     spend_date: z.string().describe("Spend date in YYYY-MM-DD format"),
 25 |     net_spend: z.number().optional().describe("Net spend amount"),
 26 |     gross_spend: z.number().optional().describe("Gross spend amount"),
 27 |     partner_paid_action_count: z.number().optional().describe("Partner paid action count"),
 28 |     client_paid_action_count: z.number().optional().describe("Client paid action count"),
 29 |   },
 30 |   async (params) => {
 31 |     try {
 32 |       if (!params.net_spend && !params.gross_spend && !params.partner_paid_action_count && !params.client_paid_action_count) {
 33 |         throw new Error("必须提供至少一个支出指标:net_spend, gross_spend, partner_paid_action_count 或 client_paid_action_count");
 34 |       }
 35 | 
 36 |       const result = await createDirectSpend(
 37 |         params.click_url_id,
 38 |         params.spend_date,
 39 |         params.net_spend,
 40 |         params.gross_spend,
 41 |         params.partner_paid_action_count,
 42 |         params.client_paid_action_count
 43 |       );
 44 |       const formattedData = JSON.stringify(result, null, 2);
 45 |       return {
 46 |         content: [{
 47 |           type: "text",
 48 |           text: `Direct spend created successfully:\n\`\`\`json\n${formattedData}\n\`\`\``,
 49 |         }],
 50 |       };
 51 |     } catch (error: unknown) {
 52 |       const errorMessage = error instanceof Error ? error.message : "An unknown error occurred while creating direct spend.";
 53 |       console.error("Error in create_direct_spend tool:", errorMessage);
 54 |       return {
 55 |         content: [{ type: "text", text: `Error creating direct spend: ${errorMessage}` }],
 56 |         isError: true,
 57 |       };
 58 |     }
 59 |   }
 60 | );
 61 | 
 62 | // Tool Definition for Getting Direct Spends
 63 | server.tool(
 64 |   "get_direct_spends",
 65 |   "Get direct spends data via FeedMob API.",
 66 |   {
 67 |     start_date: z.string().describe("Start date in YYYY-MM-DD format"),
 68 |     end_date: z.string().describe("End date in YYYY-MM-DD format"),
 69 |     click_url_ids: z.array(z.string()).describe("Array of click URL IDs"),
 70 |   },
 71 |   async (params) => {
 72 |     try {
 73 |       const spendData = await fetchDirectSpendsData(
 74 |         params.start_date,
 75 |         params.end_date,
 76 |         params.click_url_ids
 77 |       );
 78 |       const formattedData = JSON.stringify(spendData, null, 2);
 79 |       return {
 80 |         content: [{
 81 |           type: "text",
 82 |           text: `Direct spends data:\n\`\`\`json\n${formattedData}\n\`\`\``,
 83 |         }],
 84 |       };
 85 |     } catch (error: unknown) {
 86 |       const errorMessage = error instanceof Error ? error.message : "An unknown error occurred while fetching direct spends data.";
 87 |       console.error("Error in get_direct_spends tool:", errorMessage);
 88 |       return {
 89 |         content: [{ type: "text", text: `Error fetching direct spends data: ${errorMessage}` }],
 90 |         isError: true,
 91 |       };
 92 |     }
 93 |   }
 94 | );
 95 | 
 96 | // Tool Definition for Agency Conversion Metrics
 97 | server.tool(
 98 |   "get_agency_conversion_metrics",
 99 |   "Get agency_conversion_records metrics for one or more click URL IDs.",
100 |   {
101 |     click_url_ids: z.array(z.number()).describe("Array of click URL IDs"),
102 |     date: z.string().optional().describe("Optional date in YYYY-MM-DD format"),
103 |   },
104 |   async (params) => {
105 |     try {
106 |       const data = await getAgencyConversionMetrics(params.click_url_ids, params.date);
107 |       const formattedData = JSON.stringify(data, null, 2);
108 |       return {
109 |         content: [{
110 |           type: "text",
111 |           text: `Agency conversion metrics data:\n\`\`\`json\n${formattedData}\n\`\`\``,
112 |         }],
113 |       };
114 |     } catch (error: unknown) {
115 |       const errorMessage = error instanceof Error ? error.message : "An unknown error occurred while fetching agency conversion metrics.";
116 |       console.error("Error in get_agency_conversion_metrics tool:", errorMessage);
117 |       return {
118 |         content: [{ type: "text", text: `Error fetching agency conversion metrics: ${errorMessage}` }],
119 |         isError: true,
120 |       };
121 |     }
122 |   }
123 | );
124 | 
125 | // Tool Definition for Click URL Histories
126 | server.tool(
127 |   "get_click_url_histories",
128 |   "Get historical CPI data for click URL IDs.",
129 |   {
130 |     click_url_ids: z.array(z.number()).describe("Array of click URL IDs"),
131 |     date: z.string().optional().describe("Optional date in YYYY-MM-DD format"),
132 |   },
133 |   async (params) => {
134 |     try {
135 |       const data = await getClickUrlHistories(params.click_url_ids, params.date);
136 |       const formattedData = JSON.stringify(data, null, 2);
137 |       return {
138 |         content: [{
139 |           type: "text",
140 |           text: `Click URL histories data:\n\`\`\`json\n${formattedData}\n\`\`\``,
141 |         }],
142 |       };
143 |     } catch (error: unknown) {
144 |       const errorMessage = error instanceof Error ? error.message : "An unknown error occurred while fetching click URL histories.";
145 |       console.error("Error in get_click_url_histories tool:", errorMessage);
146 |       return {
147 |         content: [{ type: "text", text: `Error fetching click URL histories: ${errorMessage}` }],
148 |         isError: true,
149 |       };
150 |     }
151 |   }
152 | );
153 | 
154 | // Tool Definition for Inmobi Report IDs
155 | server.tool(
156 |   "get_inmobi_report_ids",
157 |   "Get Inmobi report IDs for a date range. next step must use tool check_inmobi_report_id_status to check skan_report_id and non_skan_report_id available",
158 |   {
159 |     start_date: z.string().describe("Start date in YYYY-MM-DD format"),
160 |     end_date: z.string().describe("End date in YYYY-MM-DD format"),
161 |   },
162 |   async (params) => {
163 |     try {
164 |       const data = await getInmobiReportIds(params.start_date, params.end_date);
165 |       const formattedData = JSON.stringify(data, null, 2);
166 |       return {
167 |         content: [{
168 |           type: "text",
169 |           text: `Inmobi report IDs:\n\`\`\`json\n${formattedData}\n\`\`\``,
170 |         }],
171 |       };
172 |     } catch (error: unknown) {
173 |       const errorMessage = error instanceof Error ? error.message : "An unknown error occurred while fetching Inmobi report IDs.";
174 |       console.error("Error in get_inmobi_report_ids tool:", errorMessage);
175 |       return {
176 |         content: [{ type: "text", text: `Error fetching Inmobi report IDs: ${errorMessage}` }],
177 |         isError: true,
178 |       };
179 |     }
180 |   }
181 | );
182 | 
183 | // Tool Definition for Checking Report Status
184 | server.tool(
185 |   "check_inmobi_report_status",
186 |   "Check the status of an Inmobi report.",
187 |   {
188 |     start_date: z.string().describe("Start date in YYYY-MM-DD format"),
189 |     end_date: z.string().describe("End date in YYYY-MM-DD format"),
190 |     report_id: z.string().describe("Report ID to check status for"),
191 |   },
192 |   async (params) => {
193 |     try {
194 |       const data = await checkInmobiReportStatus(params.start_date, params.end_date, params.report_id);
195 |       const formattedData = JSON.stringify(data, null, 2);
196 |       return {
197 |         content: [{
198 |           type: "text",
199 |           text: `Inmobi report status:\n\`\`\`json\n${formattedData}\n\`\`\``,
200 |         }],
201 |       };
202 |     } catch (error: unknown) {
203 |       const errorMessage = error instanceof Error ? error.message : "An unknown error occurred while checking Inmobi report status.";
204 |       console.error("Error in check_inmobi_report_status tool:", errorMessage);
205 |       return {
206 |         content: [{ type: "text", text: `Error checking Inmobi report status: ${errorMessage}` }],
207 |         isError: true,
208 |       };
209 |     }
210 |   }
211 | );
212 | 
213 | // Tool Definition for Getting Reports
214 | server.tool(
215 |   "get_inmobi_reports",
216 |   "Get Inmobi reports data. next step should check direct spend from feedmob",
217 |   {
218 |     start_date: z.string().describe("Start date in YYYY-MM-DD format"),
219 |     end_date: z.string().describe("End date in YYYY-MM-DD format"),
220 |     skan_report_id: z.string().describe("SKAN report ID"),
221 |     non_skan_report_id: z.string().describe("Non-SKAN report ID"),
222 |   },
223 |   async (params) => {
224 |     try {
225 |       const data = await getInmobiReports(
226 |         params.start_date,
227 |         params.end_date,
228 |         params.skan_report_id,
229 |         params.non_skan_report_id
230 |       );
231 |       const formattedData = JSON.stringify(data, null, 2);
232 |       return {
233 |         content: [{
234 |           type: "text",
235 |           text: `Inmobi reports data:\n\`\`\`json\n${formattedData}\n\`\`\``,
236 |         }],
237 |       };
238 |     } catch (error: unknown) {
239 |       const errorMessage = error instanceof Error ? error.message : "An unknown error occurred while fetching Inmobi reports.";
240 |       console.error("Error in get_inmobi_reports tool:", errorMessage);
241 |       return {
242 |         content: [{ type: "text", text: `Error fetching Inmobi reports: ${errorMessage}` }],
243 |         isError: true,
244 |       };
245 |     }
246 |   }
247 | );
248 | 
249 | // Tool Definition for Getting AppsFlyer Reports
250 | server.tool(
251 |   "get_appsflyer_reports",
252 |   "Get AppsFlyer reports data via FeedMob API.",
253 |   {
254 |     start_date: z.string().describe("Start date in YYYY-MM-DD format"),
255 |     end_date: z.string().describe("End date in YYYY-MM-DD format"),
256 |     click_url_ids: z.array(z.string()).optional().describe("Array of click URL IDs (optional)"),
257 |     af_app_ids: z.array(z.string()).optional().describe("Array of AppsFlyer app IDs (optional)"),
258 |   },
259 |   async (params) => {
260 |     try {
261 |       const data = await getAppsflyerReports(
262 |         params.start_date,
263 |         params.end_date,
264 |         params.click_url_ids,
265 |         params.af_app_ids
266 |       );
267 |       const formattedData = JSON.stringify(data, null, 2);
268 |       return {
269 |         content: [{
270 |           type: "text",
271 |           text: `AppsFlyer reports data:\n\`\`\`json\n${formattedData}\n\`\`\``,
272 |         }],
273 |       };
274 |     } catch (error: unknown) {
275 |       const errorMessage = error instanceof Error ? error.message : "An unknown error occurred while fetching AppsFlyer reports.";
276 |       console.error("Error in get_appsflyer_reports tool:", errorMessage);
277 |       return {
278 |         content: [{ type: "text", text: `Error fetching AppsFlyer reports: ${errorMessage}` }],
279 |         isError: true,
280 |       };
281 |     }
282 |   }
283 | );
284 | 
285 | // Tool Definition for Getting AdOps Reports
286 | server.tool(
287 |   "get_adops_reports",
288 |   "Get AdOps reports data via FeedMob API.",
289 |   {
290 |     month: z.string().describe("Month in YYYY-MM format"),
291 |   },
292 |   async (params) => {
293 |     try {
294 |       const data = await getAdopsReports(params.month);
295 |       const formattedData = JSON.stringify(data, null, 2);
296 |       return {
297 |         content: [{
298 |           type: "text",
299 |           text: `AdOps reports data:\n\`\`\`json\n${formattedData}\n\`\`\``,
300 |         }],
301 |       };
302 |     } catch (error: unknown) {
303 |       const errorMessage = error instanceof Error ? error.message : "An unknown error occurred while fetching AdOps reports.";
304 |       console.error("Error in get_adops_reports tool:", errorMessage);
305 |       return {
306 |         content: [{ type: "text", text: `Error fetching AdOps reports: ${errorMessage}` }],
307 |         isError: true,
308 |       };
309 |     }
310 |   }
311 | );
312 | 
313 | // Tool Definition for Getting Possible Finance Singular Reports
314 | server.tool(
315 |   "get_possible_finance_singular_reports",
316 |   "Get Possible Finance Singular API reports data via FeedMob API.",
317 |   {
318 |     start_date: z.string().describe("Start date in YYYY-MM-DD format"),
319 |     end_date: z.string().describe("End date in YYYY-MM-DD format"),
320 |   },
321 |   async (params) => {
322 |     try {
323 |       const data = await getPossibleFinanceSingularReports(
324 |         params.start_date,
325 |         params.end_date
326 |       );
327 |       const formattedData = JSON.stringify(data, null, 2);
328 |       return {
329 |         content: [{
330 |           type: "text",
331 |           text: `Possible Finance Singular reports data:\n\`\`\`json\n${formattedData}\n\`\`\``,
332 |         }],
333 |       };
334 |     } catch (error: unknown) {
335 |       const errorMessage = error instanceof Error ? error.message : "An unknown error occurred while fetching Possible Finance Singular reports.";
336 |       console.error("Error in get_possible_finance_singular_reports tool:", errorMessage);
337 |       return {
338 |         content: [{ type: "text", text: `Error fetching Possible Finance Singular reports: ${errorMessage}` }],
339 |         isError: true,
340 |       };
341 |     }
342 |   }
343 | );
344 | 
345 | 
346 | // Prompt Definition
347 | server.prompt(
348 |   "get_inmobi_reports",
349 |   {},
350 |   () => {
351 |     return {
352 |       messages: [{
353 |         role: "user",
354 |         content: {
355 |           type: "text",
356 |           text: "next step should check direct spend from feedmob",
357 |         }
358 |       }],
359 |     };
360 |   }
361 | );
362 | 
363 | // Run the Server
364 | async function main() {
365 |   const transport = new StdioServerTransport();
366 |   try {
367 |     await server.connect(transport);
368 |     console.error("FeedMob Spend MCP Server running on stdio...");
369 |   } catch (error) {
370 |     console.error("Failed to start FeedMob Spend MCP Server:", error);
371 |     process.exit(1);
372 |   }
373 | }
374 | 
375 | main();
376 | 
```

--------------------------------------------------------------------------------
/src/femini-reporting/src/index.ts:
--------------------------------------------------------------------------------

```typescript
  1 | #!/usr/bin/env node
  2 | 
  3 | import { FastMCP } from "fastmcp";
  4 | import { Schema, z } from "zod";
  5 | import jwt from 'jsonwebtoken';
  6 | 
  7 | // Create FastMCP server instance
  8 | const server = new FastMCP({
  9 |   name: "femini-reporting",
 10 |   version: "0.0.5",
 11 |   instructions: `
 12 | This is a customized MCP server for the Feedmob project, specifically for querying and analyzing ad spend data.
 13 | 
 14 | Key Features:
 15 | - Query ad spend data (Campaign Spends)
 16 | - Supports various grouping methods and metrics
 17 | - Provides flexible filtering conditions
 18 | `.trim(),
 19 | });
 20 | 
 21 | const FEMINI_API_URL = process.env.FEMINI_API_URL;
 22 | const FEMINI_API_TOKEN = process.env.FEMINI_API_TOKEN;
 23 | const FEEDMOB_API_BASE = process.env.FEEDMOB_API_BASE;
 24 | const FEEDMOB_KEY = process.env.FEEDMOB_KEY;
 25 | const FEEDMOB_SECRET = process.env.FEEDMOB_SECRET;
 26 | 
 27 | if (!FEEDMOB_KEY || !FEEDMOB_SECRET) {
 28 |   console.error("Error: FEEDMOB_KEY and FEEDMOB_SECRET environment variables must be set.");
 29 |   process.exit(1);
 30 | }
 31 | 
 32 | // Generate JWT token
 33 | function generateToken(key: string, secret: string): string {
 34 |   const expirationDate = new Date();
 35 |   expirationDate.setDate(expirationDate.getDate() + 7); // 7 days from now
 36 | 
 37 |   const payload = {
 38 |     key: key,
 39 |     expired_at: expirationDate.toISOString().split('T')[0] // Format as YYYY-MM-DD
 40 |   };
 41 | 
 42 |   return jwt.sign(payload, secret, { algorithm: 'HS256' });
 43 | }
 44 | 
 45 | server.addTool({
 46 |   name: "query_admin_infomation",
 47 |   description: "Queries various metric data for client, campaign, partner, and click_url from the admin system, supporting multiple metrics and filtering conditions.",
 48 |   parameters: z.object({
 49 |     date_gteq: z.string().optional().describe("Start date (YYYY-MM-DD format), defaults to the first day of the previous month"),
 50 |     date_lteq: z.string().optional().describe("End date (YYYY-MM-DD format), defaults to yesterday"),
 51 |     metrics: z.array(z.enum([
 52 |         "direct_spends",
 53 |         "infomation",
 54 |         "direct_spend_with_change_logs",
 55 |         "infomation_with_change_logs",
 56 |         "price_rate_change_logs",
 57 |         "spend_requests",
 58 |         "spend_request_with_change_logs"
 59 |       ]))
 60 |       .optional()
 61 |       .default(["infomation"])
 62 |       .describe(`### infomation
 63 |         CAMPAIGN NAME,VENDOR NAME,TRACKER,LINK TYPE,MMP CLICK TRACKING LINK,MMP IMPRESSION TRACKING LINK,STATUS,START TIME,END TIME,DIRECT SPEND INPUT,NET CPI/CPA/CPM,GROSS UNIT PRICE,MARGIN,CLIENT PAID ACTION,VENDOR PAID ACTION,CAP ACTION,TARGET CAP,MAX CAP,DIRECT SPEND AUTOMATION SWITCH,CREATED AT,UPDATED AT
 64 |         ### direct_spends
 65 |         List of direct spends for the specified date range, including:
 66 |         - date
 67 |         - gross_spend
 68 |         - net_spend
 69 |         - margin
 70 |         - last_update_user
 71 |         ### direct_spend_with_change_logs
 72 |         List of direct spends for the specified date range, including:
 73 |         - date
 74 |         - gross_spend
 75 |         - net_spend
 76 |         - margin
 77 |         - last_update_user
 78 |         - change_logs (user_name, action, version, comment, created_at, audited_changes)
 79 |         ### infomation_with_change_logs
 80 |         Same as \`infomation\`, but additionally includes \`CHANGE LOGS\` (user_name, action, version, comment, created_at, audited_changes)
 81 |         ### price_rate_change_logs
 82 |         List of price change for the specified date range, including:
 83 |         - start_date
 84 |         - end_date
 85 |         - net_rate
 86 |         - gross_rate
 87 |         - margin
 88 |         - create_user
 89 |         ### spend_requests
 90 |         List of spend requests, including:
 91 |         - gross_spend_formula
 92 |         - net_spend_formula
 93 |         - margin_formula
 94 |         - gross_spend_source
 95 |         - net_spend_source
 96 |         - margin_source
 97 |         - github_ticket
 98 |         - having_client_report
 99 |         - margin_type
100 |         - status
101 |         - created_at
102 |         - hubspot_ticket
103 |         - client_paid_actions
104 |         - vendor_paid_actions
105 |         - automation_start_date
106 |         ### spend_request_with_change_logs
107 |         Same as \`spend_requests\`, but additionally includes \`change_logs\` (user_name, action, version, comment, created_at, audited_changes)
108 |       `).describe(`Metrics for admin system data.`),
109 |     legacy_client_id_in: z.array(z.string()).optional().describe("Client ID filter (array)"),
110 |     legacy_partner_id_in: z.array(z.string()).optional().describe("Partner ID filter (array)"),
111 |     legacy_campaign_id_in: z.array(z.string()).optional().describe("Campaign ID filter (array)"),
112 |     legacy_click_url_id_in: z.array(z.string()).optional().describe("Click URL ID filter (array)"),
113 |   }),
114 |   execute: async (args, { log }) => {
115 |     try {
116 |       const queryParams = new URLSearchParams();
117 |       
118 |       if (args.date_gteq) queryParams.append('date_gteq', args.date_gteq);
119 |       if (args.date_lteq) queryParams.append('date_lteq', args.date_lteq);
120 |       
121 |       // Process array parameters
122 |       if (args.metrics) {
123 |         args.metrics.forEach(metric => queryParams.append('metrics[]', metric));
124 |       }
125 |       if (args.legacy_client_id_in) {
126 |         args.legacy_client_id_in.forEach(id => queryParams.append('legacy_client_id_in[]', id));
127 |       }
128 |       if (args.legacy_partner_id_in) {
129 |         args.legacy_partner_id_in.forEach(id => queryParams.append('legacy_partner_id_in[]', id));
130 |       }
131 |       if (args.legacy_campaign_id_in) {
132 |         args.legacy_campaign_id_in.forEach(id => queryParams.append('legacy_campaign_id_in[]', id));
133 |       }
134 |       if (args.legacy_click_url_id_in) {
135 |         args.legacy_click_url_id_in.forEach(id => queryParams.append('legacy_click_url_id_in[]', id));
136 |       }
137 | 
138 |       const apiUrl = `${FEEDMOB_API_BASE}/ai/api/femini_mcp_reports?${queryParams.toString()}`;
139 |       const token = generateToken(FEEDMOB_KEY as string, FEEDMOB_SECRET as string);
140 |       const response = await fetch(apiUrl, {
141 |         method: 'GET',
142 |         headers: {
143 |           'Content-Type': 'application/json',
144 |           'Accept': 'application/json',
145 |           'FEEDMOB-KEY': FEEDMOB_KEY,
146 |           'FEEDMOB-TOKEN': token
147 |         },
148 |       });
149 | 
150 |       if (!response.ok) {
151 |         throw new Error(`API request failed: ${response.status} ${response.statusText}`);
152 |       }
153 | 
154 |       const data = await response.json();
155 | 
156 |       return {
157 |         content: [
158 |           {
159 |             type: "text",
160 |             text: `# Query Result
161 | **Query Parameters:**
162 | - Metrics: ${args.metrics?.join(', ')}
163 | - Date Range: ${args.date_gteq || 'default'} to ${args.date_lteq || 'default'}
164 | **Raw JSON Data:**
165 | \`\`\`json
166 | ${JSON.stringify(data, null, 2)}
167 | \`\`\`
168 | **Please further analyze and find the data required by the user based on the prompt, and return the data in a human-readable, formatted, and aesthetically pleasing manner.**
169 | `,
170 |           },
171 |         ],
172 |       };
173 |     } catch (error: unknown) {
174 |       throw new Error(`Failed to query admin infomation: ${(error as Error).message}`);
175 |     }
176 |   },
177 | });
178 | 
179 | // Query femini data
180 | server.addTool({
181 |   name: "query_campaign_spends",
182 |   description: "Queries ad spend data from the femoni system, supporting various grouping methods and filtering conditions. After obtaining the results, further analyze and summarize the returned data, such as calculating total gross spend, total net spend, and identifying clients with the highest spend.",
183 |   parameters: z.object({
184 |     guide: z.string().describe("get from system resources campaign-spends-api-guide://usage"),
185 |     date_gteq: z.string().optional().describe("Start date (YYYY-MM-DD format), defaults to the first day of the previous month"),
186 |     date_lteq: z.string().optional().describe("End date (YYYY-MM-DD format), defaults to yesterday"),
187 |     groups: z.array(z.enum(["day", "week", "month", "client", "partner", "campaign", "click_url", "country"]))
188 |       .optional()
189 |       .default(['campaign', 'partner'])
190 |       .describe("Grouping methods: day, week, month, client, partner, campaign, click_url, country"),
191 |     metrics: z.array(z.enum(["gross", "net", "revenue", "impressions", "clicks", "installs", "cvr", "margin"]))
192 |       .optional()
193 |       .default(["gross", "net"])
194 |       .describe("Metrics to return: gross, net, revenue, impressions, clicks, installs, cvr, margin"),
195 |     legacy_client_id_in: z.array(z.string()).optional().describe("Client ID filter (array)"),
196 |     legacy_partner_id_in: z.array(z.string()).optional().describe("Partner ID filter (array)"),
197 |     legacy_campaign_id_in: z.array(z.string()).optional().describe("Campaign ID filter (array)"),
198 |     legacy_click_url_id_in: z.array(z.string()).optional().describe("Click URL ID filter (array)"),
199 |   }),
200 |   annotations: {
201 |     title: "Ad Spend Data Query Tool",
202 |     readOnlyHint: true,
203 |     openWorldHint: true,
204 |   },
205 |   execute: async (args, { log }) => {
206 |     try {
207 |       log.info("Querying ad spend data", { 
208 |         groups: args.groups, 
209 |         metrics: args.metrics,
210 |         date_range: `${args.date_gteq || 'default'} to ${args.date_lteq || 'default'}`
211 |       });
212 | 
213 |       // Construct query parameters
214 |       const queryParams = new URLSearchParams();
215 |       
216 |       if (args.date_gteq) queryParams.append('date_gteq', args.date_gteq);
217 |       if (args.date_lteq) queryParams.append('date_lteq', args.date_lteq);
218 |       
219 |       // Process array parameters
220 |       if (args.metrics) {
221 |         args.metrics.forEach(metric => queryParams.append('metrics[]', metric));
222 |       }
223 |       if (args.groups) {
224 |         args.groups.forEach(group => queryParams.append('groups[]', group));
225 |       }
226 |       if (args.legacy_client_id_in) {
227 |         args.legacy_client_id_in.forEach(id => queryParams.append('legacy_client_id_in[]', id));
228 |       }
229 |       if (args.legacy_partner_id_in) {
230 |         args.legacy_partner_id_in.forEach(id => queryParams.append('legacy_partner_id_in[]', id));
231 |       }
232 |       if (args.legacy_campaign_id_in) {
233 |         args.legacy_campaign_id_in.forEach(id => queryParams.append('legacy_campaign_id_in[]', id));
234 |       }
235 |       if (args.legacy_click_url_id_in) {
236 |         args.legacy_click_url_id_in.forEach(id => queryParams.append('legacy_click_url_id_in[]', id));
237 |       }
238 | 
239 |       // Construct full API URL
240 |       const apiUrl = `${FEMINI_API_URL}/api/unstable/mcp/campaign_spends?${queryParams.toString()}`;
241 |       
242 |       log.info("Sending API request", { url: apiUrl });
243 | 
244 |       // Send HTTP request
245 |       const response = await fetch(apiUrl, {
246 |         method: 'GET',
247 |         headers: {
248 |           'Accept': 'application/json',
249 |           'Content-Type': 'application/json',
250 |           'Authorization': "Bearer " + FEMINI_API_TOKEN
251 |         },
252 |       });
253 | 
254 |       if (!response.ok) {
255 |         throw new Error(`API request failed: ${response.status} ${response.statusText}`);
256 |       }
257 | 
258 |       const data = await response.json();
259 |       log.info("API request successful", { resultCount: Object.keys(data).length });
260 | 
261 |       return {
262 |         content: [
263 |           {
264 |             type: "text",
265 |             text: `# Ad Spend Data Query Result
266 | **Query Parameters:**
267 | - Grouping Method: ${args.groups}
268 | - Metrics: ${args.metrics?.join(', ')}
269 | - Date Range: ${args.date_gteq || 'default'} to ${args.date_lteq || 'default'}
270 | **Raw JSON Data:**
271 | \`\`\`json
272 | ${JSON.stringify(data, null, 2)}
273 | \`\`\`
274 | **Please further analyze and summarize the JSON array data based on the prompt, for example, calculate total gross spend, total net spend, and identify clients with the highest spend. Return the data in a formatted and aesthetically pleasing manner.**
275 | **For table data, please generate plain text tables that are easy for humans to read.**
276 | `,
277 |           },
278 |         ],
279 |       };
280 |     } catch (error: unknown) {
281 |       log.error("Failed to query ad spend data", { error: (error as Error).message });
282 |       throw new Error(`Failed to query ad spend data: ${(error as Error).message}`);
283 |     }
284 |   },
285 | });
286 | 
287 | server.addTool({
288 |   name: "search_ids",
289 |   description: "Retrieves a list of client, partner, and campaign ID information based on keywords. Note: 'femini', 'assistant', and 'feedmob' are existing system keywords and do not require ID queries.",
290 |   parameters: z.object({
291 |     keys: z.array(z.string()).optional().describe("List of keywords to get client, partner, campaign ID"),
292 |   }),
293 |   execute: async (args, { log }) => {
294 |     try {
295 |       const q = args.keys?.join(',') || '';
296 |       const apiUrl = `${FEMINI_API_URL}/api/unstable/entities/search?q=${q}`;
297 |       
298 |       const response = await fetch(apiUrl, {
299 |         method: 'GET',
300 |         headers: {
301 |           'Accept': 'application/json',
302 |           'Content-Type': 'application/json',
303 |           'Authorization': "Bearer " + FEMINI_API_TOKEN
304 |         },
305 |       });
306 | 
307 |       if (!response.ok) {
308 |         throw new Error(`API request failed: ${response.status} ${response.statusText}`);
309 |       }
310 | 
311 |       return await response.text();
312 |     } catch (error: unknown) {
313 |       log.error("Failed to get IDs", { error: (error as Error).message });
314 |       throw new Error(`Failed to get IDs: ${(error as Error).message}`);
315 |     }
316 |   },
317 | });
318 | 
319 | // Add CampaignSpendsApiQuery User Manual resource
320 | server.addResource({
321 |   uri: "campaign-spends-api-guide://usage",
322 |   name: "CampaignSpendsApiQuery User Manual",
323 |   mimeType: "text/markdown",
324 |   async load() {
325 |     const guideUrl = `${FEMINI_API_URL}/mcp/campaign-spends-api-guide.en.md`;
326 |     const response = await fetch(guideUrl, {
327 |       method: 'GET',
328 |       headers: {
329 |         'Accept': 'application/json',
330 |         'Content-Type': 'application/json',
331 |         'Authorization': "Bearer " + FEMINI_API_TOKEN
332 |       },
333 |     });
334 |    
335 |     try {
336 |       const response = await fetch(guideUrl);
337 |       if (!response.ok) {
338 |         throw new Error(`Failed to fetch guide: ${response.status} ${response.statusText}`);
339 |       }
340 |       const text = await response.text();
341 |       return {
342 |         text: text,
343 |       };
344 |     } catch (error) {
345 |       console.error("Error loading resource from URL:", error);
346 |       throw new Error(`Failed to load resource from URL: ${(error as Error).message}`);
347 |     }
348 |   },
349 | });
350 | 
351 | server.start({
352 |   transportType: "stdio"
353 | });
354 | 
```

--------------------------------------------------------------------------------
/src/liftoff-reporting/src/index.ts:
--------------------------------------------------------------------------------

```typescript
  1 | #!/usr/bin/env node
  2 | 
  3 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
  4 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
  5 | import { z, ZodSchema } from "zod";
  6 | import axios, { AxiosError } from "axios";
  7 | import dotenv from "dotenv";
  8 | import * as process from 'process';
  9 | 
 10 | dotenv.config(); // Load environment variables from .env file
 11 | 
 12 | // Liftoff API Configuration
 13 | const LIFTOFF_API_BASE = "https://data.liftoff.io/api/v1";
 14 | const LIFTOFF_API_KEY = process.env.LIFTOFF_API_KEY;
 15 | const LIFTOFF_API_SECRET = process.env.LIFTOFF_API_SECRET;
 16 | 
 17 | if (!LIFTOFF_API_KEY || !LIFTOFF_API_SECRET) {
 18 |   console.error("Error: LIFTOFF_API_KEY or LIFTOFF_API_SECRET environment variable is not set.");
 19 |   process.exit(1);
 20 | }
 21 | 
 22 | // Create server instance
 23 | const server = new McpServer({
 24 |   name: "liftoff-reporting",
 25 |   version: "0.0.3", // Updated version
 26 |   capabilities: {
 27 |     tools: {}, // Only tools capability needed for now
 28 |   },
 29 | });
 30 | 
 31 | // --- Helper Function for Liftoff API Calls ---
 32 | interface LiftoffErrorResponse {
 33 |   error_type?: string;
 34 |   message?: string;
 35 |   errors?: string[];
 36 | }
 37 | 
 38 | async function makeLiftoffApiRequest(
 39 |   method: 'get' | 'post',
 40 |   endpoint: string,
 41 |   params?: Record<string, any>,
 42 |   data?: Record<string, any>
 43 | ): Promise<any> {
 44 |   const url = `${LIFTOFF_API_BASE}${endpoint}`;
 45 |   const auth = {
 46 |     username: LIFTOFF_API_KEY!,
 47 |     password: LIFTOFF_API_SECRET!,
 48 |   };
 49 |   const headers = {
 50 |     'Content-Type': 'application/json',
 51 |     'Accept': 'application/json',
 52 |   };
 53 | 
 54 |   try {
 55 |     const response = await axios({
 56 |       method: method,
 57 |       url: url,
 58 |       auth: auth,
 59 |       headers: headers,
 60 |       params: params, // GET request parameters
 61 |       data: data,     // POST request body
 62 |       timeout: 60000, // 60 second timeout for potentially long reports
 63 |     });
 64 |     // For data download, response might not be JSON if format=csv
 65 |     if (endpoint.endsWith('/data') && response.headers['content-type']?.includes('text/csv')) {
 66 |         return response.data; // Return raw CSV string
 67 |     }
 68 |     return response.data;
 69 |   } catch (error: unknown) {
 70 |     console.error(`Error making Liftoff API request to ${method.toUpperCase()} ${url}:`, error);
 71 |     if (axios.isAxiosError(error)) {
 72 |       const axiosError = error as AxiosError<LiftoffErrorResponse>;
 73 |       console.error("Axios error details:", {
 74 |         message: axiosError.message,
 75 |         code: axiosError.code,
 76 |         status: axiosError.response?.status,
 77 |         data: axiosError.response?.data,
 78 |       });
 79 |       const errorData = axiosError.response?.data;
 80 |       const errorType = errorData?.error_type || 'Unknown Error';
 81 |       const errorMessage = errorData?.message || axiosError.message;
 82 |       const errorDetails = errorData?.errors?.join(', ') || 'No details provided.';
 83 |       throw new Error(`Liftoff API Error (${axiosError.response?.status} ${errorType}): ${errorMessage} Details: ${errorDetails}`);
 84 |     }
 85 |     throw new Error(`Failed to call Liftoff API: ${error}`);
 86 |   }
 87 | }
 88 | 
 89 | // --- Tool Definitions ---
 90 | 
 91 | const reportGroupBySchema = z.array(z.string()).optional().default(["apps", "campaigns", "country"]).describe(
 92 |   "Group metrics by one of the available presets. e.g., [\"apps\", \"campaigns\"], [\"apps\", \"campaigns\", \"country\"]"
 93 | );
 94 | 
 95 | const reportFormatSchema = z.enum(["csv", "json"]).optional().default("csv").describe("Format of the report data");
 96 | 
 97 | const createReportInputSchema = z.object({
 98 |     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."),
 99 |     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."),
100 |     group_by: reportGroupBySchema,
101 |     app_ids: z.array(z.string()).optional().describe("Optional. Filter by specific app IDs."),
102 |     campaign_ids: z.array(z.string()).optional().describe("Optional. Filter by specific campaign IDs."),
103 |     event_ids: z.array(z.string()).optional().describe("Optional. Filter by specific event IDs."),
104 |     cohort_window: z.number().int().min(1).max(90).optional().describe("Optional. Number of days since install (1-90)."),
105 |     format: reportFormatSchema,
106 |     callback_url: z.string().url().optional().describe("Optional. URL to receive POST when report is done."),
107 |     timezone: z.string().optional().default("UTC").describe("Optional. TZ database name (e.g., 'America/Los_Angeles')."),
108 |     include_repeat_events: z.boolean().optional().default(true),
109 |     remove_zero_rows: z.boolean().optional().default(false),
110 |     use_two_letter_country: z.boolean().optional().default(false),
111 | });
112 | 
113 | // 1. Create Report Tool
114 | server.tool(
115 |   "create_liftoff_report",
116 |   "Generate a report via the Liftoff Reporting API.",
117 |   createReportInputSchema.shape,
118 |   async (reportParams: z.infer<typeof createReportInputSchema>) => {
119 |     try {
120 |       const response = await makeLiftoffApiRequest('post', '/reports', undefined, reportParams);
121 |       return {
122 |         content: [{
123 |           type: "text",
124 |           text: `Report creation initiated successfully. Report ID: ${response.id}, State: ${response.state}`,
125 |         }],
126 |       };
127 |     } catch (error: unknown) {
128 |       const errorMessage = error instanceof Error ? error.message : "An unknown error occurred creating the report.";
129 |       console.error("Error in create_liftoff_report tool:", errorMessage);
130 |       return {
131 |         content: [{ type: "text", text: `Error creating report: ${errorMessage}` }],
132 |         isError: true,
133 |       };
134 |     }
135 |   }
136 | );
137 | 
138 | // 2. Check Report Status Tool
139 | const checkStatusInputSchema = z.object({
140 |     report_id: z.string().describe("The ID of the report to check."),
141 | });
142 | 
143 | server.tool(
144 |   "check_liftoff_report_status",
145 |   "Get the status of a previously created Liftoff report once every minute until it is completed.",
146 |   checkStatusInputSchema.shape,
147 |   async ({ report_id }: z.infer<typeof checkStatusInputSchema>) => {
148 |     try {
149 |       const response = await makeLiftoffApiRequest('get', `/reports/${report_id}/status`);
150 |       return {
151 |         content: [{
152 |           type: "text",
153 |           text: `Report Status for ID ${report_id}: ${response.state}. Created at: ${response.created_at}. Parameters: ${JSON.stringify(response.parameters)}`,
154 |         }],
155 |       };
156 |     } catch (error: unknown) {
157 |       const errorMessage = error instanceof Error ? error.message : "An unknown error occurred checking report status.";
158 |       console.error("Error in check_liftoff_report_status tool:", errorMessage);
159 |       return {
160 |         content: [{ type: "text", text: `Error checking report status: ${errorMessage}` }],
161 |         isError: true,
162 |       };
163 |     }
164 |   }
165 | );
166 | 
167 | // 3. Download Report Data Tool
168 | const downloadDataInputSchema = z.object({
169 |     report_id: z.string().describe("The ID of the completed report to download."),
170 |     // Format is determined at creation time, not download time according to docs.
171 |     // If download format override was possible, add 'format' here.
172 | });
173 | 
174 | server.tool(
175 |   "download_liftoff_report_data",
176 |   "Download the data for a completed Liftoff report.",
177 |   downloadDataInputSchema.shape,
178 |   async ({ report_id }: z.infer<typeof downloadDataInputSchema>) => {
179 |     try {
180 |       // Note: The API might return CSV text or JSON based on creation 'format'.
181 |       // This tool returns the raw response text. The LLM might need to parse it.
182 |       const reportData = await makeLiftoffApiRequest('get', `/reports/${report_id}/data`);
183 |       let outputText = '';
184 |       if (typeof reportData === 'string') {
185 |         // Likely CSV data
186 |         outputText = `Report data (CSV) for ID ${report_id}:\n\`\`\`csv\n${reportData}\n\`\`\``;
187 |       } else if (typeof reportData === 'object') {
188 |          // Likely JSON data
189 |          outputText = `Report data (JSON) for ID ${report_id}:\n\`\`\`json\n${JSON.stringify(reportData, null, 2)}\n\`\`\``;
190 |       } else {
191 |         outputText = `Received unexpected data format for report ID ${report_id}.`;
192 |       }
193 | 
194 |       return {
195 |         content: [{ type: "text", text: outputText }],
196 |       };
197 |     } catch (error: unknown) {
198 |       const errorMessage = error instanceof Error ? error.message : "An unknown error occurred downloading report data.";
199 |       console.error("Error in download_liftoff_report_data tool:", errorMessage);
200 |       return {
201 |         content: [{ type: "text", text: `Error downloading report data: ${errorMessage}` }],
202 |         isError: true,
203 |       };
204 |     }
205 |   }
206 | );
207 | 
208 | // 4. List Apps Tool
209 | const listAppsInputSchema = z.object({}); // Keep the object for inference
210 | server.tool(
211 |   "list_liftoff_apps",
212 |   "Fetch app details from the Liftoff Reporting API.",
213 |   listAppsInputSchema.shape,
214 |   async () => {
215 |     try {
216 |       const apps = await makeLiftoffApiRequest('get', '/apps');
217 |       return {
218 |         content: [{
219 |           type: "text",
220 |           text: `Available Liftoff Apps:\n\`\`\`json\n${JSON.stringify(apps, null, 2)}\n\`\`\``,
221 |         }],
222 |       };
223 |     } catch (error: unknown) {
224 |       const errorMessage = error instanceof Error ? error.message : "An unknown error occurred listing apps.";
225 |       console.error("Error in list_liftoff_apps tool:", errorMessage);
226 |       return {
227 |         content: [{ type: "text", text: `Error listing apps: ${errorMessage}` }],
228 |         isError: true,
229 |       };
230 |     }
231 |   }
232 | );
233 | 
234 | // 5. List Campaigns Tool
235 | const listCampaignsInputSchema = z.object({}); // Keep the object for inference
236 | server.tool(
237 |   "list_liftoff_campaigns",
238 |   "Fetch campaign details from the Liftoff Reporting API.",
239 |   listCampaignsInputSchema.shape,
240 |   async () => {
241 |     try {
242 |       const campaigns = await makeLiftoffApiRequest('get', '/campaigns');
243 |       return {
244 |         content: [{
245 |           type: "text",
246 |           text: `Available Liftoff Campaigns:\n\`\`\`json\n${JSON.stringify(campaigns, null, 2)}\n\`\`\``,
247 |         }],
248 |       };
249 |     } catch (error: unknown) {
250 |       const errorMessage = error instanceof Error ? error.message : "An unknown error occurred listing campaigns.";
251 |       console.error("Error in list_liftoff_campaigns tool:", errorMessage);
252 |       return {
253 |         content: [{ type: "text", text: `Error listing campaigns: ${errorMessage}` }],
254 |         isError: true,
255 |       };
256 |     }
257 |   }
258 | );
259 | 
260 | // 6. Download Report Data with Campaign Names Tool
261 | server.tool(
262 |   "download_liftoff_report_with_names",
263 |   "Download the data for a completed Liftoff report with campaign names.",
264 |   downloadDataInputSchema.shape,
265 |   async ({ report_id }: z.infer<typeof downloadDataInputSchema>) => {
266 |     try {
267 |       // Get report data
268 |       const reportData = await makeLiftoffApiRequest('get', `/reports/${report_id}/data`);
269 | 
270 |       // Get campaign information
271 |       const campaignsResponse = await makeLiftoffApiRequest('get', '/campaigns');
272 | 
273 |       if (!Array.isArray(campaignsResponse)) {
274 |         throw new Error("Failed to retrieve campaign information");
275 |       }
276 | 
277 |       // Create map of campaign IDs to names
278 |       const campaignMap = new Map();
279 |       campaignsResponse.forEach((campaign: any) => {
280 |         campaignMap.set(campaign.id, campaign.name);
281 |       });
282 | 
283 |       // Process the report data to include campaign names
284 |       let outputData;
285 | 
286 |       if (typeof reportData === 'string') {
287 |         // If CSV format, need to parse and modify
288 |         const rows = reportData.split('\n');
289 |         const headers = rows[0].split(',');
290 | 
291 |         // Add campaign_name header
292 |         const campaignIdIndex = headers.indexOf('campaign_id');
293 |         if (campaignIdIndex > -1) {
294 |           headers.push('campaign_name');
295 |           rows[0] = headers.join(',');
296 | 
297 |           // Add campaign name to each data row
298 |           for (let i = 1; i < rows.length; i++) {
299 |             if (rows[i].trim()) {
300 |               const values = rows[i].split(',');
301 |               const campaignId = values[campaignIdIndex];
302 |               const campaignName = campaignMap.get(campaignId) || 'Unknown Campaign';
303 |               values.push(`"${campaignName}"`);
304 |               rows[i] = values.join(',');
305 |             }
306 |           }
307 |         }
308 |         outputData = rows.join('\n');
309 |         return {
310 |           content: [{ type: "text", text: `Report data (CSV) with campaign names for ID ${report_id}:\n\`\`\`csv\n${outputData}\n\`\`\`` }],
311 |         };
312 |       } else if (typeof reportData === 'object') {
313 |         // If JSON format
314 |         if (reportData.columns && reportData.rows && Array.isArray(reportData.rows)) {
315 |           // Add campaign_name to columns
316 |           const campaignIdIndex = reportData.columns.indexOf('campaign_id');
317 |           if (campaignIdIndex > -1) {
318 |             reportData.columns.push('campaign_name');
319 | 
320 |             // Add campaign name to each row
321 |             reportData.rows.forEach((row: any[]) => {
322 |               const campaignId = row[campaignIdIndex];
323 |               const campaignName = campaignMap.get(campaignId) || 'Unknown Campaign';
324 |               row.push(campaignName);
325 |             });
326 |           }
327 |         }
328 |         return {
329 |           content: [{ type: "text", text: `Report data (JSON) with campaign names for ID ${report_id}:\n\`\`\`json\n${JSON.stringify(reportData, null, 2)}\n\`\`\`` }],
330 |         };
331 |       } else {
332 |         return {
333 |           content: [{ type: "text", text: `Received unexpected data format for report ID ${report_id}.` }],
334 |         };
335 |       }
336 |     } catch (error: unknown) {
337 |       const errorMessage = error instanceof Error ? error.message : "An unknown error occurred downloading report data with campaign names.";
338 |       console.error("Error in download_liftoff_report_with_names tool:", errorMessage);
339 |       return {
340 |         content: [{ type: "text", text: `Error downloading report data with campaign names: ${errorMessage}` }],
341 |         isError: true,
342 |       };
343 |     }
344 |   }
345 | );
346 | 
347 | // --- Run the Server ---
348 | async function main() {
349 |   const transport = new StdioServerTransport();
350 |   try {
351 |     await server.connect(transport);
352 |     console.error("Liftoff Reporting MCP Server running on stdio..."); // Updated message
353 |   } catch (error) {
354 |     console.error("Failed to start Liftoff Reporting MCP Server:", error); // Updated message
355 |     process.exit(1);
356 |   }
357 | }
358 | 
359 | main();
360 | 
```

--------------------------------------------------------------------------------
/src/feedmob-reporting/src/api.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import axios from "axios";
  2 | import jwt from 'jsonwebtoken';
  3 | import dotenv from "dotenv";
  4 | 
  5 | dotenv.config(); // Load environment variables from .env file
  6 | 
  7 | const FEEDMOB_API_BASE = process.env.FEEDMOB_API_BASE;
  8 | const FEEDMOB_KEY = process.env.FEEDMOB_KEY;
  9 | const FEEDMOB_SECRET = process.env.FEEDMOB_SECRET;
 10 | 
 11 | if (!FEEDMOB_KEY || !FEEDMOB_SECRET) {
 12 |   console.error("Error: FEEDMOB_KEY and FEEDMOB_SECRET environment variables must be set.");
 13 |   process.exit(1);
 14 | }
 15 | 
 16 | // Generate JWT token
 17 | function generateToken(key: string, secret: string): string {
 18 |   const expirationDate = new Date();
 19 |   expirationDate.setDate(expirationDate.getDate() + 7); // 7 days from now
 20 | 
 21 |   const payload = {
 22 |     key: key,
 23 |     expired_at: expirationDate.toISOString().split('T')[0] // Format as YYYY-MM-DD
 24 |   };
 25 | 
 26 |   return jwt.sign(payload, secret, { algorithm: 'HS256' });
 27 | }
 28 | 
 29 | // Helper Function for API Call
 30 | export async function fetchDirectSpendsData(
 31 |   start_date: string,
 32 |   end_date: string,
 33 |   click_url_ids: string[]
 34 | ): Promise<any> {
 35 |   const urlObj = new URL(`${FEEDMOB_API_BASE}/ai/api/direct_spends`);
 36 | 
 37 |   // Add query parameters
 38 |   urlObj.searchParams.append('start_date', start_date);
 39 |   urlObj.searchParams.append('end_date', end_date);
 40 |   click_url_ids.forEach(id => {
 41 |     urlObj.searchParams.append('click_url_ids[]', id);
 42 |   });
 43 | 
 44 |   const url = urlObj.toString();
 45 | 
 46 |   try {
 47 |     const token = generateToken(FEEDMOB_KEY as string, FEEDMOB_SECRET as string);
 48 |     const response = await axios.get(url, {
 49 |       headers: {
 50 |         'Content-Type': 'application/json',
 51 |         'Accept': 'application/json',
 52 |         'FEEDMOB-KEY': FEEDMOB_KEY,
 53 |         'FEEDMOB-TOKEN': token
 54 |       },
 55 |       timeout: 30000,
 56 |     });
 57 |     return response.data;
 58 |   } catch (error: unknown) {
 59 |     console.error("Error fetching direct spends data from FeedMob API:", error);
 60 |     if (error && typeof error === 'object' && 'response' in error) {
 61 |       const err = error as Record<string, any>;
 62 |       const status = err.response?.status;
 63 |       if (status === 401) {
 64 |         throw new Error('FeedMob API request failed: Unauthorized (Invalid API Key or Token)');
 65 |       } else if (status === 400) {
 66 |         throw new Error('FeedMob API request failed: Bad Request');
 67 |       } else if (status === 404) {
 68 |         throw new Error('FeedMob API request failed: Not Found');
 69 |       } else {
 70 |         throw new Error(`FeedMob API request failed: ${status || 'Unknown error'}`);
 71 |       }
 72 |     }
 73 |     throw new Error('Failed to fetch direct spends data from FeedMob API');
 74 |   }
 75 | }
 76 | 
 77 | export async function getAgencyConversionMetrics(
 78 |   click_url_ids: number[],
 79 |   date?: string
 80 | ): Promise<any> {
 81 |   const urlObj = new URL(`${FEEDMOB_API_BASE}/ai/api/agency_conversion_metrics`);
 82 |   click_url_ids.forEach((id) => urlObj.searchParams.append('click_url_ids[]', String(id)));
 83 |   if (date) {
 84 |     urlObj.searchParams.append('date', date);
 85 |   }
 86 | 
 87 |   const url = urlObj.toString();
 88 | 
 89 |   try {
 90 |     const token = generateToken(FEEDMOB_KEY as string, FEEDMOB_SECRET as string);
 91 |     const response = await axios.get(url, {
 92 |       headers: {
 93 |         'Content-Type': 'application/json',
 94 |         'Accept': 'application/json',
 95 |         'FEEDMOB-KEY': FEEDMOB_KEY,
 96 |         'FEEDMOB-TOKEN': token,
 97 |       },
 98 |       timeout: 30000,
 99 |     });
100 |     return response.data;
101 |   } catch (error: unknown) {
102 |     console.error('Error fetching agency conversion metrics:', error);
103 |     if (error && typeof error === 'object' && 'response' in error) {
104 |       const err = error as Record<string, any>;
105 |       const status = err.response?.status;
106 |       if (status === 401) {
107 |         throw new Error('FeedMob API request failed: Unauthorized (Invalid API Key or Token)');
108 |       } else if (status === 400) {
109 |         throw new Error('FeedMob API request failed: Bad Request');
110 |       } else if (status === 404) {
111 |         throw new Error('FeedMob API request failed: Not Found');
112 |       } else {
113 |         throw new Error(`FeedMob API request failed: ${status || 'Unknown error'}`);
114 |       }
115 |     }
116 |     throw new Error('Failed to fetch agency conversion metrics');
117 |   }
118 | }
119 | 
120 | export async function getClickUrlHistories(
121 |   click_url_ids: number[],
122 |   date?: string
123 | ): Promise<any> {
124 |   const urlObj = new URL(`${FEEDMOB_API_BASE}/ai/api/click_url_histories`);
125 |   click_url_ids.forEach((id) => urlObj.searchParams.append('click_url_ids[]', String(id)));
126 |   if (date) {
127 |     urlObj.searchParams.append('date', date);
128 |   }
129 | 
130 |   const url = urlObj.toString();
131 | 
132 |   try {
133 |     const token = generateToken(FEEDMOB_KEY as string, FEEDMOB_SECRET as string);
134 |     const response = await axios.get(url, {
135 |       headers: {
136 |         'Content-Type': 'application/json',
137 |         'Accept': 'application/json',
138 |         'FEEDMOB-KEY': FEEDMOB_KEY,
139 |         'FEEDMOB-TOKEN': token,
140 |       },
141 |       timeout: 30000,
142 |     });
143 |     return response.data;
144 |   } catch (error: unknown) {
145 |     console.error('Error fetching click URL histories:', error);
146 |     if (error && typeof error === 'object' && 'response' in error) {
147 |       const err = error as Record<string, any>;
148 |       const status = err.response?.status;
149 |       if (status === 401) {
150 |         throw new Error('FeedMob API request failed: Unauthorized (Invalid API Key or Token)');
151 |       } else if (status === 400) {
152 |         throw new Error('FeedMob API request failed: Bad Request');
153 |       } else if (status === 404) {
154 |         throw new Error('FeedMob API request failed: Not Found');
155 |       } else {
156 |         throw new Error(`FeedMob API request failed: ${status || 'Unknown error'}`);
157 |       }
158 |     }
159 |     throw new Error('Failed to fetch click URL histories');
160 |   }
161 | }
162 | 
163 | export async function getInmobiReportIds(
164 |   start_date: string,
165 |   end_date: string
166 | ): Promise<any> {
167 |   const urlObj = new URL(`${FEEDMOB_API_BASE}/ai/api/inmobi_api_reports/get_inmobi_report_ids`);
168 |   urlObj.searchParams.append('start_date', start_date);
169 |   urlObj.searchParams.append('end_date', end_date);
170 | 
171 |   const url = urlObj.toString();
172 | 
173 |   try {
174 |     const token = generateToken(FEEDMOB_KEY as string, FEEDMOB_SECRET as string);
175 |     const response = await axios.get(url, {
176 |       headers: {
177 |         'Content-Type': 'application/json',
178 |         'Accept': 'application/json',
179 |         'FEEDMOB-KEY': FEEDMOB_KEY,
180 |         'FEEDMOB-TOKEN': token
181 |       },
182 |       timeout: 30000,
183 |     });
184 |     return response.data;
185 |   } catch (error: unknown) {
186 |     console.error("Error fetching Inmobi report IDs:", error);
187 |     if (error && typeof error === 'object' && 'response' in error) {
188 |       const err = error as Record<string, any>;
189 |       const status = err.response?.status;
190 |       if (status === 401) {
191 |         throw new Error('FeedMob API request failed: Unauthorized (Invalid API Key or Token)');
192 |       } else if (status === 400) {
193 |         throw new Error('FeedMob API request failed: Bad Request');
194 |       } else if (status === 404) {
195 |         throw new Error('FeedMob API request failed: Not Found');
196 |       } else {
197 |         throw new Error(`FeedMob API request failed: ${status || 'Unknown error'}`);
198 |       }
199 |     }
200 |     throw new Error('Failed to fetch Inmobi report IDs');
201 |   }
202 | }
203 | 
204 | export async function checkInmobiReportStatus(
205 |   start_date: string,
206 |   end_date: string,
207 |   report_id: string
208 | ): Promise<any> {
209 |   const urlObj = new URL(`${FEEDMOB_API_BASE}/ai/api/inmobi_api_reports/check_inmobi_report_id_status`);
210 |   urlObj.searchParams.append('start_date', start_date);
211 |   urlObj.searchParams.append('end_date', end_date);
212 |   urlObj.searchParams.append('report_id', report_id);
213 | 
214 |   const url = urlObj.toString();
215 | 
216 |   try {
217 |     const token = generateToken(FEEDMOB_KEY as string, FEEDMOB_SECRET as string);
218 |     const response = await axios.get(url, {
219 |       headers: {
220 |         'Content-Type': 'application/json',
221 |         'Accept': 'application/json',
222 |         'FEEDMOB-KEY': FEEDMOB_KEY,
223 |         'FEEDMOB-TOKEN': token
224 |       },
225 |       timeout: 30000,
226 |     });
227 |     return response.data;
228 |   } catch (error: unknown) {
229 |     console.error("Error checking Inmobi report status:", error);
230 |     if (error && typeof error === 'object' && 'response' in error) {
231 |       const err = error as Record<string, any>;
232 |       const status = err.response?.status;
233 |       if (status === 401) {
234 |         throw new Error('FeedMob API request failed: Unauthorized (Invalid API Key or Token)');
235 |       } else if (status === 400) {
236 |         throw new Error('FeedMob API request failed: Bad Request');
237 |       } else if (status === 404) {
238 |         throw new Error('FeedMob API request failed: Not Found');
239 |       } else {
240 |         throw new Error(`FeedMob API request failed: ${status || 'Unknown error'}`);
241 |       }
242 |     }
243 |     throw new Error('Failed to check Inmobi report status');
244 |   }
245 | }
246 | 
247 | export async function createDirectSpend(
248 |   click_url_id: number,
249 |   spend_date: string,
250 |   net_spend?: number,
251 |   gross_spend?: number,
252 |   partner_paid_action_count?: number,
253 |   client_paid_action_count?: number
254 | ): Promise<any> {
255 |   // Validate at least one spend metric is provided
256 |   if (!net_spend && !gross_spend && !partner_paid_action_count && !client_paid_action_count) {
257 |     throw new Error('必须提供至少一个支出指标:net_spend, gross_spend, partner_paid_action_count 或 client_paid_action_count');
258 |   }
259 | 
260 |   const url = `${FEEDMOB_API_BASE}/ai/api/direct_spends`;
261 | 
262 |   try {
263 |     const token = generateToken(FEEDMOB_KEY as string, FEEDMOB_SECRET as string);
264 |     const response = await axios.post(url, {
265 |       click_url_id,
266 |       spend_date,
267 |       net_spend,
268 |       gross_spend,
269 |       partner_paid_action_count,
270 |       client_paid_action_count
271 |     }, {
272 |       headers: {
273 |         'Content-Type': 'application/json',
274 |         'Accept': 'application/json',
275 |         'FEEDMOB-KEY': FEEDMOB_KEY,
276 |         'FEEDMOB-TOKEN': token
277 |       },
278 |       timeout: 30000,
279 |     });
280 |     return response.data;
281 |   } catch (error: unknown) {
282 |     console.error("Error creating direct spend:", error);
283 |     if (error && typeof error === 'object' && 'response' in error) {
284 |       const err = error as Record<string, any>;
285 |       const status = err.response?.status;
286 |       if (status === 401) {
287 |         throw new Error('FeedMob API request failed: Unauthorized (Invalid API Key or Token)');
288 |       } else if (status === 400) {
289 |         throw new Error('FeedMob API request failed: Bad Request');
290 |       } else if (status === 404) {
291 |         throw new Error('FeedMob API request failed: Not Found');
292 |       } else {
293 |         throw new Error(`FeedMob API request failed: ${status || 'Unknown error'}`);
294 |       }
295 |     }
296 |     throw new Error('Failed to create direct spend');
297 |   }
298 | }
299 | 
300 | export async function getInmobiReports(
301 |   start_date: string,
302 |   end_date: string,
303 |   skan_report_id: string,
304 |   non_skan_report_id: string
305 | ): Promise<any> {
306 |   const urlObj = new URL(`${FEEDMOB_API_BASE}/ai/api/inmobi_api_reports`);
307 |   urlObj.searchParams.append('start_date', start_date);
308 |   urlObj.searchParams.append('end_date', end_date);
309 |   urlObj.searchParams.append('skan_report_id', skan_report_id);
310 |   urlObj.searchParams.append('non_skan_report_id', non_skan_report_id);
311 | 
312 |   const url = urlObj.toString();
313 | 
314 |   try {
315 |     const token = generateToken(FEEDMOB_KEY as string, FEEDMOB_SECRET as string);
316 |     const response = await axios.get(url, {
317 |       headers: {
318 |         'Content-Type': 'application/json',
319 |         'Accept': 'application/json',
320 |         'FEEDMOB-KEY': FEEDMOB_KEY,
321 |         'FEEDMOB-TOKEN': token
322 |       },
323 |       timeout: 30000,
324 |     });
325 |     return response.data;
326 |   } catch (error: unknown) {
327 |     console.error("Error fetching Inmobi reports:", error);
328 |     if (error && typeof error === 'object' && 'response' in error) {
329 |       const err = error as Record<string, any>;
330 |       const status = err.response?.status;
331 |       if (status === 401) {
332 |         throw new Error('FeedMob API request failed: Unauthorized (Invalid API Key or Token)');
333 |       } else if (status === 400) {
334 |         throw new Error('FeedMob API request failed: Bad Request');
335 |       } else if (status === 404) {
336 |         throw new Error('FeedMob API request failed: Not Found');
337 |       } else {
338 |         throw new Error(`FeedMob API request failed: ${status || 'Unknown error'}`);
339 |       }
340 |     }
341 |     throw new Error('Failed to fetch Inmobi reports');
342 |   }
343 | }
344 | 
345 | export async function getAppsflyerReports(
346 |   start_date: string,
347 |   end_date: string,
348 |   click_url_ids?: string[],
349 |   af_app_ids?: string[]
350 | ): Promise<any> {
351 |   const urlObj = new URL(`${FEEDMOB_API_BASE}/ai/api/appsflyer_reports`);
352 | 
353 |   // Add required parameters
354 |   urlObj.searchParams.append('start_date', start_date);
355 |   urlObj.searchParams.append('end_date', end_date);
356 | 
357 |   // Add optional parameters
358 |   if (click_url_ids && click_url_ids.length > 0) {
359 |     click_url_ids.forEach(id => {
360 |       urlObj.searchParams.append('click_url_ids[]', id);
361 |     });
362 |   }
363 | 
364 |   if (af_app_ids && af_app_ids.length > 0) {
365 |     af_app_ids.forEach(id => {
366 |       urlObj.searchParams.append('af_app_ids[]', id);
367 |     });
368 |   }
369 | 
370 |   const url = urlObj.toString();
371 | 
372 |   try {
373 |     const token = generateToken(FEEDMOB_KEY as string, FEEDMOB_SECRET as string);
374 |     const response = await axios.get(url, {
375 |       headers: {
376 |         'Content-Type': 'application/json',
377 |         'Accept': 'application/json',
378 |         'FEEDMOB-KEY': FEEDMOB_KEY,
379 |         'FEEDMOB-TOKEN': token
380 |       },
381 |       timeout: 30000,
382 |     });
383 |     return response.data;
384 |   } catch (error: unknown) {
385 |     console.error("Error fetching AppsFlyer reports:", error);
386 |     if (error && typeof error === 'object' && 'response' in error) {
387 |       const err = error as Record<string, any>;
388 |       const status = err.response?.status;
389 |       if (status === 401) {
390 |         throw new Error('FeedMob API request failed: Unauthorized (Invalid API Key or Token)');
391 |       } else if (status === 400) {
392 |         throw new Error('FeedMob API request failed: Bad Request');
393 |       } else if (status === 404) {
394 |         throw new Error('FeedMob API request failed: Not Found');
395 |       } else {
396 |         throw new Error(`FeedMob API request failed: ${status || 'Unknown error'}`);
397 |       }
398 |     }
399 |     throw new Error('Failed to fetch AppsFlyer reports');
400 |   }
401 | }
402 | 
403 | export async function getAdopsReports(
404 |   month: string
405 | ): Promise<any> {
406 |   const urlObj = new URL(`${FEEDMOB_API_BASE}/ai/api/adops_reports`);
407 | 
408 |   // Add month parameter
409 |   urlObj.searchParams.append('month', month);
410 | 
411 |   const url = urlObj.toString();
412 | 
413 |   try {
414 |     const token = generateToken(FEEDMOB_KEY as string, FEEDMOB_SECRET as string);
415 |     const response = await axios.get(url, {
416 |       headers: {
417 |         'Content-Type': 'application/json',
418 |         'Accept': 'application/json',
419 |         'FEEDMOB-KEY': FEEDMOB_KEY,
420 |         'FEEDMOB-TOKEN': token
421 |       },
422 |       timeout: 30000,
423 |     });
424 |     return response.data;
425 |   } catch (error: unknown) {
426 |     console.error("Error fetching AdOps reports:", error);
427 |     if (error && typeof error === 'object' && 'response' in error) {
428 |       const err = error as Record<string, any>;
429 |       const status = err.response?.status;
430 |       if (status === 401) {
431 |         throw new Error('FeedMob API request failed: Unauthorized (Invalid API Key or Token)');
432 |       } else if (status === 400) {
433 |         throw new Error('FeedMob API request failed: Bad Request');
434 |       } else if (status === 404) {
435 |         throw new Error('FeedMob API request failed: Not Found');
436 |       } else {
437 |         throw new Error(`FeedMob API request failed: ${status || 'Unknown error'}`);
438 |       }
439 |     }
440 |     throw new Error('Failed to fetch AdOps reports');
441 |   }
442 | }
443 | 
444 | export async function getPossibleFinanceSingularReports(
445 |   start_date: string,
446 |   end_date: string
447 | ): Promise<any> {
448 |   const urlObj = new URL(`${FEEDMOB_API_BASE}/ai/api/possible_finance_singular_api_reports`);
449 | 
450 |   // Add required parameters
451 |   urlObj.searchParams.append('start_date', start_date);
452 |   urlObj.searchParams.append('end_date', end_date);
453 | 
454 |   const url = urlObj.toString();
455 | 
456 |   try {
457 |     const token = generateToken(FEEDMOB_KEY as string, FEEDMOB_SECRET as string);
458 |     const response = await axios.get(url, {
459 |       headers: {
460 |         'Content-Type': 'application/json',
461 |         'Accept': 'application/json',
462 |         'FEEDMOB-KEY': FEEDMOB_KEY,
463 |         'FEEDMOB-TOKEN': token
464 |       },
465 |       timeout: 30000,
466 |     });
467 |     return response.data;
468 |   } catch (error: unknown) {
469 |     console.error("Error fetching Possible Finance Singular reports:", error);
470 |     if (error && typeof error === 'object' && 'response' in error) {
471 |       const err = error as Record<string, any>;
472 |       const status = err.response?.status;
473 |       if (status === 401) {
474 |         throw new Error('FeedMob API request failed: Unauthorized (Invalid API Key or Token)');
475 |       } else if (status === 400) {
476 |         throw new Error('FeedMob API request failed: Bad Request');
477 |       } else if (status === 404) {
478 |         throw new Error('FeedMob API request failed: Not Found');
479 |       } else {
480 |         throw new Error(`FeedMob API request failed: ${status || 'Unknown error'}`);
481 |       }
482 |     }
483 |     throw new Error('Failed to fetch Possible Finance Singular reports');
484 |   }
485 | }
486 | 
```

--------------------------------------------------------------------------------
/src/samsung-reporting/src/index.ts:
--------------------------------------------------------------------------------

```typescript
  1 | #!/usr/bin/env node
  2 | 
  3 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
  4 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
  5 | import { z } from "zod";
  6 | import fetch from "node-fetch";
  7 | import dotenv from "dotenv";
  8 | import jwt from "jsonwebtoken";
  9 | 
 10 | dotenv.config();
 11 | 
 12 | const server = new McpServer({
 13 |   name: "Samsung Reporting MCP Server",
 14 |   version: "0.1.2"
 15 | });
 16 | 
 17 | // Configuration constants
 18 | const SAMSUNG_BASE_URL = process.env.SAMSUNG_BASE_URL || 'https://devapi.samsungapps.com';
 19 | const SAMSUNG_ISS = process.env.SAMSUNG_ISS || '';
 20 | const SAMSUNG_PRIVATE_KEY = process.env.SAMSUNG_PRIVATE_KEY || '';
 21 | const SAMSUNG_SCOPES = ['publishing', 'gss'] as const;
 22 | const JWT_EXPIRY_MINUTES = 20;
 23 | const TOKEN_BUFFER_MINUTES = 2; // Refresh token 2 minutes before expiry
 24 | 
 25 | // Type definitions
 26 | interface ContentApp {
 27 |   app: string;
 28 |   contentIds: string[];
 29 | }
 30 | 
 31 | interface MetricResult {
 32 |   contentIds: string[];
 33 |   metrics?: any[];
 34 |   error?: string;
 35 | }
 36 | 
 37 | interface TokenInfo {
 38 |   token: string;
 39 |   expiresAt: number;
 40 | }
 41 | 
 42 | // Default metric IDs
 43 | const DEFAULT_METRIC_IDS = [
 44 |   'total_unique_installs_filter',
 45 |   'dn_by_total_dvce',
 46 |   'revenue_total',
 47 |   'revenue_iap_order_count',
 48 |   'daily_rat_score',
 49 |   'daily_rat_volumne'
 50 | ] as const;
 51 | 
 52 | const SAMSUNG_CONTENT_IDS: ContentApp[] = [
 53 |   { app: 'Lyft', contentIds: ['000007874233'] },
 54 |   { app: 'Self Financial', contentIds: ['000008094857'] },
 55 |   { app: 'Chime', contentIds: ['000008223186'] },
 56 |   { app: 'ZipRecruiter', contentIds: ['000008182313'] },
 57 |   { app: 'Upside', contentIds: ['000007104981', '000008222297'] },
 58 | ];
 59 | 
 60 | /**
 61 |  * Custom error classes
 62 |  */
 63 | class CapturedError extends Error {
 64 |   constructor(message: string) {
 65 |     super(message);
 66 |     this.name = 'CapturedError';
 67 |   }
 68 | }
 69 | 
 70 | class SamsungApiError extends CapturedError {
 71 |   constructor(message: string) {
 72 |     super(message);
 73 |     this.name = 'SamsungApiError';
 74 |   }
 75 | }
 76 | 
 77 | class ConfigurationError extends CapturedError {
 78 |   constructor(message: string) {
 79 |     super(message);
 80 |     this.name = 'ConfigurationError';
 81 |   }
 82 | }
 83 | 
 84 | /**
 85 |  * Utility functions
 86 |  */
 87 | function validateConfiguration(): void {
 88 |   if (!SAMSUNG_ISS) {
 89 |     throw new ConfigurationError('SAMSUNG_ISS environment variable is required');
 90 |   }
 91 |   if (!SAMSUNG_PRIVATE_KEY) {
 92 |     throw new ConfigurationError('SAMSUNG_PRIVATE_KEY environment variable is required');
 93 |   }
 94 | }
 95 | 
 96 | function isValidDateFormat(date: string): boolean {
 97 |   return /^\d{4}-\d{2}-\d{2}$/.test(date);
 98 | }
 99 | 
100 | function isValidDateRange(startDate: string, endDate: string): boolean {
101 |   return new Date(startDate) <= new Date(endDate);
102 | }
103 | 
104 | /**
105 |  * Get available app names for validation
106 |  */
107 | function getAvailableAppNames(): string[] {
108 |   return SAMSUNG_CONTENT_IDS.map(app => app.app);
109 | }
110 | 
111 | /**
112 |  * Find app by name (case-insensitive)
113 |  */
114 | function findAppByName(appName: string): ContentApp | undefined {
115 |   return SAMSUNG_CONTENT_IDS.find(app =>
116 |     app.app.toLowerCase() === appName.toLowerCase()
117 |   );
118 | }
119 | 
120 | /**
121 |  * Filter apps by name
122 |  */
123 | function filterAppsByName(appName?: string): ContentApp[] {
124 |   if (!appName) {
125 |     return SAMSUNG_CONTENT_IDS;
126 |   }
127 | 
128 |   const foundApp = findAppByName(appName);
129 |   if (!foundApp) {
130 |     throw new SamsungApiError(
131 |       `App "${appName}" not found. Available apps: ${getAvailableAppNames().join(', ')}`
132 |     );
133 |   }
134 | 
135 |   return [foundApp];
136 | }
137 | 
138 | /**
139 |  * Samsung API Service Class
140 |  */
141 | class SamsungApiService {
142 |   private readonly startDate: string;
143 |   private readonly endDate: string;
144 |   private tokenInfo: TokenInfo | null = null;
145 | 
146 |   constructor(startDate: string, endDate: string) {
147 |     validateConfiguration();
148 | 
149 |     if (!isValidDateFormat(startDate) || !isValidDateFormat(endDate)) {
150 |       throw new SamsungApiError('Invalid date format. Use YYYY-MM-DD format.');
151 |     }
152 | 
153 |     if (!isValidDateRange(startDate, endDate)) {
154 |       throw new SamsungApiError('Start date cannot be after end date.');
155 |     }
156 | 
157 |     this.startDate = startDate;
158 |     this.endDate = endDate;
159 |   }
160 | 
161 |   /**
162 |    * Generate JWT token for Samsung API authentication
163 |    */
164 |   private generateJwt(): string {
165 |     try {
166 |       const iat = Math.floor(Date.now() / 1000);
167 |       const exp = iat + (JWT_EXPIRY_MINUTES * 60);
168 | 
169 |       const payload = {
170 |         iss: SAMSUNG_ISS,
171 |         scopes: SAMSUNG_SCOPES,
172 |         exp,
173 |         iat
174 |       };
175 | 
176 |       return jwt.sign(payload, SAMSUNG_PRIVATE_KEY, { algorithm: 'RS256' });
177 |     } catch (error: any) {
178 |       console.error('Error generating JWT:', error);
179 |       throw new SamsungApiError(`Failed to generate JWT: ${error.message}`);
180 |     }
181 |   }
182 | 
183 |   /**
184 |    * Check if current token is valid and not expired
185 |    */
186 |   private isTokenValid(): boolean {
187 |     if (!this.tokenInfo) {
188 |       return false;
189 |     }
190 | 
191 |     const now = Date.now();
192 |     const bufferTime = TOKEN_BUFFER_MINUTES * 60 * 1000;
193 |     return now < (this.tokenInfo.expiresAt - bufferTime);
194 |   }
195 | 
196 |   /**
197 |    * Fetch access token using JWT with caching
198 |    */
199 |   private async fetchAccessToken(): Promise<void> {
200 |     if (this.isTokenValid()) {
201 |       return;
202 |     }
203 | 
204 |     try {
205 |       const jwtToken = this.generateJwt();
206 | 
207 |       const response = await fetch(`${SAMSUNG_BASE_URL}/auth/accessToken`, {
208 |         method: 'POST',
209 |         headers: {
210 |           'Content-Type': 'application/json',
211 |           'Authorization': `Bearer ${jwtToken}`
212 |         }
213 |       });
214 | 
215 |       if (!response.ok) {
216 |         const errorBody = await response.text();
217 |         throw new Error(`HTTP ${response.status}: ${errorBody}`);
218 |       }
219 | 
220 |       const data = await response.json() as any;
221 |       const accessToken = data.createdItem?.accessToken;
222 | 
223 |       if (!accessToken) {
224 |         throw new Error('Access token not found in response');
225 |       }
226 | 
227 |       // Cache token with expiry time
228 |       this.tokenInfo = {
229 |         token: accessToken,
230 |         expiresAt: Date.now() + (JWT_EXPIRY_MINUTES * 60 * 1000)
231 |       };
232 | 
233 |       console.error('Successfully obtained Samsung access token');
234 |     } catch (error: any) {
235 |       console.error('Error fetching access token:', error);
236 |       throw new SamsungApiError(`Failed to fetch access token: ${error.message}`);
237 |     }
238 |   }
239 | 
240 |   /**
241 |    * Sleep for specified milliseconds
242 |    */
243 |   private async sleep(ms: number): Promise<void> {
244 |     return new Promise(resolve => setTimeout(resolve, ms));
245 |   }
246 | 
247 |   /**
248 |    * Fetch content metrics for a given content ID with retry mechanism
249 |    */
250 |   async fetchContentMetric(
251 |     contentId: string,
252 |     metricIds: string[] = [...DEFAULT_METRIC_IDS],
253 |     maxRetries: number = 3,
254 |     baseDelay: number = 2000,
255 |     noBreakdown: boolean = true
256 |   ): Promise<any[]> {
257 |     let lastError: Error | null = null;
258 | 
259 |     for (let attempt = 1; attempt <= maxRetries; attempt++) {
260 |       try {
261 |         await this.fetchAccessToken();
262 | 
263 |         if (!this.tokenInfo) {
264 |           throw new Error('No valid access token available');
265 |         }
266 | 
267 |         const requestBody = {
268 |           contentId,
269 |           periods: [{
270 |             startDate: this.startDate,
271 |             endDate: this.endDate
272 |           }],
273 |           getDailyMetrics: false,
274 |           noContentMetadata: true,
275 |           noBreakdown: noBreakdown,
276 |           metricIds,
277 |           filters: {},
278 |           trendAggregation: 'day'
279 |         };
280 | 
281 |         console.error(`Fetching content metrics for content ID: ${contentId} (attempt ${attempt}/${maxRetries})`);
282 | 
283 |         // Add delay before making the request (except for first attempt)
284 |         if (attempt > 1) {
285 |           const delay = baseDelay * Math.pow(2, attempt - 2); // Exponential backoff
286 |           console.error(`Waiting ${delay}ms before retry attempt ${attempt}`);
287 |           await this.sleep(delay);
288 |         }
289 | 
290 |         const response = await fetch(`${SAMSUNG_BASE_URL}/gss/query/contentMetric`, {
291 |           method: 'POST',
292 |           headers: {
293 |             'Content-Type': 'application/json',
294 |             'Authorization': `Bearer ${this.tokenInfo.token}`,
295 |             'service-account-id': SAMSUNG_ISS
296 |           },
297 |           body: JSON.stringify(requestBody)
298 |         });
299 | 
300 |         if (!response.ok) {
301 |           const errorBody = await response.text();
302 |           const error = new Error(`HTTP ${response.status}: ${errorBody}`);
303 | 
304 |           // Check if it's a retryable error
305 |           if (response.status >= 500 || response.status === 429 || response.status === 408) {
306 |             lastError = error;
307 |             console.error(`Retryable error for ${contentId} (attempt ${attempt}/${maxRetries}):`, error.message);
308 | 
309 |             if (attempt === maxRetries) {
310 |               throw new SamsungApiError(`Failed to fetch content metric for ${contentId} after ${maxRetries} attempts: ${error.message}`);
311 |             }
312 |             continue; // Retry
313 |           } else {
314 |             // Non-retryable error (4xx except 429 and 408)
315 |             throw error;
316 |           }
317 |         }
318 | 
319 |         const data = await response.json() as any;
320 | 
321 |         // Add a small delay after successful request to be respectful to the API
322 |         await this.sleep(500);
323 | 
324 |         console.error(`Successfully fetched content metrics for ${contentId} on attempt ${attempt}`);
325 |         return data.data?.periods || [];
326 | 
327 |       } catch (error: any) {
328 |         lastError = error;
329 | 
330 |         // If it's not a network/server error, don't retry
331 |         if (error.name === 'TypeError' && error.message.includes('fetch')) {
332 |           // Network error - retry
333 |           console.error(`Network error for ${contentId} (attempt ${attempt}/${maxRetries}):`, error.message);
334 |         } else if (error.message.includes('HTTP 5') || error.message.includes('HTTP 429') || error.message.includes('HTTP 408')) {
335 |           // Server error or rate limit - retry
336 |           console.error(`Server error for ${contentId} (attempt ${attempt}/${maxRetries}):`, error.message);
337 |         } else {
338 |           // Other errors (like auth errors) - don't retry
339 |           console.error(`Non-retryable error for ${contentId}:`, error.message);
340 |           throw new SamsungApiError(`Failed to fetch content metric for ${contentId}: ${error.message}`);
341 |         }
342 | 
343 |         if (attempt === maxRetries) {
344 |           throw new SamsungApiError(`Failed to fetch content metric for ${contentId} after ${maxRetries} attempts: ${lastError?.message || 'Unknown error'}`);
345 |         }
346 |       }
347 |     }
348 | 
349 |     // This should never be reached, but just in case
350 |     throw new SamsungApiError(`Failed to fetch content metric for ${contentId} after ${maxRetries} attempts: ${lastError?.message || 'Unknown error'}`);
351 |   }
352 | 
353 |   /**
354 |    * Fetch content metrics for a given array of content IDs with retry mechanism, and aggregate results
355 |    */
356 |   async fetchContentMetricsForApp(
357 |     contentIds: string[],
358 |     metricIds: string[] = [...DEFAULT_METRIC_IDS],
359 |     maxRetries: number = 3,
360 |     baseDelay: number = 2000,
361 |     noBreakdown: boolean = true
362 |   ): Promise<any[]> {
363 |     const allResults: any[] = [];
364 |     for (const contentId of contentIds) {
365 |       try {
366 |         const result = await this.fetchContentMetric(contentId, metricIds, maxRetries, baseDelay, noBreakdown);
367 |         allResults.push({ contentId, metrics: result });
368 |       } catch (error: any) {
369 |         allResults.push({ contentId, error: error.message });
370 |       }
371 |     }
372 |     return allResults;
373 |   }
374 | 
375 |   /**
376 |    * Fetch content metrics for specified apps with parallel processing and retry mechanism
377 |    */
378 |   async fetchContentMetrics(
379 |     apps: ContentApp[],
380 |     metricIds: string[] = [...DEFAULT_METRIC_IDS],
381 |     maxRetries: number = 3,
382 |     baseDelay: number = 2000,
383 |     noBreakdown: boolean = true
384 |   ): Promise<Record<string, MetricResult>> {
385 |     try {
386 |       // Pre-fetch access token to avoid multiple token requests
387 |       await this.fetchAccessToken();
388 | 
389 |       // Process specified apps in parallel for better performance
390 |       const promises = apps.map(async ({ app, contentIds }): Promise<[string, MetricResult]> => {
391 |         try {
392 |           console.error(`Fetching metrics for ${app} (${contentIds.join(',')}) with retry mechanism`);
393 |           const metricsArr = await this.fetchContentMetricsForApp(contentIds, metricIds, maxRetries, baseDelay, noBreakdown);
394 |           return [app, { contentIds, metrics: metricsArr } as any];
395 |         } catch (error: any) {
396 |           console.error(`Error fetching metrics for ${app} after ${maxRetries} retries: ${error.message}`);
397 |           return [app, { contentIds, error: error.message } as any];
398 |         }
399 |       });
400 | 
401 |       const results = await Promise.all(promises);
402 |       return Object.fromEntries(results);
403 |     } catch (error: any) {
404 |       console.error('Error fetching content metrics:', error);
405 |       throw new SamsungApiError(`Failed to fetch content metrics: ${error.message}`);
406 |     }
407 |   }
408 | }
409 | 
410 | // Input validation schemas
411 | const dateSchema = z.string()
412 |   .regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in YYYY-MM-DD format")
413 |   .refine((date) => !isNaN(Date.parse(date)), "Invalid date");
414 | 
415 | const metricIdsSchema = z.array(z.string()).optional()
416 |   .describe("Optional array of metric IDs to fetch. Defaults to standard metrics if not provided.");
417 | 
418 | const appNameSchema = z.string().optional()
419 |   .describe(`Optional app name to filter results. Available apps: ${getAvailableAppNames().join(', ')}. If not provided, returns data for all apps.`);
420 | 
421 | const maxRetriesSchema = z.number().int().min(1).max(10).optional().default(3)
422 |   .describe("Maximum number of retry attempts for failed requests (1-10, default: 3)");
423 | 
424 | const baseDelaySchema = z.number().int().min(500).max(10000).optional().default(2000)
425 |   .describe("Base delay in milliseconds between retry attempts (500-10000ms, default: 2000ms)");
426 | 
427 | const noBreakdownSchema = z.boolean().optional().default(true)
428 |   .describe("Whether to exclude device breakdown data. Set to false to include detailed device metrics (default: true)");
429 | 
430 | // Tool: Get Samsung Content Metrics
431 | server.tool("get_samsung_content_metrics",
432 |   "Fetch content metrics from Samsung API for a specific date range. Optionally filter by app name. Includes retry mechanism for improved reliability.",
433 |   {
434 |     startDate: dateSchema.describe("Start date for the report (YYYY-MM-DD)"),
435 |     endDate: dateSchema.describe("End date for the report (YYYY-MM-DD)"),
436 |     appName: appNameSchema,
437 |     metricIds: metricIdsSchema,
438 |     maxRetries: maxRetriesSchema,
439 |     baseDelay: baseDelaySchema,
440 |     noBreakdown: noBreakdownSchema
441 |   },
442 |   async ({ startDate, endDate, appName, metricIds, maxRetries = 3, baseDelay = 2000, noBreakdown = true }) => {
443 |     try {
444 |       const logMessage = appName
445 |         ? `Fetching Samsung content metrics for ${appName}, date range: ${startDate} to ${endDate} (retries: ${maxRetries}, delay: ${baseDelay}ms)`
446 |         : `Fetching Samsung content metrics for all apps, date range: ${startDate} to ${endDate} (retries: ${maxRetries}, delay: ${baseDelay}ms)`;
447 | 
448 |       console.error(logMessage);
449 | 
450 |       // Filter apps based on appName parameter
451 |       const appsToFetch = filterAppsByName(appName);
452 | 
453 |       const samsungService = new SamsungApiService(startDate, endDate);
454 |       const allMetrics = await samsungService.fetchContentMetrics(appsToFetch, metricIds, maxRetries, baseDelay, noBreakdown);
455 | 
456 |       // Format response with better structure
457 |       const response = {
458 |         dateRange: { startDate, endDate },
459 |         requestedApp: appName || 'all',
460 |         availableApps: getAvailableAppNames(),
461 |         retryConfig: { maxRetries, baseDelay },
462 |         noBreakdown: noBreakdown,
463 |         totalApps: Object.keys(allMetrics).length,
464 |         successfulApps: Object.values(allMetrics).filter(result => !result.error).length,
465 |         failedApps: Object.values(allMetrics).filter(result => result.error).length,
466 |         data: allMetrics
467 |       };
468 | 
469 |       return {
470 |         content: [
471 |           {
472 |             type: "text",
473 |             text: JSON.stringify(response, null, 2)
474 |           }
475 |         ]
476 |       };
477 |     } catch (error: any) {
478 |       const errorMessage = `Error fetching Samsung content metrics: ${error.message}`;
479 |       console.error(errorMessage);
480 | 
481 |       return {
482 |         content: [
483 |           {
484 |             type: "text",
485 |             text: JSON.stringify({
486 |               error: errorMessage,
487 |               dateRange: { startDate, endDate },
488 |               requestedApp: appName || 'all',
489 |               availableApps: getAvailableAppNames(),
490 |               retryConfig: { maxRetries, baseDelay },
491 |               noBreakdown: noBreakdown,
492 |               timestamp: new Date().toISOString()
493 |             }, null, 2)
494 |           }
495 |         ],
496 |         isError: true
497 |       };
498 |     }
499 |   }
500 | );
501 | 
502 | // Start server
503 | async function runServer(): Promise<void> {
504 |   try {
505 |     const transport = new StdioServerTransport();
506 |     await server.connect(transport);
507 |     console.error("Samsung Reporting MCP Server running on stdio");
508 |   } catch (error) {
509 |     console.error("Failed to start server:", error);
510 |     throw error;
511 |   }
512 | }
513 | 
514 | // Graceful shutdown handling
515 | process.on('SIGINT', () => {
516 |   console.error('Received SIGINT, shutting down gracefully...');
517 |   process.exit(0);
518 | });
519 | 
520 | process.on('SIGTERM', () => {
521 |   console.error('Received SIGTERM, shutting down gracefully...');
522 |   process.exit(0);
523 | });
524 | 
525 | runServer().catch((error) => {
526 |   console.error("Fatal error running server:", error);
527 |   process.exit(1);
528 | });
529 | 
```

--------------------------------------------------------------------------------
/src/n8n-nodes-sensor-tower/nodes/SensorTower/SensorTower.node.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import type {
  2 |   IExecuteFunctions,
  3 |   INodeExecutionData,
  4 |   INodeType,
  5 |   INodeTypeDescription,
  6 | } from 'n8n-workflow';
  7 | 
  8 | declare const fetch: any;
  9 | 
 10 | type SensorTowerCredentials = {
 11 |   baseUrl: string;
 12 |   authToken: string;
 13 | };
 14 | 
 15 | export class SensorTower implements INodeType {
 16 |   description: INodeTypeDescription = {
 17 |     displayName: 'Sensor Tower',
 18 |     name: 'sensorTower',
 19 |     icon: 'file:logo.svg',
 20 |     group: ['transform'],
 21 |     version: 1,
 22 |     subtitle: '={{$parameter["operation"]}}',
 23 |     description: 'Sensor Tower Reporting via MCP-equivalent REST',
 24 |     defaults: { name: 'Sensor Tower' },
 25 |     inputs: ['main'],
 26 |     outputs: ['main'],
 27 |     credentials: [
 28 |       { name: 'sensorTowerApi', required: true },
 29 |     ],
 30 |     properties: [
 31 |       {
 32 |         displayName: 'Operation',
 33 |         name: 'operation',
 34 |         type: 'options',
 35 |         options: [
 36 |           { name: 'Get App Metadata', value: 'get_app_metadata', action: 'Get app metadata' },
 37 |           { name: 'Get Top In-App Purchases', value: 'get_top_in_app_purchases', action: 'Get top in-app purchases' },
 38 |           { name: 'Get Compact Sales Report Estimates', value: 'get_compact_sales_report_estimates', action: 'Get compact sales report estimates' },
 39 |           { name: 'Get Active Users', value: 'get_active_users', action: 'Get active users' },
 40 |           { name: 'Get Category History', value: 'get_category_history', action: 'Get category history' },
 41 |           { name: 'Get Category Ranking Summary', value: 'get_category_ranking_summary', action: 'Get category ranking summary' },
 42 |           { name: 'Get Network Analysis', value: 'get_network_analysis', action: 'Get network analysis' },
 43 |           { name: 'Get Network Analysis Rank', value: 'get_network_analysis_rank', action: 'Get network analysis rank' },
 44 |           { name: 'Get Retention', value: 'get_retention', action: 'Get retention' },
 45 |           { name: 'Get Downloads By Sources', value: 'get_downloads_by_sources', action: 'Get downloads by sources' },
 46 |         ],
 47 |         default: 'get_app_metadata',
 48 |       },
 49 | 
 50 |       // Shared fields
 51 |       { displayName: 'OS', name: 'os', type: 'options', options: [ { name: 'iOS', value: 'ios' }, { name: 'Android', value: 'android' } ], default: 'ios', displayOptions: { show: { operation: ['get_app_metadata','get_compact_sales_report_estimates','get_active_users','get_category_history','get_category_ranking_summary','get_network_analysis','get_network_analysis_rank','get_retention','get_downloads_by_sources'] } } },
 52 | 
 53 |       // get_app_metadata
 54 |       { displayName: 'App IDs (comma-separated)', name: 'appIds', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_app_metadata'] } } },
 55 |       { displayName: 'Country', name: 'country', type: 'string', default: 'US', displayOptions: { show: { operation: ['get_app_metadata'] } } },
 56 | 
 57 |       // get_top_in_app_purchases (iOS only)
 58 |       { displayName: 'App IDs (comma-separated)', name: 'iapAppIds', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_top_in_app_purchases'] } } },
 59 |       { displayName: 'Country', name: 'iapCountry', type: 'string', default: 'US', displayOptions: { show: { operation: ['get_top_in_app_purchases'] } } },
 60 | 
 61 |       // get_compact_sales_report_estimates
 62 |       { displayName: 'Start Date (YYYY-MM-DD)', name: 'csrStartDate', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_compact_sales_report_estimates'] } } },
 63 |       { displayName: 'End Date (YYYY-MM-DD)', name: 'csrEndDate', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_compact_sales_report_estimates'] } } },
 64 |       { displayName: 'App IDs (comma-separated)', name: 'csrAppIds', type: 'string', default: '', displayOptions: { show: { operation: ['get_compact_sales_report_estimates'] } } },
 65 |       { displayName: 'Publisher IDs (multiple allowed)', name: 'csrPublisherIds', type: 'string', default: '', description: 'Comma-separated', displayOptions: { show: { operation: ['get_compact_sales_report_estimates'] } } },
 66 |       { displayName: 'Unified App IDs', name: 'csrUnifiedAppIds', type: 'string', default: '', displayOptions: { show: { operation: ['get_compact_sales_report_estimates'] } } },
 67 |       { displayName: 'Unified Publisher IDs', name: 'csrUnifiedPublisherIds', type: 'string', default: '', displayOptions: { show: { operation: ['get_compact_sales_report_estimates'] } } },
 68 |       { displayName: 'Categories', name: 'csrCategories', type: 'string', default: '', displayOptions: { show: { operation: ['get_compact_sales_report_estimates'] } } },
 69 |       { displayName: 'Date Granularity', name: 'csrDateGranularity', type: 'string', default: '', displayOptions: { show: { operation: ['get_compact_sales_report_estimates'] } } },
 70 |       { displayName: 'Data Model', name: 'csrDataModel', type: 'string', default: '', displayOptions: { show: { operation: ['get_compact_sales_report_estimates'] } } },
 71 | 
 72 |       // get_active_users
 73 |       { displayName: 'App IDs (comma-separated)', name: 'auAppIds', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_active_users'] } } },
 74 |       { displayName: 'Time Period', name: 'auTimePeriod', type: 'options', options: [ { name: 'day', value: 'day' }, { name: 'week', value: 'week' }, { name: 'month', value: 'month' } ], default: 'day', displayOptions: { show: { operation: ['get_active_users'] } } },
 75 |       { displayName: 'Start Date (YYYY-MM-DD)', name: 'auStartDate', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_active_users'] } } },
 76 |       { displayName: 'End Date (YYYY-MM-DD)', name: 'auEndDate', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_active_users'] } } },
 77 |       { displayName: 'Countries (comma-separated)', name: 'auCountries', type: 'string', default: '', displayOptions: { show: { operation: ['get_active_users'] } } },
 78 |       { displayName: 'Data Model', name: 'auDataModel', type: 'string', default: '', displayOptions: { show: { operation: ['get_active_users'] } } },
 79 | 
 80 |       // get_category_history
 81 |       { displayName: 'App IDs (comma-separated)', name: 'chAppIds', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_category_history'] } } },
 82 |       { displayName: 'Category', name: 'chCategory', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_category_history'] } } },
 83 |       { displayName: 'Chart Type IDs (comma-separated)', name: 'chChartTypeIds', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_category_history'] } } },
 84 |       { displayName: 'Countries (comma-separated)', name: 'chCountries', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_category_history'] } } },
 85 |       { displayName: 'Start Date (YYYY-MM-DD)', name: 'chStartDate', type: 'string', default: '', displayOptions: { show: { operation: ['get_category_history'] } } },
 86 |       { displayName: 'End Date (YYYY-MM-DD)', name: 'chEndDate', type: 'string', default: '', displayOptions: { show: { operation: ['get_category_history'] } } },
 87 |       { displayName: 'Is Hourly', name: 'chIsHourly', type: 'boolean', default: false, displayOptions: { show: { operation: ['get_category_history'] } } },
 88 | 
 89 |       // get_category_ranking_summary
 90 |       { displayName: 'App ID', name: 'crsAppId', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_category_ranking_summary'] } } },
 91 |       { displayName: 'Country', name: 'crsCountry', type: 'string', default: 'US', required: true, displayOptions: { show: { operation: ['get_category_ranking_summary'] } } },
 92 | 
 93 |       // get_network_analysis
 94 |       { displayName: 'App IDs (comma-separated)', name: 'naAppIds', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_network_analysis'] } } },
 95 |       { displayName: 'Start Date (YYYY-MM-DD)', name: 'naStartDate', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_network_analysis'] } } },
 96 |       { displayName: 'End Date (YYYY-MM-DD)', name: 'naEndDate', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_network_analysis'] } } },
 97 |       { displayName: 'Period', name: 'naPeriod', type: 'options', options: [ { name: 'day', value: 'day' } ], default: 'day', required: true, displayOptions: { show: { operation: ['get_network_analysis'] } } },
 98 |       { displayName: 'Networks (comma-separated)', name: 'naNetworks', type: 'string', default: '', displayOptions: { show: { operation: ['get_network_analysis'] } } },
 99 |       { displayName: 'Countries (comma-separated)', name: 'naCountries', type: 'string', default: '', displayOptions: { show: { operation: ['get_network_analysis'] } } },
100 | 
101 |       // get_network_analysis_rank
102 |       { displayName: 'App IDs (comma-separated)', name: 'narAppIds', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_network_analysis_rank'] } } },
103 |       { displayName: 'Start Date (YYYY-MM-DD)', name: 'narStartDate', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_network_analysis_rank'] } } },
104 |       { displayName: 'End Date (YYYY-MM-DD)', name: 'narEndDate', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_network_analysis_rank'] } } },
105 |       { displayName: 'Period', name: 'narPeriod', type: 'options', options: [ { name: 'day', value: 'day' } ], default: 'day', required: true, displayOptions: { show: { operation: ['get_network_analysis_rank'] } } },
106 |       { displayName: 'Networks (comma-separated)', name: 'narNetworks', type: 'string', default: '', displayOptions: { show: { operation: ['get_network_analysis_rank'] } } },
107 |       { displayName: 'Countries (comma-separated)', name: 'narCountries', type: 'string', default: '', displayOptions: { show: { operation: ['get_network_analysis_rank'] } } },
108 | 
109 |       // get_retention
110 |       { displayName: 'App IDs (comma-separated)', name: 'retAppIds', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_retention'] } } },
111 |       { displayName: 'Date Granularity', name: 'retDateGranularity', type: 'options', options: [ { name: 'all_time', value: 'all_time' }, { name: 'quarterly', value: 'quarterly' } ], default: 'all_time', required: true, displayOptions: { show: { operation: ['get_retention'] } } },
112 |       { displayName: 'Start Date (YYYY-MM-DD)', name: 'retStartDate', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_retention'] } } },
113 |       { displayName: 'End Date (YYYY-MM-DD)', name: 'retEndDate', type: 'string', default: '', displayOptions: { show: { operation: ['get_retention'] } } },
114 |       { displayName: 'Country', name: 'retCountry', type: 'string', default: '', displayOptions: { show: { operation: ['get_retention'] } } },
115 | 
116 |       // get_downloads_by_sources
117 |       { displayName: 'App IDs (comma-separated; unified IDs)', name: 'dbsAppIds', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_downloads_by_sources'] } } },
118 |       { displayName: 'Countries (comma-separated)', name: 'dbsCountries', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_downloads_by_sources'] } } },
119 |       { displayName: 'Start Date (YYYY-MM-DD)', name: 'dbsStartDate', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_downloads_by_sources'] } } },
120 |       { displayName: 'End Date (YYYY-MM-DD)', name: 'dbsEndDate', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_downloads_by_sources'] } } },
121 |       { displayName: 'Date Granularity', name: 'dbsDateGranularity', type: 'options', options: [ { name: 'monthly', value: 'monthly' }, { name: 'daily', value: 'daily' } ], default: 'monthly', displayOptions: { show: { operation: ['get_downloads_by_sources'] } } },
122 |     ],
123 |   };
124 | 
125 |   async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
126 |     const items = this.getInputData();
127 |     const returnItems: INodeExecutionData[] = [];
128 | 
129 |     const credentials = (await this.getCredentials('sensorTowerApi')) as unknown as SensorTowerCredentials;
130 |     const baseUrl = credentials.baseUrl || 'https://api.sensortower.com';
131 |     const auth = credentials.authToken;
132 | 
133 |     const buildQuery = (params: Record<string, unknown>): string => {
134 |       const qs = new URLSearchParams();
135 |       Object.entries(params).forEach(([key, value]) => {
136 |         if (value === undefined || value === null || value === '') return;
137 |         if (Array.isArray(value)) {
138 |           value.forEach((v) => qs.append(key, String(v)));
139 |         } else if (typeof value === 'string' && key.endsWith('[]')) {
140 |           value.split(',').map((s) => s.trim()).filter(Boolean).forEach((v) => qs.append(key, v));
141 |         } else {
142 |           qs.append(key, String(value));
143 |         }
144 |       });
145 |       return qs.toString();
146 |     };
147 | 
148 |     const request = async (endpoint: string, params: Record<string, unknown> = {}) => {
149 |       const query = buildQuery({ ...params, auth_token: auth as string });
150 |       const res = await fetch(`${baseUrl}${endpoint}?${query}`, {
151 |         method: 'GET',
152 |         headers: { 'Accept': 'application/json' },
153 |       });
154 |       return await res.json();
155 |     };
156 | 
157 |     const operation = this.getNodeParameter('operation', 0) as string;
158 | 
159 |     for (let i = 0; i < items.length; i++) {
160 |       if (operation === 'get_app_metadata') {
161 |         const os = this.getNodeParameter('os', i) as string;
162 |         const appIds = this.getNodeParameter('appIds', i) as string;
163 |         const country = this.getNodeParameter('country', i, 'US') as string;
164 |         const data = await request(`/v1/${os}/apps`, { app_ids: appIds, country });
165 |         returnItems.push({ json: data });
166 |       } else if (operation === 'get_top_in_app_purchases') {
167 |         const appIds = this.getNodeParameter('iapAppIds', i) as string;
168 |         const country = this.getNodeParameter('iapCountry', i, 'US') as string;
169 |         const data = await request('/v1/ios/apps/top_in_app_purchases', { app_ids: appIds, country });
170 |         returnItems.push({ json: data });
171 |       } else if (operation === 'get_compact_sales_report_estimates') {
172 |         const os = this.getNodeParameter('os', i) as string;
173 |         const startDate = this.getNodeParameter('csrStartDate', i) as string;
174 |         const endDate = this.getNodeParameter('csrEndDate', i) as string;
175 |         const appIds = this.getNodeParameter('csrAppIds', i, '') as string;
176 |         const publisherIds = this.getNodeParameter('csrPublisherIds', i, '') as string;
177 |         const unifiedAppIds = this.getNodeParameter('csrUnifiedAppIds', i, '') as string;
178 |         const unifiedPublisherIds = this.getNodeParameter('csrUnifiedPublisherIds', i, '') as string;
179 |         const categories = this.getNodeParameter('csrCategories', i, '') as string;
180 |         const dateGranularity = this.getNodeParameter('csrDateGranularity', i, '') as string;
181 |         const dataModel = this.getNodeParameter('csrDataModel', i, '') as string;
182 | 
183 |         const params: Record<string, unknown> = { start_date: startDate, end_date: endDate };
184 |         if (appIds) params.app_ids = appIds;
185 |         if (publisherIds) params['publisher_ids[]'] = publisherIds.split(',').map((s) => s.trim()).filter(Boolean);
186 |         if (unifiedAppIds) params.unified_app_ids = unifiedAppIds;
187 |         if (unifiedPublisherIds) params.unified_publisher_ids = unifiedPublisherIds;
188 |         if (categories) params.categories = categories;
189 |         if (dateGranularity) params.date_granularity = dateGranularity;
190 |         if (dataModel) params.data_model = dataModel;
191 |         const data = await request(`/v1/${os}/compact_sales_report_estimates`, params);
192 |         returnItems.push({ json: data });
193 |       } else if (operation === 'get_active_users') {
194 |         const os = this.getNodeParameter('os', i) as string;
195 |         const appIds = this.getNodeParameter('auAppIds', i) as string;
196 |         const timePeriod = this.getNodeParameter('auTimePeriod', i) as string;
197 |         const startDate = this.getNodeParameter('auStartDate', i) as string;
198 |         const endDate = this.getNodeParameter('auEndDate', i) as string;
199 |         const countries = this.getNodeParameter('auCountries', i, '') as string;
200 |         const dataModel = this.getNodeParameter('auDataModel', i, '') as string;
201 |         const params: Record<string, unknown> = { app_ids: appIds, time_period: timePeriod, start_date: startDate, end_date: endDate };
202 |         if (countries) params.countries = countries;
203 |         if (dataModel) params.data_model = dataModel;
204 |         const data = await request(`/v1/${os}/usage/active_users`, params);
205 |         returnItems.push({ json: data });
206 |       } else if (operation === 'get_category_history') {
207 |         const os = this.getNodeParameter('os', i) as string;
208 |         const appIds = this.getNodeParameter('chAppIds', i) as string;
209 |         const category = this.getNodeParameter('chCategory', i) as string;
210 |         const chartTypeIds = this.getNodeParameter('chChartTypeIds', i) as string;
211 |         const countries = this.getNodeParameter('chCountries', i) as string;
212 |         const startDate = this.getNodeParameter('chStartDate', i, '') as string;
213 |         const endDate = this.getNodeParameter('chEndDate', i, '') as string;
214 |         const isHourly = this.getNodeParameter('chIsHourly', i, false) as boolean;
215 |         const params: Record<string, unknown> = { app_ids: appIds, category, chart_type_ids: chartTypeIds, countries };
216 |         if (startDate) params.start_date = startDate;
217 |         if (endDate) params.end_date = endDate;
218 |         if (isHourly !== undefined) params.is_hourly = String(isHourly);
219 |         const data = await request(`/v1/${os}/category/category_history`, params);
220 |         returnItems.push({ json: data });
221 |       } else if (operation === 'get_category_ranking_summary') {
222 |         const os = this.getNodeParameter('os', i) as string;
223 |         const appId = this.getNodeParameter('crsAppId', i) as string;
224 |         const country = this.getNodeParameter('crsCountry', i) as string;
225 |         const data = await request(`/v1/${os}/category/category_ranking_summary`, { app_id: appId, country });
226 |         returnItems.push({ json: data });
227 |       } else if (operation === 'get_network_analysis') {
228 |         const os = this.getNodeParameter('os', i) as string;
229 |         const appIds = this.getNodeParameter('naAppIds', i) as string;
230 |         const startDate = this.getNodeParameter('naStartDate', i) as string;
231 |         const endDate = this.getNodeParameter('naEndDate', i) as string;
232 |         const period = this.getNodeParameter('naPeriod', i) as string;
233 |         const networks = this.getNodeParameter('naNetworks', i, '') as string;
234 |         const countries = this.getNodeParameter('naCountries', i, '') as string;
235 |         const params: Record<string, unknown> = { app_ids: appIds, start_date: startDate, end_date: endDate, period };
236 |         if (networks) params.networks = networks;
237 |         if (countries) params.countries = countries;
238 |         const data = await request(`/v1/${os}/ad_intel/network_analysis`, params);
239 |         returnItems.push({ json: data });
240 |       } else if (operation === 'get_network_analysis_rank') {
241 |         const os = this.getNodeParameter('os', i) as string;
242 |         const appIds = this.getNodeParameter('narAppIds', i) as string;
243 |         const startDate = this.getNodeParameter('narStartDate', i) as string;
244 |         const endDate = this.getNodeParameter('narEndDate', i) as string;
245 |         const period = this.getNodeParameter('narPeriod', i) as string;
246 |         const networks = this.getNodeParameter('narNetworks', i, '') as string;
247 |         const countries = this.getNodeParameter('narCountries', i, '') as string;
248 |         const params: Record<string, unknown> = { app_ids: appIds, start_date: startDate, end_date: endDate, period };
249 |         if (networks) params.networks = networks;
250 |         if (countries) params.countries = countries;
251 |         const data = await request(`/v1/${os}/ad_intel/network_analysis/rank`, params);
252 |         returnItems.push({ json: data });
253 |       } else if (operation === 'get_retention') {
254 |         const os = this.getNodeParameter('os', i) as string;
255 |         const appIds = this.getNodeParameter('retAppIds', i) as string;
256 |         const dateGranularity = this.getNodeParameter('retDateGranularity', i) as string;
257 |         const startDate = this.getNodeParameter('retStartDate', i) as string;
258 |         const endDate = this.getNodeParameter('retEndDate', i, '') as string;
259 |         const country = this.getNodeParameter('retCountry', i, '') as string;
260 |         const params: Record<string, unknown> = { app_ids: appIds, date_granularity: dateGranularity, start_date: startDate };
261 |         if (endDate) params.end_date = endDate;
262 |         if (country) params.country = country;
263 |         const data = await request(`/v1/${os}/usage/retention`, params);
264 |         returnItems.push({ json: data });
265 |       } else if (operation === 'get_downloads_by_sources') {
266 |         const os = this.getNodeParameter('os', i) as string;
267 |         const appIds = this.getNodeParameter('dbsAppIds', i) as string;
268 |         const countries = this.getNodeParameter('dbsCountries', i) as string;
269 |         const startDate = this.getNodeParameter('dbsStartDate', i) as string;
270 |         const endDate = this.getNodeParameter('dbsEndDate', i) as string;
271 |         const dateGranularity = this.getNodeParameter('dbsDateGranularity', i, 'monthly') as string;
272 |         const params: Record<string, unknown> = { app_ids: appIds, countries, start_date: startDate, end_date: endDate, date_granularity: dateGranularity };
273 |         const data = await request(`/v1/${os}/downloads_by_sources`, params);
274 |         returnItems.push({ json: data });
275 |       }
276 |     }
277 | 
278 |     return [returnItems];
279 |   }
280 | }
281 | 
282 | 
283 | 
```
Page 4/5FirstPrevNextLast