This is page 2 of 4. Use http://codebase.md/feed-mob/fm-mcp-servers?page={x} to view the full context.
# Directory Structure
```
├── .claude
│ └── settings.local.json
├── .cursor
│ └── rules
│ └── typescript-indentation.mdc
├── .github
│ └── workflows
│ ├── publish-civitai-records.yml
│ ├── publish-github-issues.yml
│ ├── publish-imagekit.yml
│ ├── publish-n8n-nodes-feedmob-direct-spend-visualizer.yml
│ ├── publish-reporting-packages.yml
│ └── publish-work-journals.yml
├── .gitignore
├── AGENTS.md
├── package-lock.json
├── README.md
├── src
│ ├── applovin-reporting
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── appsamurai-reporting
│ │ ├── .env.example
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── civitai-records
│ │ ├── .gitignore
│ │ ├── build.sh
│ │ ├── docker-compose.yml
│ │ ├── docs
│ │ │ └── civitai-owner-createrole.md
│ │ ├── env.sample
│ │ ├── infra
│ │ │ └── db-init
│ │ │ ├── 01-roles.sql
│ │ │ ├── 02_functions.sql
│ │ │ ├── 03_init.sql
│ │ │ ├── 04_create_prompts.sql
│ │ │ ├── 05_create_assets.sql
│ │ │ ├── 06_create_civitai_posts.sql
│ │ │ ├── 07_add_constraints_and_input_assets.sql
│ │ │ ├── 08_migration_add_on_behalf_of.sql
│ │ │ ├── 09_migration_update_functions_for_on_behalf_of.sql
│ │ │ ├── 10_update_on_behalf_of_for_existing_records.sql
│ │ │ ├── 11_create_asset_stats.sql
│ │ │ ├── 12_fix_trigger_for_tables_without_on_behalf_of.sql
│ │ │ └── 13_add_columns_to_asset_stats.sql
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── prisma
│ │ │ └── schema.prisma
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── lib
│ │ │ │ ├── __tests__
│ │ │ │ │ ├── detectRemoteAssetType.test.ts
│ │ │ │ │ └── sha256.test.ts
│ │ │ │ ├── civitaiApi.ts
│ │ │ │ ├── detectRemoteAssetType.ts
│ │ │ │ ├── handleDatabaseError.ts
│ │ │ │ ├── prisma.ts
│ │ │ │ └── sha256.ts
│ │ │ ├── prompts
│ │ │ │ ├── civitai-media-engagement.md
│ │ │ │ ├── civitaiMediaEngagement.ts
│ │ │ │ ├── record-civitai-workflow.md
│ │ │ │ └── recordCivitaiWorkflow.ts
│ │ │ ├── server.ts
│ │ │ └── tools
│ │ │ ├── calculateSha256.ts
│ │ │ ├── createAsset.ts
│ │ │ ├── createCivitaiPost.ts
│ │ │ ├── createPrompt.ts
│ │ │ ├── fetchCivitaiPostAssets.ts
│ │ │ ├── findAsset.ts
│ │ │ ├── getMediaEngagementGuide.ts
│ │ │ ├── getWorkflowGuide.ts
│ │ │ ├── listCivitaiPosts.ts
│ │ │ ├── syncPostAssetStats.ts
│ │ │ └── updateAsset.ts
│ │ └── tsconfig.json
│ ├── feedmob-reporting
│ │ ├── .env.example
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── api.ts
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── femini-reporting
│ │ ├── Dockerfile
│ │ ├── femini_mcp_guide.md
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── github-issues
│ │ ├── common
│ │ │ ├── errors.ts
│ │ │ ├── types.ts
│ │ │ ├── utils.ts
│ │ │ └── version.ts
│ │ ├── index.ts
│ │ ├── operations
│ │ │ ├── issues.ts
│ │ │ └── search.ts
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ └── tsconfig.json
│ ├── imagekit
│ │ ├── env.sample
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── server.ts
│ │ │ ├── services
│ │ │ │ ├── imageKitUpload.ts
│ │ │ │ └── imageUploader.ts
│ │ │ └── tools
│ │ │ ├── cropAndWatermark.ts
│ │ │ └── uploadFile.ts
│ │ └── tsconfig.json
│ ├── impact-radius-reporting
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── fm_impact_radius_mapping.ts
│ │ │ └── index.ts
│ │ ├── tsconfig.json
│ │ └── yarn.lock
│ ├── inmobi-reporting
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── ironsource-aura-reporting
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── ironsource-reporting
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── jampp-reporting
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── kayzen-reporting
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── index.ts
│ │ │ └── kayzen-client.ts
│ │ └── tsconfig.json
│ ├── liftoff-reporting
│ │ ├── .env.example
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── mintegral-reporting
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── n8n-nodes-feedmob-direct-spend-visualizer
│ │ ├── AGENTS.md
│ │ ├── credentials
│ │ │ └── FeedmobDirectSpendVisualizerApi.credentials.ts
│ │ ├── index.ts
│ │ ├── nodes
│ │ │ └── FeedmobDirectSpendVisualizer
│ │ │ ├── FeedmobDirectSpendVisualizer.node.ts
│ │ │ └── logo.svg
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── scripts
│ │ │ ├── build-assets.js
│ │ │ └── setup-plugin.js
│ │ ├── tsconfig.json
│ │ └── types.d.ts
│ ├── n8n-nodes-sensor-tower
│ │ ├── credentials
│ │ │ └── SensorTowerApi.credentials.ts
│ │ ├── index.ts
│ │ ├── MCP_to_n8n_Guide_zh.md
│ │ ├── nodes
│ │ │ └── SensorTower
│ │ │ ├── logo.svg
│ │ │ └── SensorTower.node.ts
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── tsconfig.json
│ │ └── types.d.ts
│ ├── rtb-house-reporting
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── samsung-reporting
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── sensor-tower-reporting
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── singular-reporting
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── smadex-reporting
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── tapjoy-reporting
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── user-activity-reporting
│ │ ├── .env.example
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── api.ts
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ └── work-journals
│ ├── package-lock.json
│ ├── package.json
│ ├── README.md
│ ├── src
│ │ └── index.ts
│ └── tsconfig.json
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/src/kayzen-reporting/src/kayzen-client.ts:
--------------------------------------------------------------------------------
```typescript
import axios from 'axios';
import type { AxiosRequestConfig } from 'axios';
import dotenv from 'dotenv';
dotenv.config();
export interface KayzenConfig {
userName: string;
password: string;
basicAuthToken: string;
baseUrl: string;
}
interface AuthResponse {
access_token: string;
}
export class KayzenClient {
private baseUrl: string;
private username?: string;
private password?: string;
private basicAuth?: string;
private authToken: string | null = null;
private tokenExpiry: Date | null = null;
constructor() {
this.baseUrl = process.env.KAYZEN_BASE_URL || 'https://api.kayzen.io/v1';
this.username = process.env.KAYZEN_USERNAME;
this.password = process.env.KAYZEN_PASSWORD;
this.basicAuth = process.env.KAYZEN_BASIC_AUTH;
}
private async getAuthToken(): Promise<string> {
if (this.authToken && this.tokenExpiry && new Date() < this.tokenExpiry) {
return this.authToken;
}
if (!this.username || !this.password || !this.basicAuth) {
throw new Error('Authentication credentials required for this operation');
}
const url = `${this.baseUrl}/authentication/token`;
const payload = {
grant_type: 'password',
username: this.username,
password: this.password
};
const headers = {
accept: 'application/json',
'content-type': 'application/json',
authorization: `Basic ${this.basicAuth}`
};
try {
const response = await axios.post<AuthResponse>(url, payload, { headers });
this.authToken = response.data.access_token;
this.tokenExpiry = new Date(Date.now() + 25 * 60 * 1000);
return this.authToken;
} catch (error) {
console.error('Error getting auth token:', error);
throw error;
}
}
private async makeRequest<T>(method: string, endpoint: string, params: Record<string, unknown> = {}) {
const token = await this.getAuthToken();
const config: AxiosRequestConfig = {
method,
url: `${this.baseUrl}${endpoint}`,
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
params
};
try {
const response = await axios<T>(config);
return response.data;
} catch (error) {
console.error(`Error making request to ${endpoint}:`, error);
throw error;
}
}
async listReports(params: {
advertiser_id?: number;
q?: string;
page?: number;
per_page?: number;
sort_field?: string;
sort_direction?: 'asc' | 'desc';
} = {}) {
if (!this.username || !this.password || !this.basicAuth) {
throw new Error('Authentication credentials required for this operation');
}
const queryParams: Record<string, unknown> = {};
if (params.advertiser_id !== undefined) queryParams.advertiser_id = params.advertiser_id;
if (params.q !== undefined) queryParams.q = params.q;
if (params.page !== undefined) queryParams.page = params.page;
if (params.per_page !== undefined) queryParams.per_page = params.per_page;
if (params.sort_field !== undefined) queryParams.sort_field = params.sort_field;
if (params.sort_direction !== undefined) queryParams.sort_direction = params.sort_direction;
return this.makeRequest('GET', '/reports', queryParams);
}
async getReportResults(reportId: string, startDate?: string, endDate?: string) {
if (!this.username || !this.password || !this.basicAuth) {
throw new Error('Authentication credentials required for this operation');
}
const params: Record<string, string> = {};
if (startDate) params.start_date = startDate;
if (endDate) params.end_date = endDate;
return this.makeRequest('GET', `/reports/${reportId}/report_results`, params);
}
}
```
--------------------------------------------------------------------------------
/src/github-issues/common/utils.ts:
--------------------------------------------------------------------------------
```typescript
import { getUserAgent } from "universal-user-agent";
import { createGitHubError } from "./errors.js";
import { VERSION } from "./version.js";
type RequestOptions = {
method?: string;
body?: unknown;
headers?: Record<string, string>;
}
async function parseResponseBody(response: Response): Promise<unknown> {
const contentType = response.headers.get("content-type");
if (contentType?.includes("application/json")) {
return response.json();
}
return response.text();
}
export function buildUrl(baseUrl: string, params: Record<string, string | number | undefined>): string {
const url = new URL(baseUrl);
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) {
url.searchParams.append(key, value.toString());
}
});
return url.toString();
}
const USER_AGENT = `modelcontextprotocol/servers/github/v${VERSION} ${getUserAgent()}`;
export async function githubRequest(
url: string,
options: RequestOptions = {}
): Promise<unknown> {
const headers: Record<string, string> = {
"Accept": "application/vnd.github.v3+json",
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
...options.headers,
};
if (process.env.GITHUB_PERSONAL_ACCESS_TOKEN) {
headers["Authorization"] = `Bearer ${process.env.GITHUB_PERSONAL_ACCESS_TOKEN}`;
}
const response = await fetch(url, {
method: options.method || "GET",
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
});
const responseBody = await parseResponseBody(response);
if (!response.ok) {
throw createGitHubError(response.status, responseBody);
}
return responseBody;
}
export function validateBranchName(branch: string): string {
const sanitized = branch.trim();
if (!sanitized) {
throw new Error("Branch name cannot be empty");
}
if (sanitized.includes("..")) {
throw new Error("Branch name cannot contain '..'");
}
if (/[\s~^:?*[\\\]]/.test(sanitized)) {
throw new Error("Branch name contains invalid characters");
}
if (sanitized.startsWith("/") || sanitized.endsWith("/")) {
throw new Error("Branch name cannot start or end with '/'");
}
if (sanitized.endsWith(".lock")) {
throw new Error("Branch name cannot end with '.lock'");
}
return sanitized;
}
export function validateRepositoryName(name: string): string {
const sanitized = name.trim().toLowerCase();
if (!sanitized) {
throw new Error("Repository name cannot be empty");
}
if (!/^[a-z0-9_.-]+$/.test(sanitized)) {
throw new Error(
"Repository name can only contain lowercase letters, numbers, hyphens, periods, and underscores"
);
}
if (sanitized.startsWith(".") || sanitized.endsWith(".")) {
throw new Error("Repository name cannot start or end with a period");
}
return sanitized;
}
export function validateOwnerName(owner: string): string {
const sanitized = owner.trim().toLowerCase();
if (!sanitized) {
throw new Error("Owner name cannot be empty");
}
if (!/^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$/.test(sanitized)) {
throw new Error(
"Owner name must start with a letter or number and can contain up to 39 characters"
);
}
return sanitized;
}
export async function checkBranchExists(
owner: string,
repo: string,
branch: string
): Promise<boolean> {
try {
await githubRequest(
`https://api.github.com/repos/${owner}/${repo}/branches/${branch}`
);
return true;
} catch (error) {
if (error && typeof error === "object" && "status" in error && error.status === 404) {
return false;
}
throw error;
}
}
export async function checkUserExists(username: string): Promise<boolean> {
try {
await githubRequest(`https://api.github.com/users/${username}`);
return true;
} catch (error) {
if (error && typeof error === "object" && "status" in error && error.status === 404) {
return false;
}
throw error;
}
}
```
--------------------------------------------------------------------------------
/src/impact-radius-reporting/src/fm_impact_radius_mapping.ts:
--------------------------------------------------------------------------------
```typescript
import axios from "axios";
import jwt from 'jsonwebtoken';
import dotenv from "dotenv";
dotenv.config(); // Load environment variables from .env file
// TypeScript interfaces for API response structure
export interface ImpactCampaignMapping {
id: number;
impact_brand: string;
impact_ad: string;
impact_event_type: string;
click_url_id: number;
vendor_name: string | null;
campaign_name: string | null;
client_name: string | null;
}
export interface PaginationInfo {
current_page: number;
per_page: number;
total_pages: number;
total_count: number;
}
export interface ImpactCampaignMappingResponse {
status: number;
data: ImpactCampaignMapping[];
pagination: PaginationInfo;
}
export interface ImpactCampaignMappingErrorResponse {
status: number;
error: string;
message: string;
}
export interface FetchImpactRadiusCampaignMappingParams {
click_url_id?: number;
impact_brand?: string;
impact_ad?: string;
impact_event_type?: string;
page?: number;
per_page?: number;
}
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 fetchImpactRaidusCampaignMapping(
params: FetchImpactRadiusCampaignMappingParams
): Promise<ImpactCampaignMappingResponse> {
const urlObj = new URL(`${FEEDMOB_API_BASE}/ai/api/impact_campaign_mappings`);
// Add filtering parameters if provided
if (params.click_url_id !== undefined) {
urlObj.searchParams.set('click_url_id', params.click_url_id.toString());
}
if (params.impact_brand) {
urlObj.searchParams.set('impact_brand', params.impact_brand);
}
if (params.impact_ad) {
urlObj.searchParams.set('impact_ad', params.impact_ad);
}
if (params.impact_event_type) {
urlObj.searchParams.set('impact_event_type', params.impact_event_type);
}
// Add pagination parameters if provided
if (params.page !== undefined) {
urlObj.searchParams.set('page', params.page.toString());
}
if (params.per_page !== undefined) {
urlObj.searchParams.set('per_page', params.per_page.toString());
}
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 as ImpactCampaignMappingResponse;
} catch (error: unknown) {
console.error("Error fetching impact campaign mappings from FeedMob API:", error);
if (error && typeof error === 'object' && 'response' in error) {
const err = error as Record<string, any>;
const status = err.response?.status;
const errorData = err.response?.data as ImpactCampaignMappingErrorResponse;
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 if (status === 500 && errorData) {
throw new Error(`FeedMob API request failed: ${errorData.error} - ${errorData.message}`);
} else {
throw new Error(`FeedMob API request failed: ${status || 'Unknown error'}`);
}
}
throw new Error('Failed to fetch impact campaign mappings from FeedMob API');
}
}
```
--------------------------------------------------------------------------------
/src/civitai-records/src/tools/syncPostAssetStats.ts:
--------------------------------------------------------------------------------
```typescript
import type { ContentResult } from "fastmcp";
import { z } from "zod";
import { PrismaClient } from "@prisma/client";
import { fetchCivitaiPostImageStats } from "../lib/civitaiApi.js";
import { handleDatabaseError } from "../lib/handleDatabaseError.js";
const prisma = new PrismaClient();
export const syncPostAssetStatsParameters = z.object({
civitai_post_id: z
.string()
.min(1)
.describe("The Civitai post ID to sync all image stats for."),
});
export type SyncPostAssetStatsParameters = z.infer<
typeof syncPostAssetStatsParameters
>;
export const syncPostAssetStatsTool = {
name: "sync_post_asset_stats",
description:
"Batch sync engagement statistics for all images in a Civitai post. Fetches stats for all images from the post's public API and updates or creates corresponding records in the asset_stats table. Only processes images that exist in the assets table (matched by civitai_id).",
parameters: syncPostAssetStatsParameters,
execute: async ({
civitai_post_id,
}: SyncPostAssetStatsParameters): Promise<ContentResult> => {
const allStats = await fetchCivitaiPostImageStats(civitai_post_id);
if (allStats.length === 0) {
return {
content: [
{
type: "text",
text: JSON.stringify(
{
success: true,
civitai_post_id,
message: "No images found for this post",
synced_count: 0,
skipped_count: 0,
failed: [],
},
null,
2
),
},
],
} satisfies ContentResult;
}
const civitaiIds = allStats.map((s) => s.civitai_id);
const assets = await prisma.assets.findMany({
where: { civitai_id: { in: civitaiIds } },
select: { id: true, civitai_id: true, uri: true, post_id: true, on_behalf_of: true },
});
const assetMap = new Map(assets.map((a) => [a.civitai_id, a]));
let syncedCount = 0;
let skippedCount = 0;
const failed: Array<{ civitai_id: string; error: string }> = [];
for (const stats of allStats) {
const asset = assetMap.get(stats.civitai_id);
if (!asset) {
skippedCount++;
continue;
}
try {
await prisma.asset_stats.upsert({
where: { asset_id: asset.id },
update: {
cry_count: BigInt(stats.cry_count),
laugh_count: BigInt(stats.laugh_count),
like_count: BigInt(stats.like_count),
dislike_count: BigInt(stats.dislike_count),
heart_count: BigInt(stats.heart_count),
comment_count: BigInt(stats.comment_count),
civitai_created_at: stats.civitai_created_at ? new Date(stats.civitai_created_at) : null,
civitai_account: stats.civitai_account,
post_id: asset.post_id,
on_behalf_of: asset.on_behalf_of,
updated_at: new Date(),
},
create: {
asset_id: asset.id,
cry_count: BigInt(stats.cry_count),
laugh_count: BigInt(stats.laugh_count),
like_count: BigInt(stats.like_count),
dislike_count: BigInt(stats.dislike_count),
heart_count: BigInt(stats.heart_count),
comment_count: BigInt(stats.comment_count),
civitai_created_at: stats.civitai_created_at ? new Date(stats.civitai_created_at) : null,
civitai_account: stats.civitai_account,
post_id: asset.post_id,
on_behalf_of: asset.on_behalf_of,
},
});
syncedCount++;
} catch (error) {
try {
handleDatabaseError(error, `Civitai ID: ${stats.civitai_id}`);
} catch (handledError) {
failed.push({
civitai_id: stats.civitai_id,
error: handledError instanceof Error ? handledError.message : String(handledError),
});
}
}
}
return {
content: [
{
type: "text",
text: JSON.stringify(
{
success: true,
civitai_post_id,
total_images_fetched: allStats.length,
synced_count: syncedCount,
skipped_count: skippedCount,
failed_count: failed.length,
...(failed.length > 0 && { failed }),
},
null,
2
),
},
],
} satisfies ContentResult;
},
};
```
--------------------------------------------------------------------------------
/src/n8n-nodes-feedmob-direct-spend-visualizer/credentials/FeedmobDirectSpendVisualizerApi.credentials.ts:
--------------------------------------------------------------------------------
```typescript
import type { INodeProperties } from 'n8n-workflow';
export class FeedmobDirectSpendVisualizerApi {
name = 'feedmobDirectSpendVisualizerApi';
displayName = 'FeedMob Direct Spend Visualizer';
properties: INodeProperties[] = [
{
displayName: 'Provider',
name: 'provider',
type: 'options',
options: [
{ name: 'AWS Bedrock', value: 'aws_bedrock' },
{ name: 'Zhipu AI (GLM)', value: 'glm' },
],
default: 'aws_bedrock',
required: true,
description: 'Choose the AI provider to use.',
},
// --- AWS Bedrock Fields ---
{
displayName: 'AWS Region',
name: 'awsRegion',
type: 'string',
default: 'us-east-1',
required: true,
displayOptions: {
show: {
provider: ['aws_bedrock'],
},
},
description: 'Region used for the Bedrock-based Claude Agent SDK run.',
},
{
displayName: 'AWS Access Key ID',
name: 'awsAccessKeyId',
type: 'string',
default: '',
required: true,
displayOptions: {
show: {
provider: ['aws_bedrock'],
},
},
description: 'Access key with permission to invoke Bedrock Claude models.',
},
{
displayName: 'AWS Secret Access Key',
name: 'awsSecretAccessKey',
type: 'string',
typeOptions: { password: true },
default: '',
required: true,
displayOptions: {
show: {
provider: ['aws_bedrock'],
},
},
description: 'Secret for the above access key.',
},
{
displayName: 'Anthropic Model (primary)',
name: 'anthropicModel',
type: 'string',
default: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0',
required: true,
displayOptions: {
show: {
provider: ['aws_bedrock'],
},
},
description: 'Model identifier passed through to CLAUDE_CODE on Bedrock.',
},
{
displayName: 'Anthropic Model (fast)',
name: 'anthropicSmallModel',
type: 'string',
default: 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
required: true,
displayOptions: {
show: {
provider: ['aws_bedrock'],
},
},
description: 'Fast/cheap model identifier for CLAUDE_CODE small tasks.',
},
// --- GLM / Zhipu AI Fields ---
{
displayName: 'Base URL',
name: 'anthropicBaseUrl',
type: 'string',
default: 'https://open.bigmodel.cn/api/anthropic',
required: true,
displayOptions: {
show: {
provider: ['glm'],
},
},
description: 'Base URL for the GLM / Zhipu AI API (compatible with Anthropic SDK).',
},
{
displayName: 'API Key',
name: 'anthropicAuthToken',
type: 'string',
typeOptions: { password: true },
default: '',
required: true,
displayOptions: {
show: {
provider: ['glm'],
},
},
description: 'API Key for GLM / Zhipu AI.',
},
{
displayName: 'Model',
name: 'glmModel',
type: 'string',
default: 'glm-4.6',
required: true,
displayOptions: {
show: {
provider: ['glm'],
},
},
description: 'Model identifier for GLM.',
},
{
displayName: 'Small/Fast Model',
name: 'glmSmallModel',
type: 'string',
default: 'glm-4.6',
required: true,
displayOptions: {
show: {
provider: ['glm'],
},
},
description: 'Fast/cheap model identifier for GLM (can be same as main model).',
},
// --- Common Fields ---
{
displayName: 'FeedMob Key',
name: 'feedmobKey',
type: 'string',
typeOptions: { password: true },
default: '',
required: true,
description: 'FEEDMOB_KEY used by the plugin’s FeedMob MCP server (generate from https://admin.feedmob.com/api_keys).',
},
{
displayName: 'FeedMob Secret',
name: 'feedmobSecret',
type: 'string',
typeOptions: { password: true },
default: '',
required: true,
description: 'FEEDMOB_SECRET used by the plugin’s FeedMob MCP server (generate from https://admin.feedmob.com/api_keys).',
},
{
displayName: 'FeedMob API Base',
name: 'feedmobApiBase',
type: 'string',
default: 'https://admin.feedmob.com',
required: true,
description: 'FEEDMOB_API_BASE base URL expected by the plugin.',
},
];
}
```
--------------------------------------------------------------------------------
/src/civitai-records/src/tools/fetchCivitaiPostAssets.ts:
--------------------------------------------------------------------------------
```typescript
import type { ContentResult } from "fastmcp";
import { z } from "zod";
const civitaiImageStatsSchema = z
.object({
cryCount: z.number().int().nonnegative().optional(),
laughCount: z.number().int().nonnegative().optional(),
likeCount: z.number().int().nonnegative().optional(),
dislikeCount: z.number().int().nonnegative().optional(),
heartCount: z.number().int().nonnegative().optional(),
commentCount: z.number().int().nonnegative().optional(),
})
.partial()
.optional();
const civitaiImageItemSchema = z.object({
id: z.number(),
url: z.string().url(),
width: z.number().int().positive().optional(),
height: z.number().int().positive().optional(),
nsfwLevel: z.string().nullable().optional(),
type: z.string().min(1),
nsfw: z.boolean().optional(),
browsingLevel: z.number().int().optional(),
createdAt: z.string().optional(),
postId: z.number().optional(),
stats: civitaiImageStatsSchema,
meta: z.record(z.any()).nullable().optional(),
username: z.string().nullable().optional(),
baseModel: z.string().nullable().optional(),
modelVersionIds: z.array(z.number()).optional(),
});
const civitaiImagesResponseSchema = z.object({
items: z.array(civitaiImageItemSchema),
metadata: z.record(z.any()).optional(),
});
export const fetchCivitaiPostAssetsParameters = z.object({
post_id: z
.string()
.min(1)
.describe(
"Numeric post ID from the Civitai post URL. Example: '23683656' extracted from https://civitai.com/posts/23683656."
),
limit: z
.number()
.int()
.min(1)
.max(100)
.default(50)
.describe("Maximum number of assets to fetch per page. Civitai caps this at 100."),
page: z
.number()
.int()
.min(1)
.default(1)
.describe("Page number for pagination. Starts at 1."),
});
export type FetchCivitaiPostAssetsParameters = z.infer<
typeof fetchCivitaiPostAssetsParameters
>;
function normalizeItem(item: z.infer<typeof civitaiImageItemSchema>) {
const stats = item.stats ?? {};
return {
civitai_image_id: item.id.toString(),
civitai_post_id: item.postId?.toString() ?? null,
asset_url: item.url,
type: item.type,
dimensions: {
width: item.width ?? null,
height: item.height ?? null,
},
nsfw: item.nsfw ?? false,
nsfw_level: item.nsfwLevel ?? null,
browsing_level: item.browsingLevel ?? null,
created_at: item.createdAt ?? null,
username: item.username ?? null,
base_model: item.baseModel ?? null,
model_version_ids: item.modelVersionIds?.map(String) ?? [],
engagement_stats: {
cry: stats.cryCount ?? 0,
laugh: stats.laughCount ?? 0,
like: stats.likeCount ?? 0,
dislike: stats.dislikeCount ?? 0,
heart: stats.heartCount ?? 0,
comment: stats.commentCount ?? 0,
},
metadata: item.meta ?? null,
};
}
export const fetchCivitaiPostAssetsTool = {
name: "fetch_civitai_post_assets",
description:
"Fetch media assets for a specific Civitai post directly from the public Civitai Images API, including per-asset engagement stats like likes, hearts, and comments. Use this to inspect performance or get the Civitai media assets (Civitai images) from the given post.",
parameters: fetchCivitaiPostAssetsParameters,
execute: async ({
post_id,
limit,
page,
}: FetchCivitaiPostAssetsParameters): Promise<ContentResult> => {
const trimmedPostId = post_id.trim();
if (!/^[0-9]+$/.test(trimmedPostId)) {
throw new Error("post_id must be a numeric string");
}
const url = new URL("https://civitai.com/api/v1/images");
url.searchParams.set("postId", trimmedPostId);
url.searchParams.set("limit", limit.toString());
url.searchParams.set("page", page.toString());
const response = await fetch(url, {
method: "GET",
headers: {
Accept: "application/json",
},
});
if (!response.ok) {
throw new Error(
`Failed to fetch assets from Civitai (status ${response.status} ${response.statusText})`
);
}
let json: unknown;
try {
json = await response.json();
} catch (error) {
throw new Error("Civitai response was not valid JSON");
}
const parsed = civitaiImagesResponseSchema.parse(json);
const assets = parsed.items.map(normalizeItem);
return {
content: [
{
type: "text",
text: JSON.stringify(
{
post_id: trimmedPostId,
page,
limit,
asset_count: assets.length,
assets,
metadata: parsed.metadata ?? null,
},
null,
2
),
},
],
} satisfies ContentResult;
},
};
```
--------------------------------------------------------------------------------
/src/applovin-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: "AppLovin Reporting MCP Server",
version: "0.0.1"
});
const APPLOVIN_API_BASE_URL = "https://r.applovin.com/report";
const APPLOVIN_API_KEY = process.env.APPLOVIN_API_KEY || '';
if (!APPLOVIN_API_KEY) {
console.error("Missing AppLovin API credentials. Please set APPLOVIN_API_KEY environment variable.");
process.exit(1);
}
/**
* Make request to AppLovin Reporting API
*/
async function fetchAppLovinReport(params: Record<string, string>) {
// Construct URL with query parameters
const queryParams = new URLSearchParams({
api_key: APPLOVIN_API_KEY,
...params
});
const reportUrl = `${APPLOVIN_API_BASE_URL}?${queryParams.toString()}`;
try {
console.error(`Requesting AppLovin report with params: ${JSON.stringify(params)}`);
const response = await fetch(reportUrl, {
method: 'GET',
headers: {
'Accept': 'application/json; */*'
}
});
if (!response.ok) {
const errorBody = await response.text();
console.error(`AppLovin API Error Response: ${response.status} ${response.statusText} - ${errorBody}`);
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
}
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return await response.json();
} else {
// If response is CSV or other format
return await response.text();
}
} catch (error: any) {
console.error(`Error fetching AppLovin report:`, error);
throw new Error(`Failed to get AppLovin report: ${error.message}`);
}
}
// Tool: Get Advertiser Report
server.tool("get_advertiser_report",
"Get campaign spending data from AppLovin Reporting API for advertisers.",
{
start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Start date must be in YYYY-MM-DD format").describe("Start date for the report (YYYY-MM-DD)"),
end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "End date must be in YYYY-MM-DD format").describe("End date for the report (YYYY-MM-DD)"),
columns: z.string().optional().describe("Comma-separated list of columns to include (e.g., 'day,campaign,impressions,clicks,conversions,cost')"),
format: z.enum(["json", "csv"]).default("json").describe("Format of the report data"),
filter_campaign: z.string().optional().describe("Filter results by campaign name"),
filter_country: z.string().optional().describe("Filter results by country (e.g., 'US,JP')"),
filter_platform: z.string().optional().describe("Filter results by platform (e.g., 'android,ios')"),
sort_column: z.string().optional().describe("Column to sort by (e.g., 'cost')"),
sort_order: z.enum(["ASC", "DESC"]).optional().describe("Sort order (ASC or DESC)")
}, async ({ start_date, end_date, columns, format, filter_campaign, filter_country, filter_platform, sort_column, sort_order }) => {
try {
// Validate date range logic
if (new Date(start_date) > new Date(end_date)) {
throw new Error("Start date cannot be after end date.");
}
// Default columns if not specified
const reportColumns = columns || "day,campaign,impressions,clicks,ctr,conversions,conversion_rate,cost";
// Build parameters
const params: Record<string, string> = {
start: start_date,
end: end_date,
columns: reportColumns,
format: format,
report_type: "advertiser" // Specify advertiser report type
};
// Add optional filters if provided
if (filter_campaign) params['filter_campaign'] = filter_campaign;
if (filter_country) params['filter_country'] = filter_country;
if (filter_platform) params['filter_platform'] = filter_platform;
// Add sorting if provided
if (sort_column && sort_order) {
params[`sort_${sort_column}`] = sort_order;
}
const data = await fetchAppLovinReport(params);
return {
content: [
{
type: "text",
text: typeof data === 'string' ? data : JSON.stringify(data, null, 2)
}
]
};
} catch (error: any) {
const errorMessage = `Error getting AppLovin advertiser 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("AppLovin Reporting MCP Server running on stdio");
}
runServer().catch((error) => {
console.error("Fatal error running server:", error);
process.exit(1);
});
```
--------------------------------------------------------------------------------
/src/github-issues/operations/issues.ts:
--------------------------------------------------------------------------------
```typescript
import { z } from "zod";
import { githubRequest, buildUrl } from "../common/utils.js";
import { subDays, format } from 'date-fns';
export const FeedmobSearchOptions = z.object({
scheam: z.string().describe("get from system resources issues/search_schema"),
start_date: z.string().default(format(subDays(new Date(), 30), 'yyyy-MM-dd')).describe("The creation start date of the issue"),
end_date: z.string().default(format(new Date(), 'yyyy-MM-dd')).describe("The creation end date of the issue"),
status: z.string().optional().describe("The status of the issue, e.g., 'open', 'closed'"),
repo: z.string().optional().describe("The repository name, e.g., 'feedmob', 'tracking_admin', If the user does not specify otherwise, this parameter can be omitted and all repos will be searched by default."),
users: z.array(z.string()).optional().describe("The users to filter issues by, can be assign_users, developers, code_reviewers, publishers, create_user, pm_qa_user"),
team: z.string().optional().describe("The team name, e.g., 'Star', 'Mighty'"),
title: z.string().optional().describe("The title of the issue, supports fuzzy matching"),
labels: z.array(z.string()).optional().describe("Labels to filter issues by"),
score_status: z.string().optional().describe("The issue score status, e.g., 'not scored', 'scored'"),
fields: z.array(z.string()).describe("Fields to return for each issue, available fields: 'issue_id', 'repo', 'title', 'created_at', 'closed_at', 'hubspot_ticket_link', 'create_user', 'assign_users', 'status', 'current_labels', 'process_time_seconds', 'developers', 'code_reviewers', 'publishers', 'qa_members', 'pm_qa_user', 'team'"),
});
export const GetIssueSchema = z.object({
comment_count: z.string().default('all').describe("Get all comments, or a specified number of comments, by default starting from the latest submission."),
repo_issues: z.array(z.object({
repo: z.string(),
issue_number: z.number()
}))
});
export const IssueCommentSchema = z.object({
owner: z.string(),
repo: z.string().describe("The repository name, e.g., 'feedmob', 'tracking_admin'"),
issue_number: z.number(),
body: z.string(),
});
export const CreateIssueOptionsSchema = z.object({
title: z.string(),
body: z.string().optional(),
assignees: z.array(z.string()).optional(),
milestone: z.number().optional(),
labels: z.array(z.string()).optional(),
});
export const CreateIssueSchema = z.object({
owner: z.string().optional(),
repo: z.string().describe("The repository name, e.g., 'feedmob', 'tracking_admin'"),
...CreateIssueOptionsSchema.shape,
});
export const ListIssuesOptionsSchema = z.object({
owner: z.string(),
repo: z.string(),
direction: z.enum(["asc", "desc"]).optional(),
labels: z.array(z.string()).optional(),
page: z.number().optional(),
per_page: z.number().optional(),
since: z.string().optional(),
sort: z.enum(["created", "updated", "comments"]).optional(),
state: z.enum(["open", "closed", "all"]).optional(),
});
export const UpdateIssueOptionsSchema = z.object({
owner: z.string(),
repo: z.string().describe("The repository name, e.g., 'feedmob', 'tracking_admin'"),
issue_number: z.number(),
title: z.string().optional(),
body: z.string().optional(),
assignees: z.array(z.string()).optional(),
milestone: z.number().optional(),
labels: z.array(z.string()).optional(),
state: z.enum(["open", "closed"]).optional(),
});
export async function getIssue(owner: string, repo: string, issue_number: number) {
return githubRequest(`https://api.github.com/repos/${owner}/${repo}/issues/${issue_number}`);
}
export async function addIssueComment(
owner: string,
repo: string,
issue_number: number,
body: string
) {
return githubRequest(`https://api.github.com/repos/${owner}/${repo}/issues/${issue_number}/comments`, {
method: "POST",
body: { body },
});
}
export async function createIssue(
owner: string,
repo: string,
options: z.infer<typeof CreateIssueOptionsSchema>
) {
return githubRequest(
`https://api.github.com/repos/${owner}/${repo}/issues`,
{
method: "POST",
body: options,
}
);
}
export async function listIssues(
owner: string,
repo: string,
options: Omit<z.infer<typeof ListIssuesOptionsSchema>, "owner" | "repo">
) {
const urlParams: Record<string, string | undefined> = {
direction: options.direction,
labels: options.labels?.join(","),
page: options.page?.toString(),
per_page: options.per_page?.toString(),
since: options.since,
sort: options.sort,
state: options.state
};
return githubRequest(
buildUrl(`https://api.github.com/repos/${owner}/${repo}/issues`, urlParams)
);
}
export async function updateIssue(
owner: string,
repo: string,
issue_number: number,
options: Omit<z.infer<typeof UpdateIssueOptionsSchema>, "owner" | "repo" | "issue_number">
) {
return githubRequest(
`https://api.github.com/repos/${owner}/${repo}/issues/${issue_number}`,
{
method: "PATCH",
body: options,
}
);
}
```
--------------------------------------------------------------------------------
/src/work-journals/src/index.ts:
--------------------------------------------------------------------------------
```typescript
#!/usr/bin/env node
import { FastMCP } from "fastmcp";
import { z } from "zod";
import { subDays, format } from 'date-fns';
// Create FastMCP server instance
const server = new FastMCP({
name: "work-journals",
version: "0.0.1",
instructions: `
This is an MCP server for querying and managing work journals.
`.trim(),
});
const API_URL = process.env.API_URL;
const API_TOKEN = process.env.API_TOKEN;
if (!API_URL || !API_TOKEN) {
console.error("Error: API_URL, API_TOKEN environment variables must be set.");
process.exit(1);
}
server.addTool({
name: "query_journals",
description: "Queries work journals, supporting filtering by date range, user ID, and team ID.",
parameters: z.object({
scheam: z.string().describe("get from system resources time-off-api-scheam://usage"),
start_date: z.string().default(format(subDays(new Date(), 7), 'yyyy-MM-dd')).describe("Start date (YYYY-MM-DD format), defaults to 7 days before today"),
end_date: z.string().default(format(new Date(), 'yyyy-MM-dd')).describe("End date (YYYY-MM-DD format), defaults to today"),
current_user_only: z.boolean().optional().describe("Whether to query only the current user's work journals (true/false)"),
user_ids: z.array(z.string()).optional().describe("List of user IDs (array)"),
team_ids: z.array(z.string()).optional().describe("List of team IDs (array)"),
}),
execute: async (args, { log }) => {
try {
const queryParams = new URLSearchParams();
if (args.start_date) queryParams.append('start_date', args.start_date);
if (args.end_date) queryParams.append('end_date', args.end_date);
if (args.current_user_only !== undefined) queryParams.append('current_user_only', String(args.current_user_only));
if (args.user_ids) {
args.user_ids.forEach(id => queryParams.append('user_ids[]', id));
}
if (args.team_ids) {
args.team_ids.forEach(id => queryParams.append('team_ids[]', id));
}
const apiUrl = `${API_URL}/journals?${queryParams.toString()}`;
const response = await fetch(apiUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer ${API_TOKEN}`,
},
});
if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
}
const data = await response.json();
return {
content: [
{
type: "text",
text: `# Work Journal Query Result
**Query Parameters:**
- Start Date: ${args.start_date || 'default'}
- End Date: ${args.end_date || 'default'}
- Current User Only: ${args.current_user_only !== undefined ? args.current_user_only : 'default'}
- User IDs: ${args.user_ids?.join(', ') || 'None'}
- Team IDs: ${args.team_ids?.join(', ') || 'None'}
**Raw JSON Data:**
\`\`\`json
${JSON.stringify(data, null, 2)}
\`\`\`
`,
},
],
};
} catch (error: unknown) {
throw new Error(`Failed to query work journals: ${(error as Error).message}`);
}
},
});
server.addTool({
name: "create_or_update_journal",
description: "Creates or updates a work journal.",
parameters: z.object({
date: z.string().describe("Journal date (YYYY-MM-DD format)"),
content: z.string().describe("Journal content"),
}),
execute: async (args, { log }) => {
try {
const apiUrl = `${API_URL}/journals/create_or_update`;
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer ${API_TOKEN}`,
},
body: JSON.stringify({
date: args.date,
content: args.content
}),
});
if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
}
const data = await response.json();
return {
content: [
{
type: "text",
text: `# Work Journal Create/Update Result
**Date:** ${args.date}
**Content:** ${args.content}
**Response Information:**
\`\`\`json
${JSON.stringify(data, null, 2)}
\`\`\`
`,
},
],
};
} catch (error: unknown) {
throw new Error(`Failed to create or update work journal: ${(error as Error).message}`);
}
},
});
server.addResource({
uri: "time-off-api-scheam://usage",
name: "Time Off API Scheam, include Teams, Users Infomation",
mimeType: "text/json",
async load() {
const url = `${API_URL}/schema`;
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': "Bearer " + API_TOKEN
},
});
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/imagekit/src/services/imageKitUpload.ts:
--------------------------------------------------------------------------------
```typescript
import { z } from "zod";
import ImageKit, { APIError, ImageKitError } from "@imagekit/nodejs";
import type {
FileUploadParams,
FileUploadResponse,
} from "@imagekit/nodejs/resources/files/files.js";
import type {
ImageUploadRequest,
ImageUploadResult,
ImageUploader,
} from "./imageUploader.js";
const DEFAULT_FOLDER = "upload/";
const DEFAULT_TAGS = ["upload"] as const;
const DEFAULT_BASE_URL = "https://upload.imagekit.io";
const baseRequestSchema = z.object({
file: z
.string()
.trim()
.min(1, { message: "file must be at least 1 character" })
.describe(
"File contents to upload. Provide a base64 string, binary buffer encoded as base64, a publicly accessible URL, or a local file path.",
),
fileName: z
.string()
.trim()
.min(1, { message: "fileName is required" })
.describe("Target filename to assign in ImageKit"),
folder: z
.string()
.trim()
.min(1, { message: "folder must be at least 1 character" })
.optional()
.default(DEFAULT_FOLDER)
.describe("Optional folder path such as /marketing/banners"),
tags: z
.array(z.string().trim().min(1, { message: "tags cannot be empty" }))
.max(30, { message: "Up to 30 tags are supported by ImageKit" })
.optional()
.default([...DEFAULT_TAGS])
.describe("Optional tags to attach to the asset."),
});
const imageKitOptionsSchema = z.object({
useUniqueFileName: z
.boolean()
.default(true)
.optional()
.describe("When true, ImageKit appends a unique suffix to avoid collisions."),
isPrivateFile: z
.boolean()
.default(false)
.optional()
.describe("Mark the file as private to require signed URLs."),
responseFields: z
.array(z.string().trim().min(1))
.max(20)
.optional()
.describe("Restrict the upload response to the supplied fields."),
customMetadata: z
.record(z.union([z.string(), z.number(), z.boolean()]))
.optional()
.describe(
"Custom metadata defined in the ImageKit dashboard. Values must be string, number, or boolean.",
),
});
export const imageKitUploadBaseSchema = baseRequestSchema;
export const imageKitUploadParametersSchema = imageKitUploadBaseSchema.extend({
options: imageKitOptionsSchema.optional(),
});
export type ImageKitUploadOptions = z.infer<typeof imageKitOptionsSchema>;
export type ImageKitUploadRequest = ImageUploadRequest<ImageKitUploadOptions>;
export type ImageKitUploadParameters = z.infer<typeof imageKitUploadParametersSchema>;
export type ImageKitUploadResponse = FileUploadResponse & Record<string, unknown>;
export interface ImageKitUploaderConfig {
privateKey: string;
baseURL?: string;
uploadEndpoint?: string;
}
export class ImageKitUploader
implements ImageUploader<
ImageKitUploadRequest,
ImageUploadResult<ImageKitUploadResponse>
>
{
private readonly client: ImageKit;
constructor(config: ImageKitUploaderConfig) {
if (!config.privateKey?.trim()) {
throw new Error("ImageKit private key is required to initialize the uploader");
}
const baseURL = config.baseURL ?? config.uploadEndpoint ?? DEFAULT_BASE_URL;
this.client = new ImageKit({
privateKey: config.privateKey.trim(),
baseURL,
});
}
async upload(
request: ImageKitUploadRequest,
signal?: AbortSignal,
): Promise<ImageUploadResult<ImageKitUploadResponse>> {
const responseFields = request.options?.responseFields as
| FileUploadParams["responseFields"]
| undefined;
const params: FileUploadParams = {
file: request.file,
fileName: request.fileName,
folder: request.folder ?? DEFAULT_FOLDER,
tags: request.tags?.length ? request.tags : [...DEFAULT_TAGS],
useUniqueFileName:
request.options?.useUniqueFileName ?? true,
isPrivateFile: request.options?.isPrivateFile ?? false,
responseFields,
customMetadata: request.options?.customMetadata,
};
try {
const providerData = (await this.client.files.upload(params, {
signal,
})) as ImageKitUploadResponse;
const metadata = providerData.metadata;
const normalizedMetadata =
metadata && typeof metadata === "object"
? (metadata as Record<string, unknown>)
: undefined;
return {
id: providerData.fileId,
url: providerData.url,
name: providerData.name,
metadata: normalizedMetadata,
providerData,
} satisfies ImageUploadResult<ImageKitUploadResponse>;
} catch (error) {
if (error instanceof APIError || error instanceof ImageKitError) {
const status =
typeof (error as APIError).status === "number" ? (error as APIError).status : undefined;
const message = error.message ?? "ImageKit upload failed";
throw new Error(
status
? `ImageKit upload failed with status ${status}: ${message}`
: `ImageKit upload failed: ${message}`,
);
}
throw error;
}
}
}
export async function uploadImageWithImageKit(
config: ImageKitUploaderConfig,
request: ImageKitUploadRequest,
signal?: AbortSignal,
): Promise<ImageUploadResult<ImageKitUploadResponse>> {
const uploader = new ImageKitUploader(config);
return uploader.upload(request, signal);
}
```
--------------------------------------------------------------------------------
/src/jampp-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: "Jampp MCP Server",
version: "0.1.3"
});
const AUTH_URL = "https://auth.jampp.com/v1/oauth/token";
const API_URL = "https://reporting-api.jampp.com/v1/graphql";
const CLIENT_ID = process.env.JAMPP_CLIENT_ID || '';
const CLIENT_SECRET = process.env.JAMPP_CLIENT_SECRET || '';
if (!CLIENT_ID || !CLIENT_SECRET) {
console.error("Missing Jampp API credentials. Please set JAMPP_CLIENT_ID and JAMPP_CLIENT_SECRET environment variables.");
process.exit(1);
}
// Token cache
let accessToken: string | null = null;
let tokenExpiry = 0;
/**
* Get valid Jampp API access token
*/
async function getAccessToken(): Promise<string> {
// Check if we have a valid token
if (accessToken && Date.now() < tokenExpiry) {
return accessToken;
}
// Request new token
const params = new URLSearchParams();
params.append('grant_type', 'client_credentials');
params.append('client_id', CLIENT_ID);
params.append('client_secret', CLIENT_SECRET);
const response = await fetch(AUTH_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params,
});
if (!response.ok) {
throw new Error(`Authentication failed: ${response.statusText}`);
}
const data = await response.json();
accessToken = data.access_token;
// Set expiry time with 5 minutes buffer
tokenExpiry = Date.now() + (data.expires_in - 300) * 1000;
if (!accessToken) {
throw new Error('Failed to get access token: Token is empty');
}
return accessToken;
}
/**
* Execute GraphQL query
*/
async function executeQuery(query: string, variables: Record<string, any> = {}) {
const token = await getAccessToken();
const response = await fetch(API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
query,
variables,
}),
});
if (!response.ok) {
throw new Error(`API request failed: ${response.statusText}`);
}
return response.json();
}
// Tool: Get campaign spend
server.tool("get_campaign_spend",
"Get the spend per campaign for a particular time range from Jampp Reporting API",
{
from_date: z.string().describe("Start date in YYYY-MM-DD format"),
to_date: z.string().describe("End date in YYYY-MM-DD format")
}, async ({ from_date, to_date }) => {
try {
// Add end of day time to to_date
const endOfDay = to_date + "T23:59:59";
let query = `
query spendPerCampaign($from: DateTime!, $to: DateTime!) {
spendPerCampaign: pivot(
from: $from,
to: $to
) {
results {
campaignId
campaign
impressions
clicks
installs
spend
}
}
}
`;
const variables: Record<string, any> = {
from: from_date,
to: endOfDay // Use the modified end date
};
const data = await executeQuery(query, variables);
return {
content: [
{
type: "text",
text: JSON.stringify(data.data.spendPerCampaign.results, null, 2)
}
]
};
} catch (error: any) {
return {
content: [
{
type: "text",
text: `Error getting campaign spend: ${error.message}`
}
],
isError: true
};
}
});
// Tool: Get campaign daily spend
server.tool("get_campaign_daily_spend",
"Get the daily spend per campaign for a particular time range from Jampp Reporting API",
{
from_date: z.string().describe("Start date in YYYY-MM-DD format"),
to_date: z.string().describe("End date in YYYY-MM-DD format"),
campaign_id: z.number().describe("Campaign ID to filter by")
}, async ({ from_date, to_date, campaign_id }) => {
try {
// Add end of day time to to_date
const endOfDay = to_date + "T23:59:59";
const query = `
query dailySpend($from: DateTime!, $to: DateTime!, $campaignId: Int!) {
dailySpend: pivot(
from: $from,
to: $to,
filter: {
campaignId: {
equals: $campaignId
}
}
) {
results {
date(granularity: DAILY)
campaignId
campaign
impressions
clicks
installs
spend
}
}
}
`;
const variables = {
from: from_date,
to: endOfDay,
campaignId: campaign_id
};
const data = await executeQuery(query, variables);
return {
content: [
{
type: "text",
text: JSON.stringify(data.data.dailySpend.results, null, 2)
}
]
};
} catch (error: any) {
return {
content: [
{
type: "text",
text: `Error getting campaign daily spend: ${error.message}`
}
],
isError: true
};
}
});
// Start server
async function runServer() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Jampp Reporting MCP Server running on stdio");
}
runServer().catch((error) => {
console.error("Fatal error running server:", error);
process.exit(1);
});
```
--------------------------------------------------------------------------------
/src/imagekit/src/tools/cropAndWatermark.ts:
--------------------------------------------------------------------------------
```typescript
import { z } from "zod";
import type { ImageUploadResult } from "../services/imageUploader.js";
import {
uploadImageWithImageKit,
type ImageKitUploadOptions,
type ImageKitUploadRequest,
type ImageKitUploaderConfig,
type ImageKitUploadResponse,
} from "../services/imageKitUpload.js";
const aspectRatioValues = [
"1:1",
"4:3",
"3:4",
"9:16",
"16:9",
"3:2",
"2:3",
"21:9",
] as const;
export type ImageAspectRatio = (typeof aspectRatioValues)[number];
export const aspectRatioSchema = z.enum(aspectRatioValues);
const aspectRatioSizes: Record<ImageAspectRatio, string> = {
"1:1": "2048x2048",
"4:3": "2304x1728",
"3:4": "1728x2304",
"9:16": "1440x2560",
"16:9": "2560x1440",
"3:2": "2496x1664",
"2:3": "1664x2496",
"21:9": "3024x1296",
};
const DEFAULT_API_BASE_URL = "https://api.cometapi.com/v1";
const GENERATION_PATH = "/images/generations";
const DEFAULT_MODEL_ID = "bytedance-seedream-4-0-250828";
export interface CropAndWatermarkOptions {
imageUrl: string;
aspectRatio: ImageAspectRatio;
watermarkText?: string;
apiKey: string;
apiBaseUrl?: string;
modelId?: string;
imageKit?: ImageKitPostUploadConfig;
}
export interface ImageKitPostUploadConfig {
config: ImageKitUploaderConfig;
fileName?: string;
folder?: string;
tags?: string[];
options?: ImageKitUploadOptions;
}
export async function cropAndWatermarkImage({
imageUrl,
aspectRatio,
watermarkText = "",
apiKey,
apiBaseUrl,
modelId = DEFAULT_MODEL_ID,
imageKit,
}: CropAndWatermarkOptions): Promise<string> {
const payload = createGenerationPayload({
aspectRatio,
imageUrl,
modelId,
watermarkText,
});
const endpoint = resolveGenerationEndpoint(apiBaseUrl);
const generatedUrl = await requestGeneratedImage({
apiKey,
endpoint,
payload,
});
if (!imageKit) {
return generatedUrl;
}
const uploadedUrl = await uploadGeneratedImage({
generatedUrl,
imageKit,
});
return uploadedUrl ?? generatedUrl;
}
export const defaultImageApiBaseUrl = DEFAULT_API_BASE_URL;
export const defaultImageModelId = DEFAULT_MODEL_ID;
function deriveFileNameFromUrl(sourceUrl: string): string {
try {
const parsed = new URL(sourceUrl);
const lastSegment = parsed.pathname.split("/").filter(Boolean).pop();
if (lastSegment) {
return sanitizeFileName(`cropped-${lastSegment}`);
}
} catch {
// ignore URL parsing errors and fall back to timestamped name
}
return `cropped-${Date.now()}.jpg`;
}
function sanitizeFileName(name: string): string {
return name.replace(/[^A-Za-z0-9._-]/g, "_");
}
function getUrlFromProviderData(
result: ImageUploadResult<ImageKitUploadResponse>,
): string | undefined {
const providerUrl = result.providerData?.url;
return typeof providerUrl === "string" && providerUrl.trim().length > 0
? providerUrl
: undefined;
}
type GenerationPayload = ReturnType<typeof createGenerationPayload>;
function createGenerationPayload({
aspectRatio,
imageUrl,
modelId,
watermarkText,
}: {
aspectRatio: ImageAspectRatio;
imageUrl: string;
modelId: string;
watermarkText?: string;
}) {
return {
model: modelId,
prompt: buildGenerationPrompt(aspectRatio, watermarkText),
image: imageUrl,
sequential_image_generation: "disabled",
response_format: "url",
size: aspectRatioSizes[aspectRatio],
stream: false,
watermark: false,
} as const;
}
function buildGenerationPrompt(
aspectRatio: ImageAspectRatio,
watermarkText?: string,
) {
const promptParts = [
`Crop the image to a ${aspectRatio} aspect ratio while keeping the main subject centered and intact.`,
];
const trimmedWatermark = watermarkText?.trim();
if (trimmedWatermark) {
promptParts.push(
`Add a subtle, semi-transparent watermark reading "${trimmedWatermark}" in the bottom-right corner. Ensure every other part of the image remains unchanged.`,
);
}
return promptParts.join(" ");
}
function resolveGenerationEndpoint(apiBaseUrl?: string): string {
const normalizedBaseUrl = (apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/$/, "");
return `${normalizedBaseUrl}${GENERATION_PATH}`;
}
async function requestGeneratedImage({
apiKey,
endpoint,
payload,
}: {
apiKey: string;
endpoint: string;
payload: GenerationPayload;
}): Promise<string> {
const response = await fetch(endpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(
`Image generation API request failed with status ${response.status}: ${errorBody}`,
);
}
const data = (await response.json()) as GenerationResponse;
const url = data?.data?.[0]?.url;
if (!url) {
throw new Error("Image generation API response did not include an image URL");
}
return url;
}
async function uploadGeneratedImage({
generatedUrl,
imageKit,
}: {
generatedUrl: string;
imageKit: ImageKitPostUploadConfig;
}): Promise<string | undefined> {
const uploadRequest: ImageKitUploadRequest = {
file: generatedUrl,
fileName: imageKit.fileName ?? deriveFileNameFromUrl(generatedUrl),
folder: imageKit.folder,
tags: imageKit.tags,
options: imageKit.options,
};
const uploadResult = await uploadImageWithImageKit(imageKit.config, uploadRequest);
return uploadResult.url ?? getUrlFromProviderData(uploadResult);
}
type GenerationResponse = {
data?: Array<{ url?: string }>;
};
```
--------------------------------------------------------------------------------
/src/impact-radius-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 dotenv from "dotenv";
import axios from "axios";
import { z } from "zod";
import type { AxiosError } from "axios";
import { fetchImpactRaidusCampaignMapping } from "./fm_impact_radius_mapping.js";
// Load environment variables
dotenv.config();
const impactRadiusSid = process.env.IMPACT_RADIUS_SID;
const impactRadiusToken = process.env.IMPACT_RADIUS_TOKEN;
if (!impactRadiusSid || !impactRadiusToken) {
throw new Error("Missing required environment variables: IMPACT_RADIUS_SID and IMPACT_RADIUS_TOKEN");
}
// Create MCP server
const server = new McpServer({
name: "Impact Radius MCP Server",
version: "0.0.1",
});
// Define the params for Impact Radius FCO reporting
const fetchFcoParams = {
start_date: z.string().describe("Start date in YYYY-MM-DD format"),
end_date: z.string().describe("End date in YYYY-MM-DD format")
} as const;
interface ImpactRadiusRecord {
date_display: string;
[key: string]: any;
}
interface ImpactRadiusResponse {
Records?: ImpactRadiusRecord[];
}
server.tool(
"fetch_action_list_from_impact_radius",
"Fetch action list from Impact Radius API with campaign mapping integration for a date range",
fetchFcoParams,
async (params) => {
try {
// First, get campaign mappings from FeedMob API
const campaignMappings = await fetchImpactRaidusCampaignMapping({});
if (!campaignMappings || !campaignMappings.data || !Array.isArray(campaignMappings.data)) {
throw new Error("Invalid campaign mapping response");
}
const url = `https://api.impact.com/Mediapartners/${impactRadiusSid}/Reports/mp_action_listing_sku.json`;
const allRecords: ImpactRadiusRecord[] = [];
// Make API call for each mapping
for (const mapping of campaignMappings.data) {
const requestParams = {
ResultFormat: 'JSON',
StartDate: `${params.start_date}T00:00:00Z`,
EndDate: `${params.end_date}T23:59:59Z`,
PUB_CAMPAIGN: mapping.impact_brand || '',
MP_AD_ID: mapping.impact_ad || '',
PUB_ACTION_TRACKER: mapping.impact_event_type || '',
SUPERSTATUS_MS: ['APPROVED', 'NA', 'PENDING'],
};
try {
const response = await axios.get(url, {
params: requestParams,
auth: {
username: impactRadiusSid,
password: impactRadiusToken
},
headers: {
'Accept': 'application/json'
}
});
if (response.status === 200) {
const data: ImpactRadiusResponse = response.data;
const records = data.Records || [];
// Add mapping info to each record
const recordsWithMapping = records.map(record => ({
...record,
mapping_impact_brand: mapping.impact_brand,
mapping_impact_ad: mapping.impact_ad,
mapping_impact_event_type: mapping.impact_event_type,
campaign: mapping.campaign_name,
client_name: mapping.client_name,
}));
allRecords.push(...recordsWithMapping);
}
} catch (error) {
console.error(`Error fetching data for mapping ${mapping.id}:`, error);
}
}
return {
content: [{
type: "text",
text: JSON.stringify({
allrecords: allRecords,
total_count: allRecords.length
}, null, 2)
}],
};
} catch (error) {
const errorMessage = error instanceof Error
? `Impact Radius API error: ${(error as AxiosError).response?.data || error.message}`
: 'Unknown error occurred';
return {
content: [{
type: "text",
text: errorMessage
}],
isError: true
};
}
}
);
// Add documentation prompt
server.prompt(
"help",
{},
() => ({
messages: [{
role: "user",
content: {
type: "text",
text: `
Impact Radius MCP Server
Available tools:
1. fetch_action_list_from_impact_radius
Fetch action list from Impact Radius API with campaign mapping integration for a date range.
Parameters:
- start_date: string (required) - Start date in YYYY-MM-DD format
- end_date: string (required) - End date in YYYY-MM-DD format
Returns:
- JSON object with allrecords array containing action data with mapping context
- Each record includes: original Impact Radius action data plus mapping fields
- Additional fields: mapping_impact_brand, mapping_impact_ad, mapping_impact_event_type, campaign, client_name
- Includes total_count of all records returned
Authentication:
- Requires IMPACT_RADIUS_SID and IMPACT_RADIUS_TOKEN environment variables for Impact Radius API
- Requires FEEDMOB_KEY and FEEDMOB_SECRET environment variables for FeedMob campaign mapping API
- Uses HTTP Basic Authentication for Impact Radius and JWT authentication for FeedMob
Data Integration:
- First fetches campaign mappings from FeedMob API
- Then queries Impact Radius API for each mapping configuration
- Combines action data with campaign mapping context
Example usage:
fetch_action_list_from_impact_radius({
start_date: "2024-01-01",
end_date: "2024-01-31"
})
`
}
}]
})
);
// Set up STDIO transport
const transport = new StdioServerTransport();
// Connect server to transport
await server.connect(transport);
```
--------------------------------------------------------------------------------
/.github/workflows/publish-reporting-packages.yml:
--------------------------------------------------------------------------------
```yaml
name: Publish Reporting Packages
on:
push:
branches:
- main
paths:
- 'src/liftoff-reporting/**'
- 'src/tapjoy-reporting/**'
- 'src/appsamurai-reporting/**'
- 'src/singular-reporting/**'
- 'src/kayzen-reporting/**'
- 'src/jampp-reporting/**'
- 'src/applovin-reporting/**'
- 'src/feedmob-reporting/**'
- 'src/ironsource-reporting/**'
- 'src/mintegral-reporting/**'
- 'src/inmobi-reporting/**'
- 'src/ironsource-aura-reporting/**'
- 'src/smadex-reporting/**'
- 'src/samsung-reporting/**'
- 'src/rtb-house-reporting/**'
- 'src/femini-reporting/**'
- 'src/sensor-tower-reporting/**'
- 'src/impact-radius-reporting/**'
workflow_dispatch:
inputs:
package:
description: 'Reporting package to publish (leave empty to detect from changed files)'
required: false
type: choice
options:
- ''
- liftoff
- tapjoy
- appsamurai
- singular
- kayzen
- jampp
- applovin
- feedmob
- ironsource
- mintegral
- inmobi
- ironsource-aura
- smadex
- samsung
- rtb-house
- femini
- impact-radius
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- id: set-matrix
name: Detect changed packages
shell: bash
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ github.event.inputs.package }}" != "" ]]; then
# Use manually specified package
echo "matrix={\"package\":[\"${{ github.event.inputs.package }}\"]}" >> $GITHUB_OUTPUT
else
# Detect from changed files
CHANGED_FILES=$(git diff --name-only HEAD^ HEAD)
# Initialize an empty JSON array
JSON_ARRAY="[]"
# Add packages to JSON array if they have changes
if echo "$CHANGED_FILES" | grep -q "src/liftoff-reporting/"; then
JSON_ARRAY=$(echo $JSON_ARRAY | jq -c '. += ["liftoff"]')
fi
if echo "$CHANGED_FILES" | grep -q "src/tapjoy-reporting/"; then
JSON_ARRAY=$(echo $JSON_ARRAY | jq -c '. += ["tapjoy"]')
fi
if echo "$CHANGED_FILES" | grep -q "src/appsamurai-reporting/"; then
JSON_ARRAY=$(echo $JSON_ARRAY | jq -c '. += ["appsamurai"]')
fi
if echo "$CHANGED_FILES" | grep -q "src/singular-reporting/"; then
JSON_ARRAY=$(echo $JSON_ARRAY | jq -c '. += ["singular"]')
fi
if echo "$CHANGED_FILES" | grep -q "src/kayzen-reporting/"; then
JSON_ARRAY=$(echo $JSON_ARRAY | jq -c '. += ["kayzen"]')
fi
if echo "$CHANGED_FILES" | grep -q "src/jampp-reporting/"; then
JSON_ARRAY=$(echo $JSON_ARRAY | jq -c '. += ["jampp"]')
fi
if echo "$CHANGED_FILES" | grep -q "src/applovin-reporting/"; then
JSON_ARRAY=$(echo $JSON_ARRAY | jq -c '. += ["applovin"]')
fi
if echo "$CHANGED_FILES" | grep -q "src/feedmob-reporting/"; then
JSON_ARRAY=$(echo $JSON_ARRAY | jq -c '. += ["feedmob"]')
fi
if echo "$CHANGED_FILES" | grep -q "src/ironsource-reporting/"; then
JSON_ARRAY=$(echo $JSON_ARRAY | jq -c '. += ["ironsource"]')
fi
if echo "$CHANGED_FILES" | grep -q "src/mintegral-reporting/"; then
JSON_ARRAY=$(echo $JSON_ARRAY | jq -c '. += ["mintegral"]')
fi
if echo "$CHANGED_FILES" | grep -q "src/inmobi-reporting/"; then
JSON_ARRAY=$(echo $JSON_ARRAY | jq -c '. += ["inmobi"]')
fi
if echo "$CHANGED_FILES" | grep -q "src/ironsource-aura-reporting/"; then
JSON_ARRAY=$(echo $JSON_ARRAY | jq -c '. += ["ironsource-aura"]')
fi
if echo "$CHANGED_FILES" | grep -q "src/smadex-reporting/"; then
JSON_ARRAY=$(echo $JSON_ARRAY | jq -c '. += ["smadex"]')
fi
if echo "$CHANGED_FILES" | grep -q "src/samsung-reporting/"; then
JSON_ARRAY=$(echo $JSON_ARRAY | jq -c '. += ["samsung"]')
fi
if echo "$CHANGED_FILES" | grep -q "src/rtb-house-reporting/"; then
JSON_ARRAY=$(echo $JSON_ARRAY | jq -c '. += ["rtb-house"]')
fi
if echo "$CHANGED_FILES" | grep -q "src/femini-reporting/"; then
JSON_ARRAY=$(echo $JSON_ARRAY | jq -c '. += ["femini"]')
fi
if echo "$CHANGED_FILES" | grep -q "src/sensor-tower-reporting/"; then
JSON_ARRAY=$(echo $JSON_ARRAY | jq -c '. += ["sensor-tower"]')
fi
if echo "$CHANGED_FILES" | grep -q "src/impact-radius-reporting/"; then
JSON_ARRAY=$(echo $JSON_ARRAY | jq -c '. += ["impact-radius"]')
fi
# Generate the matrix output with proper JSON formatting
echo "matrix={\"package\":$JSON_ARRAY}" >> $GITHUB_OUTPUT
fi
publish:
needs: detect-changes
if: ${{ fromJSON(needs.detect-changes.outputs.matrix).package[0] != null }}
runs-on: ubuntu-latest
strategy:
matrix: ${{ fromJSON(needs.detect-changes.outputs.matrix) }}
defaults:
run:
working-directory: src/${{ matrix.package }}-reporting
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Publish to npm
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTOMATION_TOKEN }}
```
--------------------------------------------------------------------------------
/src/civitai-records/src/tools/findAsset.ts:
--------------------------------------------------------------------------------
```typescript
import type { ContentResult } from "fastmcp";
import { z } from "zod";
import { prisma } from "../lib/prisma.js";
export const findAssetParameters = z.object({
asset_id: z
.string()
.optional()
.describe("The ID of the asset to find. Must be a valid integer ID."),
sha256sum: z
.string()
.optional()
.describe("The SHA-256 hash of the asset to find. Use this to check if an asset with this hash already exists in the database."),
civitai_id: z
.string()
.optional()
.describe(
"The Civitai media ID (image/video) to look up. Extract it from the Civitai URL; e.g., for 'https://civitai.com/images/106432973' the ID is '106432973'."
),
civitai_url: z
.string()
.optional()
.describe(
"The Civitai media page URL (image/video) to look up. Example: 'https://civitai.com/images/106432973'."
),
post_id: z
.string()
.optional()
.describe(
"The civitai_posts table ID linked to the asset. Use the ID returned from create_civitai_post or list_civitai_posts."
),
}).refine(
(data) =>
[
data.asset_id,
data.sha256sum,
data.civitai_id,
data.civitai_url,
data.post_id,
].some((value) => typeof value === "string" && value.trim().length > 0),
{
message: "At least one of asset_id, sha256sum, civitai_id, civitai_url, or post_id must be provided",
}
);
export type FindAssetParameters = z.infer<typeof findAssetParameters>;
export const findAssetTool = {
name: "find_asset",
description: "Find an asset by its ID or SHA-256 hash. Use this to check if an asset already exists in the database before creating a duplicate, or to retrieve full asset details including prompt and post associations.",
parameters: findAssetParameters,
execute: async ({
asset_id,
sha256sum,
civitai_id,
civitai_url,
post_id,
}: FindAssetParameters): Promise<ContentResult> => {
let assetIdBigInt: bigint | undefined;
if (asset_id) {
const trimmed = asset_id.trim();
if (trimmed) {
try {
assetIdBigInt = BigInt(trimmed);
} catch (error) {
throw new Error("asset_id must be a valid integer ID");
}
}
}
let postIdBigInt: bigint | undefined;
if (post_id) {
const trimmed = post_id.trim();
if (trimmed) {
try {
postIdBigInt = BigInt(trimmed);
} catch (error) {
throw new Error("post_id must be a valid integer ID");
}
}
}
const whereClause: {
id?: bigint;
sha256sum?: string;
civitai_id?: string;
civitai_url?: string;
post_id?: bigint | null;
} = {};
if (assetIdBigInt !== undefined) {
whereClause.id = assetIdBigInt;
}
if (sha256sum) {
whereClause.sha256sum = sha256sum.trim();
}
if (civitai_id) {
const trimmed = civitai_id.trim();
if (trimmed) {
whereClause.civitai_id = trimmed;
}
}
if (civitai_url) {
const trimmed = civitai_url.trim();
if (trimmed) {
whereClause.civitai_url = trimmed;
}
}
if (postIdBigInt !== undefined) {
whereClause.post_id = postIdBigInt;
}
const asset = await prisma.assets.findFirst({
where: whereClause,
include: {
prompts_assets_input_prompt_idToprompts: true,
prompts_assets_output_prompt_idToprompts: true,
civitai_posts: true,
},
});
if (!asset) {
return {
content: [
{
type: "text",
text: JSON.stringify({
found: false,
message: "No asset found matching the provided criteria",
}, null, 2),
},
],
} satisfies ContentResult;
}
return {
content: [
{
type: "text",
text: JSON.stringify({
found: true,
asset_id: asset.id.toString(),
asset_type: asset.asset_type,
asset_source: asset.asset_source,
uri: asset.uri,
sha256sum: asset.sha256sum,
civitai_id: asset.civitai_id,
civitai_url: asset.civitai_url,
post_id: asset.post_id?.toString() ?? null,
input_prompt_id: asset.input_prompt_id?.toString() ?? null,
output_prompt_id: asset.output_prompt_id?.toString() ?? null,
input_asset_ids: asset.input_asset_ids.map((id: bigint) => id.toString()),
metadata: asset.metadata,
created_at: asset.created_at.toISOString(),
updated_at: asset.updated_at.toISOString(),
input_prompt: asset.prompts_assets_input_prompt_idToprompts ? {
prompt_id: asset.prompts_assets_input_prompt_idToprompts.id.toString(),
prompt_text: asset.prompts_assets_input_prompt_idToprompts.content,
llm_model_provider: asset.prompts_assets_input_prompt_idToprompts.llm_model_provider,
llm_model: asset.prompts_assets_input_prompt_idToprompts.llm_model,
purpose: asset.prompts_assets_input_prompt_idToprompts.purpose,
} : null,
output_prompt: asset.prompts_assets_output_prompt_idToprompts ? {
prompt_id: asset.prompts_assets_output_prompt_idToprompts.id.toString(),
prompt_text: asset.prompts_assets_output_prompt_idToprompts.content,
llm_model_provider: asset.prompts_assets_output_prompt_idToprompts.llm_model_provider,
llm_model: asset.prompts_assets_output_prompt_idToprompts.llm_model,
purpose: asset.prompts_assets_output_prompt_idToprompts.purpose,
} : null,
post: asset.civitai_posts ? {
post_id: asset.civitai_posts.id.toString(),
civitai_id: asset.civitai_posts.civitai_id,
civitai_url: asset.civitai_posts.civitai_url,
title: asset.civitai_posts.title,
description: asset.civitai_posts.description,
status: asset.civitai_posts.status,
} : null,
}, null, 2),
},
],
} satisfies ContentResult;
},
};
```
--------------------------------------------------------------------------------
/src/rtb-house-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 { Buffer } from 'buffer';
dotenv.config();
const server = new McpServer({
name: "RTB House Reporting MCP Server",
version: "0.0.2"
});
// --- RTB House Configuration ---
const RTB_HOUSE_API_URL = 'https://api.panel.rtbhouse.com/v5/advertisers';
const RTB_HOUSE_USER = process.env.RTB_HOUSE_USER || '';
const RTB_HOUSE_PASSWORD = process.env.RTB_HOUSE_PASSWORD || '';
class ConfigurationError extends Error {
constructor(message: string) {
super(message);
this.name = 'ConfigurationError';
}
}
function validateRtbHouseConfig(): void {
if (!RTB_HOUSE_USER || !RTB_HOUSE_PASSWORD) {
throw new ConfigurationError('RTB_HOUSE_USER and RTB_HOUSE_PASSWORD environment variables are required');
}
}
// 通过 API 获取广告主列表
async function fetchRtbHouseAdvertisersFromApi(): Promise<{ name: string, hash: string }[]> {
validateRtbHouseConfig();
const url = `${RTB_HOUSE_API_URL}?fields=name,hash`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Authorization': 'Basic ' + Buffer.from(`${RTB_HOUSE_USER}:${RTB_HOUSE_PASSWORD}`).toString('base64'),
'Accept': 'application/json',
},
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`Failed to fetch advertisers: HTTP ${response.status}: ${errorBody}`);
}
const res = await response.json();
// 兼容返回格式
if (Array.isArray(res)) return res;
if (Array.isArray(res.data)) return res.data;
throw new Error('Unexpected advertisers API response');
}
/**
* Fetch RTB House data for a given date range, returns mapping of app -> full API data array
*/
async function fetchRtbHouseData(dateFrom: string, dateTo: string, app?: string, maxRetries = 3): Promise<Record<string, any[]>> {
validateRtbHouseConfig();
const result: Record<string, any[]> = {};
const paramsBase = {
dayFrom: dateFrom,
dayTo: dateTo,
groupBy: 'day-subcampaign',
metrics: 'campaignCost-impsCount-clicksCount',
};
// 动态获取广告主列表
const advertisersList = await fetchRtbHouseAdvertisersFromApi();
let advertisers: { name: string, hash: string }[];
if (app) {
advertisers = advertisersList.filter(a => a.name.toLowerCase() === app.toLowerCase());
} else {
advertisers = advertisersList;
}
if (advertisers.length === 0) {
throw new ConfigurationError(`App '${app}' not found in RTB House advertisers`);
}
for (const adv of advertisers) {
let lastError: any = null;
let data: any[] = [];
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const params = new URLSearchParams(paramsBase as any).toString();
const url = `${RTB_HOUSE_API_URL}/${adv.hash}/rtb-stats?${params}`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Authorization': 'Basic ' + Buffer.from(`${RTB_HOUSE_USER}:${RTB_HOUSE_PASSWORD}`).toString('base64'),
'Accept': 'application/json',
},
});
if (!response.ok) {
const errorBody = await response.text();
lastError = new Error(`HTTP ${response.status}: ${errorBody}`);
if (response.status >= 500 || response.status === 429 || response.status === 408) {
if (attempt === maxRetries) throw lastError;
await new Promise(res => setTimeout(res, 1000 * attempt));
continue;
} else {
throw lastError;
}
}
const res: any = await response.json();
data = (res.data || []); // 不再过滤单一天
break; // success, break retry loop
} catch (err: any) {
lastError = err;
if (attempt === maxRetries) {
data = [];
console.error(`Failed to fetch RTB House data for app ${adv.name}: ${err.message}`);
}
}
}
result[adv.name] = data;
}
return result;
}
// --- RTB House Data Tool Registration ---
const rtbHouseDateSchema = z.string()
.regex(/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/, 'Date must be in YYYY-MM-DD format')
.refine((date) => !isNaN(Date.parse(date)), 'Invalid date');
server.tool(
'get_rtb_house_data',
'Fetch RTB House full API data for a given date range.',
{
dateFrom: rtbHouseDateSchema.describe('Start date for the report (YYYY-MM-DD)'),
dateTo: rtbHouseDateSchema.describe('End date for the report (YYYY-MM-DD)'),
app: z.string().optional().describe('Optional app name to filter results. If not provided, returns data for all apps.'),
maxRetries: z.number().int().min(1).max(10).optional().default(3).describe('Maximum number of retry attempts (default: 3)'),
},
async ({ dateFrom, dateTo, app, maxRetries = 3 }) => {
try {
const data = await fetchRtbHouseData(dateFrom, dateTo, app, maxRetries);
return {
content: [
{
type: 'text',
text: JSON.stringify({ dateFrom, dateTo, app: app || 'all', data }, null, 2)
}
]
};
} catch (error: any) {
return {
content: [
{
type: 'text',
text: JSON.stringify({ error: error.message, dateFrom, dateTo, app: app || 'all' }, null, 2)
}
],
isError: true
};
}
}
);
// Start server
async function runServer(): Promise<void> {
try {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("RTB House 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/civitai-records/infra/db-init/02_functions.sql:
--------------------------------------------------------------------------------
```sql
-- =========================================================
-- 02_functions.sql
-- Shared helpers:
-- - create_app_user(username, password)
-- - set_created_updated_by() (BEFORE trigger)
-- - audit_event() (AFTER trigger)
-- - register_audited_table(...) DRY helper
-- =========================================================
SET ROLE civitai_owner;
-- 1) Create/Update a login user and grant civitai_user
CREATE OR REPLACE FUNCTION civitai.create_app_user(
p_username text,
p_password text
) RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
IF p_username IS NULL OR length(trim(p_username)) = 0 THEN
RAISE EXCEPTION 'Username cannot be empty';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = p_username) THEN
EXECUTE format('CREATE ROLE %I LOGIN PASSWORD %L', p_username, p_password);
ELSE
EXECUTE format('ALTER ROLE %I PASSWORD %L', p_username, p_password);
END IF;
EXECUTE format('GRANT civitai_user TO %I', p_username);
END;
$$;
-- 2) BEFORE trigger: maintain created_by/updated_by + timestamps
CREATE OR REPLACE FUNCTION civitai.set_created_updated_by()
RETURNS trigger AS $$
BEGIN
IF (TG_OP = 'INSERT') THEN
NEW.created_by := current_user; -- actual login role
NEW.updated_by := current_user;
NEW.created_at := COALESCE(NEW.created_at, now());
NEW.updated_at := now();
ELSIF (TG_OP = 'UPDATE') THEN
IF NEW.created_by IS DISTINCT FROM OLD.created_by THEN
RAISE EXCEPTION 'created_by is immutable';
END IF;
NEW.updated_by := current_user;
NEW.updated_at := now();
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- 3) AFTER trigger: append audit row into civitai.events
CREATE OR REPLACE FUNCTION civitai.audit_event()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = civitai, pg_catalog
AS $$
DECLARE
v_row_id bigint;
BEGIN
IF TG_OP = 'INSERT' THEN
v_row_id := NEW.id;
INSERT INTO civitai.events(actor, table_name, op, row_id, old_data, new_data)
VALUES (current_user, TG_TABLE_NAME, TG_OP, v_row_id, NULL, to_jsonb(NEW));
RETURN NEW;
ELSIF TG_OP = 'UPDATE' THEN
v_row_id := COALESCE(NEW.id, OLD.id);
INSERT INTO civitai.events(actor, table_name, op, row_id, old_data, new_data)
VALUES (current_user, TG_TABLE_NAME, TG_OP, v_row_id, to_jsonb(OLD), to_jsonb(NEW));
RETURN NEW;
ELSE
RETURN NULL;
END IF;
END;
$$;
-- 4) Helper to register a table for grants, triggers, and RLS
-- p_rls_mode: 'permissive' (anyone can read/update) or 'owned' (creator-only)
-- p_block_delete: revoke DELETE
-- p_protect_audit_cols: revoke INSERT/UPDATE on (created_by, updated_by)
CREATE OR REPLACE FUNCTION civitai.register_audited_table(
p_table regclass,
p_grant_role text DEFAULT 'civitai_user',
p_id_col text DEFAULT 'id',
p_rls_mode text DEFAULT 'permissive', -- or 'owned'
p_block_delete boolean DEFAULT true,
p_protect_audit_cols boolean DEFAULT true
) RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = civitai, pg_catalog
AS $$
DECLARE
v_schema text;
v_table text;
trg_before text;
trg_after text;
pol_sel text;
pol_ins text;
pol_upd text;
seq_schema text;
seq_name text;
BEGIN
SELECT n.nspname, c.relname
INTO v_schema, v_table
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.oid = p_table;
trg_before := format('trg_%s_created_updated_by', v_table);
trg_after := format('trg_%s_audit_event', v_table);
pol_sel := format('p_%s_select', v_table);
pol_ins := format('p_%s_insert', v_table);
pol_upd := format('p_%s_update', v_table);
-- Grants
EXECUTE format('GRANT SELECT, INSERT, UPDATE ON %s TO %I', p_table, p_grant_role);
IF p_block_delete THEN
EXECUTE format('REVOKE DELETE ON %s FROM %I', p_table, p_grant_role);
END IF;
-- Ensure callers can advance any serial/identity sequence tied to the id column
SELECT n.nspname, c.relname
INTO seq_schema, seq_name
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.oid = pg_get_serial_sequence(p_table::text, p_id_col)::regclass;
IF seq_schema IS NOT NULL THEN
EXECUTE format('GRANT USAGE, SELECT ON SEQUENCE %I.%I TO %I', seq_schema, seq_name, p_grant_role);
END IF;
-- Column protections
IF p_protect_audit_cols THEN
EXECUTE format('REVOKE INSERT (created_by, updated_by) ON %s FROM %I', p_table, p_grant_role);
EXECUTE format('REVOKE UPDATE (created_by, updated_by) ON %s FROM %I', p_table, p_grant_role);
END IF;
-- BEFORE trigger
EXECUTE format('DROP TRIGGER IF EXISTS %I ON %s', trg_before, p_table);
EXECUTE format(
'CREATE TRIGGER %I BEFORE INSERT OR UPDATE ON %s
FOR EACH ROW EXECUTE FUNCTION civitai.set_created_updated_by()',
trg_before, p_table
);
-- AFTER trigger
EXECUTE format('DROP TRIGGER IF EXISTS %I ON %s', trg_after, p_table);
EXECUTE format(
'CREATE TRIGGER %I AFTER INSERT OR UPDATE ON %s
FOR EACH ROW EXECUTE FUNCTION civitai.audit_event()',
trg_after, p_table
);
-- RLS & policies
EXECUTE format('ALTER TABLE %s ENABLE ROW LEVEL SECURITY', p_table);
EXECUTE format('ALTER TABLE %s FORCE ROW LEVEL SECURITY', p_table);
EXECUTE format('DROP POLICY IF EXISTS %I ON %s', pol_sel, p_table);
EXECUTE format('DROP POLICY IF EXISTS %I ON %s', pol_ins, p_table);
EXECUTE format('DROP POLICY IF EXISTS %I ON %s', pol_upd, p_table);
IF lower(p_rls_mode) = 'owned' THEN
EXECUTE format('CREATE POLICY %I ON %s FOR SELECT USING (created_by = current_user)', pol_sel, p_table);
EXECUTE format('CREATE POLICY %I ON %s FOR INSERT WITH CHECK (true)', pol_ins, p_table);
EXECUTE format('CREATE POLICY %I ON %s FOR UPDATE USING (created_by = current_user) WITH CHECK (created_by = current_user)', pol_upd, p_table);
ELSE
EXECUTE format('CREATE POLICY %I ON %s FOR SELECT USING (true)', pol_sel, p_table);
EXECUTE format('CREATE POLICY %I ON %s FOR INSERT WITH CHECK (true)', pol_ins, p_table);
EXECUTE format('CREATE POLICY %I ON %s FOR UPDATE USING (true) WITH CHECK (true)', pol_upd, p_table);
END IF;
END;
$$;
RESET ROLE;
```
--------------------------------------------------------------------------------
/src/civitai-records/src/tools/updateAsset.ts:
--------------------------------------------------------------------------------
```typescript
import type { ContentResult } from "fastmcp";
import { z } from "zod";
import { prisma } from "../lib/prisma.js";
import { handleDatabaseError } from "../lib/handleDatabaseError.js";
/**
* Parse and validate an ID string parameter to BigInt.
* Returns null if the input is null/empty.
*/
function parseIdParameter(id: string | null | undefined, parameterName: string): bigint | null | undefined {
if (id === undefined) return undefined;
if (id === null || id.trim() === "") return null;
try {
return BigInt(id.trim());
} catch (error) {
throw new Error(`Invalid ${parameterName}: must be a valid integer ID`);
}
}
/**
* Parse and validate an array of ID strings to BigInt array.
* Returns null if the input is null/empty, undefined if not provided.
*/
function parseIdArrayParameter(ids: string[] | null | undefined, parameterName: string): bigint[] | null | undefined {
if (ids === undefined) return undefined;
if (ids === null || ids.length === 0) return [];
try {
return ids.map((id, index) => {
const trimmed = id.trim();
if (!trimmed) {
throw new Error(`Empty ID at index ${index}`);
}
return BigInt(trimmed);
});
} catch (error) {
throw new Error(`Invalid ${parameterName}: ${error instanceof Error ? error.message : 'must contain valid integer IDs'}`);
}
}
export const updateAssetParameters = z.object({
asset_id: z
.string()
.min(1)
.describe("The ID of the asset to update. This should be the asset_id returned from a previous create_asset call."),
input_prompt_id: z
.string()
.nullable()
.optional()
.describe("The ID of the prompt that generated this asset. Set to null to remove the association. Leave undefined to keep current value."),
output_prompt_id: z
.string()
.nullable()
.optional()
.describe("The ID of the prompt that was derived from this asset. Set to null to remove the association. Leave undefined to keep current value."),
civitai_id: z
.string()
.nullable()
.optional()
.describe("The Civitai image ID. Extract from URL (e.g., '106432973' from 'https://civitai.com/images/106432973'). Set to null to remove. Leave undefined to keep current value."),
civitai_url: z
.string()
.nullable()
.optional()
.describe("The full Civitai page URL (e.g., 'https://civitai.com/images/106432973'). Set to null to remove. Leave undefined to keep current value."),
on_behalf_of: z
.string()
.nullable()
.optional()
.describe("The user account this action is being performed on behalf of. Set to null to clear the value. Leave undefined to keep current value."),
post_id: z
.string()
.nullable()
.optional()
.describe("The ID of the Civitai post this asset belongs to. References civitai_posts table. Set to null to remove the association. Leave undefined to keep current value."),
input_asset_ids: z
.array(z.string())
.nullable()
.optional()
.describe("Array of asset IDs that were used as inputs to generate this asset. Set to empty array [] or null to clear. Leave undefined to keep current value."),
});
export type UpdateAssetParameters = z.infer<typeof updateAssetParameters>;
export const updateAssetTool = {
name: "update_asset",
description: "Update an existing asset's optional fields including prompt associations (input_prompt_id, output_prompt_id), Civitai metadata (civitai_id, civitai_url), post association (post_id), account attribution (on_behalf_of), and input assets (input_asset_ids). Only provided fields will be updated; undefined fields keep their current values.",
parameters: updateAssetParameters,
execute: async ({
asset_id,
input_prompt_id,
output_prompt_id,
civitai_id,
civitai_url,
on_behalf_of,
post_id,
input_asset_ids,
}: UpdateAssetParameters): Promise<ContentResult> => {
// Parse and validate asset ID
const assetIdBigInt = parseIdParameter(asset_id, 'asset_id');
if (!assetIdBigInt) {
throw new Error("asset_id is required and must be a valid integer ID");
}
// Build update data object with only provided fields
const data: any = {};
const inputPromptId = parseIdParameter(input_prompt_id, 'input_prompt_id');
if (inputPromptId !== undefined) {
data.input_prompt_id = inputPromptId;
}
const outputPromptId = parseIdParameter(output_prompt_id, 'output_prompt_id');
if (outputPromptId !== undefined) {
data.output_prompt_id = outputPromptId;
}
if (civitai_id !== undefined) {
data.civitai_id = civitai_id === null || civitai_id.trim() === "" ? null : civitai_id.trim();
}
if (civitai_url !== undefined) {
data.civitai_url = civitai_url === null || civitai_url.trim() === "" ? null : civitai_url.trim();
}
if (on_behalf_of !== undefined) {
data.on_behalf_of = on_behalf_of === null || on_behalf_of.trim() === "" ? null : on_behalf_of.trim();
}
const postIdBigInt = parseIdParameter(post_id, 'post_id');
if (postIdBigInt !== undefined) {
data.post_id = postIdBigInt;
}
const inputAssetIds = parseIdArrayParameter(input_asset_ids, 'input_asset_ids');
if (inputAssetIds !== undefined) {
data.input_asset_ids = inputAssetIds;
}
const asset = await prisma.assets.update({
where: {
id: assetIdBigInt,
},
data,
}).catch(error => handleDatabaseError(error, `Asset ID: ${asset_id}`));
return {
content: [
{
type: "text",
text: JSON.stringify({
asset_id: asset.id.toString(),
asset_type: asset.asset_type,
asset_source: asset.asset_source,
asset_url: asset.uri,
sha256sum: asset.sha256sum,
input_prompt_id: asset.input_prompt_id?.toString() ?? null,
output_prompt_id: asset.output_prompt_id?.toString() ?? null,
civitai_id: asset.civitai_id,
civitai_url: asset.civitai_url,
on_behalf_of: asset.on_behalf_of,
post_id: asset.post_id?.toString() ?? null,
input_asset_ids: asset.input_asset_ids.map((id: bigint) => id.toString()),
metadata: asset.metadata,
updated_at: asset.updated_at.toISOString(),
}, null, 2),
},
],
} satisfies ContentResult;
},
};
```
--------------------------------------------------------------------------------
/src/singular-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 dotenv from "dotenv";
import axios from "axios";
import { z } from "zod";
import type { AxiosError } from "axios";
// Load environment variables
dotenv.config();
const apiKey = process.env.SINGULAR_API_KEY;
const apiBaseUrl = process.env.SINGULAR_API_BASE_URL;
if (!apiKey || !apiBaseUrl) {
throw new Error("Missing required environment variables: SINGULAR_API_KEY and SINGULAR_API_BASE_URL");
}
// Create MCP server
const server = new McpServer({
name: "Singular MCP Server",
version: "0.0.3",
});
// Define the params directly as a ZodRawShape
const createReportParams = {
start_date: z.string().describe("Start date in YYYY-MM-DD format"),
end_date: z.string().describe("End date in YYYY-MM-DD format"),
source: z.string().optional().describe("Optional. Filter results by specific source"),
time_breakdown: z.string().optional().describe("Optional. Time breakdown: 'day' for daily data, 'all' for aggregated data (default: 'all')")
} as const;
server.tool(
"create_report",
"Getting reporting data from Singular via generates an asynchronous report query",
createReportParams, // Use the params directly
async (params) => {
try {
const requestBody = {
...params,
dimensions: "unified_campaign_name",
metrics: "custom_impressions,custom_clicks,custom_installs,adn_cost",
time_breakdown: params.time_breakdown || "all",
format: "csv"
};
// Only add source filter if it's provided
if (params.source) {
requestBody.source = params.source;
}
const response = await axios.post(
`${apiBaseUrl}/create_async_report`,
requestBody,
{
params: { api_key: apiKey }
}
);
return {
content: [{
type: "text",
text: JSON.stringify(response.data, null, 2)
}],
};
} catch (error) {
const errorMessage = error instanceof Error
? `Singular API error: ${(error as AxiosError<SingularErrorResponse>).response?.data?.message || error.message}`
: 'Unknown error occurred';
return {
content: [{
type: "text",
text: errorMessage
}],
isError: true
};
}
}
);
const getReportStatusParams = {
report_id: z.string(),
};
const getReportStatusSchema = z.object(getReportStatusParams);
type GetReportStatusParams = z.infer<typeof getReportStatusSchema>;
interface SingularErrorResponse {
message: string;
}
server.tool(
"get_singular_report",
"Get the complete report from Singular. Checks status and automatically downloads the CSV report data when ready.",
getReportStatusParams,
async (params: GetReportStatusParams) => {
try {
const response = await axios.get(
`${apiBaseUrl}/get_report_status`,
{
params: {
api_key: apiKey,
report_id: params.report_id
}
}
);
const reportData = response.data;
// If status is DONE and download_url is available, automatically download the report
if (reportData.value && reportData.value.status === 'DONE' && reportData.value.download_url) {
try {
const downloadResponse = await axios.get(reportData.value.download_url, {
responseType: 'text'
});
// Return data in the specified format
const formattedResponse = {
status: 0,
substatus: 0,
value: {
csv_report: downloadResponse.data
}
};
return {
content: [{
type: "text",
text: JSON.stringify(formattedResponse)
}]
};
} catch (downloadError) {
const downloadErrorMessage = downloadError instanceof Error
? `Download error: ${(downloadError as AxiosError<SingularErrorResponse>).response?.data?.message || downloadError.message}`
: 'Unknown download error occurred';
return {
content: [{
type: "text",
text: `Report is ready but download failed: ${downloadErrorMessage}\n\nStatus response: ${JSON.stringify(reportData, null, 2)}`
}],
isError: true
};
}
} else {
// Return status information if not done yet
return {
content: [{
type: "text",
text: JSON.stringify(reportData, null, 2)
}]
};
}
} catch (error) {
const errorMessage = error instanceof Error
? `Singular API error: ${(error as AxiosError<SingularErrorResponse>).response?.data?.message || error.message}`
: 'Unknown error occurred';
return {
content: [{
type: "text",
text: errorMessage
}],
isError: true
};
}
}
);
// Add documentation prompt
server.prompt(
"help",
{},
() => ({
messages: [{
role: "user",
content: {
type: "text",
text: `
Available tools:
1. create_report
Creates an asynchronous report in Singular with predefined settings.
Parameters:
- start_date: string (required) - Start date (YYYY-MM-DD)
- end_date: string (required) - End date (YYYY-MM-DD)
- source: string (optional) - Filter results by specific source
- time_breakdown: string (optional) - Time breakdown: 'day' for daily data, 'all' for aggregated data (default: 'all')
Fixed settings:
- dimensions: unified_campaign_name
- metrics: custom_impressions,custom_clicks,custom_installs,adn_cost
- format: csv
2. get_singular_report
Gets the complete report from Singular. Checks status and automatically downloads the CSV report data when ready.
Parameters:
- report_id: string (required) - The ID of the report to check
Returns:
- If report is still processing: Status information in JSON format
- If report is complete: Full CSV report data wrapped in JSON format with csv_report field
Note: This tool handles the complete workflow - you don't need to manually check status or download separately.
`
}
}]
})
);
// Set up STDIO transport
const transport = new StdioServerTransport();
// Connect server to transport
await server.connect(transport);
```
--------------------------------------------------------------------------------
/src/civitai-records/src/lib/handleDatabaseError.ts:
--------------------------------------------------------------------------------
```typescript
import { Prisma } from "@prisma/client";
/**
* Handle Prisma database errors and convert them to LLM-friendly messages.
* This is a centralized error handler for all database operations.
*
* The messages are designed to be:
* - Clear and actionable
* - Explain WHY the error occurred
* - Suggest HOW to fix it
* - Provide context about what went wrong
*
* @param error - The error thrown by Prisma
* @param context - Additional context to include in error messages (e.g., URL, ID)
* @throws Always throws an error with an LLM-friendly message
*/
export function handleDatabaseError(error: unknown, context?: string): never {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
const target = error.meta?.target as string[] | undefined;
const contextInfo = context ? `\n\n${context}` : '';
// P2002: Unique constraint violation
if (error.code === 'P2002') {
// Specific handling for sha256sum (duplicate content)
if (target?.includes('sha256sum')) {
throw new Error(
`❌ DUPLICATE CONTENT DETECTED\n\n` +
`This asset already exists in the database with the same content (same sha256sum hash).\n\n` +
`What this means:\n` +
`- You're trying to save content that has already been saved before\n` +
`- The file content is identical to an existing asset\n\n` +
`What to do:\n` +
`1. Use the find_asset tool to search for existing assets with this URL or content\n` +
`2. If you need to update the existing asset, use update_asset instead of create_asset\n` +
`3. If you want to create a new record anyway, make sure the content is actually different${contextInfo}`
);
}
// Specific handling for civitai_id
if (target?.includes('civitai_id')) {
throw new Error(
`❌ DUPLICATE CIVITAI ID\n\n` +
`A record with this Civitai ID already exists in the database.\n\n` +
`What this means:\n` +
`- You're trying to create a new record with a civitai_id that's already in use\n` +
`- This could be an asset or post that was already saved\n\n` +
`What to do:\n` +
`1. Use find_asset or list_civitai_posts to check if this record already exists\n` +
`2. If it exists and needs updates, use update_asset or update the post instead\n` +
`3. Verify that the civitai_id is correct - maybe you copied it from the wrong URL${contextInfo}`
);
}
// Generic unique constraint violation
const fields = target?.join(', ') || 'unknown field(s)';
throw new Error(
`❌ DUPLICATE DATA DETECTED\n\n` +
`A record with the same ${fields} already exists in the database.\n\n` +
`What this means:\n` +
`- You're trying to create a record with data that must be unique\n` +
`- Another record already uses this value\n\n` +
`What to do:\n` +
`1. Check if a similar record already exists using the appropriate search tool\n` +
`2. If you want to modify an existing record, use an update tool instead\n` +
`3. If you need a new record, make sure all unique fields have different values${contextInfo}`
);
}
// P2003: Foreign key constraint violation
if (error.code === 'P2003') {
const field = error.meta?.field_name as string | undefined;
throw new Error(
`❌ INVALID REFERENCE ID\n\n` +
`The ${field || 'referenced'} ID you provided doesn't exist in the database.\n\n` +
`What this means:\n` +
`- You're trying to link to a record that doesn't exist\n` +
`- The ID might be wrong, or the referenced record was never created\n\n` +
`What to do:\n` +
`1. If referencing a prompt: Create the prompt first using create_prompt, then use its returned ID\n` +
`2. If referencing a post: Create the post first using create_civitai_post, then use its returned ID\n` +
`3. Double-check that you're using the correct ID from a previous operation\n` +
`4. Make sure you didn't skip a step in your workflow${contextInfo}`
);
}
// P2025: Record not found
if (error.code === 'P2025') {
throw new Error(
`❌ RECORD NOT FOUND\n\n` +
`The record you're trying to access doesn't exist in the database.\n\n` +
`What this means:\n` +
`- The ID you provided is incorrect or the record was deleted\n\n` +
`What to do:\n` +
`1. Verify the ID is correct\n` +
`2. Use the appropriate find or list tool to search for the record\n` +
`3. If you need to create it, use a create tool instead of update${contextInfo}`
);
}
// P2014: Required relation violation
if (error.code === 'P2014') {
throw new Error(
`❌ REQUIRED RELATIONSHIP VIOLATION\n\n` +
`This operation would break a required relationship in the database.\n\n` +
`What this means:\n` +
`- You're trying to remove or change something that other records depend on\n\n` +
`What to do:\n` +
`1. Check what other records are linked to this one\n` +
`2. Update or delete dependent records first\n` +
`3. Consider if this operation is really necessary${contextInfo}`
);
}
// Generic Prisma error
throw new Error(
`❌ DATABASE ERROR\n\n` +
`Error Code: ${error.code}\n` +
`Message: ${error.message}\n\n` +
`What to do:\n` +
`1. Review the error message for clues\n` +
`2. Check that all required fields are provided\n` +
`3. Verify that data types are correct${contextInfo}`
);
}
// Re-throw unknown errors with better formatting if possible
if (error instanceof Error) {
throw new Error(
`❌ UNEXPECTED ERROR\n\n` +
`${error.message}\n\n` +
`This is an unexpected error. Please review the operation and try again.${context ? `\n\n${context}` : ''}`
);
}
throw error;
}
/**
* Wrap a database operation with error handling.
* Provides a clean way to handle errors without try-catch in every function.
*
* @param operation - The async database operation to execute
* @param context - Additional context for error messages
* @returns The result of the operation
*/
export async function withDatabaseErrorHandling<T>(
operation: () => Promise<T>,
context?: string
): Promise<T> {
try {
return await operation();
} catch (error) {
handleDatabaseError(error, context);
}
}
```
--------------------------------------------------------------------------------
/src/user-activity-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 dotenv from 'dotenv';
import * as api from './api.js';
dotenv.config();
const server = new McpServer({ name: 'user-activity-reporting', version: '0.0.3' });
const errMsg = (e: unknown) => e instanceof Error ? e.message : 'Unknown error';
const errResp = (msg: string) => ({ content: [{ type: 'text' as const, text: `Error: ${msg}` }], isError: true });
const textResp = (text: string) => ({ content: [{ type: 'text' as const, text }] });
server.tool('get_all_client_contacts', 'Query all client contacts with team members (POD, AA, AM, AE, PM, PA, AO).', {
month: z.string().optional().describe('Month in YYYY-MM format'),
}, async (args) => {
try {
const d = await api.getAllContacts(args.month);
const preview = d.clients.slice(0, 20);
const more = d.total > 20 ? `\n... and ${d.total - 20} more` : '';
return textResp(`# All Client Contacts\n\nMonth: ${d.month} | Total: ${d.total}\n\n\`\`\`json\n${JSON.stringify(preview, null, 2)}\n\`\`\`${more}`);
} catch (e) { return errResp(errMsg(e)); }
});
server.tool('get_client_team_members', 'Query team members for a client. Returns POD, AA, AM, AE, PM, PA, AO.', {
client_name: z.string().describe('Client name (fuzzy match)'),
month: z.string().optional().describe('Month in YYYY-MM format'),
}, async (args) => {
try {
const d = await api.getContactByClient(args.client_name, args.month);
const team = { POD: d.pod || 'N/A', AA: d.aa || 'N/A', AM: d.am || 'N/A', AE: d.ae || 'N/A', PM: d.pm || 'N/A', PA: d.pa || 'N/A', AO: d.ao || 'N/A' };
return textResp(`# Team for "${d.client_name}"\n\n\`\`\`json\n${JSON.stringify({ client_id: d.client_id, client_name: d.client_name, month: d.month, team }, null, 2)}\n\`\`\``);
} catch (e) { return errResp(errMsg(e)); }
});
server.tool('get_clients_by_pod', 'Query clients in a POD team.', {
pod: z.string().describe('POD name (fuzzy match)'),
month: z.string().optional().describe('Month in YYYY-MM format'),
}, async (args) => {
try {
const d = await api.getClientsByPod(args.pod, args.month);
return textResp(`# Clients in POD: ${d.pod}\n\nMonth: ${d.month} | Count: ${d.count}\n\n${d.client_names.map(c => `- ${c}`).join('\n')}`);
} catch (e) { return errResp(errMsg(e)); }
});
server.tool('get_clients_by_name', 'Query clients managed by a person. Can filter by role.', {
name: z.string().describe('Person name'),
role: z.enum(['aa', 'am', 'ae', 'pm', 'pa', 'ao']).optional().describe('Role filter'),
month: z.string().optional().describe('Month in YYYY-MM format'),
}, async (args) => {
try {
if (args.role) {
const d = await api.getClientsByRole(args.role, args.name, args.month);
return textResp(`# Clients for ${d.role.toUpperCase()}: ${d.name}\n\nMonth: ${d.month} | Count: ${d.count}\n\n${d.client_names.map(c => `- ${c}`).join('\n')}`);
}
const d = await api.getClientsByName(args.name, args.month);
const lines = Object.entries(d.results).map(([r, cs]) => `**${r}:** ${cs.join(', ')}`);
return textResp(`# Clients for "${d.name}"\n\nMonth: ${d.month}\n\n${lines.join('\n') || 'No clients found'}`);
} catch (e) { return errResp(errMsg(e)); }
});
server.tool('get_user_slack_history', 'Search Slack messages from a user.', {
user_name: z.string().describe('User name'),
query: z.string().optional().describe('Keyword filter'),
limit: z.number().optional().default(20).describe('Max results'),
}, async (args) => {
try {
const msgs = await api.searchSlackMsgs(args.user_name, args.query, args.limit || 20);
if (!msgs.length) return textResp(`No Slack messages found for: ${args.user_name}`);
const fmt = msgs.map(m => ({ channel: m.channel, text: m.text.slice(0, 200) + (m.text.length > 200 ? '...' : ''), ts: new Date(parseFloat(m.ts) * 1000).toISOString(), link: m.permalink }));
return textResp(`# Slack Messages from ${args.user_name}\n\nFound ${msgs.length}\n\n\`\`\`json\n${JSON.stringify(fmt, null, 2)}\n\`\`\``);
} catch (e) { return errResp(errMsg(e)); }
});
server.tool('get_hubspot_tickets', 'Query HubSpot tickets.', {
status: z.string().optional().describe('Status filter'),
start_date: z.string().optional().describe('Start date YYYY-MM-DD'),
end_date: z.string().optional().describe('End date YYYY-MM-DD'),
limit: z.number().optional().default(50).describe('Max results'),
}, async (args) => {
try {
const tickets = await api.getTickets({ status: args.status, startDate: args.start_date, endDate: args.end_date, limit: args.limit });
if (!tickets.length) return textResp('No HubSpot tickets found');
const fmt = tickets.map(t => ({ id: t.id, subject: t.subject, status: t.status, priority: t.priority || 'N/A', created: t.createdAt }));
return textResp(`# HubSpot Tickets\n\nFound ${tickets.length}\n\n\`\`\`json\n${JSON.stringify(fmt, null, 2)}\n\`\`\``);
} catch (e) { return errResp(errMsg(e)); }
});
server.tool('get_hubspot_ticket_detail', 'Get HubSpot ticket details.', {
ticket_id: z.string().describe('Ticket ID'),
}, async (args) => {
try {
const t = await api.getTicketById(args.ticket_id);
if (!t) return textResp(`Ticket not found: ${args.ticket_id}`);
return textResp(`# ${t.subject}\n\n**ID:** ${t.id}\n**Status:** ${t.status}\n**Priority:** ${t.priority || 'N/A'}\n**Created:** ${t.createdAt}\n\n## Description\n\n${t.content || 'No content'}`);
} catch (e) { return errResp(errMsg(e)); }
});
server.tool('get_hubspot_tickets_by_user', 'Query HubSpot tickets by user.', {
user_name: z.string().optional().describe('User name'),
email: z.string().optional().describe('Email'),
limit: z.number().optional().default(50).describe('Max results'),
}, async (args) => {
try {
if (!args.user_name && !args.email) return errResp('Provide user_name or email');
const tickets = await api.getTicketsByUser({ userName: args.user_name, email: args.email, limit: args.limit });
if (!tickets.length) return textResp(`No tickets found for: ${args.user_name || args.email}`);
const fmt = tickets.map(t => ({ id: t.id, subject: t.subject, status: t.status, created: t.createdAt }));
return textResp(`# Tickets for "${args.user_name || args.email}"\n\nFound ${tickets.length}\n\n\`\`\`json\n${JSON.stringify(fmt, null, 2)}\n\`\`\``);
} catch (e) { return errResp(errMsg(e)); }
});
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('User Activity Reporting MCP Server running...');
}
main();
```
--------------------------------------------------------------------------------
/src/appsamurai-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 axios from "axios";
import dotenv from "dotenv";
dotenv.config(); // Load environment variables from .env file
// Corrected Base URL
const APPSAMURAI_API_BASE = "http://api.appsamurai.com/api";
const APPSAMURAI_API_KEY = process.env.APPSAMURAI_API_KEY;
if (!APPSAMURAI_API_KEY) {
console.error("Error: APPSAMURAI_API_KEY environment variable is not set.");
process.exit(1);
}
// Create server instance
const server = new McpServer({
name: "appsamurai-reporting",
version: "0.0.5",
capabilities: {
tools: {},
prompts: {},
},
});
// --- Corrected Helper Function for API Call ---
async function fetchAppSamuraiData(
startDate: string,
endDate: string,
campaignId?: string,
bundleId?: string,
platform?: string,
campaignName?: string,
country?: string
): Promise<any> {
// Construct URL with API key in the path
const url = `${APPSAMURAI_API_BASE}/customer-pull/spent/${APPSAMURAI_API_KEY}`;
try {
const response = await axios.get(url, {
headers: { // No Authorization header needed
'Content-Type': 'application/json',
'Accept': 'application/json',
},
params: { // Query parameters
start_date: startDate,
end_date: endDate,
...(campaignId && { campaign_id: campaignId }),
...(bundleId && { bundle_id: bundleId }),
...(platform && { platform }),
...(campaignName && { campaign_name: campaignName }),
...(country && { country }),
},
timeout: 30000, // 30 second timeout
});
return response.data;
} catch (error: unknown) {
console.error("Error fetching data from AppSamurai API:", error);
if (axios.isAxiosError(error)) {
console.error("Axios error details:", {
message: error.message,
code: error.code,
status: error.response?.status,
data: error.response?.data,
});
// Provide more specific error messages based on status code
if (error.response?.status === 401) {
throw new Error(`AppSamurai API request failed: Unauthorized (Invalid API Key?)`);
} else if (error.response?.status === 400) {
throw new Error(`AppSamurai API request failed: Bad Request (Invalid Date Format?)`);
} else if (error.response?.status === 404) {
throw new Error(`AppSamurai API request failed: Not Found (No data matches filters?)`);
} else {
throw new Error(`AppSamurai API request failed: ${error.response?.status || error.message}`);
}
}
throw new Error(`Failed to fetch data from AppSamurai API: ${error}`);
}
}
// --- Tool Definition ---
server.tool(
"get_appsamurai_campaign_spend", // Tool name
"Get campaign spending data via AppSamurai Campaign Spend API.", // Tool description
{ // Input schema using Zod
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Start date in YYYY-MM-DD format"),
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("End date in YYYY-MM-DD format"),
campaignId: z.string().optional().describe("Filter by specific campaign ID"),
bundleId: z.string().optional().describe("Filter by specific application bundle ID"),
platform: z.string().optional().describe("Filter by platform (e.g., ios, play)"),
campaignName: z.string().optional().describe("Filter by campaign name"),
country: z.string().optional().describe("Filter by country in ISO 3166-1 alpha-2 format (e.g., US, GB)"),
},
async ({ startDate, endDate, campaignId, bundleId, platform, campaignName, country }) => { // Tool execution logic
try {
const spendData = await fetchAppSamuraiData(
startDate,
endDate,
campaignId,
bundleId,
platform,
campaignName,
country
);
// Format the data nicely for the LLM/user
const formattedData = JSON.stringify(spendData, null, 2);
return {
content: [{
type: "text",
text: `Campaign spend data from ${startDate} to ${endDate}:\n\`\`\`json\n${formattedData}\n\`\`\``,
}],
};
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "An unknown error occurred while fetching campaign spend.";
console.error("Error in get_campaign_spend tool:", errorMessage);
return {
content: [{ type: "text", text: `Error fetching campaign spend: ${errorMessage}` }],
isError: true, // Indicate that the tool execution resulted in an error
};
}
}
);
// --- Prompt Definition ---
server.prompt(
"check_appsamurai_campaign_spend", // Prompt name
"Check campaign spending for a specific period through the AppSamurai Campaign Spend API.", // Prompt description
{ // Argument schema using Zod
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Start date (YYYY-MM-DD)"),
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("End date (YYYY-MM-DD)"),
campaignId: z.string().optional().describe("Filter by specific campaign ID"),
bundleId: z.string().optional().describe("Filter by specific application bundle ID"),
platform: z.string().optional().describe("Filter by platform (e.g., ios, play)"),
campaignName: z.string().optional().describe("Filter by campaign name"),
country: z.string().optional().describe("Filter by country in ISO 3166-1 alpha-2 format (e.g., US, GB)"),
},
({ startDate, endDate, campaignId, bundleId, platform, campaignName, country }) => {
// Build filters text based on provided optional parameters
const filters = [];
if (campaignId) filters.push(`campaign ID: ${campaignId}`);
if (bundleId) filters.push(`bundle ID: ${bundleId}`);
if (platform) filters.push(`platform: ${platform}`);
if (campaignName) filters.push(`campaign name: ${campaignName}`);
if (country) filters.push(`country: ${country}`);
const filtersText = filters.length > 0
? ` with filters: ${filters.join(', ')}`
: '';
return {
messages: [{
role: "user",
content: {
type: "text",
text: `Please retrieve and summarize the AppSamurai campaign spend data from ${startDate} to ${endDate}${filtersText}.`,
}
}],
};
}
);
// --- Run the Server ---
async function main() {
const transport = new StdioServerTransport();
try {
await server.connect(transport);
console.error("AppSamurai Reporting MCP Server running on stdio...");
} catch (error) {
console.error("Failed to start AppSamurai Reporting MCP Server:", error);
process.exit(1);
}
}
main();
```
--------------------------------------------------------------------------------
/src/ironsource-aura-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: "IronSource Aura Reporting MCP Server",
version: "0.0.1"
});
const IRONSOURCE_AURA_API_BASE_URL = process.env.IRONSOURCE_AURA_API_BASE_URL || "";
const IRONSOURCE_AURA_API_KEY = process.env.IRONSOURCE_AURA_API_KEY || '';
if (!IRONSOURCE_AURA_API_KEY) {
console.error("Missing IronSource Aura API credentials. Please set IRONSOURCE_AURA_API_KEY environment variable.");
process.exit(1);
}
/**
* Make request to IronSource Aura Reporting API with retry mechanism
*/
async function fetchIronSourceAuraReport(params: Record<string, string>, maxRetries = 3, baseInterval = 3000): Promise<any> {
let attempt = 0;
while (attempt < maxRetries) {
try {
console.error(`Requesting IronSource Aura report with params: ${JSON.stringify(params)}`);
const queryParams = new URLSearchParams(params);
const reportUrl = `${IRONSOURCE_AURA_API_BASE_URL}?${queryParams.toString()}`;
const response = await fetch(reportUrl, {
method: 'GET',
headers: {
'Accept': 'application/json; */*',
'Authorization': IRONSOURCE_AURA_API_KEY
}
});
if (!response.ok) {
const errorBody = await response.text();
console.error(`IronSource Aura API Error Response: ${response.status} ${response.statusText} - ${errorBody}`);
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
}
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
const data = await response.json();
return data.data || data; // Return data.data if it exists (matching Ruby implementation)
} else {
// If response is CSV or other format
return await response.text();
}
} catch (error: any) {
attempt++;
if (attempt >= maxRetries) {
console.error(`Error fetching IronSource Aura report after ${maxRetries} attempts:`, error);
throw new Error(`Failed to get IronSource Aura report: ${error.message}`);
}
// Exponential backoff with jitter
const delay = baseInterval * Math.pow(2, attempt - 1) * (0.5 + Math.random() * 0.5);
console.error(`Retrying in ${Math.round(delay / 1000)} seconds... (Attempt ${attempt} of ${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw new Error("Failed to fetch IronSource Aura report after maximum retries");
}
// Tool: Get Advertiser Report
server.tool("get_advertiser_report_from_aura",
"Get campaign spending data from Aura(IronSource) Reporting API for advertisers.",
{
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)"),
metrics: z.string().optional().default("impressions,clicks,completions,installs,spend").describe("Comma-separated list of metrics to include (default: 'impressions,clicks,completions,installs,spend')"),
breakdowns: z.string().optional().default("day,campaign_name").describe("Comma-separated list of breakdowns (default: 'day,campaign_name')"),
format: z.enum(["json", "csv"]).default("json").describe("Format of the report data"),
count: z.number().optional().describe("Number of records to return (default: 10000, max: 250000)"),
campaignId: z.string().optional().describe("Filter by comma-separated list of campaign IDs"),
bundleId: z.string().optional().describe("Filter by comma-separated list of bundle IDs"),
creativeId: z.string().optional().describe("Filter by comma-separated list of creative IDs"),
country: z.string().optional().describe("Filter by comma-separated list of countries (ISO 3166-2)"),
os: z.enum(["ios", "android"]).optional().describe("Filter by operating system (ios or android)"),
deviceType: z.enum(["phone", "tablet"]).optional().describe("Filter by device type"),
adUnit: z.string().optional().describe("Filter by ad unit type (e.g., 'rewardedVideo,interstitial')"),
order: z.string().optional().describe("Order results by breakdown/metric"),
direction: z.enum(["asc", "desc"]).optional().default("asc").describe("Order direction (asc or desc)")
}, async ({ startDate, endDate, metrics, breakdowns, format, count, campaignId, bundleId, creativeId, country, os, deviceType, adUnit, order, direction }) => {
try {
// Validate date range logic
if (new Date(startDate) > new Date(endDate)) {
throw new Error("Start date cannot be after end date.");
}
// Build parameters with defaults matching the Ruby implementation
const params: Record<string, string> = {
start_date: startDate, // Change to snake_case to match Ruby implementation
end_date: endDate, // Change to snake_case to match Ruby implementation
metrics: metrics || "impressions,clicks,completions,installs,spend",
breakdowns: breakdowns || "day,campaign_name", // Default to campaign_name instead of campaign
format: format || "json"
};
// Add optional parameters if provided
if (count) params.count = count.toString();
if (campaignId) params.campaignId = campaignId;
if (bundleId) params.bundleId = bundleId;
if (creativeId) params.creativeId = creativeId;
if (country) params.country = country;
if (os) params.os = os;
if (deviceType) params.deviceType = deviceType;
if (adUnit) params.adUnit = adUnit;
if (order) params.order = order;
if (direction) params.direction = direction;
const data = await fetchIronSourceAuraReport(params);
return {
content: [
{
type: "text",
text: typeof data === 'string' ? data : JSON.stringify(data, null, 2)
}
]
};
} catch (error: any) {
const errorMessage = `Error getting IronSource Aura advertiser 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("IronSource Aura Reporting MCP Server running on stdio");
}
runServer().catch((error) => {
console.error("Fatal error running server:", error);
process.exit(1);
});
```
--------------------------------------------------------------------------------
/src/github-issues/common/types.ts:
--------------------------------------------------------------------------------
```typescript
import { z } from "zod";
// Base schemas for common types
export const GitHubAuthorSchema = z.object({
name: z.string(),
email: z.string(),
date: z.string(),
});
export const GitHubOwnerSchema = z.object({
login: z.string(),
id: z.number(),
node_id: z.string(),
avatar_url: z.string(),
url: z.string(),
html_url: z.string(),
type: z.string(),
});
export const GitHubRepositorySchema = z.object({
id: z.number(),
node_id: z.string(),
name: z.string(),
full_name: z.string(),
private: z.boolean(),
owner: GitHubOwnerSchema,
html_url: z.string(),
description: z.string().nullable(),
fork: z.boolean(),
url: z.string(),
created_at: z.string(),
updated_at: z.string(),
pushed_at: z.string(),
git_url: z.string(),
ssh_url: z.string(),
clone_url: z.string(),
default_branch: z.string(),
});
export const GithubFileContentLinks = z.object({
self: z.string(),
git: z.string().nullable(),
html: z.string().nullable()
});
export const GitHubFileContentSchema = z.object({
name: z.string(),
path: z.string(),
sha: z.string(),
size: z.number(),
url: z.string(),
html_url: z.string(),
git_url: z.string(),
download_url: z.string(),
type: z.string(),
content: z.string().optional(),
encoding: z.string().optional(),
_links: GithubFileContentLinks
});
export const GitHubDirectoryContentSchema = z.object({
type: z.string(),
size: z.number(),
name: z.string(),
path: z.string(),
sha: z.string(),
url: z.string(),
git_url: z.string(),
html_url: z.string(),
download_url: z.string().nullable(),
});
export const GitHubContentSchema = z.union([
GitHubFileContentSchema,
z.array(GitHubDirectoryContentSchema),
]);
export const GitHubTreeEntrySchema = z.object({
path: z.string(),
mode: z.enum(["100644", "100755", "040000", "160000", "120000"]),
type: z.enum(["blob", "tree", "commit"]),
size: z.number().optional(),
sha: z.string(),
url: z.string(),
});
export const GitHubTreeSchema = z.object({
sha: z.string(),
url: z.string(),
tree: z.array(GitHubTreeEntrySchema),
truncated: z.boolean(),
});
export const GitHubCommitSchema = z.object({
sha: z.string(),
node_id: z.string(),
url: z.string(),
author: GitHubAuthorSchema,
committer: GitHubAuthorSchema,
message: z.string(),
tree: z.object({
sha: z.string(),
url: z.string(),
}),
parents: z.array(
z.object({
sha: z.string(),
url: z.string(),
})
),
});
export const GitHubListCommitsSchema = z.array(z.object({
sha: z.string(),
node_id: z.string(),
commit: z.object({
author: GitHubAuthorSchema,
committer: GitHubAuthorSchema,
message: z.string(),
tree: z.object({
sha: z.string(),
url: z.string()
}),
url: z.string(),
comment_count: z.number(),
}),
url: z.string(),
html_url: z.string(),
comments_url: z.string()
}));
export const GitHubReferenceSchema = z.object({
ref: z.string(),
node_id: z.string(),
url: z.string(),
object: z.object({
sha: z.string(),
type: z.string(),
url: z.string(),
}),
});
// User and assignee schemas
export const GitHubIssueAssigneeSchema = z.object({
login: z.string(),
id: z.number(),
avatar_url: z.string(),
url: z.string(),
html_url: z.string(),
});
// Issue-related schemas
export const GitHubLabelSchema = z.object({
id: z.number(),
node_id: z.string(),
url: z.string(),
name: z.string(),
color: z.string(),
default: z.boolean(),
description: z.string().nullable().optional(),
});
export const GitHubMilestoneSchema = z.object({
url: z.string(),
html_url: z.string(),
labels_url: z.string(),
id: z.number(),
node_id: z.string(),
number: z.number(),
title: z.string(),
description: z.string(),
state: z.string(),
});
export const GitHubIssueSchema = z.object({
url: z.string(),
repository_url: z.string(),
labels_url: z.string(),
comments_url: z.string(),
events_url: z.string(),
html_url: z.string(),
id: z.number(),
node_id: z.string(),
number: z.number(),
title: z.string(),
user: GitHubIssueAssigneeSchema,
labels: z.array(GitHubLabelSchema),
state: z.string(),
locked: z.boolean(),
assignee: GitHubIssueAssigneeSchema.nullable(),
assignees: z.array(GitHubIssueAssigneeSchema),
milestone: GitHubMilestoneSchema.nullable(),
comments: z.number(),
created_at: z.string(),
updated_at: z.string(),
closed_at: z.string().nullable(),
body: z.string().nullable(),
});
// Search-related schemas
export const GitHubSearchResponseSchema = z.object({
total_count: z.number(),
incomplete_results: z.boolean(),
items: z.array(GitHubRepositorySchema),
});
// Pull request schemas
export const GitHubPullRequestRefSchema = z.object({
label: z.string(),
ref: z.string(),
sha: z.string(),
user: GitHubIssueAssigneeSchema,
repo: GitHubRepositorySchema,
});
export const GitHubPullRequestSchema = z.object({
url: z.string(),
id: z.number(),
node_id: z.string(),
html_url: z.string(),
diff_url: z.string(),
patch_url: z.string(),
issue_url: z.string(),
number: z.number(),
state: z.string(),
locked: z.boolean(),
title: z.string(),
user: GitHubIssueAssigneeSchema,
body: z.string().nullable(),
created_at: z.string(),
updated_at: z.string(),
closed_at: z.string().nullable(),
merged_at: z.string().nullable(),
merge_commit_sha: z.string().nullable(),
assignee: GitHubIssueAssigneeSchema.nullable(),
assignees: z.array(GitHubIssueAssigneeSchema),
requested_reviewers: z.array(GitHubIssueAssigneeSchema),
labels: z.array(GitHubLabelSchema),
head: GitHubPullRequestRefSchema,
base: GitHubPullRequestRefSchema,
});
// Export types
export type GitHubAuthor = z.infer<typeof GitHubAuthorSchema>;
export type GitHubRepository = z.infer<typeof GitHubRepositorySchema>;
export type GitHubFileContent = z.infer<typeof GitHubFileContentSchema>;
export type GitHubDirectoryContent = z.infer<typeof GitHubDirectoryContentSchema>;
export type GitHubContent = z.infer<typeof GitHubContentSchema>;
export type GitHubTree = z.infer<typeof GitHubTreeSchema>;
export type GitHubCommit = z.infer<typeof GitHubCommitSchema>;
export type GitHubListCommits = z.infer<typeof GitHubListCommitsSchema>;
export type GitHubReference = z.infer<typeof GitHubReferenceSchema>;
export type GitHubIssueAssignee = z.infer<typeof GitHubIssueAssigneeSchema>;
export type GitHubLabel = z.infer<typeof GitHubLabelSchema>;
export type GitHubMilestone = z.infer<typeof GitHubMilestoneSchema>;
export type GitHubIssue = z.infer<typeof GitHubIssueSchema>;
export type GitHubSearchResponse = z.infer<typeof GitHubSearchResponseSchema>;
export type GitHubPullRequest = z.infer<typeof GitHubPullRequestSchema>;
export type GitHubPullRequestRef = z.infer<typeof GitHubPullRequestRefSchema>;
```
--------------------------------------------------------------------------------
/src/ironsource-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: "IronSource Reporting MCP Server",
version: "0.0.1"
});
const IRONSOURCE_API_BASE_URL = "https://api.ironsrc.com/advertisers/v2/reports";
const IRONSOURCE_AUTH_URL = "https://platform.ironsrc.com/partners/publisher/auth";
const IRONSOURCE_SECRET_KEY = process.env.IRONSOURCE_SECRET_KEY || '';
const IRONSOURCE_REFRESH_TOKEN = process.env.IRONSOURCE_REFRESH_TOKEN || '';
let bearerToken = '';
let tokenExpirationTime = 0;
if (!IRONSOURCE_SECRET_KEY || !IRONSOURCE_REFRESH_TOKEN) {
console.error("Missing IronSource API credentials. Please set IRONSOURCE_SECRET_KEY and IRONSOURCE_REFRESH_TOKEN environment variables.");
process.exit(1);
}
/**
* Get a valid Bearer token for IronSource API
*/
async function getIronSourceBearerToken(): Promise<string> {
// Check if token is still valid (with 5 min buffer)
const now = Math.floor(Date.now() / 1000);
if (bearerToken && tokenExpirationTime > now + 300) {
return bearerToken;
}
try {
console.error('Fetching new IronSource bearer token');
const response = await fetch(IRONSOURCE_AUTH_URL, {
method: 'GET',
headers: {
'secretkey': IRONSOURCE_SECRET_KEY,
'refreshToken': IRONSOURCE_REFRESH_TOKEN
}
});
if (!response.ok) {
const errorBody = await response.text();
console.error(`IronSource Auth Error: ${response.status} ${response.statusText} - ${errorBody}`);
throw new Error(`Auth failed: ${response.status} ${response.statusText}`);
}
const token = await response.text();
// Remove quotes if they exist in the response
bearerToken = token.replace(/"/g, '');
// Set expiration time (60 minutes from now)
tokenExpirationTime = now + 3600;
console.error('Successfully obtained IronSource bearer token');
return bearerToken;
} catch (error: any) {
console.error('Error obtaining IronSource bearer token:', error);
throw new Error(`Failed to get authentication token: ${error.message}`);
}
}
/**
* Make request to IronSource Reporting API
*/
async function fetchIronSourceReport(params: Record<string, string>) {
// Get a valid token
const token = await getIronSourceBearerToken();
// Construct URL with query parameters
const queryParams = new URLSearchParams(params);
const reportUrl = `${IRONSOURCE_API_BASE_URL}?${queryParams.toString()}`;
try {
console.error(`Requesting IronSource report with params: ${JSON.stringify(params)}`);
const response = await fetch(reportUrl, {
method: 'GET',
headers: {
'Accept': 'application/json; */*',
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
const errorBody = await response.text();
console.error(`IronSource API Error Response: ${response.status} ${response.statusText} - ${errorBody}`);
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
}
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return await response.json();
} else {
// If response is CSV or other format
return await response.text();
}
} catch (error: any) {
console.error(`Error fetching IronSource report:`, error);
throw new Error(`Failed to get IronSource report: ${error.message}`);
}
}
// Tool: Get Advertiser Report
server.tool("get_advertiser_report_from_ironsource",
"Get campaign spending data from IronSource Reporting API for advertisers.",
{
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)"),
metrics: z.string().optional().default("impressions,clicks,completions,installs,spend").describe("Comma-separated list of metrics to include (default: 'impressions,clicks,completions,installs,spend')"),
breakdowns: z.string().optional().default("day,campaign").describe("Comma-separated list of breakdowns (default: 'day,campaign')"),
format: z.enum(["json", "csv"]).default("json").describe("Format of the report data"),
count: z.number().optional().describe("Number of records to return (default: 10000, max: 250000)"),
campaignId: z.string().optional().describe("Filter by comma-separated list of campaign IDs"),
bundleId: z.string().optional().describe("Filter by comma-separated list of bundle IDs"),
creativeId: z.string().optional().describe("Filter by comma-separated list of creative IDs"),
country: z.string().optional().describe("Filter by comma-separated list of countries (ISO 3166-2)"),
os: z.enum(["ios", "android"]).optional().describe("Filter by operating system (ios or android)"),
deviceType: z.enum(["phone", "tablet"]).optional().describe("Filter by device type"),
adUnit: z.string().optional().describe("Filter by ad unit type (e.g., 'rewardedVideo,interstitial')"),
order: z.string().optional().describe("Order results by breakdown/metric"),
direction: z.enum(["asc", "desc"]).optional().default("asc").describe("Order direction (asc or desc)")
}, async ({ startDate, endDate, metrics, breakdowns, format, count, campaignId, bundleId, creativeId, country, os, deviceType, adUnit, order, direction }) => {
try {
// Validate date range logic
if (new Date(startDate) > new Date(endDate)) {
throw new Error("Start date cannot be after end date.");
}
// Build parameters with defaults matching the example query
const params: Record<string, string> = {
startDate,
endDate,
metrics: metrics || "impressions,clicks,completions,installs,spend",
breakdowns: breakdowns || "day,campaign",
format: format || "json"
};
// Add optional parameters if provided
if (count) params.count = count.toString();
if (campaignId) params.campaignId = campaignId;
if (bundleId) params.bundleId = bundleId;
if (creativeId) params.creativeId = creativeId;
if (country) params.country = country;
if (os) params.os = os;
if (deviceType) params.deviceType = deviceType;
if (adUnit) params.adUnit = adUnit;
if (order) params.order = order;
if (direction) params.direction = direction;
const data = await fetchIronSourceReport(params);
return {
content: [
{
type: "text",
text: typeof data === 'string' ? data : JSON.stringify(data, null, 2)
}
]
};
} catch (error: any) {
const errorMessage = `Error getting IronSource advertiser 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("IronSource Reporting MCP Server running on stdio");
}
runServer().catch((error) => {
console.error("Fatal error running server:", error);
process.exit(1);
});
```
--------------------------------------------------------------------------------
/src/mintegral-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 crypto from "crypto";
dotenv.config();
// ============= Server Setup =============
const server = new McpServer({
name: "mintegral-reporting",
version: "0.0.4"
});
// ============= Mintegral Implementation =============
const MINTEGRAL_API_HOST = "ss-api.mintegral.com";
const MINTEGRAL_API_PATH = "/api/v1/reports/data";
const MINTEGRAL_ACCESS_KEY = process.env.MINTEGRAL_ACCESS_KEY || '';
const MINTEGRAL_API_KEY = process.env.MINTEGRAL_API_KEY || '';
// Check credentials on startup
if (!MINTEGRAL_ACCESS_KEY || !MINTEGRAL_API_KEY) {
console.error("[Mintegral] Missing API credentials. Please set MINTEGRAL_ACCESS_KEY and MINTEGRAL_API_KEY environment variables.");
process.exit(1);
}
/**
* Get current timestamp in seconds (Unix timestamp)
*/
function getTimestamp(): number {
return Math.floor(Date.now() / 1000);
}
/**
* Generate token for Mintegral API authentication
*
* Token is Md5(API key.md5(timestamp)) as per documentation
*/
function getMintegralToken(timestamp: number): string {
const timestampMd5 = crypto.createHash('md5').update(timestamp.toString()).digest('hex');
const token = crypto.createHash('md5').update(MINTEGRAL_API_KEY + timestampMd5).digest('hex');
return token;
}
/**
* Validate Mintegral API parameters
*/
function validateMintegralParams(params: Record<string, any>): void {
const { start_date, end_date } = params;
// Check date format
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
if (!dateRegex.test(start_date) || !dateRegex.test(end_date)) {
throw new Error("Dates must be in YYYY-MM-DD format");
}
// Check date range
const startDate = new Date(start_date);
const endDate = new Date(end_date);
if (startDate > endDate) {
throw new Error("Start date cannot be after end date");
}
// Check if dates are valid
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
throw new Error("Invalid date values");
}
// Check if date range exceeds 8 days as per API docs
const dayDiff = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
if (dayDiff > 8) {
throw new Error("Date range cannot exceed 8 days for a single request");
}
// Check if per_page exceeds maximum
if (params.per_page && params.per_page > 5000) {
throw new Error("per_page cannot exceed 5000");
}
}
/**
* Make request to Mintegral Performance Reporting API
*/
async function fetchMintegralReport(params: Record<string, string>) {
try {
// Validate parameters
validateMintegralParams(params);
// Construct URL with query parameters
const queryParams = new URLSearchParams(params);
const reportUrl = `https://${MINTEGRAL_API_HOST}${MINTEGRAL_API_PATH}?${queryParams.toString()}`;
// Generate authentication headers based on documentation at:
// https://adv-new.mintegral.com/doc/en/guide/introduction/token.html
const timestamp = getTimestamp();
const token = getMintegralToken(timestamp);
console.error(`[Mintegral] Auth: timestamp=${timestamp}, token=${token}`);
console.error(`[Mintegral] Requesting report with params: ${JSON.stringify(params)}`);
const response = await fetch(reportUrl, {
method: 'GET',
headers: {
'Accept': 'application/json',
'access-key': MINTEGRAL_ACCESS_KEY,
'token': token,
'timestamp': timestamp.toString()
}
});
if (!response.ok) {
const errorBody = await response.text();
console.error(`[Mintegral] API Error Response: ${response.status} ${response.statusText} - ${errorBody}`);
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
}
const data = await response.json();
// Check for API error codes in the response
if (data.code && data.code !== 200) {
console.error(`[Mintegral] API Error Code: ${data.code}, Message: ${data.msg || 'Unknown error'}`);
throw new Error(`API returned error code ${data.code}: ${data.msg || 'Unknown error'}`);
}
return data;
} catch (error: any) {
console.error(`[Mintegral] Error fetching report:`, error);
throw new Error(`Failed to get Mintegral report: ${error.message}`);
}
}
// Tool: Get Mintegral Performance Report
server.tool("get_mintegral_performance_report",
"Get performance data from Mintegral Reporting API.",
{
start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Start date must be in YYYY-MM-DD format").describe("Start date for the report (YYYY-MM-DD)"),
end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "End date must be in YYYY-MM-DD format").describe("End date for the report (YYYY-MM-DD)"),
utc: z.string().optional().default("+8").describe("Timezone (default: '+8')"),
per_page: z.number().optional().default(50).describe("Number of results per page (max: 5000)"),
page: z.number().optional().default(1).describe("Page number"),
dimension: z.string().optional().describe("Data dimension (e.g., 'location', 'sub_id', 'creative')"),
uuid: z.string().optional().describe("Filter by uuid"),
campaign_id: z.number().optional().describe("Filter by campaign_id"),
package_name: z.string().optional().describe("Filter by android bundle id or ios app store id"),
not_empty_field: z.string().optional().describe("Fields that can't be empty (comma-separated: 'click', 'install', 'impression', 'spend')")
},
async (params) => {
try {
// Build parameters to match example request format
const apiParams: Record<string, string> = {};
// Add parameters in the order shown in the example
apiParams.start_date = params.start_date;
apiParams.end_date = params.end_date;
if (params.per_page) apiParams.per_page = params.per_page.toString();
if (params.page) apiParams.page = params.page.toString();
if (params.utc) apiParams.utc = params.utc;
if (params.dimension) apiParams.dimension = params.dimension;
// Add remaining optional parameters
if (params.uuid) apiParams.uuid = params.uuid;
if (params.campaign_id) apiParams.campaign_id = params.campaign_id.toString();
if (params.package_name) apiParams.package_name = params.package_name;
if (params.not_empty_field) apiParams.not_empty_field = params.not_empty_field;
const data = await fetchMintegralReport(apiParams);
return {
content: [
{
type: "text",
text: JSON.stringify(data, null, 2)
}
]
};
} catch (error: any) {
const errorMessage = `Error getting Mintegral performance report: ${error.message}`;
return {
content: [
{
type: "text",
text: errorMessage
}
],
isError: true
};
}
}
);
// ============= Server Startup =============
async function runServer() {
try {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("[Server] mintegral-reporting MCP Server running on stdio");
} catch (error) {
console.error("[Server] Error during server startup:", error);
process.exit(1);
}
}
runServer().catch((error) => {
console.error("[Server] Fatal error running server:", error);
process.exit(1);
});
```
--------------------------------------------------------------------------------
/src/femini-reporting/femini_mcp_guide.md:
--------------------------------------------------------------------------------
```markdown
# Femini Postgres Database Guide
This guide documents the Femini Postgres database schema and provides example queries for accessing campaign spend data via MCP (Model Context Protocol).
## MCP Server Configuration
The Femini Postgres database is accessible via an MCP server with the following configuration:
```json
{
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-postgres",
"postgres://mcp_read:gL%218iujL%29l2kKrY9Hn%24kjIow@pg-femini-for-mcp.cgb5t3jqdx7r.us-east-1.rds.amazonaws.com/femini"
]
}
```
## Database Schema
### Main Tables
#### campaign_spends
The primary table containing campaign spend data.
| Column | Data Type | Description |
|--------|-----------|-------------|
| id | bigint | Primary key |
| date | date | Date of the spend |
| spend | numeric | Amount spent |
| click_url_id | bigint | Reference to click_urls table |
| partner_id | bigint | Reference to partners table |
| client_id | bigint | Reference to clients table |
| spend_type | character varying | Type of spend (client or partner) |
| calculation_source | character varying | Source of calculation (imported or event_aggregate) |
| calculation_metadata | json | Additional metadata in JSON format |
| created_at | timestamp | Record creation timestamp |
| updated_at | timestamp | Record update timestamp |
#### campaigns
Contains information about marketing campaigns.
| Column | Data Type | Description |
|--------|-----------|-------------|
| id | bigint | Primary key |
| client_id | bigint | Reference to clients table |
| name | character varying | Campaign name |
| status | character varying | Campaign status |
| country_code | character varying | Country code for the campaign |
| mobile_app_id | bigint | Reference to mobile_apps table |
| legacy_id | bigint | Legacy ID reference |
| created_at | timestamp | Record creation timestamp |
| updated_at | timestamp | Record update timestamp |
#### clients
Contains information about clients.
| Column | Data Type | Description |
|--------|-----------|-------------|
| id | bigint | Primary key |
| name | character varying | Client name |
| website | character varying | Client website |
| is_test | boolean | Whether this is a test client |
| legacy_id | bigint | Legacy ID reference |
| created_at | timestamp | Record creation timestamp |
| updated_at | timestamp | Record update timestamp |
#### partners
Contains information about partners.
| Column | Data Type | Description |
|--------|-----------|-------------|
| id | bigint | Primary key |
| name | character varying | Partner name |
| email | character varying | Partner email |
| website | character varying | Partner website |
| description | text | Partner description |
| status | character varying | Partner status |
| is_test | boolean | Whether this is a test partner |
| legacy_id | bigint | Legacy ID reference |
| created_at | timestamp | Record creation timestamp |
| updated_at | timestamp | Record update timestamp |
#### click_urls
Contains information about click URLs.
| Column | Data Type | Description |
|--------|-----------|-------------|
| id | bigint | Primary key |
| campaign_id | bigint | Reference to campaigns table |
| partner_id | bigint | Reference to partners table |
| track_party_id | bigint | Reference to track_parties table |
| status | character varying | Click URL status |
| link_type | character varying | Type of link |
| external_track_party_campaign_id | character varying | External track party campaign ID |
| external_partner_campaign_id | character varying | External partner campaign ID |
| legacy_id | bigint | Legacy ID reference |
| created_at | timestamp | Record creation timestamp |
| updated_at | timestamp | Record update timestamp |
### Relationships
- `campaign_spends.client_id` → `clients.id`
- `campaign_spends.partner_id` → `partners.id`
- `campaign_spends.click_url_id` → `click_urls.id`
- `click_urls.campaign_id` → `campaigns.id`
- `click_urls.partner_id` → `partners.id`
- `campaigns.client_id` → `clients.id`
## Example Queries
### 1. List All Tables
```sql
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
ORDER BY table_name;
```
### 2. Explore Table Structure
```sql
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = 'campaign_spends'
ORDER BY ordinal_position;
```
### 3. Basic Campaign Spend Data
```sql
SELECT cs.id, cs.date, cs.spend, cs.spend_type,
c.name as client_name, p.name as partner_name
FROM campaign_spends cs
JOIN clients c ON cs.client_id = c.id
JOIN partners p ON cs.partner_id = p.id
ORDER BY cs.date DESC
LIMIT 10;
```
### 4. Campaign Spend with Campaign Information
```sql
SELECT cs.id, cs.date, cs.spend, cs.spend_type,
c.name as client_name, p.name as partner_name,
cam.name as campaign_name, cam.country_code
FROM campaign_spends cs
JOIN clients c ON cs.client_id = c.id
JOIN partners p ON cs.partner_id = p.id
LEFT JOIN click_urls cu ON cs.click_url_id = cu.id
LEFT JOIN campaigns cam ON cu.campaign_id = cam.id
ORDER BY cs.date DESC
LIMIT 10;
```
### 5. Aggregate Spend by Client and Partner
```sql
SELECT c.name as client_name, p.name as partner_name,
cs.spend_type, SUM(cs.spend) as total_spend
FROM campaign_spends cs
JOIN clients c ON cs.client_id = c.id
JOIN partners p ON cs.partner_id = p.id
WHERE cs.date >= '2025-04-01' AND cs.date <= '2025-04-30'
GROUP BY c.name, p.name, cs.spend_type
ORDER BY total_spend DESC
LIMIT 20;
```
### 6. Daily Spend Trends
```sql
SELECT date, SUM(spend) as total_spend, COUNT(*) as transaction_count
FROM campaign_spends
WHERE date >= '2025-04-01' AND date <= '2025-04-30'
GROUP BY date
ORDER BY date;
```
### 7. Spend by Country
```sql
SELECT cam.country_code, SUM(cs.spend) as total_spend
FROM campaign_spends cs
JOIN click_urls cu ON cs.click_url_id = cu.id
JOIN campaigns cam ON cu.campaign_id = cam.id
WHERE cs.date >= '2025-04-01' AND cs.date <= '2025-04-30'
GROUP BY cam.country_code
ORDER BY total_spend DESC;
```
### 8. Spend by Type
```sql
SELECT spend_type, SUM(spend) as total_spend
FROM campaign_spends
WHERE date >= '2025-04-01' AND date <= '2025-04-30'
GROUP BY spend_type
ORDER BY total_spend DESC;
```
### 9. Spend by Calculation Source
```sql
SELECT calculation_source, COUNT(*) as count, SUM(spend) as total_spend
FROM campaign_spends
WHERE date >= '2025-04-01' AND date <= '2025-04-30'
GROUP BY calculation_source
ORDER BY total_spend DESC;
```
## Using MCP to Query the Database
To query the Femini Postgres database using MCP in code:
```javascript
<use_mcp_tool>
<server_name>postgres</server_name>
<tool_name>query</tool_name>
<arguments>
{
"sql": "YOUR SQL QUERY HERE"
}
</arguments>
</use_mcp_tool>
```
## Query Optimization Tips
1. **Use specific date ranges** to limit the amount of data processed
2. **Include appropriate JOINs** only when needed
3. **Use aggregation** (GROUP BY) for summary data
4. **Limit results** when retrieving large datasets
5. **Order results** only when necessary
6. **Select only needed columns** instead of using SELECT *
## Common Analysis Tasks
1. **Monthly spend analysis**: Filter by date range and group by client, partner, or campaign
2. **Geographic performance**: Group by country_code to analyze regional performance
3. **Client/Partner comparison**: Compare spend and performance across different clients or partners
4. **Trend analysis**: Group by date to analyze spend trends over time
5. **Spend type analysis**: Compare client vs. partner spend
```
--------------------------------------------------------------------------------
/src/civitai-records/src/tools/listCivitaiPosts.ts:
--------------------------------------------------------------------------------
```typescript
import type { ContentResult } from "fastmcp";
import { z } from "zod";
import { prisma } from "../lib/prisma.js";
export const listCivitaiPostsParameters = z.object({
id: z
.string()
.nullable()
.default(null)
.describe("Filter by the civitai_posts table ID."),
civitai_id: z
.string()
.nullable()
.default(null)
.describe("Filter posts by Civitai ID. The numeric post ID from the Civitai post URL. Extract this from URLs like https://civitai.com/posts/23602354 where the ID is 23602354."),
status: z
.enum(["pending", "published", "failed"])
.nullable()
.default(null)
.describe("Filter posts by status: 'pending', 'published', or 'failed'."),
created_by: z
.string()
.nullable()
.default(null)
.transform((val) => val?.toLowerCase() ?? null)
.describe("Filter posts by creator name. Default is null to load records created by all users."),
on_behalf_of: z
.string()
.nullable()
.default(null)
.transform((val) => val?.toLowerCase() ?? null)
.describe("Filter posts by the user account this action was performed on behalf of. Default is null to load records regardless of on_behalf_of value."),
start_time: z
.string()
.nullable()
.default(null)
.describe("Filter posts created on or after this timestamp. Provide as ISO 8601 format (e.g., '2025-01-15T10:00:00Z')."),
end_time: z
.string()
.nullable()
.default(null)
.describe("Filter posts created on or before this timestamp. Provide as ISO 8601 format (e.g., '2025-01-15T23:59:59Z')."),
include_details: z
.boolean()
.default(false)
.describe("If true, include related asset and prompt details in the response. Default is false for lightweight responses."),
limit: z
.number()
.int()
.min(1)
.max(100)
.default(50)
.describe("Maximum number of posts to return. Default is 50, maximum is 100."),
offset: z
.number()
.int()
.min(0)
.default(0)
.describe("Number of posts to skip for pagination. Default is 0."),
});
export type ListCivitaiPostsParameters = z.infer<typeof listCivitaiPostsParameters>;
interface WhereClauseParams {
id: string | null;
civitai_id: string | null;
status: "pending" | "published" | "failed" | null;
created_by: string | null;
on_behalf_of: string | null;
start_time: string | null;
end_time: string | null;
}
function buildWhereClause(params: WhereClauseParams): any {
const where: any = {};
if (params.id) {
where.id = BigInt(params.id);
}
if (params.civitai_id) {
where.civitai_id = params.civitai_id;
}
if (params.status) {
where.status = params.status;
}
if (params.created_by) {
where.created_by = params.created_by;
}
if (params.on_behalf_of) {
where.on_behalf_of = params.on_behalf_of;
}
if (params.start_time || params.end_time) {
where.created_at = {};
if (params.start_time) {
try {
where.created_at.gte = new Date(params.start_time);
} catch (error) {
throw new Error("Invalid start_time format. Use ISO 8601 format (e.g., '2025-01-15T10:00:00Z').");
}
}
if (params.end_time) {
try {
where.created_at.lte = new Date(params.end_time);
} catch (error) {
throw new Error("Invalid end_time format. Use ISO 8601 format (e.g., '2025-01-15T23:59:59Z').");
}
}
}
return where;
}
function serializePost(post: any, include_details: boolean): any {
const result: any = {
post_id: post.id.toString(),
civitai_id: post.civitai_id,
civitai_url: post.civitai_url,
status: post.status,
title: post.title,
description: post.description,
created_by: post.created_by,
metadata: post.metadata,
created_at: post.created_at.toISOString(),
updated_at: post.updated_at.toISOString(),
};
if (include_details && post.assets && post.assets.length > 0) {
result.assets = post.assets.map((asset: any) => ({
asset_id: asset.id.toString(),
asset_type: asset.asset_type,
asset_source: asset.asset_source,
asset_url: asset.uri,
sha256sum: asset.sha256sum,
civitai_id: asset.civitai_id,
civitai_url: asset.civitai_url,
created_by: asset.created_by,
metadata: asset.metadata,
created_at: asset.created_at.toISOString(),
updated_at: asset.updated_at.toISOString(),
input_prompt: asset.prompts_assets_input_prompt_idToprompts ? {
prompt_id: asset.prompts_assets_input_prompt_idToprompts.id.toString(),
content: asset.prompts_assets_input_prompt_idToprompts.content,
llm_model_provider: asset.prompts_assets_input_prompt_idToprompts.llm_model_provider,
llm_model: asset.prompts_assets_input_prompt_idToprompts.llm_model,
purpose: asset.prompts_assets_input_prompt_idToprompts.purpose,
metadata: asset.prompts_assets_input_prompt_idToprompts.metadata,
created_by: asset.prompts_assets_input_prompt_idToprompts.created_by,
created_at: asset.prompts_assets_input_prompt_idToprompts.created_at.toISOString(),
updated_at: asset.prompts_assets_input_prompt_idToprompts.updated_at.toISOString(),
} : null,
output_prompt: asset.prompts_assets_output_prompt_idToprompts ? {
prompt_id: asset.prompts_assets_output_prompt_idToprompts.id.toString(),
content: asset.prompts_assets_output_prompt_idToprompts.content,
llm_model_provider: asset.prompts_assets_output_prompt_idToprompts.llm_model_provider,
llm_model: asset.prompts_assets_output_prompt_idToprompts.llm_model,
purpose: asset.prompts_assets_output_prompt_idToprompts.purpose,
metadata: asset.prompts_assets_output_prompt_idToprompts.metadata,
created_by: asset.prompts_assets_output_prompt_idToprompts.created_by,
created_at: asset.prompts_assets_output_prompt_idToprompts.created_at.toISOString(),
updated_at: asset.prompts_assets_output_prompt_idToprompts.updated_at.toISOString(),
} : null,
}));
}
return result;
}
export const listCivitaiPostsTool = {
name: "list_civitai_posts",
description: "Get a list of Civitai posts with optional filtering. Can filter by civitai_id, status, created_by, or time range. Supports pagination with limit and offset. Use include_details to get linked asset information.",
parameters: listCivitaiPostsParameters,
execute: async ({
id,
civitai_id,
status,
created_by,
on_behalf_of,
start_time,
end_time,
include_details,
limit,
offset,
}: ListCivitaiPostsParameters): Promise<ContentResult> => {
const where = buildWhereClause({
id,
civitai_id,
status,
created_by,
on_behalf_of,
start_time,
end_time,
});
const posts = await prisma.civitai_posts.findMany({
where,
include: include_details ? {
assets: {
include: {
prompts_assets_input_prompt_idToprompts: true,
prompts_assets_output_prompt_idToprompts: true,
},
},
} : undefined,
take: limit,
skip: offset,
orderBy: {
created_at: 'desc',
},
});
const serializedPosts = posts.map((post: any) => serializePost(post, include_details));
return {
content: [
{
type: "text",
text: JSON.stringify({
posts: serializedPosts,
count: posts.length,
limit,
offset,
}, null, 2),
},
],
} satisfies ContentResult;
},
};
```
--------------------------------------------------------------------------------
/src/civitai-records/src/prompts/civitai-media-engagement.md:
--------------------------------------------------------------------------------
```markdown
# Civitai Media Engagement Guide
This guide helps you find and analyze engagement metrics for Civitai media, especially videos, using the available tools.
## Overview
Civitai posts contain media assets (images and videos) with engagement metrics including:
- **Reactions**: likes, hearts, laughs, cries, dislikes
- **Comments**: comment count
## Tools for Finding Media Engagement
### 1. `fetch_civitai_post_assets`
**Primary tool for getting live engagement data directly from Civitai.**
**Purpose**: Fetches real-time media assets and their engagement stats for a specific post without querying the local database.
**When to use**:
- You have a Civitai post URL and want to see current engagement
- You need up-to-date performance metrics
- You want to inspect video URLs and metadata
**Input**:
```json
{
"post_id": "23602354",
"limit": 50,
"page": 1
}
```
**Output includes**:
- `civitai_image_id`: Unique identifier for each media asset
- `asset_url`: Direct download URL for the video/image
- `type`: Media type (e.g., "video", "image")
- `engagement_stats`:
- `like`: Number of likes
- `heart`: Number of hearts
- `laugh`: Number of laughs
- `cry`: Number of cries
- `dislike`: Number of dislikes
- `comment`: Number of comments
- `dimensions`: Width and height (when available)
- `created_at`: When the asset was uploaded
- `username`: Creator username
- `nsfw`: NSFW flag and level
**Example workflow**:
```json
{
"post_id": "23602354"
}
```
Response shows all videos in the post with their current engagement metrics.
### 2. `list_civitai_posts`
**Secondary tool for browsing recorded posts and their stored asset data.**
**Purpose**: Query the local database for posts you've previously recorded.
**When to use**:
- You want to see what posts are already in your database
- You need to filter by status, creator, or time range
- You want to get stored asset information (use `include_details: true`)
**Input**:
```json
{
"civitai_id": "23602354",
"include_details": true
}
```
**Note**: This returns stored data from your database, not live Civitai data. For current engagement metrics, use `fetch_civitai_post_assets`.
### 3. `find_asset`
**Tertiary tool for looking up specific assets in your database.**
**Purpose**: Find a single asset by ID, SHA256 hash, Civitai ID, or post ID.
**When to use**:
- You have an asset's Civitai image ID and want to check if it's recorded locally
- You need full details about a specific asset including linked prompts and posts
**Input**:
```json
{
"civitai_id": "106432973"
}
```
**Note**: This queries your local database. Engagement metrics are only available if you stored them in the `metadata` field when creating/updating the asset.
## Step-by-Step: Finding Media Engagement
### Scenario 1: You have a Civitai post URL
**Goal**: Get engagement metrics for all videos in the post.
1. **Extract the post ID** from the URL:
- URL: `https://civitai.com/posts/23602354`
- Post ID: `23602354`
2. **Fetch live engagement data**:
```json
{
"post_id": "23602354",
"limit": 100
}
```
Use `fetch_civitai_post_assets` to get all media assets.
3. **Analyze the data**:
- Total engagement = sum of all reaction types
- Most popular videos = highest like or heart counts
- Controversial content = high dislike or mix of reactions
### Scenario 2: You have a Civitai video/image URL
**Goal**: Get engagement for a specific video or image.
1. **Extract the media ID** from the URL:
- URL: `https://civitai.com/images/106432973`
- Media ID: `106432973`
2. **Check if it's in your database**:
```json
{
"civitai_id": "106432973"
}
```
Use `find_asset` to see if you've recorded it.
3. **Get the post ID** from the result (if found):
- Look for `post.civitai_id` in the response
4. **Fetch live engagement**:
```json
{
"post_id": "<post_civitai_id>"
}
```
Use `fetch_civitai_post_assets` and find the matching `civitai_image_id`.
## Engagement Metrics Explained
### Reaction Types
- **Like** 👍: Standard positive reaction
- **Heart** ❤️: Strong positive reaction, often indicates favorite content
- **Laugh** 😂: Humorous or entertaining content
- **Cry** 😢: Emotional or touching content
- **Dislike** 👎: Negative reaction
- **Comment** 💬: Discussion and engagement depth
### Interpreting Engagement
- **High engagement**: Large total reaction count relative to views
- **Positive ratio**: (likes + hearts + laughs) / (total reactions)
- **Controversy score**: dislikes / total reactions
- **Discussion depth**: comments / total reactions
## Best Practices
1. **Always use `fetch_civitai_post_assets` for current data**
- Don't rely on stored database values for live engagement
- The database stores historical snapshots, not real-time data
2. **Extract post IDs from URLs correctly**
- Post URL: `https://civitai.com/posts/XXXXXX` → `XXXXXX`
- Image URL: `https://civitai.com/images/YYYYYY` → need to find parent post
3. **Handle pagination for large posts**
- Default limit is 50, maximum is 100
- Use `page` parameter to fetch additional assets
- Check `asset_count` to know if there are more pages
## Common Workflows
### Find Top-Performing Media
1. Fetch all assets from multiple posts
2. Filter by type if desired (e.g., `type === "video"` or `type === "image"`)
3. Sort by engagement metrics (e.g., likes + hearts)
4. Identify patterns in high-performing content
### Compare Video vs Image Engagement
1. Fetch assets from posts with mixed media
2. Separate by type
3. Calculate average engagement per type
4. Analyze which format performs better for your content
### Audit Your Content Library
1. Use `list_civitai_posts` with `include_details: true`
2. Get stored post_ids from your database
3. Fetch current engagement for each using `fetch_civitai_post_assets`
### Analyze Engagement by Creator
**Goal**: Get total engagement metrics across all posts from a specific creator.
1. **List all posts by creator**:
```json
{
"created_by": "username",
"limit": 100
}
```
Use `list_civitai_posts` to get all posts from the creator.
2. **Extract post IDs**:
- From the response, collect all `civitai_id` values
- These are the post IDs you'll need for fetching engagement
3. **Fetch engagement for each post**:
- For each `civitai_id` from step 2, call `fetch_civitai_post_assets`:
```json
{
"post_id": "<civitai_id>"
}
```
4. **Aggregate the metrics**:
- Sum all engagement stats (likes, hearts, comments, etc.) across all posts
- Calculate average engagement per post
- Identify the creator's top-performing content
- Analyze engagement trends over time (use `created_at` field)
**Example analysis**:
- Total reactions across all creator's posts
- Average likes per video/image
- Most engaged post by this creator
- Engagement distribution by content type (video vs image)
## Troubleshooting
**"Failed to fetch assets from Civitai"**
- Verify the post ID is correct (numeric only)
- Check if the post is public and accessible
- Ensure you have internet connectivity
**"No assets found"**
- The post might have been deleted or made private
- Try accessing the post URL in a browser to verify
- Check if pagination is needed (increase `page` parameter)
**"Asset not in database"**
- Use `fetch_civitai_post_assets` to get data directly from Civitai
- The asset might not be recorded locally yet
- Use the returned data to create a new asset record
## Summary
To find Civitai media (video/image) engagement:
1. **Have a post URL?** → Extract ID → `fetch_civitai_post_assets`
2. **Have a video/image URL?** → Extract ID → `find_asset` → get post ID → `fetch_civitai_post_assets`
3. **Want stored data?** → `list_civitai_posts` with `include_details: true`
4. **Need real-time metrics?** → Always use `fetch_civitai_post_assets`
```
--------------------------------------------------------------------------------
/src/civitai-records/src/tools/createAsset.ts:
--------------------------------------------------------------------------------
```typescript
import type { ContentResult } from "fastmcp";
import { z } from "zod";
import { prisma } from "../lib/prisma.js";
import { sha256 } from "../lib/sha256.js";
import { detectRemoteAssetType, type RemoteAssetTypeResult } from "../lib/detectRemoteAssetType.js";
import { handleDatabaseError } from "../lib/handleDatabaseError.js";
const metadataSchema = z.record(z.any()).nullable().default(null);
/**
* Validate that the provided asset type matches the detected type.
* Throws an error with a helpful message if there's a mismatch.
*/
function validateAssetType(
providedType: "image" | "video",
detectionResult: RemoteAssetTypeResult,
url: string
): void {
const { assetType: detectedType, mime, from: detectionMethod } = detectionResult;
if (!detectedType) {
// Unable to detect, allow the provided type
console.warn(`Unable to detect asset type for URL: ${url}. Accepting provided type: ${providedType}`);
return;
}
if (detectedType === providedType) {
console.log(`Asset type '${providedType}' confirmed via ${detectionMethod}`);
return;
}
// Mismatch detected - throw error with helpful message
const errorDetails = mime
? `Detected type: '${detectedType}' (MIME: ${mime}, via ${detectionMethod})`
: `Detected type: '${detectedType}' (via ${detectionMethod})`;
throw new Error(
`Asset type mismatch for URL: ${url}\n` +
`Provided: '${providedType}'\n` +
`${errorDetails}\n` +
`Please update the asset_type parameter to '${detectedType}' and try again.`
);
}
/**
* Parse and validate an ID string parameter to BigInt.
* Returns null if the input is null/empty.
*/
function parseIdParameter(id: string | null, parameterName: string): bigint | null {
if (!id) {
return null;
}
const trimmed = id.trim();
if (!trimmed) {
return null;
}
try {
return BigInt(trimmed);
} catch (error) {
throw new Error(`Invalid ${parameterName}: must be a valid integer ID`);
}
}
/**
* Parse and validate an array of ID strings to BigInt array.
* Returns null if the input is null/empty.
*/
function parseIdArrayParameter(ids: string[] | null, parameterName: string): bigint[] | null {
if (!ids || ids.length === 0) {
return null;
}
try {
return ids.map((id, index) => {
const trimmed = id.trim();
if (!trimmed) {
throw new Error(`Empty ID at index ${index}`);
}
return BigInt(trimmed);
});
} catch (error) {
throw new Error(`Invalid ${parameterName}: ${error instanceof Error ? error.message : 'must contain valid integer IDs'}`);
}
}
export const createAssetParameters = z.object({
asset_url: z
.string()
.min(1)
.describe("The actual resource storage URL. Can be from original source or Civitai CDN. Example: 'https://image.civitai.com/.../video.mp4' or 'https://storage.example.com/image.png'"),
asset_type: z
.enum(["image", "video"])
.describe("The type of media asset. Choose 'image' or 'video' based on the content type."),
asset_source: z
.enum(["generated", "upload"])
.describe("How this asset was created. Use 'generated' for AI-generated content or 'upload' for user-uploaded files."),
input_prompt_id: z
.string()
.nullable()
.default(null)
.describe("The ID of the prompt that was used to generate this asset. If you generated content from a prompt, first call create_prompt to save the prompt and get its ID, then use that ID here to link the asset to its source prompt. Leave empty for uploaded assets or content not generated from a prompt."),
output_prompt_id: z
.string()
.nullable()
.default(null)
.describe("The ID of a prompt that was derived from or describes this asset. For example, if you used an LLM to caption this image or extract a description from the generated content, save that caption/description as a prompt using create_prompt and link it here."),
civitai_id: z
.string()
.nullable()
.default(null)
.describe("The Civitai image ID for this asset if it has been uploaded to Civitai. Extract from the Civitai URL, e.g., for 'https://civitai.com/images/106432973' the ID is '106432973'."),
civitai_url: z
.string()
.nullable()
.default(null)
.describe("The Civitai page URL for this asset if it has been uploaded to Civitai. Example: 'https://civitai.com/images/106432973'."),
post_id: z
.string()
.nullable()
.default(null)
.describe("The ID from the civitai_posts table that this asset is associated with. Use create_civitai_post tool to create a post first if needed."),
input_asset_ids: z
.array(z.string())
.nullable()
.default(null)
.describe("Array of asset IDs that were used as inputs to generate this asset. For example, if this is a video generated from multiple images, list those image asset IDs here. Leave empty for assets that weren't generated from other assets."),
metadata: metadataSchema.describe("Additional information about this asset in JSON format. Can include technical details (resolution, duration, file size), generation parameters, quality scores, or any custom data relevant to this asset."),
on_behalf_of: z
.string()
.nullable()
.default(null)
.describe("The user account this action is being performed on behalf of. If not provided, defaults to the authenticated database user and can be updated later."),
});
export type CreateAssetParameters = z.infer<typeof createAssetParameters>;
export const createAssetTool = {
name: "create_asset",
description: "Save a generated or uploaded media asset (video, image) to the database. Use this after creating content to track what was generated, where it's stored, and link it back to the original prompt that created it. IMPORTANT: The asset_type parameter is validated against the actual file content by checking HTTP headers, content sniffing, and URL patterns. If there's a mismatch (e.g., you specify 'video' but the URL is an image), the tool will return an error telling you the correct type to use.",
parameters: createAssetParameters,
execute: async ({ asset_url, asset_type, asset_source, input_prompt_id, output_prompt_id, civitai_id, civitai_url, post_id, input_asset_ids, metadata, on_behalf_of }: CreateAssetParameters): Promise<ContentResult> => {
// Detect and validate asset type
const detectionResult = await detectRemoteAssetType(asset_url, { skipRemote: false, timeout: 5000 });
validateAssetType(asset_type, detectionResult, asset_url);
// Parse ID parameters
const inputPromptId = parseIdParameter(input_prompt_id, 'input_prompt_id');
const outputPromptId = parseIdParameter(output_prompt_id, 'output_prompt_id');
const postIdBigInt = parseIdParameter(post_id, 'post_id');
const inputAssetIds = parseIdArrayParameter(input_asset_ids, 'input_asset_ids');
const sha256sum = await sha256(asset_url);
const asset = await prisma.assets.create({
data: {
uri: asset_url,
sha256sum,
asset_type: asset_type,
asset_source,
input_prompt_id: inputPromptId,
output_prompt_id: outputPromptId,
civitai_id: civitai_id?.trim() || null,
civitai_url: civitai_url?.trim() || null,
post_id: postIdBigInt,
input_asset_ids: inputAssetIds ?? undefined,
metadata: metadata ?? undefined,
on_behalf_of: on_behalf_of ?? undefined,
},
}).catch(error => handleDatabaseError(error, `URL: ${asset_url}`));
return {
content: [
{
type: "text",
text: JSON.stringify({
asset_id: asset.id.toString(),
asset_type: asset.asset_type,
asset_source: asset.asset_source,
uri: asset.uri,
sha256sum: asset.sha256sum,
civitai_id: asset.civitai_id,
civitai_url: asset.civitai_url,
post_id: asset.post_id?.toString() ?? null,
input_prompt_id: asset.input_prompt_id?.toString() ?? null,
output_prompt_id: asset.output_prompt_id?.toString() ?? null,
input_asset_ids: asset.input_asset_ids.map((id: bigint) => id.toString()),
on_behalf_of: asset.on_behalf_of,
created_at: asset.created_at.toISOString(),
}, null, 2),
},
],
} satisfies ContentResult;
},
};
```
--------------------------------------------------------------------------------
/src/tapjoy-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: "Tapjoy GraphQL Reporting MCP Server",
version: "0.0.5"
});
const TAPJOY_API_BASE_URL = "https://api.tapjoy.com";
const TAPJOY_API_KEY = process.env.TAPJOY_API_KEY || '';
if (!TAPJOY_API_KEY) {
console.error("Missing Tapjoy API credentials. Please set TAPJOY_API_KEY environment variable.");
process.exit(1);
}
// Token cache
let accessToken: string | null = null;
let tokenExpiry = 0;
/**
* Get valid Tapjoy API access token using the API Key
*/
async function getTapjoyAccessToken(): Promise<string> {
// Check if we have a valid token
if (accessToken && Date.now() < tokenExpiry) {
return accessToken;
}
// Request new token
const authUrl = `${TAPJOY_API_BASE_URL}/v1/oauth2/token`;
try {
console.error("Requesting new Tapjoy access token...");
const response = await fetch(authUrl, {
method: 'POST',
headers: {
'Authorization': `Basic ${TAPJOY_API_KEY}`,
'Accept': 'application/json; */*'
}
});
if (!response.ok) {
const errorBody = await response.text();
console.error(`Tapjoy Authentication Error Response: ${response.status} ${response.statusText} - ${errorBody}`);
throw new Error(`Authentication failed: ${response.status} ${response.statusText}`);
}
const data = await response.json() as { access_token: string; expires_in: number };
if (!data.access_token) {
console.error("Tapjoy Authentication Response missing access_token:", data);
throw new Error('Failed to get access token: Token is empty in response');
}
accessToken = data.access_token;
// Set expiry time with 1 minute buffer (tokens last 1 hour = 3600s)
tokenExpiry = Date.now() + (data.expires_in - 60) * 1000;
console.error("Successfully obtained new Tapjoy access token.");
return accessToken;
} catch (error: any) {
console.error("Error fetching Tapjoy access token:", error);
// Reset token info on failure
accessToken = null;
tokenExpiry = 0;
throw new Error(`Failed to get Tapjoy access token: ${error.message}`);
}
}
/**
* Generates the GraphQL query string for advertiser ad set spend.
*/
function getAdvertiserAdSetSpendQuery(startDate: string, endDate: string): string {
// Add 1 day to end date for the 'until' parameter as per Ruby example
const untilDate = new Date(endDate);
untilDate.setDate(untilDate.getDate() + 1);
const untilDateString = untilDate.toISOString().split('T')[0]; // Format as YYYY-MM-DD
// Ensure Z(ulu) timezone indicator for UTC
const startTime = `${startDate}T00:00:00Z`;
const untilTime = `${untilDateString}T00:00:00Z`;
return `
query {
advertiser {
adSets(configuredStatus: ACTIVE, first: 50) {
nodes {
campaign {
name
}
insights(timeRange: {from: "${startTime}", until: "${untilTime}"}) {
reports {
spend
}
}
}
}
}
}
`;
}
/**
* Make authenticated GraphQL request to Tapjoy API
*/
async function makeTapjoyGraphqlRequest(query: string) {
const token = await getTapjoyAccessToken();
const graphqlUrl = `${TAPJOY_API_BASE_URL}/graphql`;
try {
const response = await fetch(graphqlUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'Accept': 'application/json; */*'
},
body: JSON.stringify({ query })
});
if (!response.ok) {
// Handle specific Tapjoy error codes if needed, e.g., 401 for expired token
if (response.status === 401) {
console.warn("Tapjoy token likely expired or invalid, attempting to refresh...");
accessToken = null; // Force token refresh on next call
throw new Error(`GraphQL request failed: ${response.status} ${response.statusText} (Unauthorized - check API key or token may have expired)`);
}
const errorBody = await response.text();
console.error(`Tapjoy GraphQL API Error Response: ${response.status} ${response.statusText} - ${errorBody}`);
throw new Error(`GraphQL request failed: ${response.status} ${response.statusText}`);
}
const responseData = await response.json();
// Check for GraphQL errors within the response body
if (responseData.errors && responseData.errors.length > 0) {
console.error("Tapjoy GraphQL Query Errors:", JSON.stringify(responseData.errors, null, 2));
throw new Error(`GraphQL query failed: ${responseData.errors.map((e: any) => e.message).join(', ')}`);
}
return responseData.data; // Return the actual data part
} catch (error: any) {
console.error(`Error making Tapjoy GraphQL request to ${graphqlUrl}:`, error);
// If it's an auth error, reset token
if (error.message.includes("401")) {
accessToken = null;
tokenExpiry = 0;
}
throw error; // Re-throw the error to be caught by the tool handler
}
}
// Tool: Get Advertiser Ad Set Spend using GraphQL API
server.tool("get_advertiser_adset_spend",
"Get spend for active advertiser ad sets within a date range using the Tapjoy GraphQL API.",
{
start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Start date must be in YYYY-MM-DD format").describe("Start date for the report (YYYY-MM-DD)"),
end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "End date must be in YYYY-MM-DD format").describe("End date for the report (YYYY-MM-DD)")
}, async ({ start_date, end_date }) => {
try {
// Validate date range logic if necessary (e.g., start_date <= end_date)
if (new Date(start_date) > new Date(end_date)) {
throw new Error("Start date cannot be after end date.");
}
const query = getAdvertiserAdSetSpendQuery(start_date, end_date);
const data = await makeTapjoyGraphqlRequest(query);
// Extract the relevant nodes as per Ruby example
const adSetNodes = data?.advertiser?.adSets?.nodes;
let processedResults: any[] = []; // Initialize array for processed results
if (Array.isArray(adSetNodes)) {
processedResults = adSetNodes.map(node => {
const campaignName = node?.campaign?.name ?? 'Unknown Campaign';
// Safely access nested spend value
const rawSpend = node?.insights?.reports?.[0]?.spend?.[0];
let spendUSD = 0; // Default to 0 if spend is not found or invalid
if (typeof rawSpend === 'number') {
spendUSD = rawSpend / 1000000; // Convert micro-dollars to USD
} else if (rawSpend != null) {
console.warn(`Invalid spend value found for campaign ${campaignName}:`, rawSpend);
}
return {
campaign: { name: campaignName },
insights: {
reports: [ { spendUSD: spendUSD } ] // Use spendUSD key
}
};
});
if (processedResults.length === 0) {
console.warn("No ad set nodes with spend data found after processing.");
}
} else {
console.warn("Tapjoy GraphQL response structure might have changed or no adSetNodes array found. Full data:", JSON.stringify(data, null, 2));
// Optionally return raw data or an empty array if no nodes found
processedResults = data ?? {}; // Fallback to returning raw data or empty object
}
return {
content: [
{
type: "text",
// Return the processed results with USD spend
text: JSON.stringify(processedResults, null, 2)
}
]
};
} catch (error: any) {
let errorMessage = `Error getting Tapjoy advertiser ad set spend: ${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("Tapjoy GraphQL Reporting MCP Server running on stdio");
}
runServer().catch((error) => {
console.error("Fatal error running server:", error);
process.exit(1);
});
```