#
tokens: 49284/50000 18/180 files (page 3/5)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 3 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/singular-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 dotenv from "dotenv";
  6 | import axios from "axios";
  7 | import { z } from "zod";
  8 | import type { AxiosError } from "axios";
  9 | 
 10 | // Load environment variables
 11 | dotenv.config();
 12 | 
 13 | const apiKey = process.env.SINGULAR_API_KEY;
 14 | const apiBaseUrl = process.env.SINGULAR_API_BASE_URL;
 15 | 
 16 | if (!apiKey || !apiBaseUrl) {
 17 |   throw new Error("Missing required environment variables: SINGULAR_API_KEY and SINGULAR_API_BASE_URL");
 18 | }
 19 | 
 20 | // Create MCP server
 21 | const server = new McpServer({
 22 |   name: "Singular MCP Server",
 23 |   version: "0.0.3",
 24 | });
 25 | 
 26 | // Define the params directly as a ZodRawShape
 27 | const createReportParams = {
 28 |   start_date: z.string().describe("Start date in YYYY-MM-DD format"),
 29 |   end_date: z.string().describe("End date in YYYY-MM-DD format"),
 30 |   source: z.string().optional().describe("Optional. Filter results by specific source"),
 31 |   time_breakdown: z.string().optional().describe("Optional. Time breakdown: 'day' for daily data, 'all' for aggregated data (default: 'all')")
 32 | } as const;
 33 | 
 34 | server.tool(
 35 |   "create_report",
 36 |   "Getting reporting data from Singular via generates an asynchronous report query",
 37 |   createReportParams,  // Use the params directly
 38 |   async (params) => {
 39 |     try {
 40 |       const requestBody = {
 41 |         ...params,
 42 |         dimensions: "unified_campaign_name",
 43 |         metrics: "custom_impressions,custom_clicks,custom_installs,adn_cost",
 44 |         time_breakdown: params.time_breakdown || "all",
 45 |         format: "csv"
 46 |       };
 47 | 
 48 |       // Only add source filter if it's provided
 49 |       if (params.source) {
 50 |         requestBody.source = params.source;
 51 |       }
 52 | 
 53 |       const response = await axios.post(
 54 |         `${apiBaseUrl}/create_async_report`,
 55 |         requestBody,
 56 |         {
 57 |           params: { api_key: apiKey }
 58 |         }
 59 |       );
 60 |       return {
 61 |         content: [{
 62 |           type: "text",
 63 |           text: JSON.stringify(response.data, null, 2)
 64 |         }],
 65 |       };
 66 |     } catch (error) {
 67 |       const errorMessage = error instanceof Error
 68 |         ? `Singular API error: ${(error as AxiosError<SingularErrorResponse>).response?.data?.message || error.message}`
 69 |         : 'Unknown error occurred';
 70 |       return {
 71 |         content: [{
 72 |           type: "text",
 73 |           text: errorMessage
 74 |         }],
 75 |         isError: true
 76 |       };
 77 |     }
 78 |   }
 79 | );
 80 | 
 81 | const getReportStatusParams = {
 82 |   report_id: z.string(),
 83 | };
 84 | 
 85 | const getReportStatusSchema = z.object(getReportStatusParams);
 86 | type GetReportStatusParams = z.infer<typeof getReportStatusSchema>;
 87 | 
 88 | interface SingularErrorResponse {
 89 |   message: string;
 90 | }
 91 | 
 92 | server.tool(
 93 |   "get_singular_report",
 94 |   "Get the complete report from Singular. Checks status and automatically downloads the CSV report data when ready.",
 95 |   getReportStatusParams,
 96 |   async (params: GetReportStatusParams) => {
 97 |     try {
 98 |       const response = await axios.get(
 99 |         `${apiBaseUrl}/get_report_status`,
100 |         {
101 |           params: {
102 |             api_key: apiKey,
103 |             report_id: params.report_id
104 |           }
105 |         }
106 |       );
107 | 
108 |       const reportData = response.data;
109 | 
110 |       // If status is DONE and download_url is available, automatically download the report
111 |       if (reportData.value && reportData.value.status === 'DONE' && reportData.value.download_url) {
112 |         try {
113 |           const downloadResponse = await axios.get(reportData.value.download_url, {
114 |             responseType: 'text'
115 |           });
116 | 
117 |           // Return data in the specified format
118 |           const formattedResponse = {
119 |             status: 0,
120 |             substatus: 0,
121 |             value: {
122 |               csv_report: downloadResponse.data
123 |             }
124 |           };
125 | 
126 |           return {
127 |             content: [{
128 |               type: "text",
129 |               text: JSON.stringify(formattedResponse)
130 |             }]
131 |           };
132 |         } catch (downloadError) {
133 |           const downloadErrorMessage = downloadError instanceof Error
134 |             ? `Download error: ${(downloadError as AxiosError<SingularErrorResponse>).response?.data?.message || downloadError.message}`
135 |             : 'Unknown download error occurred';
136 |           return {
137 |             content: [{
138 |               type: "text",
139 |               text: `Report is ready but download failed: ${downloadErrorMessage}\n\nStatus response: ${JSON.stringify(reportData, null, 2)}`
140 |             }],
141 |             isError: true
142 |           };
143 |         }
144 |       } else {
145 |         // Return status information if not done yet
146 |         return {
147 |           content: [{
148 |             type: "text",
149 |             text: JSON.stringify(reportData, null, 2)
150 |           }]
151 |         };
152 |       }
153 |     } catch (error) {
154 |       const errorMessage = error instanceof Error
155 |         ? `Singular API error: ${(error as AxiosError<SingularErrorResponse>).response?.data?.message || error.message}`
156 |         : 'Unknown error occurred';
157 |       return {
158 |         content: [{
159 |           type: "text",
160 |           text: errorMessage
161 |         }],
162 |         isError: true
163 |       };
164 |     }
165 |   }
166 | );
167 | 
168 | // Add documentation prompt
169 | server.prompt(
170 |   "help",
171 |   {},
172 |   () => ({
173 |     messages: [{
174 |       role: "user",
175 |       content: {
176 |         type: "text",
177 |         text: `
178 | Available tools:
179 | 
180 | 1. create_report
181 |    Creates an asynchronous report in Singular with predefined settings.
182 |    Parameters:
183 |    - start_date: string (required) - Start date (YYYY-MM-DD)
184 |    - end_date: string (required) - End date (YYYY-MM-DD)
185 |    - source: string (optional) - Filter results by specific source
186 |    - time_breakdown: string (optional) - Time breakdown: 'day' for daily data, 'all' for aggregated data (default: 'all')
187 | 
188 |    Fixed settings:
189 |    - dimensions: unified_campaign_name
190 |    - metrics: custom_impressions,custom_clicks,custom_installs,adn_cost
191 |    - format: csv
192 | 
193 | 2. get_singular_report
194 |    Gets the complete report from Singular. Checks status and automatically downloads the CSV report data when ready.
195 |    Parameters:
196 |    - report_id: string (required) - The ID of the report to check
197 | 
198 |    Returns:
199 |    - If report is still processing: Status information in JSON format
200 |    - If report is complete: Full CSV report data wrapped in JSON format with csv_report field
201 | 
202 |    Note: This tool handles the complete workflow - you don't need to manually check status or download separately.
203 |         `
204 |       }
205 |     }]
206 |   })
207 | );
208 | 
209 | // Set up STDIO transport
210 | const transport = new StdioServerTransport();
211 | 
212 | // Connect server to transport
213 | await server.connect(transport);
214 | 
```

--------------------------------------------------------------------------------
/src/civitai-records/src/lib/handleDatabaseError.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { Prisma } from "@prisma/client";
  2 | 
  3 | /**
  4 |  * Handle Prisma database errors and convert them to LLM-friendly messages.
  5 |  * This is a centralized error handler for all database operations.
  6 |  * 
  7 |  * The messages are designed to be:
  8 |  * - Clear and actionable
  9 |  * - Explain WHY the error occurred
 10 |  * - Suggest HOW to fix it
 11 |  * - Provide context about what went wrong
 12 |  * 
 13 |  * @param error - The error thrown by Prisma
 14 |  * @param context - Additional context to include in error messages (e.g., URL, ID)
 15 |  * @throws Always throws an error with an LLM-friendly message
 16 |  */
 17 | export function handleDatabaseError(error: unknown, context?: string): never {
 18 |   if (error instanceof Prisma.PrismaClientKnownRequestError) {
 19 |     const target = error.meta?.target as string[] | undefined;
 20 |     const contextInfo = context ? `\n\n${context}` : '';
 21 |     
 22 |     // P2002: Unique constraint violation
 23 |     if (error.code === 'P2002') {
 24 |       // Specific handling for sha256sum (duplicate content)
 25 |       if (target?.includes('sha256sum')) {
 26 |         throw new Error(
 27 |           `❌ DUPLICATE CONTENT DETECTED\n\n` +
 28 |           `This asset already exists in the database with the same content (same sha256sum hash).\n\n` +
 29 |           `What this means:\n` +
 30 |           `- You're trying to save content that has already been saved before\n` +
 31 |           `- The file content is identical to an existing asset\n\n` +
 32 |           `What to do:\n` +
 33 |           `1. Use the find_asset tool to search for existing assets with this URL or content\n` +
 34 |           `2. If you need to update the existing asset, use update_asset instead of create_asset\n` +
 35 |           `3. If you want to create a new record anyway, make sure the content is actually different${contextInfo}`
 36 |         );
 37 |       }
 38 |       
 39 |       // Specific handling for civitai_id
 40 |       if (target?.includes('civitai_id')) {
 41 |         throw new Error(
 42 |           `❌ DUPLICATE CIVITAI ID\n\n` +
 43 |           `A record with this Civitai ID already exists in the database.\n\n` +
 44 |           `What this means:\n` +
 45 |           `- You're trying to create a new record with a civitai_id that's already in use\n` +
 46 |           `- This could be an asset or post that was already saved\n\n` +
 47 |           `What to do:\n` +
 48 |           `1. Use find_asset or list_civitai_posts to check if this record already exists\n` +
 49 |           `2. If it exists and needs updates, use update_asset or update the post instead\n` +
 50 |           `3. Verify that the civitai_id is correct - maybe you copied it from the wrong URL${contextInfo}`
 51 |         );
 52 |       }
 53 |       
 54 |       // Generic unique constraint violation
 55 |       const fields = target?.join(', ') || 'unknown field(s)';
 56 |       throw new Error(
 57 |         `❌ DUPLICATE DATA DETECTED\n\n` +
 58 |         `A record with the same ${fields} already exists in the database.\n\n` +
 59 |         `What this means:\n` +
 60 |         `- You're trying to create a record with data that must be unique\n` +
 61 |         `- Another record already uses this value\n\n` +
 62 |         `What to do:\n` +
 63 |         `1. Check if a similar record already exists using the appropriate search tool\n` +
 64 |         `2. If you want to modify an existing record, use an update tool instead\n` +
 65 |         `3. If you need a new record, make sure all unique fields have different values${contextInfo}`
 66 |       );
 67 |     }
 68 |     
 69 |     // P2003: Foreign key constraint violation
 70 |     if (error.code === 'P2003') {
 71 |       const field = error.meta?.field_name as string | undefined;
 72 |       throw new Error(
 73 |         `❌ INVALID REFERENCE ID\n\n` +
 74 |         `The ${field || 'referenced'} ID you provided doesn't exist in the database.\n\n` +
 75 |         `What this means:\n` +
 76 |         `- You're trying to link to a record that doesn't exist\n` +
 77 |         `- The ID might be wrong, or the referenced record was never created\n\n` +
 78 |         `What to do:\n` +
 79 |         `1. If referencing a prompt: Create the prompt first using create_prompt, then use its returned ID\n` +
 80 |         `2. If referencing a post: Create the post first using create_civitai_post, then use its returned ID\n` +
 81 |         `3. Double-check that you're using the correct ID from a previous operation\n` +
 82 |         `4. Make sure you didn't skip a step in your workflow${contextInfo}`
 83 |       );
 84 |     }
 85 |     
 86 |     // P2025: Record not found
 87 |     if (error.code === 'P2025') {
 88 |       throw new Error(
 89 |         `❌ RECORD NOT FOUND\n\n` +
 90 |         `The record you're trying to access doesn't exist in the database.\n\n` +
 91 |         `What this means:\n` +
 92 |         `- The ID you provided is incorrect or the record was deleted\n\n` +
 93 |         `What to do:\n` +
 94 |         `1. Verify the ID is correct\n` +
 95 |         `2. Use the appropriate find or list tool to search for the record\n` +
 96 |         `3. If you need to create it, use a create tool instead of update${contextInfo}`
 97 |       );
 98 |     }
 99 |     
100 |     // P2014: Required relation violation
101 |     if (error.code === 'P2014') {
102 |       throw new Error(
103 |         `❌ REQUIRED RELATIONSHIP VIOLATION\n\n` +
104 |         `This operation would break a required relationship in the database.\n\n` +
105 |         `What this means:\n` +
106 |         `- You're trying to remove or change something that other records depend on\n\n` +
107 |         `What to do:\n` +
108 |         `1. Check what other records are linked to this one\n` +
109 |         `2. Update or delete dependent records first\n` +
110 |         `3. Consider if this operation is really necessary${contextInfo}`
111 |       );
112 |     }
113 |     
114 |     // Generic Prisma error
115 |     throw new Error(
116 |       `❌ DATABASE ERROR\n\n` +
117 |       `Error Code: ${error.code}\n` +
118 |       `Message: ${error.message}\n\n` +
119 |       `What to do:\n` +
120 |       `1. Review the error message for clues\n` +
121 |       `2. Check that all required fields are provided\n` +
122 |       `3. Verify that data types are correct${contextInfo}`
123 |     );
124 |   }
125 |   
126 |   // Re-throw unknown errors with better formatting if possible
127 |   if (error instanceof Error) {
128 |     throw new Error(
129 |       `❌ UNEXPECTED ERROR\n\n` +
130 |       `${error.message}\n\n` +
131 |       `This is an unexpected error. Please review the operation and try again.${context ? `\n\n${context}` : ''}`
132 |     );
133 |   }
134 |   
135 |   throw error;
136 | }
137 | 
138 | /**
139 |  * Wrap a database operation with error handling.
140 |  * Provides a clean way to handle errors without try-catch in every function.
141 |  * 
142 |  * @param operation - The async database operation to execute
143 |  * @param context - Additional context for error messages
144 |  * @returns The result of the operation
145 |  */
146 | export async function withDatabaseErrorHandling<T>(
147 |   operation: () => Promise<T>,
148 |   context?: string
149 | ): Promise<T> {
150 |   try {
151 |     return await operation();
152 |   } catch (error) {
153 |     handleDatabaseError(error, context);
154 |   }
155 | }
156 | 
```

--------------------------------------------------------------------------------
/src/user-activity-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 dotenv from 'dotenv';
  7 | import * as api from './api.js';
  8 | 
  9 | dotenv.config();
 10 | 
 11 | const server = new McpServer({ name: 'user-activity-reporting', version: '0.0.3' });
 12 | 
 13 | const errMsg = (e: unknown) => e instanceof Error ? e.message : 'Unknown error';
 14 | const errResp = (msg: string) => ({ content: [{ type: 'text' as const, text: `Error: ${msg}` }], isError: true });
 15 | const textResp = (text: string) => ({ content: [{ type: 'text' as const, text }] });
 16 | 
 17 | server.tool('get_all_client_contacts', 'Query all client contacts with team members (POD, AA, AM, AE, PM, PA, AO).', {
 18 |   month: z.string().optional().describe('Month in YYYY-MM format'),
 19 | }, async (args) => {
 20 |   try {
 21 |     const d = await api.getAllContacts(args.month);
 22 |     const preview = d.clients.slice(0, 20);
 23 |     const more = d.total > 20 ? `\n... and ${d.total - 20} more` : '';
 24 |     return textResp(`# All Client Contacts\n\nMonth: ${d.month} | Total: ${d.total}\n\n\`\`\`json\n${JSON.stringify(preview, null, 2)}\n\`\`\`${more}`);
 25 |   } catch (e) { return errResp(errMsg(e)); }
 26 | });
 27 | 
 28 | server.tool('get_client_team_members', 'Query team members for a client. Returns POD, AA, AM, AE, PM, PA, AO.', {
 29 |   client_name: z.string().describe('Client name (fuzzy match)'),
 30 |   month: z.string().optional().describe('Month in YYYY-MM format'),
 31 | }, async (args) => {
 32 |   try {
 33 |     const d = await api.getContactByClient(args.client_name, args.month);
 34 |     const team = { POD: d.pod || 'N/A', AA: d.aa || 'N/A', AM: d.am || 'N/A', AE: d.ae || 'N/A', PM: d.pm || 'N/A', PA: d.pa || 'N/A', AO: d.ao || 'N/A' };
 35 |     return textResp(`# Team for "${d.client_name}"\n\n\`\`\`json\n${JSON.stringify({ client_id: d.client_id, client_name: d.client_name, month: d.month, team }, null, 2)}\n\`\`\``);
 36 |   } catch (e) { return errResp(errMsg(e)); }
 37 | });
 38 | 
 39 | server.tool('get_clients_by_pod', 'Query clients in a POD team.', {
 40 |   pod: z.string().describe('POD name (fuzzy match)'),
 41 |   month: z.string().optional().describe('Month in YYYY-MM format'),
 42 | }, async (args) => {
 43 |   try {
 44 |     const d = await api.getClientsByPod(args.pod, args.month);
 45 |     return textResp(`# Clients in POD: ${d.pod}\n\nMonth: ${d.month} | Count: ${d.count}\n\n${d.client_names.map(c => `- ${c}`).join('\n')}`);
 46 |   } catch (e) { return errResp(errMsg(e)); }
 47 | });
 48 | 
 49 | server.tool('get_clients_by_name', 'Query clients managed by a person. Can filter by role.', {
 50 |   name: z.string().describe('Person name'),
 51 |   role: z.enum(['aa', 'am', 'ae', 'pm', 'pa', 'ao']).optional().describe('Role filter'),
 52 |   month: z.string().optional().describe('Month in YYYY-MM format'),
 53 | }, async (args) => {
 54 |   try {
 55 |     if (args.role) {
 56 |       const d = await api.getClientsByRole(args.role, args.name, args.month);
 57 |       return textResp(`# Clients for ${d.role.toUpperCase()}: ${d.name}\n\nMonth: ${d.month} | Count: ${d.count}\n\n${d.client_names.map(c => `- ${c}`).join('\n')}`);
 58 |     }
 59 |     const d = await api.getClientsByName(args.name, args.month);
 60 |     const lines = Object.entries(d.results).map(([r, cs]) => `**${r}:** ${cs.join(', ')}`);
 61 |     return textResp(`# Clients for "${d.name}"\n\nMonth: ${d.month}\n\n${lines.join('\n') || 'No clients found'}`);
 62 |   } catch (e) { return errResp(errMsg(e)); }
 63 | });
 64 | 
 65 | server.tool('get_user_slack_history', 'Search Slack messages from a user.', {
 66 |   user_name: z.string().describe('User name'),
 67 |   query: z.string().optional().describe('Keyword filter'),
 68 |   limit: z.number().optional().default(20).describe('Max results'),
 69 | }, async (args) => {
 70 |   try {
 71 |     const msgs = await api.searchSlackMsgs(args.user_name, args.query, args.limit || 20);
 72 |     if (!msgs.length) return textResp(`No Slack messages found for: ${args.user_name}`);
 73 |     const fmt = msgs.map(m => ({ channel: m.channel, text: m.text.slice(0, 200) + (m.text.length > 200 ? '...' : ''), ts: new Date(parseFloat(m.ts) * 1000).toISOString(), link: m.permalink }));
 74 |     return textResp(`# Slack Messages from ${args.user_name}\n\nFound ${msgs.length}\n\n\`\`\`json\n${JSON.stringify(fmt, null, 2)}\n\`\`\``);
 75 |   } catch (e) { return errResp(errMsg(e)); }
 76 | });
 77 | 
 78 | server.tool('get_hubspot_tickets', 'Query HubSpot tickets.', {
 79 |   status: z.string().optional().describe('Status filter'),
 80 |   start_date: z.string().optional().describe('Start date YYYY-MM-DD'),
 81 |   end_date: z.string().optional().describe('End date YYYY-MM-DD'),
 82 |   limit: z.number().optional().default(50).describe('Max results'),
 83 | }, async (args) => {
 84 |   try {
 85 |     const tickets = await api.getTickets({ status: args.status, startDate: args.start_date, endDate: args.end_date, limit: args.limit });
 86 |     if (!tickets.length) return textResp('No HubSpot tickets found');
 87 |     const fmt = tickets.map(t => ({ id: t.id, subject: t.subject, status: t.status, priority: t.priority || 'N/A', created: t.createdAt }));
 88 |     return textResp(`# HubSpot Tickets\n\nFound ${tickets.length}\n\n\`\`\`json\n${JSON.stringify(fmt, null, 2)}\n\`\`\``);
 89 |   } catch (e) { return errResp(errMsg(e)); }
 90 | });
 91 | 
 92 | server.tool('get_hubspot_ticket_detail', 'Get HubSpot ticket details.', {
 93 |   ticket_id: z.string().describe('Ticket ID'),
 94 | }, async (args) => {
 95 |   try {
 96 |     const t = await api.getTicketById(args.ticket_id);
 97 |     if (!t) return textResp(`Ticket not found: ${args.ticket_id}`);
 98 |     return textResp(`# ${t.subject}\n\n**ID:** ${t.id}\n**Status:** ${t.status}\n**Priority:** ${t.priority || 'N/A'}\n**Created:** ${t.createdAt}\n\n## Description\n\n${t.content || 'No content'}`);
 99 |   } catch (e) { return errResp(errMsg(e)); }
100 | });
101 | 
102 | server.tool('get_hubspot_tickets_by_user', 'Query HubSpot tickets by user.', {
103 |   user_name: z.string().optional().describe('User name'),
104 |   email: z.string().optional().describe('Email'),
105 |   limit: z.number().optional().default(50).describe('Max results'),
106 | }, async (args) => {
107 |   try {
108 |     if (!args.user_name && !args.email) return errResp('Provide user_name or email');
109 |     const tickets = await api.getTicketsByUser({ userName: args.user_name, email: args.email, limit: args.limit });
110 |     if (!tickets.length) return textResp(`No tickets found for: ${args.user_name || args.email}`);
111 |     const fmt = tickets.map(t => ({ id: t.id, subject: t.subject, status: t.status, created: t.createdAt }));
112 |     return textResp(`# Tickets for "${args.user_name || args.email}"\n\nFound ${tickets.length}\n\n\`\`\`json\n${JSON.stringify(fmt, null, 2)}\n\`\`\``);
113 |   } catch (e) { return errResp(errMsg(e)); }
114 | });
115 | 
116 | async function main() {
117 |   const transport = new StdioServerTransport();
118 |   await server.connect(transport);
119 |   console.error('User Activity Reporting MCP Server running...');
120 | }
121 | 
122 | main();
123 | 
```

--------------------------------------------------------------------------------
/src/appsamurai-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 axios from "axios";
  7 | import dotenv from "dotenv";
  8 | 
  9 | dotenv.config(); // Load environment variables from .env file
 10 | 
 11 | // Corrected Base URL
 12 | const APPSAMURAI_API_BASE = "http://api.appsamurai.com/api";
 13 | const APPSAMURAI_API_KEY = process.env.APPSAMURAI_API_KEY;
 14 | 
 15 | if (!APPSAMURAI_API_KEY) {
 16 |   console.error("Error: APPSAMURAI_API_KEY environment variable is not set.");
 17 |   process.exit(1);
 18 | }
 19 | 
 20 | // Create server instance
 21 | const server = new McpServer({
 22 |   name: "appsamurai-reporting",
 23 |   version: "0.0.5",
 24 |   capabilities: {
 25 |     tools: {},
 26 |     prompts: {},
 27 |   },
 28 | });
 29 | 
 30 | // --- Corrected Helper Function for API Call ---
 31 | async function fetchAppSamuraiData(
 32 |   startDate: string,
 33 |   endDate: string,
 34 |   campaignId?: string,
 35 |   bundleId?: string,
 36 |   platform?: string,
 37 |   campaignName?: string,
 38 |   country?: string
 39 | ): Promise<any> {
 40 |   // Construct URL with API key in the path
 41 |   const url = `${APPSAMURAI_API_BASE}/customer-pull/spent/${APPSAMURAI_API_KEY}`;
 42 |   try {
 43 |     const response = await axios.get(url, {
 44 |       headers: { // No Authorization header needed
 45 |         'Content-Type': 'application/json',
 46 |         'Accept': 'application/json',
 47 |       },
 48 |       params: { // Query parameters
 49 |         start_date: startDate,
 50 |         end_date: endDate,
 51 |         ...(campaignId && { campaign_id: campaignId }),
 52 |         ...(bundleId && { bundle_id: bundleId }),
 53 |         ...(platform && { platform }),
 54 |         ...(campaignName && { campaign_name: campaignName }),
 55 |         ...(country && { country }),
 56 |       },
 57 |       timeout: 30000, // 30 second timeout
 58 |     });
 59 |     return response.data;
 60 |   } catch (error: unknown) {
 61 |     console.error("Error fetching data from AppSamurai API:", error);
 62 |     if (axios.isAxiosError(error)) {
 63 |       console.error("Axios error details:", {
 64 |         message: error.message,
 65 |         code: error.code,
 66 |         status: error.response?.status,
 67 |         data: error.response?.data,
 68 |       });
 69 |       // Provide more specific error messages based on status code
 70 |       if (error.response?.status === 401) {
 71 |         throw new Error(`AppSamurai API request failed: Unauthorized (Invalid API Key?)`);
 72 |       } else if (error.response?.status === 400) {
 73 |          throw new Error(`AppSamurai API request failed: Bad Request (Invalid Date Format?)`);
 74 |       } else if (error.response?.status === 404) {
 75 |          throw new Error(`AppSamurai API request failed: Not Found (No data matches filters?)`);
 76 |       } else {
 77 |         throw new Error(`AppSamurai API request failed: ${error.response?.status || error.message}`);
 78 |       }
 79 |     }
 80 |     throw new Error(`Failed to fetch data from AppSamurai API: ${error}`);
 81 |   }
 82 | }
 83 | 
 84 | // --- Tool Definition ---
 85 | server.tool(
 86 |   "get_appsamurai_campaign_spend", // Tool name
 87 |   "Get campaign spending data via AppSamurai Campaign Spend API.", // Tool description
 88 |   { // Input schema using Zod
 89 |     startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Start date in YYYY-MM-DD format"),
 90 |     endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("End date in YYYY-MM-DD format"),
 91 |     campaignId: z.string().optional().describe("Filter by specific campaign ID"),
 92 |     bundleId: z.string().optional().describe("Filter by specific application bundle ID"),
 93 |     platform: z.string().optional().describe("Filter by platform (e.g., ios, play)"),
 94 |     campaignName: z.string().optional().describe("Filter by campaign name"),
 95 |     country: z.string().optional().describe("Filter by country in ISO 3166-1 alpha-2 format (e.g., US, GB)"),
 96 |   },
 97 |   async ({ startDate, endDate, campaignId, bundleId, platform, campaignName, country }) => { // Tool execution logic
 98 |     try {
 99 |       const spendData = await fetchAppSamuraiData(
100 |         startDate,
101 |         endDate,
102 |         campaignId,
103 |         bundleId,
104 |         platform,
105 |         campaignName,
106 |         country
107 |       );
108 |       // Format the data nicely for the LLM/user
109 |       const formattedData = JSON.stringify(spendData, null, 2);
110 |       return {
111 |         content: [{
112 |           type: "text",
113 |           text: `Campaign spend data from ${startDate} to ${endDate}:\n\`\`\`json\n${formattedData}\n\`\`\``,
114 |         }],
115 |       };
116 |     } catch (error: unknown) {
117 |       const errorMessage = error instanceof Error ? error.message : "An unknown error occurred while fetching campaign spend.";
118 |       console.error("Error in get_campaign_spend tool:", errorMessage);
119 |       return {
120 |         content: [{ type: "text", text: `Error fetching campaign spend: ${errorMessage}` }],
121 |         isError: true, // Indicate that the tool execution resulted in an error
122 |       };
123 |     }
124 |   }
125 | );
126 | 
127 | // --- Prompt Definition ---
128 | server.prompt(
129 |   "check_appsamurai_campaign_spend", // Prompt name
130 |   "Check campaign spending for a specific period through the AppSamurai Campaign Spend API.", // Prompt description
131 |   { // Argument schema using Zod
132 |     startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Start date (YYYY-MM-DD)"),
133 |     endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("End date (YYYY-MM-DD)"),
134 |     campaignId: z.string().optional().describe("Filter by specific campaign ID"),
135 |     bundleId: z.string().optional().describe("Filter by specific application bundle ID"),
136 |     platform: z.string().optional().describe("Filter by platform (e.g., ios, play)"),
137 |     campaignName: z.string().optional().describe("Filter by campaign name"),
138 |     country: z.string().optional().describe("Filter by country in ISO 3166-1 alpha-2 format (e.g., US, GB)"),
139 |   },
140 |   ({ startDate, endDate, campaignId, bundleId, platform, campaignName, country }) => {
141 |     // Build filters text based on provided optional parameters
142 |     const filters = [];
143 |     if (campaignId) filters.push(`campaign ID: ${campaignId}`);
144 |     if (bundleId) filters.push(`bundle ID: ${bundleId}`);
145 |     if (platform) filters.push(`platform: ${platform}`);
146 |     if (campaignName) filters.push(`campaign name: ${campaignName}`);
147 |     if (country) filters.push(`country: ${country}`);
148 | 
149 |     const filtersText = filters.length > 0
150 |       ? ` with filters: ${filters.join(', ')}`
151 |       : '';
152 | 
153 |     return {
154 |       messages: [{
155 |         role: "user",
156 |         content: {
157 |           type: "text",
158 |           text: `Please retrieve and summarize the AppSamurai campaign spend data from ${startDate} to ${endDate}${filtersText}.`,
159 |         }
160 |       }],
161 |     };
162 |   }
163 | );
164 | 
165 | 
166 | // --- Run the Server ---
167 | async function main() {
168 |   const transport = new StdioServerTransport();
169 |   try {
170 |     await server.connect(transport);
171 |     console.error("AppSamurai Reporting MCP Server running on stdio...");
172 |   } catch (error) {
173 |     console.error("Failed to start AppSamurai Reporting MCP Server:", error);
174 |     process.exit(1);
175 |   }
176 | }
177 | 
178 | main();
179 | 
```

--------------------------------------------------------------------------------
/src/ironsource-aura-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 | 
  9 | dotenv.config();
 10 | 
 11 | const server = new McpServer({
 12 |   name: "IronSource Aura Reporting MCP Server",
 13 |   version: "0.0.1"
 14 | });
 15 | 
 16 | const IRONSOURCE_AURA_API_BASE_URL = process.env.IRONSOURCE_AURA_API_BASE_URL || "";
 17 | const IRONSOURCE_AURA_API_KEY = process.env.IRONSOURCE_AURA_API_KEY || '';
 18 | 
 19 | if (!IRONSOURCE_AURA_API_KEY) {
 20 |   console.error("Missing IronSource Aura API credentials. Please set IRONSOURCE_AURA_API_KEY environment variable.");
 21 |   process.exit(1);
 22 | }
 23 | 
 24 | /**
 25 |  * Make request to IronSource Aura Reporting API with retry mechanism
 26 |  */
 27 | async function fetchIronSourceAuraReport(params: Record<string, string>, maxRetries = 3, baseInterval = 3000): Promise<any> {
 28 |   let attempt = 0;
 29 | 
 30 |   while (attempt < maxRetries) {
 31 |     try {
 32 |       console.error(`Requesting IronSource Aura report with params: ${JSON.stringify(params)}`);
 33 |       const queryParams = new URLSearchParams(params);
 34 |       const reportUrl = `${IRONSOURCE_AURA_API_BASE_URL}?${queryParams.toString()}`;
 35 | 
 36 |       const response = await fetch(reportUrl, {
 37 |         method: 'GET',
 38 |         headers: {
 39 |           'Accept': 'application/json; */*',
 40 |           'Authorization': IRONSOURCE_AURA_API_KEY
 41 |         }
 42 |       });
 43 | 
 44 |       if (!response.ok) {
 45 |         const errorBody = await response.text();
 46 |         console.error(`IronSource Aura API Error Response: ${response.status} ${response.statusText} - ${errorBody}`);
 47 |         throw new Error(`API request failed: ${response.status} ${response.statusText}`);
 48 |       }
 49 | 
 50 |       const contentType = response.headers.get('content-type');
 51 |       if (contentType && contentType.includes('application/json')) {
 52 |         const data = await response.json();
 53 |         return data.data || data; // Return data.data if it exists (matching Ruby implementation)
 54 |       } else {
 55 |         // If response is CSV or other format
 56 |         return await response.text();
 57 |       }
 58 |     } catch (error: any) {
 59 |       attempt++;
 60 |       if (attempt >= maxRetries) {
 61 |         console.error(`Error fetching IronSource Aura report after ${maxRetries} attempts:`, error);
 62 |         throw new Error(`Failed to get IronSource Aura report: ${error.message}`);
 63 |       }
 64 | 
 65 |       // Exponential backoff with jitter
 66 |       const delay = baseInterval * Math.pow(2, attempt - 1) * (0.5 + Math.random() * 0.5);
 67 |       console.error(`Retrying in ${Math.round(delay / 1000)} seconds... (Attempt ${attempt} of ${maxRetries})`);
 68 |       await new Promise(resolve => setTimeout(resolve, delay));
 69 |     }
 70 |   }
 71 | 
 72 |   throw new Error("Failed to fetch IronSource Aura report after maximum retries");
 73 | }
 74 | 
 75 | // Tool: Get Advertiser Report
 76 | server.tool("get_advertiser_report_from_aura",
 77 |   "Get campaign spending data from Aura(IronSource) Reporting API for advertisers.",
 78 |   {
 79 |     startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Start date must be in YYYY-MM-DD format").describe("Start date for the report (YYYY-MM-DD)"),
 80 |     endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "End date must be in YYYY-MM-DD format").describe("End date for the report (YYYY-MM-DD)"),
 81 |     metrics: z.string().optional().default("impressions,clicks,completions,installs,spend").describe("Comma-separated list of metrics to include (default: 'impressions,clicks,completions,installs,spend')"),
 82 |     breakdowns: z.string().optional().default("day,campaign_name").describe("Comma-separated list of breakdowns (default: 'day,campaign_name')"),
 83 |     format: z.enum(["json", "csv"]).default("json").describe("Format of the report data"),
 84 |     count: z.number().optional().describe("Number of records to return (default: 10000, max: 250000)"),
 85 |     campaignId: z.string().optional().describe("Filter by comma-separated list of campaign IDs"),
 86 |     bundleId: z.string().optional().describe("Filter by comma-separated list of bundle IDs"),
 87 |     creativeId: z.string().optional().describe("Filter by comma-separated list of creative IDs"),
 88 |     country: z.string().optional().describe("Filter by comma-separated list of countries (ISO 3166-2)"),
 89 |     os: z.enum(["ios", "android"]).optional().describe("Filter by operating system (ios or android)"),
 90 |     deviceType: z.enum(["phone", "tablet"]).optional().describe("Filter by device type"),
 91 |     adUnit: z.string().optional().describe("Filter by ad unit type (e.g., 'rewardedVideo,interstitial')"),
 92 |     order: z.string().optional().describe("Order results by breakdown/metric"),
 93 |     direction: z.enum(["asc", "desc"]).optional().default("asc").describe("Order direction (asc or desc)")
 94 | }, async ({ startDate, endDate, metrics, breakdowns, format, count, campaignId, bundleId, creativeId, country, os, deviceType, adUnit, order, direction }) => {
 95 |   try {
 96 |     // Validate date range logic
 97 |     if (new Date(startDate) > new Date(endDate)) {
 98 |       throw new Error("Start date cannot be after end date.");
 99 |     }
100 | 
101 |     // Build parameters with defaults matching the Ruby implementation
102 |     const params: Record<string, string> = {
103 |       start_date: startDate,    // Change to snake_case to match Ruby implementation
104 |       end_date: endDate,        // Change to snake_case to match Ruby implementation
105 |       metrics: metrics || "impressions,clicks,completions,installs,spend",
106 |       breakdowns: breakdowns || "day,campaign_name",  // Default to campaign_name instead of campaign
107 |       format: format || "json"
108 |     };
109 | 
110 |     // Add optional parameters if provided
111 |     if (count) params.count = count.toString();
112 |     if (campaignId) params.campaignId = campaignId;
113 |     if (bundleId) params.bundleId = bundleId;
114 |     if (creativeId) params.creativeId = creativeId;
115 |     if (country) params.country = country;
116 |     if (os) params.os = os;
117 |     if (deviceType) params.deviceType = deviceType;
118 |     if (adUnit) params.adUnit = adUnit;
119 |     if (order) params.order = order;
120 |     if (direction) params.direction = direction;
121 | 
122 |     const data = await fetchIronSourceAuraReport(params);
123 | 
124 |     return {
125 |       content: [
126 |         {
127 |           type: "text",
128 |           text: typeof data === 'string' ? data : JSON.stringify(data, null, 2)
129 |         }
130 |       ]
131 |     };
132 |   } catch (error: any) {
133 |     const errorMessage = `Error getting IronSource Aura advertiser report: ${error.message}`;
134 | 
135 |     return {
136 |       content: [
137 |         {
138 |           type: "text",
139 |           text: errorMessage
140 |         }
141 |       ],
142 |       isError: true
143 |     };
144 |   }
145 | });
146 | 
147 | // Start server
148 | async function runServer() {
149 |   const transport = new StdioServerTransport();
150 |   await server.connect(transport);
151 |   console.error("IronSource Aura Reporting MCP Server running on stdio");
152 | }
153 | 
154 | runServer().catch((error) => {
155 |   console.error("Fatal error running server:", error);
156 |   process.exit(1);
157 | });
158 | 
```

--------------------------------------------------------------------------------
/src/github-issues/common/types.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { z } from "zod";
  2 | 
  3 | // Base schemas for common types
  4 | export const GitHubAuthorSchema = z.object({
  5 |   name: z.string(),
  6 |   email: z.string(),
  7 |   date: z.string(),
  8 | });
  9 | 
 10 | export const GitHubOwnerSchema = z.object({
 11 |   login: z.string(),
 12 |   id: z.number(),
 13 |   node_id: z.string(),
 14 |   avatar_url: z.string(),
 15 |   url: z.string(),
 16 |   html_url: z.string(),
 17 |   type: z.string(),
 18 | });
 19 | 
 20 | export const GitHubRepositorySchema = z.object({
 21 |   id: z.number(),
 22 |   node_id: z.string(),
 23 |   name: z.string(),
 24 |   full_name: z.string(),
 25 |   private: z.boolean(),
 26 |   owner: GitHubOwnerSchema,
 27 |   html_url: z.string(),
 28 |   description: z.string().nullable(),
 29 |   fork: z.boolean(),
 30 |   url: z.string(),
 31 |   created_at: z.string(),
 32 |   updated_at: z.string(),
 33 |   pushed_at: z.string(),
 34 |   git_url: z.string(),
 35 |   ssh_url: z.string(),
 36 |   clone_url: z.string(),
 37 |   default_branch: z.string(),
 38 | });
 39 | 
 40 | export const GithubFileContentLinks = z.object({
 41 |   self: z.string(),
 42 |   git: z.string().nullable(),
 43 |   html: z.string().nullable()
 44 | });
 45 | 
 46 | export const GitHubFileContentSchema = z.object({
 47 |   name: z.string(),
 48 |   path: z.string(),
 49 |   sha: z.string(),
 50 |   size: z.number(),
 51 |   url: z.string(),
 52 |   html_url: z.string(),
 53 |   git_url: z.string(),
 54 |   download_url: z.string(),
 55 |   type: z.string(),
 56 |   content: z.string().optional(),
 57 |   encoding: z.string().optional(),
 58 |   _links: GithubFileContentLinks
 59 | });
 60 | 
 61 | export const GitHubDirectoryContentSchema = z.object({
 62 |   type: z.string(),
 63 |   size: z.number(),
 64 |   name: z.string(),
 65 |   path: z.string(),
 66 |   sha: z.string(),
 67 |   url: z.string(),
 68 |   git_url: z.string(),
 69 |   html_url: z.string(),
 70 |   download_url: z.string().nullable(),
 71 | });
 72 | 
 73 | export const GitHubContentSchema = z.union([
 74 |   GitHubFileContentSchema,
 75 |   z.array(GitHubDirectoryContentSchema),
 76 | ]);
 77 | 
 78 | export const GitHubTreeEntrySchema = z.object({
 79 |   path: z.string(),
 80 |   mode: z.enum(["100644", "100755", "040000", "160000", "120000"]),
 81 |   type: z.enum(["blob", "tree", "commit"]),
 82 |   size: z.number().optional(),
 83 |   sha: z.string(),
 84 |   url: z.string(),
 85 | });
 86 | 
 87 | export const GitHubTreeSchema = z.object({
 88 |   sha: z.string(),
 89 |   url: z.string(),
 90 |   tree: z.array(GitHubTreeEntrySchema),
 91 |   truncated: z.boolean(),
 92 | });
 93 | 
 94 | export const GitHubCommitSchema = z.object({
 95 |   sha: z.string(),
 96 |   node_id: z.string(),
 97 |   url: z.string(),
 98 |   author: GitHubAuthorSchema,
 99 |   committer: GitHubAuthorSchema,
100 |   message: z.string(),
101 |   tree: z.object({
102 |     sha: z.string(),
103 |     url: z.string(),
104 |   }),
105 |   parents: z.array(
106 |     z.object({
107 |       sha: z.string(),
108 |       url: z.string(),
109 |     })
110 |   ),
111 | });
112 | 
113 | export const GitHubListCommitsSchema = z.array(z.object({
114 |   sha: z.string(),
115 |   node_id: z.string(),
116 |   commit: z.object({
117 |     author: GitHubAuthorSchema,
118 |     committer: GitHubAuthorSchema,
119 |     message: z.string(),
120 |     tree: z.object({
121 |       sha: z.string(),
122 |       url: z.string()
123 |     }),
124 |     url: z.string(),
125 |     comment_count: z.number(),
126 |   }),
127 |   url: z.string(),
128 |   html_url: z.string(),
129 |   comments_url: z.string()
130 | }));
131 | 
132 | export const GitHubReferenceSchema = z.object({
133 |   ref: z.string(),
134 |   node_id: z.string(),
135 |   url: z.string(),
136 |   object: z.object({
137 |     sha: z.string(),
138 |     type: z.string(),
139 |     url: z.string(),
140 |   }),
141 | });
142 | 
143 | // User and assignee schemas
144 | export const GitHubIssueAssigneeSchema = z.object({
145 |   login: z.string(),
146 |   id: z.number(),
147 |   avatar_url: z.string(),
148 |   url: z.string(),
149 |   html_url: z.string(),
150 | });
151 | 
152 | // Issue-related schemas
153 | export const GitHubLabelSchema = z.object({
154 |   id: z.number(),
155 |   node_id: z.string(),
156 |   url: z.string(),
157 |   name: z.string(),
158 |   color: z.string(),
159 |   default: z.boolean(),
160 |   description: z.string().nullable().optional(),
161 | });
162 | 
163 | export const GitHubMilestoneSchema = z.object({
164 |   url: z.string(),
165 |   html_url: z.string(),
166 |   labels_url: z.string(),
167 |   id: z.number(),
168 |   node_id: z.string(),
169 |   number: z.number(),
170 |   title: z.string(),
171 |   description: z.string(),
172 |   state: z.string(),
173 | });
174 | 
175 | export const GitHubIssueSchema = z.object({
176 |   url: z.string(),
177 |   repository_url: z.string(),
178 |   labels_url: z.string(),
179 |   comments_url: z.string(),
180 |   events_url: z.string(),
181 |   html_url: z.string(),
182 |   id: z.number(),
183 |   node_id: z.string(),
184 |   number: z.number(),
185 |   title: z.string(),
186 |   user: GitHubIssueAssigneeSchema,
187 |   labels: z.array(GitHubLabelSchema),
188 |   state: z.string(),
189 |   locked: z.boolean(),
190 |   assignee: GitHubIssueAssigneeSchema.nullable(),
191 |   assignees: z.array(GitHubIssueAssigneeSchema),
192 |   milestone: GitHubMilestoneSchema.nullable(),
193 |   comments: z.number(),
194 |   created_at: z.string(),
195 |   updated_at: z.string(),
196 |   closed_at: z.string().nullable(),
197 |   body: z.string().nullable(),
198 | });
199 | 
200 | // Search-related schemas
201 | export const GitHubSearchResponseSchema = z.object({
202 |   total_count: z.number(),
203 |   incomplete_results: z.boolean(),
204 |   items: z.array(GitHubRepositorySchema),
205 | });
206 | 
207 | // Pull request schemas
208 | export const GitHubPullRequestRefSchema = z.object({
209 |   label: z.string(),
210 |   ref: z.string(),
211 |   sha: z.string(),
212 |   user: GitHubIssueAssigneeSchema,
213 |   repo: GitHubRepositorySchema,
214 | });
215 | 
216 | export const GitHubPullRequestSchema = z.object({
217 |   url: z.string(),
218 |   id: z.number(),
219 |   node_id: z.string(),
220 |   html_url: z.string(),
221 |   diff_url: z.string(),
222 |   patch_url: z.string(),
223 |   issue_url: z.string(),
224 |   number: z.number(),
225 |   state: z.string(),
226 |   locked: z.boolean(),
227 |   title: z.string(),
228 |   user: GitHubIssueAssigneeSchema,
229 |   body: z.string().nullable(),
230 |   created_at: z.string(),
231 |   updated_at: z.string(),
232 |   closed_at: z.string().nullable(),
233 |   merged_at: z.string().nullable(),
234 |   merge_commit_sha: z.string().nullable(),
235 |   assignee: GitHubIssueAssigneeSchema.nullable(),
236 |   assignees: z.array(GitHubIssueAssigneeSchema),
237 |   requested_reviewers: z.array(GitHubIssueAssigneeSchema),
238 |   labels: z.array(GitHubLabelSchema),
239 |   head: GitHubPullRequestRefSchema,
240 |   base: GitHubPullRequestRefSchema,
241 | });
242 | 
243 | // Export types
244 | export type GitHubAuthor = z.infer<typeof GitHubAuthorSchema>;
245 | export type GitHubRepository = z.infer<typeof GitHubRepositorySchema>;
246 | export type GitHubFileContent = z.infer<typeof GitHubFileContentSchema>;
247 | export type GitHubDirectoryContent = z.infer<typeof GitHubDirectoryContentSchema>;
248 | export type GitHubContent = z.infer<typeof GitHubContentSchema>;
249 | export type GitHubTree = z.infer<typeof GitHubTreeSchema>;
250 | export type GitHubCommit = z.infer<typeof GitHubCommitSchema>;
251 | export type GitHubListCommits = z.infer<typeof GitHubListCommitsSchema>;
252 | export type GitHubReference = z.infer<typeof GitHubReferenceSchema>;
253 | export type GitHubIssueAssignee = z.infer<typeof GitHubIssueAssigneeSchema>;
254 | export type GitHubLabel = z.infer<typeof GitHubLabelSchema>;
255 | export type GitHubMilestone = z.infer<typeof GitHubMilestoneSchema>;
256 | export type GitHubIssue = z.infer<typeof GitHubIssueSchema>;
257 | export type GitHubSearchResponse = z.infer<typeof GitHubSearchResponseSchema>;
258 | export type GitHubPullRequest = z.infer<typeof GitHubPullRequestSchema>;
259 | export type GitHubPullRequestRef = z.infer<typeof GitHubPullRequestRefSchema>;
```

--------------------------------------------------------------------------------
/src/ironsource-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 | 
  9 | dotenv.config();
 10 | 
 11 | const server = new McpServer({
 12 |   name: "IronSource Reporting MCP Server",
 13 |   version: "0.0.1"
 14 | });
 15 | 
 16 | const IRONSOURCE_API_BASE_URL = "https://api.ironsrc.com/advertisers/v2/reports";
 17 | const IRONSOURCE_AUTH_URL = "https://platform.ironsrc.com/partners/publisher/auth";
 18 | const IRONSOURCE_SECRET_KEY = process.env.IRONSOURCE_SECRET_KEY || '';
 19 | const IRONSOURCE_REFRESH_TOKEN = process.env.IRONSOURCE_REFRESH_TOKEN || '';
 20 | 
 21 | let bearerToken = '';
 22 | let tokenExpirationTime = 0;
 23 | 
 24 | if (!IRONSOURCE_SECRET_KEY || !IRONSOURCE_REFRESH_TOKEN) {
 25 |   console.error("Missing IronSource API credentials. Please set IRONSOURCE_SECRET_KEY and IRONSOURCE_REFRESH_TOKEN environment variables.");
 26 |   process.exit(1);
 27 | }
 28 | 
 29 | /**
 30 |  * Get a valid Bearer token for IronSource API
 31 |  */
 32 | async function getIronSourceBearerToken(): Promise<string> {
 33 |   // Check if token is still valid (with 5 min buffer)
 34 |   const now = Math.floor(Date.now() / 1000);
 35 |   if (bearerToken && tokenExpirationTime > now + 300) {
 36 |     return bearerToken;
 37 |   }
 38 | 
 39 |   try {
 40 |     console.error('Fetching new IronSource bearer token');
 41 |     const response = await fetch(IRONSOURCE_AUTH_URL, {
 42 |       method: 'GET',
 43 |       headers: {
 44 |         'secretkey': IRONSOURCE_SECRET_KEY,
 45 |         'refreshToken': IRONSOURCE_REFRESH_TOKEN
 46 |       }
 47 |     });
 48 | 
 49 |     if (!response.ok) {
 50 |       const errorBody = await response.text();
 51 |       console.error(`IronSource Auth Error: ${response.status} ${response.statusText} - ${errorBody}`);
 52 |       throw new Error(`Auth failed: ${response.status} ${response.statusText}`);
 53 |     }
 54 | 
 55 |     const token = await response.text();
 56 |     // Remove quotes if they exist in the response
 57 |     bearerToken = token.replace(/"/g, '');
 58 | 
 59 |     // Set expiration time (60 minutes from now)
 60 |     tokenExpirationTime = now + 3600;
 61 | 
 62 |     console.error('Successfully obtained IronSource bearer token');
 63 |     return bearerToken;
 64 |   } catch (error: any) {
 65 |     console.error('Error obtaining IronSource bearer token:', error);
 66 |     throw new Error(`Failed to get authentication token: ${error.message}`);
 67 |   }
 68 | }
 69 | 
 70 | /**
 71 |  * Make request to IronSource Reporting API
 72 |  */
 73 | async function fetchIronSourceReport(params: Record<string, string>) {
 74 |   // Get a valid token
 75 |   const token = await getIronSourceBearerToken();
 76 | 
 77 |   // Construct URL with query parameters
 78 |   const queryParams = new URLSearchParams(params);
 79 |   const reportUrl = `${IRONSOURCE_API_BASE_URL}?${queryParams.toString()}`;
 80 | 
 81 |   try {
 82 |     console.error(`Requesting IronSource report with params: ${JSON.stringify(params)}`);
 83 |     const response = await fetch(reportUrl, {
 84 |       method: 'GET',
 85 |       headers: {
 86 |         'Accept': 'application/json; */*',
 87 |         'Authorization': `Bearer ${token}`
 88 |       }
 89 |     });
 90 | 
 91 |     if (!response.ok) {
 92 |       const errorBody = await response.text();
 93 |       console.error(`IronSource API Error Response: ${response.status} ${response.statusText} - ${errorBody}`);
 94 |       throw new Error(`API request failed: ${response.status} ${response.statusText}`);
 95 |     }
 96 | 
 97 |     const contentType = response.headers.get('content-type');
 98 |     if (contentType && contentType.includes('application/json')) {
 99 |       return await response.json();
100 |     } else {
101 |       // If response is CSV or other format
102 |       return await response.text();
103 |     }
104 |   } catch (error: any) {
105 |      console.error(`Error fetching IronSource report:`, error);
106 |      throw new Error(`Failed to get IronSource report: ${error.message}`);
107 |   }
108 | }
109 | 
110 | // Tool: Get Advertiser Report
111 | server.tool("get_advertiser_report_from_ironsource",
112 |   "Get campaign spending data from IronSource Reporting API for advertisers.",
113 |   {
114 |     startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Start date must be in YYYY-MM-DD format").describe("Start date for the report (YYYY-MM-DD)"),
115 |     endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "End date must be in YYYY-MM-DD format").describe("End date for the report (YYYY-MM-DD)"),
116 |     metrics: z.string().optional().default("impressions,clicks,completions,installs,spend").describe("Comma-separated list of metrics to include (default: 'impressions,clicks,completions,installs,spend')"),
117 |     breakdowns: z.string().optional().default("day,campaign").describe("Comma-separated list of breakdowns (default: 'day,campaign')"),
118 |     format: z.enum(["json", "csv"]).default("json").describe("Format of the report data"),
119 |     count: z.number().optional().describe("Number of records to return (default: 10000, max: 250000)"),
120 |     campaignId: z.string().optional().describe("Filter by comma-separated list of campaign IDs"),
121 |     bundleId: z.string().optional().describe("Filter by comma-separated list of bundle IDs"),
122 |     creativeId: z.string().optional().describe("Filter by comma-separated list of creative IDs"),
123 |     country: z.string().optional().describe("Filter by comma-separated list of countries (ISO 3166-2)"),
124 |     os: z.enum(["ios", "android"]).optional().describe("Filter by operating system (ios or android)"),
125 |     deviceType: z.enum(["phone", "tablet"]).optional().describe("Filter by device type"),
126 |     adUnit: z.string().optional().describe("Filter by ad unit type (e.g., 'rewardedVideo,interstitial')"),
127 |     order: z.string().optional().describe("Order results by breakdown/metric"),
128 |     direction: z.enum(["asc", "desc"]).optional().default("asc").describe("Order direction (asc or desc)")
129 | }, async ({ startDate, endDate, metrics, breakdowns, format, count, campaignId, bundleId, creativeId, country, os, deviceType, adUnit, order, direction }) => {
130 |   try {
131 |     // Validate date range logic
132 |     if (new Date(startDate) > new Date(endDate)) {
133 |       throw new Error("Start date cannot be after end date.");
134 |     }
135 | 
136 |     // Build parameters with defaults matching the example query
137 |     const params: Record<string, string> = {
138 |       startDate,
139 |       endDate,
140 |       metrics: metrics || "impressions,clicks,completions,installs,spend",
141 |       breakdowns: breakdowns || "day,campaign",
142 |       format: format || "json"
143 |     };
144 | 
145 |     // Add optional parameters if provided
146 |     if (count) params.count = count.toString();
147 |     if (campaignId) params.campaignId = campaignId;
148 |     if (bundleId) params.bundleId = bundleId;
149 |     if (creativeId) params.creativeId = creativeId;
150 |     if (country) params.country = country;
151 |     if (os) params.os = os;
152 |     if (deviceType) params.deviceType = deviceType;
153 |     if (adUnit) params.adUnit = adUnit;
154 |     if (order) params.order = order;
155 |     if (direction) params.direction = direction;
156 | 
157 |     const data = await fetchIronSourceReport(params);
158 | 
159 |     return {
160 |       content: [
161 |         {
162 |           type: "text",
163 |           text: typeof data === 'string' ? data : JSON.stringify(data, null, 2)
164 |         }
165 |       ]
166 |     };
167 |   } catch (error: any) {
168 |     const errorMessage = `Error getting IronSource advertiser report: ${error.message}`;
169 | 
170 |     return {
171 |       content: [
172 |         {
173 |           type: "text",
174 |           text: errorMessage
175 |         }
176 |       ],
177 |       isError: true
178 |     };
179 |   }
180 | });
181 | 
182 | // Start server
183 | async function runServer() {
184 |   const transport = new StdioServerTransport();
185 |   await server.connect(transport);
186 |   console.error("IronSource Reporting MCP Server running on stdio");
187 | }
188 | 
189 | runServer().catch((error) => {
190 |   console.error("Fatal error running server:", error);
191 |   process.exit(1);
192 | });
193 | 
```

--------------------------------------------------------------------------------
/src/mintegral-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 crypto from "crypto";
  9 | 
 10 | dotenv.config();
 11 | 
 12 | // ============= Server Setup =============
 13 | const server = new McpServer({
 14 |   name: "mintegral-reporting",
 15 |   version: "0.0.4"
 16 | });
 17 | 
 18 | // ============= Mintegral Implementation =============
 19 | const MINTEGRAL_API_HOST = "ss-api.mintegral.com";
 20 | const MINTEGRAL_API_PATH = "/api/v1/reports/data";
 21 | const MINTEGRAL_ACCESS_KEY = process.env.MINTEGRAL_ACCESS_KEY || '';
 22 | const MINTEGRAL_API_KEY = process.env.MINTEGRAL_API_KEY || '';
 23 | 
 24 | // Check credentials on startup
 25 | if (!MINTEGRAL_ACCESS_KEY || !MINTEGRAL_API_KEY) {
 26 |   console.error("[Mintegral] Missing API credentials. Please set MINTEGRAL_ACCESS_KEY and MINTEGRAL_API_KEY environment variables.");
 27 |   process.exit(1);
 28 | }
 29 | 
 30 | /**
 31 |  * Get current timestamp in seconds (Unix timestamp)
 32 |  */
 33 | function getTimestamp(): number {
 34 |   return Math.floor(Date.now() / 1000);
 35 | }
 36 | 
 37 | /**
 38 |  * Generate token for Mintegral API authentication
 39 |  *
 40 |  * Token is Md5(API key.md5(timestamp)) as per documentation
 41 |  */
 42 | function getMintegralToken(timestamp: number): string {
 43 |   const timestampMd5 = crypto.createHash('md5').update(timestamp.toString()).digest('hex');
 44 |   const token = crypto.createHash('md5').update(MINTEGRAL_API_KEY + timestampMd5).digest('hex');
 45 |   return token;
 46 | }
 47 | 
 48 | /**
 49 |  * Validate Mintegral API parameters
 50 |  */
 51 | function validateMintegralParams(params: Record<string, any>): void {
 52 |   const { start_date, end_date } = params;
 53 | 
 54 |   // Check date format
 55 |   const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
 56 |   if (!dateRegex.test(start_date) || !dateRegex.test(end_date)) {
 57 |     throw new Error("Dates must be in YYYY-MM-DD format");
 58 |   }
 59 | 
 60 |   // Check date range
 61 |   const startDate = new Date(start_date);
 62 |   const endDate = new Date(end_date);
 63 | 
 64 |   if (startDate > endDate) {
 65 |     throw new Error("Start date cannot be after end date");
 66 |   }
 67 | 
 68 |   // Check if dates are valid
 69 |   if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
 70 |     throw new Error("Invalid date values");
 71 |   }
 72 | 
 73 |   // Check if date range exceeds 8 days as per API docs
 74 |   const dayDiff = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
 75 |   if (dayDiff > 8) {
 76 |     throw new Error("Date range cannot exceed 8 days for a single request");
 77 |   }
 78 | 
 79 |   // Check if per_page exceeds maximum
 80 |   if (params.per_page && params.per_page > 5000) {
 81 |     throw new Error("per_page cannot exceed 5000");
 82 |   }
 83 | }
 84 | 
 85 | /**
 86 |  * Make request to Mintegral Performance Reporting API
 87 |  */
 88 | async function fetchMintegralReport(params: Record<string, string>) {
 89 |   try {
 90 |     // Validate parameters
 91 |     validateMintegralParams(params);
 92 | 
 93 |     // Construct URL with query parameters
 94 |     const queryParams = new URLSearchParams(params);
 95 |     const reportUrl = `https://${MINTEGRAL_API_HOST}${MINTEGRAL_API_PATH}?${queryParams.toString()}`;
 96 | 
 97 |     // Generate authentication headers based on documentation at:
 98 |     // https://adv-new.mintegral.com/doc/en/guide/introduction/token.html
 99 |     const timestamp = getTimestamp();
100 |     const token = getMintegralToken(timestamp);
101 | 
102 |     console.error(`[Mintegral] Auth: timestamp=${timestamp}, token=${token}`);
103 |     console.error(`[Mintegral] Requesting report with params: ${JSON.stringify(params)}`);
104 | 
105 |     const response = await fetch(reportUrl, {
106 |       method: 'GET',
107 |       headers: {
108 |         'Accept': 'application/json',
109 |         'access-key': MINTEGRAL_ACCESS_KEY,
110 |         'token': token,
111 |         'timestamp': timestamp.toString()
112 |       }
113 |     });
114 | 
115 |     if (!response.ok) {
116 |       const errorBody = await response.text();
117 |       console.error(`[Mintegral] API Error Response: ${response.status} ${response.statusText} - ${errorBody}`);
118 |       throw new Error(`API request failed: ${response.status} ${response.statusText}`);
119 |     }
120 | 
121 |     const data = await response.json();
122 | 
123 |     // Check for API error codes in the response
124 |     if (data.code && data.code !== 200) {
125 |       console.error(`[Mintegral] API Error Code: ${data.code}, Message: ${data.msg || 'Unknown error'}`);
126 |       throw new Error(`API returned error code ${data.code}: ${data.msg || 'Unknown error'}`);
127 |     }
128 | 
129 |     return data;
130 |   } catch (error: any) {
131 |      console.error(`[Mintegral] Error fetching report:`, error);
132 |      throw new Error(`Failed to get Mintegral report: ${error.message}`);
133 |   }
134 | }
135 | 
136 | // Tool: Get Mintegral Performance Report
137 | server.tool("get_mintegral_performance_report",
138 |   "Get performance data from Mintegral Reporting API.",
139 |   {
140 |     start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Start date must be in YYYY-MM-DD format").describe("Start date for the report (YYYY-MM-DD)"),
141 |     end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "End date must be in YYYY-MM-DD format").describe("End date for the report (YYYY-MM-DD)"),
142 |     utc: z.string().optional().default("+8").describe("Timezone (default: '+8')"),
143 |     per_page: z.number().optional().default(50).describe("Number of results per page (max: 5000)"),
144 |     page: z.number().optional().default(1).describe("Page number"),
145 |     dimension: z.string().optional().describe("Data dimension (e.g., 'location', 'sub_id', 'creative')"),
146 |     uuid: z.string().optional().describe("Filter by uuid"),
147 |     campaign_id: z.number().optional().describe("Filter by campaign_id"),
148 |     package_name: z.string().optional().describe("Filter by android bundle id or ios app store id"),
149 |     not_empty_field: z.string().optional().describe("Fields that can't be empty (comma-separated: 'click', 'install', 'impression', 'spend')")
150 |   },
151 |   async (params) => {
152 |     try {
153 |       // Build parameters to match example request format
154 |       const apiParams: Record<string, string> = {};
155 | 
156 |       // Add parameters in the order shown in the example
157 |       apiParams.start_date = params.start_date;
158 |       apiParams.end_date = params.end_date;
159 |       if (params.per_page) apiParams.per_page = params.per_page.toString();
160 |       if (params.page) apiParams.page = params.page.toString();
161 |       if (params.utc) apiParams.utc = params.utc;
162 |       if (params.dimension) apiParams.dimension = params.dimension;
163 | 
164 |       // Add remaining optional parameters
165 |       if (params.uuid) apiParams.uuid = params.uuid;
166 |       if (params.campaign_id) apiParams.campaign_id = params.campaign_id.toString();
167 |       if (params.package_name) apiParams.package_name = params.package_name;
168 |       if (params.not_empty_field) apiParams.not_empty_field = params.not_empty_field;
169 | 
170 |       const data = await fetchMintegralReport(apiParams);
171 | 
172 |       return {
173 |         content: [
174 |           {
175 |             type: "text",
176 |             text: JSON.stringify(data, null, 2)
177 |           }
178 |         ]
179 |       };
180 |     } catch (error: any) {
181 |       const errorMessage = `Error getting Mintegral performance report: ${error.message}`;
182 | 
183 |       return {
184 |         content: [
185 |           {
186 |             type: "text",
187 |             text: errorMessage
188 |           }
189 |         ],
190 |         isError: true
191 |       };
192 |     }
193 |   }
194 | );
195 | 
196 | // ============= Server Startup =============
197 | async function runServer() {
198 |   try {
199 |     const transport = new StdioServerTransport();
200 |     await server.connect(transport);
201 |     console.error("[Server] mintegral-reporting MCP Server running on stdio");
202 |   } catch (error) {
203 |     console.error("[Server] Error during server startup:", error);
204 |     process.exit(1);
205 |   }
206 | }
207 | 
208 | runServer().catch((error) => {
209 |   console.error("[Server] Fatal error running server:", error);
210 |   process.exit(1);
211 | });
212 | 
```

--------------------------------------------------------------------------------
/src/femini-reporting/femini_mcp_guide.md:
--------------------------------------------------------------------------------

```markdown
  1 | # Femini Postgres Database Guide
  2 | 
  3 | This guide documents the Femini Postgres database schema and provides example queries for accessing campaign spend data via MCP (Model Context Protocol).
  4 | 
  5 | ## MCP Server Configuration
  6 | 
  7 | The Femini Postgres database is accessible via an MCP server with the following configuration:
  8 | 
  9 | ```json
 10 | {
 11 |   "command": "npx",
 12 |   "args": [
 13 |     "-y",
 14 |     "@modelcontextprotocol/server-postgres",
 15 |     "postgres://mcp_read:gL%218iujL%29l2kKrY9Hn%24kjIow@pg-femini-for-mcp.cgb5t3jqdx7r.us-east-1.rds.amazonaws.com/femini"
 16 |   ]
 17 | }
 18 | ```
 19 | 
 20 | ## Database Schema
 21 | 
 22 | ### Main Tables
 23 | 
 24 | #### campaign_spends
 25 | The primary table containing campaign spend data.
 26 | 
 27 | | Column | Data Type | Description |
 28 | |--------|-----------|-------------|
 29 | | id | bigint | Primary key |
 30 | | date | date | Date of the spend |
 31 | | spend | numeric | Amount spent |
 32 | | click_url_id | bigint | Reference to click_urls table |
 33 | | partner_id | bigint | Reference to partners table |
 34 | | client_id | bigint | Reference to clients table |
 35 | | spend_type | character varying | Type of spend (client or partner) |
 36 | | calculation_source | character varying | Source of calculation (imported or event_aggregate) |
 37 | | calculation_metadata | json | Additional metadata in JSON format |
 38 | | created_at | timestamp | Record creation timestamp |
 39 | | updated_at | timestamp | Record update timestamp |
 40 | 
 41 | #### campaigns
 42 | Contains information about marketing campaigns.
 43 | 
 44 | | Column | Data Type | Description |
 45 | |--------|-----------|-------------|
 46 | | id | bigint | Primary key |
 47 | | client_id | bigint | Reference to clients table |
 48 | | name | character varying | Campaign name |
 49 | | status | character varying | Campaign status |
 50 | | country_code | character varying | Country code for the campaign |
 51 | | mobile_app_id | bigint | Reference to mobile_apps table |
 52 | | legacy_id | bigint | Legacy ID reference |
 53 | | created_at | timestamp | Record creation timestamp |
 54 | | updated_at | timestamp | Record update timestamp |
 55 | 
 56 | #### clients
 57 | Contains information about clients.
 58 | 
 59 | | Column | Data Type | Description |
 60 | |--------|-----------|-------------|
 61 | | id | bigint | Primary key |
 62 | | name | character varying | Client name |
 63 | | website | character varying | Client website |
 64 | | is_test | boolean | Whether this is a test client |
 65 | | legacy_id | bigint | Legacy ID reference |
 66 | | created_at | timestamp | Record creation timestamp |
 67 | | updated_at | timestamp | Record update timestamp |
 68 | 
 69 | #### partners
 70 | Contains information about partners.
 71 | 
 72 | | Column | Data Type | Description |
 73 | |--------|-----------|-------------|
 74 | | id | bigint | Primary key |
 75 | | name | character varying | Partner name |
 76 | | email | character varying | Partner email |
 77 | | website | character varying | Partner website |
 78 | | description | text | Partner description |
 79 | | status | character varying | Partner status |
 80 | | is_test | boolean | Whether this is a test partner |
 81 | | legacy_id | bigint | Legacy ID reference |
 82 | | created_at | timestamp | Record creation timestamp |
 83 | | updated_at | timestamp | Record update timestamp |
 84 | 
 85 | #### click_urls
 86 | Contains information about click URLs.
 87 | 
 88 | | Column | Data Type | Description |
 89 | |--------|-----------|-------------|
 90 | | id | bigint | Primary key |
 91 | | campaign_id | bigint | Reference to campaigns table |
 92 | | partner_id | bigint | Reference to partners table |
 93 | | track_party_id | bigint | Reference to track_parties table |
 94 | | status | character varying | Click URL status |
 95 | | link_type | character varying | Type of link |
 96 | | external_track_party_campaign_id | character varying | External track party campaign ID |
 97 | | external_partner_campaign_id | character varying | External partner campaign ID |
 98 | | legacy_id | bigint | Legacy ID reference |
 99 | | created_at | timestamp | Record creation timestamp |
100 | | updated_at | timestamp | Record update timestamp |
101 | 
102 | ### Relationships
103 | 
104 | - `campaign_spends.client_id` → `clients.id`
105 | - `campaign_spends.partner_id` → `partners.id`
106 | - `campaign_spends.click_url_id` → `click_urls.id`
107 | - `click_urls.campaign_id` → `campaigns.id`
108 | - `click_urls.partner_id` → `partners.id`
109 | - `campaigns.client_id` → `clients.id`
110 | 
111 | ## Example Queries
112 | 
113 | ### 1. List All Tables
114 | 
115 | ```sql
116 | SELECT table_name 
117 | FROM information_schema.tables 
118 | WHERE table_schema = 'public' 
119 | ORDER BY table_name;
120 | ```
121 | 
122 | ### 2. Explore Table Structure
123 | 
124 | ```sql
125 | SELECT column_name, data_type 
126 | FROM information_schema.columns 
127 | WHERE table_name = 'campaign_spends' 
128 | ORDER BY ordinal_position;
129 | ```
130 | 
131 | ### 3. Basic Campaign Spend Data
132 | 
133 | ```sql
134 | SELECT cs.id, cs.date, cs.spend, cs.spend_type, 
135 |        c.name as client_name, p.name as partner_name 
136 | FROM campaign_spends cs
137 | JOIN clients c ON cs.client_id = c.id
138 | JOIN partners p ON cs.partner_id = p.id
139 | ORDER BY cs.date DESC
140 | LIMIT 10;
141 | ```
142 | 
143 | ### 4. Campaign Spend with Campaign Information
144 | 
145 | ```sql
146 | SELECT cs.id, cs.date, cs.spend, cs.spend_type, 
147 |        c.name as client_name, p.name as partner_name,
148 |        cam.name as campaign_name, cam.country_code
149 | FROM campaign_spends cs
150 | JOIN clients c ON cs.client_id = c.id
151 | JOIN partners p ON cs.partner_id = p.id
152 | LEFT JOIN click_urls cu ON cs.click_url_id = cu.id
153 | LEFT JOIN campaigns cam ON cu.campaign_id = cam.id
154 | ORDER BY cs.date DESC
155 | LIMIT 10;
156 | ```
157 | 
158 | ### 5. Aggregate Spend by Client and Partner
159 | 
160 | ```sql
161 | SELECT c.name as client_name, p.name as partner_name, 
162 |        cs.spend_type, SUM(cs.spend) as total_spend 
163 | FROM campaign_spends cs
164 | JOIN clients c ON cs.client_id = c.id
165 | JOIN partners p ON cs.partner_id = p.id
166 | WHERE cs.date >= '2025-04-01' AND cs.date <= '2025-04-30'
167 | GROUP BY c.name, p.name, cs.spend_type
168 | ORDER BY total_spend DESC
169 | LIMIT 20;
170 | ```
171 | 
172 | ### 6. Daily Spend Trends
173 | 
174 | ```sql
175 | SELECT date, SUM(spend) as total_spend, COUNT(*) as transaction_count 
176 | FROM campaign_spends 
177 | WHERE date >= '2025-04-01' AND date <= '2025-04-30'
178 | GROUP BY date 
179 | ORDER BY date;
180 | ```
181 | 
182 | ### 7. Spend by Country
183 | 
184 | ```sql
185 | SELECT cam.country_code, SUM(cs.spend) as total_spend 
186 | FROM campaign_spends cs
187 | JOIN click_urls cu ON cs.click_url_id = cu.id
188 | JOIN campaigns cam ON cu.campaign_id = cam.id
189 | WHERE cs.date >= '2025-04-01' AND cs.date <= '2025-04-30'
190 | GROUP BY cam.country_code
191 | ORDER BY total_spend DESC;
192 | ```
193 | 
194 | ### 8. Spend by Type
195 | 
196 | ```sql
197 | SELECT spend_type, SUM(spend) as total_spend 
198 | FROM campaign_spends 
199 | WHERE date >= '2025-04-01' AND date <= '2025-04-30'
200 | GROUP BY spend_type 
201 | ORDER BY total_spend DESC;
202 | ```
203 | 
204 | ### 9. Spend by Calculation Source
205 | 
206 | ```sql
207 | SELECT calculation_source, COUNT(*) as count, SUM(spend) as total_spend 
208 | FROM campaign_spends 
209 | WHERE date >= '2025-04-01' AND date <= '2025-04-30'
210 | GROUP BY calculation_source 
211 | ORDER BY total_spend DESC;
212 | ```
213 | 
214 | ## Using MCP to Query the Database
215 | 
216 | To query the Femini Postgres database using MCP in code:
217 | 
218 | ```javascript
219 | <use_mcp_tool>
220 | <server_name>postgres</server_name>
221 | <tool_name>query</tool_name>
222 | <arguments>
223 | {
224 |   "sql": "YOUR SQL QUERY HERE"
225 | }
226 | </arguments>
227 | </use_mcp_tool>
228 | ```
229 | 
230 | ## Query Optimization Tips
231 | 
232 | 1. **Use specific date ranges** to limit the amount of data processed
233 | 2. **Include appropriate JOINs** only when needed
234 | 3. **Use aggregation** (GROUP BY) for summary data
235 | 4. **Limit results** when retrieving large datasets
236 | 5. **Order results** only when necessary
237 | 6. **Select only needed columns** instead of using SELECT *
238 | 
239 | ## Common Analysis Tasks
240 | 
241 | 1. **Monthly spend analysis**: Filter by date range and group by client, partner, or campaign
242 | 2. **Geographic performance**: Group by country_code to analyze regional performance
243 | 3. **Client/Partner comparison**: Compare spend and performance across different clients or partners
244 | 4. **Trend analysis**: Group by date to analyze spend trends over time
245 | 5. **Spend type analysis**: Compare client vs. partner spend
246 | 
```

--------------------------------------------------------------------------------
/src/civitai-records/src/tools/listCivitaiPosts.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import type { ContentResult } from "fastmcp";
  2 | import { z } from "zod";
  3 | import { prisma } from "../lib/prisma.js";
  4 | 
  5 | export const listCivitaiPostsParameters = z.object({
  6 |   id: z
  7 |     .string()
  8 |     .nullable()
  9 |     .default(null)
 10 |     .describe("Filter by the civitai_posts table ID."),
 11 |   civitai_id: z
 12 |     .string()
 13 |     .nullable()
 14 |     .default(null)
 15 |     .describe("Filter posts by Civitai ID. The numeric post ID from the Civitai post URL. Extract this from URLs like https://civitai.com/posts/23602354 where the ID is 23602354."),
 16 |   status: z
 17 |     .enum(["pending", "published", "failed"])
 18 |     .nullable()
 19 |     .default(null)
 20 |     .describe("Filter posts by status: 'pending', 'published', or 'failed'."),
 21 |   created_by: z
 22 |     .string()
 23 |     .nullable()
 24 |     .default(null)
 25 |     .transform((val) => val?.toLowerCase() ?? null)
 26 |     .describe("Filter posts by creator name. Default is null to load records created by all users."),
 27 |   on_behalf_of: z
 28 |     .string()
 29 |     .nullable()
 30 |     .default(null)
 31 |     .transform((val) => val?.toLowerCase() ?? null)
 32 |     .describe("Filter posts by the user account this action was performed on behalf of. Default is null to load records regardless of on_behalf_of value."),
 33 |   start_time: z
 34 |     .string()
 35 |     .nullable()
 36 |     .default(null)
 37 |     .describe("Filter posts created on or after this timestamp. Provide as ISO 8601 format (e.g., '2025-01-15T10:00:00Z')."),
 38 |   end_time: z
 39 |     .string()
 40 |     .nullable()
 41 |     .default(null)
 42 |     .describe("Filter posts created on or before this timestamp. Provide as ISO 8601 format (e.g., '2025-01-15T23:59:59Z')."),
 43 |   include_details: z
 44 |     .boolean()
 45 |     .default(false)
 46 |     .describe("If true, include related asset and prompt details in the response. Default is false for lightweight responses."),
 47 |   limit: z
 48 |     .number()
 49 |     .int()
 50 |     .min(1)
 51 |     .max(100)
 52 |     .default(50)
 53 |     .describe("Maximum number of posts to return. Default is 50, maximum is 100."),
 54 |   offset: z
 55 |     .number()
 56 |     .int()
 57 |     .min(0)
 58 |     .default(0)
 59 |     .describe("Number of posts to skip for pagination. Default is 0."),
 60 | });
 61 | 
 62 | export type ListCivitaiPostsParameters = z.infer<typeof listCivitaiPostsParameters>;
 63 | 
 64 | interface WhereClauseParams {
 65 |   id: string | null;
 66 |   civitai_id: string | null;
 67 |   status: "pending" | "published" | "failed" | null;
 68 |   created_by: string | null;
 69 |   on_behalf_of: string | null;
 70 |   start_time: string | null;
 71 |   end_time: string | null;
 72 | }
 73 | 
 74 | function buildWhereClause(params: WhereClauseParams): any {
 75 |   const where: any = {};
 76 | 
 77 |   if (params.id) {
 78 |     where.id = BigInt(params.id);
 79 |   }
 80 | 
 81 |   if (params.civitai_id) {
 82 |     where.civitai_id = params.civitai_id;
 83 |   }
 84 | 
 85 |   if (params.status) {
 86 |     where.status = params.status;
 87 |   }
 88 | 
 89 |   if (params.created_by) {
 90 |     where.created_by = params.created_by;
 91 |   }
 92 | 
 93 |   if (params.on_behalf_of) {
 94 |     where.on_behalf_of = params.on_behalf_of;
 95 |   }
 96 | 
 97 |   if (params.start_time || params.end_time) {
 98 |     where.created_at = {};
 99 |     if (params.start_time) {
100 |       try {
101 |         where.created_at.gte = new Date(params.start_time);
102 |       } catch (error) {
103 |         throw new Error("Invalid start_time format. Use ISO 8601 format (e.g., '2025-01-15T10:00:00Z').");
104 |       }
105 |     }
106 |     if (params.end_time) {
107 |       try {
108 |         where.created_at.lte = new Date(params.end_time);
109 |       } catch (error) {
110 |         throw new Error("Invalid end_time format. Use ISO 8601 format (e.g., '2025-01-15T23:59:59Z').");
111 |       }
112 |     }
113 |   }
114 | 
115 |   return where;
116 | }
117 | 
118 | 
119 | 
120 | function serializePost(post: any, include_details: boolean): any {
121 |   const result: any = {
122 |     post_id: post.id.toString(),
123 |     civitai_id: post.civitai_id,
124 |     civitai_url: post.civitai_url,
125 |     status: post.status,
126 |     title: post.title,
127 |     description: post.description,
128 |     created_by: post.created_by,
129 |     metadata: post.metadata,
130 |     created_at: post.created_at.toISOString(),
131 |     updated_at: post.updated_at.toISOString(),
132 |   };
133 | 
134 |   if (include_details && post.assets && post.assets.length > 0) {
135 |     result.assets = post.assets.map((asset: any) => ({
136 |       asset_id: asset.id.toString(),
137 |       asset_type: asset.asset_type,
138 |       asset_source: asset.asset_source,
139 |       asset_url: asset.uri,
140 |       sha256sum: asset.sha256sum,
141 |       civitai_id: asset.civitai_id,
142 |       civitai_url: asset.civitai_url,
143 |       created_by: asset.created_by,
144 |       metadata: asset.metadata,
145 |       created_at: asset.created_at.toISOString(),
146 |       updated_at: asset.updated_at.toISOString(),
147 |       input_prompt: asset.prompts_assets_input_prompt_idToprompts ? {
148 |         prompt_id: asset.prompts_assets_input_prompt_idToprompts.id.toString(),
149 |         content: asset.prompts_assets_input_prompt_idToprompts.content,
150 |         llm_model_provider: asset.prompts_assets_input_prompt_idToprompts.llm_model_provider,
151 |         llm_model: asset.prompts_assets_input_prompt_idToprompts.llm_model,
152 |         purpose: asset.prompts_assets_input_prompt_idToprompts.purpose,
153 |         metadata: asset.prompts_assets_input_prompt_idToprompts.metadata,
154 |         created_by: asset.prompts_assets_input_prompt_idToprompts.created_by,
155 |         created_at: asset.prompts_assets_input_prompt_idToprompts.created_at.toISOString(),
156 |         updated_at: asset.prompts_assets_input_prompt_idToprompts.updated_at.toISOString(),
157 |       } : null,
158 |       output_prompt: asset.prompts_assets_output_prompt_idToprompts ? {
159 |         prompt_id: asset.prompts_assets_output_prompt_idToprompts.id.toString(),
160 |         content: asset.prompts_assets_output_prompt_idToprompts.content,
161 |         llm_model_provider: asset.prompts_assets_output_prompt_idToprompts.llm_model_provider,
162 |         llm_model: asset.prompts_assets_output_prompt_idToprompts.llm_model,
163 |         purpose: asset.prompts_assets_output_prompt_idToprompts.purpose,
164 |         metadata: asset.prompts_assets_output_prompt_idToprompts.metadata,
165 |         created_by: asset.prompts_assets_output_prompt_idToprompts.created_by,
166 |         created_at: asset.prompts_assets_output_prompt_idToprompts.created_at.toISOString(),
167 |         updated_at: asset.prompts_assets_output_prompt_idToprompts.updated_at.toISOString(),
168 |       } : null,
169 |     }));
170 |   }
171 | 
172 |   return result;
173 | }
174 | 
175 | export const listCivitaiPostsTool = {
176 |   name: "list_civitai_posts",
177 |   description: "Get a list of Civitai posts with optional filtering. Can filter by civitai_id, status, created_by, or time range. Supports pagination with limit and offset. Use include_details to get linked asset information.",
178 |   parameters: listCivitaiPostsParameters,
179 |   execute: async ({
180 |     id,
181 |     civitai_id,
182 |     status,
183 |     created_by,
184 |     on_behalf_of,
185 |     start_time,
186 |     end_time,
187 |     include_details,
188 |     limit,
189 |     offset,
190 |   }: ListCivitaiPostsParameters): Promise<ContentResult> => {
191 |     const where = buildWhereClause({
192 |       id,
193 |       civitai_id,
194 |       status,
195 |       created_by,
196 |       on_behalf_of,
197 |       start_time,
198 |       end_time,
199 |     });
200 | 
201 |     const posts = await prisma.civitai_posts.findMany({
202 |       where,
203 |       include: include_details ? {
204 |         assets: {
205 |           include: {
206 |             prompts_assets_input_prompt_idToprompts: true,
207 |             prompts_assets_output_prompt_idToprompts: true,
208 |           },
209 |         },
210 |       } : undefined,
211 |       take: limit,
212 |       skip: offset,
213 |       orderBy: {
214 |         created_at: 'desc',
215 |       },
216 |     });
217 | 
218 |     const serializedPosts = posts.map((post: any) => serializePost(post, include_details));
219 | 
220 |     return {
221 |       content: [
222 |         {
223 |           type: "text",
224 |           text: JSON.stringify({
225 |             posts: serializedPosts,
226 |             count: posts.length,
227 |             limit,
228 |             offset,
229 |           }, null, 2),
230 |         },
231 |       ],
232 |     } satisfies ContentResult;
233 |   },
234 | };
235 | 
```

--------------------------------------------------------------------------------
/src/civitai-records/src/prompts/civitai-media-engagement.md:
--------------------------------------------------------------------------------

```markdown
  1 | # Civitai Media Engagement Guide
  2 | 
  3 | This guide helps you find and analyze engagement metrics for Civitai media, especially videos, using the available tools.
  4 | 
  5 | ## Overview
  6 | 
  7 | Civitai posts contain media assets (images and videos) with engagement metrics including:
  8 | - **Reactions**: likes, hearts, laughs, cries, dislikes
  9 | - **Comments**: comment count
 10 | 
 11 | ## Tools for Finding Media Engagement
 12 | 
 13 | ### 1. `fetch_civitai_post_assets`
 14 | **Primary tool for getting live engagement data directly from Civitai.**
 15 | 
 16 | **Purpose**: Fetches real-time media assets and their engagement stats for a specific post without querying the local database.
 17 | 
 18 | **When to use**:
 19 | - You have a Civitai post URL and want to see current engagement
 20 | - You need up-to-date performance metrics
 21 | - You want to inspect video URLs and metadata
 22 | 
 23 | **Input**:
 24 | ```json
 25 | {
 26 |   "post_id": "23602354",
 27 |   "limit": 50,
 28 |   "page": 1
 29 | }
 30 | ```
 31 | 
 32 | **Output includes**:
 33 | - `civitai_image_id`: Unique identifier for each media asset
 34 | - `asset_url`: Direct download URL for the video/image
 35 | - `type`: Media type (e.g., "video", "image")
 36 | - `engagement_stats`:
 37 |   - `like`: Number of likes
 38 |   - `heart`: Number of hearts
 39 |   - `laugh`: Number of laughs
 40 |   - `cry`: Number of cries
 41 |   - `dislike`: Number of dislikes
 42 |   - `comment`: Number of comments
 43 | - `dimensions`: Width and height (when available)
 44 | - `created_at`: When the asset was uploaded
 45 | - `username`: Creator username
 46 | - `nsfw`: NSFW flag and level
 47 | 
 48 | **Example workflow**:
 49 | ```json
 50 | {
 51 |   "post_id": "23602354"
 52 | }
 53 | ```
 54 | Response shows all videos in the post with their current engagement metrics.
 55 | 
 56 | ### 2. `list_civitai_posts`
 57 | **Secondary tool for browsing recorded posts and their stored asset data.**
 58 | 
 59 | **Purpose**: Query the local database for posts you've previously recorded.
 60 | 
 61 | **When to use**:
 62 | - You want to see what posts are already in your database
 63 | - You need to filter by status, creator, or time range
 64 | - You want to get stored asset information (use `include_details: true`)
 65 | 
 66 | **Input**:
 67 | ```json
 68 | {
 69 |   "civitai_id": "23602354",
 70 |   "include_details": true
 71 | }
 72 | ```
 73 | 
 74 | **Note**: This returns stored data from your database, not live Civitai data. For current engagement metrics, use `fetch_civitai_post_assets`.
 75 | 
 76 | ### 3. `find_asset`
 77 | **Tertiary tool for looking up specific assets in your database.**
 78 | 
 79 | **Purpose**: Find a single asset by ID, SHA256 hash, Civitai ID, or post ID.
 80 | 
 81 | **When to use**:
 82 | - You have an asset's Civitai image ID and want to check if it's recorded locally
 83 | - You need full details about a specific asset including linked prompts and posts
 84 | 
 85 | **Input**:
 86 | ```json
 87 | {
 88 |   "civitai_id": "106432973"
 89 | }
 90 | ```
 91 | 
 92 | **Note**: This queries your local database. Engagement metrics are only available if you stored them in the `metadata` field when creating/updating the asset.
 93 | 
 94 | ## Step-by-Step: Finding Media Engagement
 95 | 
 96 | ### Scenario 1: You have a Civitai post URL
 97 | **Goal**: Get engagement metrics for all videos in the post.
 98 | 
 99 | 1. **Extract the post ID** from the URL:
100 |    - URL: `https://civitai.com/posts/23602354`
101 |    - Post ID: `23602354`
102 | 
103 | 2. **Fetch live engagement data**:
104 |    ```json
105 |    {
106 |      "post_id": "23602354",
107 |      "limit": 100
108 |    }
109 |    ```
110 |    Use `fetch_civitai_post_assets` to get all media assets.
111 | 
112 | 3. **Analyze the data**:
113 |    - Total engagement = sum of all reaction types
114 |    - Most popular videos = highest like or heart counts
115 |    - Controversial content = high dislike or mix of reactions
116 | 
117 | ### Scenario 2: You have a Civitai video/image URL
118 | **Goal**: Get engagement for a specific video or image.
119 | 
120 | 1. **Extract the media ID** from the URL:
121 |    - URL: `https://civitai.com/images/106432973`
122 |    - Media ID: `106432973`
123 | 
124 | 2. **Check if it's in your database**:
125 |    ```json
126 |    {
127 |      "civitai_id": "106432973"
128 |    }
129 |    ```
130 |    Use `find_asset` to see if you've recorded it.
131 | 
132 | 3. **Get the post ID** from the result (if found):
133 |    - Look for `post.civitai_id` in the response
134 | 
135 | 4. **Fetch live engagement**:
136 |    ```json
137 |    {
138 |      "post_id": "<post_civitai_id>"
139 |    }
140 |    ```
141 |    Use `fetch_civitai_post_assets` and find the matching `civitai_image_id`.
142 | 
143 | ## Engagement Metrics Explained
144 | 
145 | ### Reaction Types
146 | - **Like** 👍: Standard positive reaction
147 | - **Heart** ❤️: Strong positive reaction, often indicates favorite content
148 | - **Laugh** 😂: Humorous or entertaining content
149 | - **Cry** 😢: Emotional or touching content
150 | - **Dislike** 👎: Negative reaction
151 | - **Comment** 💬: Discussion and engagement depth
152 | 
153 | ### Interpreting Engagement
154 | - **High engagement**: Large total reaction count relative to views
155 | - **Positive ratio**: (likes + hearts + laughs) / (total reactions)
156 | - **Controversy score**: dislikes / total reactions
157 | - **Discussion depth**: comments / total reactions
158 | 
159 | ## Best Practices
160 | 
161 | 1. **Always use `fetch_civitai_post_assets` for current data**
162 |    - Don't rely on stored database values for live engagement
163 |    - The database stores historical snapshots, not real-time data
164 | 
165 | 2. **Extract post IDs from URLs correctly**
166 |    - Post URL: `https://civitai.com/posts/XXXXXX` → `XXXXXX`
167 |    - Image URL: `https://civitai.com/images/YYYYYY` → need to find parent post
168 | 
169 | 3. **Handle pagination for large posts**
170 |    - Default limit is 50, maximum is 100
171 |    - Use `page` parameter to fetch additional assets
172 |    - Check `asset_count` to know if there are more pages
173 | 
174 | ## Common Workflows
175 | 
176 | ### Find Top-Performing Media
177 | 1. Fetch all assets from multiple posts
178 | 2. Filter by type if desired (e.g., `type === "video"` or `type === "image"`)
179 | 3. Sort by engagement metrics (e.g., likes + hearts)
180 | 4. Identify patterns in high-performing content
181 | 
182 | ### Compare Video vs Image Engagement
183 | 1. Fetch assets from posts with mixed media
184 | 2. Separate by type
185 | 3. Calculate average engagement per type
186 | 4. Analyze which format performs better for your content
187 | 
188 | ### Audit Your Content Library
189 | 1. Use `list_civitai_posts` with `include_details: true`
190 | 2. Get stored post_ids from your database
191 | 3. Fetch current engagement for each using `fetch_civitai_post_assets`
192 | 
193 | ### Analyze Engagement by Creator
194 | **Goal**: Get total engagement metrics across all posts from a specific creator.
195 | 
196 | 1. **List all posts by creator**:
197 |    ```json
198 |    {
199 |      "created_by": "username",
200 |      "limit": 100
201 |    }
202 |    ```
203 |    Use `list_civitai_posts` to get all posts from the creator.
204 | 
205 | 2. **Extract post IDs**:
206 |    - From the response, collect all `civitai_id` values
207 |    - These are the post IDs you'll need for fetching engagement
208 | 
209 | 3. **Fetch engagement for each post**:
210 |    - For each `civitai_id` from step 2, call `fetch_civitai_post_assets`:
211 |    ```json
212 |    {
213 |      "post_id": "<civitai_id>"
214 |    }
215 |    ```
216 | 
217 | 4. **Aggregate the metrics**:
218 |    - Sum all engagement stats (likes, hearts, comments, etc.) across all posts
219 |    - Calculate average engagement per post
220 |    - Identify the creator's top-performing content
221 |    - Analyze engagement trends over time (use `created_at` field)
222 | 
223 | **Example analysis**:
224 | - Total reactions across all creator's posts
225 | - Average likes per video/image
226 | - Most engaged post by this creator
227 | - Engagement distribution by content type (video vs image)
228 | 
229 | ## Troubleshooting
230 | 
231 | **"Failed to fetch assets from Civitai"**
232 | - Verify the post ID is correct (numeric only)
233 | - Check if the post is public and accessible
234 | - Ensure you have internet connectivity
235 | 
236 | **"No assets found"**
237 | - The post might have been deleted or made private
238 | - Try accessing the post URL in a browser to verify
239 | - Check if pagination is needed (increase `page` parameter)
240 | 
241 | **"Asset not in database"**
242 | - Use `fetch_civitai_post_assets` to get data directly from Civitai
243 | - The asset might not be recorded locally yet
244 | - Use the returned data to create a new asset record
245 | 
246 | ## Summary
247 | 
248 | To find Civitai media (video/image) engagement:
249 | 1. **Have a post URL?** → Extract ID → `fetch_civitai_post_assets`
250 | 2. **Have a video/image URL?** → Extract ID → `find_asset` → get post ID → `fetch_civitai_post_assets`
251 | 3. **Want stored data?** → `list_civitai_posts` with `include_details: true`
252 | 4. **Need real-time metrics?** → Always use `fetch_civitai_post_assets`
253 | 
```

--------------------------------------------------------------------------------
/src/civitai-records/src/tools/createAsset.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import type { ContentResult } from "fastmcp";
  2 | import { z } from "zod";
  3 | import { prisma } from "../lib/prisma.js";
  4 | import { sha256 } from "../lib/sha256.js";
  5 | import { detectRemoteAssetType, type RemoteAssetTypeResult } from "../lib/detectRemoteAssetType.js";
  6 | import { handleDatabaseError } from "../lib/handleDatabaseError.js";
  7 | 
  8 | const metadataSchema = z.record(z.any()).nullable().default(null);
  9 | 
 10 | /**
 11 |  * Validate that the provided asset type matches the detected type.
 12 |  * Throws an error with a helpful message if there's a mismatch.
 13 |  */
 14 | function validateAssetType(
 15 |   providedType: "image" | "video",
 16 |   detectionResult: RemoteAssetTypeResult,
 17 |   url: string
 18 | ): void {
 19 |   const { assetType: detectedType, mime, from: detectionMethod } = detectionResult;
 20 |   
 21 |   if (!detectedType) {
 22 |     // Unable to detect, allow the provided type
 23 |     console.warn(`Unable to detect asset type for URL: ${url}. Accepting provided type: ${providedType}`);
 24 |     return;
 25 |   }
 26 |   
 27 |   if (detectedType === providedType) {
 28 |     console.log(`Asset type '${providedType}' confirmed via ${detectionMethod}`);
 29 |     return;
 30 |   }
 31 |   
 32 |   // Mismatch detected - throw error with helpful message
 33 |   const errorDetails = mime 
 34 |     ? `Detected type: '${detectedType}' (MIME: ${mime}, via ${detectionMethod})`
 35 |     : `Detected type: '${detectedType}' (via ${detectionMethod})`;
 36 |   
 37 |   throw new Error(
 38 |     `Asset type mismatch for URL: ${url}\n` +
 39 |     `Provided: '${providedType}'\n` +
 40 |     `${errorDetails}\n` +
 41 |     `Please update the asset_type parameter to '${detectedType}' and try again.`
 42 |   );
 43 | }
 44 | 
 45 | /**
 46 |  * Parse and validate an ID string parameter to BigInt.
 47 |  * Returns null if the input is null/empty.
 48 |  */
 49 | function parseIdParameter(id: string | null, parameterName: string): bigint | null {
 50 |   if (!id) {
 51 |     return null;
 52 |   }
 53 |   
 54 |   const trimmed = id.trim();
 55 |   if (!trimmed) {
 56 |     return null;
 57 |   }
 58 |   
 59 |   try {
 60 |     return BigInt(trimmed);
 61 |   } catch (error) {
 62 |     throw new Error(`Invalid ${parameterName}: must be a valid integer ID`);
 63 |   }
 64 | }
 65 | 
 66 | /**
 67 |  * Parse and validate an array of ID strings to BigInt array.
 68 |  * Returns null if the input is null/empty.
 69 |  */
 70 | function parseIdArrayParameter(ids: string[] | null, parameterName: string): bigint[] | null {
 71 |   if (!ids || ids.length === 0) {
 72 |     return null;
 73 |   }
 74 |   
 75 |   try {
 76 |     return ids.map((id, index) => {
 77 |       const trimmed = id.trim();
 78 |       if (!trimmed) {
 79 |         throw new Error(`Empty ID at index ${index}`);
 80 |       }
 81 |       return BigInt(trimmed);
 82 |     });
 83 |   } catch (error) {
 84 |     throw new Error(`Invalid ${parameterName}: ${error instanceof Error ? error.message : 'must contain valid integer IDs'}`);
 85 |   }
 86 | }
 87 | 
 88 | export const createAssetParameters = z.object({
 89 |   asset_url: z
 90 |     .string()
 91 |     .min(1)
 92 |     .describe("The actual resource storage URL. Can be from original source or Civitai CDN. Example: 'https://image.civitai.com/.../video.mp4' or 'https://storage.example.com/image.png'"),
 93 |   asset_type: z
 94 |     .enum(["image", "video"])
 95 |     .describe("The type of media asset. Choose 'image' or 'video' based on the content type."),
 96 |   asset_source: z
 97 |     .enum(["generated", "upload"])
 98 |     .describe("How this asset was created. Use 'generated' for AI-generated content or 'upload' for user-uploaded files."),
 99 |   input_prompt_id: z
100 |     .string()
101 |     .nullable()
102 |     .default(null)
103 |     .describe("The ID of the prompt that was used to generate this asset. If you generated content from a prompt, first call create_prompt to save the prompt and get its ID, then use that ID here to link the asset to its source prompt. Leave empty for uploaded assets or content not generated from a prompt."),
104 |   output_prompt_id: z
105 |     .string()
106 |     .nullable()
107 |     .default(null)
108 |     .describe("The ID of a prompt that was derived from or describes this asset. For example, if you used an LLM to caption this image or extract a description from the generated content, save that caption/description as a prompt using create_prompt and link it here."),
109 |   civitai_id: z
110 |     .string()
111 |     .nullable()
112 |     .default(null)
113 |     .describe("The Civitai image ID for this asset if it has been uploaded to Civitai. Extract from the Civitai URL, e.g., for 'https://civitai.com/images/106432973' the ID is '106432973'."),
114 |   civitai_url: z
115 |     .string()
116 |     .nullable()
117 |     .default(null)
118 |     .describe("The Civitai page URL for this asset if it has been uploaded to Civitai. Example: 'https://civitai.com/images/106432973'."),
119 |   post_id: z
120 |     .string()
121 |     .nullable()
122 |     .default(null)
123 |     .describe("The ID from the civitai_posts table that this asset is associated with. Use create_civitai_post tool to create a post first if needed."),
124 |   input_asset_ids: z
125 |     .array(z.string())
126 |     .nullable()
127 |     .default(null)
128 |     .describe("Array of asset IDs that were used as inputs to generate this asset. For example, if this is a video generated from multiple images, list those image asset IDs here. Leave empty for assets that weren't generated from other assets."),
129 |   metadata: metadataSchema.describe("Additional information about this asset in JSON format. Can include technical details (resolution, duration, file size), generation parameters, quality scores, or any custom data relevant to this asset."),
130 |   on_behalf_of: z
131 |     .string()
132 |     .nullable()
133 |     .default(null)
134 |     .describe("The user account this action is being performed on behalf of. If not provided, defaults to the authenticated database user and can be updated later."),
135 | });
136 | 
137 | export type CreateAssetParameters = z.infer<typeof createAssetParameters>;
138 | 
139 | export const createAssetTool = {
140 |   name: "create_asset",
141 |   description: "Save a generated or uploaded media asset (video, image) to the database. Use this after creating content to track what was generated, where it's stored, and link it back to the original prompt that created it. IMPORTANT: The asset_type parameter is validated against the actual file content by checking HTTP headers, content sniffing, and URL patterns. If there's a mismatch (e.g., you specify 'video' but the URL is an image), the tool will return an error telling you the correct type to use.",
142 |   parameters: createAssetParameters,
143 |   execute: async ({ asset_url, asset_type, asset_source, input_prompt_id, output_prompt_id, civitai_id, civitai_url, post_id, input_asset_ids, metadata, on_behalf_of }: CreateAssetParameters): Promise<ContentResult> => {
144 |     // Detect and validate asset type
145 |     const detectionResult = await detectRemoteAssetType(asset_url, { skipRemote: false, timeout: 5000 });
146 |     validateAssetType(asset_type, detectionResult, asset_url);
147 |     
148 |     // Parse ID parameters
149 |     const inputPromptId = parseIdParameter(input_prompt_id, 'input_prompt_id');
150 |     const outputPromptId = parseIdParameter(output_prompt_id, 'output_prompt_id');
151 |     const postIdBigInt = parseIdParameter(post_id, 'post_id');
152 |     const inputAssetIds = parseIdArrayParameter(input_asset_ids, 'input_asset_ids');
153 | 
154 |     const sha256sum = await sha256(asset_url);
155 | 
156 |     const asset = await prisma.assets.create({
157 |       data: {
158 |         uri: asset_url,
159 |         sha256sum,
160 |         asset_type: asset_type,
161 |         asset_source,
162 |         input_prompt_id: inputPromptId,
163 |         output_prompt_id: outputPromptId,
164 |         civitai_id: civitai_id?.trim() || null,
165 |         civitai_url: civitai_url?.trim() || null,
166 |         post_id: postIdBigInt,
167 |         input_asset_ids: inputAssetIds ?? undefined,
168 |         metadata: metadata ?? undefined,
169 |         on_behalf_of: on_behalf_of ?? undefined,
170 |       },
171 |     }).catch(error => handleDatabaseError(error, `URL: ${asset_url}`));
172 | 
173 |     return {
174 |       content: [
175 |         {
176 |           type: "text",
177 |           text: JSON.stringify({
178 |             asset_id: asset.id.toString(),
179 |             asset_type: asset.asset_type,
180 |             asset_source: asset.asset_source,
181 |             uri: asset.uri,
182 |             sha256sum: asset.sha256sum,
183 |             civitai_id: asset.civitai_id,
184 |             civitai_url: asset.civitai_url,
185 |             post_id: asset.post_id?.toString() ?? null,
186 |             input_prompt_id: asset.input_prompt_id?.toString() ?? null,
187 |             output_prompt_id: asset.output_prompt_id?.toString() ?? null,
188 |             input_asset_ids: asset.input_asset_ids.map((id: bigint) => id.toString()),
189 |             on_behalf_of: asset.on_behalf_of,
190 |             created_at: asset.created_at.toISOString(),
191 |           }, null, 2),
192 |         },
193 |       ],
194 |     } satisfies ContentResult;
195 |   },
196 | };
197 | 
```

--------------------------------------------------------------------------------
/src/tapjoy-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 | 
  9 | dotenv.config();
 10 | 
 11 | const server = new McpServer({
 12 |   name: "Tapjoy GraphQL Reporting MCP Server",
 13 |   version: "0.0.5"
 14 | });
 15 | 
 16 | const TAPJOY_API_BASE_URL = "https://api.tapjoy.com";
 17 | const TAPJOY_API_KEY = process.env.TAPJOY_API_KEY || '';
 18 | 
 19 | if (!TAPJOY_API_KEY) {
 20 |   console.error("Missing Tapjoy API credentials. Please set TAPJOY_API_KEY environment variable.");
 21 |   process.exit(1);
 22 | }
 23 | 
 24 | // Token cache
 25 | let accessToken: string | null = null;
 26 | let tokenExpiry = 0;
 27 | 
 28 | /**
 29 |  * Get valid Tapjoy API access token using the API Key
 30 |  */
 31 | async function getTapjoyAccessToken(): Promise<string> {
 32 |   // Check if we have a valid token
 33 |   if (accessToken && Date.now() < tokenExpiry) {
 34 |     return accessToken;
 35 |   }
 36 | 
 37 |   // Request new token
 38 |   const authUrl = `${TAPJOY_API_BASE_URL}/v1/oauth2/token`;
 39 | 
 40 |   try {
 41 |     console.error("Requesting new Tapjoy access token...");
 42 |     const response = await fetch(authUrl, {
 43 |       method: 'POST',
 44 |       headers: {
 45 |         'Authorization': `Basic ${TAPJOY_API_KEY}`,
 46 |         'Accept': 'application/json; */*'
 47 |       }
 48 |     });
 49 | 
 50 |     if (!response.ok) {
 51 |       const errorBody = await response.text();
 52 |       console.error(`Tapjoy Authentication Error Response: ${response.status} ${response.statusText} - ${errorBody}`);
 53 |       throw new Error(`Authentication failed: ${response.status} ${response.statusText}`);
 54 |     }
 55 | 
 56 |     const data = await response.json() as { access_token: string; expires_in: number };
 57 | 
 58 |     if (!data.access_token) {
 59 |        console.error("Tapjoy Authentication Response missing access_token:", data);
 60 |        throw new Error('Failed to get access token: Token is empty in response');
 61 |     }
 62 | 
 63 |     accessToken = data.access_token;
 64 |     // Set expiry time with 1 minute buffer (tokens last 1 hour = 3600s)
 65 |     tokenExpiry = Date.now() + (data.expires_in - 60) * 1000;
 66 |     console.error("Successfully obtained new Tapjoy access token.");
 67 | 
 68 |     return accessToken;
 69 |   } catch (error: any) {
 70 |      console.error("Error fetching Tapjoy access token:", error);
 71 |      // Reset token info on failure
 72 |      accessToken = null;
 73 |      tokenExpiry = 0;
 74 |      throw new Error(`Failed to get Tapjoy access token: ${error.message}`);
 75 |   }
 76 | }
 77 | 
 78 | /**
 79 |  * Generates the GraphQL query string for advertiser ad set spend.
 80 |  */
 81 | function getAdvertiserAdSetSpendQuery(startDate: string, endDate: string): string {
 82 |   // Add 1 day to end date for the 'until' parameter as per Ruby example
 83 |   const untilDate = new Date(endDate);
 84 |   untilDate.setDate(untilDate.getDate() + 1);
 85 |   const untilDateString = untilDate.toISOString().split('T')[0]; // Format as YYYY-MM-DD
 86 | 
 87 |   // Ensure Z(ulu) timezone indicator for UTC
 88 |   const startTime = `${startDate}T00:00:00Z`;
 89 |   const untilTime = `${untilDateString}T00:00:00Z`;
 90 | 
 91 |   return `
 92 |     query {
 93 |       advertiser {
 94 |         adSets(configuredStatus: ACTIVE, first: 50) {
 95 |           nodes {
 96 |             campaign {
 97 |               name
 98 |             }
 99 |             insights(timeRange: {from: "${startTime}", until: "${untilTime}"}) {
100 |               reports {
101 |                 spend
102 |               }
103 |             }
104 |           }
105 |         }
106 |       }
107 |     }
108 |   `;
109 | }
110 | 
111 | /**
112 |  * Make authenticated GraphQL request to Tapjoy API
113 |  */
114 | async function makeTapjoyGraphqlRequest(query: string) {
115 |   const token = await getTapjoyAccessToken();
116 |   const graphqlUrl = `${TAPJOY_API_BASE_URL}/graphql`;
117 | 
118 |   try {
119 |     const response = await fetch(graphqlUrl, {
120 |       method: 'POST',
121 |       headers: {
122 |         'Content-Type': 'application/json',
123 |         'Authorization': `Bearer ${token}`,
124 |         'Accept': 'application/json; */*'
125 |       },
126 |       body: JSON.stringify({ query })
127 |     });
128 | 
129 |     if (!response.ok) {
130 |       // Handle specific Tapjoy error codes if needed, e.g., 401 for expired token
131 |       if (response.status === 401) {
132 |          console.warn("Tapjoy token likely expired or invalid, attempting to refresh...");
133 |          accessToken = null; // Force token refresh on next call
134 |          throw new Error(`GraphQL request failed: ${response.status} ${response.statusText} (Unauthorized - check API key or token may have expired)`);
135 |       }
136 |       const errorBody = await response.text();
137 |       console.error(`Tapjoy GraphQL API Error Response: ${response.status} ${response.statusText} - ${errorBody}`);
138 |       throw new Error(`GraphQL request failed: ${response.status} ${response.statusText}`);
139 |     }
140 | 
141 |     const responseData = await response.json();
142 | 
143 |     // Check for GraphQL errors within the response body
144 |     if (responseData.errors && responseData.errors.length > 0) {
145 |       console.error("Tapjoy GraphQL Query Errors:", JSON.stringify(responseData.errors, null, 2));
146 |       throw new Error(`GraphQL query failed: ${responseData.errors.map((e: any) => e.message).join(', ')}`);
147 |     }
148 | 
149 |     return responseData.data; // Return the actual data part
150 |   } catch (error: any) {
151 |      console.error(`Error making Tapjoy GraphQL request to ${graphqlUrl}:`, error);
152 |      // If it's an auth error, reset token
153 |      if (error.message.includes("401")) {
154 |          accessToken = null;
155 |          tokenExpiry = 0;
156 |      }
157 |      throw error; // Re-throw the error to be caught by the tool handler
158 |   }
159 | }
160 | 
161 | // Tool: Get Advertiser Ad Set Spend using GraphQL API
162 | server.tool("get_advertiser_adset_spend",
163 |   "Get spend for active advertiser ad sets within a date range using the Tapjoy GraphQL API.",
164 |   {
165 |     start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Start date must be in YYYY-MM-DD format").describe("Start date for the report (YYYY-MM-DD)"),
166 |     end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "End date must be in YYYY-MM-DD format").describe("End date for the report (YYYY-MM-DD)")
167 | }, async ({ start_date, end_date }) => {
168 |   try {
169 |     // Validate date range logic if necessary (e.g., start_date <= end_date)
170 |     if (new Date(start_date) > new Date(end_date)) {
171 |       throw new Error("Start date cannot be after end date.");
172 |     }
173 | 
174 |     const query = getAdvertiserAdSetSpendQuery(start_date, end_date);
175 |     const data = await makeTapjoyGraphqlRequest(query);
176 | 
177 |     // Extract the relevant nodes as per Ruby example
178 |     const adSetNodes = data?.advertiser?.adSets?.nodes;
179 | 
180 |     let processedResults: any[] = []; // Initialize array for processed results
181 | 
182 |     if (Array.isArray(adSetNodes)) {
183 |       processedResults = adSetNodes.map(node => {
184 |         const campaignName = node?.campaign?.name ?? 'Unknown Campaign';
185 |         // Safely access nested spend value
186 |         const rawSpend = node?.insights?.reports?.[0]?.spend?.[0];
187 |         let spendUSD = 0; // Default to 0 if spend is not found or invalid
188 | 
189 |         if (typeof rawSpend === 'number') {
190 |           spendUSD = rawSpend / 1000000; // Convert micro-dollars to USD
191 |         } else if (rawSpend != null) {
192 |           console.warn(`Invalid spend value found for campaign ${campaignName}:`, rawSpend);
193 |         }
194 | 
195 |         return {
196 |           campaign: { name: campaignName },
197 |           insights: {
198 |             reports: [ { spendUSD: spendUSD } ] // Use spendUSD key
199 |           }
200 |         };
201 |       });
202 | 
203 |       if (processedResults.length === 0) {
204 |          console.warn("No ad set nodes with spend data found after processing.");
205 |       }
206 | 
207 |     } else {
208 |       console.warn("Tapjoy GraphQL response structure might have changed or no adSetNodes array found. Full data:", JSON.stringify(data, null, 2));
209 |       // Optionally return raw data or an empty array if no nodes found
210 |       processedResults = data ?? {}; // Fallback to returning raw data or empty object
211 |     }
212 | 
213 |     return {
214 |       content: [
215 |         {
216 |           type: "text",
217 |           // Return the processed results with USD spend
218 |           text: JSON.stringify(processedResults, null, 2)
219 |         }
220 |       ]
221 |     };
222 |   } catch (error: any) {
223 |     let errorMessage = `Error getting Tapjoy advertiser ad set spend: ${error.message}`;
224 | 
225 |     return {
226 |       content: [
227 |         {
228 |           type: "text",
229 |           text: errorMessage
230 |         }
231 |       ],
232 |       isError: true
233 |     };
234 |   }
235 | });
236 | 
237 | // Start server
238 | async function runServer() {
239 |   const transport = new StdioServerTransport();
240 |   await server.connect(transport);
241 |   console.error("Tapjoy GraphQL Reporting MCP Server running on stdio");
242 | }
243 | 
244 | runServer().catch((error) => {
245 |   console.error("Fatal error running server:", error);
246 |   process.exit(1);
247 | });
248 | 
```

--------------------------------------------------------------------------------
/src/user-activity-reporting/src/api.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import jwt from 'jsonwebtoken';
  2 | import dotenv from 'dotenv';
  3 | 
  4 | dotenv.config();
  5 | 
  6 | const API_BASE = process.env.FEEDMOB_API_BASE;
  7 | const API_KEY = process.env.FEEDMOB_KEY;
  8 | const API_SECRET = process.env.FEEDMOB_SECRET;
  9 | 
 10 | if (!API_KEY || !API_SECRET) {
 11 |   console.error("Error: FEEDMOB_KEY and FEEDMOB_SECRET must be set.");
 12 |   process.exit(1);
 13 | }
 14 | 
 15 | function genToken(): string {
 16 |   const exp = new Date();
 17 |   exp.setDate(exp.getDate() + 7);
 18 |   return jwt.sign({ key: API_KEY, expired_at: exp.toISOString().split('T')[0] }, API_SECRET!, { algorithm: 'HS256' });
 19 | }
 20 | 
 21 | function buildUrl(path: string, params: Record<string, string>): string {
 22 |   const url = new URL(`${API_BASE}${path}`);
 23 |   Object.entries(params).forEach(([k, v]) => url.searchParams.append(k, v));
 24 |   return url.toString();
 25 | }
 26 | 
 27 | async function apiGet(path: string, params: Record<string, string> = {}): Promise<any> {
 28 |   const res = await fetch(buildUrl(path, params), {
 29 |     headers: {
 30 |       'Content-Type': 'application/json', 'Accept': 'application/json',
 31 |       'FEEDMOB-KEY': API_KEY!, 'FEEDMOB-TOKEN': genToken()
 32 |     },
 33 |     signal: AbortSignal.timeout(30000)
 34 |   });
 35 | 
 36 |   if (res.status === 401) throw new Error('Unauthorized: Invalid API Key or Token');
 37 |   if (!res.ok) {
 38 |     const err = await res.json().catch(() => ({}));
 39 |     throw new Error(err.error || `API error: ${res.status}`);
 40 |   }
 41 | 
 42 |   const r = await res.json();
 43 |   if (r.status === 404) throw new Error(r.error || 'Not found');
 44 |   if (r.status === 400) throw new Error(r.error || 'Bad request');
 45 |   return r.data;
 46 | }
 47 | 
 48 | export type ValidRole = 'aa' | 'am' | 'ae' | 'pm' | 'pa' | 'ao';
 49 | 
 50 | export interface ClientContact {
 51 |   client_id: number;
 52 |   client_name: string;
 53 |   month?: string;
 54 |   pod?: string;
 55 |   aa?: string;
 56 |   am?: string;
 57 |   ae?: string;
 58 |   pm?: string;
 59 |   pa?: string;
 60 |   ao?: string;
 61 | }
 62 | 
 63 | export interface ListResult { month: string; total: number; clients: ClientContact[]; }
 64 | export interface PodResult { pod: string; month: string; count: number; client_names: string[]; }
 65 | export interface RoleResult { role: string; name: string; month: string; count: number; client_names: string[]; }
 66 | export interface NameResult { name: string; month: string; results: Record<string, string[]>; }
 67 | 
 68 | export async function getAllContacts(month?: string): Promise<ListResult> {
 69 |   return apiGet('/ai/api/client_contacts', month ? { month } : {});
 70 | }
 71 | 
 72 | export async function getContactByClient(name: string, month?: string): Promise<ClientContact> {
 73 |   const p: Record<string, string> = { client_name: name };
 74 |   if (month) p.month = month;
 75 |   return apiGet('/ai/api/client_contacts', p);
 76 | }
 77 | 
 78 | export async function getClientsByPod(pod: string, month?: string): Promise<PodResult> {
 79 |   const p: Record<string, string> = { pod };
 80 |   if (month) p.month = month;
 81 |   return apiGet('/ai/api/client_contacts', p);
 82 | }
 83 | 
 84 | export async function getClientsByRole(role: ValidRole, name: string, month?: string): Promise<RoleResult> {
 85 |   const p: Record<string, string> = { role, name };
 86 |   if (month) p.month = month;
 87 |   return apiGet('/ai/api/client_contacts', p);
 88 | }
 89 | 
 90 | export async function getClientsByName(name: string, month?: string): Promise<NameResult> {
 91 |   const p: Record<string, string> = { name };
 92 |   if (month) p.month = month;
 93 |   return apiGet('/ai/api/client_contacts', p);
 94 | }
 95 | 
 96 | const SLACK_TOKEN = process.env.SLACK_BOT_TOKEN;
 97 | const HUBSPOT_TOKEN = process.env.HUBSPOT_ACCESS_TOKEN;
 98 | 
 99 | export interface SlackMsg { ts: string; text: string; user: string; channel: string; permalink?: string; }
100 | export interface SlackUser { id: string; name: string; real_name: string; email?: string; }
101 | 
102 | async function slackGet(method: string, params: Record<string, string> = {}): Promise<any> {
103 |   if (!SLACK_TOKEN) throw new Error('SLACK_BOT_TOKEN not set');
104 |   const url = new URL(`https://slack.com/api/${method}`);
105 |   Object.entries(params).forEach(([k, v]) => url.searchParams.append(k, v));
106 |   const r = await (await fetch(url.toString(), {
107 |     headers: { 'Authorization': `Bearer ${SLACK_TOKEN}`, 'Content-Type': 'application/x-www-form-urlencoded' }
108 |   })).json();
109 |   if (!r.ok) throw new Error(`Slack error: ${r.error}`);
110 |   return r;
111 | }
112 | 
113 | export async function findSlackUser(name: string): Promise<SlackUser | null> {
114 |   const { members = [] } = await slackGet('users.list');
115 |   const n = name.toLowerCase();
116 |   const u = members.find((m: any) =>
117 |     m.real_name?.toLowerCase().includes(n) || m.name?.toLowerCase().includes(n) || m.profile?.display_name?.toLowerCase().includes(n)
118 |   );
119 |   return u ? { id: u.id, name: u.name, real_name: u.real_name || u.name, email: u.profile?.email } : null;
120 | }
121 | 
122 | export async function searchSlackMsgs(userName: string, query?: string, limit = 20): Promise<SlackMsg[]> {
123 |   const user = await findSlackUser(userName);
124 |   if (!user) throw new Error(`Slack user not found: ${userName}`);
125 |   const q = query ? `from:${user.name} ${query}` : `from:${user.name}`;
126 |   const { messages } = await slackGet('search.messages', { query: q, count: String(limit), sort: 'timestamp', sort_dir: 'desc' });
127 |   return (messages?.matches || []).map((m: any) => ({
128 |     ts: m.ts, text: m.text, user: m.username || user.name,
129 |     channel: m.channel?.name || m.channel?.id || 'unknown', permalink: m.permalink
130 |   }));
131 | }
132 | 
133 | export interface Ticket {
134 |   id: string; subject: string; content?: string; status: string;
135 |   priority?: string; createdAt: string; updatedAt: string; owner?: string;
136 | }
137 | 
138 | async function hsPost(endpoint: string, body: any): Promise<any> {
139 |   if (!HUBSPOT_TOKEN) throw new Error('HUBSPOT_ACCESS_TOKEN not set');
140 |   const res = await fetch(`https://api.hubapi.com${endpoint}`, {
141 |     method: 'POST',
142 |     headers: { 'Authorization': `Bearer ${HUBSPOT_TOKEN}`, 'Content-Type': 'application/json' },
143 |     body: JSON.stringify(body)
144 |   });
145 |   if (!res.ok) throw new Error(`HubSpot error: ${res.status} - ${await res.text()}`);
146 |   return res.json();
147 | }
148 | 
149 | async function hsGet(endpoint: string): Promise<any> {
150 |   if (!HUBSPOT_TOKEN) throw new Error('HUBSPOT_ACCESS_TOKEN not set');
151 |   const res = await fetch(`https://api.hubapi.com${endpoint}`, {
152 |     headers: { 'Authorization': `Bearer ${HUBSPOT_TOKEN}`, 'Content-Type': 'application/json' }
153 |   });
154 |   if (!res.ok) throw new Error(`HubSpot error: ${res.status} - ${await res.text()}`);
155 |   return res.json();
156 | }
157 | 
158 | function mapTicket(t: any): Ticket {
159 |   const p = t.properties;
160 |   return {
161 |     id: t.id, subject: p.subject || 'No Subject', content: p.content,
162 |     status: p.hs_pipeline_stage || 'unknown', priority: p.hs_ticket_priority,
163 |     createdAt: p.createdate, updatedAt: p.hs_lastmodifieddate, owner: p.hubspot_owner_id
164 |   };
165 | }
166 | 
167 | export async function getTickets(opts: { status?: string; startDate?: string; endDate?: string; limit?: number } = {}): Promise<Ticket[]> {
168 |   const filters: any[] = [];
169 |   if (opts.startDate) filters.push({ propertyName: 'createdate', operator: 'GTE', value: new Date(opts.startDate).getTime() });
170 |   if (opts.endDate) filters.push({ propertyName: 'createdate', operator: 'LTE', value: new Date(opts.endDate).getTime() });
171 |   if (opts.status) filters.push({ propertyName: 'hs_pipeline_stage', operator: 'EQ', value: opts.status });
172 | 
173 |   const body: any = {
174 |     properties: ['subject', 'content', 'hs_pipeline_stage', 'hs_ticket_priority', 'createdate', 'hs_lastmodifieddate'],
175 |     limit: opts.limit || 50, sorts: [{ propertyName: 'createdate', direction: 'DESCENDING' }]
176 |   };
177 |   if (filters.length) body.filterGroups = [{ filters }];
178 | 
179 |   const { results = [] } = await hsPost('/crm/v3/objects/tickets/search', body);
180 |   return results.map(mapTicket);
181 | }
182 | 
183 | export async function getTicketById(id: string): Promise<Ticket | null> {
184 |   const props = 'subject,content,hs_pipeline_stage,hs_ticket_priority,createdate,hs_lastmodifieddate';
185 |   const data = await hsGet(`/crm/v3/objects/tickets/${id}?properties=${props}`);
186 |   return data ? mapTicket(data) : null;
187 | }
188 | 
189 | export async function getTicketsByUser(opts: { userName?: string; email?: string; limit?: number }): Promise<Ticket[]> {
190 |   const { results: owners = [] } = await hsGet('/crm/v3/owners');
191 |   const term = (opts.userName || opts.email || '').toLowerCase();
192 |   const matched = owners.filter((o: any) => {
193 |     const fn = (o.firstName || '').toLowerCase(), ln = (o.lastName || '').toLowerCase();
194 |     return fn.includes(term) || ln.includes(term) || `${fn} ${ln}`.includes(term) || (o.email || '').toLowerCase().includes(term);
195 |   });
196 |   if (!matched.length) return [];
197 | 
198 |   const body = {
199 |     properties: ['subject', 'content', 'hs_pipeline_stage', 'hs_ticket_priority', 'createdate', 'hs_lastmodifieddate', 'hubspot_owner_id'],
200 |     limit: opts.limit || 50, sorts: [{ propertyName: 'createdate', direction: 'DESCENDING' }],
201 |     filterGroups: [{ filters: [{ propertyName: 'hubspot_owner_id', operator: 'IN', values: matched.map((o: any) => o.id) }] }]
202 |   };
203 |   const { results = [] } = await hsPost('/crm/v3/objects/tickets/search', body);
204 |   const ownerMap = new Map(owners.map((o: any) => [o.id, `${o.firstName || ''} ${o.lastName || ''}`.trim() || o.email]));
205 |   return results.map((t: any) => ({ ...mapTicket(t), owner: ownerMap.get(t.properties.hubspot_owner_id) }));
206 | }
207 | 
```

--------------------------------------------------------------------------------
/src/github-issues/index.ts:
--------------------------------------------------------------------------------

```typescript
  1 | #!/usr/bin/env node
  2 | 
  3 | import { FastMCP } from "fastmcp";
  4 | import { z } from 'zod';
  5 | import fetch, { Request, Response } from 'node-fetch';
  6 | 
  7 | import * as issues from './operations/issues.js';
  8 | import * as search from './operations/search.js';
  9 | import {
 10 |   GitHubError,
 11 |   GitHubValidationError,
 12 |   GitHubResourceNotFoundError,
 13 |   GitHubAuthenticationError,
 14 |   GitHubPermissionError,
 15 |   GitHubRateLimitError,
 16 |   GitHubConflictError,
 17 |   isGitHubError,
 18 | } from './common/errors.js';
 19 | import { VERSION } from "./common/version.js";
 20 | 
 21 | // If fetch doesn't exist in global scope, add it
 22 | if (!globalThis.fetch) {
 23 |   globalThis.fetch = fetch as unknown as typeof global.fetch;
 24 | }
 25 | 
 26 | // Default values from environment variables
 27 | const DEFAULT_OWNER = process.env.GITHUB_DEFAULT_OWNER;
 28 | const AI_API_URL = process.env.AI_API_URL;
 29 | const AI_API_TOKEN = process.env.AI_API_TOKEN;
 30 | const server = new FastMCP({
 31 |   name: "feedmob-github-mcp-server",
 32 |   version: VERSION
 33 | });
 34 | 
 35 | function formatGitHubError(error: GitHubError): string {
 36 |   let message = `GitHub API Error: ${error.message}`;
 37 | 
 38 |   if (error instanceof GitHubValidationError) {
 39 |     message = `Validation Error: ${error.message}`;
 40 |     if (error.response) {
 41 |       message += `\nDetails: ${JSON.stringify(error.response)}`;
 42 |     }
 43 |   } else if (error instanceof GitHubResourceNotFoundError) {
 44 |     message = `Not Found: ${error.message}`;
 45 |   } else if (error instanceof GitHubAuthenticationError) {
 46 |     message = `Authentication Failed: ${error.message}`;
 47 |   } else if (error instanceof GitHubPermissionError) {
 48 |     message = `Permission Denied: ${error.message}`;
 49 |   } else if (error instanceof GitHubRateLimitError) {
 50 |     message = `Rate Limit Exceeded: ${error.message}\nResets at: ${error.resetAt.toISOString()}`;
 51 |   } else if (error instanceof GitHubConflictError) {
 52 |     message = `Conflict: ${error.message}`;
 53 |   }
 54 | 
 55 |   return message;
 56 | }
 57 | 
 58 | server.addResource({
 59 |   uri: "issues/search_schema",
 60 |   name: "search issues schema",
 61 |   mimeType: "text/markdown",
 62 |   async load() {
 63 |     const response = await fetch(AI_API_URL + "/issues/scheam", {
 64 |       method: 'GET',
 65 |       headers: {
 66 |         'Authorization': "Bearer " + AI_API_TOKEN,
 67 |       }
 68 |     });
 69 | 
 70 |     return {
 71 |       text: await response.text(),
 72 |     };
 73 |   },
 74 | });
 75 | 
 76 | server.addTool({
 77 |   name: "search_issues",
 78 |   description: "Search GitHub Issues",
 79 |   parameters: issues.FeedmobSearchOptions,
 80 |   execute: async (args: z.infer<typeof issues.FeedmobSearchOptions>) => {
 81 |     try {
 82 |       let params = new URLSearchParams();
 83 |       params.set('start_date', args.start_date);
 84 |       params.set('end_date', args.end_date);
 85 |       args.fields.forEach(field => params.append('fields[]', field));
 86 | 
 87 |       if (args.status !== undefined) {
 88 |         params.set('status', args.status);
 89 |       }
 90 |       if (args.repo !== undefined) {
 91 |         params.set('repo', args.repo);
 92 |       }
 93 |       if (args.users !== undefined) {
 94 |         args.users.forEach(user => params.append('users[]', user));
 95 |       }
 96 |       if (args.team !== undefined) {
 97 |         params.set('team', args.team);
 98 |       }
 99 |       if (args.title !== undefined) {
100 |         params.set('title', args.title);
101 |       }
102 |       if (args.labels !== undefined) {
103 |         args.labels.forEach(label => params.append('labels[]', label));
104 |       }
105 | 
106 |       if (args.score_status !== undefined) {
107 |         params.set('score_status', args.score_status);
108 |       }
109 | 
110 |       const response = await fetch(`${AI_API_URL}/issues?${params}`, {
111 |         method: 'GET',
112 |         headers: {
113 |           'Authorization': "Bearer " + AI_API_TOKEN
114 |         }
115 |       });
116 | 
117 |       const data = await response.text();
118 | 
119 |       return {
120 |         content: [
121 |           {
122 |             type: "text",
123 |             text: `# Github Issue Query Result
124 | **Raw JSON Data:**
125 | \`\`\`json
126 | ${JSON.stringify(data, null, 2)}
127 | \`\`\`
128 | **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.**
129 | `,
130 |           },
131 |         ],
132 |       };
133 |     } catch (error) {
134 |       return {
135 |         content: [{ type: "text", text: `API ERROR: ${error instanceof Error ? error.message : String(error)}` }],
136 |       };
137 |     }
138 |   }
139 | });
140 | 
141 | server.addTool({
142 |   name: "create_issue",
143 |   description: "Create a new issue in a GitHub repository",
144 |   parameters: issues.CreateIssueSchema,
145 |   execute: async (args: z.infer<typeof issues.CreateIssueSchema>) => {
146 |     const owner = args.owner || DEFAULT_OWNER;
147 |     const repo = args.repo;
148 |     const { ...options } = args;
149 | 
150 |     if (!owner || !repo) {
151 |       throw new Error("Repository owner and name are required. Either provide them directly or set GITHUB_DEFAULT_OWNER environment variables.");
152 |     }
153 | 
154 |     try {
155 |       console.error(`[DEBUG] Attempting to create issue in ${owner}/${repo}`);
156 |       console.error(`[DEBUG] Issue options:`, JSON.stringify(options, null, 2));
157 | 
158 |       const issue = await issues.createIssue(owner, repo, options);
159 | 
160 |       console.error(`[DEBUG] Issue created successfully`);
161 |       return {
162 |         content: [{ type: "text", text: JSON.stringify(issue, null, 2) }],
163 |       };
164 |     } catch (err) {
165 |       // Type guard for Error objects
166 |       const error = err instanceof Error ? err : new Error(String(err));
167 | 
168 |       console.error(`[ERROR] Failed to create issue:`, error);
169 | 
170 |       if (error instanceof GitHubResourceNotFoundError) {
171 |         throw new Error(
172 |           `Repository '${owner}/${repo}' not found. Please verify:\n` +
173 |           `1. The repository exists\n` +
174 |           `2. You have correct access permissions\n` +
175 |           `3. The owner and repository names are spelled correctly`
176 |         );
177 |       }
178 | 
179 |       // Safely access error properties
180 |       throw new Error(
181 |         `Failed to create issue: ${error.message}${error.stack ? `\nStack: ${error.stack}` : ''
182 |         }`
183 |       );
184 |     }
185 |   },
186 | });
187 | 
188 | server.addTool({
189 |   name: "update_issue",
190 |   description: "Update an existing issue in a GitHub repository",
191 |   parameters: issues.UpdateIssueOptionsSchema,
192 |   execute: async (args: z.infer<typeof issues.UpdateIssueOptionsSchema>) => {
193 |     const owner = args.owner || DEFAULT_OWNER;
194 |     const repo = args.repo;
195 |     const { issue_number, ...options } = args;
196 | 
197 |     if (!owner || !repo) {
198 |       throw new Error("Repository owner and name are required. Either provide them directly or set GITHUB_DEFAULT_OWNER environment variables.");
199 |     }
200 | 
201 |     const result = await issues.updateIssue(owner, repo, issue_number, options);
202 |     return {
203 |       content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
204 |     };
205 |   },
206 | });
207 | 
208 | server.addTool({
209 |   name: "get_issues",
210 |   description: "Get comments for multiple issues in bulk",
211 |   parameters: issues.GetIssueSchema,
212 |   execute: async (args: z.infer<typeof issues.GetIssueSchema>) => {
213 |     try {
214 |       const response = await fetch(`${AI_API_URL}/issues/get_comments`, {
215 |         method: 'POST',
216 |         headers: {
217 |           'Authorization': "Bearer " + AI_API_TOKEN,
218 |           'Content-Type': 'application/json'
219 |         },
220 |         body: JSON.stringify({
221 |           repo_issues: args.repo_issues,
222 |           comment_count: args.comment_count
223 |         })
224 |       });
225 | 
226 |       if (!response.ok) {
227 |         throw new Error(`HTTP error! status: ${response.status}`);
228 |       }
229 | 
230 |       const data = await response.text();
231 |       return {
232 |         content: [
233 |           {
234 |             type: "text",
235 |             text: `# Github Issue Comments
236 | **Raw JSON Data:**
237 | \`\`\`json
238 | ${JSON.stringify(data, null, 2)}
239 | \`\`\`
240 | **Please format the above data beautifully, refer to the following markdown example for the converted format and return the corresponding data.**
241 | ### title
242 | repo: repo
243 | issue_number: issue_number
244 | ------- Comment 1: user create_at -------
245 | comment body(Original text, no need to convert to md)
246 | `,
247 |           },
248 |         ],
249 |       };
250 |     } catch (error) {
251 |       console.error(`[ERROR] Failed to get issue comments:`, error);
252 |       return {
253 |         content: [{ type: "text", text: `Failed to get issue comments: ${error instanceof Error ? error.message : String(error)}` }],
254 |       };
255 |     }
256 |   },
257 | });
258 | 
259 | server.addTool({
260 |   name: "add_issue_comment",
261 |   description: "Add a comment to an existing issue",
262 |   parameters: issues.IssueCommentSchema,
263 |   execute: async (args: z.infer<typeof issues.IssueCommentSchema>) => {
264 |     const { owner, repo, issue_number, body } = args;
265 |     const result = await issues.addIssueComment(owner, repo, issue_number, body);
266 |     return {
267 |       content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
268 |     };
269 |   },
270 | });
271 | 
272 | server.addTool({
273 |   name: "sync_latest_issues",
274 |   description: "sync latest issues from Api",
275 |   execute: async () => {
276 |     try {
277 |       const response = await fetch(AI_API_URL + "/issues/sync_latest", {
278 |         method: 'GET',
279 |         headers: {
280 |           'Authorization': "Bearer " + AI_API_TOKEN,
281 |         }
282 |       });
283 | 
284 |       return {
285 |         content: [{ type: "text", text: await response.text() }],
286 |       };
287 |     } catch (error) {
288 |       return {
289 |         content: [{ type: "text", text: `API ERROR: ${error instanceof Error ? error.message : String(error)}` }],
290 |       };
291 |     }
292 |   },
293 | });
294 | 
295 | server.start({
296 |   transportType: "stdio"
297 | });
298 | 
```

--------------------------------------------------------------------------------
/src/smadex-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 | 
  9 | dotenv.config();
 10 | 
 11 | const server = new McpServer({
 12 |   name: "Smadex Reporting MCP Server",
 13 |   version: "0.0.1"
 14 | });
 15 | 
 16 | const SMADEX_API_BASE_URL = process.env.SMADEX_API_BASE_URL || '';
 17 | const SMADEX_EMAIL = process.env.SMADEX_EMAIL || '';
 18 | const SMADEX_PASSWORD = process.env.SMADEX_PASSWORD || '';
 19 | 
 20 | let accessToken = '';
 21 | let tokenExpirationTime = 0;
 22 | 
 23 | if (!SMADEX_EMAIL || !SMADEX_PASSWORD) {
 24 |   console.error("Missing Smadex API credentials. Please set SMADEX_EMAIL and SMADEX_PASSWORD environment variables.");
 25 |   process.exit(1);
 26 | }
 27 | 
 28 | /**
 29 |  * Get a valid access token for Smadex API
 30 |  */
 31 | async function getSmadexAccessToken(): Promise<string> {
 32 |   // Check if token is still valid (with 5 min buffer)
 33 |   const now = Math.floor(Date.now() / 1000);
 34 |   if (accessToken && tokenExpirationTime > now + 300) {
 35 |     return accessToken;
 36 |   }
 37 | 
 38 |   try {
 39 |     console.error('Fetching new Smadex access token');
 40 |     const response = await fetch(`${SMADEX_API_BASE_URL}/login`, {
 41 |       method: 'POST',
 42 |       headers: {
 43 |         'Content-Type': 'application/json'
 44 |       },
 45 |       body: JSON.stringify({
 46 |         email: SMADEX_EMAIL,
 47 |         password: SMADEX_PASSWORD
 48 |       })
 49 |     });
 50 | 
 51 |     if (!response.ok) {
 52 |       const errorBody = await response.text();
 53 |       console.error(`Smadex Auth Error: ${response.status} ${response.statusText} - ${errorBody}`);
 54 |       throw new Error(`Auth failed: ${response.status} ${response.statusText}`);
 55 |     }
 56 | 
 57 |     const data = await response.json();
 58 |     accessToken = data.accessToken;
 59 | 
 60 |     if (!accessToken) {
 61 |       throw new Error('Access Token is empty');
 62 |     }
 63 | 
 64 |     // Set expiration time (30 minutes from now)
 65 |     tokenExpirationTime = now + 30 * 60;
 66 | 
 67 |     console.error('Successfully obtained Smadex access token');
 68 |     return accessToken;
 69 |   } catch (error: any) {
 70 |     console.error('Error obtaining Smadex access token:', error);
 71 |     throw new Error(`Failed to get authentication token: ${error.message}`);
 72 |   }
 73 | }
 74 | 
 75 | /**
 76 |  * Create an asynchronous report request and get the report ID
 77 |  */
 78 | async function createReportRequest(params: any): Promise<string> {
 79 |   // Get a valid token
 80 |   const token = await getSmadexAccessToken();
 81 | 
 82 |   try {
 83 |     console.error(`Creating Smadex report request with params: ${JSON.stringify(params)}`);
 84 |     const response = await fetch(`${SMADEX_API_BASE_URL}/analytics/reports/async`, {
 85 |       method: 'POST',
 86 |       headers: {
 87 |         'Content-Type': 'application/json',
 88 |         'Authorization': `Bearer ${token}`
 89 |       },
 90 |       body: JSON.stringify(params)
 91 |     });
 92 | 
 93 |     if (!response.ok) {
 94 |       const errorBody = await response.text();
 95 |       console.error(`Smadex API Error: ${response.status} ${response.statusText} - ${errorBody}`);
 96 |       throw new Error(`API request failed: ${response.status} ${response.statusText}`);
 97 |     }
 98 | 
 99 |     const data = await response.json();
100 |     const reportId = data.id;
101 | 
102 |     if (!reportId) {
103 |       throw new Error('Report ID is empty');
104 |     }
105 | 
106 |     return reportId;
107 |   } catch (error: any) {
108 |      console.error(`Error creating Smadex report:`, error);
109 |      throw new Error(`Failed to create Smadex report: ${error.message}`);
110 |   }
111 | }
112 | 
113 | /**
114 |  * Check the status of an asynchronous report and get the download URL when complete
115 |  */
116 | async function getReportDownloadUrl(reportId: string): Promise<string> {
117 |   // Get a valid token
118 |   const token = await getSmadexAccessToken();
119 | 
120 |   try {
121 |     console.error(`Checking status of Smadex report: ${reportId}`);
122 | 
123 |     // Implement polling mechanism for report status
124 |     let downloadUrl = null;
125 |     let attempts = 0;
126 |     const maxAttempts = 20; // Maximum number of attempts (10 min total with 30s intervals)
127 | 
128 |     while (!downloadUrl && attempts < maxAttempts) {
129 |       const response = await fetch(`${SMADEX_API_BASE_URL}/analytics/reports/async/${reportId}`, {
130 |         method: 'GET',
131 |         headers: {
132 |           'Authorization': `Bearer ${token}`
133 |         }
134 |       });
135 | 
136 |       if (!response.ok) {
137 |         const errorBody = await response.text();
138 |         console.error(`Smadex API Error: ${response.status} ${response.statusText} - ${errorBody}`);
139 |         throw new Error(`API request failed: ${response.status} ${response.statusText}`);
140 |       }
141 | 
142 |       const data = await response.json();
143 | 
144 |       if (data.status === 'COMPLETED') {
145 |         downloadUrl = data.downloadUrl;
146 |         console.error('Report completed. Download URL available.');
147 |       } else {
148 |         console.error(`Report status: ${data.status}, waiting 30 seconds...`);
149 |         // Wait 30 seconds before the next check
150 |         await new Promise(resolve => setTimeout(resolve, 30000));
151 |         attempts++;
152 |       }
153 |     }
154 | 
155 |     if (!downloadUrl) {
156 |       throw new Error('Download URL is empty or report generation timed out');
157 |     }
158 | 
159 |     return downloadUrl;
160 |   } catch (error: any) {
161 |      console.error(`Error getting report download URL:`, error);
162 |      throw new Error(`Failed to get download URL: ${error.message}`);
163 |   }
164 | }
165 | 
166 | /**
167 |  * Download the report CSV data
168 |  */
169 | async function downloadReport(url: string): Promise<string> {
170 |   try {
171 |     console.error(`Downloading report from URL: ${url}`);
172 |     const response = await fetch(url);
173 | 
174 |     if (!response.ok) {
175 |       const errorBody = await response.text();
176 |       console.error(`Smadex Download Error: ${response.status} ${response.statusText} - ${errorBody}`);
177 |       throw new Error(`Download failed: ${response.status} ${response.statusText}`);
178 |     }
179 | 
180 |     const data = await response.text();
181 | 
182 |     if (!data) {
183 |       throw new Error('Downloaded data is empty');
184 |     }
185 | 
186 |     // Remove quotes from CSV data (as in the Ruby example)
187 |     const cleanedData = data.replace(/"/g, '');
188 |     return cleanedData;
189 |   } catch (error: any) {
190 |      console.error(`Error downloading report:`, error);
191 |      throw new Error(`Failed to download report: ${error.message}`);
192 |   }
193 | }
194 | 
195 | // Tool: Get Smadex Report ID
196 | server.tool("get_smadex_report_id",
197 |   "Create a Smadex report request and get the report ID.",
198 |   {
199 |     startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Start date must be in YYYY-MM-DD format").describe("Start date for the report (YYYY-MM-DD)"),
200 |     endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "End date must be in YYYY-MM-DD format").describe("End date for the report (YYYY-MM-DD)"),
201 | }, async ({ startDate, endDate }) => {
202 |   try {
203 |     // Validate date range logic
204 |     if (new Date(startDate) > new Date(endDate)) {
205 |       throw new Error("Start date cannot be after end date.");
206 |     }
207 | 
208 |     // Build report request parameters
209 |     const reportParams = {
210 |       dimensions: ['account_name', 'campaign_name', 'country'],
211 |       startDate: startDate,
212 |       endDate: endDate,
213 |       format: 'csv',
214 |       metrics: ['media_spend'],
215 |       rollUp: 'day'
216 |     };
217 | 
218 |     // Create a report request and get the report ID
219 |     console.error('Creating report request');
220 |     const reportId = await createReportRequest(reportParams);
221 |     console.error(`Report ID: ${reportId}`);
222 | 
223 |     return {
224 |       content: [
225 |         {
226 |           type: "text",
227 |           text: reportId
228 |         }
229 |       ]
230 |     };
231 |   } catch (error: any) {
232 |     const errorMessage = `Error creating Smadex report request: ${error.message}`;
233 |     return {
234 |       content: [
235 |         {
236 |           type: "text",
237 |           text: errorMessage
238 |         }
239 |       ],
240 |       isError: true
241 |     };
242 |   }
243 | });
244 | 
245 | // Tool: Get Smadex Report Download URL
246 | server.tool("get_smadex_report_download_url",
247 |   "Get the download URL for a Smadex report by its ID until the report is completed.",
248 |   {
249 |     reportId: z.string().describe("The report ID returned from get_smadex_report_id")
250 |   }, async ({ reportId }) => {
251 |   try {
252 |     console.error(`Getting download URL for report ID: ${reportId}`);
253 |     const downloadUrl = await getReportDownloadUrl(reportId);
254 |     console.error(`Download URL: ${downloadUrl}`);
255 | 
256 |     return {
257 |       content: [
258 |         {
259 |           type: "text",
260 |           text: downloadUrl
261 |         }
262 |       ]
263 |     };
264 |   } catch (error: any) {
265 |     const errorMessage = `Error getting download URL: ${error.message}`;
266 |     return {
267 |       content: [
268 |         {
269 |           type: "text",
270 |           text: errorMessage
271 |         }
272 |       ],
273 |       isError: true
274 |     };
275 |   }
276 | });
277 | 
278 | // Tool: Get Smadex Report Data
279 | server.tool("get_smadex_report",
280 |   "Download and return report data from a Smadex report download URL.",
281 |   {
282 |     downloadUrl: z.string().describe("The download URL for the report")
283 |   }, async ({ downloadUrl }) => {
284 |   try {
285 |     console.error(`Downloading report from URL: ${downloadUrl}`);
286 |     const reportData = await downloadReport(downloadUrl);
287 | 
288 |     return {
289 |       content: [
290 |         {
291 |           type: "text",
292 |           text: reportData
293 |         }
294 |       ]
295 |     };
296 |   } catch (error: any) {
297 |     const errorMessage = `Error downloading report: ${error.message}`;
298 |     return {
299 |       content: [
300 |         {
301 |           type: "text",
302 |           text: errorMessage
303 |         }
304 |       ],
305 |       isError: true
306 |     };
307 |   }
308 | });
309 | 
310 | // Start server
311 | async function runServer() {
312 |   const transport = new StdioServerTransport();
313 |   await server.connect(transport);
314 |   console.error("Smadex Reporting MCP Server running on stdio");
315 | }
316 | 
317 | runServer().catch((error) => {
318 |   console.error("Fatal error running server:", error);
319 |   process.exit(1);
320 | });
321 | 
```

--------------------------------------------------------------------------------
/src/civitai-records/src/lib/detectRemoteAssetType.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { fileTypeFromBuffer } from 'file-type';
  2 | import path from 'node:path';
  3 | 
  4 | type DetectionSource = 'header' | 'sniff' | 'extension' | 'fallback';
  5 | 
  6 | export interface RemoteAssetTypeResult {
  7 |   assetType: 'image' | 'video' | null;
  8 |   mime?: string;
  9 |   ext?: string;
 10 |   from: DetectionSource;
 11 | }
 12 | 
 13 | // ============================================================================
 14 | // MIME Type Classification Helpers
 15 | // ============================================================================
 16 | 
 17 | /**
 18 |  * Check if a MIME type indicates an image
 19 |  */
 20 | function isImageMime(mime: string): boolean {
 21 |   return mime.startsWith('image/');
 22 | }
 23 | 
 24 | /**
 25 |  * Check if a MIME type indicates a video
 26 |  */
 27 | function isVideoMime(mime: string): boolean {
 28 |   return mime.startsWith('video/');
 29 | }
 30 | 
 31 | /**
 32 |  * Check if a Content-Type is too generic to be useful
 33 |  */
 34 | function isGenericMime(contentType: string): boolean {
 35 |   return /octet-stream|binary/i.test(contentType);
 36 | }
 37 | 
 38 | /**
 39 |  * Convert MIME type to asset type classification
 40 |  */
 41 | function mimeToAssetType(mime: string): 'image' | 'video' | null {
 42 |   if (isImageMime(mime)) return 'image';
 43 |   if (isVideoMime(mime)) return 'video';
 44 |   return null;
 45 | }
 46 | 
 47 | // ============================================================================
 48 | // Network Request Helpers
 49 | // ============================================================================
 50 | 
 51 | /**
 52 |  * Create an AbortController with timeout for fetch requests
 53 |  */
 54 | function createTimeoutController(timeoutMs: number): { controller: AbortController; timeoutId: NodeJS.Timeout } {
 55 |   const controller = new AbortController();
 56 |   const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
 57 |   return { controller, timeoutId };
 58 | }
 59 | 
 60 | /**
 61 |  * Read up to maxBytes from a response body stream
 62 |  */
 63 | async function readPartialStream(reader: ReadableStreamDefaultReader<Uint8Array>, maxBytes: number): Promise<Buffer> {
 64 |   const chunks: Uint8Array[] = [];
 65 |   let total = 0;
 66 | 
 67 |   try {
 68 |     while (total < maxBytes) {
 69 |       const { value, done } = await reader.read();
 70 |       if (done || !value) break;
 71 |       
 72 |       chunks.push(value);
 73 |       total += value.byteLength;
 74 |       
 75 |       if (total >= maxBytes) {
 76 |         await reader.cancel();
 77 |         break;
 78 |       }
 79 |     }
 80 |   } catch (error) {
 81 |     // Reader may already be cancelled, ignore
 82 |   }
 83 | 
 84 |   return Buffer.concat(chunks.map(u8 => Buffer.from(u8)));
 85 | }
 86 | 
 87 | // ============================================================================
 88 | // Tier 1: HTTP HEAD Request Detection
 89 | // ============================================================================
 90 | 
 91 | /**
 92 |  * Detect asset type from HTTP HEAD request Content-Type header.
 93 |  * Fast method that doesn't download any file content.
 94 |  */
 95 | async function detectFromHttpHeaders(url: string, timeout: number): Promise<RemoteAssetTypeResult | null> {
 96 |   try {
 97 |     const { controller, timeoutId } = createTimeoutController(timeout);
 98 |     
 99 |     const response = await fetch(url, { 
100 |       method: 'HEAD',
101 |       signal: controller.signal
102 |     });
103 |     clearTimeout(timeoutId);
104 | 
105 |     if (!response.ok) {
106 |       return null;
107 |     }
108 | 
109 |     const contentType = response.headers.get('content-type')?.split(';')[0].trim();
110 |     
111 |     if (!contentType || isGenericMime(contentType)) {
112 |       if (contentType) {
113 |         console.debug(`Content-Type '${contentType}' is too generic, skipping header detection`);
114 |       }
115 |       return null;
116 |     }
117 | 
118 |     const assetType = mimeToAssetType(contentType);
119 |     if (assetType) {
120 |       console.debug(`✓ Detected ${assetType} via Content-Type header: ${contentType}`);
121 |       return { assetType, mime: contentType, from: 'header' };
122 |     }
123 | 
124 |     return null;
125 |   } catch (error) {
126 |     console.debug(`HEAD request failed: ${error instanceof Error ? error.message : error}`);
127 |     return null;
128 |   }
129 | }
130 | 
131 | // ============================================================================
132 | // Tier 2: Content Sniffing Detection
133 | // ============================================================================
134 | 
135 | /**
136 |  * Detect asset type by downloading and analyzing file signature (magic bytes).
137 |  * More accurate but requires partial file download (up to 16KB).
138 |  */
139 | async function detectFromContentSniffing(url: string, timeout: number): Promise<RemoteAssetTypeResult | null> {
140 |   const MAX_BYTES = 16 * 1024; // 16 KB
141 | 
142 |   try {
143 |     const { controller, timeoutId } = createTimeoutController(timeout);
144 |     
145 |     const response = await fetch(url, { 
146 |       headers: { Range: `bytes=0-${MAX_BYTES - 1}` },
147 |       signal: controller.signal
148 |     });
149 |     clearTimeout(timeoutId);
150 | 
151 |     if (!response.ok) {
152 |       throw new Error(`Fetch failed: ${response.status} ${response.statusText}`);
153 |     }
154 | 
155 |     const reader = response.body?.getReader();
156 |     if (!reader) {
157 |       return null;
158 |     }
159 | 
160 |     const buffer = await readPartialStream(reader, MAX_BYTES);
161 |     
162 |     if (buffer.length === 0) {
163 |       return null;
164 |     }
165 | 
166 |     const fileType = await fileTypeFromBuffer(buffer);
167 |     if (!fileType) {
168 |       console.debug(`Content sniffing returned no file type`);
169 |       return null;
170 |     }
171 | 
172 |     const assetType = mimeToAssetType(fileType.mime);
173 |     if (assetType) {
174 |       console.debug(`✓ Detected ${assetType} via content sniffing: ${fileType.mime} (${fileType.ext})`);
175 |       return { assetType, mime: fileType.mime, ext: fileType.ext, from: 'sniff' };
176 |     }
177 | 
178 |     return null;
179 |   } catch (error) {
180 |     console.debug(`Content sniffing failed: ${error instanceof Error ? error.message : error}`);
181 |     return null;
182 |   }
183 | }
184 | 
185 | // ============================================================================
186 | // Tier 3: URL Pattern Detection
187 | // ============================================================================
188 | 
189 | // Known file extensions by type
190 | const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg', '.ico', '.tiff', '.tif', '.heic', '.heif', '.avif'];
191 | const VIDEO_EXTENSIONS = ['.mp4', '.mov', '.avi', '.wmv', '.flv', '.mkv', '.webm', '.m4v', '.mpg', '.mpeg', '.3gp'];
192 | 
193 | /**
194 |  * Check if URL contains a known file extension
195 |  */
196 | function detectFromFileExtension(urlLower: string): RemoteAssetTypeResult | null {
197 |   // Check image extensions
198 |   for (const ext of IMAGE_EXTENSIONS) {
199 |     if (urlLower.includes(ext)) {
200 |       console.debug(`✓ Detected image from extension: ${ext}`);
201 |       return { assetType: 'image', ext: ext.slice(1), from: 'extension' };
202 |     }
203 |   }
204 |   
205 |   // Check video extensions
206 |   for (const ext of VIDEO_EXTENSIONS) {
207 |     if (urlLower.includes(ext)) {
208 |       console.debug(`✓ Detected video from extension: ${ext}`);
209 |       return { assetType: 'video', ext: ext.slice(1), from: 'extension' };
210 |     }
211 |   }
212 | 
213 |   return null;
214 | }
215 | 
216 | /**
217 |  * Check if URL path contains common CDN patterns
218 |  */
219 | function detectFromPathPattern(urlLower: string): RemoteAssetTypeResult | null {
220 |   if (urlLower.includes('/images/') || urlLower.includes('/image/')) {
221 |     console.debug(`✓ Detected image from path pattern: /images/ or /image/`);
222 |     return { assetType: 'image', from: 'extension' };
223 |   }
224 |   
225 |   if (urlLower.includes('/videos/') || urlLower.includes('/video/')) {
226 |     console.debug(`✓ Detected video from path pattern: /videos/ or /video/`);
227 |     return { assetType: 'video', from: 'extension' };
228 |   }
229 | 
230 |   return null;
231 | }
232 | 
233 | /**
234 |  * Extract and check file extension from URL pathname
235 |  */
236 | function detectFromUrlPathname(url: string): RemoteAssetTypeResult | null {
237 |   try {
238 |     const pathname = new URL(url).pathname;
239 |     const ext = path.extname(pathname).slice(1).toLowerCase();
240 |     
241 |     if (!ext) {
242 |       return null;
243 |     }
244 | 
245 |     if (IMAGE_EXTENSIONS.includes(`.${ext}`)) {
246 |       console.debug(`✓ Detected image from URL path extension: .${ext}`);
247 |       return { assetType: 'image', ext, from: 'extension' };
248 |     }
249 |     
250 |     if (VIDEO_EXTENSIONS.includes(`.${ext}`)) {
251 |       console.debug(`✓ Detected video from URL path extension: .${ext}`);
252 |       return { assetType: 'video', ext, from: 'extension' };
253 |     }
254 | 
255 |     return null;
256 |   } catch {
257 |     // Invalid URL
258 |     return null;
259 |   }
260 | }
261 | 
262 | /**
263 |  * Fast URL-based detection using extension and path patterns.
264 |  * This is the fallback method when remote detection fails or is skipped.
265 |  * No network calls, instant response.
266 |  */
267 | function detectFromUrl(url: string): RemoteAssetTypeResult {
268 |   const urlLower = url.toLowerCase();
269 |   
270 |   // Try file extension detection
271 |   const extResult = detectFromFileExtension(urlLower);
272 |   if (extResult) return extResult;
273 |   
274 |   // Try path pattern detection
275 |   const pathResult = detectFromPathPattern(urlLower);
276 |   if (pathResult) return pathResult;
277 |   
278 |   // Try URL pathname extraction
279 |   const pathnameResult = detectFromUrlPathname(url);
280 |   if (pathnameResult) return pathnameResult;
281 |   
282 |   // Nothing worked
283 |   console.debug(`✗ Unable to detect asset type from URL`);
284 |   return { assetType: null, from: 'fallback' };
285 | }
286 | 
287 | // ============================================================================
288 | // Main Detection Function
289 | // ============================================================================
290 | 
291 | /**
292 |  * Detect asset type from a remote URL using a multi-tier approach:
293 |  * 
294 |  * **Tier 1 (Remote - Fast):** HTTP HEAD request to check Content-Type header
295 |  * - Fastest method, no file download
296 |  * - Works when server provides accurate Content-Type
297 |  * 
298 |  * **Tier 2 (Remote - Accurate):** Partial content sniffing via Range request
299 |  * - Downloads only first 16KB to check file signature (magic bytes)
300 |  * - Most accurate, works even if server headers are wrong
301 |  * 
302 |  * **Tier 3 (Local - Fallback):** URL pattern matching
303 |  * - Checks file extension and path patterns
304 |  * - No network overhead, instant response
305 |  * - Used when remote methods fail or are unavailable
306 |  * 
307 |  * @param url - The remote URL to check
308 |  * @param options - Configuration options
309 |  * @param options.skipRemote - If true, skip remote checks and only use URL pattern matching (default: false)
310 |  * @param options.timeout - Request timeout in milliseconds (default: 5000)
311 |  * @returns Asset type detection result with source indicator
312 |  */
313 | export async function detectRemoteAssetType(
314 |   url: string,
315 |   options: { skipRemote?: boolean; timeout?: number } = {}
316 | ): Promise<RemoteAssetTypeResult> {
317 |   const { skipRemote = false, timeout = 5000 } = options;
318 | 
319 |   // If skipRemote is true, only use URL-based detection
320 |   if (skipRemote) {
321 |     console.debug(`Skipping remote detection, using URL-based fallback for: ${url}`);
322 |     return detectFromUrl(url);
323 |   }
324 | 
325 |   // TIER 1: Try HTTP HEAD request (fast)
326 |   const headerResult = await detectFromHttpHeaders(url, timeout);
327 |   if (headerResult) {
328 |     return headerResult;
329 |   }
330 | 
331 |   // TIER 2: Try content sniffing (accurate)
332 |   const sniffResult = await detectFromContentSniffing(url, timeout);
333 |   if (sniffResult) {
334 |     return sniffResult;
335 |   }
336 | 
337 |   // TIER 3: Fallback to URL-based detection (always succeeds)
338 |   console.debug(`Remote detection failed, using URL-based pattern matching as fallback`);
339 |   return detectFromUrl(url);
340 | }
341 | 
```

--------------------------------------------------------------------------------
/src/inmobi-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 | 
  9 | dotenv.config();
 10 | 
 11 | const server = new McpServer({
 12 |   name: "Inmobi Reporting MCP Server",
 13 |   version: "0.0.1"
 14 | });
 15 | 
 16 | const INMOBI_AUTH_URL = process.env.INMOBI_AUTH_URL || "";
 17 | const INMOBI_SKAN_REPORT_URL = process.env.INMOBI_SKAN_REPORT_URL || "";
 18 | const INMOBI_NON_SKAN_REPORT_URL = process.env.INMOBI_NON_SKAN_REPORT_URL || "";
 19 | const INMOBI_REPORT_BASE_URL = process.env.INMOBI_REPORT_BASE_URL || "";
 20 | const INMOBI_CLIENT_ID = process.env.INMOBI_CLIENT_ID || "";
 21 | const INMOBI_CLIENT_SECRET = process.env.INMOBI_CLIENT_SECRET || "";
 22 | 
 23 | const LOOP_COUNT = 24;
 24 | 
 25 | let authToken = '';
 26 | let tokenExpirationTime = 0;
 27 | 
 28 | if (!INMOBI_CLIENT_ID || !INMOBI_CLIENT_SECRET) {
 29 |   console.error("Missing Inmobi API credentials. Please set INMOBI_CLIENT_ID and INMOBI_CLIENT_SECRET environment variables.");
 30 |   process.exit(1);
 31 | }
 32 | 
 33 | /**
 34 |  * Get a valid auth token for Inmobi API
 35 |  */
 36 | async function getInmobiAuthToken(): Promise<string> {
 37 |   // Check if token is still valid
 38 |   const now = Math.floor(Date.now() / 1000);
 39 |   if (authToken && tokenExpirationTime > now + 300) {
 40 |     return authToken;
 41 |   }
 42 | 
 43 |   try {
 44 |     console.error('Fetching new Inmobi auth token');
 45 |     const requestBody = {
 46 |       clientId: INMOBI_CLIENT_ID,
 47 |       clientSecret: INMOBI_CLIENT_SECRET
 48 |     };
 49 | 
 50 |     const response = await fetch(INMOBI_AUTH_URL, {
 51 |       method: 'POST',
 52 |       headers: {
 53 |         'Content-Type': 'application/json;charset=utf-8'
 54 |       },
 55 |       body: JSON.stringify(requestBody)
 56 |     });
 57 | 
 58 |     if (!response.ok) {
 59 |       const errorBody = await response.text();
 60 |       console.error(`Inmobi Auth Error: ${response.status} ${response.statusText} - ${errorBody}`);
 61 |       throw new Error(`Auth failed: ${response.status} ${response.statusText}`);
 62 |     }
 63 | 
 64 |     const data = await response.json();
 65 |     authToken = data?.data?.token;
 66 | 
 67 |     if (!authToken) {
 68 |       throw new Error('No token returned from Inmobi API');
 69 |     }
 70 | 
 71 |     // Set expiration time (60 minutes from now, to be safe)
 72 |     tokenExpirationTime = now + 3600;
 73 | 
 74 |     console.error('Successfully obtained Inmobi auth token');
 75 |     return authToken;
 76 |   } catch (error: any) {
 77 |     console.error('Error obtaining Inmobi auth token:', error);
 78 |     throw new Error(`Failed to get authentication token: ${error.message}`);
 79 |   }
 80 | }
 81 | 
 82 | /**
 83 |  * Generate a report request payload
 84 |  */
 85 | function generatePayload(startDate: string, endDate: string, os: string): string {
 86 |   return JSON.stringify({
 87 |     startDate: startDate,
 88 |     endDate: endDate,
 89 |     filters: {
 90 |       os: [os]
 91 |     },
 92 |     dimensions: [
 93 |       "date",
 94 |       "campaign_id",
 95 |       "campaign_name",
 96 |       "os"
 97 |     ]
 98 |   });
 99 | }
100 | 
101 | /**
102 |  * Generate a SKAN report (iOS)
103 |  */
104 | async function getSkanReportId(startDate: string, endDate: string): Promise<string> {
105 |   try {
106 |     const token = await getInmobiAuthToken();
107 |     const payload = generatePayload(startDate, endDate, 'iOS');
108 | 
109 |     const response = await fetch(INMOBI_SKAN_REPORT_URL, {
110 |       method: 'POST',
111 |       headers: {
112 |         'Authorization': token,
113 |         'Content-Type': 'application/json;charset=utf-8'
114 |       },
115 |       body: payload
116 |     });
117 | 
118 |     if (!response.ok) {
119 |       const errorBody = await response.text();
120 |       console.error(`Inmobi SKAN Report Error: ${response.status} ${response.statusText} - ${errorBody}`);
121 |       throw new Error(`SKAN Report generation failed: ${response.status} ${response.statusText}`);
122 |     }
123 | 
124 |     const data = await response.json();
125 |     const reportId = data?.data?.reportId;
126 | 
127 |     if (!reportId) {
128 |       throw new Error('No reportId returned from Inmobi API');
129 |     }
130 | 
131 |     return reportId;
132 |   } catch (error: any) {
133 |     console.error('Error generating SKAN report:', error);
134 |     throw new Error(`Failed to generate SKAN report: ${error.message}`);
135 |   }
136 | }
137 | 
138 | /**
139 |  * Generate a non-SKAN report (Android)
140 |  */
141 | async function getNonSkanReportId(startDate: string, endDate: string): Promise<string> {
142 |   try {
143 |     const token = await getInmobiAuthToken();
144 |     const payload = generatePayload(startDate, endDate, 'Android');
145 | 
146 |     const response = await fetch(INMOBI_NON_SKAN_REPORT_URL, {
147 |       method: 'POST',
148 |       headers: {
149 |         'Authorization': token,
150 |         'Content-Type': 'application/json;charset=utf-8'
151 |       },
152 |       body: payload
153 |     });
154 | 
155 |     if (!response.ok) {
156 |       const errorBody = await response.text();
157 |       console.error(`Inmobi non-SKAN Report Error: ${response.status} ${response.statusText} - ${errorBody}`);
158 |       throw new Error(`Non-SKAN Report generation failed: ${response.status} ${response.statusText}`);
159 |     }
160 | 
161 |     const data = await response.json();
162 |     const reportId = data?.data?.reportId;
163 | 
164 |     if (!reportId) {
165 |       throw new Error('No reportId returned from Inmobi API');
166 |     }
167 | 
168 |     return reportId;
169 |   } catch (error: any) {
170 |     console.error('Error generating non-SKAN report:', error);
171 |     throw new Error(`Failed to generate non-SKAN report: ${error.message}`);
172 |   }
173 | }
174 | 
175 | /**
176 |  * Check the status of a report
177 |  */
178 | async function getReportStatus(reportId: string): Promise<string | null> {
179 |   try {
180 |     if (!reportId) return null;
181 | 
182 |     const token = await getInmobiAuthToken();
183 |     const url = `${INMOBI_REPORT_BASE_URL}/${reportId}/status`;
184 | 
185 |     const response = await fetch(url, {
186 |       method: 'GET',
187 |       headers: {
188 |         'Authorization': token
189 |       }
190 |     });
191 | 
192 |     if (!response.ok) {
193 |       const errorBody = await response.text();
194 |       console.error(`Inmobi Report Status Error: ${response.status} ${response.statusText} - ${errorBody}`);
195 |       throw new Error(`Report status check failed: ${response.status} ${response.statusText}`);
196 |     }
197 | 
198 |     const data = await response.json();
199 |     return data?.data?.reportStatus || null;
200 |   } catch (error: any) {
201 |     console.error('Error checking report status:', error);
202 |     throw new Error(`Failed to check report status: ${error.message}`);
203 |   }
204 | }
205 | 
206 | /**
207 |  * Wait for a report to be ready
208 |  */
209 | async function checkReportStatus(reportId: string): Promise<boolean> {
210 |   let count = 0;
211 |   let reportAvailable = false;
212 | 
213 |   while (count < LOOP_COUNT) {
214 |     const status = await getReportStatus(reportId);
215 | 
216 |     if (status === 'report.status.available') {
217 |       reportAvailable = true;
218 |       break;
219 |     }
220 | 
221 |     count++;
222 |     // Wait 5 seconds between status checks
223 |     await new Promise(resolve => setTimeout(resolve, 5000));
224 |   }
225 | 
226 |   if (!reportAvailable) {
227 |     console.error(`Check report status timeout for report ID: ${reportId}`);
228 |   }
229 | 
230 |   return reportAvailable;
231 | }
232 | 
233 | /**
234 |  * Download report data
235 |  */
236 | async function fetchReportData(reportId: string): Promise<any[]> {
237 |   try {
238 |     if (!reportId) return [];
239 | 
240 |     const token = await getInmobiAuthToken();
241 |     const url = `${INMOBI_REPORT_BASE_URL}/${reportId}/download`;
242 | 
243 |     const response = await fetch(url, {
244 |       method: 'GET',
245 |       headers: {
246 |         'Authorization': token
247 |       }
248 |     });
249 | 
250 |     if (!response.ok) {
251 |       const errorBody = await response.text();
252 |       console.error(`Inmobi Report Download Error: ${response.status} ${response.statusText} - ${errorBody}`);
253 |       throw new Error(`Report download failed: ${response.status} ${response.statusText}`);
254 |     }
255 | 
256 |     const csvData = await response.text();
257 | 
258 |     // Parse CSV data
259 |     // This is a simple parsing approach - you might need a more robust CSV parser
260 |     const rows = csvData.split('\n');
261 |     const headers = rows[0].split(',');
262 | 
263 |     return rows.slice(1).filter(row => row.trim()).map(row => {
264 |       const values = row.split(',');
265 |       const obj: any = {};
266 | 
267 |       headers.forEach((header, index) => {
268 |         obj[header.trim()] = values[index]?.trim() || '';
269 |       });
270 | 
271 |       return obj;
272 |     });
273 |   } catch (error: any) {
274 |     console.error('Error fetching report data:', error);
275 |     throw new Error(`Failed to fetch report data: ${error.message}`);
276 |   }
277 | }
278 | 
279 | // Tool: Generate report IDs
280 | server.tool("generate_inmobi_report_ids",
281 |   "Generate Inmobi report IDs for SKAN (iOS) and non-SKAN (Android) reports.",
282 |   {
283 |     startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Start date must be in YYYY-MM-DD format").describe("Start date for the report (YYYY-MM-DD)"),
284 |     endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "End date must be in YYYY-MM-DD format").describe("End date for the report (YYYY-MM-DD)"),
285 |   }, async ({ startDate, endDate }) => {
286 |   try {
287 |     // Validate date range logic
288 |     if (new Date(startDate) > new Date(endDate)) {
289 |       throw new Error("Start date cannot be after end date.");
290 |     }
291 | 
292 |     const skanReportId = await getSkanReportId(startDate, endDate);
293 |     const nonSkanReportId = await getNonSkanReportId(startDate, endDate);
294 | 
295 |     return {
296 |       content: [
297 |         {
298 |           type: "text",
299 |           text: JSON.stringify({
300 |             skanReportId,
301 |             nonSkanReportId
302 |           }, null, 2)
303 |         }
304 |       ]
305 |     };
306 |   } catch (error: any) {
307 |     const errorMessage = `Error generating Inmobi report IDs: ${error.message}`;
308 | 
309 |     return {
310 |       content: [
311 |         {
312 |           type: "text",
313 |           text: errorMessage
314 |         }
315 |       ],
316 |       isError: true
317 |     };
318 |   }
319 | });
320 | 
321 | // Tool: Fetch report data
322 | server.tool("fetch_inmobi_report_data",
323 |   "Fetch data from Inmobi reports using report IDs.",
324 |   {
325 |     skanReportId: z.string().describe("SKAN report ID obtained from generate_inmobi_report_ids"),
326 |     nonSkanReportId: z.string().describe("Non-SKAN report ID obtained from generate_inmobi_report_ids"),
327 |   }, async ({ skanReportId, nonSkanReportId }) => {
328 |   try {
329 |     let allData: any[] = [];
330 | 
331 |     // Check SKAN report status and fetch data if available
332 |     const skanReportAvailable = await checkReportStatus(skanReportId);
333 |     if (skanReportAvailable) {
334 |       const skanData = await fetchReportData(skanReportId);
335 |       allData = allData.concat(skanData);
336 |     }
337 | 
338 |     // Check non-SKAN report status and fetch data if available
339 |     const nonSkanReportAvailable = await checkReportStatus(nonSkanReportId);
340 |     if (nonSkanReportAvailable) {
341 |       const nonSkanData = await fetchReportData(nonSkanReportId);
342 |       allData = allData.concat(nonSkanData);
343 |     }
344 | 
345 |     if (allData.length === 0) {
346 |       throw new Error("No data available from either report.");
347 |     }
348 | 
349 |     return {
350 |       content: [
351 |         {
352 |           type: "text",
353 |           text: JSON.stringify(allData, null, 2)
354 |         }
355 |       ]
356 |     };
357 |   } catch (error: any) {
358 |     const errorMessage = `Error fetching Inmobi report data: ${error.message}`;
359 | 
360 |     return {
361 |       content: [
362 |         {
363 |           type: "text",
364 |           text: errorMessage
365 |         }
366 |       ],
367 |       isError: true
368 |     };
369 |   }
370 | });
371 | 
372 | // Start server
373 | async function runServer() {
374 |   const transport = new StdioServerTransport();
375 |   await server.connect(transport);
376 |   console.error("Inmobi Reporting MCP Server running on stdio");
377 | }
378 | 
379 | runServer().catch((error) => {
380 |   console.error("Fatal error running server:", error);
381 |   process.exit(1);
382 | });
383 | 
```
Page 3/5FirstPrevNextLast