This is page 2 of 5. Use http://codebase.md/feed-mob/fm-mcp-servers?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .claude
│ └── settings.local.json
├── .cursor
│ └── rules
│ └── typescript-indentation.mdc
├── .github
│ └── workflows
│ ├── publish-civitai-records.yml
│ ├── publish-github-issues.yml
│ ├── publish-imagekit.yml
│ ├── publish-n8n-nodes-feedmob-direct-spend-visualizer.yml
│ ├── publish-reporting-packages.yml
│ └── publish-work-journals.yml
├── .gitignore
├── AGENTS.md
├── package-lock.json
├── README.md
├── src
│ ├── applovin-reporting
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── appsamurai-reporting
│ │ ├── .env.example
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── civitai-records
│ │ ├── .gitignore
│ │ ├── build.sh
│ │ ├── docker-compose.yml
│ │ ├── docs
│ │ │ └── civitai-owner-createrole.md
│ │ ├── env.sample
│ │ ├── infra
│ │ │ └── db-init
│ │ │ ├── 01-roles.sql
│ │ │ ├── 02_functions.sql
│ │ │ ├── 03_init.sql
│ │ │ ├── 04_create_prompts.sql
│ │ │ ├── 05_create_assets.sql
│ │ │ ├── 06_create_civitai_posts.sql
│ │ │ ├── 07_add_constraints_and_input_assets.sql
│ │ │ ├── 08_migration_add_on_behalf_of.sql
│ │ │ ├── 09_migration_update_functions_for_on_behalf_of.sql
│ │ │ ├── 10_update_on_behalf_of_for_existing_records.sql
│ │ │ ├── 11_create_asset_stats.sql
│ │ │ ├── 12_fix_trigger_for_tables_without_on_behalf_of.sql
│ │ │ └── 13_add_columns_to_asset_stats.sql
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── prisma
│ │ │ └── schema.prisma
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── lib
│ │ │ │ ├── __tests__
│ │ │ │ │ ├── detectRemoteAssetType.test.ts
│ │ │ │ │ └── sha256.test.ts
│ │ │ │ ├── civitaiApi.ts
│ │ │ │ ├── detectRemoteAssetType.ts
│ │ │ │ ├── handleDatabaseError.ts
│ │ │ │ ├── prisma.ts
│ │ │ │ └── sha256.ts
│ │ │ ├── prompts
│ │ │ │ ├── civitai-media-engagement.md
│ │ │ │ ├── civitaiMediaEngagement.ts
│ │ │ │ ├── record-civitai-workflow.md
│ │ │ │ └── recordCivitaiWorkflow.ts
│ │ │ ├── server.ts
│ │ │ └── tools
│ │ │ ├── calculateSha256.ts
│ │ │ ├── createAsset.ts
│ │ │ ├── createCivitaiPost.ts
│ │ │ ├── createPrompt.ts
│ │ │ ├── fetchCivitaiPostAssets.ts
│ │ │ ├── findAsset.ts
│ │ │ ├── getMediaEngagementGuide.ts
│ │ │ ├── getWorkflowGuide.ts
│ │ │ ├── listCivitaiPosts.ts
│ │ │ ├── syncPostAssetStats.ts
│ │ │ └── updateAsset.ts
│ │ └── tsconfig.json
│ ├── feedmob-reporting
│ │ ├── .env.example
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── api.ts
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── femini-reporting
│ │ ├── Dockerfile
│ │ ├── femini_mcp_guide.md
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── github-issues
│ │ ├── common
│ │ │ ├── errors.ts
│ │ │ ├── types.ts
│ │ │ ├── utils.ts
│ │ │ └── version.ts
│ │ ├── index.ts
│ │ ├── operations
│ │ │ ├── issues.ts
│ │ │ └── search.ts
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ └── tsconfig.json
│ ├── imagekit
│ │ ├── env.sample
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── server.ts
│ │ │ ├── services
│ │ │ │ ├── imageKitUpload.ts
│ │ │ │ └── imageUploader.ts
│ │ │ └── tools
│ │ │ ├── cropAndWatermark.ts
│ │ │ └── uploadFile.ts
│ │ └── tsconfig.json
│ ├── impact-radius-reporting
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── fm_impact_radius_mapping.ts
│ │ │ └── index.ts
│ │ ├── tsconfig.json
│ │ └── yarn.lock
│ ├── inmobi-reporting
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── ironsource-aura-reporting
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── ironsource-reporting
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── jampp-reporting
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── kayzen-reporting
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── index.ts
│ │ │ └── kayzen-client.ts
│ │ └── tsconfig.json
│ ├── liftoff-reporting
│ │ ├── .env.example
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── mintegral-reporting
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── n8n-nodes-feedmob-direct-spend-visualizer
│ │ ├── AGENTS.md
│ │ ├── credentials
│ │ │ └── FeedmobDirectSpendVisualizerApi.credentials.ts
│ │ ├── index.ts
│ │ ├── nodes
│ │ │ └── FeedmobDirectSpendVisualizer
│ │ │ ├── FeedmobDirectSpendVisualizer.node.ts
│ │ │ └── logo.svg
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── scripts
│ │ │ ├── build-assets.js
│ │ │ └── setup-plugin.js
│ │ ├── tsconfig.json
│ │ └── types.d.ts
│ ├── n8n-nodes-sensor-tower
│ │ ├── credentials
│ │ │ └── SensorTowerApi.credentials.ts
│ │ ├── index.ts
│ │ ├── MCP_to_n8n_Guide_zh.md
│ │ ├── nodes
│ │ │ └── SensorTower
│ │ │ ├── logo.svg
│ │ │ └── SensorTower.node.ts
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── tsconfig.json
│ │ └── types.d.ts
│ ├── rtb-house-reporting
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── samsung-reporting
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── sensor-tower-reporting
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── singular-reporting
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── smadex-reporting
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── tapjoy-reporting
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── user-activity-reporting
│ │ ├── .env.example
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── api.ts
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ └── work-journals
│ ├── package-lock.json
│ ├── package.json
│ ├── README.md
│ ├── src
│ │ └── index.ts
│ └── tsconfig.json
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/src/n8n-nodes-feedmob-direct-spend-visualizer/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "@feedmob/n8n-nodes-feedmob-direct-spend-visualizer",
3 | "version": "0.1.3",
4 | "description": "n8n nodes for orchestrating FeedMob direct spend visualizations through Claude Agent SDK plugins",
5 | "keywords": [
6 | "n8n-community-node-package",
7 | "n8n",
8 | "feedmob",
9 | "direct-spend",
10 | "claude-agent-sdk"
11 | ],
12 | "author": "FeedMob",
13 | "license": "MIT",
14 | "homepage": "https://github.com/feed-mob/fm-mcp-servers",
15 | "bugs": "https://github.com/feed-mob/fm-mcp-servers/issues",
16 | "main": "dist/index.js",
17 | "scripts": {
18 | "postinstall": "node scripts/setup-plugin.js",
19 | "build": "tsc && node scripts/build-assets.js",
20 | "dev": "tsc --watch",
21 | "prepare": "npm run build"
22 | },
23 | "n8n": {
24 | "nodes": [
25 | "./dist/nodes/FeedmobDirectSpendVisualizer/FeedmobDirectSpendVisualizer.node.js"
26 | ],
27 | "credentials": [
28 | "./dist/credentials/FeedmobDirectSpendVisualizerApi.credentials.js"
29 | ]
30 | },
31 | "publishConfig": {
32 | "access": "public"
33 | },
34 | "dependencies": {
35 | "@anthropic-ai/claude-agent-sdk": "^0.1.49"
36 | },
37 | "devDependencies": {
38 | "@types/node": "^20.16.5",
39 | "n8n-core": "^1.68.0",
40 | "n8n-workflow": "^1.68.0",
41 | "typescript": "^5.8.2"
42 | },
43 | "engines": {
44 | "node": ">=18.17.0"
45 | },
46 | "files": [
47 | "dist",
48 | "scripts/setup-plugin.js",
49 | "scripts/build-assets.js"
50 | ]
51 | }
52 |
```
--------------------------------------------------------------------------------
/src/civitai-records/src/tools/getMediaEngagementGuide.ts:
--------------------------------------------------------------------------------
```typescript
1 | import type { ContentResult } from "fastmcp";
2 | import { z } from "zod";
3 | import * as fs from "fs";
4 | import * as path from "path";
5 | import { fileURLToPath } from "url";
6 |
7 | const __filename = fileURLToPath(import.meta.url);
8 | const __dirname = path.dirname(__filename);
9 |
10 | export const getMediaEngagementGuideParameters = z.object({});
11 |
12 | export type GetMediaEngagementGuideParameters = z.infer<
13 | typeof getMediaEngagementGuideParameters
14 | >;
15 |
16 | export const getMediaEngagementGuideTool = {
17 | name: "get_media_engagement_guide",
18 | description:
19 | "Get the comprehensive guide for finding and analyzing Civitai media (videos and images) engagement metrics. This guide explains how to use find_asset, list_civitai_posts, and fetch_civitai_post_assets to retrieve engagement data like likes, hearts, comments, and other reactions for videos and images on Civitai.",
20 | parameters: getMediaEngagementGuideParameters,
21 | execute: async (
22 | _params: GetMediaEngagementGuideParameters
23 | ): Promise<ContentResult> => {
24 | const guidePath = path.join(
25 | __dirname,
26 | "..",
27 | "prompts",
28 | "civitai-media-engagement.md"
29 | );
30 | const content = await fs.promises.readFile(guidePath, "utf-8");
31 |
32 | return {
33 | content: [
34 | {
35 | type: "text",
36 | text: content,
37 | },
38 | ],
39 | } satisfies ContentResult;
40 | },
41 | };
42 |
```
--------------------------------------------------------------------------------
/src/n8n-nodes-feedmob-direct-spend-visualizer/types.d.ts:
--------------------------------------------------------------------------------
```typescript
1 | declare module 'n8n-workflow' {
2 | export interface INodeProperties {
3 | displayName: string;
4 | name: string;
5 | type: string;
6 | default: unknown;
7 | required?: boolean;
8 | description?: string;
9 | placeholder?: string;
10 | typeOptions?: Record<string, unknown>;
11 | displayOptions?: Record<string, unknown>;
12 | options?: Array<{ name: string; value: string; description?: string; action?: string }>;
13 | }
14 | export interface INodeTypeDescription {
15 | displayName: string;
16 | name: string;
17 | icon?: string;
18 | group: string[];
19 | version: number;
20 | subtitle?: string;
21 | description?: string;
22 | defaults: { name: string };
23 | inputs: string[];
24 | outputs: string[];
25 | credentials?: Array<{ name: string; required: boolean }>;
26 | properties: INodeProperties[];
27 | }
28 | export interface INodeExecutionData { json: any }
29 | export interface IExecuteFunctions {
30 | getInputData(): INodeExecutionData[];
31 | getNodeParameter(name: string, itemIndex: number, fallback?: unknown): unknown;
32 | getCredentials(name: string): Promise<Record<string, unknown>>;
33 | }
34 | export interface INodeType {
35 | description: INodeTypeDescription;
36 | execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]>;
37 | }
38 |
39 | export interface ICredentialType {
40 | name: string;
41 | displayName: string;
42 | properties: INodeProperties[];
43 | }
44 | }
45 |
46 |
47 |
```
--------------------------------------------------------------------------------
/src/n8n-nodes-sensor-tower/types.d.ts:
--------------------------------------------------------------------------------
```typescript
1 | declare module 'n8n-workflow' {
2 | export interface INodeProperties {
3 | displayName: string;
4 | name: string;
5 | type: string;
6 | default: unknown;
7 | required?: boolean;
8 | description?: string;
9 | placeholder?: string;
10 | typeOptions?: Record<string, unknown>;
11 | displayOptions?: Record<string, unknown>;
12 | options?: Array<{ name: string; value: string; description?: string; action?: string }>;
13 | }
14 | export interface INodeTypeDescription {
15 | displayName: string;
16 | name: string;
17 | icon?: string;
18 | group: string[];
19 | version: number;
20 | subtitle?: string;
21 | description?: string;
22 | defaults: { name: string };
23 | inputs: string[];
24 | outputs: string[];
25 | credentials?: Array<{ name: string; required: boolean }>;
26 | properties: INodeProperties[];
27 | }
28 | export interface INodeExecutionData { json: any }
29 | export interface IExecuteFunctions {
30 | getInputData(): INodeExecutionData[];
31 | getNodeParameter(name: string, itemIndex: number, fallback?: unknown): unknown;
32 | getCredentials(name: string): Promise<Record<string, unknown>>;
33 | }
34 | export interface INodeType {
35 | description: INodeTypeDescription;
36 | execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]>;
37 | }
38 |
39 | export interface ICredentialType {
40 | name: string;
41 | displayName: string;
42 | properties: INodeProperties[];
43 | }
44 | }
45 |
46 |
47 |
```
--------------------------------------------------------------------------------
/src/github-issues/operations/search.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { z } from "zod";
2 | import { githubRequest, buildUrl } from "../common/utils.js";
3 |
4 | export const SearchOptions = z.object({
5 | q: z.string(),
6 | order: z.enum(["asc", "desc"]).optional(),
7 | page: z.number().min(1).optional(),
8 | per_page: z.number().min(1).max(100).optional(),
9 | });
10 |
11 | export const SearchUsersOptions = SearchOptions.extend({
12 | sort: z.enum(["followers", "repositories", "joined"]).optional(),
13 | });
14 |
15 | export const SearchIssuesOptions = SearchOptions.extend({
16 | sort: z.enum([
17 | "comments",
18 | "reactions",
19 | "reactions-+1",
20 | "reactions--1",
21 | "reactions-smile",
22 | "reactions-thinking_face",
23 | "reactions-heart",
24 | "reactions-tada",
25 | "interactions",
26 | "created",
27 | "updated",
28 | ]).optional(),
29 | });
30 |
31 | export const SearchCodeSchema = SearchOptions;
32 | export const SearchUsersSchema = SearchUsersOptions;
33 | export const SearchIssuesSchema = SearchIssuesOptions;
34 |
35 | export async function searchCode(params: z.infer<typeof SearchCodeSchema>) {
36 | return githubRequest(buildUrl("https://api.github.com/search/code", params));
37 | }
38 |
39 | export async function searchIssues(params: z.infer<typeof SearchIssuesSchema>) {
40 | return githubRequest(buildUrl("https://api.github.com/search/issues", params));
41 | }
42 |
43 | export async function searchUsers(params: z.infer<typeof SearchUsersSchema>) {
44 | return githubRequest(buildUrl("https://api.github.com/search/users", params));
45 | }
46 |
```
--------------------------------------------------------------------------------
/src/imagekit/src/services/imageUploader.ts:
--------------------------------------------------------------------------------
```typescript
1 | export interface ImageUploadRequestBase {
2 | /**
3 | * File content to upload. Accepts a base64 string, binary buffer, or remote URL.
4 | */
5 | file: string;
6 | /** Target filename consumers expect when retrieving the asset. */
7 | fileName: string;
8 | /** Optional folder or path hint for the provider. */
9 | folder?: string;
10 | /** Optional tags to attach to the uploaded asset. */
11 | tags?: string[];
12 | }
13 |
14 | export type ImageUploadRequest<TOptions = Record<string, unknown>> =
15 | ImageUploadRequestBase & {
16 | /** Provider-specific options (e.g., privacy flags, overwrite behaviour). */
17 | options?: TOptions;
18 | };
19 |
20 | export interface ImageUploadResultBase {
21 | /** Provider-generated identifier for the stored asset. */
22 | id?: string;
23 | /** Resolved URL that callers can use to fetch the asset. */
24 | url?: string;
25 | /** Provider-assigned name for the stored asset. */
26 | name?: string;
27 | /** Arbitrary metadata exposed by the provider. */
28 | metadata?: Record<string, unknown>;
29 | }
30 |
31 | export type ImageUploadResult<
32 | TProviderData = Record<string, unknown>,
33 | > = ImageUploadResultBase & {
34 | /** Original provider payload for callers that need full fidelity. */
35 | providerData?: TProviderData;
36 | };
37 |
38 | export interface ImageUploader<
39 | Request extends ImageUploadRequest = ImageUploadRequest,
40 | Result extends ImageUploadResult = ImageUploadResult,
41 | > {
42 | upload(request: Request, signal?: AbortSignal): Promise<Result>;
43 | }
44 |
```
--------------------------------------------------------------------------------
/src/civitai-records/infra/db-init/05_create_assets.sql:
--------------------------------------------------------------------------------
```sql
1 | -- =========================================================
2 | -- 05_create_assets.sql
3 | -- Define civitai.assets and register it for grants/triggers/RLS
4 | -- =========================================================
5 |
6 | SET ROLE civitai_owner;
7 |
8 | CREATE TYPE civitai.asset_type AS ENUM ('image', 'video');
9 | CREATE TYPE civitai.asset_source AS ENUM ('generated', 'upload');
10 |
11 | CREATE TABLE civitai.assets (
12 | id bigserial PRIMARY KEY,
13 | input_prompt_id bigint REFERENCES civitai.prompts(id) NULL,
14 | output_prompt_id bigint REFERENCES civitai.prompts(id) NULL,
15 | asset_type civitai.asset_type NOT NULL,
16 | asset_source civitai.asset_source NOT NULL,
17 | uri text NOT NULL,
18 | sha256sum text NOT NULL,
19 | civitai_id text,
20 | civitai_url text,
21 | post_id bigint,
22 | metadata jsonb,
23 | created_by text NOT NULL DEFAULT current_user,
24 | updated_by text NOT NULL DEFAULT current_user,
25 | created_at timestamptz NOT NULL DEFAULT now(),
26 | updated_at timestamptz NOT NULL DEFAULT now()
27 | );
28 |
29 | -- Permissive mode: any user can update any record
30 | SELECT civitai.register_audited_table(
31 | p_table := 'civitai.assets'::regclass,
32 | p_grant_role := 'civitai_user',
33 | p_id_col := 'id',
34 | p_rls_mode := 'permissive',
35 | p_block_delete := true,
36 | p_protect_audit_cols := true
37 | );
38 |
39 | RESET ROLE;
40 |
```
--------------------------------------------------------------------------------
/src/imagekit/src/server.ts:
--------------------------------------------------------------------------------
```typescript
1 | #!/usr/bin/env node
2 |
3 | import "dotenv/config";
4 | import { FastMCP } from "fastmcp";
5 | import { z } from "zod";
6 |
7 | import {
8 | aspectRatioSchema,
9 | cropAndWatermarkImage,
10 | } from "./tools/cropAndWatermark.js";
11 | import { createUploadFileTool } from "./tools/uploadFile.js";
12 |
13 | const server = new FastMCP({
14 | name: "feedmob-imagekit",
15 | version: "1.0.0",
16 | });
17 |
18 | const imageKitPrivateKey = process.env.IMAGEKIT_PRIVATE_KEY;
19 |
20 | server.addTool({
21 | name: "crop_and_watermark_image",
22 | description:
23 | "Crop an image to a target aspect ratio, optionally add a watermark, and upload to ImageKit when configured.",
24 | parameters: z.object({
25 | imageUrl: z.string().url(),
26 | aspectRatio: aspectRatioSchema,
27 | watermarkText: z
28 | .string()
29 | .trim()
30 | .max(200)
31 | .optional()
32 | .default(""),
33 | }),
34 | execute: async ({ imageUrl, aspectRatio, watermarkText }) => {
35 | const apiKey = process.env.IMAGE_TOOL_API_KEY;
36 | const apiBaseUrl = process.env.IMAGE_TOOL_BASE_URL;
37 | const modelId = process.env.IMAGE_TOOL_MODEL_ID;
38 |
39 | if (!apiKey) {
40 | throw new Error("IMAGE_TOOL_API_KEY is not configured");
41 | }
42 |
43 | const generatedUrl = await cropAndWatermarkImage({
44 | imageUrl,
45 | aspectRatio,
46 | watermarkText,
47 | apiKey,
48 | apiBaseUrl,
49 | modelId,
50 | imageKit: imageKitPrivateKey
51 | ? {
52 | config: { privateKey: imageKitPrivateKey },
53 | }
54 | : undefined,
55 | });
56 |
57 | return generatedUrl;
58 | },
59 | });
60 |
61 | server.addTool(createUploadFileTool({ imageKitPrivateKey }));
62 |
63 | server.start({ transportType: "stdio" });
64 |
```
--------------------------------------------------------------------------------
/src/civitai-records/infra/db-init/07_add_constraints_and_input_assets.sql:
--------------------------------------------------------------------------------
```sql
1 | -- =========================================================
2 | -- 07_add_constraints_and_input_assets.sql
3 | -- Add unique constraints and input_asset_ids column
4 | -- =========================================================
5 |
6 | SET ROLE civitai_owner;
7 |
8 | -- 1. Add unique constraint to civitai_posts.civitai_id to avoid duplications
9 | ALTER TABLE civitai.civitai_posts
10 | ADD CONSTRAINT civitai_posts_civitai_id_unique UNIQUE (civitai_id);
11 |
12 | -- 2. Add unique constraints to assets table
13 | -- Note: sha256sum should be unique per asset to prevent duplicate content
14 | ALTER TABLE civitai.assets
15 | ADD CONSTRAINT assets_sha256sum_unique UNIQUE (sha256sum);
16 |
17 | -- Note: civitai_id should be unique when not null (partial unique index)
18 | -- This allows multiple NULL values but ensures uniqueness for non-NULL values
19 | CREATE UNIQUE INDEX assets_civitai_id_unique
20 | ON civitai.assets (civitai_id)
21 | WHERE civitai_id IS NOT NULL;
22 |
23 | -- 3. Add input_asset_ids column to track input assets used to generate this asset
24 | -- This creates a many-to-many relationship where an asset can reference multiple input assets
25 | ALTER TABLE civitai.assets
26 | ADD COLUMN input_asset_ids bigint[] NOT NULL DEFAULT '{}';
27 |
28 | -- Add comment to explain the column
29 | COMMENT ON COLUMN civitai.assets.input_asset_ids IS
30 | 'Array of asset IDs that were used as inputs to generate this asset. For example, if this is a video generated from multiple images, those image asset IDs would be listed here.';
31 |
32 | -- Create an index to support queries filtering by input_asset_ids
33 | CREATE INDEX assets_input_asset_ids_idx
34 | ON civitai.assets USING GIN (input_asset_ids);
35 |
36 | RESET ROLE;
37 |
```
--------------------------------------------------------------------------------
/src/civitai-records/infra/db-init/01-roles.sql:
--------------------------------------------------------------------------------
```sql
1 | -- =========================================================
2 | -- 01_roles.sql
3 | -- Roles, schema, base grants, default privileges, events table
4 | -- =========================================================
5 |
6 | -- 1) Roles
7 | CREATE ROLE civitai_owner NOLOGIN CREATEROLE;
8 | CREATE ROLE civitai_user NOLOGIN;
9 |
10 | GRANT civitai_user TO civitai_owner WITH ADMIN OPTION;
11 | GRANT civitai_owner TO CURRENT_USER; -- membership enables SET ROLE (required for AWS RDS)
12 |
13 | -- 2) Schema (owned by civitai_owner)
14 | CREATE SCHEMA civitai AUTHORIZATION civitai_owner;
15 |
16 | -- 3) Base schema privileges
17 | REVOKE ALL ON SCHEMA civitai FROM PUBLIC;
18 | GRANT USAGE ON SCHEMA civitai TO civitai_user;
19 |
20 | -- 4) Default privileges for future tables created by civitai_owner
21 | ALTER DEFAULT PRIVILEGES FOR ROLE civitai_owner IN SCHEMA civitai
22 | GRANT SELECT, INSERT, UPDATE ON TABLES TO civitai_user;
23 | ALTER DEFAULT PRIVILEGES FOR ROLE civitai_owner IN SCHEMA civitai
24 | REVOKE DELETE ON TABLES FROM civitai_user;
25 |
26 | -- 5) Audit events table (append-only; readable by app users)
27 | SET ROLE civitai_owner;
28 |
29 | CREATE TABLE civitai.events (
30 | id bigserial PRIMARY KEY,
31 | occurred_at timestamptz NOT NULL DEFAULT now(),
32 | actor text NOT NULL, -- DB user who performed the change
33 | table_name text NOT NULL, -- e.g. 'prompts'
34 | op text NOT NULL, -- 'INSERT' | 'UPDATE'
35 | row_id bigint NOT NULL, -- assumes bigint PK 'id' on target tables
36 | old_data jsonb,
37 | new_data jsonb
38 | );
39 |
40 | GRANT SELECT ON civitai.events TO civitai_user;
41 | REVOKE INSERT, UPDATE, DELETE ON civitai.events FROM civitai_user;
42 |
43 | RESET ROLE;
44 |
```
--------------------------------------------------------------------------------
/src/civitai-records/src/tools/calculateSha256.ts:
--------------------------------------------------------------------------------
```typescript
1 | import type { ContentResult } from "fastmcp";
2 | import { z } from "zod";
3 | import { sha256 } from "../lib/sha256.js";
4 |
5 | export const calculateSha256Parameters = z.object({
6 | path: z
7 | .string()
8 | .describe("The file path (local file system path) or URL (http/https) to calculate SHA-256 hash for."),
9 | });
10 |
11 | export type CalculateSha256Parameters = z.infer<typeof calculateSha256Parameters>;
12 |
13 | export const calculateSha256Tool = {
14 | name: "calculate_sha256",
15 | description: "Calculate SHA-256 hash for a file from a local path or URL.",
16 | parameters: calculateSha256Parameters,
17 | execute: async ({ path }: CalculateSha256Parameters): Promise<ContentResult> => {
18 | try {
19 | const trimmedPath = path.trim();
20 | const sha256sum = await sha256(trimmedPath);
21 |
22 | const sourceType = trimmedPath.startsWith("http://") || trimmedPath.startsWith("https://")
23 | ? "url"
24 | : "file";
25 |
26 | return {
27 | content: [
28 | {
29 | type: "text",
30 | text: JSON.stringify({
31 | success: true,
32 | path: trimmedPath,
33 | source_type: sourceType,
34 | sha256sum,
35 | }, null, 2),
36 | },
37 | ],
38 | } satisfies ContentResult;
39 | } catch (error) {
40 | const errorMessage = error instanceof Error ? error.message : String(error);
41 |
42 | return {
43 | content: [
44 | {
45 | type: "text",
46 | text: JSON.stringify({
47 | success: false,
48 | path: path.trim(),
49 | error: errorMessage,
50 | }, null, 2),
51 | },
52 | ],
53 | } satisfies ContentResult;
54 | }
55 | },
56 | };
57 |
```
--------------------------------------------------------------------------------
/src/civitai-records/src/server.ts:
--------------------------------------------------------------------------------
```typescript
1 | #!/usr/bin/env node
2 |
3 | import "dotenv/config";
4 | import { FastMCP } from "fastmcp";
5 |
6 | import { createPromptTool } from "./tools/createPrompt.js";
7 | import { createAssetTool } from "./tools/createAsset.js";
8 | import { updateAssetTool } from "./tools/updateAsset.js";
9 | import { findAssetTool } from "./tools/findAsset.js";
10 | import { calculateSha256Tool } from "./tools/calculateSha256.js";
11 | import { createCivitaiPostTool } from "./tools/createCivitaiPost.js";
12 | import { listCivitaiPostsTool } from "./tools/listCivitaiPosts.js";
13 | import { getWorkflowGuideTool } from "./tools/getWorkflowGuide.js";
14 | import { fetchCivitaiPostAssetsTool } from "./tools/fetchCivitaiPostAssets.js";
15 | import { getMediaEngagementGuideTool } from "./tools/getMediaEngagementGuide.js";
16 | import { syncPostAssetStatsTool } from "./tools/syncPostAssetStats.js";
17 | import { recordCivitaiWorkflowPrompt } from "./prompts/recordCivitaiWorkflow.js";
18 | import { civitaiMediaEngagementPrompt } from "./prompts/civitaiMediaEngagement.js";
19 |
20 | const server = new FastMCP({
21 | name: "feedmob-civitai-records",
22 | version: "0.1.0",
23 | });
24 |
25 | server.addPrompt(recordCivitaiWorkflowPrompt);
26 | server.addPrompt(civitaiMediaEngagementPrompt);
27 |
28 | server.addTool(getWorkflowGuideTool);
29 | server.addTool(getMediaEngagementGuideTool);
30 | server.addTool(createPromptTool);
31 | server.addTool(createAssetTool);
32 | server.addTool(updateAssetTool);
33 | server.addTool(findAssetTool);
34 | server.addTool(calculateSha256Tool);
35 | server.addTool(createCivitaiPostTool);
36 | server.addTool(listCivitaiPostsTool);
37 | server.addTool(fetchCivitaiPostAssetsTool);
38 | server.addTool(syncPostAssetStatsTool);
39 |
40 | server.start({ transportType: "stdio" });
41 |
```
--------------------------------------------------------------------------------
/src/civitai-records/src/lib/sha256.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { createReadStream } from 'node:fs';
2 | import { createHash } from 'node:crypto';
3 | import type { BinaryLike } from 'node:crypto';
4 | import { Readable } from 'node:stream';
5 | import type { ReadableStream as WebReadableStream } from 'node:stream/web';
6 |
7 | const SUPPORTED_PROTOCOLS = new Set(['http:', 'https:']);
8 |
9 | async function digestStream(stream: NodeJS.ReadableStream): Promise<string> {
10 | const hash = createHash('sha256');
11 |
12 | for await (const chunk of stream) {
13 | hash.update(chunk as BinaryLike);
14 | }
15 |
16 | return hash.digest('hex');
17 | }
18 |
19 | export async function sha256FromFile(filePath: string): Promise<string> {
20 | const stream = createReadStream(filePath);
21 |
22 | try {
23 | return await digestStream(stream);
24 | } catch (error) {
25 | const message = error instanceof Error ? error.message : String(error);
26 | throw new Error(`Failed to calculate SHA-256 for file "${filePath}": ${message}`);
27 | }
28 | }
29 |
30 | export async function sha256FromUrl(input: string): Promise<string> {
31 | type FetchResponse = Awaited<ReturnType<typeof fetch>>;
32 | let response: FetchResponse;
33 |
34 | try {
35 | response = await fetch(input);
36 | } catch (error) {
37 | const message = error instanceof Error ? error.message : String(error);
38 | throw new Error(`Failed to fetch "${input}" while calculating SHA-256: ${message}`);
39 | }
40 |
41 | if (!response.ok) {
42 | throw new Error(`Failed to download "${input}" (status ${response.status})`);
43 | }
44 |
45 | if (!response.body) {
46 | throw new Error(`Response for "${input}" does not contain a readable body.`);
47 | }
48 |
49 | const stream = Readable.fromWeb(response.body as unknown as WebReadableStream<Uint8Array>);
50 |
51 | try {
52 | return await digestStream(stream);
53 | } catch (error) {
54 | const message = error instanceof Error ? error.message : String(error);
55 | throw new Error(`Failed to calculate SHA-256 for remote resource "${input}": ${message}`);
56 | }
57 | }
58 |
59 | export async function sha256(resource: string): Promise<string> {
60 | if (isHttpUrl(resource)) {
61 | return sha256FromUrl(resource);
62 | }
63 |
64 | return sha256FromFile(resource);
65 | }
66 |
67 | function isHttpUrl(resource: string): boolean {
68 | try {
69 | const { protocol } = new URL(resource);
70 | return SUPPORTED_PROTOCOLS.has(protocol);
71 | } catch (_error) {
72 | return false;
73 | }
74 | }
75 |
```
--------------------------------------------------------------------------------
/src/github-issues/common/errors.ts:
--------------------------------------------------------------------------------
```typescript
1 | export class GitHubError extends Error {
2 | constructor(
3 | message: string,
4 | public readonly status: number,
5 | public readonly response: unknown
6 | ) {
7 | super(message);
8 | this.name = "GitHubError";
9 | }
10 | }
11 |
12 | export class GitHubValidationError extends GitHubError {
13 | constructor(message: string, status: number, response: unknown) {
14 | super(message, status, response);
15 | this.name = "GitHubValidationError";
16 | }
17 | }
18 |
19 | export class GitHubResourceNotFoundError extends GitHubError {
20 | constructor(resource: string) {
21 | super(`Resource not found: ${resource}`, 404, { message: `${resource} not found` });
22 | this.name = "GitHubResourceNotFoundError";
23 | }
24 | }
25 |
26 | export class GitHubAuthenticationError extends GitHubError {
27 | constructor(message = "Authentication failed") {
28 | super(message, 401, { message });
29 | this.name = "GitHubAuthenticationError";
30 | }
31 | }
32 |
33 | export class GitHubPermissionError extends GitHubError {
34 | constructor(message = "Insufficient permissions") {
35 | super(message, 403, { message });
36 | this.name = "GitHubPermissionError";
37 | }
38 | }
39 |
40 | export class GitHubRateLimitError extends GitHubError {
41 | constructor(
42 | message = "Rate limit exceeded",
43 | public readonly resetAt: Date
44 | ) {
45 | super(message, 429, { message, reset_at: resetAt.toISOString() });
46 | this.name = "GitHubRateLimitError";
47 | }
48 | }
49 |
50 | export class GitHubConflictError extends GitHubError {
51 | constructor(message: string) {
52 | super(message, 409, { message });
53 | this.name = "GitHubConflictError";
54 | }
55 | }
56 |
57 | export function isGitHubError(error: unknown): error is GitHubError {
58 | return error instanceof GitHubError;
59 | }
60 |
61 | export function createGitHubError(status: number, response: any): GitHubError {
62 | switch (status) {
63 | case 401:
64 | return new GitHubAuthenticationError(response?.message);
65 | case 403:
66 | return new GitHubPermissionError(response?.message);
67 | case 404:
68 | return new GitHubResourceNotFoundError(response?.message || "Resource");
69 | case 409:
70 | return new GitHubConflictError(response?.message || "Conflict occurred");
71 | case 422:
72 | return new GitHubValidationError(
73 | response?.message || "Validation failed",
74 | status,
75 | response
76 | );
77 | case 429:
78 | return new GitHubRateLimitError(
79 | response?.message,
80 | new Date(response?.reset_at || Date.now() + 60000)
81 | );
82 | default:
83 | return new GitHubError(
84 | response?.message || "GitHub API error",
85 | status,
86 | response
87 | );
88 | }
89 | }
```
--------------------------------------------------------------------------------
/src/civitai-records/src/lib/__tests__/detectRemoteAssetType.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it } from 'node:test';
2 | import assert from 'node:assert';
3 | import { detectRemoteAssetType } from '../detectRemoteAssetType.js';
4 |
5 | describe('detectRemoteAssetType', () => {
6 | it('should detect image from URL extension (fallback)', async () => {
7 | const result = await detectRemoteAssetType(
8 | 'https://example.com/photo.jpg',
9 | { skipRemote: true } // Force fallback to URL-based detection
10 | );
11 |
12 | assert.strictEqual(result.assetType, 'image');
13 | assert.strictEqual(result.ext, 'jpg');
14 | assert.strictEqual(result.from, 'extension');
15 | });
16 |
17 | it('should detect video from URL extension (fallback)', async () => {
18 | const result = await detectRemoteAssetType(
19 | 'https://example.com/video.mp4',
20 | { skipRemote: true }
21 | );
22 |
23 | assert.strictEqual(result.assetType, 'video');
24 | assert.strictEqual(result.ext, 'mp4');
25 | assert.strictEqual(result.from, 'extension');
26 | });
27 |
28 | it('should detect image from CDN path pattern (fallback)', async () => {
29 | const result = await detectRemoteAssetType(
30 | 'https://cdn.example.com/images/abc123',
31 | { skipRemote: true }
32 | );
33 |
34 | assert.strictEqual(result.assetType, 'image');
35 | assert.strictEqual(result.from, 'extension');
36 | });
37 |
38 | it('should detect video from CDN path pattern (fallback)', async () => {
39 | const result = await detectRemoteAssetType(
40 | 'https://cdn.example.com/videos/xyz789',
41 | { skipRemote: true }
42 | );
43 |
44 | assert.strictEqual(result.assetType, 'video');
45 | assert.strictEqual(result.from, 'extension');
46 | });
47 |
48 | it('should return null for unknown URL (fallback)', async () => {
49 | const result = await detectRemoteAssetType(
50 | 'https://example.com/unknown',
51 | { skipRemote: true }
52 | );
53 |
54 | assert.strictEqual(result.assetType, null);
55 | assert.strictEqual(result.from, 'fallback');
56 | });
57 |
58 | it('should handle various image extensions', async () => {
59 | const extensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'];
60 |
61 | for (const ext of extensions) {
62 | const result = await detectRemoteAssetType(
63 | `https://example.com/file.${ext}`,
64 | { skipRemote: true }
65 | );
66 |
67 | assert.strictEqual(result.assetType, 'image', `Failed for .${ext}`);
68 | assert.strictEqual(result.ext, ext);
69 | }
70 | });
71 |
72 | it('should handle various video extensions', async () => {
73 | const extensions = ['mp4', 'mov', 'avi', 'webm', 'mkv'];
74 |
75 | for (const ext of extensions) {
76 | const result = await detectRemoteAssetType(
77 | `https://example.com/file.${ext}`,
78 | { skipRemote: true }
79 | );
80 |
81 | assert.strictEqual(result.assetType, 'video', `Failed for .${ext}`);
82 | assert.strictEqual(result.ext, ext);
83 | }
84 | });
85 | });
86 |
```
--------------------------------------------------------------------------------
/src/civitai-records/src/tools/createPrompt.ts:
--------------------------------------------------------------------------------
```typescript
1 | import type { ContentResult } from "fastmcp";
2 | import { z } from "zod";
3 | import { prisma } from "../lib/prisma.js";
4 |
5 | const metadataSchema = z.record(z.any()).nullable().default(null);
6 |
7 | export const createPromptParameters = z.object({
8 | prompt_text: z
9 | .string()
10 | .min(1)
11 | .describe("The actual text content of the prompt. This is the main creative or instructional text that describes what the user wants to generate or accomplish."),
12 | llm_model_provider: z
13 | .string()
14 | .nullable()
15 | .default(null)
16 | .describe("The AI model provider used with this prompt (e.g., 'openai', 'anthropic', 'google'). Leave empty if not applicable or unknown."),
17 | llm_model: z
18 | .string()
19 | .nullable()
20 | .default(null)
21 | .describe("The specific AI model name or identifier (e.g., 'gpt-4', 'claude-3-opus', 'gemini-pro'). Leave empty if not applicable or unknown."),
22 | purpose: z
23 | .string()
24 | .nullable()
25 | .default(null)
26 | .describe("The intended purpose or use case for this prompt (e.g., 'image_generation', 'video_creation', 'text_completion'). Leave empty if not applicable or unknown."),
27 | metadata: metadataSchema.describe("Additional structured data about this prompt in JSON format. Can include tags, categories, version info, or any custom fields relevant to your workflow."),
28 | on_behalf_of: z
29 | .string()
30 | .nullable()
31 | .default(null)
32 | .describe("The user account this action is being performed on behalf of. If not provided, defaults to the authenticated database user and can be changed later via update tools."),
33 | });
34 |
35 | export type CreatePromptParameters = z.infer<typeof createPromptParameters>;
36 |
37 | export const createPromptTool = {
38 | name: "create_prompt",
39 | description: "Save a user's text prompt to the database. Use this to store prompts that will be used for content generation, video creation, or other AI tasks.",
40 | parameters: createPromptParameters,
41 | execute: async ({ prompt_text, llm_model_provider, llm_model, purpose, metadata, on_behalf_of }: CreatePromptParameters): Promise<ContentResult> => {
42 | const prompt = await prisma.prompts.create({
43 | data: {
44 | content: prompt_text,
45 | llm_model_provider,
46 | llm_model,
47 | purpose,
48 | metadata: metadata ?? undefined,
49 | on_behalf_of: on_behalf_of ?? undefined,
50 | },
51 | });
52 |
53 | return {
54 | content: [
55 | {
56 | type: "text",
57 | text: JSON.stringify({
58 | prompt_id: prompt.id.toString(),
59 | prompt_text: prompt.content,
60 | llm_model_provider: prompt.llm_model_provider,
61 | llm_model: prompt.llm_model,
62 | on_behalf_of: prompt.on_behalf_of,
63 | created_at: prompt.created_at.toISOString(),
64 | }, null, 2),
65 | },
66 | ],
67 | } satisfies ContentResult;
68 | },
69 | };
70 |
```
--------------------------------------------------------------------------------
/src/civitai-records/src/lib/__tests__/sha256.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { createHash } from 'node:crypto';
2 | import { mkdtemp, writeFile } from 'node:fs/promises';
3 | import { tmpdir } from 'node:os';
4 | import { join } from 'node:path';
5 | import { ReadableStream } from 'node:stream/web';
6 | import { test } from 'node:test';
7 | import assert from 'node:assert/strict';
8 |
9 | import { sha256, sha256FromFile, sha256FromUrl } from '../sha256.js';
10 |
11 | function expectHashFor(value: string): string {
12 | return createHash('sha256').update(value).digest('hex');
13 | }
14 |
15 | test('sha256FromFile hashes local files without reading everything into memory', async () => {
16 | const dir = await mkdtemp(join(tmpdir(), 'sha256-'));
17 | const filePath = join(dir, 'payload.txt');
18 | const body = 'civitai-records-test';
19 | await writeFile(filePath, body, { encoding: 'utf8' });
20 |
21 | const result = await sha256FromFile(filePath);
22 |
23 | assert.equal(result, expectHashFor(body));
24 | });
25 |
26 | test('sha256FromUrl streams response bodies to produce a digest', async () => {
27 | const payload = 'remote-hash-fixture';
28 | const originalFetch = globalThis.fetch;
29 | const url = 'https://example.com/file.bin';
30 |
31 | globalThis.fetch = (async (input) => {
32 | assert.equal(String(input), url);
33 |
34 | const readable = new ReadableStream<Uint8Array>({
35 | start(controller) {
36 | controller.enqueue(new TextEncoder().encode(payload));
37 | controller.close();
38 | },
39 | });
40 |
41 | return {
42 | ok: true,
43 | status: 200,
44 | body: readable,
45 | } as Awaited<ReturnType<typeof fetch>>;
46 | }) as typeof fetch;
47 |
48 | try {
49 | const result = await sha256FromUrl(url);
50 | assert.equal(result, expectHashFor(payload));
51 | } finally {
52 | globalThis.fetch = originalFetch;
53 | }
54 | });
55 |
56 | test('sha256 chooses URL hashing for http(s) resources and file hashing otherwise', async () => {
57 | const payload = 'dispatch-check';
58 | const originalFetch = globalThis.fetch;
59 | const url = 'https://feedmob.example/resource';
60 |
61 | let fetchInvocations = 0;
62 |
63 | globalThis.fetch = (async () => {
64 | fetchInvocations += 1;
65 |
66 | return {
67 | ok: true,
68 | status: 200,
69 | body: new ReadableStream<Uint8Array>({
70 | start(controller) {
71 | controller.enqueue(new TextEncoder().encode(payload));
72 | controller.close();
73 | },
74 | }),
75 | } as Awaited<ReturnType<typeof fetch>>;
76 | }) as typeof fetch;
77 |
78 | const dir = await mkdtemp(join(tmpdir(), 'sha256-dispatch-'));
79 | const filePath = join(dir, 'payload.txt');
80 | await writeFile(filePath, payload, { encoding: 'utf8' });
81 |
82 | try {
83 | const remoteResult = await sha256(url);
84 | assert.equal(remoteResult, expectHashFor(payload));
85 | assert.equal(fetchInvocations, 1, 'expected fetch to be used for remote URLs');
86 |
87 | const localResult = await sha256(filePath);
88 | assert.equal(localResult, expectHashFor(payload));
89 | assert.equal(fetchInvocations, 1, 'expected fetch not to run for local files');
90 | } finally {
91 | globalThis.fetch = originalFetch;
92 | }
93 | });
94 |
```
--------------------------------------------------------------------------------
/src/imagekit/src/tools/uploadFile.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { access, readFile } from "node:fs/promises";
2 | import { resolve } from "node:path";
3 | import type { ContentResult } from "fastmcp";
4 | import { z } from "zod";
5 |
6 | import {
7 | ImageKitUploader,
8 | imageKitUploadParametersSchema,
9 | type ImageKitUploadRequest,
10 | type ImageKitUploadResponse,
11 | } from "../services/imageKitUpload.js";
12 |
13 | export const uploadFileParametersSchema = imageKitUploadParametersSchema.extend({
14 | provider: z.literal("imagekit").default("imagekit"),
15 | });
16 |
17 | export type UploadFileParameters = z.infer<typeof uploadFileParametersSchema>;
18 |
19 | export interface UploadFileToolConfig {
20 | imageKitPrivateKey?: string;
21 | }
22 |
23 | function isRemoteUrl(input: string): boolean {
24 | return /^https?:\/\//i.test(input);
25 | }
26 |
27 | function isDataUrl(input: string): boolean {
28 | return input.startsWith("data:");
29 | }
30 |
31 | async function resolveFileInput(file: string): Promise<string> {
32 | if (isRemoteUrl(file) || isDataUrl(file)) {
33 | return file;
34 | }
35 |
36 | const resolvedPath = resolve(file);
37 |
38 | try {
39 | await access(resolvedPath);
40 | } catch {
41 | return file;
42 | }
43 |
44 | try {
45 | const fileBuffer = await readFile(resolvedPath);
46 | return fileBuffer.toString("base64");
47 | } catch (error) {
48 | const reason =
49 | error && typeof error === "object" && "message" in error
50 | ? (error as Error).message
51 | : String(error);
52 |
53 | throw new Error(`Unable to read local file at ${resolvedPath}: ${reason}`);
54 | }
55 | }
56 |
57 | export async function executeUploadFile(
58 | params: UploadFileParameters,
59 | privateKey: string,
60 | ): Promise<ContentResult> {
61 | if (params.provider !== "imagekit") {
62 | throw new Error(`Unsupported provider: ${params.provider}`);
63 | }
64 |
65 | const uploader = new ImageKitUploader({
66 | privateKey,
67 | });
68 |
69 | const { provider, file, ...request } = params;
70 | const resolvedFile = await resolveFileInput(file);
71 |
72 | const uploadRequest: ImageKitUploadRequest = {
73 | ...request,
74 | file: resolvedFile,
75 | };
76 | const result = await uploader.upload(uploadRequest);
77 |
78 | const providerData: ImageKitUploadResponse =
79 | (result.providerData as ImageKitUploadResponse | undefined) ?? {};
80 | const displayName = result.name ?? uploadRequest.fileName;
81 |
82 | const summary = {
83 | provider,
84 | id: result.id ?? providerData.fileId,
85 | name: displayName,
86 | url: result.url ?? providerData.url,
87 | thumbnailUrl: providerData.thumbnailUrl,
88 | };
89 |
90 | return {
91 | content: [
92 | {
93 | type: "text",
94 | text: JSON.stringify({ summary, providerData }, null, 2),
95 | },
96 | ],
97 | } satisfies ContentResult;
98 | }
99 |
100 | export function createUploadFileTool(config: UploadFileToolConfig) {
101 | return {
102 | name: "upload_file",
103 | description:
104 | "Upload an asset to the configured media provider. Defaults to ImageKit and accepts base64 content, a local file path, or a remote URL.",
105 | parameters: uploadFileParametersSchema,
106 | execute: async (params: UploadFileParameters): Promise<ContentResult> => {
107 | const privateKey = config.imageKitPrivateKey;
108 |
109 | if (!privateKey) {
110 | throw new Error("IMAGEKIT_PRIVATE_KEY is not configured");
111 | }
112 |
113 | return executeUploadFile(params, privateKey);
114 | },
115 | };
116 | }
117 |
```
--------------------------------------------------------------------------------
/src/civitai-records/src/lib/civitaiApi.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { z } from "zod";
2 |
3 | const civitaiImageStatsSchema = z
4 | .object({
5 | cryCountAllTime: z.number().int().nonnegative().optional(),
6 | laughCountAllTime: z.number().int().nonnegative().optional(),
7 | likeCountAllTime: z.number().int().nonnegative().optional(),
8 | dislikeCountAllTime: z.number().int().nonnegative().optional(),
9 | heartCountAllTime: z.number().int().nonnegative().optional(),
10 | commentCountAllTime: z.number().int().nonnegative().optional(),
11 | })
12 | .partial()
13 | .optional();
14 |
15 | const civitaiImageItemSchema = z.object({
16 | id: z.number(),
17 | stats: civitaiImageStatsSchema,
18 | createdAt: z.string().optional(),
19 | user: z.object({ username: z.string().optional() }).optional(),
20 | });
21 |
22 | const civitaiTrpcResponseSchema = z.object({
23 | result: z.object({
24 | data: z.object({
25 | json: z.object({
26 | nextCursor: z.union([z.number(), z.string()]).nullable().optional(),
27 | items: z.array(civitaiImageItemSchema),
28 | }),
29 | }),
30 | }),
31 | });
32 |
33 | export type CivitaiImageStats = {
34 | civitai_id: string;
35 | cry_count: number;
36 | laugh_count: number;
37 | like_count: number;
38 | dislike_count: number;
39 | heart_count: number;
40 | comment_count: number;
41 | civitai_created_at: string | null;
42 | civitai_account: string | null;
43 | };
44 |
45 | export async function fetchCivitaiPostImageStats(
46 | postId: string
47 | ): Promise<CivitaiImageStats[]> {
48 | const allStats: CivitaiImageStats[] = [];
49 | let nextCursor: number | string | null | undefined = undefined;
50 | let isFirstPage = true;
51 |
52 | while (isFirstPage || nextCursor) {
53 | const input: any = {
54 | json: {
55 | postId: parseInt(postId),
56 | pending: false,
57 | browsingLevel: null,
58 | withMeta: false,
59 | include: [],
60 | excludedTagIds: [],
61 | disablePoi: true,
62 | disableMinor: false,
63 | cursor: nextCursor ?? null,
64 | },
65 | meta: {
66 | values: {
67 | browsingLevel: ["undefined"],
68 | cursor: ["undefined"],
69 | },
70 | },
71 | };
72 |
73 | if (nextCursor) {
74 | delete input.meta.values.cursor;
75 | }
76 |
77 | const url = new URL("https://civitai.com/api/trpc/image.getInfinite");
78 | url.searchParams.set("input", JSON.stringify(input));
79 |
80 | const response = await fetch(url, {
81 | method: "GET",
82 | headers: {
83 | Accept: "application/json",
84 | },
85 | });
86 |
87 | if (!response.ok) {
88 | throw new Error(
89 | `Failed to fetch post images from Civitai TRPC (status ${response.status} ${response.statusText})`
90 | );
91 | }
92 |
93 | let json: unknown;
94 | try {
95 | json = await response.json();
96 | } catch (error) {
97 | throw new Error("Civitai response was not valid JSON");
98 | }
99 |
100 | const parsed = civitaiTrpcResponseSchema.parse(json);
101 | const items = parsed.result.data.json.items;
102 |
103 | for (const item of items) {
104 | const stats = item.stats ?? {};
105 | allStats.push({
106 | civitai_id: item.id.toString(),
107 | cry_count: stats.cryCountAllTime ?? 0,
108 | laugh_count: stats.laughCountAllTime ?? 0,
109 | like_count: stats.likeCountAllTime ?? 0,
110 | dislike_count: stats.dislikeCountAllTime ?? 0,
111 | heart_count: stats.heartCountAllTime ?? 0,
112 | comment_count: stats.commentCountAllTime ?? 0,
113 | civitai_created_at: item.createdAt ?? null,
114 | civitai_account: item.user?.username ?? null,
115 | });
116 | }
117 |
118 | nextCursor = parsed.result.data.json.nextCursor;
119 | isFirstPage = false;
120 | }
121 |
122 | return allStats;
123 | }
124 |
```
--------------------------------------------------------------------------------
/src/civitai-records/src/tools/createCivitaiPost.ts:
--------------------------------------------------------------------------------
```typescript
1 | import type { ContentResult } from "fastmcp";
2 | import { z } from "zod";
3 | import { prisma } from "../lib/prisma.js";
4 | import { handleDatabaseError } from "../lib/handleDatabaseError.js";
5 |
6 | const metadataSchema = z.record(z.any()).nullable().default(null);
7 |
8 | export const createCivitaiPostParameters = z.object({
9 | civitai_id: z
10 | .string()
11 | .min(1)
12 | .describe("The numeric post ID from the Civitai post URL. Extract this from URLs like https://civitai.com/posts/23602354 where the ID is 23602354."),
13 | civitai_url: z
14 | .string()
15 | .min(1)
16 | .describe("The full public URL where this publication can be viewed on Civitai (e.g., 'https://civitai.com/posts/12345678')."),
17 | status: z
18 | .enum(["pending", "published", "failed"])
19 | .default("published")
20 | .describe("The publication status. Use 'published' for successful posts, 'pending' for scheduled posts, or 'failed' for unsuccessful attempts."),
21 | title: z
22 | .string()
23 | .nullable()
24 | .default(null)
25 | .describe("The title of the publication as it appears on Civitai."),
26 | description: z
27 | .string()
28 | .nullable()
29 | .default(null)
30 | .describe("The description or caption of the publication as posted to Civitai."),
31 | metadata: metadataSchema.describe("Additional structured data about this post in JSON format. Can include Civitai API response, engagement metrics (views, likes, comments), tags, categories, workflow details, or any custom fields relevant to tracking this post."),
32 | on_behalf_of: z
33 | .string()
34 | .nullable()
35 | .default(null)
36 | .describe("The user account this action is being performed on behalf of. If not provided, defaults to the authenticated database user and can be modified later if needed."),
37 | });
38 |
39 | export type CreateCivitaiPostParameters = z.infer<typeof createCivitaiPostParameters>;
40 |
41 | export const createCivitaiPostTool = {
42 | name: "create_civitai_post",
43 | description: "Record a publication to Civitai.com. Use this after publishing content to Civitai to track the publication in the database. Note: To link an asset to this post, update the asset's post_id field using the update_asset tool.",
44 | parameters: createCivitaiPostParameters,
45 | execute: async ({
46 | civitai_id,
47 | civitai_url,
48 | status,
49 | title,
50 | description,
51 | metadata,
52 | on_behalf_of,
53 | }: CreateCivitaiPostParameters): Promise<ContentResult> => {
54 | const accountValue = process.env.CIVITAI_ACCOUNT ?? "c29";
55 |
56 | const post = await prisma.civitai_posts.create({
57 | data: {
58 | civitai_id,
59 | civitai_url,
60 | civitai_account: accountValue,
61 | status,
62 | title: title ?? undefined,
63 | description: description ?? undefined,
64 | metadata: metadata ?? undefined,
65 | on_behalf_of: on_behalf_of ?? undefined,
66 | },
67 | }).catch(error => handleDatabaseError(error, `Civitai ID: ${civitai_id}`));
68 |
69 | return {
70 | content: [
71 | {
72 | type: "text",
73 | text: JSON.stringify({
74 | post_id: post.id.toString(),
75 | civitai_id: post.civitai_id,
76 | civitai_url: post.civitai_url,
77 | civitai_account: post.civitai_account,
78 | status: post.status,
79 | title: post.title,
80 | description: post.description,
81 | on_behalf_of: post.on_behalf_of,
82 | created_at: post.created_at.toISOString(),
83 | }, null, 2),
84 | },
85 | ],
86 | } satisfies ContentResult;
87 | },
88 | };
89 |
```
--------------------------------------------------------------------------------
/src/civitai-records/docs/civitai-owner-createrole.md:
--------------------------------------------------------------------------------
```markdown
1 | # 📘 Why We Give `civitai_owner` Limited Admin Powers
2 |
3 | ## 1️⃣ Context
4 |
5 | Our Civitai PostgreSQL deployment provisions human users (e.g. `richard`, `alice`) via SQL during container startup. Each login should:
6 |
7 | * Use its own database role and password.
8 | * Only perform `SELECT`, `INSERT`, `UPDATE` on application tables.
9 | * Automatically populate audit columns (`created_by`, `updated_by`).
10 | * Be created idempotently by migration scripts (`infra/db-init/*.sql`).
11 |
12 | PostgreSQL enforces that only roles with `CREATEROLE` can create or alter other roles, so we need a safe way for migrations to provision logins without handing blanket superuser access to the runtime roles.
13 |
14 | ---
15 |
16 | ## 2️⃣ What the Code Does Today
17 |
18 | `infra/db-init/01-roles.sql` establishes two structural roles:
19 |
20 | ```sql
21 | CREATE ROLE civitai_owner NOLOGIN CREATEROLE;
22 | CREATE ROLE civitai_user NOLOGIN;
23 | GRANT civitai_user TO civitai_owner WITH ADMIN OPTION;
24 | ```
25 |
26 | `civitai_owner` owns the schema and is marked `CREATEROLE`, which lets it manage downstream login roles. Application connections inherit privileges through `civitai_user`, keeping runtime permissions narrow.
27 |
28 | `infra/db-init/02_functions.sql` defines the helper that migrations call:
29 |
30 | ```sql
31 | CREATE OR REPLACE FUNCTION civitai.create_app_user(p_username text, p_password text)
32 | RETURNS void
33 | LANGUAGE plpgsql
34 | SECURITY DEFINER
35 | AS $$
36 | BEGIN
37 | -- validates, creates or alters the login role, then grants civitai_user
38 | END;
39 | $$;
40 | ```
41 |
42 | Because the function is `SECURITY DEFINER` and owned by `civitai_owner`, the code executes with `civitai_owner`'s `CREATEROLE` privilege. `infra/db-init/03_init.sql` then provisions seed logins using this helper.
43 |
44 | ---
45 |
46 | ## 3️⃣ Why This Shape Works
47 |
48 | * **Least privilege at runtime** — client connections still use logins that only have `civitai_user`; they cannot create roles or bypass row-level security.
49 | * **Controlled role creation** — only the migration-time helper inherits `CREATEROLE`, isolating the capability inside a single audited function.
50 | * **Idempotent provisioning** — the function updates existing roles (`ALTER ROLE ... PASSWORD`) when rerun, so migrations stay reentrant.
51 | * **Audit consistency** — the helper hands out `civitai_user`, and our triggers (`set_created_updated_by`, `audit_event`) rely on `current_user` to capture who changed a record.
52 |
53 | ---
54 |
55 | ## 4️⃣ Security Guardrails in Place
56 |
57 | * `civitai_owner` is `NOLOGIN`, so no one can connect as it directly.
58 | * The helper only grants `civitai_user`, preventing privilege escalation even if it is misused.
59 | * Table-level grants, trigger wiring, and row-level policies are enforced through `civitai.register_audited_table`, ensuring new tables inherit the right protections automatically.
60 |
61 | > 📌 We intentionally **do not** introduce an extra `civitai_admin` role in this repository; the existing migrations already give `civitai_owner` the minimal elevated capability required to run `create_app_user` during initialization.
62 |
63 | ---
64 |
65 | ## 5️⃣ Call Flow Overview
66 |
67 | ```
68 | postgres (container superuser)
69 | │
70 | ├── executes 01-roles.sql → civitai_owner (NOLOGIN, CREATEROLE)
71 | │
72 | ├── executes 02_functions.sql → civitai.create_app_user() [SECURITY DEFINER]
73 | │
74 | └── executes 03_init.sql → civitai.create_app_user('richard', '...')
75 | │
76 | ▼
77 | civitai_owner (definer)
78 | │
79 | ├── CREATE ROLE richard LOGIN ...
80 | └── GRANT civitai_user TO richard
81 | ```
82 |
83 | ---
84 |
85 | ## 6️⃣ Takeaway
86 |
87 | Granting `CREATEROLE` directly to the schema owner keeps the privilege surface small while letting our migrations operate unattended. The helper function encapsulates role provisioning, making it safe to rerun and easy to audit. No additional admin role is needed for the current code path.
88 |
```
--------------------------------------------------------------------------------
/src/kayzen-reporting/src/kayzen-client.ts:
--------------------------------------------------------------------------------
```typescript
1 | import axios from 'axios';
2 | import type { AxiosRequestConfig } from 'axios';
3 | import dotenv from 'dotenv';
4 |
5 | dotenv.config();
6 |
7 | export interface KayzenConfig {
8 | userName: string;
9 | password: string;
10 | basicAuthToken: string;
11 | baseUrl: string;
12 | }
13 |
14 | interface AuthResponse {
15 | access_token: string;
16 | }
17 |
18 | export class KayzenClient {
19 | private baseUrl: string;
20 | private username?: string;
21 | private password?: string;
22 | private basicAuth?: string;
23 | private authToken: string | null = null;
24 | private tokenExpiry: Date | null = null;
25 |
26 | constructor() {
27 | this.baseUrl = process.env.KAYZEN_BASE_URL || 'https://api.kayzen.io/v1';
28 | this.username = process.env.KAYZEN_USERNAME;
29 | this.password = process.env.KAYZEN_PASSWORD;
30 | this.basicAuth = process.env.KAYZEN_BASIC_AUTH;
31 | }
32 |
33 | private async getAuthToken(): Promise<string> {
34 | if (this.authToken && this.tokenExpiry && new Date() < this.tokenExpiry) {
35 | return this.authToken;
36 | }
37 |
38 | if (!this.username || !this.password || !this.basicAuth) {
39 | throw new Error('Authentication credentials required for this operation');
40 | }
41 |
42 | const url = `${this.baseUrl}/authentication/token`;
43 | const payload = {
44 | grant_type: 'password',
45 | username: this.username,
46 | password: this.password
47 | };
48 | const headers = {
49 | accept: 'application/json',
50 | 'content-type': 'application/json',
51 | authorization: `Basic ${this.basicAuth}`
52 | };
53 |
54 | try {
55 | const response = await axios.post<AuthResponse>(url, payload, { headers });
56 | this.authToken = response.data.access_token;
57 | this.tokenExpiry = new Date(Date.now() + 25 * 60 * 1000);
58 | return this.authToken;
59 | } catch (error) {
60 | console.error('Error getting auth token:', error);
61 | throw error;
62 | }
63 | }
64 |
65 | private async makeRequest<T>(method: string, endpoint: string, params: Record<string, unknown> = {}) {
66 | const token = await this.getAuthToken();
67 | const config: AxiosRequestConfig = {
68 | method,
69 | url: `${this.baseUrl}${endpoint}`,
70 | headers: {
71 | Authorization: `Bearer ${token}`,
72 | 'Content-Type': 'application/json'
73 | },
74 | params
75 | };
76 |
77 | try {
78 | const response = await axios<T>(config);
79 | return response.data;
80 | } catch (error) {
81 | console.error(`Error making request to ${endpoint}:`, error);
82 | throw error;
83 | }
84 | }
85 |
86 | async listReports(params: {
87 | advertiser_id?: number;
88 | q?: string;
89 | page?: number;
90 | per_page?: number;
91 | sort_field?: string;
92 | sort_direction?: 'asc' | 'desc';
93 | } = {}) {
94 | if (!this.username || !this.password || !this.basicAuth) {
95 | throw new Error('Authentication credentials required for this operation');
96 | }
97 |
98 | const queryParams: Record<string, unknown> = {};
99 | if (params.advertiser_id !== undefined) queryParams.advertiser_id = params.advertiser_id;
100 | if (params.q !== undefined) queryParams.q = params.q;
101 | if (params.page !== undefined) queryParams.page = params.page;
102 | if (params.per_page !== undefined) queryParams.per_page = params.per_page;
103 | if (params.sort_field !== undefined) queryParams.sort_field = params.sort_field;
104 | if (params.sort_direction !== undefined) queryParams.sort_direction = params.sort_direction;
105 |
106 | return this.makeRequest('GET', '/reports', queryParams);
107 | }
108 |
109 | async getReportResults(reportId: string, startDate?: string, endDate?: string) {
110 | if (!this.username || !this.password || !this.basicAuth) {
111 | throw new Error('Authentication credentials required for this operation');
112 | }
113 | const params: Record<string, string> = {};
114 | if (startDate) params.start_date = startDate;
115 | if (endDate) params.end_date = endDate;
116 |
117 | return this.makeRequest('GET', `/reports/${reportId}/report_results`, params);
118 | }
119 | }
120 |
```
--------------------------------------------------------------------------------
/src/github-issues/common/utils.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { getUserAgent } from "universal-user-agent";
2 | import { createGitHubError } from "./errors.js";
3 | import { VERSION } from "./version.js";
4 |
5 | type RequestOptions = {
6 | method?: string;
7 | body?: unknown;
8 | headers?: Record<string, string>;
9 | }
10 |
11 | async function parseResponseBody(response: Response): Promise<unknown> {
12 | const contentType = response.headers.get("content-type");
13 | if (contentType?.includes("application/json")) {
14 | return response.json();
15 | }
16 | return response.text();
17 | }
18 |
19 | export function buildUrl(baseUrl: string, params: Record<string, string | number | undefined>): string {
20 | const url = new URL(baseUrl);
21 | Object.entries(params).forEach(([key, value]) => {
22 | if (value !== undefined) {
23 | url.searchParams.append(key, value.toString());
24 | }
25 | });
26 | return url.toString();
27 | }
28 |
29 | const USER_AGENT = `modelcontextprotocol/servers/github/v${VERSION} ${getUserAgent()}`;
30 |
31 | export async function githubRequest(
32 | url: string,
33 | options: RequestOptions = {}
34 | ): Promise<unknown> {
35 | const headers: Record<string, string> = {
36 | "Accept": "application/vnd.github.v3+json",
37 | "Content-Type": "application/json",
38 | "User-Agent": USER_AGENT,
39 | ...options.headers,
40 | };
41 |
42 | if (process.env.GITHUB_PERSONAL_ACCESS_TOKEN) {
43 | headers["Authorization"] = `Bearer ${process.env.GITHUB_PERSONAL_ACCESS_TOKEN}`;
44 | }
45 |
46 | const response = await fetch(url, {
47 | method: options.method || "GET",
48 | headers,
49 | body: options.body ? JSON.stringify(options.body) : undefined,
50 | });
51 |
52 | const responseBody = await parseResponseBody(response);
53 |
54 | if (!response.ok) {
55 | throw createGitHubError(response.status, responseBody);
56 | }
57 |
58 | return responseBody;
59 | }
60 |
61 | export function validateBranchName(branch: string): string {
62 | const sanitized = branch.trim();
63 | if (!sanitized) {
64 | throw new Error("Branch name cannot be empty");
65 | }
66 | if (sanitized.includes("..")) {
67 | throw new Error("Branch name cannot contain '..'");
68 | }
69 | if (/[\s~^:?*[\\\]]/.test(sanitized)) {
70 | throw new Error("Branch name contains invalid characters");
71 | }
72 | if (sanitized.startsWith("/") || sanitized.endsWith("/")) {
73 | throw new Error("Branch name cannot start or end with '/'");
74 | }
75 | if (sanitized.endsWith(".lock")) {
76 | throw new Error("Branch name cannot end with '.lock'");
77 | }
78 | return sanitized;
79 | }
80 |
81 | export function validateRepositoryName(name: string): string {
82 | const sanitized = name.trim().toLowerCase();
83 | if (!sanitized) {
84 | throw new Error("Repository name cannot be empty");
85 | }
86 | if (!/^[a-z0-9_.-]+$/.test(sanitized)) {
87 | throw new Error(
88 | "Repository name can only contain lowercase letters, numbers, hyphens, periods, and underscores"
89 | );
90 | }
91 | if (sanitized.startsWith(".") || sanitized.endsWith(".")) {
92 | throw new Error("Repository name cannot start or end with a period");
93 | }
94 | return sanitized;
95 | }
96 |
97 | export function validateOwnerName(owner: string): string {
98 | const sanitized = owner.trim().toLowerCase();
99 | if (!sanitized) {
100 | throw new Error("Owner name cannot be empty");
101 | }
102 | if (!/^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$/.test(sanitized)) {
103 | throw new Error(
104 | "Owner name must start with a letter or number and can contain up to 39 characters"
105 | );
106 | }
107 | return sanitized;
108 | }
109 |
110 | export async function checkBranchExists(
111 | owner: string,
112 | repo: string,
113 | branch: string
114 | ): Promise<boolean> {
115 | try {
116 | await githubRequest(
117 | `https://api.github.com/repos/${owner}/${repo}/branches/${branch}`
118 | );
119 | return true;
120 | } catch (error) {
121 | if (error && typeof error === "object" && "status" in error && error.status === 404) {
122 | return false;
123 | }
124 | throw error;
125 | }
126 | }
127 |
128 | export async function checkUserExists(username: string): Promise<boolean> {
129 | try {
130 | await githubRequest(`https://api.github.com/users/${username}`);
131 | return true;
132 | } catch (error) {
133 | if (error && typeof error === "object" && "status" in error && error.status === 404) {
134 | return false;
135 | }
136 | throw error;
137 | }
138 | }
```
--------------------------------------------------------------------------------
/src/impact-radius-reporting/src/fm_impact_radius_mapping.ts:
--------------------------------------------------------------------------------
```typescript
1 | import axios from "axios";
2 | import jwt from 'jsonwebtoken';
3 | import dotenv from "dotenv";
4 |
5 | dotenv.config(); // Load environment variables from .env file
6 |
7 | // TypeScript interfaces for API response structure
8 | export interface ImpactCampaignMapping {
9 | id: number;
10 | impact_brand: string;
11 | impact_ad: string;
12 | impact_event_type: string;
13 | click_url_id: number;
14 | vendor_name: string | null;
15 | campaign_name: string | null;
16 | client_name: string | null;
17 | }
18 |
19 | export interface PaginationInfo {
20 | current_page: number;
21 | per_page: number;
22 | total_pages: number;
23 | total_count: number;
24 | }
25 |
26 | export interface ImpactCampaignMappingResponse {
27 | status: number;
28 | data: ImpactCampaignMapping[];
29 | pagination: PaginationInfo;
30 | }
31 |
32 | export interface ImpactCampaignMappingErrorResponse {
33 | status: number;
34 | error: string;
35 | message: string;
36 | }
37 |
38 | export interface FetchImpactRadiusCampaignMappingParams {
39 | click_url_id?: number;
40 | impact_brand?: string;
41 | impact_ad?: string;
42 | impact_event_type?: string;
43 | page?: number;
44 | per_page?: number;
45 | }
46 |
47 | const FEEDMOB_API_BASE = process.env.FEEDMOB_API_BASE;
48 | const FEEDMOB_KEY = process.env.FEEDMOB_KEY;
49 | const FEEDMOB_SECRET = process.env.FEEDMOB_SECRET;
50 |
51 | if (!FEEDMOB_KEY || !FEEDMOB_SECRET) {
52 | console.error("Error: FEEDMOB_KEY and FEEDMOB_SECRET environment variables must be set.");
53 | process.exit(1);
54 | }
55 |
56 | // Generate JWT token
57 | function generateToken(key: string, secret: string): string {
58 | const expirationDate = new Date();
59 | expirationDate.setDate(expirationDate.getDate() + 7); // 7 days from now
60 |
61 | const payload = {
62 | key: key,
63 | expired_at: expirationDate.toISOString().split('T')[0] // Format as YYYY-MM-DD
64 | };
65 |
66 | return jwt.sign(payload, secret, { algorithm: 'HS256' });
67 | }
68 |
69 | // Helper Function for API Call
70 | export async function fetchImpactRaidusCampaignMapping(
71 | params: FetchImpactRadiusCampaignMappingParams
72 | ): Promise<ImpactCampaignMappingResponse> {
73 | const urlObj = new URL(`${FEEDMOB_API_BASE}/ai/api/impact_campaign_mappings`);
74 |
75 | // Add filtering parameters if provided
76 | if (params.click_url_id !== undefined) {
77 | urlObj.searchParams.set('click_url_id', params.click_url_id.toString());
78 | }
79 | if (params.impact_brand) {
80 | urlObj.searchParams.set('impact_brand', params.impact_brand);
81 | }
82 | if (params.impact_ad) {
83 | urlObj.searchParams.set('impact_ad', params.impact_ad);
84 | }
85 | if (params.impact_event_type) {
86 | urlObj.searchParams.set('impact_event_type', params.impact_event_type);
87 | }
88 |
89 | // Add pagination parameters if provided
90 | if (params.page !== undefined) {
91 | urlObj.searchParams.set('page', params.page.toString());
92 | }
93 | if (params.per_page !== undefined) {
94 | urlObj.searchParams.set('per_page', params.per_page.toString());
95 | }
96 |
97 | const url = urlObj.toString();
98 |
99 | try {
100 | const token = generateToken(FEEDMOB_KEY as string, FEEDMOB_SECRET as string);
101 | const response = await axios.get(url, {
102 | headers: {
103 | 'Content-Type': 'application/json',
104 | 'Accept': 'application/json',
105 | 'FEEDMOB-KEY': FEEDMOB_KEY,
106 | 'FEEDMOB-TOKEN': token
107 | },
108 | timeout: 30000,
109 | });
110 | return response.data as ImpactCampaignMappingResponse;
111 | } catch (error: unknown) {
112 | console.error("Error fetching impact campaign mappings from FeedMob API:", error);
113 | if (error && typeof error === 'object' && 'response' in error) {
114 | const err = error as Record<string, any>;
115 | const status = err.response?.status;
116 | const errorData = err.response?.data as ImpactCampaignMappingErrorResponse;
117 |
118 | if (status === 401) {
119 | throw new Error('FeedMob API request failed: Unauthorized (Invalid API Key or Token)');
120 | } else if (status === 400) {
121 | throw new Error('FeedMob API request failed: Bad Request');
122 | } else if (status === 404) {
123 | throw new Error('FeedMob API request failed: Not Found');
124 | } else if (status === 500 && errorData) {
125 | throw new Error(`FeedMob API request failed: ${errorData.error} - ${errorData.message}`);
126 | } else {
127 | throw new Error(`FeedMob API request failed: ${status || 'Unknown error'}`);
128 | }
129 | }
130 | throw new Error('Failed to fetch impact campaign mappings from FeedMob API');
131 | }
132 | }
133 |
```
--------------------------------------------------------------------------------
/src/civitai-records/src/tools/syncPostAssetStats.ts:
--------------------------------------------------------------------------------
```typescript
1 | import type { ContentResult } from "fastmcp";
2 | import { z } from "zod";
3 | import { PrismaClient } from "@prisma/client";
4 | import { fetchCivitaiPostImageStats } from "../lib/civitaiApi.js";
5 | import { handleDatabaseError } from "../lib/handleDatabaseError.js";
6 |
7 | const prisma = new PrismaClient();
8 |
9 | export const syncPostAssetStatsParameters = z.object({
10 | civitai_post_id: z
11 | .string()
12 | .min(1)
13 | .describe("The Civitai post ID to sync all image stats for."),
14 | });
15 |
16 | export type SyncPostAssetStatsParameters = z.infer<
17 | typeof syncPostAssetStatsParameters
18 | >;
19 |
20 | export const syncPostAssetStatsTool = {
21 | name: "sync_post_asset_stats",
22 | description:
23 | "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).",
24 | parameters: syncPostAssetStatsParameters,
25 | execute: async ({
26 | civitai_post_id,
27 | }: SyncPostAssetStatsParameters): Promise<ContentResult> => {
28 | const allStats = await fetchCivitaiPostImageStats(civitai_post_id);
29 |
30 | if (allStats.length === 0) {
31 | return {
32 | content: [
33 | {
34 | type: "text",
35 | text: JSON.stringify(
36 | {
37 | success: true,
38 | civitai_post_id,
39 | message: "No images found for this post",
40 | synced_count: 0,
41 | skipped_count: 0,
42 | failed: [],
43 | },
44 | null,
45 | 2
46 | ),
47 | },
48 | ],
49 | } satisfies ContentResult;
50 | }
51 |
52 | const civitaiIds = allStats.map((s) => s.civitai_id);
53 | const assets = await prisma.assets.findMany({
54 | where: { civitai_id: { in: civitaiIds } },
55 | select: { id: true, civitai_id: true, uri: true, post_id: true, on_behalf_of: true },
56 | });
57 |
58 | const assetMap = new Map(assets.map((a) => [a.civitai_id, a]));
59 |
60 | let syncedCount = 0;
61 | let skippedCount = 0;
62 | const failed: Array<{ civitai_id: string; error: string }> = [];
63 |
64 | for (const stats of allStats) {
65 | const asset = assetMap.get(stats.civitai_id);
66 |
67 | if (!asset) {
68 | skippedCount++;
69 | continue;
70 | }
71 |
72 | try {
73 | await prisma.asset_stats.upsert({
74 | where: { asset_id: asset.id },
75 | update: {
76 | cry_count: BigInt(stats.cry_count),
77 | laugh_count: BigInt(stats.laugh_count),
78 | like_count: BigInt(stats.like_count),
79 | dislike_count: BigInt(stats.dislike_count),
80 | heart_count: BigInt(stats.heart_count),
81 | comment_count: BigInt(stats.comment_count),
82 | civitai_created_at: stats.civitai_created_at ? new Date(stats.civitai_created_at) : null,
83 | civitai_account: stats.civitai_account,
84 | post_id: asset.post_id,
85 | on_behalf_of: asset.on_behalf_of,
86 | updated_at: new Date(),
87 | },
88 | create: {
89 | asset_id: asset.id,
90 | cry_count: BigInt(stats.cry_count),
91 | laugh_count: BigInt(stats.laugh_count),
92 | like_count: BigInt(stats.like_count),
93 | dislike_count: BigInt(stats.dislike_count),
94 | heart_count: BigInt(stats.heart_count),
95 | comment_count: BigInt(stats.comment_count),
96 | civitai_created_at: stats.civitai_created_at ? new Date(stats.civitai_created_at) : null,
97 | civitai_account: stats.civitai_account,
98 | post_id: asset.post_id,
99 | on_behalf_of: asset.on_behalf_of,
100 | },
101 | });
102 |
103 | syncedCount++;
104 | } catch (error) {
105 | try {
106 | handleDatabaseError(error, `Civitai ID: ${stats.civitai_id}`);
107 | } catch (handledError) {
108 | failed.push({
109 | civitai_id: stats.civitai_id,
110 | error: handledError instanceof Error ? handledError.message : String(handledError),
111 | });
112 | }
113 | }
114 | }
115 |
116 | return {
117 | content: [
118 | {
119 | type: "text",
120 | text: JSON.stringify(
121 | {
122 | success: true,
123 | civitai_post_id,
124 | total_images_fetched: allStats.length,
125 | synced_count: syncedCount,
126 | skipped_count: skippedCount,
127 | failed_count: failed.length,
128 | ...(failed.length > 0 && { failed }),
129 | },
130 | null,
131 | 2
132 | ),
133 | },
134 | ],
135 | } satisfies ContentResult;
136 | },
137 | };
138 |
```
--------------------------------------------------------------------------------
/src/n8n-nodes-feedmob-direct-spend-visualizer/credentials/FeedmobDirectSpendVisualizerApi.credentials.ts:
--------------------------------------------------------------------------------
```typescript
1 | import type { INodeProperties } from 'n8n-workflow';
2 |
3 | export class FeedmobDirectSpendVisualizerApi {
4 | name = 'feedmobDirectSpendVisualizerApi';
5 | displayName = 'FeedMob Direct Spend Visualizer';
6 |
7 | properties: INodeProperties[] = [
8 | {
9 | displayName: 'Provider',
10 | name: 'provider',
11 | type: 'options',
12 | options: [
13 | { name: 'AWS Bedrock', value: 'aws_bedrock' },
14 | { name: 'Zhipu AI (GLM)', value: 'glm' },
15 | ],
16 | default: 'aws_bedrock',
17 | required: true,
18 | description: 'Choose the AI provider to use.',
19 | },
20 | // --- AWS Bedrock Fields ---
21 | {
22 | displayName: 'AWS Region',
23 | name: 'awsRegion',
24 | type: 'string',
25 | default: 'us-east-1',
26 | required: true,
27 | displayOptions: {
28 | show: {
29 | provider: ['aws_bedrock'],
30 | },
31 | },
32 | description: 'Region used for the Bedrock-based Claude Agent SDK run.',
33 | },
34 | {
35 | displayName: 'AWS Access Key ID',
36 | name: 'awsAccessKeyId',
37 | type: 'string',
38 | default: '',
39 | required: true,
40 | displayOptions: {
41 | show: {
42 | provider: ['aws_bedrock'],
43 | },
44 | },
45 | description: 'Access key with permission to invoke Bedrock Claude models.',
46 | },
47 | {
48 | displayName: 'AWS Secret Access Key',
49 | name: 'awsSecretAccessKey',
50 | type: 'string',
51 | typeOptions: { password: true },
52 | default: '',
53 | required: true,
54 | displayOptions: {
55 | show: {
56 | provider: ['aws_bedrock'],
57 | },
58 | },
59 | description: 'Secret for the above access key.',
60 | },
61 | {
62 | displayName: 'Anthropic Model (primary)',
63 | name: 'anthropicModel',
64 | type: 'string',
65 | default: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0',
66 | required: true,
67 | displayOptions: {
68 | show: {
69 | provider: ['aws_bedrock'],
70 | },
71 | },
72 | description: 'Model identifier passed through to CLAUDE_CODE on Bedrock.',
73 | },
74 | {
75 | displayName: 'Anthropic Model (fast)',
76 | name: 'anthropicSmallModel',
77 | type: 'string',
78 | default: 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
79 | required: true,
80 | displayOptions: {
81 | show: {
82 | provider: ['aws_bedrock'],
83 | },
84 | },
85 | description: 'Fast/cheap model identifier for CLAUDE_CODE small tasks.',
86 | },
87 | // --- GLM / Zhipu AI Fields ---
88 | {
89 | displayName: 'Base URL',
90 | name: 'anthropicBaseUrl',
91 | type: 'string',
92 | default: 'https://open.bigmodel.cn/api/anthropic',
93 | required: true,
94 | displayOptions: {
95 | show: {
96 | provider: ['glm'],
97 | },
98 | },
99 | description: 'Base URL for the GLM / Zhipu AI API (compatible with Anthropic SDK).',
100 | },
101 | {
102 | displayName: 'API Key',
103 | name: 'anthropicAuthToken',
104 | type: 'string',
105 | typeOptions: { password: true },
106 | default: '',
107 | required: true,
108 | displayOptions: {
109 | show: {
110 | provider: ['glm'],
111 | },
112 | },
113 | description: 'API Key for GLM / Zhipu AI.',
114 | },
115 | {
116 | displayName: 'Model',
117 | name: 'glmModel',
118 | type: 'string',
119 | default: 'glm-4.6',
120 | required: true,
121 | displayOptions: {
122 | show: {
123 | provider: ['glm'],
124 | },
125 | },
126 | description: 'Model identifier for GLM.',
127 | },
128 | {
129 | displayName: 'Small/Fast Model',
130 | name: 'glmSmallModel',
131 | type: 'string',
132 | default: 'glm-4.6',
133 | required: true,
134 | displayOptions: {
135 | show: {
136 | provider: ['glm'],
137 | },
138 | },
139 | description: 'Fast/cheap model identifier for GLM (can be same as main model).',
140 | },
141 | // --- Common Fields ---
142 | {
143 | displayName: 'FeedMob Key',
144 | name: 'feedmobKey',
145 | type: 'string',
146 | typeOptions: { password: true },
147 | default: '',
148 | required: true,
149 | description: 'FEEDMOB_KEY used by the plugin’s FeedMob MCP server (generate from https://admin.feedmob.com/api_keys).',
150 | },
151 | {
152 | displayName: 'FeedMob Secret',
153 | name: 'feedmobSecret',
154 | type: 'string',
155 | typeOptions: { password: true },
156 | default: '',
157 | required: true,
158 | description: 'FEEDMOB_SECRET used by the plugin’s FeedMob MCP server (generate from https://admin.feedmob.com/api_keys).',
159 | },
160 | {
161 | displayName: 'FeedMob API Base',
162 | name: 'feedmobApiBase',
163 | type: 'string',
164 | default: 'https://admin.feedmob.com',
165 | required: true,
166 | description: 'FEEDMOB_API_BASE base URL expected by the plugin.',
167 | },
168 | ];
169 | }
170 |
```
--------------------------------------------------------------------------------
/src/civitai-records/src/tools/fetchCivitaiPostAssets.ts:
--------------------------------------------------------------------------------
```typescript
1 | import type { ContentResult } from "fastmcp";
2 | import { z } from "zod";
3 |
4 | const civitaiImageStatsSchema = z
5 | .object({
6 | cryCount: z.number().int().nonnegative().optional(),
7 | laughCount: z.number().int().nonnegative().optional(),
8 | likeCount: z.number().int().nonnegative().optional(),
9 | dislikeCount: z.number().int().nonnegative().optional(),
10 | heartCount: z.number().int().nonnegative().optional(),
11 | commentCount: z.number().int().nonnegative().optional(),
12 | })
13 | .partial()
14 | .optional();
15 |
16 | const civitaiImageItemSchema = z.object({
17 | id: z.number(),
18 | url: z.string().url(),
19 | width: z.number().int().positive().optional(),
20 | height: z.number().int().positive().optional(),
21 | nsfwLevel: z.string().nullable().optional(),
22 | type: z.string().min(1),
23 | nsfw: z.boolean().optional(),
24 | browsingLevel: z.number().int().optional(),
25 | createdAt: z.string().optional(),
26 | postId: z.number().optional(),
27 | stats: civitaiImageStatsSchema,
28 | meta: z.record(z.any()).nullable().optional(),
29 | username: z.string().nullable().optional(),
30 | baseModel: z.string().nullable().optional(),
31 | modelVersionIds: z.array(z.number()).optional(),
32 | });
33 |
34 | const civitaiImagesResponseSchema = z.object({
35 | items: z.array(civitaiImageItemSchema),
36 | metadata: z.record(z.any()).optional(),
37 | });
38 |
39 | export const fetchCivitaiPostAssetsParameters = z.object({
40 | post_id: z
41 | .string()
42 | .min(1)
43 | .describe(
44 | "Numeric post ID from the Civitai post URL. Example: '23683656' extracted from https://civitai.com/posts/23683656."
45 | ),
46 | limit: z
47 | .number()
48 | .int()
49 | .min(1)
50 | .max(100)
51 | .default(50)
52 | .describe("Maximum number of assets to fetch per page. Civitai caps this at 100."),
53 | page: z
54 | .number()
55 | .int()
56 | .min(1)
57 | .default(1)
58 | .describe("Page number for pagination. Starts at 1."),
59 | });
60 |
61 | export type FetchCivitaiPostAssetsParameters = z.infer<
62 | typeof fetchCivitaiPostAssetsParameters
63 | >;
64 |
65 | function normalizeItem(item: z.infer<typeof civitaiImageItemSchema>) {
66 | const stats = item.stats ?? {};
67 | return {
68 | civitai_image_id: item.id.toString(),
69 | civitai_post_id: item.postId?.toString() ?? null,
70 | asset_url: item.url,
71 | type: item.type,
72 | dimensions: {
73 | width: item.width ?? null,
74 | height: item.height ?? null,
75 | },
76 | nsfw: item.nsfw ?? false,
77 | nsfw_level: item.nsfwLevel ?? null,
78 | browsing_level: item.browsingLevel ?? null,
79 | created_at: item.createdAt ?? null,
80 | username: item.username ?? null,
81 | base_model: item.baseModel ?? null,
82 | model_version_ids: item.modelVersionIds?.map(String) ?? [],
83 | engagement_stats: {
84 | cry: stats.cryCount ?? 0,
85 | laugh: stats.laughCount ?? 0,
86 | like: stats.likeCount ?? 0,
87 | dislike: stats.dislikeCount ?? 0,
88 | heart: stats.heartCount ?? 0,
89 | comment: stats.commentCount ?? 0,
90 | },
91 | metadata: item.meta ?? null,
92 | };
93 | }
94 |
95 | export const fetchCivitaiPostAssetsTool = {
96 | name: "fetch_civitai_post_assets",
97 | description:
98 | "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.",
99 | parameters: fetchCivitaiPostAssetsParameters,
100 | execute: async ({
101 | post_id,
102 | limit,
103 | page,
104 | }: FetchCivitaiPostAssetsParameters): Promise<ContentResult> => {
105 | const trimmedPostId = post_id.trim();
106 | if (!/^[0-9]+$/.test(trimmedPostId)) {
107 | throw new Error("post_id must be a numeric string");
108 | }
109 |
110 | const url = new URL("https://civitai.com/api/v1/images");
111 | url.searchParams.set("postId", trimmedPostId);
112 | url.searchParams.set("limit", limit.toString());
113 | url.searchParams.set("page", page.toString());
114 |
115 | const response = await fetch(url, {
116 | method: "GET",
117 | headers: {
118 | Accept: "application/json",
119 | },
120 | });
121 |
122 | if (!response.ok) {
123 | throw new Error(
124 | `Failed to fetch assets from Civitai (status ${response.status} ${response.statusText})`
125 | );
126 | }
127 |
128 | let json: unknown;
129 | try {
130 | json = await response.json();
131 | } catch (error) {
132 | throw new Error("Civitai response was not valid JSON");
133 | }
134 |
135 | const parsed = civitaiImagesResponseSchema.parse(json);
136 | const assets = parsed.items.map(normalizeItem);
137 |
138 | return {
139 | content: [
140 | {
141 | type: "text",
142 | text: JSON.stringify(
143 | {
144 | post_id: trimmedPostId,
145 | page,
146 | limit,
147 | asset_count: assets.length,
148 | assets,
149 | metadata: parsed.metadata ?? null,
150 | },
151 | null,
152 | 2
153 | ),
154 | },
155 | ],
156 | } satisfies ContentResult;
157 | },
158 | };
159 |
```
--------------------------------------------------------------------------------
/src/applovin-reporting/src/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | #!/usr/bin/env node
2 |
3 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5 | import { z } from "zod";
6 | import fetch from "node-fetch";
7 | import dotenv from "dotenv";
8 |
9 | dotenv.config();
10 |
11 | const server = new McpServer({
12 | name: "AppLovin Reporting MCP Server",
13 | version: "0.0.1"
14 | });
15 |
16 | const APPLOVIN_API_BASE_URL = "https://r.applovin.com/report";
17 | const APPLOVIN_API_KEY = process.env.APPLOVIN_API_KEY || '';
18 |
19 | if (!APPLOVIN_API_KEY) {
20 | console.error("Missing AppLovin API credentials. Please set APPLOVIN_API_KEY environment variable.");
21 | process.exit(1);
22 | }
23 |
24 | /**
25 | * Make request to AppLovin Reporting API
26 | */
27 | async function fetchAppLovinReport(params: Record<string, string>) {
28 | // Construct URL with query parameters
29 | const queryParams = new URLSearchParams({
30 | api_key: APPLOVIN_API_KEY,
31 | ...params
32 | });
33 |
34 | const reportUrl = `${APPLOVIN_API_BASE_URL}?${queryParams.toString()}`;
35 |
36 | try {
37 | console.error(`Requesting AppLovin report with params: ${JSON.stringify(params)}`);
38 | const response = await fetch(reportUrl, {
39 | method: 'GET',
40 | headers: {
41 | 'Accept': 'application/json; */*'
42 | }
43 | });
44 |
45 | if (!response.ok) {
46 | const errorBody = await response.text();
47 | console.error(`AppLovin API Error Response: ${response.status} ${response.statusText} - ${errorBody}`);
48 | throw new Error(`API request failed: ${response.status} ${response.statusText}`);
49 | }
50 |
51 | const contentType = response.headers.get('content-type');
52 | if (contentType && contentType.includes('application/json')) {
53 | return await response.json();
54 | } else {
55 | // If response is CSV or other format
56 | return await response.text();
57 | }
58 | } catch (error: any) {
59 | console.error(`Error fetching AppLovin report:`, error);
60 | throw new Error(`Failed to get AppLovin report: ${error.message}`);
61 | }
62 | }
63 |
64 | // Tool: Get Advertiser Report
65 | server.tool("get_advertiser_report",
66 | "Get campaign spending data from AppLovin Reporting API for advertisers.",
67 | {
68 | 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)"),
69 | 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)"),
70 | columns: z.string().optional().describe("Comma-separated list of columns to include (e.g., 'day,campaign,impressions,clicks,conversions,cost')"),
71 | format: z.enum(["json", "csv"]).default("json").describe("Format of the report data"),
72 | filter_campaign: z.string().optional().describe("Filter results by campaign name"),
73 | filter_country: z.string().optional().describe("Filter results by country (e.g., 'US,JP')"),
74 | filter_platform: z.string().optional().describe("Filter results by platform (e.g., 'android,ios')"),
75 | sort_column: z.string().optional().describe("Column to sort by (e.g., 'cost')"),
76 | sort_order: z.enum(["ASC", "DESC"]).optional().describe("Sort order (ASC or DESC)")
77 | }, async ({ start_date, end_date, columns, format, filter_campaign, filter_country, filter_platform, sort_column, sort_order }) => {
78 | try {
79 | // Validate date range logic
80 | if (new Date(start_date) > new Date(end_date)) {
81 | throw new Error("Start date cannot be after end date.");
82 | }
83 |
84 | // Default columns if not specified
85 | const reportColumns = columns || "day,campaign,impressions,clicks,ctr,conversions,conversion_rate,cost";
86 |
87 | // Build parameters
88 | const params: Record<string, string> = {
89 | start: start_date,
90 | end: end_date,
91 | columns: reportColumns,
92 | format: format,
93 | report_type: "advertiser" // Specify advertiser report type
94 | };
95 |
96 | // Add optional filters if provided
97 | if (filter_campaign) params['filter_campaign'] = filter_campaign;
98 | if (filter_country) params['filter_country'] = filter_country;
99 | if (filter_platform) params['filter_platform'] = filter_platform;
100 |
101 | // Add sorting if provided
102 | if (sort_column && sort_order) {
103 | params[`sort_${sort_column}`] = sort_order;
104 | }
105 |
106 | const data = await fetchAppLovinReport(params);
107 |
108 | return {
109 | content: [
110 | {
111 | type: "text",
112 | text: typeof data === 'string' ? data : JSON.stringify(data, null, 2)
113 | }
114 | ]
115 | };
116 | } catch (error: any) {
117 | const errorMessage = `Error getting AppLovin advertiser report: ${error.message}`;
118 |
119 | return {
120 | content: [
121 | {
122 | type: "text",
123 | text: errorMessage
124 | }
125 | ],
126 | isError: true
127 | };
128 | }
129 | });
130 |
131 |
132 | // Start server
133 | async function runServer() {
134 | const transport = new StdioServerTransport();
135 | await server.connect(transport);
136 | console.error("AppLovin Reporting MCP Server running on stdio");
137 | }
138 |
139 | runServer().catch((error) => {
140 | console.error("Fatal error running server:", error);
141 | process.exit(1);
142 | });
143 |
```
--------------------------------------------------------------------------------
/src/github-issues/operations/issues.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { z } from "zod";
2 | import { githubRequest, buildUrl } from "../common/utils.js";
3 | import { subDays, format } from 'date-fns';
4 |
5 | export const FeedmobSearchOptions = z.object({
6 | scheam: z.string().describe("get from system resources issues/search_schema"),
7 | start_date: z.string().default(format(subDays(new Date(), 30), 'yyyy-MM-dd')).describe("The creation start date of the issue"),
8 | end_date: z.string().default(format(new Date(), 'yyyy-MM-dd')).describe("The creation end date of the issue"),
9 | status: z.string().optional().describe("The status of the issue, e.g., 'open', 'closed'"),
10 | 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."),
11 | 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"),
12 | team: z.string().optional().describe("The team name, e.g., 'Star', 'Mighty'"),
13 | title: z.string().optional().describe("The title of the issue, supports fuzzy matching"),
14 | labels: z.array(z.string()).optional().describe("Labels to filter issues by"),
15 | score_status: z.string().optional().describe("The issue score status, e.g., 'not scored', 'scored'"),
16 | 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'"),
17 | });
18 |
19 | export const GetIssueSchema = z.object({
20 | comment_count: z.string().default('all').describe("Get all comments, or a specified number of comments, by default starting from the latest submission."),
21 | repo_issues: z.array(z.object({
22 | repo: z.string(),
23 | issue_number: z.number()
24 | }))
25 | });
26 |
27 | export const IssueCommentSchema = z.object({
28 | owner: z.string(),
29 | repo: z.string().describe("The repository name, e.g., 'feedmob', 'tracking_admin'"),
30 | issue_number: z.number(),
31 | body: z.string(),
32 | });
33 |
34 | export const CreateIssueOptionsSchema = z.object({
35 | title: z.string(),
36 | body: z.string().optional(),
37 | assignees: z.array(z.string()).optional(),
38 | milestone: z.number().optional(),
39 | labels: z.array(z.string()).optional(),
40 | });
41 |
42 | export const CreateIssueSchema = z.object({
43 | owner: z.string().optional(),
44 | repo: z.string().describe("The repository name, e.g., 'feedmob', 'tracking_admin'"),
45 | ...CreateIssueOptionsSchema.shape,
46 | });
47 |
48 | export const ListIssuesOptionsSchema = z.object({
49 | owner: z.string(),
50 | repo: z.string(),
51 | direction: z.enum(["asc", "desc"]).optional(),
52 | labels: z.array(z.string()).optional(),
53 | page: z.number().optional(),
54 | per_page: z.number().optional(),
55 | since: z.string().optional(),
56 | sort: z.enum(["created", "updated", "comments"]).optional(),
57 | state: z.enum(["open", "closed", "all"]).optional(),
58 | });
59 |
60 | export const UpdateIssueOptionsSchema = z.object({
61 | owner: z.string(),
62 | repo: z.string().describe("The repository name, e.g., 'feedmob', 'tracking_admin'"),
63 | issue_number: z.number(),
64 | title: z.string().optional(),
65 | body: z.string().optional(),
66 | assignees: z.array(z.string()).optional(),
67 | milestone: z.number().optional(),
68 | labels: z.array(z.string()).optional(),
69 | state: z.enum(["open", "closed"]).optional(),
70 | });
71 |
72 | export async function getIssue(owner: string, repo: string, issue_number: number) {
73 | return githubRequest(`https://api.github.com/repos/${owner}/${repo}/issues/${issue_number}`);
74 | }
75 |
76 | export async function addIssueComment(
77 | owner: string,
78 | repo: string,
79 | issue_number: number,
80 | body: string
81 | ) {
82 | return githubRequest(`https://api.github.com/repos/${owner}/${repo}/issues/${issue_number}/comments`, {
83 | method: "POST",
84 | body: { body },
85 | });
86 | }
87 |
88 | export async function createIssue(
89 | owner: string,
90 | repo: string,
91 | options: z.infer<typeof CreateIssueOptionsSchema>
92 | ) {
93 | return githubRequest(
94 | `https://api.github.com/repos/${owner}/${repo}/issues`,
95 | {
96 | method: "POST",
97 | body: options,
98 | }
99 | );
100 | }
101 |
102 | export async function listIssues(
103 | owner: string,
104 | repo: string,
105 | options: Omit<z.infer<typeof ListIssuesOptionsSchema>, "owner" | "repo">
106 | ) {
107 | const urlParams: Record<string, string | undefined> = {
108 | direction: options.direction,
109 | labels: options.labels?.join(","),
110 | page: options.page?.toString(),
111 | per_page: options.per_page?.toString(),
112 | since: options.since,
113 | sort: options.sort,
114 | state: options.state
115 | };
116 |
117 | return githubRequest(
118 | buildUrl(`https://api.github.com/repos/${owner}/${repo}/issues`, urlParams)
119 | );
120 | }
121 |
122 | export async function updateIssue(
123 | owner: string,
124 | repo: string,
125 | issue_number: number,
126 | options: Omit<z.infer<typeof UpdateIssueOptionsSchema>, "owner" | "repo" | "issue_number">
127 | ) {
128 | return githubRequest(
129 | `https://api.github.com/repos/${owner}/${repo}/issues/${issue_number}`,
130 | {
131 | method: "PATCH",
132 | body: options,
133 | }
134 | );
135 | }
136 |
```
--------------------------------------------------------------------------------
/src/work-journals/src/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | #!/usr/bin/env node
2 |
3 | import { FastMCP } from "fastmcp";
4 | import { z } from "zod";
5 | import { subDays, format } from 'date-fns';
6 |
7 | // Create FastMCP server instance
8 | const server = new FastMCP({
9 | name: "work-journals",
10 | version: "0.0.1",
11 | instructions: `
12 | This is an MCP server for querying and managing work journals.
13 | `.trim(),
14 | });
15 |
16 | const API_URL = process.env.API_URL;
17 | const API_TOKEN = process.env.API_TOKEN;
18 |
19 | if (!API_URL || !API_TOKEN) {
20 | console.error("Error: API_URL, API_TOKEN environment variables must be set.");
21 | process.exit(1);
22 | }
23 |
24 | server.addTool({
25 | name: "query_journals",
26 | description: "Queries work journals, supporting filtering by date range, user ID, and team ID.",
27 | parameters: z.object({
28 | scheam: z.string().describe("get from system resources time-off-api-scheam://usage"),
29 | 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"),
30 | end_date: z.string().default(format(new Date(), 'yyyy-MM-dd')).describe("End date (YYYY-MM-DD format), defaults to today"),
31 | current_user_only: z.boolean().optional().describe("Whether to query only the current user's work journals (true/false)"),
32 | user_ids: z.array(z.string()).optional().describe("List of user IDs (array)"),
33 | team_ids: z.array(z.string()).optional().describe("List of team IDs (array)"),
34 |
35 | }),
36 | execute: async (args, { log }) => {
37 | try {
38 | const queryParams = new URLSearchParams();
39 |
40 | if (args.start_date) queryParams.append('start_date', args.start_date);
41 | if (args.end_date) queryParams.append('end_date', args.end_date);
42 | if (args.current_user_only !== undefined) queryParams.append('current_user_only', String(args.current_user_only));
43 | if (args.user_ids) {
44 | args.user_ids.forEach(id => queryParams.append('user_ids[]', id));
45 | }
46 | if (args.team_ids) {
47 | args.team_ids.forEach(id => queryParams.append('team_ids[]', id));
48 | }
49 |
50 | const apiUrl = `${API_URL}/journals?${queryParams.toString()}`;
51 | const response = await fetch(apiUrl, {
52 | method: 'GET',
53 | headers: {
54 | 'Content-Type': 'application/json',
55 | 'Accept': 'application/json',
56 | 'Authorization': `Bearer ${API_TOKEN}`,
57 | },
58 | });
59 |
60 | if (!response.ok) {
61 | throw new Error(`API request failed: ${response.status} ${response.statusText}`);
62 | }
63 |
64 | const data = await response.json();
65 |
66 | return {
67 | content: [
68 | {
69 | type: "text",
70 | text: `# Work Journal Query Result
71 | **Query Parameters:**
72 | - Start Date: ${args.start_date || 'default'}
73 | - End Date: ${args.end_date || 'default'}
74 | - Current User Only: ${args.current_user_only !== undefined ? args.current_user_only : 'default'}
75 | - User IDs: ${args.user_ids?.join(', ') || 'None'}
76 | - Team IDs: ${args.team_ids?.join(', ') || 'None'}
77 | **Raw JSON Data:**
78 | \`\`\`json
79 | ${JSON.stringify(data, null, 2)}
80 | \`\`\`
81 | `,
82 | },
83 | ],
84 | };
85 | } catch (error: unknown) {
86 | throw new Error(`Failed to query work journals: ${(error as Error).message}`);
87 | }
88 | },
89 | });
90 |
91 | server.addTool({
92 | name: "create_or_update_journal",
93 | description: "Creates or updates a work journal.",
94 | parameters: z.object({
95 | date: z.string().describe("Journal date (YYYY-MM-DD format)"),
96 | content: z.string().describe("Journal content"),
97 | }),
98 | execute: async (args, { log }) => {
99 | try {
100 | const apiUrl = `${API_URL}/journals/create_or_update`;
101 | const response = await fetch(apiUrl, {
102 | method: 'POST',
103 | headers: {
104 | 'Content-Type': 'application/json',
105 | 'Accept': 'application/json',
106 | 'Authorization': `Bearer ${API_TOKEN}`,
107 | },
108 | body: JSON.stringify({
109 | date: args.date,
110 | content: args.content
111 | }),
112 | });
113 |
114 | if (!response.ok) {
115 | throw new Error(`API request failed: ${response.status} ${response.statusText}`);
116 | }
117 |
118 | const data = await response.json();
119 |
120 | return {
121 | content: [
122 | {
123 | type: "text",
124 | text: `# Work Journal Create/Update Result
125 | **Date:** ${args.date}
126 | **Content:** ${args.content}
127 | **Response Information:**
128 | \`\`\`json
129 | ${JSON.stringify(data, null, 2)}
130 | \`\`\`
131 | `,
132 | },
133 | ],
134 | };
135 | } catch (error: unknown) {
136 | throw new Error(`Failed to create or update work journal: ${(error as Error).message}`);
137 | }
138 | },
139 | });
140 |
141 | server.addResource({
142 | uri: "time-off-api-scheam://usage",
143 | name: "Time Off API Scheam, include Teams, Users Infomation",
144 | mimeType: "text/json",
145 | async load() {
146 | const url = `${API_URL}/schema`;
147 | try {
148 | const response = await fetch(url, {
149 | method: 'GET',
150 | headers: {
151 | 'Accept': 'application/json',
152 | 'Content-Type': 'application/json',
153 | 'Authorization': "Bearer " + API_TOKEN
154 | },
155 | });
156 |
157 | const text = await response.text();
158 | return {
159 | text: text,
160 | };
161 | } catch (error) {
162 | console.error("Error loading resource from URL:", error);
163 | throw new Error(`Failed to load resource from URL: ${(error as Error).message}`);
164 | }
165 | },
166 | });
167 |
168 | server.start({
169 | transportType: "stdio"
170 | });
171 |
```
--------------------------------------------------------------------------------
/src/imagekit/src/services/imageKitUpload.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { z } from "zod";
2 |
3 | import ImageKit, { APIError, ImageKitError } from "@imagekit/nodejs";
4 | import type {
5 | FileUploadParams,
6 | FileUploadResponse,
7 | } from "@imagekit/nodejs/resources/files/files.js";
8 |
9 | import type {
10 | ImageUploadRequest,
11 | ImageUploadResult,
12 | ImageUploader,
13 | } from "./imageUploader.js";
14 |
15 | const DEFAULT_FOLDER = "upload/";
16 | const DEFAULT_TAGS = ["upload"] as const;
17 | const DEFAULT_BASE_URL = "https://upload.imagekit.io";
18 |
19 | const baseRequestSchema = z.object({
20 | file: z
21 | .string()
22 | .trim()
23 | .min(1, { message: "file must be at least 1 character" })
24 | .describe(
25 | "File contents to upload. Provide a base64 string, binary buffer encoded as base64, a publicly accessible URL, or a local file path.",
26 | ),
27 | fileName: z
28 | .string()
29 | .trim()
30 | .min(1, { message: "fileName is required" })
31 | .describe("Target filename to assign in ImageKit"),
32 | folder: z
33 | .string()
34 | .trim()
35 | .min(1, { message: "folder must be at least 1 character" })
36 | .optional()
37 | .default(DEFAULT_FOLDER)
38 | .describe("Optional folder path such as /marketing/banners"),
39 | tags: z
40 | .array(z.string().trim().min(1, { message: "tags cannot be empty" }))
41 | .max(30, { message: "Up to 30 tags are supported by ImageKit" })
42 | .optional()
43 | .default([...DEFAULT_TAGS])
44 | .describe("Optional tags to attach to the asset."),
45 | });
46 |
47 | const imageKitOptionsSchema = z.object({
48 | useUniqueFileName: z
49 | .boolean()
50 | .default(true)
51 | .optional()
52 | .describe("When true, ImageKit appends a unique suffix to avoid collisions."),
53 | isPrivateFile: z
54 | .boolean()
55 | .default(false)
56 | .optional()
57 | .describe("Mark the file as private to require signed URLs."),
58 | responseFields: z
59 | .array(z.string().trim().min(1))
60 | .max(20)
61 | .optional()
62 | .describe("Restrict the upload response to the supplied fields."),
63 | customMetadata: z
64 | .record(z.union([z.string(), z.number(), z.boolean()]))
65 | .optional()
66 | .describe(
67 | "Custom metadata defined in the ImageKit dashboard. Values must be string, number, or boolean.",
68 | ),
69 | });
70 |
71 | export const imageKitUploadBaseSchema = baseRequestSchema;
72 |
73 | export const imageKitUploadParametersSchema = imageKitUploadBaseSchema.extend({
74 | options: imageKitOptionsSchema.optional(),
75 | });
76 |
77 | export type ImageKitUploadOptions = z.infer<typeof imageKitOptionsSchema>;
78 | export type ImageKitUploadRequest = ImageUploadRequest<ImageKitUploadOptions>;
79 | export type ImageKitUploadParameters = z.infer<typeof imageKitUploadParametersSchema>;
80 | export type ImageKitUploadResponse = FileUploadResponse & Record<string, unknown>;
81 |
82 | export interface ImageKitUploaderConfig {
83 | privateKey: string;
84 | baseURL?: string;
85 | uploadEndpoint?: string;
86 | }
87 |
88 | export class ImageKitUploader
89 | implements ImageUploader<
90 | ImageKitUploadRequest,
91 | ImageUploadResult<ImageKitUploadResponse>
92 | >
93 | {
94 | private readonly client: ImageKit;
95 |
96 | constructor(config: ImageKitUploaderConfig) {
97 | if (!config.privateKey?.trim()) {
98 | throw new Error("ImageKit private key is required to initialize the uploader");
99 | }
100 |
101 | const baseURL = config.baseURL ?? config.uploadEndpoint ?? DEFAULT_BASE_URL;
102 |
103 | this.client = new ImageKit({
104 | privateKey: config.privateKey.trim(),
105 | baseURL,
106 | });
107 | }
108 |
109 | async upload(
110 | request: ImageKitUploadRequest,
111 | signal?: AbortSignal,
112 | ): Promise<ImageUploadResult<ImageKitUploadResponse>> {
113 | const responseFields = request.options?.responseFields as
114 | | FileUploadParams["responseFields"]
115 | | undefined;
116 |
117 | const params: FileUploadParams = {
118 | file: request.file,
119 | fileName: request.fileName,
120 | folder: request.folder ?? DEFAULT_FOLDER,
121 | tags: request.tags?.length ? request.tags : [...DEFAULT_TAGS],
122 | useUniqueFileName:
123 | request.options?.useUniqueFileName ?? true,
124 | isPrivateFile: request.options?.isPrivateFile ?? false,
125 | responseFields,
126 | customMetadata: request.options?.customMetadata,
127 | };
128 |
129 | try {
130 | const providerData = (await this.client.files.upload(params, {
131 | signal,
132 | })) as ImageKitUploadResponse;
133 |
134 | const metadata = providerData.metadata;
135 | const normalizedMetadata =
136 | metadata && typeof metadata === "object"
137 | ? (metadata as Record<string, unknown>)
138 | : undefined;
139 |
140 | return {
141 | id: providerData.fileId,
142 | url: providerData.url,
143 | name: providerData.name,
144 | metadata: normalizedMetadata,
145 | providerData,
146 | } satisfies ImageUploadResult<ImageKitUploadResponse>;
147 | } catch (error) {
148 | if (error instanceof APIError || error instanceof ImageKitError) {
149 | const status =
150 | typeof (error as APIError).status === "number" ? (error as APIError).status : undefined;
151 | const message = error.message ?? "ImageKit upload failed";
152 | throw new Error(
153 | status
154 | ? `ImageKit upload failed with status ${status}: ${message}`
155 | : `ImageKit upload failed: ${message}`,
156 | );
157 | }
158 |
159 | throw error;
160 | }
161 | }
162 | }
163 |
164 | export async function uploadImageWithImageKit(
165 | config: ImageKitUploaderConfig,
166 | request: ImageKitUploadRequest,
167 | signal?: AbortSignal,
168 | ): Promise<ImageUploadResult<ImageKitUploadResponse>> {
169 | const uploader = new ImageKitUploader(config);
170 | return uploader.upload(request, signal);
171 | }
172 |
```
--------------------------------------------------------------------------------
/src/jampp-reporting/src/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | #!/usr/bin/env node
2 |
3 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5 | import { z } from "zod";
6 | import fetch from "node-fetch";
7 | import dotenv from "dotenv";
8 |
9 | dotenv.config();
10 |
11 | const server = new McpServer({
12 | name: "Jampp MCP Server",
13 | version: "0.1.3"
14 | });
15 |
16 | const AUTH_URL = "https://auth.jampp.com/v1/oauth/token";
17 | const API_URL = "https://reporting-api.jampp.com/v1/graphql";
18 |
19 | const CLIENT_ID = process.env.JAMPP_CLIENT_ID || '';
20 | const CLIENT_SECRET = process.env.JAMPP_CLIENT_SECRET || '';
21 |
22 | if (!CLIENT_ID || !CLIENT_SECRET) {
23 | console.error("Missing Jampp API credentials. Please set JAMPP_CLIENT_ID and JAMPP_CLIENT_SECRET environment variables.");
24 | process.exit(1);
25 | }
26 |
27 | // Token cache
28 | let accessToken: string | null = null;
29 | let tokenExpiry = 0;
30 |
31 | /**
32 | * Get valid Jampp API access token
33 | */
34 | async function getAccessToken(): Promise<string> {
35 | // Check if we have a valid token
36 | if (accessToken && Date.now() < tokenExpiry) {
37 | return accessToken;
38 | }
39 |
40 | // Request new token
41 | const params = new URLSearchParams();
42 | params.append('grant_type', 'client_credentials');
43 | params.append('client_id', CLIENT_ID);
44 | params.append('client_secret', CLIENT_SECRET);
45 |
46 | const response = await fetch(AUTH_URL, {
47 | method: 'POST',
48 | headers: {
49 | 'Content-Type': 'application/x-www-form-urlencoded',
50 | },
51 | body: params,
52 | });
53 |
54 | if (!response.ok) {
55 | throw new Error(`Authentication failed: ${response.statusText}`);
56 | }
57 |
58 | const data = await response.json();
59 | accessToken = data.access_token;
60 |
61 | // Set expiry time with 5 minutes buffer
62 | tokenExpiry = Date.now() + (data.expires_in - 300) * 1000;
63 |
64 | if (!accessToken) {
65 | throw new Error('Failed to get access token: Token is empty');
66 | }
67 |
68 | return accessToken;
69 | }
70 |
71 | /**
72 | * Execute GraphQL query
73 | */
74 | async function executeQuery(query: string, variables: Record<string, any> = {}) {
75 | const token = await getAccessToken();
76 |
77 | const response = await fetch(API_URL, {
78 | method: 'POST',
79 | headers: {
80 | 'Content-Type': 'application/json',
81 | 'Authorization': `Bearer ${token}`,
82 | },
83 | body: JSON.stringify({
84 | query,
85 | variables,
86 | }),
87 | });
88 |
89 | if (!response.ok) {
90 | throw new Error(`API request failed: ${response.statusText}`);
91 | }
92 |
93 | return response.json();
94 | }
95 |
96 | // Tool: Get campaign spend
97 | server.tool("get_campaign_spend",
98 | "Get the spend per campaign for a particular time range from Jampp Reporting API",
99 | {
100 | from_date: z.string().describe("Start date in YYYY-MM-DD format"),
101 | to_date: z.string().describe("End date in YYYY-MM-DD format")
102 | }, async ({ from_date, to_date }) => {
103 | try {
104 | // Add end of day time to to_date
105 | const endOfDay = to_date + "T23:59:59";
106 |
107 | let query = `
108 | query spendPerCampaign($from: DateTime!, $to: DateTime!) {
109 | spendPerCampaign: pivot(
110 | from: $from,
111 | to: $to
112 | ) {
113 | results {
114 | campaignId
115 | campaign
116 | impressions
117 | clicks
118 | installs
119 | spend
120 | }
121 | }
122 | }
123 | `;
124 |
125 | const variables: Record<string, any> = {
126 | from: from_date,
127 | to: endOfDay // Use the modified end date
128 | };
129 |
130 | const data = await executeQuery(query, variables);
131 |
132 | return {
133 | content: [
134 | {
135 | type: "text",
136 | text: JSON.stringify(data.data.spendPerCampaign.results, null, 2)
137 | }
138 | ]
139 | };
140 | } catch (error: any) {
141 | return {
142 | content: [
143 | {
144 | type: "text",
145 | text: `Error getting campaign spend: ${error.message}`
146 | }
147 | ],
148 | isError: true
149 | };
150 | }
151 | });
152 |
153 | // Tool: Get campaign daily spend
154 | server.tool("get_campaign_daily_spend",
155 | "Get the daily spend per campaign for a particular time range from Jampp Reporting API",
156 | {
157 | from_date: z.string().describe("Start date in YYYY-MM-DD format"),
158 | to_date: z.string().describe("End date in YYYY-MM-DD format"),
159 | campaign_id: z.number().describe("Campaign ID to filter by")
160 | }, async ({ from_date, to_date, campaign_id }) => {
161 | try {
162 |
163 | // Add end of day time to to_date
164 | const endOfDay = to_date + "T23:59:59";
165 |
166 | const query = `
167 | query dailySpend($from: DateTime!, $to: DateTime!, $campaignId: Int!) {
168 | dailySpend: pivot(
169 | from: $from,
170 | to: $to,
171 | filter: {
172 | campaignId: {
173 | equals: $campaignId
174 | }
175 | }
176 | ) {
177 | results {
178 | date(granularity: DAILY)
179 | campaignId
180 | campaign
181 | impressions
182 | clicks
183 | installs
184 | spend
185 | }
186 | }
187 | }
188 | `;
189 |
190 | const variables = {
191 | from: from_date,
192 | to: endOfDay,
193 | campaignId: campaign_id
194 | };
195 |
196 | const data = await executeQuery(query, variables);
197 |
198 | return {
199 | content: [
200 | {
201 | type: "text",
202 | text: JSON.stringify(data.data.dailySpend.results, null, 2)
203 | }
204 | ]
205 | };
206 | } catch (error: any) {
207 | return {
208 | content: [
209 | {
210 | type: "text",
211 | text: `Error getting campaign daily spend: ${error.message}`
212 | }
213 | ],
214 | isError: true
215 | };
216 | }
217 | });
218 |
219 | // Start server
220 | async function runServer() {
221 | const transport = new StdioServerTransport();
222 | await server.connect(transport);
223 | console.error("Jampp Reporting MCP Server running on stdio");
224 | }
225 |
226 | runServer().catch((error) => {
227 | console.error("Fatal error running server:", error);
228 | process.exit(1);
229 | });
230 |
```
--------------------------------------------------------------------------------
/src/imagekit/src/tools/cropAndWatermark.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { z } from "zod";
2 |
3 | import type { ImageUploadResult } from "../services/imageUploader.js";
4 | import {
5 | uploadImageWithImageKit,
6 | type ImageKitUploadOptions,
7 | type ImageKitUploadRequest,
8 | type ImageKitUploaderConfig,
9 | type ImageKitUploadResponse,
10 | } from "../services/imageKitUpload.js";
11 |
12 | const aspectRatioValues = [
13 | "1:1",
14 | "4:3",
15 | "3:4",
16 | "9:16",
17 | "16:9",
18 | "3:2",
19 | "2:3",
20 | "21:9",
21 | ] as const;
22 |
23 | export type ImageAspectRatio = (typeof aspectRatioValues)[number];
24 |
25 | export const aspectRatioSchema = z.enum(aspectRatioValues);
26 |
27 | const aspectRatioSizes: Record<ImageAspectRatio, string> = {
28 | "1:1": "2048x2048",
29 | "4:3": "2304x1728",
30 | "3:4": "1728x2304",
31 | "9:16": "1440x2560",
32 | "16:9": "2560x1440",
33 | "3:2": "2496x1664",
34 | "2:3": "1664x2496",
35 | "21:9": "3024x1296",
36 | };
37 |
38 | const DEFAULT_API_BASE_URL = "https://api.cometapi.com/v1";
39 | const GENERATION_PATH = "/images/generations";
40 |
41 | const DEFAULT_MODEL_ID = "bytedance-seedream-4-0-250828";
42 |
43 | export interface CropAndWatermarkOptions {
44 | imageUrl: string;
45 | aspectRatio: ImageAspectRatio;
46 | watermarkText?: string;
47 | apiKey: string;
48 | apiBaseUrl?: string;
49 | modelId?: string;
50 | imageKit?: ImageKitPostUploadConfig;
51 | }
52 |
53 | export interface ImageKitPostUploadConfig {
54 | config: ImageKitUploaderConfig;
55 | fileName?: string;
56 | folder?: string;
57 | tags?: string[];
58 | options?: ImageKitUploadOptions;
59 | }
60 |
61 | export async function cropAndWatermarkImage({
62 | imageUrl,
63 | aspectRatio,
64 | watermarkText = "",
65 | apiKey,
66 | apiBaseUrl,
67 | modelId = DEFAULT_MODEL_ID,
68 | imageKit,
69 | }: CropAndWatermarkOptions): Promise<string> {
70 | const payload = createGenerationPayload({
71 | aspectRatio,
72 | imageUrl,
73 | modelId,
74 | watermarkText,
75 | });
76 |
77 | const endpoint = resolveGenerationEndpoint(apiBaseUrl);
78 | const generatedUrl = await requestGeneratedImage({
79 | apiKey,
80 | endpoint,
81 | payload,
82 | });
83 |
84 | if (!imageKit) {
85 | return generatedUrl;
86 | }
87 |
88 | const uploadedUrl = await uploadGeneratedImage({
89 | generatedUrl,
90 | imageKit,
91 | });
92 |
93 | return uploadedUrl ?? generatedUrl;
94 | }
95 |
96 | export const defaultImageApiBaseUrl = DEFAULT_API_BASE_URL;
97 | export const defaultImageModelId = DEFAULT_MODEL_ID;
98 |
99 | function deriveFileNameFromUrl(sourceUrl: string): string {
100 | try {
101 | const parsed = new URL(sourceUrl);
102 | const lastSegment = parsed.pathname.split("/").filter(Boolean).pop();
103 | if (lastSegment) {
104 | return sanitizeFileName(`cropped-${lastSegment}`);
105 | }
106 | } catch {
107 | // ignore URL parsing errors and fall back to timestamped name
108 | }
109 |
110 | return `cropped-${Date.now()}.jpg`;
111 | }
112 |
113 | function sanitizeFileName(name: string): string {
114 | return name.replace(/[^A-Za-z0-9._-]/g, "_");
115 | }
116 |
117 | function getUrlFromProviderData(
118 | result: ImageUploadResult<ImageKitUploadResponse>,
119 | ): string | undefined {
120 | const providerUrl = result.providerData?.url;
121 | return typeof providerUrl === "string" && providerUrl.trim().length > 0
122 | ? providerUrl
123 | : undefined;
124 | }
125 |
126 | type GenerationPayload = ReturnType<typeof createGenerationPayload>;
127 |
128 | function createGenerationPayload({
129 | aspectRatio,
130 | imageUrl,
131 | modelId,
132 | watermarkText,
133 | }: {
134 | aspectRatio: ImageAspectRatio;
135 | imageUrl: string;
136 | modelId: string;
137 | watermarkText?: string;
138 | }) {
139 | return {
140 | model: modelId,
141 | prompt: buildGenerationPrompt(aspectRatio, watermarkText),
142 | image: imageUrl,
143 | sequential_image_generation: "disabled",
144 | response_format: "url",
145 | size: aspectRatioSizes[aspectRatio],
146 | stream: false,
147 | watermark: false,
148 | } as const;
149 | }
150 |
151 | function buildGenerationPrompt(
152 | aspectRatio: ImageAspectRatio,
153 | watermarkText?: string,
154 | ) {
155 | const promptParts = [
156 | `Crop the image to a ${aspectRatio} aspect ratio while keeping the main subject centered and intact.`,
157 | ];
158 |
159 | const trimmedWatermark = watermarkText?.trim();
160 | if (trimmedWatermark) {
161 | promptParts.push(
162 | `Add a subtle, semi-transparent watermark reading "${trimmedWatermark}" in the bottom-right corner. Ensure every other part of the image remains unchanged.`,
163 | );
164 | }
165 |
166 | return promptParts.join(" ");
167 | }
168 |
169 | function resolveGenerationEndpoint(apiBaseUrl?: string): string {
170 | const normalizedBaseUrl = (apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/$/, "");
171 | return `${normalizedBaseUrl}${GENERATION_PATH}`;
172 | }
173 |
174 | async function requestGeneratedImage({
175 | apiKey,
176 | endpoint,
177 | payload,
178 | }: {
179 | apiKey: string;
180 | endpoint: string;
181 | payload: GenerationPayload;
182 | }): Promise<string> {
183 | const response = await fetch(endpoint, {
184 | method: "POST",
185 | headers: {
186 | Authorization: `Bearer ${apiKey}`,
187 | "Content-Type": "application/json",
188 | },
189 | body: JSON.stringify(payload),
190 | });
191 |
192 | if (!response.ok) {
193 | const errorBody = await response.text();
194 | throw new Error(
195 | `Image generation API request failed with status ${response.status}: ${errorBody}`,
196 | );
197 | }
198 |
199 | const data = (await response.json()) as GenerationResponse;
200 | const url = data?.data?.[0]?.url;
201 |
202 | if (!url) {
203 | throw new Error("Image generation API response did not include an image URL");
204 | }
205 |
206 | return url;
207 | }
208 |
209 | async function uploadGeneratedImage({
210 | generatedUrl,
211 | imageKit,
212 | }: {
213 | generatedUrl: string;
214 | imageKit: ImageKitPostUploadConfig;
215 | }): Promise<string | undefined> {
216 | const uploadRequest: ImageKitUploadRequest = {
217 | file: generatedUrl,
218 | fileName: imageKit.fileName ?? deriveFileNameFromUrl(generatedUrl),
219 | folder: imageKit.folder,
220 | tags: imageKit.tags,
221 | options: imageKit.options,
222 | };
223 |
224 | const uploadResult = await uploadImageWithImageKit(imageKit.config, uploadRequest);
225 | return uploadResult.url ?? getUrlFromProviderData(uploadResult);
226 | }
227 |
228 | type GenerationResponse = {
229 | data?: Array<{ url?: string }>;
230 | };
231 |
```
--------------------------------------------------------------------------------
/src/impact-radius-reporting/src/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | #!/usr/bin/env node
2 |
3 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5 | import dotenv from "dotenv";
6 | import axios from "axios";
7 | import { z } from "zod";
8 | import type { AxiosError } from "axios";
9 | import { fetchImpactRaidusCampaignMapping } from "./fm_impact_radius_mapping.js";
10 |
11 | // Load environment variables
12 | dotenv.config();
13 |
14 | const impactRadiusSid = process.env.IMPACT_RADIUS_SID;
15 | const impactRadiusToken = process.env.IMPACT_RADIUS_TOKEN;
16 |
17 | if (!impactRadiusSid || !impactRadiusToken) {
18 | throw new Error("Missing required environment variables: IMPACT_RADIUS_SID and IMPACT_RADIUS_TOKEN");
19 | }
20 |
21 | // Create MCP server
22 | const server = new McpServer({
23 | name: "Impact Radius MCP Server",
24 | version: "0.0.1",
25 | });
26 |
27 | // Define the params for Impact Radius FCO reporting
28 | const fetchFcoParams = {
29 | start_date: z.string().describe("Start date in YYYY-MM-DD format"),
30 | end_date: z.string().describe("End date in YYYY-MM-DD format")
31 | } as const;
32 |
33 | interface ImpactRadiusRecord {
34 | date_display: string;
35 | [key: string]: any;
36 | }
37 |
38 | interface ImpactRadiusResponse {
39 | Records?: ImpactRadiusRecord[];
40 | }
41 |
42 | server.tool(
43 | "fetch_action_list_from_impact_radius",
44 | "Fetch action list from Impact Radius API with campaign mapping integration for a date range",
45 | fetchFcoParams,
46 | async (params) => {
47 | try {
48 | // First, get campaign mappings from FeedMob API
49 | const campaignMappings = await fetchImpactRaidusCampaignMapping({});
50 |
51 | if (!campaignMappings || !campaignMappings.data || !Array.isArray(campaignMappings.data)) {
52 | throw new Error("Invalid campaign mapping response");
53 | }
54 |
55 | const url = `https://api.impact.com/Mediapartners/${impactRadiusSid}/Reports/mp_action_listing_sku.json`;
56 | const allRecords: ImpactRadiusRecord[] = [];
57 |
58 | // Make API call for each mapping
59 | for (const mapping of campaignMappings.data) {
60 | const requestParams = {
61 | ResultFormat: 'JSON',
62 | StartDate: `${params.start_date}T00:00:00Z`,
63 | EndDate: `${params.end_date}T23:59:59Z`,
64 | PUB_CAMPAIGN: mapping.impact_brand || '',
65 | MP_AD_ID: mapping.impact_ad || '',
66 | PUB_ACTION_TRACKER: mapping.impact_event_type || '',
67 | SUPERSTATUS_MS: ['APPROVED', 'NA', 'PENDING'],
68 | };
69 |
70 | try {
71 | const response = await axios.get(url, {
72 | params: requestParams,
73 | auth: {
74 | username: impactRadiusSid,
75 | password: impactRadiusToken
76 | },
77 | headers: {
78 | 'Accept': 'application/json'
79 | }
80 | });
81 |
82 | if (response.status === 200) {
83 | const data: ImpactRadiusResponse = response.data;
84 | const records = data.Records || [];
85 |
86 | // Add mapping info to each record
87 | const recordsWithMapping = records.map(record => ({
88 | ...record,
89 | mapping_impact_brand: mapping.impact_brand,
90 | mapping_impact_ad: mapping.impact_ad,
91 | mapping_impact_event_type: mapping.impact_event_type,
92 | campaign: mapping.campaign_name,
93 | client_name: mapping.client_name,
94 | }));
95 |
96 | allRecords.push(...recordsWithMapping);
97 | }
98 | } catch (error) {
99 | console.error(`Error fetching data for mapping ${mapping.id}:`, error);
100 | }
101 | }
102 |
103 | return {
104 | content: [{
105 | type: "text",
106 | text: JSON.stringify({
107 | allrecords: allRecords,
108 | total_count: allRecords.length
109 | }, null, 2)
110 | }],
111 | };
112 | } catch (error) {
113 | const errorMessage = error instanceof Error
114 | ? `Impact Radius API error: ${(error as AxiosError).response?.data || error.message}`
115 | : 'Unknown error occurred';
116 | return {
117 | content: [{
118 | type: "text",
119 | text: errorMessage
120 | }],
121 | isError: true
122 | };
123 | }
124 | }
125 | );
126 |
127 | // Add documentation prompt
128 | server.prompt(
129 | "help",
130 | {},
131 | () => ({
132 | messages: [{
133 | role: "user",
134 | content: {
135 | type: "text",
136 | text: `
137 | Impact Radius MCP Server
138 |
139 | Available tools:
140 |
141 | 1. fetch_action_list_from_impact_radius
142 | Fetch action list from Impact Radius API with campaign mapping integration for a date range.
143 |
144 | Parameters:
145 | - start_date: string (required) - Start date in YYYY-MM-DD format
146 | - end_date: string (required) - End date in YYYY-MM-DD format
147 |
148 | Returns:
149 | - JSON object with allrecords array containing action data with mapping context
150 | - Each record includes: original Impact Radius action data plus mapping fields
151 | - Additional fields: mapping_impact_brand, mapping_impact_ad, mapping_impact_event_type, campaign, client_name
152 | - Includes total_count of all records returned
153 |
154 | Authentication:
155 | - Requires IMPACT_RADIUS_SID and IMPACT_RADIUS_TOKEN environment variables for Impact Radius API
156 | - Requires FEEDMOB_KEY and FEEDMOB_SECRET environment variables for FeedMob campaign mapping API
157 | - Uses HTTP Basic Authentication for Impact Radius and JWT authentication for FeedMob
158 |
159 | Data Integration:
160 | - First fetches campaign mappings from FeedMob API
161 | - Then queries Impact Radius API for each mapping configuration
162 | - Combines action data with campaign mapping context
163 |
164 | Example usage:
165 | fetch_action_list_from_impact_radius({
166 | start_date: "2024-01-01",
167 | end_date: "2024-01-31"
168 | })
169 | `
170 | }
171 | }]
172 | })
173 | );
174 |
175 | // Set up STDIO transport
176 | const transport = new StdioServerTransport();
177 |
178 | // Connect server to transport
179 | await server.connect(transport);
180 |
```
--------------------------------------------------------------------------------
/.github/workflows/publish-reporting-packages.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: Publish Reporting Packages
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | paths:
8 | - 'src/liftoff-reporting/**'
9 | - 'src/tapjoy-reporting/**'
10 | - 'src/appsamurai-reporting/**'
11 | - 'src/singular-reporting/**'
12 | - 'src/kayzen-reporting/**'
13 | - 'src/jampp-reporting/**'
14 | - 'src/applovin-reporting/**'
15 | - 'src/feedmob-reporting/**'
16 | - 'src/ironsource-reporting/**'
17 | - 'src/mintegral-reporting/**'
18 | - 'src/inmobi-reporting/**'
19 | - 'src/ironsource-aura-reporting/**'
20 | - 'src/smadex-reporting/**'
21 | - 'src/samsung-reporting/**'
22 | - 'src/rtb-house-reporting/**'
23 | - 'src/femini-reporting/**'
24 | - 'src/sensor-tower-reporting/**'
25 | - 'src/impact-radius-reporting/**'
26 |
27 | workflow_dispatch:
28 | inputs:
29 | package:
30 | description: 'Reporting package to publish (leave empty to detect from changed files)'
31 | required: false
32 | type: choice
33 | options:
34 | - ''
35 | - liftoff
36 | - tapjoy
37 | - appsamurai
38 | - singular
39 | - kayzen
40 | - jampp
41 | - applovin
42 | - feedmob
43 | - ironsource
44 | - mintegral
45 | - inmobi
46 | - ironsource-aura
47 | - smadex
48 | - samsung
49 | - rtb-house
50 | - femini
51 | - impact-radius
52 | jobs:
53 | detect-changes:
54 | runs-on: ubuntu-latest
55 | outputs:
56 | matrix: ${{ steps.set-matrix.outputs.matrix }}
57 | steps:
58 | - uses: actions/checkout@v4
59 | with:
60 | fetch-depth: 2
61 |
62 | - id: set-matrix
63 | name: Detect changed packages
64 | shell: bash
65 | run: |
66 | if [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ github.event.inputs.package }}" != "" ]]; then
67 | # Use manually specified package
68 | echo "matrix={\"package\":[\"${{ github.event.inputs.package }}\"]}" >> $GITHUB_OUTPUT
69 | else
70 | # Detect from changed files
71 | CHANGED_FILES=$(git diff --name-only HEAD^ HEAD)
72 |
73 | # Initialize an empty JSON array
74 | JSON_ARRAY="[]"
75 |
76 | # Add packages to JSON array if they have changes
77 | if echo "$CHANGED_FILES" | grep -q "src/liftoff-reporting/"; then
78 | JSON_ARRAY=$(echo $JSON_ARRAY | jq -c '. += ["liftoff"]')
79 | fi
80 | if echo "$CHANGED_FILES" | grep -q "src/tapjoy-reporting/"; then
81 | JSON_ARRAY=$(echo $JSON_ARRAY | jq -c '. += ["tapjoy"]')
82 | fi
83 | if echo "$CHANGED_FILES" | grep -q "src/appsamurai-reporting/"; then
84 | JSON_ARRAY=$(echo $JSON_ARRAY | jq -c '. += ["appsamurai"]')
85 | fi
86 | if echo "$CHANGED_FILES" | grep -q "src/singular-reporting/"; then
87 | JSON_ARRAY=$(echo $JSON_ARRAY | jq -c '. += ["singular"]')
88 | fi
89 | if echo "$CHANGED_FILES" | grep -q "src/kayzen-reporting/"; then
90 | JSON_ARRAY=$(echo $JSON_ARRAY | jq -c '. += ["kayzen"]')
91 | fi
92 | if echo "$CHANGED_FILES" | grep -q "src/jampp-reporting/"; then
93 | JSON_ARRAY=$(echo $JSON_ARRAY | jq -c '. += ["jampp"]')
94 | fi
95 | if echo "$CHANGED_FILES" | grep -q "src/applovin-reporting/"; then
96 | JSON_ARRAY=$(echo $JSON_ARRAY | jq -c '. += ["applovin"]')
97 | fi
98 | if echo "$CHANGED_FILES" | grep -q "src/feedmob-reporting/"; then
99 | JSON_ARRAY=$(echo $JSON_ARRAY | jq -c '. += ["feedmob"]')
100 | fi
101 | if echo "$CHANGED_FILES" | grep -q "src/ironsource-reporting/"; then
102 | JSON_ARRAY=$(echo $JSON_ARRAY | jq -c '. += ["ironsource"]')
103 | fi
104 | if echo "$CHANGED_FILES" | grep -q "src/mintegral-reporting/"; then
105 | JSON_ARRAY=$(echo $JSON_ARRAY | jq -c '. += ["mintegral"]')
106 | fi
107 | if echo "$CHANGED_FILES" | grep -q "src/inmobi-reporting/"; then
108 | JSON_ARRAY=$(echo $JSON_ARRAY | jq -c '. += ["inmobi"]')
109 | fi
110 | if echo "$CHANGED_FILES" | grep -q "src/ironsource-aura-reporting/"; then
111 | JSON_ARRAY=$(echo $JSON_ARRAY | jq -c '. += ["ironsource-aura"]')
112 | fi
113 | if echo "$CHANGED_FILES" | grep -q "src/smadex-reporting/"; then
114 | JSON_ARRAY=$(echo $JSON_ARRAY | jq -c '. += ["smadex"]')
115 | fi
116 | if echo "$CHANGED_FILES" | grep -q "src/samsung-reporting/"; then
117 | JSON_ARRAY=$(echo $JSON_ARRAY | jq -c '. += ["samsung"]')
118 | fi
119 | if echo "$CHANGED_FILES" | grep -q "src/rtb-house-reporting/"; then
120 | JSON_ARRAY=$(echo $JSON_ARRAY | jq -c '. += ["rtb-house"]')
121 | fi
122 | if echo "$CHANGED_FILES" | grep -q "src/femini-reporting/"; then
123 | JSON_ARRAY=$(echo $JSON_ARRAY | jq -c '. += ["femini"]')
124 | fi
125 | if echo "$CHANGED_FILES" | grep -q "src/sensor-tower-reporting/"; then
126 | JSON_ARRAY=$(echo $JSON_ARRAY | jq -c '. += ["sensor-tower"]')
127 | fi
128 | if echo "$CHANGED_FILES" | grep -q "src/impact-radius-reporting/"; then
129 | JSON_ARRAY=$(echo $JSON_ARRAY | jq -c '. += ["impact-radius"]')
130 | fi
131 | # Generate the matrix output with proper JSON formatting
132 | echo "matrix={\"package\":$JSON_ARRAY}" >> $GITHUB_OUTPUT
133 | fi
134 |
135 | publish:
136 | needs: detect-changes
137 | if: ${{ fromJSON(needs.detect-changes.outputs.matrix).package[0] != null }}
138 | runs-on: ubuntu-latest
139 | strategy:
140 | matrix: ${{ fromJSON(needs.detect-changes.outputs.matrix) }}
141 | defaults:
142 | run:
143 | working-directory: src/${{ matrix.package }}-reporting
144 |
145 | steps:
146 | - uses: actions/checkout@v4
147 |
148 | - name: Setup Node.js
149 | uses: actions/setup-node@v4
150 | with:
151 | node-version: '20'
152 | registry-url: 'https://registry.npmjs.org'
153 |
154 | - name: Install dependencies
155 | run: npm ci
156 |
157 | - name: Build
158 | run: npm run build
159 |
160 | - name: Publish to npm
161 | run: npm publish --access public
162 | env:
163 | NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTOMATION_TOKEN }}
164 |
```
--------------------------------------------------------------------------------
/src/civitai-records/src/tools/findAsset.ts:
--------------------------------------------------------------------------------
```typescript
1 | import type { ContentResult } from "fastmcp";
2 | import { z } from "zod";
3 | import { prisma } from "../lib/prisma.js";
4 |
5 | export const findAssetParameters = z.object({
6 | asset_id: z
7 | .string()
8 | .optional()
9 | .describe("The ID of the asset to find. Must be a valid integer ID."),
10 | sha256sum: z
11 | .string()
12 | .optional()
13 | .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."),
14 | civitai_id: z
15 | .string()
16 | .optional()
17 | .describe(
18 | "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'."
19 | ),
20 | civitai_url: z
21 | .string()
22 | .optional()
23 | .describe(
24 | "The Civitai media page URL (image/video) to look up. Example: 'https://civitai.com/images/106432973'."
25 | ),
26 | post_id: z
27 | .string()
28 | .optional()
29 | .describe(
30 | "The civitai_posts table ID linked to the asset. Use the ID returned from create_civitai_post or list_civitai_posts."
31 | ),
32 | }).refine(
33 | (data) =>
34 | [
35 | data.asset_id,
36 | data.sha256sum,
37 | data.civitai_id,
38 | data.civitai_url,
39 | data.post_id,
40 | ].some((value) => typeof value === "string" && value.trim().length > 0),
41 | {
42 | message: "At least one of asset_id, sha256sum, civitai_id, civitai_url, or post_id must be provided",
43 | }
44 | );
45 |
46 | export type FindAssetParameters = z.infer<typeof findAssetParameters>;
47 |
48 | export const findAssetTool = {
49 | name: "find_asset",
50 | 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.",
51 | parameters: findAssetParameters,
52 | execute: async ({
53 | asset_id,
54 | sha256sum,
55 | civitai_id,
56 | civitai_url,
57 | post_id,
58 | }: FindAssetParameters): Promise<ContentResult> => {
59 | let assetIdBigInt: bigint | undefined;
60 | if (asset_id) {
61 | const trimmed = asset_id.trim();
62 | if (trimmed) {
63 | try {
64 | assetIdBigInt = BigInt(trimmed);
65 | } catch (error) {
66 | throw new Error("asset_id must be a valid integer ID");
67 | }
68 | }
69 | }
70 |
71 | let postIdBigInt: bigint | undefined;
72 | if (post_id) {
73 | const trimmed = post_id.trim();
74 | if (trimmed) {
75 | try {
76 | postIdBigInt = BigInt(trimmed);
77 | } catch (error) {
78 | throw new Error("post_id must be a valid integer ID");
79 | }
80 | }
81 | }
82 |
83 | const whereClause: {
84 | id?: bigint;
85 | sha256sum?: string;
86 | civitai_id?: string;
87 | civitai_url?: string;
88 | post_id?: bigint | null;
89 | } = {};
90 |
91 | if (assetIdBigInt !== undefined) {
92 | whereClause.id = assetIdBigInt;
93 | }
94 |
95 | if (sha256sum) {
96 | whereClause.sha256sum = sha256sum.trim();
97 | }
98 |
99 | if (civitai_id) {
100 | const trimmed = civitai_id.trim();
101 | if (trimmed) {
102 | whereClause.civitai_id = trimmed;
103 | }
104 | }
105 |
106 | if (civitai_url) {
107 | const trimmed = civitai_url.trim();
108 | if (trimmed) {
109 | whereClause.civitai_url = trimmed;
110 | }
111 | }
112 |
113 | if (postIdBigInt !== undefined) {
114 | whereClause.post_id = postIdBigInt;
115 | }
116 |
117 | const asset = await prisma.assets.findFirst({
118 | where: whereClause,
119 | include: {
120 | prompts_assets_input_prompt_idToprompts: true,
121 | prompts_assets_output_prompt_idToprompts: true,
122 | civitai_posts: true,
123 | },
124 | });
125 |
126 | if (!asset) {
127 | return {
128 | content: [
129 | {
130 | type: "text",
131 | text: JSON.stringify({
132 | found: false,
133 | message: "No asset found matching the provided criteria",
134 | }, null, 2),
135 | },
136 | ],
137 | } satisfies ContentResult;
138 | }
139 |
140 | return {
141 | content: [
142 | {
143 | type: "text",
144 | text: JSON.stringify({
145 | found: true,
146 | asset_id: asset.id.toString(),
147 | asset_type: asset.asset_type,
148 | asset_source: asset.asset_source,
149 | uri: asset.uri,
150 | sha256sum: asset.sha256sum,
151 | civitai_id: asset.civitai_id,
152 | civitai_url: asset.civitai_url,
153 | post_id: asset.post_id?.toString() ?? null,
154 | input_prompt_id: asset.input_prompt_id?.toString() ?? null,
155 | output_prompt_id: asset.output_prompt_id?.toString() ?? null,
156 | input_asset_ids: asset.input_asset_ids.map((id: bigint) => id.toString()),
157 | metadata: asset.metadata,
158 | created_at: asset.created_at.toISOString(),
159 | updated_at: asset.updated_at.toISOString(),
160 | input_prompt: asset.prompts_assets_input_prompt_idToprompts ? {
161 | prompt_id: asset.prompts_assets_input_prompt_idToprompts.id.toString(),
162 | prompt_text: asset.prompts_assets_input_prompt_idToprompts.content,
163 | llm_model_provider: asset.prompts_assets_input_prompt_idToprompts.llm_model_provider,
164 | llm_model: asset.prompts_assets_input_prompt_idToprompts.llm_model,
165 | purpose: asset.prompts_assets_input_prompt_idToprompts.purpose,
166 | } : null,
167 | output_prompt: asset.prompts_assets_output_prompt_idToprompts ? {
168 | prompt_id: asset.prompts_assets_output_prompt_idToprompts.id.toString(),
169 | prompt_text: asset.prompts_assets_output_prompt_idToprompts.content,
170 | llm_model_provider: asset.prompts_assets_output_prompt_idToprompts.llm_model_provider,
171 | llm_model: asset.prompts_assets_output_prompt_idToprompts.llm_model,
172 | purpose: asset.prompts_assets_output_prompt_idToprompts.purpose,
173 | } : null,
174 | post: asset.civitai_posts ? {
175 | post_id: asset.civitai_posts.id.toString(),
176 | civitai_id: asset.civitai_posts.civitai_id,
177 | civitai_url: asset.civitai_posts.civitai_url,
178 | title: asset.civitai_posts.title,
179 | description: asset.civitai_posts.description,
180 | status: asset.civitai_posts.status,
181 | } : null,
182 | }, null, 2),
183 | },
184 | ],
185 | } satisfies ContentResult;
186 | },
187 | };
188 |
```
--------------------------------------------------------------------------------
/src/rtb-house-reporting/src/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | #!/usr/bin/env node
2 |
3 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5 | import { z } from "zod";
6 | import fetch from "node-fetch";
7 | import dotenv from "dotenv";
8 | import { Buffer } from 'buffer';
9 |
10 | dotenv.config();
11 |
12 | const server = new McpServer({
13 | name: "RTB House Reporting MCP Server",
14 | version: "0.0.2"
15 | });
16 |
17 | // --- RTB House Configuration ---
18 | const RTB_HOUSE_API_URL = 'https://api.panel.rtbhouse.com/v5/advertisers';
19 |
20 | const RTB_HOUSE_USER = process.env.RTB_HOUSE_USER || '';
21 | const RTB_HOUSE_PASSWORD = process.env.RTB_HOUSE_PASSWORD || '';
22 |
23 | class ConfigurationError extends Error {
24 | constructor(message: string) {
25 | super(message);
26 | this.name = 'ConfigurationError';
27 | }
28 | }
29 |
30 | function validateRtbHouseConfig(): void {
31 | if (!RTB_HOUSE_USER || !RTB_HOUSE_PASSWORD) {
32 | throw new ConfigurationError('RTB_HOUSE_USER and RTB_HOUSE_PASSWORD environment variables are required');
33 | }
34 | }
35 |
36 | // 通过 API 获取广告主列表
37 | async function fetchRtbHouseAdvertisersFromApi(): Promise<{ name: string, hash: string }[]> {
38 | validateRtbHouseConfig();
39 | const url = `${RTB_HOUSE_API_URL}?fields=name,hash`;
40 | const response = await fetch(url, {
41 | method: 'GET',
42 | headers: {
43 | 'Authorization': 'Basic ' + Buffer.from(`${RTB_HOUSE_USER}:${RTB_HOUSE_PASSWORD}`).toString('base64'),
44 | 'Accept': 'application/json',
45 | },
46 | });
47 | if (!response.ok) {
48 | const errorBody = await response.text();
49 | throw new Error(`Failed to fetch advertisers: HTTP ${response.status}: ${errorBody}`);
50 | }
51 | const res = await response.json();
52 | // 兼容返回格式
53 | if (Array.isArray(res)) return res;
54 | if (Array.isArray(res.data)) return res.data;
55 | throw new Error('Unexpected advertisers API response');
56 | }
57 |
58 | /**
59 | * Fetch RTB House data for a given date range, returns mapping of app -> full API data array
60 | */
61 | async function fetchRtbHouseData(dateFrom: string, dateTo: string, app?: string, maxRetries = 3): Promise<Record<string, any[]>> {
62 | validateRtbHouseConfig();
63 | const result: Record<string, any[]> = {};
64 | const paramsBase = {
65 | dayFrom: dateFrom,
66 | dayTo: dateTo,
67 | groupBy: 'day-subcampaign',
68 | metrics: 'campaignCost-impsCount-clicksCount',
69 | };
70 | // 动态获取广告主列表
71 | const advertisersList = await fetchRtbHouseAdvertisersFromApi();
72 | let advertisers: { name: string, hash: string }[];
73 | if (app) {
74 | advertisers = advertisersList.filter(a => a.name.toLowerCase() === app.toLowerCase());
75 | } else {
76 | advertisers = advertisersList;
77 | }
78 | if (advertisers.length === 0) {
79 | throw new ConfigurationError(`App '${app}' not found in RTB House advertisers`);
80 | }
81 | for (const adv of advertisers) {
82 | let lastError: any = null;
83 | let data: any[] = [];
84 | for (let attempt = 1; attempt <= maxRetries; attempt++) {
85 | try {
86 | const params = new URLSearchParams(paramsBase as any).toString();
87 | const url = `${RTB_HOUSE_API_URL}/${adv.hash}/rtb-stats?${params}`;
88 | const response = await fetch(url, {
89 | method: 'GET',
90 | headers: {
91 | 'Authorization': 'Basic ' + Buffer.from(`${RTB_HOUSE_USER}:${RTB_HOUSE_PASSWORD}`).toString('base64'),
92 | 'Accept': 'application/json',
93 | },
94 | });
95 | if (!response.ok) {
96 | const errorBody = await response.text();
97 | lastError = new Error(`HTTP ${response.status}: ${errorBody}`);
98 | if (response.status >= 500 || response.status === 429 || response.status === 408) {
99 | if (attempt === maxRetries) throw lastError;
100 | await new Promise(res => setTimeout(res, 1000 * attempt));
101 | continue;
102 | } else {
103 | throw lastError;
104 | }
105 | }
106 | const res: any = await response.json();
107 | data = (res.data || []); // 不再过滤单一天
108 | break; // success, break retry loop
109 | } catch (err: any) {
110 | lastError = err;
111 | if (attempt === maxRetries) {
112 | data = [];
113 | console.error(`Failed to fetch RTB House data for app ${adv.name}: ${err.message}`);
114 | }
115 | }
116 | }
117 | result[adv.name] = data;
118 | }
119 | return result;
120 | }
121 |
122 | // --- RTB House Data Tool Registration ---
123 | const rtbHouseDateSchema = z.string()
124 | .regex(/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/, 'Date must be in YYYY-MM-DD format')
125 | .refine((date) => !isNaN(Date.parse(date)), 'Invalid date');
126 |
127 | server.tool(
128 | 'get_rtb_house_data',
129 | 'Fetch RTB House full API data for a given date range.',
130 | {
131 | dateFrom: rtbHouseDateSchema.describe('Start date for the report (YYYY-MM-DD)'),
132 | dateTo: rtbHouseDateSchema.describe('End date for the report (YYYY-MM-DD)'),
133 | app: z.string().optional().describe('Optional app name to filter results. If not provided, returns data for all apps.'),
134 | maxRetries: z.number().int().min(1).max(10).optional().default(3).describe('Maximum number of retry attempts (default: 3)'),
135 | },
136 | async ({ dateFrom, dateTo, app, maxRetries = 3 }) => {
137 | try {
138 | const data = await fetchRtbHouseData(dateFrom, dateTo, app, maxRetries);
139 | return {
140 | content: [
141 | {
142 | type: 'text',
143 | text: JSON.stringify({ dateFrom, dateTo, app: app || 'all', data }, null, 2)
144 | }
145 | ]
146 | };
147 | } catch (error: any) {
148 | return {
149 | content: [
150 | {
151 | type: 'text',
152 | text: JSON.stringify({ error: error.message, dateFrom, dateTo, app: app || 'all' }, null, 2)
153 | }
154 | ],
155 | isError: true
156 | };
157 | }
158 | }
159 | );
160 |
161 | // Start server
162 | async function runServer(): Promise<void> {
163 | try {
164 | const transport = new StdioServerTransport();
165 | await server.connect(transport);
166 | console.error("RTB House Reporting MCP Server running on stdio");
167 | } catch (error) {
168 | console.error("Failed to start server:", error);
169 | throw error;
170 | }
171 | }
172 |
173 | // Graceful shutdown handling
174 | process.on('SIGINT', () => {
175 | console.error('Received SIGINT, shutting down gracefully...');
176 | process.exit(0);
177 | });
178 |
179 | process.on('SIGTERM', () => {
180 | console.error('Received SIGTERM, shutting down gracefully...');
181 | process.exit(0);
182 | });
183 |
184 | runServer().catch((error) => {
185 | console.error("Fatal error running server:", error);
186 | process.exit(1);
187 | });
188 |
```
--------------------------------------------------------------------------------
/src/civitai-records/infra/db-init/02_functions.sql:
--------------------------------------------------------------------------------
```sql
1 |
2 | -- =========================================================
3 | -- 02_functions.sql
4 | -- Shared helpers:
5 | -- - create_app_user(username, password)
6 | -- - set_created_updated_by() (BEFORE trigger)
7 | -- - audit_event() (AFTER trigger)
8 | -- - register_audited_table(...) DRY helper
9 | -- =========================================================
10 |
11 | SET ROLE civitai_owner;
12 |
13 | -- 1) Create/Update a login user and grant civitai_user
14 | CREATE OR REPLACE FUNCTION civitai.create_app_user(
15 | p_username text,
16 | p_password text
17 | ) RETURNS void
18 | LANGUAGE plpgsql
19 | SECURITY DEFINER
20 | AS $$
21 | BEGIN
22 | IF p_username IS NULL OR length(trim(p_username)) = 0 THEN
23 | RAISE EXCEPTION 'Username cannot be empty';
24 | END IF;
25 |
26 | IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = p_username) THEN
27 | EXECUTE format('CREATE ROLE %I LOGIN PASSWORD %L', p_username, p_password);
28 | ELSE
29 | EXECUTE format('ALTER ROLE %I PASSWORD %L', p_username, p_password);
30 | END IF;
31 |
32 | EXECUTE format('GRANT civitai_user TO %I', p_username);
33 | END;
34 | $$;
35 |
36 | -- 2) BEFORE trigger: maintain created_by/updated_by + timestamps
37 | CREATE OR REPLACE FUNCTION civitai.set_created_updated_by()
38 | RETURNS trigger AS $$
39 | BEGIN
40 | IF (TG_OP = 'INSERT') THEN
41 | NEW.created_by := current_user; -- actual login role
42 | NEW.updated_by := current_user;
43 | NEW.created_at := COALESCE(NEW.created_at, now());
44 | NEW.updated_at := now();
45 | ELSIF (TG_OP = 'UPDATE') THEN
46 | IF NEW.created_by IS DISTINCT FROM OLD.created_by THEN
47 | RAISE EXCEPTION 'created_by is immutable';
48 | END IF;
49 | NEW.updated_by := current_user;
50 | NEW.updated_at := now();
51 | END IF;
52 | RETURN NEW;
53 | END;
54 | $$ LANGUAGE plpgsql;
55 |
56 | -- 3) AFTER trigger: append audit row into civitai.events
57 | CREATE OR REPLACE FUNCTION civitai.audit_event()
58 | RETURNS trigger
59 | LANGUAGE plpgsql
60 | SECURITY DEFINER
61 | SET search_path = civitai, pg_catalog
62 | AS $$
63 | DECLARE
64 | v_row_id bigint;
65 | BEGIN
66 | IF TG_OP = 'INSERT' THEN
67 | v_row_id := NEW.id;
68 | INSERT INTO civitai.events(actor, table_name, op, row_id, old_data, new_data)
69 | VALUES (current_user, TG_TABLE_NAME, TG_OP, v_row_id, NULL, to_jsonb(NEW));
70 | RETURN NEW;
71 |
72 | ELSIF TG_OP = 'UPDATE' THEN
73 | v_row_id := COALESCE(NEW.id, OLD.id);
74 | INSERT INTO civitai.events(actor, table_name, op, row_id, old_data, new_data)
75 | VALUES (current_user, TG_TABLE_NAME, TG_OP, v_row_id, to_jsonb(OLD), to_jsonb(NEW));
76 | RETURN NEW;
77 |
78 | ELSE
79 | RETURN NULL;
80 | END IF;
81 | END;
82 | $$;
83 |
84 | -- 4) Helper to register a table for grants, triggers, and RLS
85 | -- p_rls_mode: 'permissive' (anyone can read/update) or 'owned' (creator-only)
86 | -- p_block_delete: revoke DELETE
87 | -- p_protect_audit_cols: revoke INSERT/UPDATE on (created_by, updated_by)
88 | CREATE OR REPLACE FUNCTION civitai.register_audited_table(
89 | p_table regclass,
90 | p_grant_role text DEFAULT 'civitai_user',
91 | p_id_col text DEFAULT 'id',
92 | p_rls_mode text DEFAULT 'permissive', -- or 'owned'
93 | p_block_delete boolean DEFAULT true,
94 | p_protect_audit_cols boolean DEFAULT true
95 | ) RETURNS void
96 | LANGUAGE plpgsql
97 | SECURITY DEFINER
98 | SET search_path = civitai, pg_catalog
99 | AS $$
100 | DECLARE
101 | v_schema text;
102 | v_table text;
103 | trg_before text;
104 | trg_after text;
105 | pol_sel text;
106 | pol_ins text;
107 | pol_upd text;
108 | seq_schema text;
109 | seq_name text;
110 | BEGIN
111 | SELECT n.nspname, c.relname
112 | INTO v_schema, v_table
113 | FROM pg_class c
114 | JOIN pg_namespace n ON n.oid = c.relnamespace
115 | WHERE c.oid = p_table;
116 |
117 | trg_before := format('trg_%s_created_updated_by', v_table);
118 | trg_after := format('trg_%s_audit_event', v_table);
119 |
120 | pol_sel := format('p_%s_select', v_table);
121 | pol_ins := format('p_%s_insert', v_table);
122 | pol_upd := format('p_%s_update', v_table);
123 |
124 | -- Grants
125 | EXECUTE format('GRANT SELECT, INSERT, UPDATE ON %s TO %I', p_table, p_grant_role);
126 | IF p_block_delete THEN
127 | EXECUTE format('REVOKE DELETE ON %s FROM %I', p_table, p_grant_role);
128 | END IF;
129 |
130 | -- Ensure callers can advance any serial/identity sequence tied to the id column
131 | SELECT n.nspname, c.relname
132 | INTO seq_schema, seq_name
133 | FROM pg_class c
134 | JOIN pg_namespace n ON n.oid = c.relnamespace
135 | WHERE c.oid = pg_get_serial_sequence(p_table::text, p_id_col)::regclass;
136 |
137 | IF seq_schema IS NOT NULL THEN
138 | EXECUTE format('GRANT USAGE, SELECT ON SEQUENCE %I.%I TO %I', seq_schema, seq_name, p_grant_role);
139 | END IF;
140 |
141 | -- Column protections
142 | IF p_protect_audit_cols THEN
143 | EXECUTE format('REVOKE INSERT (created_by, updated_by) ON %s FROM %I', p_table, p_grant_role);
144 | EXECUTE format('REVOKE UPDATE (created_by, updated_by) ON %s FROM %I', p_table, p_grant_role);
145 | END IF;
146 |
147 | -- BEFORE trigger
148 | EXECUTE format('DROP TRIGGER IF EXISTS %I ON %s', trg_before, p_table);
149 | EXECUTE format(
150 | 'CREATE TRIGGER %I BEFORE INSERT OR UPDATE ON %s
151 | FOR EACH ROW EXECUTE FUNCTION civitai.set_created_updated_by()',
152 | trg_before, p_table
153 | );
154 |
155 | -- AFTER trigger
156 | EXECUTE format('DROP TRIGGER IF EXISTS %I ON %s', trg_after, p_table);
157 | EXECUTE format(
158 | 'CREATE TRIGGER %I AFTER INSERT OR UPDATE ON %s
159 | FOR EACH ROW EXECUTE FUNCTION civitai.audit_event()',
160 | trg_after, p_table
161 | );
162 |
163 | -- RLS & policies
164 | EXECUTE format('ALTER TABLE %s ENABLE ROW LEVEL SECURITY', p_table);
165 | EXECUTE format('ALTER TABLE %s FORCE ROW LEVEL SECURITY', p_table);
166 |
167 | EXECUTE format('DROP POLICY IF EXISTS %I ON %s', pol_sel, p_table);
168 | EXECUTE format('DROP POLICY IF EXISTS %I ON %s', pol_ins, p_table);
169 | EXECUTE format('DROP POLICY IF EXISTS %I ON %s', pol_upd, p_table);
170 |
171 | IF lower(p_rls_mode) = 'owned' THEN
172 | EXECUTE format('CREATE POLICY %I ON %s FOR SELECT USING (created_by = current_user)', pol_sel, p_table);
173 | EXECUTE format('CREATE POLICY %I ON %s FOR INSERT WITH CHECK (true)', pol_ins, p_table);
174 | EXECUTE format('CREATE POLICY %I ON %s FOR UPDATE USING (created_by = current_user) WITH CHECK (created_by = current_user)', pol_upd, p_table);
175 | ELSE
176 | EXECUTE format('CREATE POLICY %I ON %s FOR SELECT USING (true)', pol_sel, p_table);
177 | EXECUTE format('CREATE POLICY %I ON %s FOR INSERT WITH CHECK (true)', pol_ins, p_table);
178 | EXECUTE format('CREATE POLICY %I ON %s FOR UPDATE USING (true) WITH CHECK (true)', pol_upd, p_table);
179 | END IF;
180 | END;
181 | $$;
182 |
183 | RESET ROLE;
184 |
```
--------------------------------------------------------------------------------
/src/civitai-records/src/tools/updateAsset.ts:
--------------------------------------------------------------------------------
```typescript
1 | import type { ContentResult } from "fastmcp";
2 | import { z } from "zod";
3 | import { prisma } from "../lib/prisma.js";
4 | import { handleDatabaseError } from "../lib/handleDatabaseError.js";
5 |
6 | /**
7 | * Parse and validate an ID string parameter to BigInt.
8 | * Returns null if the input is null/empty.
9 | */
10 | function parseIdParameter(id: string | null | undefined, parameterName: string): bigint | null | undefined {
11 | if (id === undefined) return undefined;
12 | if (id === null || id.trim() === "") return null;
13 |
14 | try {
15 | return BigInt(id.trim());
16 | } catch (error) {
17 | throw new Error(`Invalid ${parameterName}: must be a valid integer ID`);
18 | }
19 | }
20 |
21 | /**
22 | * Parse and validate an array of ID strings to BigInt array.
23 | * Returns null if the input is null/empty, undefined if not provided.
24 | */
25 | function parseIdArrayParameter(ids: string[] | null | undefined, parameterName: string): bigint[] | null | undefined {
26 | if (ids === undefined) return undefined;
27 | if (ids === null || ids.length === 0) return [];
28 |
29 | try {
30 | return ids.map((id, index) => {
31 | const trimmed = id.trim();
32 | if (!trimmed) {
33 | throw new Error(`Empty ID at index ${index}`);
34 | }
35 | return BigInt(trimmed);
36 | });
37 | } catch (error) {
38 | throw new Error(`Invalid ${parameterName}: ${error instanceof Error ? error.message : 'must contain valid integer IDs'}`);
39 | }
40 | }
41 |
42 | export const updateAssetParameters = z.object({
43 | asset_id: z
44 | .string()
45 | .min(1)
46 | .describe("The ID of the asset to update. This should be the asset_id returned from a previous create_asset call."),
47 | input_prompt_id: z
48 | .string()
49 | .nullable()
50 | .optional()
51 | .describe("The ID of the prompt that generated this asset. Set to null to remove the association. Leave undefined to keep current value."),
52 | output_prompt_id: z
53 | .string()
54 | .nullable()
55 | .optional()
56 | .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."),
57 | civitai_id: z
58 | .string()
59 | .nullable()
60 | .optional()
61 | .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."),
62 | civitai_url: z
63 | .string()
64 | .nullable()
65 | .optional()
66 | .describe("The full Civitai page URL (e.g., 'https://civitai.com/images/106432973'). Set to null to remove. Leave undefined to keep current value."),
67 | on_behalf_of: z
68 | .string()
69 | .nullable()
70 | .optional()
71 | .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."),
72 | post_id: z
73 | .string()
74 | .nullable()
75 | .optional()
76 | .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."),
77 | input_asset_ids: z
78 | .array(z.string())
79 | .nullable()
80 | .optional()
81 | .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."),
82 | });
83 |
84 | export type UpdateAssetParameters = z.infer<typeof updateAssetParameters>;
85 |
86 | export const updateAssetTool = {
87 | name: "update_asset",
88 | 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.",
89 | parameters: updateAssetParameters,
90 | execute: async ({
91 | asset_id,
92 | input_prompt_id,
93 | output_prompt_id,
94 | civitai_id,
95 | civitai_url,
96 | on_behalf_of,
97 | post_id,
98 | input_asset_ids,
99 | }: UpdateAssetParameters): Promise<ContentResult> => {
100 | // Parse and validate asset ID
101 | const assetIdBigInt = parseIdParameter(asset_id, 'asset_id');
102 | if (!assetIdBigInt) {
103 | throw new Error("asset_id is required and must be a valid integer ID");
104 | }
105 |
106 | // Build update data object with only provided fields
107 | const data: any = {};
108 |
109 | const inputPromptId = parseIdParameter(input_prompt_id, 'input_prompt_id');
110 | if (inputPromptId !== undefined) {
111 | data.input_prompt_id = inputPromptId;
112 | }
113 |
114 | const outputPromptId = parseIdParameter(output_prompt_id, 'output_prompt_id');
115 | if (outputPromptId !== undefined) {
116 | data.output_prompt_id = outputPromptId;
117 | }
118 |
119 | if (civitai_id !== undefined) {
120 | data.civitai_id = civitai_id === null || civitai_id.trim() === "" ? null : civitai_id.trim();
121 | }
122 |
123 | if (civitai_url !== undefined) {
124 | data.civitai_url = civitai_url === null || civitai_url.trim() === "" ? null : civitai_url.trim();
125 | }
126 |
127 | if (on_behalf_of !== undefined) {
128 | data.on_behalf_of = on_behalf_of === null || on_behalf_of.trim() === "" ? null : on_behalf_of.trim();
129 | }
130 |
131 | const postIdBigInt = parseIdParameter(post_id, 'post_id');
132 | if (postIdBigInt !== undefined) {
133 | data.post_id = postIdBigInt;
134 | }
135 |
136 | const inputAssetIds = parseIdArrayParameter(input_asset_ids, 'input_asset_ids');
137 | if (inputAssetIds !== undefined) {
138 | data.input_asset_ids = inputAssetIds;
139 | }
140 |
141 | const asset = await prisma.assets.update({
142 | where: {
143 | id: assetIdBigInt,
144 | },
145 | data,
146 | }).catch(error => handleDatabaseError(error, `Asset ID: ${asset_id}`));
147 |
148 | return {
149 | content: [
150 | {
151 | type: "text",
152 | text: JSON.stringify({
153 | asset_id: asset.id.toString(),
154 | asset_type: asset.asset_type,
155 | asset_source: asset.asset_source,
156 | asset_url: asset.uri,
157 | sha256sum: asset.sha256sum,
158 | input_prompt_id: asset.input_prompt_id?.toString() ?? null,
159 | output_prompt_id: asset.output_prompt_id?.toString() ?? null,
160 | civitai_id: asset.civitai_id,
161 | civitai_url: asset.civitai_url,
162 | on_behalf_of: asset.on_behalf_of,
163 | post_id: asset.post_id?.toString() ?? null,
164 | input_asset_ids: asset.input_asset_ids.map((id: bigint) => id.toString()),
165 | metadata: asset.metadata,
166 | updated_at: asset.updated_at.toISOString(),
167 | }, null, 2),
168 | },
169 | ],
170 | } satisfies ContentResult;
171 | },
172 | };
173 |
```