This is page 3 of 4. Use http://codebase.md/feed-mob/fm-mcp-servers?lines=false&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/user-activity-reporting/src/api.ts:
--------------------------------------------------------------------------------
```typescript
import jwt from 'jsonwebtoken';
import dotenv from 'dotenv';
dotenv.config();
const API_BASE = process.env.FEEDMOB_API_BASE;
const API_KEY = process.env.FEEDMOB_KEY;
const API_SECRET = process.env.FEEDMOB_SECRET;
if (!API_KEY || !API_SECRET) {
console.error("Error: FEEDMOB_KEY and FEEDMOB_SECRET must be set.");
process.exit(1);
}
function genToken(): string {
const exp = new Date();
exp.setDate(exp.getDate() + 7);
return jwt.sign({ key: API_KEY, expired_at: exp.toISOString().split('T')[0] }, API_SECRET!, { algorithm: 'HS256' });
}
function buildUrl(path: string, params: Record<string, string>): string {
const url = new URL(`${API_BASE}${path}`);
Object.entries(params).forEach(([k, v]) => url.searchParams.append(k, v));
return url.toString();
}
async function apiGet(path: string, params: Record<string, string> = {}): Promise<any> {
const res = await fetch(buildUrl(path, params), {
headers: {
'Content-Type': 'application/json', 'Accept': 'application/json',
'FEEDMOB-KEY': API_KEY!, 'FEEDMOB-TOKEN': genToken()
},
signal: AbortSignal.timeout(30000)
});
if (res.status === 401) throw new Error('Unauthorized: Invalid API Key or Token');
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `API error: ${res.status}`);
}
const r = await res.json();
if (r.status === 404) throw new Error(r.error || 'Not found');
if (r.status === 400) throw new Error(r.error || 'Bad request');
return r.data;
}
export type ValidRole = 'aa' | 'am' | 'ae' | 'pm' | 'pa' | 'ao';
export interface ClientContact {
client_id: number;
client_name: string;
month?: string;
pod?: string;
aa?: string;
am?: string;
ae?: string;
pm?: string;
pa?: string;
ao?: string;
}
export interface ListResult { month: string; total: number; clients: ClientContact[]; }
export interface PodResult { pod: string; month: string; count: number; client_names: string[]; }
export interface RoleResult { role: string; name: string; month: string; count: number; client_names: string[]; }
export interface NameResult { name: string; month: string; results: Record<string, string[]>; }
export async function getAllContacts(month?: string): Promise<ListResult> {
return apiGet('/ai/api/client_contacts', month ? { month } : {});
}
export async function getContactByClient(name: string, month?: string): Promise<ClientContact> {
const p: Record<string, string> = { client_name: name };
if (month) p.month = month;
return apiGet('/ai/api/client_contacts', p);
}
export async function getClientsByPod(pod: string, month?: string): Promise<PodResult> {
const p: Record<string, string> = { pod };
if (month) p.month = month;
return apiGet('/ai/api/client_contacts', p);
}
export async function getClientsByRole(role: ValidRole, name: string, month?: string): Promise<RoleResult> {
const p: Record<string, string> = { role, name };
if (month) p.month = month;
return apiGet('/ai/api/client_contacts', p);
}
export async function getClientsByName(name: string, month?: string): Promise<NameResult> {
const p: Record<string, string> = { name };
if (month) p.month = month;
return apiGet('/ai/api/client_contacts', p);
}
const SLACK_TOKEN = process.env.SLACK_BOT_TOKEN;
const HUBSPOT_TOKEN = process.env.HUBSPOT_ACCESS_TOKEN;
export interface SlackMsg { ts: string; text: string; user: string; channel: string; permalink?: string; }
export interface SlackUser { id: string; name: string; real_name: string; email?: string; }
async function slackGet(method: string, params: Record<string, string> = {}): Promise<any> {
if (!SLACK_TOKEN) throw new Error('SLACK_BOT_TOKEN not set');
const url = new URL(`https://slack.com/api/${method}`);
Object.entries(params).forEach(([k, v]) => url.searchParams.append(k, v));
const r = await (await fetch(url.toString(), {
headers: { 'Authorization': `Bearer ${SLACK_TOKEN}`, 'Content-Type': 'application/x-www-form-urlencoded' }
})).json();
if (!r.ok) throw new Error(`Slack error: ${r.error}`);
return r;
}
export async function findSlackUser(name: string): Promise<SlackUser | null> {
const { members = [] } = await slackGet('users.list');
const n = name.toLowerCase();
const u = members.find((m: any) =>
m.real_name?.toLowerCase().includes(n) || m.name?.toLowerCase().includes(n) || m.profile?.display_name?.toLowerCase().includes(n)
);
return u ? { id: u.id, name: u.name, real_name: u.real_name || u.name, email: u.profile?.email } : null;
}
export async function searchSlackMsgs(userName: string, query?: string, limit = 20): Promise<SlackMsg[]> {
const user = await findSlackUser(userName);
if (!user) throw new Error(`Slack user not found: ${userName}`);
const q = query ? `from:${user.name} ${query}` : `from:${user.name}`;
const { messages } = await slackGet('search.messages', { query: q, count: String(limit), sort: 'timestamp', sort_dir: 'desc' });
return (messages?.matches || []).map((m: any) => ({
ts: m.ts, text: m.text, user: m.username || user.name,
channel: m.channel?.name || m.channel?.id || 'unknown', permalink: m.permalink
}));
}
export interface Ticket {
id: string; subject: string; content?: string; status: string;
priority?: string; createdAt: string; updatedAt: string; owner?: string;
}
async function hsPost(endpoint: string, body: any): Promise<any> {
if (!HUBSPOT_TOKEN) throw new Error('HUBSPOT_ACCESS_TOKEN not set');
const res = await fetch(`https://api.hubapi.com${endpoint}`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${HUBSPOT_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) throw new Error(`HubSpot error: ${res.status} - ${await res.text()}`);
return res.json();
}
async function hsGet(endpoint: string): Promise<any> {
if (!HUBSPOT_TOKEN) throw new Error('HUBSPOT_ACCESS_TOKEN not set');
const res = await fetch(`https://api.hubapi.com${endpoint}`, {
headers: { 'Authorization': `Bearer ${HUBSPOT_TOKEN}`, 'Content-Type': 'application/json' }
});
if (!res.ok) throw new Error(`HubSpot error: ${res.status} - ${await res.text()}`);
return res.json();
}
function mapTicket(t: any): Ticket {
const p = t.properties;
return {
id: t.id, subject: p.subject || 'No Subject', content: p.content,
status: p.hs_pipeline_stage || 'unknown', priority: p.hs_ticket_priority,
createdAt: p.createdate, updatedAt: p.hs_lastmodifieddate, owner: p.hubspot_owner_id
};
}
export async function getTickets(opts: { status?: string; startDate?: string; endDate?: string; limit?: number } = {}): Promise<Ticket[]> {
const filters: any[] = [];
if (opts.startDate) filters.push({ propertyName: 'createdate', operator: 'GTE', value: new Date(opts.startDate).getTime() });
if (opts.endDate) filters.push({ propertyName: 'createdate', operator: 'LTE', value: new Date(opts.endDate).getTime() });
if (opts.status) filters.push({ propertyName: 'hs_pipeline_stage', operator: 'EQ', value: opts.status });
const body: any = {
properties: ['subject', 'content', 'hs_pipeline_stage', 'hs_ticket_priority', 'createdate', 'hs_lastmodifieddate'],
limit: opts.limit || 50, sorts: [{ propertyName: 'createdate', direction: 'DESCENDING' }]
};
if (filters.length) body.filterGroups = [{ filters }];
const { results = [] } = await hsPost('/crm/v3/objects/tickets/search', body);
return results.map(mapTicket);
}
export async function getTicketById(id: string): Promise<Ticket | null> {
const props = 'subject,content,hs_pipeline_stage,hs_ticket_priority,createdate,hs_lastmodifieddate';
const data = await hsGet(`/crm/v3/objects/tickets/${id}?properties=${props}`);
return data ? mapTicket(data) : null;
}
export async function getTicketsByUser(opts: { userName?: string; email?: string; limit?: number }): Promise<Ticket[]> {
const { results: owners = [] } = await hsGet('/crm/v3/owners');
const term = (opts.userName || opts.email || '').toLowerCase();
const matched = owners.filter((o: any) => {
const fn = (o.firstName || '').toLowerCase(), ln = (o.lastName || '').toLowerCase();
return fn.includes(term) || ln.includes(term) || `${fn} ${ln}`.includes(term) || (o.email || '').toLowerCase().includes(term);
});
if (!matched.length) return [];
const body = {
properties: ['subject', 'content', 'hs_pipeline_stage', 'hs_ticket_priority', 'createdate', 'hs_lastmodifieddate', 'hubspot_owner_id'],
limit: opts.limit || 50, sorts: [{ propertyName: 'createdate', direction: 'DESCENDING' }],
filterGroups: [{ filters: [{ propertyName: 'hubspot_owner_id', operator: 'IN', values: matched.map((o: any) => o.id) }] }]
};
const { results = [] } = await hsPost('/crm/v3/objects/tickets/search', body);
const ownerMap = new Map(owners.map((o: any) => [o.id, `${o.firstName || ''} ${o.lastName || ''}`.trim() || o.email]));
return results.map((t: any) => ({ ...mapTicket(t), owner: ownerMap.get(t.properties.hubspot_owner_id) }));
}
```
--------------------------------------------------------------------------------
/src/github-issues/index.ts:
--------------------------------------------------------------------------------
```typescript
#!/usr/bin/env node
import { FastMCP } from "fastmcp";
import { z } from 'zod';
import fetch, { Request, Response } from 'node-fetch';
import * as issues from './operations/issues.js';
import * as search from './operations/search.js';
import {
GitHubError,
GitHubValidationError,
GitHubResourceNotFoundError,
GitHubAuthenticationError,
GitHubPermissionError,
GitHubRateLimitError,
GitHubConflictError,
isGitHubError,
} from './common/errors.js';
import { VERSION } from "./common/version.js";
// If fetch doesn't exist in global scope, add it
if (!globalThis.fetch) {
globalThis.fetch = fetch as unknown as typeof global.fetch;
}
// Default values from environment variables
const DEFAULT_OWNER = process.env.GITHUB_DEFAULT_OWNER;
const AI_API_URL = process.env.AI_API_URL;
const AI_API_TOKEN = process.env.AI_API_TOKEN;
const server = new FastMCP({
name: "feedmob-github-mcp-server",
version: VERSION
});
function formatGitHubError(error: GitHubError): string {
let message = `GitHub API Error: ${error.message}`;
if (error instanceof GitHubValidationError) {
message = `Validation Error: ${error.message}`;
if (error.response) {
message += `\nDetails: ${JSON.stringify(error.response)}`;
}
} else if (error instanceof GitHubResourceNotFoundError) {
message = `Not Found: ${error.message}`;
} else if (error instanceof GitHubAuthenticationError) {
message = `Authentication Failed: ${error.message}`;
} else if (error instanceof GitHubPermissionError) {
message = `Permission Denied: ${error.message}`;
} else if (error instanceof GitHubRateLimitError) {
message = `Rate Limit Exceeded: ${error.message}\nResets at: ${error.resetAt.toISOString()}`;
} else if (error instanceof GitHubConflictError) {
message = `Conflict: ${error.message}`;
}
return message;
}
server.addResource({
uri: "issues/search_schema",
name: "search issues schema",
mimeType: "text/markdown",
async load() {
const response = await fetch(AI_API_URL + "/issues/scheam", {
method: 'GET',
headers: {
'Authorization': "Bearer " + AI_API_TOKEN,
}
});
return {
text: await response.text(),
};
},
});
server.addTool({
name: "search_issues",
description: "Search GitHub Issues",
parameters: issues.FeedmobSearchOptions,
execute: async (args: z.infer<typeof issues.FeedmobSearchOptions>) => {
try {
let params = new URLSearchParams();
params.set('start_date', args.start_date);
params.set('end_date', args.end_date);
args.fields.forEach(field => params.append('fields[]', field));
if (args.status !== undefined) {
params.set('status', args.status);
}
if (args.repo !== undefined) {
params.set('repo', args.repo);
}
if (args.users !== undefined) {
args.users.forEach(user => params.append('users[]', user));
}
if (args.team !== undefined) {
params.set('team', args.team);
}
if (args.title !== undefined) {
params.set('title', args.title);
}
if (args.labels !== undefined) {
args.labels.forEach(label => params.append('labels[]', label));
}
if (args.score_status !== undefined) {
params.set('score_status', args.score_status);
}
const response = await fetch(`${AI_API_URL}/issues?${params}`, {
method: 'GET',
headers: {
'Authorization': "Bearer " + AI_API_TOKEN
}
});
const data = await response.text();
return {
content: [
{
type: "text",
text: `# Github Issue Query Result
**Raw JSON Data:**
\`\`\`json
${JSON.stringify(data, null, 2)}
\`\`\`
**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.**
`,
},
],
};
} catch (error) {
return {
content: [{ type: "text", text: `API ERROR: ${error instanceof Error ? error.message : String(error)}` }],
};
}
}
});
server.addTool({
name: "create_issue",
description: "Create a new issue in a GitHub repository",
parameters: issues.CreateIssueSchema,
execute: async (args: z.infer<typeof issues.CreateIssueSchema>) => {
const owner = args.owner || DEFAULT_OWNER;
const repo = args.repo;
const { ...options } = args;
if (!owner || !repo) {
throw new Error("Repository owner and name are required. Either provide them directly or set GITHUB_DEFAULT_OWNER environment variables.");
}
try {
console.error(`[DEBUG] Attempting to create issue in ${owner}/${repo}`);
console.error(`[DEBUG] Issue options:`, JSON.stringify(options, null, 2));
const issue = await issues.createIssue(owner, repo, options);
console.error(`[DEBUG] Issue created successfully`);
return {
content: [{ type: "text", text: JSON.stringify(issue, null, 2) }],
};
} catch (err) {
// Type guard for Error objects
const error = err instanceof Error ? err : new Error(String(err));
console.error(`[ERROR] Failed to create issue:`, error);
if (error instanceof GitHubResourceNotFoundError) {
throw new Error(
`Repository '${owner}/${repo}' not found. Please verify:\n` +
`1. The repository exists\n` +
`2. You have correct access permissions\n` +
`3. The owner and repository names are spelled correctly`
);
}
// Safely access error properties
throw new Error(
`Failed to create issue: ${error.message}${error.stack ? `\nStack: ${error.stack}` : ''
}`
);
}
},
});
server.addTool({
name: "update_issue",
description: "Update an existing issue in a GitHub repository",
parameters: issues.UpdateIssueOptionsSchema,
execute: async (args: z.infer<typeof issues.UpdateIssueOptionsSchema>) => {
const owner = args.owner || DEFAULT_OWNER;
const repo = args.repo;
const { issue_number, ...options } = args;
if (!owner || !repo) {
throw new Error("Repository owner and name are required. Either provide them directly or set GITHUB_DEFAULT_OWNER environment variables.");
}
const result = await issues.updateIssue(owner, repo, issue_number, options);
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
};
},
});
server.addTool({
name: "get_issues",
description: "Get comments for multiple issues in bulk",
parameters: issues.GetIssueSchema,
execute: async (args: z.infer<typeof issues.GetIssueSchema>) => {
try {
const response = await fetch(`${AI_API_URL}/issues/get_comments`, {
method: 'POST',
headers: {
'Authorization': "Bearer " + AI_API_TOKEN,
'Content-Type': 'application/json'
},
body: JSON.stringify({
repo_issues: args.repo_issues,
comment_count: args.comment_count
})
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.text();
return {
content: [
{
type: "text",
text: `# Github Issue Comments
**Raw JSON Data:**
\`\`\`json
${JSON.stringify(data, null, 2)}
\`\`\`
**Please format the above data beautifully, refer to the following markdown example for the converted format and return the corresponding data.**
### title
repo: repo
issue_number: issue_number
------- Comment 1: user create_at -------
comment body(Original text, no need to convert to md)
`,
},
],
};
} catch (error) {
console.error(`[ERROR] Failed to get issue comments:`, error);
return {
content: [{ type: "text", text: `Failed to get issue comments: ${error instanceof Error ? error.message : String(error)}` }],
};
}
},
});
server.addTool({
name: "add_issue_comment",
description: "Add a comment to an existing issue",
parameters: issues.IssueCommentSchema,
execute: async (args: z.infer<typeof issues.IssueCommentSchema>) => {
const { owner, repo, issue_number, body } = args;
const result = await issues.addIssueComment(owner, repo, issue_number, body);
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
};
},
});
server.addTool({
name: "sync_latest_issues",
description: "sync latest issues from Api",
execute: async () => {
try {
const response = await fetch(AI_API_URL + "/issues/sync_latest", {
method: 'GET',
headers: {
'Authorization': "Bearer " + AI_API_TOKEN,
}
});
return {
content: [{ type: "text", text: await response.text() }],
};
} catch (error) {
return {
content: [{ type: "text", text: `API ERROR: ${error instanceof Error ? error.message : String(error)}` }],
};
}
},
});
server.start({
transportType: "stdio"
});
```
--------------------------------------------------------------------------------
/src/smadex-reporting/src/index.ts:
--------------------------------------------------------------------------------
```typescript
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import fetch from "node-fetch";
import dotenv from "dotenv";
dotenv.config();
const server = new McpServer({
name: "Smadex Reporting MCP Server",
version: "0.0.1"
});
const SMADEX_API_BASE_URL = process.env.SMADEX_API_BASE_URL || '';
const SMADEX_EMAIL = process.env.SMADEX_EMAIL || '';
const SMADEX_PASSWORD = process.env.SMADEX_PASSWORD || '';
let accessToken = '';
let tokenExpirationTime = 0;
if (!SMADEX_EMAIL || !SMADEX_PASSWORD) {
console.error("Missing Smadex API credentials. Please set SMADEX_EMAIL and SMADEX_PASSWORD environment variables.");
process.exit(1);
}
/**
* Get a valid access token for Smadex API
*/
async function getSmadexAccessToken(): Promise<string> {
// Check if token is still valid (with 5 min buffer)
const now = Math.floor(Date.now() / 1000);
if (accessToken && tokenExpirationTime > now + 300) {
return accessToken;
}
try {
console.error('Fetching new Smadex access token');
const response = await fetch(`${SMADEX_API_BASE_URL}/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: SMADEX_EMAIL,
password: SMADEX_PASSWORD
})
});
if (!response.ok) {
const errorBody = await response.text();
console.error(`Smadex Auth Error: ${response.status} ${response.statusText} - ${errorBody}`);
throw new Error(`Auth failed: ${response.status} ${response.statusText}`);
}
const data = await response.json();
accessToken = data.accessToken;
if (!accessToken) {
throw new Error('Access Token is empty');
}
// Set expiration time (30 minutes from now)
tokenExpirationTime = now + 30 * 60;
console.error('Successfully obtained Smadex access token');
return accessToken;
} catch (error: any) {
console.error('Error obtaining Smadex access token:', error);
throw new Error(`Failed to get authentication token: ${error.message}`);
}
}
/**
* Create an asynchronous report request and get the report ID
*/
async function createReportRequest(params: any): Promise<string> {
// Get a valid token
const token = await getSmadexAccessToken();
try {
console.error(`Creating Smadex report request with params: ${JSON.stringify(params)}`);
const response = await fetch(`${SMADEX_API_BASE_URL}/analytics/reports/async`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(params)
});
if (!response.ok) {
const errorBody = await response.text();
console.error(`Smadex API Error: ${response.status} ${response.statusText} - ${errorBody}`);
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
}
const data = await response.json();
const reportId = data.id;
if (!reportId) {
throw new Error('Report ID is empty');
}
return reportId;
} catch (error: any) {
console.error(`Error creating Smadex report:`, error);
throw new Error(`Failed to create Smadex report: ${error.message}`);
}
}
/**
* Check the status of an asynchronous report and get the download URL when complete
*/
async function getReportDownloadUrl(reportId: string): Promise<string> {
// Get a valid token
const token = await getSmadexAccessToken();
try {
console.error(`Checking status of Smadex report: ${reportId}`);
// Implement polling mechanism for report status
let downloadUrl = null;
let attempts = 0;
const maxAttempts = 20; // Maximum number of attempts (10 min total with 30s intervals)
while (!downloadUrl && attempts < maxAttempts) {
const response = await fetch(`${SMADEX_API_BASE_URL}/analytics/reports/async/${reportId}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
const errorBody = await response.text();
console.error(`Smadex API Error: ${response.status} ${response.statusText} - ${errorBody}`);
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
}
const data = await response.json();
if (data.status === 'COMPLETED') {
downloadUrl = data.downloadUrl;
console.error('Report completed. Download URL available.');
} else {
console.error(`Report status: ${data.status}, waiting 30 seconds...`);
// Wait 30 seconds before the next check
await new Promise(resolve => setTimeout(resolve, 30000));
attempts++;
}
}
if (!downloadUrl) {
throw new Error('Download URL is empty or report generation timed out');
}
return downloadUrl;
} catch (error: any) {
console.error(`Error getting report download URL:`, error);
throw new Error(`Failed to get download URL: ${error.message}`);
}
}
/**
* Download the report CSV data
*/
async function downloadReport(url: string): Promise<string> {
try {
console.error(`Downloading report from URL: ${url}`);
const response = await fetch(url);
if (!response.ok) {
const errorBody = await response.text();
console.error(`Smadex Download Error: ${response.status} ${response.statusText} - ${errorBody}`);
throw new Error(`Download failed: ${response.status} ${response.statusText}`);
}
const data = await response.text();
if (!data) {
throw new Error('Downloaded data is empty');
}
// Remove quotes from CSV data (as in the Ruby example)
const cleanedData = data.replace(/"/g, '');
return cleanedData;
} catch (error: any) {
console.error(`Error downloading report:`, error);
throw new Error(`Failed to download report: ${error.message}`);
}
}
// Tool: Get Smadex Report ID
server.tool("get_smadex_report_id",
"Create a Smadex report request and get the report ID.",
{
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)"),
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)"),
}, async ({ startDate, endDate }) => {
try {
// Validate date range logic
if (new Date(startDate) > new Date(endDate)) {
throw new Error("Start date cannot be after end date.");
}
// Build report request parameters
const reportParams = {
dimensions: ['account_name', 'campaign_name', 'country'],
startDate: startDate,
endDate: endDate,
format: 'csv',
metrics: ['media_spend'],
rollUp: 'day'
};
// Create a report request and get the report ID
console.error('Creating report request');
const reportId = await createReportRequest(reportParams);
console.error(`Report ID: ${reportId}`);
return {
content: [
{
type: "text",
text: reportId
}
]
};
} catch (error: any) {
const errorMessage = `Error creating Smadex report request: ${error.message}`;
return {
content: [
{
type: "text",
text: errorMessage
}
],
isError: true
};
}
});
// Tool: Get Smadex Report Download URL
server.tool("get_smadex_report_download_url",
"Get the download URL for a Smadex report by its ID until the report is completed.",
{
reportId: z.string().describe("The report ID returned from get_smadex_report_id")
}, async ({ reportId }) => {
try {
console.error(`Getting download URL for report ID: ${reportId}`);
const downloadUrl = await getReportDownloadUrl(reportId);
console.error(`Download URL: ${downloadUrl}`);
return {
content: [
{
type: "text",
text: downloadUrl
}
]
};
} catch (error: any) {
const errorMessage = `Error getting download URL: ${error.message}`;
return {
content: [
{
type: "text",
text: errorMessage
}
],
isError: true
};
}
});
// Tool: Get Smadex Report Data
server.tool("get_smadex_report",
"Download and return report data from a Smadex report download URL.",
{
downloadUrl: z.string().describe("The download URL for the report")
}, async ({ downloadUrl }) => {
try {
console.error(`Downloading report from URL: ${downloadUrl}`);
const reportData = await downloadReport(downloadUrl);
return {
content: [
{
type: "text",
text: reportData
}
]
};
} catch (error: any) {
const errorMessage = `Error downloading report: ${error.message}`;
return {
content: [
{
type: "text",
text: errorMessage
}
],
isError: true
};
}
});
// Start server
async function runServer() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Smadex Reporting MCP Server running on stdio");
}
runServer().catch((error) => {
console.error("Fatal error running server:", error);
process.exit(1);
});
```
--------------------------------------------------------------------------------
/src/civitai-records/src/lib/detectRemoteAssetType.ts:
--------------------------------------------------------------------------------
```typescript
import { fileTypeFromBuffer } from 'file-type';
import path from 'node:path';
type DetectionSource = 'header' | 'sniff' | 'extension' | 'fallback';
export interface RemoteAssetTypeResult {
assetType: 'image' | 'video' | null;
mime?: string;
ext?: string;
from: DetectionSource;
}
// ============================================================================
// MIME Type Classification Helpers
// ============================================================================
/**
* Check if a MIME type indicates an image
*/
function isImageMime(mime: string): boolean {
return mime.startsWith('image/');
}
/**
* Check if a MIME type indicates a video
*/
function isVideoMime(mime: string): boolean {
return mime.startsWith('video/');
}
/**
* Check if a Content-Type is too generic to be useful
*/
function isGenericMime(contentType: string): boolean {
return /octet-stream|binary/i.test(contentType);
}
/**
* Convert MIME type to asset type classification
*/
function mimeToAssetType(mime: string): 'image' | 'video' | null {
if (isImageMime(mime)) return 'image';
if (isVideoMime(mime)) return 'video';
return null;
}
// ============================================================================
// Network Request Helpers
// ============================================================================
/**
* Create an AbortController with timeout for fetch requests
*/
function createTimeoutController(timeoutMs: number): { controller: AbortController; timeoutId: NodeJS.Timeout } {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
return { controller, timeoutId };
}
/**
* Read up to maxBytes from a response body stream
*/
async function readPartialStream(reader: ReadableStreamDefaultReader<Uint8Array>, maxBytes: number): Promise<Buffer> {
const chunks: Uint8Array[] = [];
let total = 0;
try {
while (total < maxBytes) {
const { value, done } = await reader.read();
if (done || !value) break;
chunks.push(value);
total += value.byteLength;
if (total >= maxBytes) {
await reader.cancel();
break;
}
}
} catch (error) {
// Reader may already be cancelled, ignore
}
return Buffer.concat(chunks.map(u8 => Buffer.from(u8)));
}
// ============================================================================
// Tier 1: HTTP HEAD Request Detection
// ============================================================================
/**
* Detect asset type from HTTP HEAD request Content-Type header.
* Fast method that doesn't download any file content.
*/
async function detectFromHttpHeaders(url: string, timeout: number): Promise<RemoteAssetTypeResult | null> {
try {
const { controller, timeoutId } = createTimeoutController(timeout);
const response = await fetch(url, {
method: 'HEAD',
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
return null;
}
const contentType = response.headers.get('content-type')?.split(';')[0].trim();
if (!contentType || isGenericMime(contentType)) {
if (contentType) {
console.debug(`Content-Type '${contentType}' is too generic, skipping header detection`);
}
return null;
}
const assetType = mimeToAssetType(contentType);
if (assetType) {
console.debug(`✓ Detected ${assetType} via Content-Type header: ${contentType}`);
return { assetType, mime: contentType, from: 'header' };
}
return null;
} catch (error) {
console.debug(`HEAD request failed: ${error instanceof Error ? error.message : error}`);
return null;
}
}
// ============================================================================
// Tier 2: Content Sniffing Detection
// ============================================================================
/**
* Detect asset type by downloading and analyzing file signature (magic bytes).
* More accurate but requires partial file download (up to 16KB).
*/
async function detectFromContentSniffing(url: string, timeout: number): Promise<RemoteAssetTypeResult | null> {
const MAX_BYTES = 16 * 1024; // 16 KB
try {
const { controller, timeoutId } = createTimeoutController(timeout);
const response = await fetch(url, {
headers: { Range: `bytes=0-${MAX_BYTES - 1}` },
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`Fetch failed: ${response.status} ${response.statusText}`);
}
const reader = response.body?.getReader();
if (!reader) {
return null;
}
const buffer = await readPartialStream(reader, MAX_BYTES);
if (buffer.length === 0) {
return null;
}
const fileType = await fileTypeFromBuffer(buffer);
if (!fileType) {
console.debug(`Content sniffing returned no file type`);
return null;
}
const assetType = mimeToAssetType(fileType.mime);
if (assetType) {
console.debug(`✓ Detected ${assetType} via content sniffing: ${fileType.mime} (${fileType.ext})`);
return { assetType, mime: fileType.mime, ext: fileType.ext, from: 'sniff' };
}
return null;
} catch (error) {
console.debug(`Content sniffing failed: ${error instanceof Error ? error.message : error}`);
return null;
}
}
// ============================================================================
// Tier 3: URL Pattern Detection
// ============================================================================
// Known file extensions by type
const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg', '.ico', '.tiff', '.tif', '.heic', '.heif', '.avif'];
const VIDEO_EXTENSIONS = ['.mp4', '.mov', '.avi', '.wmv', '.flv', '.mkv', '.webm', '.m4v', '.mpg', '.mpeg', '.3gp'];
/**
* Check if URL contains a known file extension
*/
function detectFromFileExtension(urlLower: string): RemoteAssetTypeResult | null {
// Check image extensions
for (const ext of IMAGE_EXTENSIONS) {
if (urlLower.includes(ext)) {
console.debug(`✓ Detected image from extension: ${ext}`);
return { assetType: 'image', ext: ext.slice(1), from: 'extension' };
}
}
// Check video extensions
for (const ext of VIDEO_EXTENSIONS) {
if (urlLower.includes(ext)) {
console.debug(`✓ Detected video from extension: ${ext}`);
return { assetType: 'video', ext: ext.slice(1), from: 'extension' };
}
}
return null;
}
/**
* Check if URL path contains common CDN patterns
*/
function detectFromPathPattern(urlLower: string): RemoteAssetTypeResult | null {
if (urlLower.includes('/images/') || urlLower.includes('/image/')) {
console.debug(`✓ Detected image from path pattern: /images/ or /image/`);
return { assetType: 'image', from: 'extension' };
}
if (urlLower.includes('/videos/') || urlLower.includes('/video/')) {
console.debug(`✓ Detected video from path pattern: /videos/ or /video/`);
return { assetType: 'video', from: 'extension' };
}
return null;
}
/**
* Extract and check file extension from URL pathname
*/
function detectFromUrlPathname(url: string): RemoteAssetTypeResult | null {
try {
const pathname = new URL(url).pathname;
const ext = path.extname(pathname).slice(1).toLowerCase();
if (!ext) {
return null;
}
if (IMAGE_EXTENSIONS.includes(`.${ext}`)) {
console.debug(`✓ Detected image from URL path extension: .${ext}`);
return { assetType: 'image', ext, from: 'extension' };
}
if (VIDEO_EXTENSIONS.includes(`.${ext}`)) {
console.debug(`✓ Detected video from URL path extension: .${ext}`);
return { assetType: 'video', ext, from: 'extension' };
}
return null;
} catch {
// Invalid URL
return null;
}
}
/**
* Fast URL-based detection using extension and path patterns.
* This is the fallback method when remote detection fails or is skipped.
* No network calls, instant response.
*/
function detectFromUrl(url: string): RemoteAssetTypeResult {
const urlLower = url.toLowerCase();
// Try file extension detection
const extResult = detectFromFileExtension(urlLower);
if (extResult) return extResult;
// Try path pattern detection
const pathResult = detectFromPathPattern(urlLower);
if (pathResult) return pathResult;
// Try URL pathname extraction
const pathnameResult = detectFromUrlPathname(url);
if (pathnameResult) return pathnameResult;
// Nothing worked
console.debug(`✗ Unable to detect asset type from URL`);
return { assetType: null, from: 'fallback' };
}
// ============================================================================
// Main Detection Function
// ============================================================================
/**
* Detect asset type from a remote URL using a multi-tier approach:
*
* **Tier 1 (Remote - Fast):** HTTP HEAD request to check Content-Type header
* - Fastest method, no file download
* - Works when server provides accurate Content-Type
*
* **Tier 2 (Remote - Accurate):** Partial content sniffing via Range request
* - Downloads only first 16KB to check file signature (magic bytes)
* - Most accurate, works even if server headers are wrong
*
* **Tier 3 (Local - Fallback):** URL pattern matching
* - Checks file extension and path patterns
* - No network overhead, instant response
* - Used when remote methods fail or are unavailable
*
* @param url - The remote URL to check
* @param options - Configuration options
* @param options.skipRemote - If true, skip remote checks and only use URL pattern matching (default: false)
* @param options.timeout - Request timeout in milliseconds (default: 5000)
* @returns Asset type detection result with source indicator
*/
export async function detectRemoteAssetType(
url: string,
options: { skipRemote?: boolean; timeout?: number } = {}
): Promise<RemoteAssetTypeResult> {
const { skipRemote = false, timeout = 5000 } = options;
// If skipRemote is true, only use URL-based detection
if (skipRemote) {
console.debug(`Skipping remote detection, using URL-based fallback for: ${url}`);
return detectFromUrl(url);
}
// TIER 1: Try HTTP HEAD request (fast)
const headerResult = await detectFromHttpHeaders(url, timeout);
if (headerResult) {
return headerResult;
}
// TIER 2: Try content sniffing (accurate)
const sniffResult = await detectFromContentSniffing(url, timeout);
if (sniffResult) {
return sniffResult;
}
// TIER 3: Fallback to URL-based detection (always succeeds)
console.debug(`Remote detection failed, using URL-based pattern matching as fallback`);
return detectFromUrl(url);
}
```
--------------------------------------------------------------------------------
/src/inmobi-reporting/src/index.ts:
--------------------------------------------------------------------------------
```typescript
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import fetch from "node-fetch";
import dotenv from "dotenv";
dotenv.config();
const server = new McpServer({
name: "Inmobi Reporting MCP Server",
version: "0.0.1"
});
const INMOBI_AUTH_URL = process.env.INMOBI_AUTH_URL || "";
const INMOBI_SKAN_REPORT_URL = process.env.INMOBI_SKAN_REPORT_URL || "";
const INMOBI_NON_SKAN_REPORT_URL = process.env.INMOBI_NON_SKAN_REPORT_URL || "";
const INMOBI_REPORT_BASE_URL = process.env.INMOBI_REPORT_BASE_URL || "";
const INMOBI_CLIENT_ID = process.env.INMOBI_CLIENT_ID || "";
const INMOBI_CLIENT_SECRET = process.env.INMOBI_CLIENT_SECRET || "";
const LOOP_COUNT = 24;
let authToken = '';
let tokenExpirationTime = 0;
if (!INMOBI_CLIENT_ID || !INMOBI_CLIENT_SECRET) {
console.error("Missing Inmobi API credentials. Please set INMOBI_CLIENT_ID and INMOBI_CLIENT_SECRET environment variables.");
process.exit(1);
}
/**
* Get a valid auth token for Inmobi API
*/
async function getInmobiAuthToken(): Promise<string> {
// Check if token is still valid
const now = Math.floor(Date.now() / 1000);
if (authToken && tokenExpirationTime > now + 300) {
return authToken;
}
try {
console.error('Fetching new Inmobi auth token');
const requestBody = {
clientId: INMOBI_CLIENT_ID,
clientSecret: INMOBI_CLIENT_SECRET
};
const response = await fetch(INMOBI_AUTH_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json;charset=utf-8'
},
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const errorBody = await response.text();
console.error(`Inmobi Auth Error: ${response.status} ${response.statusText} - ${errorBody}`);
throw new Error(`Auth failed: ${response.status} ${response.statusText}`);
}
const data = await response.json();
authToken = data?.data?.token;
if (!authToken) {
throw new Error('No token returned from Inmobi API');
}
// Set expiration time (60 minutes from now, to be safe)
tokenExpirationTime = now + 3600;
console.error('Successfully obtained Inmobi auth token');
return authToken;
} catch (error: any) {
console.error('Error obtaining Inmobi auth token:', error);
throw new Error(`Failed to get authentication token: ${error.message}`);
}
}
/**
* Generate a report request payload
*/
function generatePayload(startDate: string, endDate: string, os: string): string {
return JSON.stringify({
startDate: startDate,
endDate: endDate,
filters: {
os: [os]
},
dimensions: [
"date",
"campaign_id",
"campaign_name",
"os"
]
});
}
/**
* Generate a SKAN report (iOS)
*/
async function getSkanReportId(startDate: string, endDate: string): Promise<string> {
try {
const token = await getInmobiAuthToken();
const payload = generatePayload(startDate, endDate, 'iOS');
const response = await fetch(INMOBI_SKAN_REPORT_URL, {
method: 'POST',
headers: {
'Authorization': token,
'Content-Type': 'application/json;charset=utf-8'
},
body: payload
});
if (!response.ok) {
const errorBody = await response.text();
console.error(`Inmobi SKAN Report Error: ${response.status} ${response.statusText} - ${errorBody}`);
throw new Error(`SKAN Report generation failed: ${response.status} ${response.statusText}`);
}
const data = await response.json();
const reportId = data?.data?.reportId;
if (!reportId) {
throw new Error('No reportId returned from Inmobi API');
}
return reportId;
} catch (error: any) {
console.error('Error generating SKAN report:', error);
throw new Error(`Failed to generate SKAN report: ${error.message}`);
}
}
/**
* Generate a non-SKAN report (Android)
*/
async function getNonSkanReportId(startDate: string, endDate: string): Promise<string> {
try {
const token = await getInmobiAuthToken();
const payload = generatePayload(startDate, endDate, 'Android');
const response = await fetch(INMOBI_NON_SKAN_REPORT_URL, {
method: 'POST',
headers: {
'Authorization': token,
'Content-Type': 'application/json;charset=utf-8'
},
body: payload
});
if (!response.ok) {
const errorBody = await response.text();
console.error(`Inmobi non-SKAN Report Error: ${response.status} ${response.statusText} - ${errorBody}`);
throw new Error(`Non-SKAN Report generation failed: ${response.status} ${response.statusText}`);
}
const data = await response.json();
const reportId = data?.data?.reportId;
if (!reportId) {
throw new Error('No reportId returned from Inmobi API');
}
return reportId;
} catch (error: any) {
console.error('Error generating non-SKAN report:', error);
throw new Error(`Failed to generate non-SKAN report: ${error.message}`);
}
}
/**
* Check the status of a report
*/
async function getReportStatus(reportId: string): Promise<string | null> {
try {
if (!reportId) return null;
const token = await getInmobiAuthToken();
const url = `${INMOBI_REPORT_BASE_URL}/${reportId}/status`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Authorization': token
}
});
if (!response.ok) {
const errorBody = await response.text();
console.error(`Inmobi Report Status Error: ${response.status} ${response.statusText} - ${errorBody}`);
throw new Error(`Report status check failed: ${response.status} ${response.statusText}`);
}
const data = await response.json();
return data?.data?.reportStatus || null;
} catch (error: any) {
console.error('Error checking report status:', error);
throw new Error(`Failed to check report status: ${error.message}`);
}
}
/**
* Wait for a report to be ready
*/
async function checkReportStatus(reportId: string): Promise<boolean> {
let count = 0;
let reportAvailable = false;
while (count < LOOP_COUNT) {
const status = await getReportStatus(reportId);
if (status === 'report.status.available') {
reportAvailable = true;
break;
}
count++;
// Wait 5 seconds between status checks
await new Promise(resolve => setTimeout(resolve, 5000));
}
if (!reportAvailable) {
console.error(`Check report status timeout for report ID: ${reportId}`);
}
return reportAvailable;
}
/**
* Download report data
*/
async function fetchReportData(reportId: string): Promise<any[]> {
try {
if (!reportId) return [];
const token = await getInmobiAuthToken();
const url = `${INMOBI_REPORT_BASE_URL}/${reportId}/download`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Authorization': token
}
});
if (!response.ok) {
const errorBody = await response.text();
console.error(`Inmobi Report Download Error: ${response.status} ${response.statusText} - ${errorBody}`);
throw new Error(`Report download failed: ${response.status} ${response.statusText}`);
}
const csvData = await response.text();
// Parse CSV data
// This is a simple parsing approach - you might need a more robust CSV parser
const rows = csvData.split('\n');
const headers = rows[0].split(',');
return rows.slice(1).filter(row => row.trim()).map(row => {
const values = row.split(',');
const obj: any = {};
headers.forEach((header, index) => {
obj[header.trim()] = values[index]?.trim() || '';
});
return obj;
});
} catch (error: any) {
console.error('Error fetching report data:', error);
throw new Error(`Failed to fetch report data: ${error.message}`);
}
}
// Tool: Generate report IDs
server.tool("generate_inmobi_report_ids",
"Generate Inmobi report IDs for SKAN (iOS) and non-SKAN (Android) reports.",
{
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)"),
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)"),
}, async ({ startDate, endDate }) => {
try {
// Validate date range logic
if (new Date(startDate) > new Date(endDate)) {
throw new Error("Start date cannot be after end date.");
}
const skanReportId = await getSkanReportId(startDate, endDate);
const nonSkanReportId = await getNonSkanReportId(startDate, endDate);
return {
content: [
{
type: "text",
text: JSON.stringify({
skanReportId,
nonSkanReportId
}, null, 2)
}
]
};
} catch (error: any) {
const errorMessage = `Error generating Inmobi report IDs: ${error.message}`;
return {
content: [
{
type: "text",
text: errorMessage
}
],
isError: true
};
}
});
// Tool: Fetch report data
server.tool("fetch_inmobi_report_data",
"Fetch data from Inmobi reports using report IDs.",
{
skanReportId: z.string().describe("SKAN report ID obtained from generate_inmobi_report_ids"),
nonSkanReportId: z.string().describe("Non-SKAN report ID obtained from generate_inmobi_report_ids"),
}, async ({ skanReportId, nonSkanReportId }) => {
try {
let allData: any[] = [];
// Check SKAN report status and fetch data if available
const skanReportAvailable = await checkReportStatus(skanReportId);
if (skanReportAvailable) {
const skanData = await fetchReportData(skanReportId);
allData = allData.concat(skanData);
}
// Check non-SKAN report status and fetch data if available
const nonSkanReportAvailable = await checkReportStatus(nonSkanReportId);
if (nonSkanReportAvailable) {
const nonSkanData = await fetchReportData(nonSkanReportId);
allData = allData.concat(nonSkanData);
}
if (allData.length === 0) {
throw new Error("No data available from either report.");
}
return {
content: [
{
type: "text",
text: JSON.stringify(allData, null, 2)
}
]
};
} catch (error: any) {
const errorMessage = `Error fetching Inmobi report data: ${error.message}`;
return {
content: [
{
type: "text",
text: errorMessage
}
],
isError: true
};
}
});
// Start server
async function runServer() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Inmobi Reporting MCP Server running on stdio");
}
runServer().catch((error) => {
console.error("Fatal error running server:", error);
process.exit(1);
});
```
--------------------------------------------------------------------------------
/src/kayzen-reporting/src/index.ts:
--------------------------------------------------------------------------------
```typescript
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { KayzenClient } from "./kayzen-client.js";
// Create an MCP server
const server = new McpServer({
name: "Kayzen Reporting",
version: "0.1.0"
});
// Initialize Kayzen client
const kayzenClient = new KayzenClient();
interface ReportListResponse {
data: Array<{
id: number;
advertiser_id: number;
report_type_id: string;
name: string;
start_date: string;
end_date: string;
date_macro: string;
time_zone_id: number;
created_at: string;
report_schedule_frequency: string | null;
report_schedule_end_date: string | null;
report_schedule_recipients: string | null;
report_schedule_status: string | null;
report_schedule_flag_reason: string | null;
report_schedule_last_sent: string | null;
time_zone_name: string;
}>;
meta: {
current_page: number;
total_pages: number;
total_entries: number;
};
}
interface ReportResultsResponse {
data: Array<Record<string, unknown>>;
metadata?: Record<string, unknown>;
}
// Helper function to parse and normalize dates with smart defaults
function parseDateWithDefaults(dateString: string): string {
const currentYear = new Date().getFullYear();
// Already in YYYY-MM-DD format
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
return dateString;
}
// MM-DD format (add current year)
if (/^\d{1,2}-\d{1,2}$/.test(dateString)) {
const [month, day] = dateString.split('-');
return `${currentYear}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
}
// MM/DD format (add current year)
if (/^\d{1,2}\/\d{1,2}$/.test(dateString)) {
const [month, day] = dateString.split('/');
return `${currentYear}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
}
// Month name patterns (January, Jan, 1月)
const monthNames = {
'january': '01', 'jan': '01', '1月': '01', '一月': '01',
'february': '02', 'feb': '02', '2月': '02', '二月': '02',
'march': '03', 'mar': '03', '3月': '03', '三月': '03',
'april': '04', 'apr': '04', '4月': '04', '四月': '04',
'may': '05', '5月': '05', '五月': '05',
'june': '06', 'jun': '06', '6月': '06', '六月': '06',
'july': '07', 'jul': '07', '7月': '07', '七月': '07',
'august': '08', 'aug': '08', '8月': '08', '八月': '08',
'september': '09', 'sep': '09', '9月': '09', '九月': '09',
'october': '10', 'oct': '10', '10月': '10', '十月': '10',
'november': '11', 'nov': '11', '11月': '11', '十一月': '11',
'december': '12', 'dec': '12', '12月': '12', '十二月': '12'
};
const lowerDate = dateString.toLowerCase();
for (const [name, monthNum] of Object.entries(monthNames)) {
if (lowerDate.includes(name)) {
return `${currentYear}-${monthNum}-01`;
}
}
// Single number (assume month, use first day)
if (/^\d{1,2}$/.test(dateString)) {
const month = parseInt(dateString);
if (month >= 1 && month <= 12) {
return `${currentYear}-${month.toString().padStart(2, '0')}-01`;
}
}
// Return original if no pattern matched
return dateString;
}
// Helper function to get month end date
function getMonthEnd(year: number, month: number): string {
const lastDay = new Date(year, month, 0).getDate();
return `${year}-${month.toString().padStart(2, '0')}-${lastDay.toString().padStart(2, '0')}`;
}
// Helper function to parse date ranges with smart defaults
function parseDateRange(startDate: string, endDate: string): { start: string; end: string } {
const currentYear = new Date().getFullYear();
let parsedStart = parseDateWithDefaults(startDate);
let parsedEnd = parseDateWithDefaults(endDate);
// If start date is just a month (ends with -01), set end date to month end
if (parsedStart.endsWith('-01') && parsedEnd === parsedStart) {
const [year, month] = parsedStart.split('-').map(Number);
parsedEnd = getMonthEnd(year, month);
}
return { start: parsedStart, end: parsedEnd };
}
// Helper function to get report details by ID
async function getReportDetails(reportId: string): Promise<{
id: number;
start_date: string;
end_date: string;
name: string;
} | null> {
try {
const result = await kayzenClient.listReports({ q: reportId }) as ReportListResponse;
const report = result.data.find(r => r.id.toString() === reportId);
return report ? {
id: report.id,
start_date: report.start_date,
end_date: report.end_date,
name: report.name
} : null;
} catch (error) {
console.error('Error getting report details:', error);
return null;
}
}
// Add list reports tool
server.tool(
"list_reports",
"Get a list of all the existing reports from Kayzen Reporting API with filtering, pagination, and sorting options",
{
advertiser_id: z.number().optional().describe("Filter reports by advertiser ID"),
q: z.string().optional().describe("Search reports by name or ID"),
page: z.number().min(1).default(1).describe("Page number (default: 1)"),
per_page: z.number().min(1).max(100).default(30).describe("Number of rows per page (default: 30, max: 100)"),
sort_field: z.enum([
"id",
"advertiser_id",
"name",
"report_type",
"time_range",
"report_schedule_frequency",
"report_schedule_status",
"report_schedule_last_sent"
]).optional().describe("Sort reports by this field"),
sort_direction: z.enum(["asc", "desc"]).optional().describe("Sort direction (asc or desc)")
},
async (params: {
advertiser_id?: number;
q?: string;
page?: number;
per_page?: number;
sort_field?: string;
sort_direction?: 'asc' | 'desc';
}) => {
try {
const result = await kayzenClient.listReports(params) as ReportListResponse;
const summary = {
pagination: {
current_page: result.meta.current_page,
total_pages: result.meta.total_pages,
total_entries: result.meta.total_entries,
per_page: params.per_page || 30
},
filters_applied: {
advertiser_id: params.advertiser_id,
search_query: params.q,
sort_field: params.sort_field,
sort_direction: params.sort_direction
}
};
return {
content: [
{
type: "text",
text: `## Reports Summary
${summary.pagination.total_entries} total reports found
Page ${summary.pagination.current_page} of ${summary.pagination.total_pages}
### Applied Filters
${JSON.stringify(summary.filters_applied, null, 2)}
### Reports Data
${JSON.stringify(result.data, null, 2)}
---
**Note for LLM**: When the user asks for report results for specific date ranges, remember to use those dates as start_date and end_date parameters in get_report_results calls. Each report above shows its original date range (start_date/end_date), but you can override these with user-specified dates if needed.
**Date Format Guidelines for LLM**:
- If user only mentions month/date without year, assume current year (${new Date().getFullYear()})
- Supported formats: "January", "Jan", "1月", "1", "01-15", "1/15", "2024-01-15"
- Examples: "January" → "${new Date().getFullYear()}-01-01 to ${new Date().getFullYear()}-01-31", "3" → "${new Date().getFullYear()}-03-01 to ${new Date().getFullYear()}-03-31"
- Use the smart date parsing by passing flexible date strings to get_report_results`
}
]
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
content: [{
type: "text",
text: `Error listing reports: ${errorMessage}`
}],
isError: true
};
}
}
);
// Add get report results tool
server.tool(
"get_report_results",
"Get the results of a report from Kayzen Reporting API. Supports flexible date formats with smart defaults. If start_date and end_date are not provided, uses the report's original date range.",
{
report_id: z.string().describe("ID of the report to fetch results for"),
start_date: z.string().optional().describe("Start date - supports flexible formats: YYYY-MM-DD, MM-DD, MM/DD, month names (January, Jan, 1月), or month numbers (1-12). Defaults to current year if year not specified."),
end_date: z.string().optional().describe("End date - supports same flexible formats as start_date. If same as start_date for month-only queries, automatically sets to month end.")
},
async (params: { report_id: string; start_date?: string; end_date?: string }) => {
try {
let actualStartDate = params.start_date;
let actualEndDate = params.end_date;
let dateSource = 'user_specified';
// If dates provided, parse them with smart defaults
if (actualStartDate && actualEndDate) {
const parsed = parseDateRange(actualStartDate, actualEndDate);
actualStartDate = parsed.start;
actualEndDate = parsed.end;
} else if (actualStartDate && !actualEndDate) {
// If only start date provided, try to infer end date
const parsedStart = parseDateWithDefaults(actualStartDate);
if (parsedStart.endsWith('-01')) {
// Month-only query, set end to month end
const [year, month] = parsedStart.split('-').map(Number);
actualStartDate = parsedStart;
actualEndDate = getMonthEnd(year, month);
} else {
actualEndDate = parsedStart; // Same day
actualStartDate = parsedStart;
}
} else if (!actualStartDate && actualEndDate) {
actualStartDate = parseDateWithDefaults(actualEndDate);
actualEndDate = parseDateWithDefaults(actualEndDate);
}
// If no dates provided, get from report details
if (!actualStartDate || !actualEndDate) {
const reportDetails = await getReportDetails(params.report_id);
if (!reportDetails) {
throw new Error(`Report with ID ${params.report_id} not found`);
}
actualStartDate = actualStartDate || reportDetails.start_date;
actualEndDate = actualEndDate || reportDetails.end_date;
dateSource = 'report_original';
}
const result = await kayzenClient.getReportResults(
params.report_id,
actualStartDate,
actualEndDate
) as ReportResultsResponse;
const response = {
...result,
time_range: {
start_date: actualStartDate,
end_date: actualEndDate,
source: dateSource
}
};
return {
content: [{
type: "text",
text: JSON.stringify(response, null, 2)
}]
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
content: [{
type: "text",
text: `Error getting report results: ${errorMessage}`
}],
isError: true
};
}
}
);
// Start the server
const transport = new StdioServerTransport();
await server.connect(transport);
```
--------------------------------------------------------------------------------
/src/n8n-nodes-feedmob-direct-spend-visualizer/nodes/FeedmobDirectSpendVisualizer/FeedmobDirectSpendVisualizer.node.ts:
--------------------------------------------------------------------------------
```typescript
import { existsSync } from 'fs';
import { resolve } from 'path';
import {
query,
type SDKAssistantMessage,
type SDKMessage,
type SDKResultMessage,
} from '@anthropic-ai/claude-agent-sdk';
import type {
IExecuteFunctions,
INodeExecutionData,
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';
type FeedmobDirectSpendVisualizerCredentials = {
provider?: 'aws_bedrock' | 'glm';
feedmobKey?: string;
feedmobSecret?: string;
feedmobApiBase?: string;
// AWS
awsRegion?: string;
awsAccessKeyId?: string;
awsSecretAccessKey?: string;
anthropicModel?: string; // used for AWS
anthropicSmallModel?: string; // used for AWS
// GLM
anthropicBaseUrl?: string; // used for GLM
anthropicAuthToken?: string; // used for GLM
glmModel?: string; // used for GLM
glmSmallModel?: string; // used for GLM
};
type AgentRunResult = {
text: string;
structuredOutput?: unknown;
usage?: SDKResultMessage['usage'];
};
const PLUGIN_SEGMENTS = ['vendor', 'claude-code-marketplace', 'plugins', 'direct-spend-visualizer'];
const buildPrompt = (clickUrlId: string, startDate: string, endDate: string) => `
Use the Claude agent skill "direct-spend-visualizer" to visualize FeedMob direct spend.
Click URL ID: ${clickUrlId}
Start date: ${startDate}
End date: ${endDate}
Return any ASCII output plus JSON with keys status, summary, and data.
`.trim();
export class FeedmobDirectSpendVisualizer implements INodeType {
description: INodeTypeDescription = {
displayName: 'FeedMob Direct Spend Visualizer',
name: 'feedmobDirectSpendVisualizer',
icon: 'file:logo.svg',
group: ['transform'],
version: 1,
description: 'Ask the Claude Agent SDK to run the FeedMob direct-spend visualizer plugin',
defaults: { name: 'Direct Spend Visualizer' },
inputs: ['main'],
outputs: ['main'],
credentials: [
{ name: 'feedmobDirectSpendVisualizerApi', required: true },
],
properties: [
{
displayName: 'Start Date',
name: 'startDate',
type: 'string',
required: true,
default: '',
description: 'Start date in YYYY-MM-DD format.',
},
{
displayName: 'End Date',
name: 'endDate',
type: 'string',
required: true,
default: '',
description: 'End date in YYYY-MM-DD format.',
},
{
displayName: 'Click URL ID',
name: 'clickUrlId',
type: 'string',
required: true,
default: '',
description: 'Single FeedMob click_url_id to visualize.',
},
{
displayName: 'Max Turns',
name: 'maxTurns',
type: 'number',
default: 50,
typeOptions: { minValue: 1, maxValue: 100, numberStepSize: 1 },
description: 'Number of reasoning turns allowed for the Claude Agent.',
},
],
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const credentials = (await this.getCredentials('feedmobDirectSpendVisualizerApi')) as FeedmobDirectSpendVisualizerCredentials;
const provider = credentials.provider || 'aws_bedrock';
// Validate provider-specific credentials
if (provider === 'aws_bedrock') {
if (!credentials.awsAccessKeyId || !credentials.awsSecretAccessKey) {
throw new Error('Missing AWS credentials in the FeedMob Direct Spend Visualizer credential.');
}
} else if (provider === 'glm') {
if (!credentials.anthropicAuthToken) {
throw new Error('Missing GLM API Key (anthropicAuthToken) in the FeedMob Direct Spend Visualizer credential.');
}
}
const items = this.getInputData();
const results: INodeExecutionData[] = [];
const execContext = this as IExecuteFunctions & { continueOnFail?: () => boolean };
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
try {
const pluginPath = resolvePluginPath();
const startDate = this.getNodeParameter('startDate', itemIndex) as string;
const endDate = this.getNodeParameter('endDate', itemIndex) as string;
const clickUrlId = this.getNodeParameter('clickUrlId', itemIndex) as string;
const prompt = buildPrompt(clickUrlId, startDate, endDate);
const maxTurns = this.getNodeParameter('maxTurns', itemIndex, 50) as number;
const agentResult = await runAgentWithPlugin(
prompt,
{
plugins: [{ type: 'local', path: pluginPath }],
allowedTools: ['Skill', 'mcp__plugin_direct-spend-visualizer_feedmob__get_direct_spends'],
maxTurns,
env: buildRuntimeEnv(credentials),
},
);
results.push({
json: {
clickUrlId,
startDate,
endDate,
pluginPath,
prompt,
responseText: agentResult.text,
structuredOutput: agentResult.structuredOutput,
usage: agentResult.usage,
parsed: normalizeResult(agentResult),
},
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown Claude Agent SDK error';
if (execContext.continueOnFail && execContext.continueOnFail()) {
results.push({ json: { error: errorMessage } });
continue;
}
throw error;
}
}
return [results];
}
}
async function runAgentWithPlugin(
prompt: string,
options: Parameters<typeof query>[0]['options'],
): Promise<AgentRunResult> {
const assistantSnippets: string[] = [];
let resultMessage: SDKResultMessage | undefined;
for await (const message of query({ prompt, options })) {
if (message.type === 'assistant') {
const text = extractAssistantText(message);
if (text) assistantSnippets.push(text);
} else if (message.type === 'result') {
resultMessage = message;
}
}
if (!resultMessage) throw new Error('Claude Agent SDK returned no result message.');
if (resultMessage.subtype !== 'success') {
const reason = Array.isArray(resultMessage.errors) && resultMessage.errors.length
? resultMessage.errors.join('; ')
: `subtype ${resultMessage.subtype}`;
throw new Error(`Claude Agent run failed: ${reason}`);
}
const responseText = (resultMessage.result || assistantSnippets.join('\n')).trim();
return {
text: responseText,
structuredOutput: resultMessage.structured_output,
usage: resultMessage.usage,
};
}
function extractAssistantText(message: Extract<SDKMessage, { type: 'assistant' }>): string {
const assistantMessage = message.message as SDKAssistantMessage['message'];
const content = Array.isArray((assistantMessage as any).content) ? (assistantMessage as any).content : [];
const textParts: string[] = [];
for (const block of content) {
if (typeof block === 'string') {
textParts.push(block);
continue;
}
if (block?.type === 'text' && typeof block.text === 'string') {
textParts.push(block.text);
continue;
}
if (block?.type === 'tool_result' && Array.isArray(block.content)) {
for (const nested of block.content) {
if (typeof nested === 'string') textParts.push(nested);
else if (nested?.type === 'text' && typeof nested.text === 'string') textParts.push(nested.text);
}
}
}
return textParts.join('\n').trim();
}
function normalizeResult(agentResult: AgentRunResult) {
const structured =
(typeof agentResult.structuredOutput === 'object' && agentResult.structuredOutput !== null
? agentResult.structuredOutput
: undefined) ?? tryParseJson(agentResult.text);
if (structured) return structured;
return { raw: agentResult.text };
}
function tryParseJson(text?: string): unknown | undefined {
if (!text) return undefined;
const trimmed = text.trim();
if (!trimmed) return undefined;
const codeBlockMatch = trimmed.match(/```json([\s\S]*?)```/i) || trimmed.match(/```([\s\S]*?)```/i);
const candidate = codeBlockMatch ? codeBlockMatch[1] : trimmed;
try {
return JSON.parse(candidate);
} catch {
return undefined;
}
}
function buildRuntimeEnv(credentials: FeedmobDirectSpendVisualizerCredentials): NodeJS.ProcessEnv {
const baseEnv = { ...process.env };
const provider = credentials.provider || 'aws_bedrock';
// Common Validations
const feedmobKey = credentials.feedmobKey ?? baseEnv.FEEDMOB_KEY;
const feedmobSecret = credentials.feedmobSecret ?? baseEnv.FEEDMOB_SECRET;
const feedmobApiBase = credentials.feedmobApiBase ?? baseEnv.FEEDMOB_API_BASE;
if (!feedmobKey || !feedmobSecret || !feedmobApiBase) {
throw new Error('FeedMob API env vars (FEEDMOB_KEY/SECRET/API_BASE) are required for the plugin.');
}
const env: NodeJS.ProcessEnv = {
...baseEnv,
FEEDMOB_KEY: feedmobKey,
FEEDMOB_SECRET: feedmobSecret,
FEEDMOB_API_BASE: feedmobApiBase,
};
if (provider === 'aws_bedrock') {
const awsAccessKeyId = credentials.awsAccessKeyId ?? baseEnv.AWS_ACCESS_KEY_ID;
const awsSecretAccessKey = credentials.awsSecretAccessKey ?? baseEnv.AWS_SECRET_ACCESS_KEY;
if (!awsAccessKeyId || !awsSecretAccessKey) {
throw new Error('AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY missing.');
}
env.AWS_REGION = credentials.awsRegion ?? baseEnv.AWS_REGION ?? 'us-east-1';
env.AWS_ACCESS_KEY_ID = awsAccessKeyId;
env.AWS_SECRET_ACCESS_KEY = awsSecretAccessKey;
env.AWS_RETRY_MODE = baseEnv.AWS_RETRY_MODE ?? 'adaptive';
env.AWS_MAX_ATTEMPTS = baseEnv.AWS_MAX_ATTEMPTS ?? '20';
env.CLAUDE_CODE_USE_BEDROCK = '1';
env.ANTHROPIC_MODEL = credentials.anthropicModel ?? baseEnv.ANTHROPIC_MODEL ?? 'us.anthropic.claude-sonnet-4-5-20250929-v1:0';
env.ANTHROPIC_SMALL_FAST_MODEL = credentials.anthropicSmallModel ?? baseEnv.ANTHROPIC_SMALL_FAST_MODEL ?? 'us.anthropic.claude-haiku-4-5-20251001-v1:0';
} else if (provider === 'glm') {
// Force Unset Bedrock flag if it exists in baseEnv to avoid confusion
if (env.CLAUDE_CODE_USE_BEDROCK) {
delete env.CLAUDE_CODE_USE_BEDROCK;
}
const auth = credentials.anthropicAuthToken ?? baseEnv.ANTHROPIC_AUTH_TOKEN;
if (!auth) {
throw new Error('ANTHROPIC_AUTH_TOKEN missing for GLM provider.');
}
env.ANTHROPIC_BASE_URL = credentials.anthropicBaseUrl ?? 'https://open.bigmodel.cn/api/anthropic';
env.ANTHROPIC_AUTH_TOKEN = auth;
env.ANTHROPIC_MODEL = credentials.glmModel ?? 'glm-4.6';
env.ANTHROPIC_SMALL_FAST_MODEL = credentials.glmSmallModel ?? 'glm-4.6';
}
return env;
}
function resolvePluginPath(): string {
const manualPath = process.env.CLAUDE_MARKETPLACE_PLUGIN_PATH?.trim();
const candidatePaths = [
manualPath ? resolve(manualPath) : undefined,
resolve(__dirname, '..', '..', ...PLUGIN_SEGMENTS),
resolve(__dirname, '..', '..', '..', ...PLUGIN_SEGMENTS),
].filter((candidate): candidate is string => Boolean(candidate));
for (const candidate of candidatePaths) {
if (existsSync(candidate)) return candidate;
}
throw new Error(
'Claude marketplace plugin directory not found. Ensure the package build copied vendor plugins or set CLAUDE_MARKETPLACE_PLUGIN_PATH.',
);
}
```
--------------------------------------------------------------------------------
/src/civitai-records/src/prompts/record-civitai-workflow.md:
--------------------------------------------------------------------------------
```markdown
# Civitai Content Recording Guide
You are assisting the Civitai tracking pipeline. Follow this guide to capture prompts, assets, and posts consistently across our tools.
## Related Guides
- **For analyzing engagement metrics** (likes, hearts, comments, etc.), see the `civitai_media_engagement` prompt or use `get_media_engagement_guide` tool.
## Goal & Mindset
- Keep a canonical, duplicate-free record that links prompts, assets, and posts.
- Record dependencies before references (e.g., prompt before asset, post before linking).
- Store every ID that a tool returns so it can be reused in subsequent calls.
## Tool Quick Reference
- `calculate_sha256`
- Purpose: Generate a SHA256 hash for a local file or remote URL to prevent duplicates and map assets.
- Input: `path` (local file path or HTTPS/HTTP URL).
- Returns: `{ sha256sum }`.
- Use before creating assets and when matching local media to Civitai content.
- Example (local):
```json
{"path": "/local/path/to/image.jpg"}
```
- Example (URL):
```json
{"path": "https://example.com/image.jpg"}
```
- `find_asset`
- Purpose: Check if an asset already exists or fetch full asset details.
- Input: At least one of `asset_id` or `sha256sum`.
- Returns: `{found: boolean, asset?: {...}}`.
- Use the SHA256 from `calculate_sha256` before creating new assets.
- Example:
```json
{"sha256sum": "abc123def456"}
```
- `create_prompt`
- Purpose: Save the text prompt used to generate an asset.
- Required: `prompt_text`.
- Optional: `llm_model_provider`, `llm_model`, `purpose`, `metadata`.
- Returns: `{prompt_id}`.
- Example:
```json
{
"prompt_text": "A serene mountain landscape at sunset",
"llm_model_provider": "openai",
"llm_model": "dall-e-3",
"purpose": "image_generation"
}
```
- `create_asset`
- Purpose: Register generated or uploaded media.
- Required: `asset_url`, `asset_type` (`image` | `video`), `asset_source` (`generated` | `upload`).
- Optional: `input_prompt_id`, `output_prompt_id`, `post_id`, `civitai_id`, `civitai_url`, `metadata`.
- Returns: `{asset_id}`.
- Tip: Link `input_prompt_id` to the prompt that produced the asset. The tool automatically hashes `asset_url` and returns `sha256sum` in the response.
- Example:
```json
{
"asset_url": "s3://bucket/images/mountain.jpg",
"asset_type": "image",
"asset_source": "generated",
"input_prompt_id": "123"
}
```
- `create_civitai_post`
- Purpose: Record a published post on Civitai.
- Required: `civitai_id`, `civitai_url`.
- Optional: `status` (`pending` | `published` | `failed`), `title`, `description`, `metadata`.
- Returns: `{post_id}`.
- Note: `civitai_account` is inferred from the `CIVITAI_ACCOUNT` env var (default `c29`).
- Example:
```json
{
"civitai_id": "23602354",
"civitai_url": "https://civitai.com/posts/23602354",
"status": "published",
"title": "Sunset Mountain Landscape",
"description": "AI-generated mountain scene",
"metadata": {
"views": 0,
"likes": 0,
"tags": ["landscape", "ai-art"],
"workflow": "flux-1-pro"
}
}
```
- `fetch_civitai_post_assets`
- Purpose: Retrieve live media assets and engagement stats for a Civitai post without writing to the database.
- Required: `post_id` (numeric string extracted from the post URL).
- Optional: `limit` (default 50, max 100), `page` (default 1).
- Returns: `{asset_count, assets:[{civitai_image_id, asset_url, engagement_stats, ...}], metadata}`.
- Use this to inspect performance or pull the authoritative asset URLs before creating/updating local records.
- Example:
```json
{
"post_id": "23683656",
"limit": 20
}
```
- `update_asset`
- Purpose: Link assets to posts, adjust prompt associations, or update metadata.
- Required: `asset_id`.
- Optional: `post_id`, `input_prompt_id`, `output_prompt_id`, `civitai_id`, `civitai_url`, `metadata`.
- Returns: Updated asset payload.
- Guidance: Omit a field or send `undefined` to keep its value; send `null` to clear it.
- Examples:
```json
{"asset_id": "456", "post_id": "789"}
```
```json
{"asset_id": "456", "post_id": null}
```
- `list_civitai_posts`
- Purpose: Browse posts and their related assets/prompts.
- Filters: `civitai_id`, `status`, `created_by`, `start_time`, `end_time`.
- Pagination: `limit`, `offset`.
- Extras: `include_details: true` returns assets with nested prompt data.
- Example:
```json
{"include_details": true, "limit": 10}
```
## Canonical Workflow
1. (Optional) `calculate_sha256` → hash local file or remote URL.
2. (Optional) `find_asset` → skip creation if the SHA already exists.
3. (Optional) `create_prompt` → store the prompt before recording the asset.
4. `create_asset` → register the media (include `input_prompt_id` when available).
5. `create_civitai_post` → save the post metadata.
6. `update_asset` → link the asset to the post (if not done during creation) and enrich metadata.
## Step-by-Step Details
### 0. Calculate SHA256 (Duplicate Prevention)
- Use when you have a file/URL and need to avoid duplicate assets or confirm matches.
- Call `calculate_sha256` with the file path or download URL.
- Use the returned `sha256sum` with `find_asset` to check for existing records or to map media to existing records.
### 1. Record the Prompt
- Capture prompts before the associated asset is created.
- Required input: `prompt_text`.
- Optional metadata: model provider, model name, purpose, custom `metadata`.
- Keep the returned `prompt_id` to set `input_prompt_id` or `output_prompt_id` later.
### 2. Record the Asset
- Required inputs: `asset_url`, `asset_type`, `asset_source`.
- Optional relationship fields:
- `input_prompt_id`: Prompt that generated the asset.
- `output_prompt_id`: Prompt derived from the asset (e.g., captioning).
- `post_id`: Civitai post containing the asset (if you already recorded it).
- `civitai_id` / `civitai_url`: Identifiers returned from `fetch_civitai_post_assets` for each media item.
- `metadata`: Any structured data you want to retain (API response, tags, metrics).
- Tip: When the Civitai post already exists, call `fetch_civitai_post_assets` first to pull the authoritative `asset_url`, set `civitai_id`/`civitai_url`, and capture engagement stats in `metadata` for downstream reporting.
- The tool automatically calculates `sha256sum` from `asset_url` and includes it in the response.
- Save the returned `asset_id` for linking or future updates.
### 3. Record the Civitai Post
- Extract the numeric ID from the post URL (`https://civitai.com/posts/23602354` → `23602354`).
- Provide `civitai_id` and `civitai_url`; include optional `status`, `title`, `description`, `metadata`.
- Store the returned `post_id`. Assets point to posts (one post can have many assets).
### 4. Link Assets and Maintain Metadata
- Use `update_asset` to:
- Attach `post_id` once the post exists.
- Set or change `input_prompt_id` / `output_prompt_id`.
- Add or refresh `civitai_id` / `civitai_url`.
- Clear values by sending `null`.
- Remember: assets own the link to posts; posts do not store asset IDs.
## Supporting Queries
- `find_asset`:
- Use `sha256sum` to prevent duplicates or locate existing records.
- Use `asset_id` to fetch the complete asset payload for auditing.
- Examples:
```json
{"sha256sum": "abc123def456"}
```
```json
{"asset_id": "456"}
```
- `list_civitai_posts`:
- Filter by `status`, `created_by`, time window, or specific `civitai_id`.
- Include `include_details: true` to retrieve each post’s assets and nested prompt metadata for verification or reporting.
- Example:
```json
{
"status": "published",
"include_details": true,
"limit": 5,
"offset": 0
}
```
- `fetch_civitai_post_assets`:
- Supply `post_id` to get the post’s current media assets directly from Civitai along with engagement stats (likes, hearts, comments, etc.).
- Use when reconciling posts, validating asset URLs, or gauging performance before recording updates.
- Example:
```json
{
"post_id": "23683656",
"page": 2,
"limit": 25
}
```
## Best Practices & Validation
- Always capture and reuse the IDs returned from each tool.
- Follow the dependency order: hash → prompt → asset → post → link.
- Prevent duplicates: hash first, look up with `find_asset`, reuse the existing `asset_id` instead of creating a new record.
- Assets automatically store a SHA256 hash of their `asset_url`; keep the returned value handy for auditing.
- Respect field constraints:
- `asset_type`: `image` or `video`.
- `asset_source`: `generated` or `upload`.
- `status`: `pending`, `published`, or `failed`.
- IDs: strings containing only digits.
- Timestamps: ISO 8601 (`2025-01-15T10:00:00Z`).
- Use the `metadata` field for API responses, engagement metrics, tags, workflows, or other tracking data.
- When linking prompts: `input_prompt_id` represents the prompt that created the asset, `output_prompt_id` represents a prompt derived from it.
## Troubleshooting Checklist
- **“asset_id must be a valid integer ID”**: Ensure you are sending the ID as a string containing digits only.
- **“Record not found”**: Confirm the ID via `list_civitai_posts` or ensure you saved the correct ID from the previous call.
- **Cannot link a prompt**: Create the prompt first, then supply the returned `prompt_id` when creating/updating the asset.
- **Multiple assets per post**: Record each asset separately and link them all to the same `post_id` using `update_asset`.
## Playbooks
### Standard End-to-End Flow
1. `calculate_sha256` on the file or URL.
2. `find_asset` with that `sha256sum`; stop if `found` is true.
3. `create_prompt` (if a prompt exists) → keep `prompt_id`.
4. `create_asset` with `input_prompt_id`.
5. `create_civitai_post` → keep `post_id`.
6. `update_asset` to attach `post_id`.
### Asset + Post Without a Prompt
1. `create_asset` → keep `asset_id`.
2. `create_civitai_post` → keep `post_id`.
3. `update_asset` with both IDs to link.
### Assets First, Then Post
1. Create each asset without `post_id`.
2. Once the post is recorded, call `fetch_civitai_post_assets` to reconcile the canonical asset list.
3. For each matching item:
- `update_asset({asset_id, post_id})` to link.
- Optionally add engagement stats or update `civitai_id`/`civitai_url` from the API response.
### Match Local Media to a Civitai URL
1. Hash the local file with `calculate_sha256`.
2. Visit candidate Civitai image pages to grab the download URLs.
3. Hash each remote file with `calculate_sha256`.
4. When hashes match, call `update_asset` to store the corresponding `civitai_id` and `civitai_url`.
## Extracting Asset URLs from Civitai Posts
1. Call `fetch_civitai_post_assets` with the post’s numeric ID to retrieve the authoritative list of media along with their direct `asset_url`, `civitai_image_id`, and engagement stats.
2. Use the returned payload to populate `asset_url`, `civitai_url` (if present), and any performance metadata when creating or updating assets locally.
3. Hash the returned `asset_url` values with `calculate_sha256` when you need duplicate detection before persisting assets.
### Example: Multi-Asset Post
For `https://civitai.com/posts/23604281` containing three images:
1. `create_civitai_post({ "civitai_id": "23604281", "civitai_url": "https://civitai.com/posts/23604281" })` → `post_id: "1"`.
2. Call `fetch_civitai_post_assets({ "post_id": "23604281" })` to pull the live asset list.
3. For each item in the response:
- If you already have a matching asset (by SHA or URL), reuse the `asset_id`. Otherwise `create_asset` with the `asset_url`, `civitai_image_id`, and `civitai_url` (if present).
- Capture `engagement_stats` and other metadata in the asset record if it’s useful for reporting.
- Link the asset to the post with `update_asset({ "asset_id": "...", "post_id": "1" })`.
### Video-Specific Notes
- `fetch_civitai_post_assets` returns direct download URLs for videos in `asset_url`. Use those when creating or updating assets.
- Hash the `asset_url` if you need dedupe guarantees before persisting.
Following this guide keeps the Civitai dataset consistent, deduplicated, and fully linked across prompts, assets, and posts.
```
--------------------------------------------------------------------------------
/src/feedmob-reporting/src/index.ts:
--------------------------------------------------------------------------------
```typescript
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { fetchDirectSpendsData, getInmobiReportIds, checkInmobiReportStatus, getInmobiReports, createDirectSpend, getAppsflyerReports, getAdopsReports, getAgencyConversionMetrics, getClickUrlHistories, getPossibleFinanceSingularReports } from "./api.js";
// Create server instance
const server = new McpServer({
name: "feedmob-reporting",
version: "0.0.7",
capabilities: {
tools: {},
prompts: {},
},
});
// Tool Definition for Creating Direct Spend
server.tool(
"create_direct_spend",
"Create Or Update a direct spend via FeedMob API.",
{
click_url_id: z.number().describe("Click URL ID"),
spend_date: z.string().describe("Spend date in YYYY-MM-DD format"),
net_spend: z.number().optional().describe("Net spend amount"),
gross_spend: z.number().optional().describe("Gross spend amount"),
partner_paid_action_count: z.number().optional().describe("Partner paid action count"),
client_paid_action_count: z.number().optional().describe("Client paid action count"),
},
async (params) => {
try {
if (!params.net_spend && !params.gross_spend && !params.partner_paid_action_count && !params.client_paid_action_count) {
throw new Error("必须提供至少一个支出指标:net_spend, gross_spend, partner_paid_action_count 或 client_paid_action_count");
}
const result = await createDirectSpend(
params.click_url_id,
params.spend_date,
params.net_spend,
params.gross_spend,
params.partner_paid_action_count,
params.client_paid_action_count
);
const formattedData = JSON.stringify(result, null, 2);
return {
content: [{
type: "text",
text: `Direct spend created successfully:\n\`\`\`json\n${formattedData}\n\`\`\``,
}],
};
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "An unknown error occurred while creating direct spend.";
console.error("Error in create_direct_spend tool:", errorMessage);
return {
content: [{ type: "text", text: `Error creating direct spend: ${errorMessage}` }],
isError: true,
};
}
}
);
// Tool Definition for Getting Direct Spends
server.tool(
"get_direct_spends",
"Get direct spends data via FeedMob API.",
{
start_date: z.string().describe("Start date in YYYY-MM-DD format"),
end_date: z.string().describe("End date in YYYY-MM-DD format"),
click_url_ids: z.array(z.string()).describe("Array of click URL IDs"),
},
async (params) => {
try {
const spendData = await fetchDirectSpendsData(
params.start_date,
params.end_date,
params.click_url_ids
);
const formattedData = JSON.stringify(spendData, null, 2);
return {
content: [{
type: "text",
text: `Direct spends data:\n\`\`\`json\n${formattedData}\n\`\`\``,
}],
};
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "An unknown error occurred while fetching direct spends data.";
console.error("Error in get_direct_spends tool:", errorMessage);
return {
content: [{ type: "text", text: `Error fetching direct spends data: ${errorMessage}` }],
isError: true,
};
}
}
);
// Tool Definition for Agency Conversion Metrics
server.tool(
"get_agency_conversion_metrics",
"Get agency_conversion_records metrics for one or more click URL IDs.",
{
click_url_ids: z.array(z.number()).describe("Array of click URL IDs"),
date: z.string().optional().describe("Optional date in YYYY-MM-DD format"),
},
async (params) => {
try {
const data = await getAgencyConversionMetrics(params.click_url_ids, params.date);
const formattedData = JSON.stringify(data, null, 2);
return {
content: [{
type: "text",
text: `Agency conversion metrics data:\n\`\`\`json\n${formattedData}\n\`\`\``,
}],
};
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "An unknown error occurred while fetching agency conversion metrics.";
console.error("Error in get_agency_conversion_metrics tool:", errorMessage);
return {
content: [{ type: "text", text: `Error fetching agency conversion metrics: ${errorMessage}` }],
isError: true,
};
}
}
);
// Tool Definition for Click URL Histories
server.tool(
"get_click_url_histories",
"Get historical CPI data for click URL IDs.",
{
click_url_ids: z.array(z.number()).describe("Array of click URL IDs"),
date: z.string().optional().describe("Optional date in YYYY-MM-DD format"),
},
async (params) => {
try {
const data = await getClickUrlHistories(params.click_url_ids, params.date);
const formattedData = JSON.stringify(data, null, 2);
return {
content: [{
type: "text",
text: `Click URL histories data:\n\`\`\`json\n${formattedData}\n\`\`\``,
}],
};
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "An unknown error occurred while fetching click URL histories.";
console.error("Error in get_click_url_histories tool:", errorMessage);
return {
content: [{ type: "text", text: `Error fetching click URL histories: ${errorMessage}` }],
isError: true,
};
}
}
);
// Tool Definition for Inmobi Report IDs
server.tool(
"get_inmobi_report_ids",
"Get Inmobi report IDs for a date range. next step must use tool check_inmobi_report_id_status to check skan_report_id and non_skan_report_id available",
{
start_date: z.string().describe("Start date in YYYY-MM-DD format"),
end_date: z.string().describe("End date in YYYY-MM-DD format"),
},
async (params) => {
try {
const data = await getInmobiReportIds(params.start_date, params.end_date);
const formattedData = JSON.stringify(data, null, 2);
return {
content: [{
type: "text",
text: `Inmobi report IDs:\n\`\`\`json\n${formattedData}\n\`\`\``,
}],
};
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "An unknown error occurred while fetching Inmobi report IDs.";
console.error("Error in get_inmobi_report_ids tool:", errorMessage);
return {
content: [{ type: "text", text: `Error fetching Inmobi report IDs: ${errorMessage}` }],
isError: true,
};
}
}
);
// Tool Definition for Checking Report Status
server.tool(
"check_inmobi_report_status",
"Check the status of an Inmobi report.",
{
start_date: z.string().describe("Start date in YYYY-MM-DD format"),
end_date: z.string().describe("End date in YYYY-MM-DD format"),
report_id: z.string().describe("Report ID to check status for"),
},
async (params) => {
try {
const data = await checkInmobiReportStatus(params.start_date, params.end_date, params.report_id);
const formattedData = JSON.stringify(data, null, 2);
return {
content: [{
type: "text",
text: `Inmobi report status:\n\`\`\`json\n${formattedData}\n\`\`\``,
}],
};
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "An unknown error occurred while checking Inmobi report status.";
console.error("Error in check_inmobi_report_status tool:", errorMessage);
return {
content: [{ type: "text", text: `Error checking Inmobi report status: ${errorMessage}` }],
isError: true,
};
}
}
);
// Tool Definition for Getting Reports
server.tool(
"get_inmobi_reports",
"Get Inmobi reports data. next step should check direct spend from feedmob",
{
start_date: z.string().describe("Start date in YYYY-MM-DD format"),
end_date: z.string().describe("End date in YYYY-MM-DD format"),
skan_report_id: z.string().describe("SKAN report ID"),
non_skan_report_id: z.string().describe("Non-SKAN report ID"),
},
async (params) => {
try {
const data = await getInmobiReports(
params.start_date,
params.end_date,
params.skan_report_id,
params.non_skan_report_id
);
const formattedData = JSON.stringify(data, null, 2);
return {
content: [{
type: "text",
text: `Inmobi reports data:\n\`\`\`json\n${formattedData}\n\`\`\``,
}],
};
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "An unknown error occurred while fetching Inmobi reports.";
console.error("Error in get_inmobi_reports tool:", errorMessage);
return {
content: [{ type: "text", text: `Error fetching Inmobi reports: ${errorMessage}` }],
isError: true,
};
}
}
);
// Tool Definition for Getting AppsFlyer Reports
server.tool(
"get_appsflyer_reports",
"Get AppsFlyer reports data via FeedMob API.",
{
start_date: z.string().describe("Start date in YYYY-MM-DD format"),
end_date: z.string().describe("End date in YYYY-MM-DD format"),
click_url_ids: z.array(z.string()).optional().describe("Array of click URL IDs (optional)"),
af_app_ids: z.array(z.string()).optional().describe("Array of AppsFlyer app IDs (optional)"),
},
async (params) => {
try {
const data = await getAppsflyerReports(
params.start_date,
params.end_date,
params.click_url_ids,
params.af_app_ids
);
const formattedData = JSON.stringify(data, null, 2);
return {
content: [{
type: "text",
text: `AppsFlyer reports data:\n\`\`\`json\n${formattedData}\n\`\`\``,
}],
};
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "An unknown error occurred while fetching AppsFlyer reports.";
console.error("Error in get_appsflyer_reports tool:", errorMessage);
return {
content: [{ type: "text", text: `Error fetching AppsFlyer reports: ${errorMessage}` }],
isError: true,
};
}
}
);
// Tool Definition for Getting AdOps Reports
server.tool(
"get_adops_reports",
"Get AdOps reports data via FeedMob API.",
{
month: z.string().describe("Month in YYYY-MM format"),
},
async (params) => {
try {
const data = await getAdopsReports(params.month);
const formattedData = JSON.stringify(data, null, 2);
return {
content: [{
type: "text",
text: `AdOps reports data:\n\`\`\`json\n${formattedData}\n\`\`\``,
}],
};
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "An unknown error occurred while fetching AdOps reports.";
console.error("Error in get_adops_reports tool:", errorMessage);
return {
content: [{ type: "text", text: `Error fetching AdOps reports: ${errorMessage}` }],
isError: true,
};
}
}
);
// Tool Definition for Getting Possible Finance Singular Reports
server.tool(
"get_possible_finance_singular_reports",
"Get Possible Finance Singular API reports data via FeedMob API.",
{
start_date: z.string().describe("Start date in YYYY-MM-DD format"),
end_date: z.string().describe("End date in YYYY-MM-DD format"),
},
async (params) => {
try {
const data = await getPossibleFinanceSingularReports(
params.start_date,
params.end_date
);
const formattedData = JSON.stringify(data, null, 2);
return {
content: [{
type: "text",
text: `Possible Finance Singular reports data:\n\`\`\`json\n${formattedData}\n\`\`\``,
}],
};
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "An unknown error occurred while fetching Possible Finance Singular reports.";
console.error("Error in get_possible_finance_singular_reports tool:", errorMessage);
return {
content: [{ type: "text", text: `Error fetching Possible Finance Singular reports: ${errorMessage}` }],
isError: true,
};
}
}
);
// Prompt Definition
server.prompt(
"get_inmobi_reports",
{},
() => {
return {
messages: [{
role: "user",
content: {
type: "text",
text: "next step should check direct spend from feedmob",
}
}],
};
}
);
// Run the Server
async function main() {
const transport = new StdioServerTransport();
try {
await server.connect(transport);
console.error("FeedMob Spend MCP Server running on stdio...");
} catch (error) {
console.error("Failed to start FeedMob Spend MCP Server:", error);
process.exit(1);
}
}
main();
```
--------------------------------------------------------------------------------
/src/femini-reporting/src/index.ts:
--------------------------------------------------------------------------------
```typescript
#!/usr/bin/env node
import { FastMCP } from "fastmcp";
import { Schema, z } from "zod";
import jwt from 'jsonwebtoken';
// Create FastMCP server instance
const server = new FastMCP({
name: "femini-reporting",
version: "0.0.5",
instructions: `
This is a customized MCP server for the Feedmob project, specifically for querying and analyzing ad spend data.
Key Features:
- Query ad spend data (Campaign Spends)
- Supports various grouping methods and metrics
- Provides flexible filtering conditions
`.trim(),
});
const FEMINI_API_URL = process.env.FEMINI_API_URL;
const FEMINI_API_TOKEN = process.env.FEMINI_API_TOKEN;
const FEEDMOB_API_BASE = process.env.FEEDMOB_API_BASE;
const FEEDMOB_KEY = process.env.FEEDMOB_KEY;
const FEEDMOB_SECRET = process.env.FEEDMOB_SECRET;
if (!FEEDMOB_KEY || !FEEDMOB_SECRET) {
console.error("Error: FEEDMOB_KEY and FEEDMOB_SECRET environment variables must be set.");
process.exit(1);
}
// Generate JWT token
function generateToken(key: string, secret: string): string {
const expirationDate = new Date();
expirationDate.setDate(expirationDate.getDate() + 7); // 7 days from now
const payload = {
key: key,
expired_at: expirationDate.toISOString().split('T')[0] // Format as YYYY-MM-DD
};
return jwt.sign(payload, secret, { algorithm: 'HS256' });
}
server.addTool({
name: "query_admin_infomation",
description: "Queries various metric data for client, campaign, partner, and click_url from the admin system, supporting multiple metrics and filtering conditions.",
parameters: z.object({
date_gteq: z.string().optional().describe("Start date (YYYY-MM-DD format), defaults to the first day of the previous month"),
date_lteq: z.string().optional().describe("End date (YYYY-MM-DD format), defaults to yesterday"),
metrics: z.array(z.enum([
"direct_spends",
"infomation",
"direct_spend_with_change_logs",
"infomation_with_change_logs",
"price_rate_change_logs",
"spend_requests",
"spend_request_with_change_logs"
]))
.optional()
.default(["infomation"])
.describe(`### infomation
CAMPAIGN NAME,VENDOR NAME,TRACKER,LINK TYPE,MMP CLICK TRACKING LINK,MMP IMPRESSION TRACKING LINK,STATUS,START TIME,END TIME,DIRECT SPEND INPUT,NET CPI/CPA/CPM,GROSS UNIT PRICE,MARGIN,CLIENT PAID ACTION,VENDOR PAID ACTION,CAP ACTION,TARGET CAP,MAX CAP,DIRECT SPEND AUTOMATION SWITCH,CREATED AT,UPDATED AT
### direct_spends
List of direct spends for the specified date range, including:
- date
- gross_spend
- net_spend
- margin
- last_update_user
### direct_spend_with_change_logs
List of direct spends for the specified date range, including:
- date
- gross_spend
- net_spend
- margin
- last_update_user
- change_logs (user_name, action, version, comment, created_at, audited_changes)
### infomation_with_change_logs
Same as \`infomation\`, but additionally includes \`CHANGE LOGS\` (user_name, action, version, comment, created_at, audited_changes)
### price_rate_change_logs
List of price change for the specified date range, including:
- start_date
- end_date
- net_rate
- gross_rate
- margin
- create_user
### spend_requests
List of spend requests, including:
- gross_spend_formula
- net_spend_formula
- margin_formula
- gross_spend_source
- net_spend_source
- margin_source
- github_ticket
- having_client_report
- margin_type
- status
- created_at
- hubspot_ticket
- client_paid_actions
- vendor_paid_actions
- automation_start_date
### spend_request_with_change_logs
Same as \`spend_requests\`, but additionally includes \`change_logs\` (user_name, action, version, comment, created_at, audited_changes)
`).describe(`Metrics for admin system data.`),
legacy_client_id_in: z.array(z.string()).optional().describe("Client ID filter (array)"),
legacy_partner_id_in: z.array(z.string()).optional().describe("Partner ID filter (array)"),
legacy_campaign_id_in: z.array(z.string()).optional().describe("Campaign ID filter (array)"),
legacy_click_url_id_in: z.array(z.string()).optional().describe("Click URL ID filter (array)"),
}),
execute: async (args, { log }) => {
try {
const queryParams = new URLSearchParams();
if (args.date_gteq) queryParams.append('date_gteq', args.date_gteq);
if (args.date_lteq) queryParams.append('date_lteq', args.date_lteq);
// Process array parameters
if (args.metrics) {
args.metrics.forEach(metric => queryParams.append('metrics[]', metric));
}
if (args.legacy_client_id_in) {
args.legacy_client_id_in.forEach(id => queryParams.append('legacy_client_id_in[]', id));
}
if (args.legacy_partner_id_in) {
args.legacy_partner_id_in.forEach(id => queryParams.append('legacy_partner_id_in[]', id));
}
if (args.legacy_campaign_id_in) {
args.legacy_campaign_id_in.forEach(id => queryParams.append('legacy_campaign_id_in[]', id));
}
if (args.legacy_click_url_id_in) {
args.legacy_click_url_id_in.forEach(id => queryParams.append('legacy_click_url_id_in[]', id));
}
const apiUrl = `${FEEDMOB_API_BASE}/ai/api/femini_mcp_reports?${queryParams.toString()}`;
const token = generateToken(FEEDMOB_KEY as string, FEEDMOB_SECRET as string);
const response = await fetch(apiUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'FEEDMOB-KEY': FEEDMOB_KEY,
'FEEDMOB-TOKEN': token
},
});
if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
}
const data = await response.json();
return {
content: [
{
type: "text",
text: `# Query Result
**Query Parameters:**
- Metrics: ${args.metrics?.join(', ')}
- Date Range: ${args.date_gteq || 'default'} to ${args.date_lteq || 'default'}
**Raw JSON Data:**
\`\`\`json
${JSON.stringify(data, null, 2)}
\`\`\`
**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.**
`,
},
],
};
} catch (error: unknown) {
throw new Error(`Failed to query admin infomation: ${(error as Error).message}`);
}
},
});
// Query femini data
server.addTool({
name: "query_campaign_spends",
description: "Queries ad spend data from the femoni system, supporting various grouping methods and filtering conditions. After obtaining the results, further analyze and summarize the returned data, such as calculating total gross spend, total net spend, and identifying clients with the highest spend.",
parameters: z.object({
guide: z.string().describe("get from system resources campaign-spends-api-guide://usage"),
date_gteq: z.string().optional().describe("Start date (YYYY-MM-DD format), defaults to the first day of the previous month"),
date_lteq: z.string().optional().describe("End date (YYYY-MM-DD format), defaults to yesterday"),
groups: z.array(z.enum(["day", "week", "month", "client", "partner", "campaign", "click_url", "country"]))
.optional()
.default(['campaign', 'partner'])
.describe("Grouping methods: day, week, month, client, partner, campaign, click_url, country"),
metrics: z.array(z.enum(["gross", "net", "revenue", "impressions", "clicks", "installs", "cvr", "margin"]))
.optional()
.default(["gross", "net"])
.describe("Metrics to return: gross, net, revenue, impressions, clicks, installs, cvr, margin"),
legacy_client_id_in: z.array(z.string()).optional().describe("Client ID filter (array)"),
legacy_partner_id_in: z.array(z.string()).optional().describe("Partner ID filter (array)"),
legacy_campaign_id_in: z.array(z.string()).optional().describe("Campaign ID filter (array)"),
legacy_click_url_id_in: z.array(z.string()).optional().describe("Click URL ID filter (array)"),
}),
annotations: {
title: "Ad Spend Data Query Tool",
readOnlyHint: true,
openWorldHint: true,
},
execute: async (args, { log }) => {
try {
log.info("Querying ad spend data", {
groups: args.groups,
metrics: args.metrics,
date_range: `${args.date_gteq || 'default'} to ${args.date_lteq || 'default'}`
});
// Construct query parameters
const queryParams = new URLSearchParams();
if (args.date_gteq) queryParams.append('date_gteq', args.date_gteq);
if (args.date_lteq) queryParams.append('date_lteq', args.date_lteq);
// Process array parameters
if (args.metrics) {
args.metrics.forEach(metric => queryParams.append('metrics[]', metric));
}
if (args.groups) {
args.groups.forEach(group => queryParams.append('groups[]', group));
}
if (args.legacy_client_id_in) {
args.legacy_client_id_in.forEach(id => queryParams.append('legacy_client_id_in[]', id));
}
if (args.legacy_partner_id_in) {
args.legacy_partner_id_in.forEach(id => queryParams.append('legacy_partner_id_in[]', id));
}
if (args.legacy_campaign_id_in) {
args.legacy_campaign_id_in.forEach(id => queryParams.append('legacy_campaign_id_in[]', id));
}
if (args.legacy_click_url_id_in) {
args.legacy_click_url_id_in.forEach(id => queryParams.append('legacy_click_url_id_in[]', id));
}
// Construct full API URL
const apiUrl = `${FEMINI_API_URL}/api/unstable/mcp/campaign_spends?${queryParams.toString()}`;
log.info("Sending API request", { url: apiUrl });
// Send HTTP request
const response = await fetch(apiUrl, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': "Bearer " + FEMINI_API_TOKEN
},
});
if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
}
const data = await response.json();
log.info("API request successful", { resultCount: Object.keys(data).length });
return {
content: [
{
type: "text",
text: `# Ad Spend Data Query Result
**Query Parameters:**
- Grouping Method: ${args.groups}
- Metrics: ${args.metrics?.join(', ')}
- Date Range: ${args.date_gteq || 'default'} to ${args.date_lteq || 'default'}
**Raw JSON Data:**
\`\`\`json
${JSON.stringify(data, null, 2)}
\`\`\`
**Please further analyze and summarize the JSON array data based on the prompt, for example, calculate total gross spend, total net spend, and identify clients with the highest spend. Return the data in a formatted and aesthetically pleasing manner.**
**For table data, please generate plain text tables that are easy for humans to read.**
`,
},
],
};
} catch (error: unknown) {
log.error("Failed to query ad spend data", { error: (error as Error).message });
throw new Error(`Failed to query ad spend data: ${(error as Error).message}`);
}
},
});
server.addTool({
name: "search_ids",
description: "Retrieves a list of client, partner, and campaign ID information based on keywords. Note: 'femini', 'assistant', and 'feedmob' are existing system keywords and do not require ID queries.",
parameters: z.object({
keys: z.array(z.string()).optional().describe("List of keywords to get client, partner, campaign ID"),
}),
execute: async (args, { log }) => {
try {
const q = args.keys?.join(',') || '';
const apiUrl = `${FEMINI_API_URL}/api/unstable/entities/search?q=${q}`;
const response = await fetch(apiUrl, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': "Bearer " + FEMINI_API_TOKEN
},
});
if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
}
return await response.text();
} catch (error: unknown) {
log.error("Failed to get IDs", { error: (error as Error).message });
throw new Error(`Failed to get IDs: ${(error as Error).message}`);
}
},
});
// Add CampaignSpendsApiQuery User Manual resource
server.addResource({
uri: "campaign-spends-api-guide://usage",
name: "CampaignSpendsApiQuery User Manual",
mimeType: "text/markdown",
async load() {
const guideUrl = `${FEMINI_API_URL}/mcp/campaign-spends-api-guide.en.md`;
const response = await fetch(guideUrl, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': "Bearer " + FEMINI_API_TOKEN
},
});
try {
const response = await fetch(guideUrl);
if (!response.ok) {
throw new Error(`Failed to fetch guide: ${response.status} ${response.statusText}`);
}
const text = await response.text();
return {
text: text,
};
} catch (error) {
console.error("Error loading resource from URL:", error);
throw new Error(`Failed to load resource from URL: ${(error as Error).message}`);
}
},
});
server.start({
transportType: "stdio"
});
```
--------------------------------------------------------------------------------
/src/liftoff-reporting/src/index.ts:
--------------------------------------------------------------------------------
```typescript
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z, ZodSchema } from "zod";
import axios, { AxiosError } from "axios";
import dotenv from "dotenv";
import * as process from 'process';
dotenv.config(); // Load environment variables from .env file
// Liftoff API Configuration
const LIFTOFF_API_BASE = "https://data.liftoff.io/api/v1";
const LIFTOFF_API_KEY = process.env.LIFTOFF_API_KEY;
const LIFTOFF_API_SECRET = process.env.LIFTOFF_API_SECRET;
if (!LIFTOFF_API_KEY || !LIFTOFF_API_SECRET) {
console.error("Error: LIFTOFF_API_KEY or LIFTOFF_API_SECRET environment variable is not set.");
process.exit(1);
}
// Create server instance
const server = new McpServer({
name: "liftoff-reporting",
version: "0.0.3", // Updated version
capabilities: {
tools: {}, // Only tools capability needed for now
},
});
// --- Helper Function for Liftoff API Calls ---
interface LiftoffErrorResponse {
error_type?: string;
message?: string;
errors?: string[];
}
async function makeLiftoffApiRequest(
method: 'get' | 'post',
endpoint: string,
params?: Record<string, any>,
data?: Record<string, any>
): Promise<any> {
const url = `${LIFTOFF_API_BASE}${endpoint}`;
const auth = {
username: LIFTOFF_API_KEY!,
password: LIFTOFF_API_SECRET!,
};
const headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
};
try {
const response = await axios({
method: method,
url: url,
auth: auth,
headers: headers,
params: params, // GET request parameters
data: data, // POST request body
timeout: 60000, // 60 second timeout for potentially long reports
});
// For data download, response might not be JSON if format=csv
if (endpoint.endsWith('/data') && response.headers['content-type']?.includes('text/csv')) {
return response.data; // Return raw CSV string
}
return response.data;
} catch (error: unknown) {
console.error(`Error making Liftoff API request to ${method.toUpperCase()} ${url}:`, error);
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError<LiftoffErrorResponse>;
console.error("Axios error details:", {
message: axiosError.message,
code: axiosError.code,
status: axiosError.response?.status,
data: axiosError.response?.data,
});
const errorData = axiosError.response?.data;
const errorType = errorData?.error_type || 'Unknown Error';
const errorMessage = errorData?.message || axiosError.message;
const errorDetails = errorData?.errors?.join(', ') || 'No details provided.';
throw new Error(`Liftoff API Error (${axiosError.response?.status} ${errorType}): ${errorMessage} Details: ${errorDetails}`);
}
throw new Error(`Failed to call Liftoff API: ${error}`);
}
}
// --- Tool Definitions ---
const reportGroupBySchema = z.array(z.string()).optional().default(["apps", "campaigns", "country"]).describe(
"Group metrics by one of the available presets. e.g., [\"apps\", \"campaigns\"], [\"apps\", \"campaigns\", \"country\"]"
);
const reportFormatSchema = z.enum(["csv", "json"]).optional().default("csv").describe("Format of the report data");
const createReportInputSchema = z.object({
start_time: z.string().regex(/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}Z)?$/).describe("Start date (YYYY-MM-DD) or timestamp (YYYY-MM-DDTHH:mm:ssZ) in UTC."),
end_time: z.string().regex(/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}Z)?$/).describe("End date (YYYY-MM-DD) or timestamp (YYYY-MM-DDTHH:mm:ssZ) in UTC."),
group_by: reportGroupBySchema,
app_ids: z.array(z.string()).optional().describe("Optional. Filter by specific app IDs."),
campaign_ids: z.array(z.string()).optional().describe("Optional. Filter by specific campaign IDs."),
event_ids: z.array(z.string()).optional().describe("Optional. Filter by specific event IDs."),
cohort_window: z.number().int().min(1).max(90).optional().describe("Optional. Number of days since install (1-90)."),
format: reportFormatSchema,
callback_url: z.string().url().optional().describe("Optional. URL to receive POST when report is done."),
timezone: z.string().optional().default("UTC").describe("Optional. TZ database name (e.g., 'America/Los_Angeles')."),
include_repeat_events: z.boolean().optional().default(true),
remove_zero_rows: z.boolean().optional().default(false),
use_two_letter_country: z.boolean().optional().default(false),
});
// 1. Create Report Tool
server.tool(
"create_liftoff_report",
"Generate a report via the Liftoff Reporting API.",
createReportInputSchema.shape,
async (reportParams: z.infer<typeof createReportInputSchema>) => {
try {
const response = await makeLiftoffApiRequest('post', '/reports', undefined, reportParams);
return {
content: [{
type: "text",
text: `Report creation initiated successfully. Report ID: ${response.id}, State: ${response.state}`,
}],
};
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "An unknown error occurred creating the report.";
console.error("Error in create_liftoff_report tool:", errorMessage);
return {
content: [{ type: "text", text: `Error creating report: ${errorMessage}` }],
isError: true,
};
}
}
);
// 2. Check Report Status Tool
const checkStatusInputSchema = z.object({
report_id: z.string().describe("The ID of the report to check."),
});
server.tool(
"check_liftoff_report_status",
"Get the status of a previously created Liftoff report once every minute until it is completed.",
checkStatusInputSchema.shape,
async ({ report_id }: z.infer<typeof checkStatusInputSchema>) => {
try {
const response = await makeLiftoffApiRequest('get', `/reports/${report_id}/status`);
return {
content: [{
type: "text",
text: `Report Status for ID ${report_id}: ${response.state}. Created at: ${response.created_at}. Parameters: ${JSON.stringify(response.parameters)}`,
}],
};
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "An unknown error occurred checking report status.";
console.error("Error in check_liftoff_report_status tool:", errorMessage);
return {
content: [{ type: "text", text: `Error checking report status: ${errorMessage}` }],
isError: true,
};
}
}
);
// 3. Download Report Data Tool
const downloadDataInputSchema = z.object({
report_id: z.string().describe("The ID of the completed report to download."),
// Format is determined at creation time, not download time according to docs.
// If download format override was possible, add 'format' here.
});
server.tool(
"download_liftoff_report_data",
"Download the data for a completed Liftoff report.",
downloadDataInputSchema.shape,
async ({ report_id }: z.infer<typeof downloadDataInputSchema>) => {
try {
// Note: The API might return CSV text or JSON based on creation 'format'.
// This tool returns the raw response text. The LLM might need to parse it.
const reportData = await makeLiftoffApiRequest('get', `/reports/${report_id}/data`);
let outputText = '';
if (typeof reportData === 'string') {
// Likely CSV data
outputText = `Report data (CSV) for ID ${report_id}:\n\`\`\`csv\n${reportData}\n\`\`\``;
} else if (typeof reportData === 'object') {
// Likely JSON data
outputText = `Report data (JSON) for ID ${report_id}:\n\`\`\`json\n${JSON.stringify(reportData, null, 2)}\n\`\`\``;
} else {
outputText = `Received unexpected data format for report ID ${report_id}.`;
}
return {
content: [{ type: "text", text: outputText }],
};
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "An unknown error occurred downloading report data.";
console.error("Error in download_liftoff_report_data tool:", errorMessage);
return {
content: [{ type: "text", text: `Error downloading report data: ${errorMessage}` }],
isError: true,
};
}
}
);
// 4. List Apps Tool
const listAppsInputSchema = z.object({}); // Keep the object for inference
server.tool(
"list_liftoff_apps",
"Fetch app details from the Liftoff Reporting API.",
listAppsInputSchema.shape,
async () => {
try {
const apps = await makeLiftoffApiRequest('get', '/apps');
return {
content: [{
type: "text",
text: `Available Liftoff Apps:\n\`\`\`json\n${JSON.stringify(apps, null, 2)}\n\`\`\``,
}],
};
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "An unknown error occurred listing apps.";
console.error("Error in list_liftoff_apps tool:", errorMessage);
return {
content: [{ type: "text", text: `Error listing apps: ${errorMessage}` }],
isError: true,
};
}
}
);
// 5. List Campaigns Tool
const listCampaignsInputSchema = z.object({}); // Keep the object for inference
server.tool(
"list_liftoff_campaigns",
"Fetch campaign details from the Liftoff Reporting API.",
listCampaignsInputSchema.shape,
async () => {
try {
const campaigns = await makeLiftoffApiRequest('get', '/campaigns');
return {
content: [{
type: "text",
text: `Available Liftoff Campaigns:\n\`\`\`json\n${JSON.stringify(campaigns, null, 2)}\n\`\`\``,
}],
};
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "An unknown error occurred listing campaigns.";
console.error("Error in list_liftoff_campaigns tool:", errorMessage);
return {
content: [{ type: "text", text: `Error listing campaigns: ${errorMessage}` }],
isError: true,
};
}
}
);
// 6. Download Report Data with Campaign Names Tool
server.tool(
"download_liftoff_report_with_names",
"Download the data for a completed Liftoff report with campaign names.",
downloadDataInputSchema.shape,
async ({ report_id }: z.infer<typeof downloadDataInputSchema>) => {
try {
// Get report data
const reportData = await makeLiftoffApiRequest('get', `/reports/${report_id}/data`);
// Get campaign information
const campaignsResponse = await makeLiftoffApiRequest('get', '/campaigns');
if (!Array.isArray(campaignsResponse)) {
throw new Error("Failed to retrieve campaign information");
}
// Create map of campaign IDs to names
const campaignMap = new Map();
campaignsResponse.forEach((campaign: any) => {
campaignMap.set(campaign.id, campaign.name);
});
// Process the report data to include campaign names
let outputData;
if (typeof reportData === 'string') {
// If CSV format, need to parse and modify
const rows = reportData.split('\n');
const headers = rows[0].split(',');
// Add campaign_name header
const campaignIdIndex = headers.indexOf('campaign_id');
if (campaignIdIndex > -1) {
headers.push('campaign_name');
rows[0] = headers.join(',');
// Add campaign name to each data row
for (let i = 1; i < rows.length; i++) {
if (rows[i].trim()) {
const values = rows[i].split(',');
const campaignId = values[campaignIdIndex];
const campaignName = campaignMap.get(campaignId) || 'Unknown Campaign';
values.push(`"${campaignName}"`);
rows[i] = values.join(',');
}
}
}
outputData = rows.join('\n');
return {
content: [{ type: "text", text: `Report data (CSV) with campaign names for ID ${report_id}:\n\`\`\`csv\n${outputData}\n\`\`\`` }],
};
} else if (typeof reportData === 'object') {
// If JSON format
if (reportData.columns && reportData.rows && Array.isArray(reportData.rows)) {
// Add campaign_name to columns
const campaignIdIndex = reportData.columns.indexOf('campaign_id');
if (campaignIdIndex > -1) {
reportData.columns.push('campaign_name');
// Add campaign name to each row
reportData.rows.forEach((row: any[]) => {
const campaignId = row[campaignIdIndex];
const campaignName = campaignMap.get(campaignId) || 'Unknown Campaign';
row.push(campaignName);
});
}
}
return {
content: [{ type: "text", text: `Report data (JSON) with campaign names for ID ${report_id}:\n\`\`\`json\n${JSON.stringify(reportData, null, 2)}\n\`\`\`` }],
};
} else {
return {
content: [{ type: "text", text: `Received unexpected data format for report ID ${report_id}.` }],
};
}
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "An unknown error occurred downloading report data with campaign names.";
console.error("Error in download_liftoff_report_with_names tool:", errorMessage);
return {
content: [{ type: "text", text: `Error downloading report data with campaign names: ${errorMessage}` }],
isError: true,
};
}
}
);
// --- Run the Server ---
async function main() {
const transport = new StdioServerTransport();
try {
await server.connect(transport);
console.error("Liftoff Reporting MCP Server running on stdio..."); // Updated message
} catch (error) {
console.error("Failed to start Liftoff Reporting MCP Server:", error); // Updated message
process.exit(1);
}
}
main();
```
--------------------------------------------------------------------------------
/src/feedmob-reporting/src/api.ts:
--------------------------------------------------------------------------------
```typescript
import axios from "axios";
import jwt from 'jsonwebtoken';
import dotenv from "dotenv";
dotenv.config(); // Load environment variables from .env file
const FEEDMOB_API_BASE = process.env.FEEDMOB_API_BASE;
const FEEDMOB_KEY = process.env.FEEDMOB_KEY;
const FEEDMOB_SECRET = process.env.FEEDMOB_SECRET;
if (!FEEDMOB_KEY || !FEEDMOB_SECRET) {
console.error("Error: FEEDMOB_KEY and FEEDMOB_SECRET environment variables must be set.");
process.exit(1);
}
// Generate JWT token
function generateToken(key: string, secret: string): string {
const expirationDate = new Date();
expirationDate.setDate(expirationDate.getDate() + 7); // 7 days from now
const payload = {
key: key,
expired_at: expirationDate.toISOString().split('T')[0] // Format as YYYY-MM-DD
};
return jwt.sign(payload, secret, { algorithm: 'HS256' });
}
// Helper Function for API Call
export async function fetchDirectSpendsData(
start_date: string,
end_date: string,
click_url_ids: string[]
): Promise<any> {
const urlObj = new URL(`${FEEDMOB_API_BASE}/ai/api/direct_spends`);
// Add query parameters
urlObj.searchParams.append('start_date', start_date);
urlObj.searchParams.append('end_date', end_date);
click_url_ids.forEach(id => {
urlObj.searchParams.append('click_url_ids[]', id);
});
const url = urlObj.toString();
try {
const token = generateToken(FEEDMOB_KEY as string, FEEDMOB_SECRET as string);
const response = await axios.get(url, {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'FEEDMOB-KEY': FEEDMOB_KEY,
'FEEDMOB-TOKEN': token
},
timeout: 30000,
});
return response.data;
} catch (error: unknown) {
console.error("Error fetching direct spends data from FeedMob API:", error);
if (error && typeof error === 'object' && 'response' in error) {
const err = error as Record<string, any>;
const status = err.response?.status;
if (status === 401) {
throw new Error('FeedMob API request failed: Unauthorized (Invalid API Key or Token)');
} else if (status === 400) {
throw new Error('FeedMob API request failed: Bad Request');
} else if (status === 404) {
throw new Error('FeedMob API request failed: Not Found');
} else {
throw new Error(`FeedMob API request failed: ${status || 'Unknown error'}`);
}
}
throw new Error('Failed to fetch direct spends data from FeedMob API');
}
}
export async function getAgencyConversionMetrics(
click_url_ids: number[],
date?: string
): Promise<any> {
const urlObj = new URL(`${FEEDMOB_API_BASE}/ai/api/agency_conversion_metrics`);
click_url_ids.forEach((id) => urlObj.searchParams.append('click_url_ids[]', String(id)));
if (date) {
urlObj.searchParams.append('date', date);
}
const url = urlObj.toString();
try {
const token = generateToken(FEEDMOB_KEY as string, FEEDMOB_SECRET as string);
const response = await axios.get(url, {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'FEEDMOB-KEY': FEEDMOB_KEY,
'FEEDMOB-TOKEN': token,
},
timeout: 30000,
});
return response.data;
} catch (error: unknown) {
console.error('Error fetching agency conversion metrics:', error);
if (error && typeof error === 'object' && 'response' in error) {
const err = error as Record<string, any>;
const status = err.response?.status;
if (status === 401) {
throw new Error('FeedMob API request failed: Unauthorized (Invalid API Key or Token)');
} else if (status === 400) {
throw new Error('FeedMob API request failed: Bad Request');
} else if (status === 404) {
throw new Error('FeedMob API request failed: Not Found');
} else {
throw new Error(`FeedMob API request failed: ${status || 'Unknown error'}`);
}
}
throw new Error('Failed to fetch agency conversion metrics');
}
}
export async function getClickUrlHistories(
click_url_ids: number[],
date?: string
): Promise<any> {
const urlObj = new URL(`${FEEDMOB_API_BASE}/ai/api/click_url_histories`);
click_url_ids.forEach((id) => urlObj.searchParams.append('click_url_ids[]', String(id)));
if (date) {
urlObj.searchParams.append('date', date);
}
const url = urlObj.toString();
try {
const token = generateToken(FEEDMOB_KEY as string, FEEDMOB_SECRET as string);
const response = await axios.get(url, {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'FEEDMOB-KEY': FEEDMOB_KEY,
'FEEDMOB-TOKEN': token,
},
timeout: 30000,
});
return response.data;
} catch (error: unknown) {
console.error('Error fetching click URL histories:', error);
if (error && typeof error === 'object' && 'response' in error) {
const err = error as Record<string, any>;
const status = err.response?.status;
if (status === 401) {
throw new Error('FeedMob API request failed: Unauthorized (Invalid API Key or Token)');
} else if (status === 400) {
throw new Error('FeedMob API request failed: Bad Request');
} else if (status === 404) {
throw new Error('FeedMob API request failed: Not Found');
} else {
throw new Error(`FeedMob API request failed: ${status || 'Unknown error'}`);
}
}
throw new Error('Failed to fetch click URL histories');
}
}
export async function getInmobiReportIds(
start_date: string,
end_date: string
): Promise<any> {
const urlObj = new URL(`${FEEDMOB_API_BASE}/ai/api/inmobi_api_reports/get_inmobi_report_ids`);
urlObj.searchParams.append('start_date', start_date);
urlObj.searchParams.append('end_date', end_date);
const url = urlObj.toString();
try {
const token = generateToken(FEEDMOB_KEY as string, FEEDMOB_SECRET as string);
const response = await axios.get(url, {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'FEEDMOB-KEY': FEEDMOB_KEY,
'FEEDMOB-TOKEN': token
},
timeout: 30000,
});
return response.data;
} catch (error: unknown) {
console.error("Error fetching Inmobi report IDs:", error);
if (error && typeof error === 'object' && 'response' in error) {
const err = error as Record<string, any>;
const status = err.response?.status;
if (status === 401) {
throw new Error('FeedMob API request failed: Unauthorized (Invalid API Key or Token)');
} else if (status === 400) {
throw new Error('FeedMob API request failed: Bad Request');
} else if (status === 404) {
throw new Error('FeedMob API request failed: Not Found');
} else {
throw new Error(`FeedMob API request failed: ${status || 'Unknown error'}`);
}
}
throw new Error('Failed to fetch Inmobi report IDs');
}
}
export async function checkInmobiReportStatus(
start_date: string,
end_date: string,
report_id: string
): Promise<any> {
const urlObj = new URL(`${FEEDMOB_API_BASE}/ai/api/inmobi_api_reports/check_inmobi_report_id_status`);
urlObj.searchParams.append('start_date', start_date);
urlObj.searchParams.append('end_date', end_date);
urlObj.searchParams.append('report_id', report_id);
const url = urlObj.toString();
try {
const token = generateToken(FEEDMOB_KEY as string, FEEDMOB_SECRET as string);
const response = await axios.get(url, {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'FEEDMOB-KEY': FEEDMOB_KEY,
'FEEDMOB-TOKEN': token
},
timeout: 30000,
});
return response.data;
} catch (error: unknown) {
console.error("Error checking Inmobi report status:", error);
if (error && typeof error === 'object' && 'response' in error) {
const err = error as Record<string, any>;
const status = err.response?.status;
if (status === 401) {
throw new Error('FeedMob API request failed: Unauthorized (Invalid API Key or Token)');
} else if (status === 400) {
throw new Error('FeedMob API request failed: Bad Request');
} else if (status === 404) {
throw new Error('FeedMob API request failed: Not Found');
} else {
throw new Error(`FeedMob API request failed: ${status || 'Unknown error'}`);
}
}
throw new Error('Failed to check Inmobi report status');
}
}
export async function createDirectSpend(
click_url_id: number,
spend_date: string,
net_spend?: number,
gross_spend?: number,
partner_paid_action_count?: number,
client_paid_action_count?: number
): Promise<any> {
// Validate at least one spend metric is provided
if (!net_spend && !gross_spend && !partner_paid_action_count && !client_paid_action_count) {
throw new Error('必须提供至少一个支出指标:net_spend, gross_spend, partner_paid_action_count 或 client_paid_action_count');
}
const url = `${FEEDMOB_API_BASE}/ai/api/direct_spends`;
try {
const token = generateToken(FEEDMOB_KEY as string, FEEDMOB_SECRET as string);
const response = await axios.post(url, {
click_url_id,
spend_date,
net_spend,
gross_spend,
partner_paid_action_count,
client_paid_action_count
}, {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'FEEDMOB-KEY': FEEDMOB_KEY,
'FEEDMOB-TOKEN': token
},
timeout: 30000,
});
return response.data;
} catch (error: unknown) {
console.error("Error creating direct spend:", error);
if (error && typeof error === 'object' && 'response' in error) {
const err = error as Record<string, any>;
const status = err.response?.status;
if (status === 401) {
throw new Error('FeedMob API request failed: Unauthorized (Invalid API Key or Token)');
} else if (status === 400) {
throw new Error('FeedMob API request failed: Bad Request');
} else if (status === 404) {
throw new Error('FeedMob API request failed: Not Found');
} else {
throw new Error(`FeedMob API request failed: ${status || 'Unknown error'}`);
}
}
throw new Error('Failed to create direct spend');
}
}
export async function getInmobiReports(
start_date: string,
end_date: string,
skan_report_id: string,
non_skan_report_id: string
): Promise<any> {
const urlObj = new URL(`${FEEDMOB_API_BASE}/ai/api/inmobi_api_reports`);
urlObj.searchParams.append('start_date', start_date);
urlObj.searchParams.append('end_date', end_date);
urlObj.searchParams.append('skan_report_id', skan_report_id);
urlObj.searchParams.append('non_skan_report_id', non_skan_report_id);
const url = urlObj.toString();
try {
const token = generateToken(FEEDMOB_KEY as string, FEEDMOB_SECRET as string);
const response = await axios.get(url, {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'FEEDMOB-KEY': FEEDMOB_KEY,
'FEEDMOB-TOKEN': token
},
timeout: 30000,
});
return response.data;
} catch (error: unknown) {
console.error("Error fetching Inmobi reports:", error);
if (error && typeof error === 'object' && 'response' in error) {
const err = error as Record<string, any>;
const status = err.response?.status;
if (status === 401) {
throw new Error('FeedMob API request failed: Unauthorized (Invalid API Key or Token)');
} else if (status === 400) {
throw new Error('FeedMob API request failed: Bad Request');
} else if (status === 404) {
throw new Error('FeedMob API request failed: Not Found');
} else {
throw new Error(`FeedMob API request failed: ${status || 'Unknown error'}`);
}
}
throw new Error('Failed to fetch Inmobi reports');
}
}
export async function getAppsflyerReports(
start_date: string,
end_date: string,
click_url_ids?: string[],
af_app_ids?: string[]
): Promise<any> {
const urlObj = new URL(`${FEEDMOB_API_BASE}/ai/api/appsflyer_reports`);
// Add required parameters
urlObj.searchParams.append('start_date', start_date);
urlObj.searchParams.append('end_date', end_date);
// Add optional parameters
if (click_url_ids && click_url_ids.length > 0) {
click_url_ids.forEach(id => {
urlObj.searchParams.append('click_url_ids[]', id);
});
}
if (af_app_ids && af_app_ids.length > 0) {
af_app_ids.forEach(id => {
urlObj.searchParams.append('af_app_ids[]', id);
});
}
const url = urlObj.toString();
try {
const token = generateToken(FEEDMOB_KEY as string, FEEDMOB_SECRET as string);
const response = await axios.get(url, {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'FEEDMOB-KEY': FEEDMOB_KEY,
'FEEDMOB-TOKEN': token
},
timeout: 30000,
});
return response.data;
} catch (error: unknown) {
console.error("Error fetching AppsFlyer reports:", error);
if (error && typeof error === 'object' && 'response' in error) {
const err = error as Record<string, any>;
const status = err.response?.status;
if (status === 401) {
throw new Error('FeedMob API request failed: Unauthorized (Invalid API Key or Token)');
} else if (status === 400) {
throw new Error('FeedMob API request failed: Bad Request');
} else if (status === 404) {
throw new Error('FeedMob API request failed: Not Found');
} else {
throw new Error(`FeedMob API request failed: ${status || 'Unknown error'}`);
}
}
throw new Error('Failed to fetch AppsFlyer reports');
}
}
export async function getAdopsReports(
month: string
): Promise<any> {
const urlObj = new URL(`${FEEDMOB_API_BASE}/ai/api/adops_reports`);
// Add month parameter
urlObj.searchParams.append('month', month);
const url = urlObj.toString();
try {
const token = generateToken(FEEDMOB_KEY as string, FEEDMOB_SECRET as string);
const response = await axios.get(url, {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'FEEDMOB-KEY': FEEDMOB_KEY,
'FEEDMOB-TOKEN': token
},
timeout: 30000,
});
return response.data;
} catch (error: unknown) {
console.error("Error fetching AdOps reports:", error);
if (error && typeof error === 'object' && 'response' in error) {
const err = error as Record<string, any>;
const status = err.response?.status;
if (status === 401) {
throw new Error('FeedMob API request failed: Unauthorized (Invalid API Key or Token)');
} else if (status === 400) {
throw new Error('FeedMob API request failed: Bad Request');
} else if (status === 404) {
throw new Error('FeedMob API request failed: Not Found');
} else {
throw new Error(`FeedMob API request failed: ${status || 'Unknown error'}`);
}
}
throw new Error('Failed to fetch AdOps reports');
}
}
export async function getPossibleFinanceSingularReports(
start_date: string,
end_date: string
): Promise<any> {
const urlObj = new URL(`${FEEDMOB_API_BASE}/ai/api/possible_finance_singular_api_reports`);
// Add required parameters
urlObj.searchParams.append('start_date', start_date);
urlObj.searchParams.append('end_date', end_date);
const url = urlObj.toString();
try {
const token = generateToken(FEEDMOB_KEY as string, FEEDMOB_SECRET as string);
const response = await axios.get(url, {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'FEEDMOB-KEY': FEEDMOB_KEY,
'FEEDMOB-TOKEN': token
},
timeout: 30000,
});
return response.data;
} catch (error: unknown) {
console.error("Error fetching Possible Finance Singular reports:", error);
if (error && typeof error === 'object' && 'response' in error) {
const err = error as Record<string, any>;
const status = err.response?.status;
if (status === 401) {
throw new Error('FeedMob API request failed: Unauthorized (Invalid API Key or Token)');
} else if (status === 400) {
throw new Error('FeedMob API request failed: Bad Request');
} else if (status === 404) {
throw new Error('FeedMob API request failed: Not Found');
} else {
throw new Error(`FeedMob API request failed: ${status || 'Unknown error'}`);
}
}
throw new Error('Failed to fetch Possible Finance Singular reports');
}
}
```
--------------------------------------------------------------------------------
/src/samsung-reporting/src/index.ts:
--------------------------------------------------------------------------------
```typescript
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import fetch from "node-fetch";
import dotenv from "dotenv";
import jwt from "jsonwebtoken";
dotenv.config();
const server = new McpServer({
name: "Samsung Reporting MCP Server",
version: "0.1.2"
});
// Configuration constants
const SAMSUNG_BASE_URL = process.env.SAMSUNG_BASE_URL || 'https://devapi.samsungapps.com';
const SAMSUNG_ISS = process.env.SAMSUNG_ISS || '';
const SAMSUNG_PRIVATE_KEY = process.env.SAMSUNG_PRIVATE_KEY || '';
const SAMSUNG_SCOPES = ['publishing', 'gss'] as const;
const JWT_EXPIRY_MINUTES = 20;
const TOKEN_BUFFER_MINUTES = 2; // Refresh token 2 minutes before expiry
// Type definitions
interface ContentApp {
app: string;
contentIds: string[];
}
interface MetricResult {
contentIds: string[];
metrics?: any[];
error?: string;
}
interface TokenInfo {
token: string;
expiresAt: number;
}
// Default metric IDs
const DEFAULT_METRIC_IDS = [
'total_unique_installs_filter',
'dn_by_total_dvce',
'revenue_total',
'revenue_iap_order_count',
'daily_rat_score',
'daily_rat_volumne'
] as const;
const SAMSUNG_CONTENT_IDS: ContentApp[] = [
{ app: 'Lyft', contentIds: ['000007874233'] },
{ app: 'Self Financial', contentIds: ['000008094857'] },
{ app: 'Chime', contentIds: ['000008223186'] },
{ app: 'ZipRecruiter', contentIds: ['000008182313'] },
{ app: 'Upside', contentIds: ['000007104981', '000008222297'] },
];
/**
* Custom error classes
*/
class CapturedError extends Error {
constructor(message: string) {
super(message);
this.name = 'CapturedError';
}
}
class SamsungApiError extends CapturedError {
constructor(message: string) {
super(message);
this.name = 'SamsungApiError';
}
}
class ConfigurationError extends CapturedError {
constructor(message: string) {
super(message);
this.name = 'ConfigurationError';
}
}
/**
* Utility functions
*/
function validateConfiguration(): void {
if (!SAMSUNG_ISS) {
throw new ConfigurationError('SAMSUNG_ISS environment variable is required');
}
if (!SAMSUNG_PRIVATE_KEY) {
throw new ConfigurationError('SAMSUNG_PRIVATE_KEY environment variable is required');
}
}
function isValidDateFormat(date: string): boolean {
return /^\d{4}-\d{2}-\d{2}$/.test(date);
}
function isValidDateRange(startDate: string, endDate: string): boolean {
return new Date(startDate) <= new Date(endDate);
}
/**
* Get available app names for validation
*/
function getAvailableAppNames(): string[] {
return SAMSUNG_CONTENT_IDS.map(app => app.app);
}
/**
* Find app by name (case-insensitive)
*/
function findAppByName(appName: string): ContentApp | undefined {
return SAMSUNG_CONTENT_IDS.find(app =>
app.app.toLowerCase() === appName.toLowerCase()
);
}
/**
* Filter apps by name
*/
function filterAppsByName(appName?: string): ContentApp[] {
if (!appName) {
return SAMSUNG_CONTENT_IDS;
}
const foundApp = findAppByName(appName);
if (!foundApp) {
throw new SamsungApiError(
`App "${appName}" not found. Available apps: ${getAvailableAppNames().join(', ')}`
);
}
return [foundApp];
}
/**
* Samsung API Service Class
*/
class SamsungApiService {
private readonly startDate: string;
private readonly endDate: string;
private tokenInfo: TokenInfo | null = null;
constructor(startDate: string, endDate: string) {
validateConfiguration();
if (!isValidDateFormat(startDate) || !isValidDateFormat(endDate)) {
throw new SamsungApiError('Invalid date format. Use YYYY-MM-DD format.');
}
if (!isValidDateRange(startDate, endDate)) {
throw new SamsungApiError('Start date cannot be after end date.');
}
this.startDate = startDate;
this.endDate = endDate;
}
/**
* Generate JWT token for Samsung API authentication
*/
private generateJwt(): string {
try {
const iat = Math.floor(Date.now() / 1000);
const exp = iat + (JWT_EXPIRY_MINUTES * 60);
const payload = {
iss: SAMSUNG_ISS,
scopes: SAMSUNG_SCOPES,
exp,
iat
};
return jwt.sign(payload, SAMSUNG_PRIVATE_KEY, { algorithm: 'RS256' });
} catch (error: any) {
console.error('Error generating JWT:', error);
throw new SamsungApiError(`Failed to generate JWT: ${error.message}`);
}
}
/**
* Check if current token is valid and not expired
*/
private isTokenValid(): boolean {
if (!this.tokenInfo) {
return false;
}
const now = Date.now();
const bufferTime = TOKEN_BUFFER_MINUTES * 60 * 1000;
return now < (this.tokenInfo.expiresAt - bufferTime);
}
/**
* Fetch access token using JWT with caching
*/
private async fetchAccessToken(): Promise<void> {
if (this.isTokenValid()) {
return;
}
try {
const jwtToken = this.generateJwt();
const response = await fetch(`${SAMSUNG_BASE_URL}/auth/accessToken`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${jwtToken}`
}
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`HTTP ${response.status}: ${errorBody}`);
}
const data = await response.json() as any;
const accessToken = data.createdItem?.accessToken;
if (!accessToken) {
throw new Error('Access token not found in response');
}
// Cache token with expiry time
this.tokenInfo = {
token: accessToken,
expiresAt: Date.now() + (JWT_EXPIRY_MINUTES * 60 * 1000)
};
console.error('Successfully obtained Samsung access token');
} catch (error: any) {
console.error('Error fetching access token:', error);
throw new SamsungApiError(`Failed to fetch access token: ${error.message}`);
}
}
/**
* Sleep for specified milliseconds
*/
private async sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Fetch content metrics for a given content ID with retry mechanism
*/
async fetchContentMetric(
contentId: string,
metricIds: string[] = [...DEFAULT_METRIC_IDS],
maxRetries: number = 3,
baseDelay: number = 2000,
noBreakdown: boolean = true
): Promise<any[]> {
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await this.fetchAccessToken();
if (!this.tokenInfo) {
throw new Error('No valid access token available');
}
const requestBody = {
contentId,
periods: [{
startDate: this.startDate,
endDate: this.endDate
}],
getDailyMetrics: false,
noContentMetadata: true,
noBreakdown: noBreakdown,
metricIds,
filters: {},
trendAggregation: 'day'
};
console.error(`Fetching content metrics for content ID: ${contentId} (attempt ${attempt}/${maxRetries})`);
// Add delay before making the request (except for first attempt)
if (attempt > 1) {
const delay = baseDelay * Math.pow(2, attempt - 2); // Exponential backoff
console.error(`Waiting ${delay}ms before retry attempt ${attempt}`);
await this.sleep(delay);
}
const response = await fetch(`${SAMSUNG_BASE_URL}/gss/query/contentMetric`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.tokenInfo.token}`,
'service-account-id': SAMSUNG_ISS
},
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const errorBody = await response.text();
const error = new Error(`HTTP ${response.status}: ${errorBody}`);
// Check if it's a retryable error
if (response.status >= 500 || response.status === 429 || response.status === 408) {
lastError = error;
console.error(`Retryable error for ${contentId} (attempt ${attempt}/${maxRetries}):`, error.message);
if (attempt === maxRetries) {
throw new SamsungApiError(`Failed to fetch content metric for ${contentId} after ${maxRetries} attempts: ${error.message}`);
}
continue; // Retry
} else {
// Non-retryable error (4xx except 429 and 408)
throw error;
}
}
const data = await response.json() as any;
// Add a small delay after successful request to be respectful to the API
await this.sleep(500);
console.error(`Successfully fetched content metrics for ${contentId} on attempt ${attempt}`);
return data.data?.periods || [];
} catch (error: any) {
lastError = error;
// If it's not a network/server error, don't retry
if (error.name === 'TypeError' && error.message.includes('fetch')) {
// Network error - retry
console.error(`Network error for ${contentId} (attempt ${attempt}/${maxRetries}):`, error.message);
} else if (error.message.includes('HTTP 5') || error.message.includes('HTTP 429') || error.message.includes('HTTP 408')) {
// Server error or rate limit - retry
console.error(`Server error for ${contentId} (attempt ${attempt}/${maxRetries}):`, error.message);
} else {
// Other errors (like auth errors) - don't retry
console.error(`Non-retryable error for ${contentId}:`, error.message);
throw new SamsungApiError(`Failed to fetch content metric for ${contentId}: ${error.message}`);
}
if (attempt === maxRetries) {
throw new SamsungApiError(`Failed to fetch content metric for ${contentId} after ${maxRetries} attempts: ${lastError?.message || 'Unknown error'}`);
}
}
}
// This should never be reached, but just in case
throw new SamsungApiError(`Failed to fetch content metric for ${contentId} after ${maxRetries} attempts: ${lastError?.message || 'Unknown error'}`);
}
/**
* Fetch content metrics for a given array of content IDs with retry mechanism, and aggregate results
*/
async fetchContentMetricsForApp(
contentIds: string[],
metricIds: string[] = [...DEFAULT_METRIC_IDS],
maxRetries: number = 3,
baseDelay: number = 2000,
noBreakdown: boolean = true
): Promise<any[]> {
const allResults: any[] = [];
for (const contentId of contentIds) {
try {
const result = await this.fetchContentMetric(contentId, metricIds, maxRetries, baseDelay, noBreakdown);
allResults.push({ contentId, metrics: result });
} catch (error: any) {
allResults.push({ contentId, error: error.message });
}
}
return allResults;
}
/**
* Fetch content metrics for specified apps with parallel processing and retry mechanism
*/
async fetchContentMetrics(
apps: ContentApp[],
metricIds: string[] = [...DEFAULT_METRIC_IDS],
maxRetries: number = 3,
baseDelay: number = 2000,
noBreakdown: boolean = true
): Promise<Record<string, MetricResult>> {
try {
// Pre-fetch access token to avoid multiple token requests
await this.fetchAccessToken();
// Process specified apps in parallel for better performance
const promises = apps.map(async ({ app, contentIds }): Promise<[string, MetricResult]> => {
try {
console.error(`Fetching metrics for ${app} (${contentIds.join(',')}) with retry mechanism`);
const metricsArr = await this.fetchContentMetricsForApp(contentIds, metricIds, maxRetries, baseDelay, noBreakdown);
return [app, { contentIds, metrics: metricsArr } as any];
} catch (error: any) {
console.error(`Error fetching metrics for ${app} after ${maxRetries} retries: ${error.message}`);
return [app, { contentIds, error: error.message } as any];
}
});
const results = await Promise.all(promises);
return Object.fromEntries(results);
} catch (error: any) {
console.error('Error fetching content metrics:', error);
throw new SamsungApiError(`Failed to fetch content metrics: ${error.message}`);
}
}
}
// Input validation schemas
const dateSchema = z.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in YYYY-MM-DD format")
.refine((date) => !isNaN(Date.parse(date)), "Invalid date");
const metricIdsSchema = z.array(z.string()).optional()
.describe("Optional array of metric IDs to fetch. Defaults to standard metrics if not provided.");
const appNameSchema = z.string().optional()
.describe(`Optional app name to filter results. Available apps: ${getAvailableAppNames().join(', ')}. If not provided, returns data for all apps.`);
const maxRetriesSchema = z.number().int().min(1).max(10).optional().default(3)
.describe("Maximum number of retry attempts for failed requests (1-10, default: 3)");
const baseDelaySchema = z.number().int().min(500).max(10000).optional().default(2000)
.describe("Base delay in milliseconds between retry attempts (500-10000ms, default: 2000ms)");
const noBreakdownSchema = z.boolean().optional().default(true)
.describe("Whether to exclude device breakdown data. Set to false to include detailed device metrics (default: true)");
// Tool: Get Samsung Content Metrics
server.tool("get_samsung_content_metrics",
"Fetch content metrics from Samsung API for a specific date range. Optionally filter by app name. Includes retry mechanism for improved reliability.",
{
startDate: dateSchema.describe("Start date for the report (YYYY-MM-DD)"),
endDate: dateSchema.describe("End date for the report (YYYY-MM-DD)"),
appName: appNameSchema,
metricIds: metricIdsSchema,
maxRetries: maxRetriesSchema,
baseDelay: baseDelaySchema,
noBreakdown: noBreakdownSchema
},
async ({ startDate, endDate, appName, metricIds, maxRetries = 3, baseDelay = 2000, noBreakdown = true }) => {
try {
const logMessage = appName
? `Fetching Samsung content metrics for ${appName}, date range: ${startDate} to ${endDate} (retries: ${maxRetries}, delay: ${baseDelay}ms)`
: `Fetching Samsung content metrics for all apps, date range: ${startDate} to ${endDate} (retries: ${maxRetries}, delay: ${baseDelay}ms)`;
console.error(logMessage);
// Filter apps based on appName parameter
const appsToFetch = filterAppsByName(appName);
const samsungService = new SamsungApiService(startDate, endDate);
const allMetrics = await samsungService.fetchContentMetrics(appsToFetch, metricIds, maxRetries, baseDelay, noBreakdown);
// Format response with better structure
const response = {
dateRange: { startDate, endDate },
requestedApp: appName || 'all',
availableApps: getAvailableAppNames(),
retryConfig: { maxRetries, baseDelay },
noBreakdown: noBreakdown,
totalApps: Object.keys(allMetrics).length,
successfulApps: Object.values(allMetrics).filter(result => !result.error).length,
failedApps: Object.values(allMetrics).filter(result => result.error).length,
data: allMetrics
};
return {
content: [
{
type: "text",
text: JSON.stringify(response, null, 2)
}
]
};
} catch (error: any) {
const errorMessage = `Error fetching Samsung content metrics: ${error.message}`;
console.error(errorMessage);
return {
content: [
{
type: "text",
text: JSON.stringify({
error: errorMessage,
dateRange: { startDate, endDate },
requestedApp: appName || 'all',
availableApps: getAvailableAppNames(),
retryConfig: { maxRetries, baseDelay },
noBreakdown: noBreakdown,
timestamp: new Date().toISOString()
}, null, 2)
}
],
isError: true
};
}
}
);
// Start server
async function runServer(): Promise<void> {
try {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Samsung Reporting MCP Server running on stdio");
} catch (error) {
console.error("Failed to start server:", error);
throw error;
}
}
// Graceful shutdown handling
process.on('SIGINT', () => {
console.error('Received SIGINT, shutting down gracefully...');
process.exit(0);
});
process.on('SIGTERM', () => {
console.error('Received SIGTERM, shutting down gracefully...');
process.exit(0);
});
runServer().catch((error) => {
console.error("Fatal error running server:", error);
process.exit(1);
});
```
--------------------------------------------------------------------------------
/src/n8n-nodes-sensor-tower/nodes/SensorTower/SensorTower.node.ts:
--------------------------------------------------------------------------------
```typescript
import type {
IExecuteFunctions,
INodeExecutionData,
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';
declare const fetch: any;
type SensorTowerCredentials = {
baseUrl: string;
authToken: string;
};
export class SensorTower implements INodeType {
description: INodeTypeDescription = {
displayName: 'Sensor Tower',
name: 'sensorTower',
icon: 'file:logo.svg',
group: ['transform'],
version: 1,
subtitle: '={{$parameter["operation"]}}',
description: 'Sensor Tower Reporting via MCP-equivalent REST',
defaults: { name: 'Sensor Tower' },
inputs: ['main'],
outputs: ['main'],
credentials: [
{ name: 'sensorTowerApi', required: true },
],
properties: [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
options: [
{ name: 'Get App Metadata', value: 'get_app_metadata', action: 'Get app metadata' },
{ name: 'Get Top In-App Purchases', value: 'get_top_in_app_purchases', action: 'Get top in-app purchases' },
{ name: 'Get Compact Sales Report Estimates', value: 'get_compact_sales_report_estimates', action: 'Get compact sales report estimates' },
{ name: 'Get Active Users', value: 'get_active_users', action: 'Get active users' },
{ name: 'Get Category History', value: 'get_category_history', action: 'Get category history' },
{ name: 'Get Category Ranking Summary', value: 'get_category_ranking_summary', action: 'Get category ranking summary' },
{ name: 'Get Network Analysis', value: 'get_network_analysis', action: 'Get network analysis' },
{ name: 'Get Network Analysis Rank', value: 'get_network_analysis_rank', action: 'Get network analysis rank' },
{ name: 'Get Retention', value: 'get_retention', action: 'Get retention' },
{ name: 'Get Downloads By Sources', value: 'get_downloads_by_sources', action: 'Get downloads by sources' },
],
default: 'get_app_metadata',
},
// Shared fields
{ displayName: 'OS', name: 'os', type: 'options', options: [ { name: 'iOS', value: 'ios' }, { name: 'Android', value: 'android' } ], default: 'ios', displayOptions: { show: { operation: ['get_app_metadata','get_compact_sales_report_estimates','get_active_users','get_category_history','get_category_ranking_summary','get_network_analysis','get_network_analysis_rank','get_retention','get_downloads_by_sources'] } } },
// get_app_metadata
{ displayName: 'App IDs (comma-separated)', name: 'appIds', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_app_metadata'] } } },
{ displayName: 'Country', name: 'country', type: 'string', default: 'US', displayOptions: { show: { operation: ['get_app_metadata'] } } },
// get_top_in_app_purchases (iOS only)
{ displayName: 'App IDs (comma-separated)', name: 'iapAppIds', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_top_in_app_purchases'] } } },
{ displayName: 'Country', name: 'iapCountry', type: 'string', default: 'US', displayOptions: { show: { operation: ['get_top_in_app_purchases'] } } },
// get_compact_sales_report_estimates
{ displayName: 'Start Date (YYYY-MM-DD)', name: 'csrStartDate', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_compact_sales_report_estimates'] } } },
{ displayName: 'End Date (YYYY-MM-DD)', name: 'csrEndDate', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_compact_sales_report_estimates'] } } },
{ displayName: 'App IDs (comma-separated)', name: 'csrAppIds', type: 'string', default: '', displayOptions: { show: { operation: ['get_compact_sales_report_estimates'] } } },
{ displayName: 'Publisher IDs (multiple allowed)', name: 'csrPublisherIds', type: 'string', default: '', description: 'Comma-separated', displayOptions: { show: { operation: ['get_compact_sales_report_estimates'] } } },
{ displayName: 'Unified App IDs', name: 'csrUnifiedAppIds', type: 'string', default: '', displayOptions: { show: { operation: ['get_compact_sales_report_estimates'] } } },
{ displayName: 'Unified Publisher IDs', name: 'csrUnifiedPublisherIds', type: 'string', default: '', displayOptions: { show: { operation: ['get_compact_sales_report_estimates'] } } },
{ displayName: 'Categories', name: 'csrCategories', type: 'string', default: '', displayOptions: { show: { operation: ['get_compact_sales_report_estimates'] } } },
{ displayName: 'Date Granularity', name: 'csrDateGranularity', type: 'string', default: '', displayOptions: { show: { operation: ['get_compact_sales_report_estimates'] } } },
{ displayName: 'Data Model', name: 'csrDataModel', type: 'string', default: '', displayOptions: { show: { operation: ['get_compact_sales_report_estimates'] } } },
// get_active_users
{ displayName: 'App IDs (comma-separated)', name: 'auAppIds', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_active_users'] } } },
{ displayName: 'Time Period', name: 'auTimePeriod', type: 'options', options: [ { name: 'day', value: 'day' }, { name: 'week', value: 'week' }, { name: 'month', value: 'month' } ], default: 'day', displayOptions: { show: { operation: ['get_active_users'] } } },
{ displayName: 'Start Date (YYYY-MM-DD)', name: 'auStartDate', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_active_users'] } } },
{ displayName: 'End Date (YYYY-MM-DD)', name: 'auEndDate', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_active_users'] } } },
{ displayName: 'Countries (comma-separated)', name: 'auCountries', type: 'string', default: '', displayOptions: { show: { operation: ['get_active_users'] } } },
{ displayName: 'Data Model', name: 'auDataModel', type: 'string', default: '', displayOptions: { show: { operation: ['get_active_users'] } } },
// get_category_history
{ displayName: 'App IDs (comma-separated)', name: 'chAppIds', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_category_history'] } } },
{ displayName: 'Category', name: 'chCategory', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_category_history'] } } },
{ displayName: 'Chart Type IDs (comma-separated)', name: 'chChartTypeIds', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_category_history'] } } },
{ displayName: 'Countries (comma-separated)', name: 'chCountries', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_category_history'] } } },
{ displayName: 'Start Date (YYYY-MM-DD)', name: 'chStartDate', type: 'string', default: '', displayOptions: { show: { operation: ['get_category_history'] } } },
{ displayName: 'End Date (YYYY-MM-DD)', name: 'chEndDate', type: 'string', default: '', displayOptions: { show: { operation: ['get_category_history'] } } },
{ displayName: 'Is Hourly', name: 'chIsHourly', type: 'boolean', default: false, displayOptions: { show: { operation: ['get_category_history'] } } },
// get_category_ranking_summary
{ displayName: 'App ID', name: 'crsAppId', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_category_ranking_summary'] } } },
{ displayName: 'Country', name: 'crsCountry', type: 'string', default: 'US', required: true, displayOptions: { show: { operation: ['get_category_ranking_summary'] } } },
// get_network_analysis
{ displayName: 'App IDs (comma-separated)', name: 'naAppIds', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_network_analysis'] } } },
{ displayName: 'Start Date (YYYY-MM-DD)', name: 'naStartDate', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_network_analysis'] } } },
{ displayName: 'End Date (YYYY-MM-DD)', name: 'naEndDate', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_network_analysis'] } } },
{ displayName: 'Period', name: 'naPeriod', type: 'options', options: [ { name: 'day', value: 'day' } ], default: 'day', required: true, displayOptions: { show: { operation: ['get_network_analysis'] } } },
{ displayName: 'Networks (comma-separated)', name: 'naNetworks', type: 'string', default: '', displayOptions: { show: { operation: ['get_network_analysis'] } } },
{ displayName: 'Countries (comma-separated)', name: 'naCountries', type: 'string', default: '', displayOptions: { show: { operation: ['get_network_analysis'] } } },
// get_network_analysis_rank
{ displayName: 'App IDs (comma-separated)', name: 'narAppIds', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_network_analysis_rank'] } } },
{ displayName: 'Start Date (YYYY-MM-DD)', name: 'narStartDate', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_network_analysis_rank'] } } },
{ displayName: 'End Date (YYYY-MM-DD)', name: 'narEndDate', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_network_analysis_rank'] } } },
{ displayName: 'Period', name: 'narPeriod', type: 'options', options: [ { name: 'day', value: 'day' } ], default: 'day', required: true, displayOptions: { show: { operation: ['get_network_analysis_rank'] } } },
{ displayName: 'Networks (comma-separated)', name: 'narNetworks', type: 'string', default: '', displayOptions: { show: { operation: ['get_network_analysis_rank'] } } },
{ displayName: 'Countries (comma-separated)', name: 'narCountries', type: 'string', default: '', displayOptions: { show: { operation: ['get_network_analysis_rank'] } } },
// get_retention
{ displayName: 'App IDs (comma-separated)', name: 'retAppIds', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_retention'] } } },
{ displayName: 'Date Granularity', name: 'retDateGranularity', type: 'options', options: [ { name: 'all_time', value: 'all_time' }, { name: 'quarterly', value: 'quarterly' } ], default: 'all_time', required: true, displayOptions: { show: { operation: ['get_retention'] } } },
{ displayName: 'Start Date (YYYY-MM-DD)', name: 'retStartDate', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_retention'] } } },
{ displayName: 'End Date (YYYY-MM-DD)', name: 'retEndDate', type: 'string', default: '', displayOptions: { show: { operation: ['get_retention'] } } },
{ displayName: 'Country', name: 'retCountry', type: 'string', default: '', displayOptions: { show: { operation: ['get_retention'] } } },
// get_downloads_by_sources
{ displayName: 'App IDs (comma-separated; unified IDs)', name: 'dbsAppIds', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_downloads_by_sources'] } } },
{ displayName: 'Countries (comma-separated)', name: 'dbsCountries', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_downloads_by_sources'] } } },
{ displayName: 'Start Date (YYYY-MM-DD)', name: 'dbsStartDate', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_downloads_by_sources'] } } },
{ displayName: 'End Date (YYYY-MM-DD)', name: 'dbsEndDate', type: 'string', default: '', required: true, displayOptions: { show: { operation: ['get_downloads_by_sources'] } } },
{ displayName: 'Date Granularity', name: 'dbsDateGranularity', type: 'options', options: [ { name: 'monthly', value: 'monthly' }, { name: 'daily', value: 'daily' } ], default: 'monthly', displayOptions: { show: { operation: ['get_downloads_by_sources'] } } },
],
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const returnItems: INodeExecutionData[] = [];
const credentials = (await this.getCredentials('sensorTowerApi')) as unknown as SensorTowerCredentials;
const baseUrl = credentials.baseUrl || 'https://api.sensortower.com';
const auth = credentials.authToken;
const buildQuery = (params: Record<string, unknown>): string => {
const qs = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') return;
if (Array.isArray(value)) {
value.forEach((v) => qs.append(key, String(v)));
} else if (typeof value === 'string' && key.endsWith('[]')) {
value.split(',').map((s) => s.trim()).filter(Boolean).forEach((v) => qs.append(key, v));
} else {
qs.append(key, String(value));
}
});
return qs.toString();
};
const request = async (endpoint: string, params: Record<string, unknown> = {}) => {
const query = buildQuery({ ...params, auth_token: auth as string });
const res = await fetch(`${baseUrl}${endpoint}?${query}`, {
method: 'GET',
headers: { 'Accept': 'application/json' },
});
return await res.json();
};
const operation = this.getNodeParameter('operation', 0) as string;
for (let i = 0; i < items.length; i++) {
if (operation === 'get_app_metadata') {
const os = this.getNodeParameter('os', i) as string;
const appIds = this.getNodeParameter('appIds', i) as string;
const country = this.getNodeParameter('country', i, 'US') as string;
const data = await request(`/v1/${os}/apps`, { app_ids: appIds, country });
returnItems.push({ json: data });
} else if (operation === 'get_top_in_app_purchases') {
const appIds = this.getNodeParameter('iapAppIds', i) as string;
const country = this.getNodeParameter('iapCountry', i, 'US') as string;
const data = await request('/v1/ios/apps/top_in_app_purchases', { app_ids: appIds, country });
returnItems.push({ json: data });
} else if (operation === 'get_compact_sales_report_estimates') {
const os = this.getNodeParameter('os', i) as string;
const startDate = this.getNodeParameter('csrStartDate', i) as string;
const endDate = this.getNodeParameter('csrEndDate', i) as string;
const appIds = this.getNodeParameter('csrAppIds', i, '') as string;
const publisherIds = this.getNodeParameter('csrPublisherIds', i, '') as string;
const unifiedAppIds = this.getNodeParameter('csrUnifiedAppIds', i, '') as string;
const unifiedPublisherIds = this.getNodeParameter('csrUnifiedPublisherIds', i, '') as string;
const categories = this.getNodeParameter('csrCategories', i, '') as string;
const dateGranularity = this.getNodeParameter('csrDateGranularity', i, '') as string;
const dataModel = this.getNodeParameter('csrDataModel', i, '') as string;
const params: Record<string, unknown> = { start_date: startDate, end_date: endDate };
if (appIds) params.app_ids = appIds;
if (publisherIds) params['publisher_ids[]'] = publisherIds.split(',').map((s) => s.trim()).filter(Boolean);
if (unifiedAppIds) params.unified_app_ids = unifiedAppIds;
if (unifiedPublisherIds) params.unified_publisher_ids = unifiedPublisherIds;
if (categories) params.categories = categories;
if (dateGranularity) params.date_granularity = dateGranularity;
if (dataModel) params.data_model = dataModel;
const data = await request(`/v1/${os}/compact_sales_report_estimates`, params);
returnItems.push({ json: data });
} else if (operation === 'get_active_users') {
const os = this.getNodeParameter('os', i) as string;
const appIds = this.getNodeParameter('auAppIds', i) as string;
const timePeriod = this.getNodeParameter('auTimePeriod', i) as string;
const startDate = this.getNodeParameter('auStartDate', i) as string;
const endDate = this.getNodeParameter('auEndDate', i) as string;
const countries = this.getNodeParameter('auCountries', i, '') as string;
const dataModel = this.getNodeParameter('auDataModel', i, '') as string;
const params: Record<string, unknown> = { app_ids: appIds, time_period: timePeriod, start_date: startDate, end_date: endDate };
if (countries) params.countries = countries;
if (dataModel) params.data_model = dataModel;
const data = await request(`/v1/${os}/usage/active_users`, params);
returnItems.push({ json: data });
} else if (operation === 'get_category_history') {
const os = this.getNodeParameter('os', i) as string;
const appIds = this.getNodeParameter('chAppIds', i) as string;
const category = this.getNodeParameter('chCategory', i) as string;
const chartTypeIds = this.getNodeParameter('chChartTypeIds', i) as string;
const countries = this.getNodeParameter('chCountries', i) as string;
const startDate = this.getNodeParameter('chStartDate', i, '') as string;
const endDate = this.getNodeParameter('chEndDate', i, '') as string;
const isHourly = this.getNodeParameter('chIsHourly', i, false) as boolean;
const params: Record<string, unknown> = { app_ids: appIds, category, chart_type_ids: chartTypeIds, countries };
if (startDate) params.start_date = startDate;
if (endDate) params.end_date = endDate;
if (isHourly !== undefined) params.is_hourly = String(isHourly);
const data = await request(`/v1/${os}/category/category_history`, params);
returnItems.push({ json: data });
} else if (operation === 'get_category_ranking_summary') {
const os = this.getNodeParameter('os', i) as string;
const appId = this.getNodeParameter('crsAppId', i) as string;
const country = this.getNodeParameter('crsCountry', i) as string;
const data = await request(`/v1/${os}/category/category_ranking_summary`, { app_id: appId, country });
returnItems.push({ json: data });
} else if (operation === 'get_network_analysis') {
const os = this.getNodeParameter('os', i) as string;
const appIds = this.getNodeParameter('naAppIds', i) as string;
const startDate = this.getNodeParameter('naStartDate', i) as string;
const endDate = this.getNodeParameter('naEndDate', i) as string;
const period = this.getNodeParameter('naPeriod', i) as string;
const networks = this.getNodeParameter('naNetworks', i, '') as string;
const countries = this.getNodeParameter('naCountries', i, '') as string;
const params: Record<string, unknown> = { app_ids: appIds, start_date: startDate, end_date: endDate, period };
if (networks) params.networks = networks;
if (countries) params.countries = countries;
const data = await request(`/v1/${os}/ad_intel/network_analysis`, params);
returnItems.push({ json: data });
} else if (operation === 'get_network_analysis_rank') {
const os = this.getNodeParameter('os', i) as string;
const appIds = this.getNodeParameter('narAppIds', i) as string;
const startDate = this.getNodeParameter('narStartDate', i) as string;
const endDate = this.getNodeParameter('narEndDate', i) as string;
const period = this.getNodeParameter('narPeriod', i) as string;
const networks = this.getNodeParameter('narNetworks', i, '') as string;
const countries = this.getNodeParameter('narCountries', i, '') as string;
const params: Record<string, unknown> = { app_ids: appIds, start_date: startDate, end_date: endDate, period };
if (networks) params.networks = networks;
if (countries) params.countries = countries;
const data = await request(`/v1/${os}/ad_intel/network_analysis/rank`, params);
returnItems.push({ json: data });
} else if (operation === 'get_retention') {
const os = this.getNodeParameter('os', i) as string;
const appIds = this.getNodeParameter('retAppIds', i) as string;
const dateGranularity = this.getNodeParameter('retDateGranularity', i) as string;
const startDate = this.getNodeParameter('retStartDate', i) as string;
const endDate = this.getNodeParameter('retEndDate', i, '') as string;
const country = this.getNodeParameter('retCountry', i, '') as string;
const params: Record<string, unknown> = { app_ids: appIds, date_granularity: dateGranularity, start_date: startDate };
if (endDate) params.end_date = endDate;
if (country) params.country = country;
const data = await request(`/v1/${os}/usage/retention`, params);
returnItems.push({ json: data });
} else if (operation === 'get_downloads_by_sources') {
const os = this.getNodeParameter('os', i) as string;
const appIds = this.getNodeParameter('dbsAppIds', i) as string;
const countries = this.getNodeParameter('dbsCountries', i) as string;
const startDate = this.getNodeParameter('dbsStartDate', i) as string;
const endDate = this.getNodeParameter('dbsEndDate', i) as string;
const dateGranularity = this.getNodeParameter('dbsDateGranularity', i, 'monthly') as string;
const params: Record<string, unknown> = { app_ids: appIds, countries, start_date: startDate, end_date: endDate, date_granularity: dateGranularity };
const data = await request(`/v1/${os}/downloads_by_sources`, params);
returnItems.push({ json: data });
}
}
return [returnItems];
}
}
```