From 4222905ff8733a6cf1cd1ea1bc12776a6e53d2dc Mon Sep 17 00:00:00 2001 From: Jonas Jesus Date: Wed, 14 Jan 2026 12:58:27 -0300 Subject: [PATCH] feat: add TikTok Ads MCP server - Add campaign management tools (list, get, create, update) - Add ad group management tools (list, get, create, update) - Add ad management tools (list, get, create, update) - Add report tools (campaign, adgroup, ad reports, advertiser info) - Implement TikTok Marketing API client with access token auth - Prepare OAuth flow for future TikTok app approval - Add comprehensive TypeScript types for all API entities - Include README with setup instructions and usage examples --- tiktok-ads/.gitignore | 5 + tiktok-ads/README.md | 286 ++++++++++++++ tiktok-ads/app.json | 20 + tiktok-ads/package.json | 28 ++ tiktok-ads/server/constants.ts | 140 +++++++ tiktok-ads/server/lib/env.ts | 26 ++ tiktok-ads/server/lib/tiktok-client.ts | 516 +++++++++++++++++++++++++ tiktok-ads/server/lib/types.ts | 437 +++++++++++++++++++++ tiktok-ads/server/main.ts | 122 ++++++ tiktok-ads/server/tools/adgroups.ts | 334 ++++++++++++++++ tiktok-ads/server/tools/ads.ts | 301 +++++++++++++++ tiktok-ads/server/tools/campaigns.ts | 263 +++++++++++++ tiktok-ads/server/tools/index.ts | 29 ++ tiktok-ads/server/tools/reports.ts | 484 +++++++++++++++++++++++ tiktok-ads/shared/deco.gen.ts | 65 ++++ tiktok-ads/tsconfig.json | 36 ++ 16 files changed, 3092 insertions(+) create mode 100644 tiktok-ads/.gitignore create mode 100644 tiktok-ads/README.md create mode 100644 tiktok-ads/app.json create mode 100644 tiktok-ads/package.json create mode 100644 tiktok-ads/server/constants.ts create mode 100644 tiktok-ads/server/lib/env.ts create mode 100644 tiktok-ads/server/lib/tiktok-client.ts create mode 100644 tiktok-ads/server/lib/types.ts create mode 100644 tiktok-ads/server/main.ts create mode 100644 tiktok-ads/server/tools/adgroups.ts create mode 100644 tiktok-ads/server/tools/ads.ts create mode 100644 tiktok-ads/server/tools/campaigns.ts create mode 100644 tiktok-ads/server/tools/index.ts create mode 100644 tiktok-ads/server/tools/reports.ts create mode 100644 tiktok-ads/shared/deco.gen.ts create mode 100644 tiktok-ads/tsconfig.json diff --git a/tiktok-ads/.gitignore b/tiktok-ads/.gitignore new file mode 100644 index 0000000..573ecc5 --- /dev/null +++ b/tiktok-ads/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.env +.env.local + diff --git a/tiktok-ads/README.md b/tiktok-ads/README.md new file mode 100644 index 0000000..85b8e00 --- /dev/null +++ b/tiktok-ads/README.md @@ -0,0 +1,286 @@ +# TikTok Ads MCP + +MCP Server for TikTok Marketing API integration. Manage campaigns, ad groups, ads and analyze performance using the TikTok Business API. + +## Features + +### Campaign Management +- **list_campaigns** - List all campaigns with filters +- **get_campaign** - Get details of a specific campaign +- **create_campaign** - Create a new campaign +- **update_campaign** - Update existing campaign + +### Ad Group Management +- **list_adgroups** - List ad groups with filters +- **get_adgroup** - Get details of a specific ad group +- **create_adgroup** - Create a new ad group +- **update_adgroup** - Update existing ad group + +### Ad Management +- **list_ads** - List ads with filters +- **get_ad** - Get details of a specific ad +- **create_ad** - Create a new ad +- **update_ad** - Update existing ad + +### Reports & Analytics +- **get_report** - Get custom performance reports +- **get_campaign_report** - Get campaign performance metrics +- **get_adgroup_report** - Get ad group performance metrics +- **get_ad_report** - Get ad performance metrics +- **get_advertiser_info** - Get advertiser account information + +## Setup + +### 1. Create App in TikTok for Business + +1. Go to [TikTok for Business Developer Portal](https://business-api.tiktok.com/portal/apps/) +2. Create a new app or use an existing one +3. Request the following permissions: + - `ad.operation.read` - Read campaigns, ad groups, and ads + - `ad.operation.write` - Create and modify campaigns + - `report.read` - Access performance reports + +### 2. Get Access Token + +#### Option A: Direct Access Token (Recommended for Development) + +1. In the TikTok Developer Portal, go to your app +2. Navigate to "Tools" > "Access Token" +3. Generate a long-lived access token +4. Copy the token + +#### Option B: OAuth Flow (For Production - Requires App Approval) + +1. Submit your app for TikTok review +2. Wait for approval (may take several weeks) +3. Once approved, OAuth flow will work automatically + +### 3. Configure Environment Variables + +Create a `.env` file with: + +```bash +# Required: Access Token (use this for development) +TIKTOK_ACCESS_TOKEN=your_access_token + +# Optional: OAuth credentials (for future OAuth support) +TIKTOK_APP_ID=your_app_id +TIKTOK_APP_SECRET=your_app_secret +``` + +## Development + +```bash +# Install dependencies (from monorepo root) +bun install + +# Run in development (hot reload) +bun run dev + +# Type check +bun run check + +# Build for production +bun run build +``` + +## Usage Examples + +### List all campaigns + +```json +{ + "tool": "list_campaigns", + "input": { + "advertiser_id": "123456789" + } +} +``` + +### Create a new campaign + +```json +{ + "tool": "create_campaign", + "input": { + "advertiser_id": "123456789", + "campaign_name": "Summer Sale 2024", + "objective_type": "WEB_CONVERSIONS", + "budget_mode": "BUDGET_MODE_DAY", + "budget": 100 + } +} +``` + +### Create an ad group + +```json +{ + "tool": "create_adgroup", + "input": { + "advertiser_id": "123456789", + "campaign_id": "987654321", + "adgroup_name": "US Adults 25-45", + "optimization_goal": "CONVERT", + "placements": ["PLACEMENT_TIKTOK"], + "budget_mode": "BUDGET_MODE_DAY", + "budget": 50, + "location_ids": ["6252001"], + "gender": "GENDER_UNLIMITED", + "age_groups": ["AGE_25_34", "AGE_35_44"] + } +} +``` + +### Create an ad + +```json +{ + "tool": "create_ad", + "input": { + "advertiser_id": "123456789", + "adgroup_id": "111222333", + "ad_name": "Summer Sale Video", + "ad_format": "SINGLE_VIDEO", + "ad_text": "Shop our summer collection! 🌴", + "call_to_action": "Shop Now", + "landing_page_url": "https://example.com/summer-sale", + "video_id": "v123456789" + } +} +``` + +### Get campaign performance report + +```json +{ + "tool": "get_campaign_report", + "input": { + "advertiser_id": "123456789", + "start_date": "2024-01-01", + "end_date": "2024-01-31" + } +} +``` + +### Get custom report with specific metrics + +```json +{ + "tool": "get_report", + "input": { + "advertiser_id": "123456789", + "data_level": "AUCTION_AD", + "start_date": "2024-01-01", + "end_date": "2024-01-07", + "dimensions": ["ad_id", "stat_time_day"], + "metrics": ["spend", "impressions", "clicks", "ctr", "video_play_actions", "likes", "shares"] + } +} +``` + +### Update campaign status + +```json +{ + "tool": "update_campaign", + "input": { + "advertiser_id": "123456789", + "campaign_id": "987654321", + "operation_status": "DISABLE" + } +} +``` + +## Project Structure + +``` +tiktok-ads/ +├── server/ +│ ├── main.ts # Entry point with OAuth config +│ ├── constants.ts # API URLs and constants +│ ├── lib/ +│ │ ├── tiktok-client.ts # API client +│ │ ├── types.ts # TypeScript types +│ │ └── env.ts # Access token helper +│ └── tools/ +│ ├── index.ts # Exports all tools +│ ├── campaigns.ts # Campaign tools +│ ├── adgroups.ts # Ad Group tools +│ ├── ads.ts # Ad tools +│ └── reports.ts # Report tools +├── shared/ +│ └── deco.gen.ts # Generated types +├── app.json # MCP configuration +├── package.json +├── tsconfig.json +└── README.md +``` + +## Campaign Objectives + +| Objective | Description | +|-----------|-------------| +| TRAFFIC | Drive traffic to your website | +| APP_PROMOTION | Promote app installs and engagement | +| WEB_CONVERSIONS | Drive website conversions | +| PRODUCT_SALES | Sell products from a catalog | +| REACH | Maximize reach to your audience | +| VIDEO_VIEWS | Get more video views | +| LEAD_GENERATION | Collect leads from forms | +| COMMUNITY_INTERACTION | Increase profile engagement | + +## Optimization Goals + +| Goal | Description | +|------|-------------| +| CLICK | Optimize for clicks | +| CONVERT | Optimize for conversions | +| SHOW | Optimize for impressions | +| REACH | Optimize for unique reach | +| VIDEO_VIEW | Optimize for video views | +| LEAD_GENERATION | Optimize for lead form submissions | +| ENGAGEMENT | Optimize for profile engagement | + +## Report Metrics + +Common metrics available in reports: + +- **spend** - Total amount spent +- **impressions** - Number of ad impressions +- **clicks** - Number of clicks +- **ctr** - Click-through rate +- **cpc** - Cost per click +- **cpm** - Cost per 1000 impressions +- **reach** - Number of unique users reached +- **frequency** - Average times ad shown per user +- **conversion** - Number of conversions +- **cost_per_conversion** - Cost per conversion +- **video_play_actions** - Video play actions +- **video_watched_2s** - 2-second video views +- **video_watched_6s** - 6-second video views +- **likes** - Number of likes +- **comments** - Number of comments +- **shares** - Number of shares +- **follows** - Number of new followers + +## Authentication Notes + +### Current: Access Token + +For development and testing, you can use a direct access token generated from the TikTok Developer Portal. This token has a long expiration and doesn't require app approval. + +### Future: OAuth 2.0 + +When your app is approved by TikTok, the OAuth flow will allow other users to authenticate with their own TikTok accounts. The MCP is already prepared for this with the OAuth configuration in `main.ts`. + +## API Reference + +This MCP uses the TikTok Marketing API v1.3: +- Base URL: `https://business-api.tiktok.com/open_api/v1.3/` +- [Official Documentation](https://business-api.tiktok.com/marketing_api/docs) + +## License + +MIT + diff --git a/tiktok-ads/app.json b/tiktok-ads/app.json new file mode 100644 index 0000000..99f796b --- /dev/null +++ b/tiktok-ads/app.json @@ -0,0 +1,20 @@ +{ + "scopeName": "deco", + "name": "tiktok-ads", + "friendlyName": "TikTok Ads", + "connection": { + "type": "HTTP", + "url": "https://sites-tiktok-ads.decocache.com/mcp" + }, + "description": "Integrate and manage your TikTok Ads campaigns. Create, edit and analyze campaigns, ad groups, ads and performance reports.", + "icon": "https://assets.decocache.com/mcp/tiktok-ads-icon.svg", + "unlisted": false, + "metadata": { + "categories": ["Marketing", "Advertising"], + "official": false, + "tags": ["tiktok", "ads", "marketing", "campaigns", "advertising", "social-media", "analytics"], + "short_description": "Integrate and manage your TikTok Ads campaigns. Create, edit and analyze campaigns, ad groups, ads and performance reports.", + "mesh_description": "The TikTok Ads MCP provides comprehensive integration with TikTok Marketing API, enabling full programmatic control over advertising campaigns. This MCP allows AI agents to create, read, update campaigns, ad groups, and individual ads, analyze performance metrics, manage audiences, and generate detailed reports. It supports advanced advertising features including campaign budget optimization, targeting configuration, bid strategies, and performance tracking across multiple advertisers. The integration is perfect for building intelligent advertising assistants, automated campaign managers, and performance optimization tools. Ideal for marketers and agencies who need to automate TikTok advertising workflows, integrate campaign management into business processes, or build advertising-aware applications. Provides secure authentication for API access." + } +} + diff --git a/tiktok-ads/package.json b/tiktok-ads/package.json new file mode 100644 index 0000000..fd29bdc --- /dev/null +++ b/tiktok-ads/package.json @@ -0,0 +1,28 @@ +{ + "name": "tiktok-ads", + "version": "1.0.0", + "description": "TikTok Ads MCP Server - Manage campaigns, ad groups, ads and reports", + "private": true, + "type": "module", + "scripts": { + "dev": "bun run --hot server/main.ts", + "build:server": "NODE_ENV=production bun build server/main.ts --target=bun --outfile=dist/server/main.js", + "build": "bun run build:server", + "publish": "cat app.json | deco registry publish -w /shared/deco -y", + "check": "tsc --noEmit" + }, + "dependencies": { + "@decocms/runtime": "^1.1.3", + "zod": "^4.0.0" + }, + "devDependencies": { + "@decocms/mcps-shared": "workspace:*", + "@modelcontextprotocol/sdk": "1.25.1", + "deco-cli": "^0.28.0", + "typescript": "^5.7.2" + }, + "engines": { + "node": ">=22.0.0" + } +} + diff --git a/tiktok-ads/server/constants.ts b/tiktok-ads/server/constants.ts new file mode 100644 index 0000000..57c2606 --- /dev/null +++ b/tiktok-ads/server/constants.ts @@ -0,0 +1,140 @@ +/** + * TikTok Marketing API constants and configuration + */ + +export const TIKTOK_API_BASE = "https://business-api.tiktok.com/open_api/v1.3"; + +// API Endpoints +export const ENDPOINTS = { + // Campaign endpoints + CAMPAIGN_GET: `${TIKTOK_API_BASE}/campaign/get/`, + CAMPAIGN_CREATE: `${TIKTOK_API_BASE}/campaign/create/`, + CAMPAIGN_UPDATE: `${TIKTOK_API_BASE}/campaign/update/`, + + // Ad Group endpoints + ADGROUP_GET: `${TIKTOK_API_BASE}/adgroup/get/`, + ADGROUP_CREATE: `${TIKTOK_API_BASE}/adgroup/create/`, + ADGROUP_UPDATE: `${TIKTOK_API_BASE}/adgroup/update/`, + + // Ad endpoints + AD_GET: `${TIKTOK_API_BASE}/ad/get/`, + AD_CREATE: `${TIKTOK_API_BASE}/ad/create/`, + AD_UPDATE: `${TIKTOK_API_BASE}/ad/update/`, + + // Report endpoints + REPORT_INTEGRATED: `${TIKTOK_API_BASE}/report/integrated/get/`, + + // Advertiser endpoints + ADVERTISER_INFO: `${TIKTOK_API_BASE}/advertiser/info/`, +}; + +// Default pagination +export const DEFAULT_PAGE_SIZE = 50; +export const MAX_PAGE_SIZE = 1000; + +// Campaign objectives +export const CAMPAIGN_OBJECTIVES = { + TRAFFIC: "TRAFFIC", + APP_PROMOTION: "APP_PROMOTION", + WEB_CONVERSIONS: "WEB_CONVERSIONS", + PRODUCT_SALES: "PRODUCT_SALES", + REACH: "REACH", + VIDEO_VIEWS: "VIDEO_VIEWS", + LEAD_GENERATION: "LEAD_GENERATION", + COMMUNITY_INTERACTION: "COMMUNITY_INTERACTION", +} as const; + +// Campaign status +export const CAMPAIGN_STATUS = { + ENABLE: "ENABLE", + DISABLE: "DISABLE", + DELETE: "DELETE", +} as const; + +// Ad Group status +export const ADGROUP_STATUS = { + ENABLE: "ENABLE", + DISABLE: "DISABLE", + DELETE: "DELETE", +} as const; + +// Ad status +export const AD_STATUS = { + ENABLE: "ENABLE", + DISABLE: "DISABLE", + DELETE: "DELETE", +} as const; + +// Budget modes +export const BUDGET_MODE = { + BUDGET_MODE_INFINITE: "BUDGET_MODE_INFINITE", + BUDGET_MODE_DAY: "BUDGET_MODE_DAY", + BUDGET_MODE_TOTAL: "BUDGET_MODE_TOTAL", +} as const; + +// Bid types +export const BID_TYPE = { + BID_TYPE_NO_BID: "BID_TYPE_NO_BID", + BID_TYPE_CUSTOM: "BID_TYPE_CUSTOM", +} as const; + +// Optimization goals +export const OPTIMIZATION_GOAL = { + CLICK: "CLICK", + CONVERT: "CONVERT", + SHOW: "SHOW", + REACH: "REACH", + VIDEO_VIEW: "VIDEO_VIEW", + LEAD_GENERATION: "LEAD_GENERATION", + ENGAGEMENT: "ENGAGEMENT", +} as const; + +// Placement types +export const PLACEMENTS = { + PLACEMENT_TIKTOK: "PLACEMENT_TIKTOK", + PLACEMENT_PANGLE: "PLACEMENT_PANGLE", + PLACEMENT_GLOBAL_APP_BUNDLE: "PLACEMENT_GLOBAL_APP_BUNDLE", +} as const; + +// Report data levels +export const REPORT_DATA_LEVEL = { + AUCTION_ADVERTISER: "AUCTION_ADVERTISER", + AUCTION_CAMPAIGN: "AUCTION_CAMPAIGN", + AUCTION_ADGROUP: "AUCTION_ADGROUP", + AUCTION_AD: "AUCTION_AD", +} as const; + +// Report dimensions +export const REPORT_DIMENSIONS = { + ADVERTISER_ID: "advertiser_id", + CAMPAIGN_ID: "campaign_id", + ADGROUP_ID: "adgroup_id", + AD_ID: "ad_id", + STAT_TIME_DAY: "stat_time_day", + STAT_TIME_HOUR: "stat_time_hour", +} as const; + +// Common metrics for reports +export const REPORT_METRICS = [ + "spend", + "impressions", + "clicks", + "ctr", + "cpc", + "cpm", + "reach", + "frequency", + "conversion", + "cost_per_conversion", + "conversion_rate", + "video_play_actions", + "video_watched_2s", + "video_watched_6s", + "average_video_play", + "average_video_play_per_user", + "profile_visits", + "likes", + "comments", + "shares", + "follows", +] as const; diff --git a/tiktok-ads/server/lib/env.ts b/tiktok-ads/server/lib/env.ts new file mode 100644 index 0000000..817b03e --- /dev/null +++ b/tiktok-ads/server/lib/env.ts @@ -0,0 +1,26 @@ +import type { Env } from "../../shared/deco.gen.ts"; + +/** + * Get TikTok access token from environment context + * Supports both OAuth flow (via MESH_REQUEST_CONTEXT) and direct token (via env var) + * @param env - The environment containing the mesh request context + * @returns The access token + * @throws Error if not authenticated + */ +export const getTikTokAccessToken = (env: Env): string => { + // First try OAuth token from mesh context + const authorization = env.MESH_REQUEST_CONTEXT?.authorization; + if (authorization) { + return authorization; + } + + // Fall back to direct token from environment variable + const directToken = process.env.TIKTOK_ACCESS_TOKEN; + if (directToken) { + return directToken; + } + + throw new Error( + "Not authenticated. Please provide TIKTOK_ACCESS_TOKEN or authorize with TikTok first.", + ); +}; diff --git a/tiktok-ads/server/lib/tiktok-client.ts b/tiktok-ads/server/lib/tiktok-client.ts new file mode 100644 index 0000000..54a0e91 --- /dev/null +++ b/tiktok-ads/server/lib/tiktok-client.ts @@ -0,0 +1,516 @@ +/** + * TikTok Marketing API client + * Handles all communication with the TikTok Marketing API + */ + +import { ENDPOINTS, DEFAULT_PAGE_SIZE } from "../constants.ts"; +import type { + Campaign, + CampaignListResponse, + CreateCampaignInput, + UpdateCampaignInput, + ListCampaignsInput, + AdGroup, + AdGroupListResponse, + CreateAdGroupInput, + UpdateAdGroupInput, + ListAdGroupsInput, + Ad, + AdListResponse, + CreateAdInput, + UpdateAdInput, + ListAdsInput, + ReportResponse, + GetReportInput, + Advertiser, + AdvertiserInfoResponse, + GetAdvertiserInfoInput, + ApiResponse, +} from "./types.ts"; + +export interface TikTokClientConfig { + accessToken: string; +} + +export class TikTokClient { + private accessToken: string; + + constructor(config: TikTokClientConfig) { + this.accessToken = config.accessToken; + } + + private async request(url: string, options: RequestInit = {}): Promise { + const response = await fetch(url, { + ...options, + headers: { + "Access-Token": this.accessToken, + "Content-Type": "application/json", + ...options.headers, + }, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error( + `TikTok Marketing API error: ${response.status} - ${error}`, + ); + } + + const result = (await response.json()) as ApiResponse; + + if (result.code !== 0) { + throw new Error(`TikTok API error: ${result.code} - ${result.message}`); + } + + return result.data; + } + + // ==================== Campaign Methods ==================== + + /** + * List campaigns for an advertiser + */ + async listCampaigns(input: ListCampaignsInput): Promise<{ + campaigns: Campaign[]; + page_info: { + page: number; + page_size: number; + total_number: number; + total_page: number; + }; + }> { + const body: any = { + advertiser_id: input.advertiser_id, + page: input.page || 1, + page_size: input.page_size || DEFAULT_PAGE_SIZE, + }; + + if (input.campaign_ids?.length) { + body.filtering = { ...body.filtering, campaign_ids: input.campaign_ids }; + } + if (input.filtering) { + body.filtering = { ...body.filtering, ...input.filtering }; + } + + const response = await fetch(ENDPOINTS.CAMPAIGN_GET, { + method: "POST", + headers: { + "Access-Token": this.accessToken, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`TikTok API error: ${response.status} - ${error}`); + } + + const result = (await response.json()) as CampaignListResponse; + + if (result.code !== 0) { + throw new Error(`TikTok API error: ${result.code} - ${result.message}`); + } + + return { + campaigns: result.data.list, + page_info: result.data.page_info, + }; + } + + /** + * Create a new campaign + */ + async createCampaign( + input: CreateCampaignInput, + ): Promise<{ campaign_id: string }> { + const response = await fetch(ENDPOINTS.CAMPAIGN_CREATE, { + method: "POST", + headers: { + "Access-Token": this.accessToken, + "Content-Type": "application/json", + }, + body: JSON.stringify(input), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`TikTok API error: ${response.status} - ${error}`); + } + + const result = (await response.json()) as ApiResponse<{ + campaign_id: string; + }>; + + if (result.code !== 0) { + throw new Error(`TikTok API error: ${result.code} - ${result.message}`); + } + + return result.data; + } + + /** + * Update an existing campaign + */ + async updateCampaign( + input: UpdateCampaignInput, + ): Promise<{ campaign_id: string }> { + const response = await fetch(ENDPOINTS.CAMPAIGN_UPDATE, { + method: "POST", + headers: { + "Access-Token": this.accessToken, + "Content-Type": "application/json", + }, + body: JSON.stringify(input), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`TikTok API error: ${response.status} - ${error}`); + } + + const result = (await response.json()) as ApiResponse<{ + campaign_id: string; + }>; + + if (result.code !== 0) { + throw new Error(`TikTok API error: ${result.code} - ${result.message}`); + } + + return result.data; + } + + // ==================== Ad Group Methods ==================== + + /** + * List ad groups for an advertiser + */ + async listAdGroups(input: ListAdGroupsInput): Promise<{ + adgroups: AdGroup[]; + page_info: { + page: number; + page_size: number; + total_number: number; + total_page: number; + }; + }> { + const body: any = { + advertiser_id: input.advertiser_id, + page: input.page || 1, + page_size: input.page_size || DEFAULT_PAGE_SIZE, + }; + + if (input.campaign_ids?.length) { + body.filtering = { ...body.filtering, campaign_ids: input.campaign_ids }; + } + if (input.adgroup_ids?.length) { + body.filtering = { ...body.filtering, adgroup_ids: input.adgroup_ids }; + } + if (input.filtering) { + body.filtering = { ...body.filtering, ...input.filtering }; + } + + const response = await fetch(ENDPOINTS.ADGROUP_GET, { + method: "POST", + headers: { + "Access-Token": this.accessToken, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`TikTok API error: ${response.status} - ${error}`); + } + + const result = (await response.json()) as AdGroupListResponse; + + if (result.code !== 0) { + throw new Error(`TikTok API error: ${result.code} - ${result.message}`); + } + + return { + adgroups: result.data.list, + page_info: result.data.page_info, + }; + } + + /** + * Create a new ad group + */ + async createAdGroup( + input: CreateAdGroupInput, + ): Promise<{ adgroup_id: string }> { + const response = await fetch(ENDPOINTS.ADGROUP_CREATE, { + method: "POST", + headers: { + "Access-Token": this.accessToken, + "Content-Type": "application/json", + }, + body: JSON.stringify(input), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`TikTok API error: ${response.status} - ${error}`); + } + + const result = (await response.json()) as ApiResponse<{ + adgroup_id: string; + }>; + + if (result.code !== 0) { + throw new Error(`TikTok API error: ${result.code} - ${result.message}`); + } + + return result.data; + } + + /** + * Update an existing ad group + */ + async updateAdGroup( + input: UpdateAdGroupInput, + ): Promise<{ adgroup_id: string }> { + const response = await fetch(ENDPOINTS.ADGROUP_UPDATE, { + method: "POST", + headers: { + "Access-Token": this.accessToken, + "Content-Type": "application/json", + }, + body: JSON.stringify(input), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`TikTok API error: ${response.status} - ${error}`); + } + + const result = (await response.json()) as ApiResponse<{ + adgroup_id: string; + }>; + + if (result.code !== 0) { + throw new Error(`TikTok API error: ${result.code} - ${result.message}`); + } + + return result.data; + } + + // ==================== Ad Methods ==================== + + /** + * List ads for an advertiser + */ + async listAds(input: ListAdsInput): Promise<{ + ads: Ad[]; + page_info: { + page: number; + page_size: number; + total_number: number; + total_page: number; + }; + }> { + const body: any = { + advertiser_id: input.advertiser_id, + page: input.page || 1, + page_size: input.page_size || DEFAULT_PAGE_SIZE, + }; + + if (input.campaign_ids?.length) { + body.filtering = { ...body.filtering, campaign_ids: input.campaign_ids }; + } + if (input.adgroup_ids?.length) { + body.filtering = { ...body.filtering, adgroup_ids: input.adgroup_ids }; + } + if (input.ad_ids?.length) { + body.filtering = { ...body.filtering, ad_ids: input.ad_ids }; + } + if (input.filtering) { + body.filtering = { ...body.filtering, ...input.filtering }; + } + + const response = await fetch(ENDPOINTS.AD_GET, { + method: "POST", + headers: { + "Access-Token": this.accessToken, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`TikTok API error: ${response.status} - ${error}`); + } + + const result = (await response.json()) as AdListResponse; + + if (result.code !== 0) { + throw new Error(`TikTok API error: ${result.code} - ${result.message}`); + } + + return { + ads: result.data.list, + page_info: result.data.page_info, + }; + } + + /** + * Create a new ad + */ + async createAd(input: CreateAdInput): Promise<{ ad_id: string }> { + const response = await fetch(ENDPOINTS.AD_CREATE, { + method: "POST", + headers: { + "Access-Token": this.accessToken, + "Content-Type": "application/json", + }, + body: JSON.stringify(input), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`TikTok API error: ${response.status} - ${error}`); + } + + const result = (await response.json()) as ApiResponse<{ ad_id: string }>; + + if (result.code !== 0) { + throw new Error(`TikTok API error: ${result.code} - ${result.message}`); + } + + return result.data; + } + + /** + * Update an existing ad + */ + async updateAd(input: UpdateAdInput): Promise<{ ad_id: string }> { + const response = await fetch(ENDPOINTS.AD_UPDATE, { + method: "POST", + headers: { + "Access-Token": this.accessToken, + "Content-Type": "application/json", + }, + body: JSON.stringify(input), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`TikTok API error: ${response.status} - ${error}`); + } + + const result = (await response.json()) as ApiResponse<{ ad_id: string }>; + + if (result.code !== 0) { + throw new Error(`TikTok API error: ${result.code} - ${result.message}`); + } + + return result.data; + } + + // ==================== Report Methods ==================== + + /** + * Get integrated report data + */ + async getReport(input: GetReportInput): Promise<{ + rows: Array<{ + dimensions: Record; + metrics: Record; + }>; + page_info: { + page: number; + page_size: number; + total_number: number; + total_page: number; + }; + }> { + const body: any = { + advertiser_id: input.advertiser_id, + data_level: input.data_level, + dimensions: input.dimensions, + metrics: input.metrics, + start_date: input.start_date, + end_date: input.end_date, + page: input.page || 1, + page_size: input.page_size || DEFAULT_PAGE_SIZE, + }; + + if (input.filters) { + body.filtering = input.filters; + } + + const response = await fetch(ENDPOINTS.REPORT_INTEGRATED, { + method: "POST", + headers: { + "Access-Token": this.accessToken, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`TikTok API error: ${response.status} - ${error}`); + } + + const result = (await response.json()) as ReportResponse; + + if (result.code !== 0) { + throw new Error(`TikTok API error: ${result.code} - ${result.message}`); + } + + return { + rows: result.data.list.map((row) => ({ + dimensions: row.dimensions, + metrics: row as unknown as Record, + })), + page_info: result.data.page_info, + }; + } + + // ==================== Advertiser Methods ==================== + + /** + * Get advertiser information + */ + async getAdvertiserInfo( + input: GetAdvertiserInfoInput, + ): Promise { + // For GET requests, we need to use query params + const url = new URL(ENDPOINTS.ADVERTISER_INFO); + url.searchParams.set( + "advertiser_ids", + JSON.stringify(input.advertiser_ids), + ); + if (input.fields?.length) { + url.searchParams.set("fields", JSON.stringify(input.fields)); + } + + const getResponse = await fetch(url.toString(), { + method: "GET", + headers: { + "Access-Token": this.accessToken, + }, + }); + + if (!getResponse.ok) { + const error = await getResponse.text(); + throw new Error(`TikTok API error: ${getResponse.status} - ${error}`); + } + + const result = (await getResponse.json()) as AdvertiserInfoResponse; + + if (result.code !== 0) { + throw new Error(`TikTok API error: ${result.code} - ${result.message}`); + } + + return result.data.list; + } +} + +// Re-export getTikTokAccessToken from env.ts for convenience +export { getTikTokAccessToken as getAccessToken } from "./env.ts"; diff --git a/tiktok-ads/server/lib/types.ts b/tiktok-ads/server/lib/types.ts new file mode 100644 index 0000000..5089548 --- /dev/null +++ b/tiktok-ads/server/lib/types.ts @@ -0,0 +1,437 @@ +/** + * TikTok Marketing API types + */ + +// ============================================================================ +// Common Types +// ============================================================================ + +export type OperationStatus = "ENABLE" | "DISABLE" | "DELETE"; + +export type BudgetMode = + | "BUDGET_MODE_INFINITE" + | "BUDGET_MODE_DAY" + | "BUDGET_MODE_TOTAL"; + +export type BidType = "BID_TYPE_NO_BID" | "BID_TYPE_CUSTOM"; + +export type OptimizationGoal = + | "CLICK" + | "CONVERT" + | "SHOW" + | "REACH" + | "VIDEO_VIEW" + | "LEAD_GENERATION" + | "ENGAGEMENT"; + +export type Placement = + | "PLACEMENT_TIKTOK" + | "PLACEMENT_PANGLE" + | "PLACEMENT_GLOBAL_APP_BUNDLE"; + +// ============================================================================ +// Campaign Types +// ============================================================================ + +export type CampaignObjective = + | "TRAFFIC" + | "APP_PROMOTION" + | "WEB_CONVERSIONS" + | "PRODUCT_SALES" + | "REACH" + | "VIDEO_VIEWS" + | "LEAD_GENERATION" + | "COMMUNITY_INTERACTION"; + +export type CampaignStatus = + | "CAMPAIGN_STATUS_ENABLE" + | "CAMPAIGN_STATUS_DISABLE" + | "CAMPAIGN_STATUS_DELETE" + | "CAMPAIGN_STATUS_ADVERTISER_AUDIT_DENY" + | "CAMPAIGN_STATUS_ADVERTISER_AUDIT" + | "CAMPAIGN_STATUS_BUDGET_EXCEED" + | "CAMPAIGN_STATUS_ALL"; + +export interface Campaign { + campaign_id: string; + campaign_name: string; + advertiser_id: string; + objective_type: CampaignObjective; + operation_status: OperationStatus; + secondary_status: CampaignStatus; + budget_mode: BudgetMode; + budget?: number; + is_smart_performance_campaign?: boolean; + create_time: string; + modify_time: string; +} + +export interface CreateCampaignInput { + advertiser_id: string; + campaign_name: string; + objective_type: CampaignObjective; + budget_mode?: BudgetMode; + budget?: number; + operation_status?: OperationStatus; +} + +export interface UpdateCampaignInput { + advertiser_id: string; + campaign_id: string; + campaign_name?: string; + budget_mode?: BudgetMode; + budget?: number; + operation_status?: OperationStatus; +} + +export interface ListCampaignsInput { + advertiser_id: string; + campaign_ids?: string[]; + filtering?: { + campaign_name?: string; + objective_type?: CampaignObjective; + secondary_status?: CampaignStatus; + }; + page?: number; + page_size?: number; +} + +export interface CampaignListResponse { + code: number; + message: string; + data: { + list: Campaign[]; + page_info: PageInfo; + }; +} + +// ============================================================================ +// Ad Group Types +// ============================================================================ + +export type AdGroupStatus = + | "ADGROUP_STATUS_DELIVERY_OK" + | "ADGROUP_STATUS_DISABLE" + | "ADGROUP_STATUS_DELETE" + | "ADGROUP_STATUS_NOT_DELIVER" + | "ADGROUP_STATUS_TIME_DONE" + | "ADGROUP_STATUS_NO_SCHEDULE" + | "ADGROUP_STATUS_CAMPAIGN_DISABLE" + | "ADGROUP_STATUS_CAMPAIGN_EXCEED" + | "ADGROUP_STATUS_BALANCE_EXCEED" + | "ADGROUP_STATUS_AUDIT" + | "ADGROUP_STATUS_REAUDIT" + | "ADGROUP_STATUS_AUDIT_DENY" + | "ADGROUP_STATUS_ALL"; + +export type ScheduleType = "SCHEDULE_START_END" | "SCHEDULE_FROM_NOW"; + +export interface AdGroup { + adgroup_id: string; + adgroup_name: string; + advertiser_id: string; + campaign_id: string; + operation_status: OperationStatus; + secondary_status: AdGroupStatus; + placement_type: string; + placements?: Placement[]; + optimization_goal: OptimizationGoal; + bid_type: BidType; + bid_price?: number; + budget_mode: BudgetMode; + budget?: number; + schedule_type: ScheduleType; + schedule_start_time?: string; + schedule_end_time?: string; + dayparting?: string; + pacing?: string; + create_time: string; + modify_time: string; +} + +export interface CreateAdGroupInput { + advertiser_id: string; + campaign_id: string; + adgroup_name: string; + placement_type?: string; + placements?: Placement[]; + optimization_goal: OptimizationGoal; + bid_type?: BidType; + bid_price?: number; + budget_mode?: BudgetMode; + budget?: number; + schedule_type?: ScheduleType; + schedule_start_time?: string; + schedule_end_time?: string; + operation_status?: OperationStatus; + location_ids?: string[]; + gender?: "GENDER_UNLIMITED" | "GENDER_MALE" | "GENDER_FEMALE"; + age_groups?: string[]; + languages?: string[]; +} + +export interface UpdateAdGroupInput { + advertiser_id: string; + adgroup_id: string; + adgroup_name?: string; + bid_price?: number; + budget?: number; + schedule_end_time?: string; + operation_status?: OperationStatus; +} + +export interface ListAdGroupsInput { + advertiser_id: string; + campaign_ids?: string[]; + adgroup_ids?: string[]; + filtering?: { + adgroup_name?: string; + secondary_status?: AdGroupStatus; + }; + page?: number; + page_size?: number; +} + +export interface AdGroupListResponse { + code: number; + message: string; + data: { + list: AdGroup[]; + page_info: PageInfo; + }; +} + +// ============================================================================ +// Ad Types +// ============================================================================ + +export type AdStatus = + | "AD_STATUS_DELIVERY_OK" + | "AD_STATUS_DISABLE" + | "AD_STATUS_DELETE" + | "AD_STATUS_NOT_DELIVER" + | "AD_STATUS_CAMPAIGN_DISABLE" + | "AD_STATUS_ADGROUP_DISABLE" + | "AD_STATUS_CAMPAIGN_EXCEED" + | "AD_STATUS_BALANCE_EXCEED" + | "AD_STATUS_AUDIT" + | "AD_STATUS_REAUDIT" + | "AD_STATUS_AUDIT_DENY" + | "AD_STATUS_ALL"; + +export type AdFormat = + | "SINGLE_VIDEO" + | "SINGLE_IMAGE" + | "VIDEO_CAROUSEL" + | "IMAGE_CAROUSEL" + | "SPARK_ADS"; + +export interface Ad { + ad_id: string; + ad_name: string; + advertiser_id: string; + campaign_id: string; + adgroup_id: string; + operation_status: OperationStatus; + secondary_status: AdStatus; + ad_format: AdFormat; + ad_text?: string; + call_to_action?: string; + call_to_action_id?: string; + landing_page_url?: string; + display_name?: string; + profile_image?: string; + video_id?: string; + image_ids?: string[]; + create_time: string; + modify_time: string; +} + +export interface CreateAdInput { + advertiser_id: string; + adgroup_id: string; + ad_name: string; + ad_format: AdFormat; + ad_text?: string; + call_to_action?: string; + landing_page_url?: string; + display_name?: string; + video_id?: string; + image_ids?: string[]; + operation_status?: OperationStatus; + identity_id?: string; + identity_type?: string; +} + +export interface UpdateAdInput { + advertiser_id: string; + ad_id: string; + ad_name?: string; + ad_text?: string; + call_to_action?: string; + landing_page_url?: string; + operation_status?: OperationStatus; +} + +export interface ListAdsInput { + advertiser_id: string; + campaign_ids?: string[]; + adgroup_ids?: string[]; + ad_ids?: string[]; + filtering?: { + ad_name?: string; + secondary_status?: AdStatus; + }; + page?: number; + page_size?: number; +} + +export interface AdListResponse { + code: number; + message: string; + data: { + list: Ad[]; + page_info: PageInfo; + }; +} + +// ============================================================================ +// Report Types +// ============================================================================ + +export type ReportDataLevel = + | "AUCTION_ADVERTISER" + | "AUCTION_CAMPAIGN" + | "AUCTION_ADGROUP" + | "AUCTION_AD"; + +export type ReportDimension = + | "advertiser_id" + | "campaign_id" + | "adgroup_id" + | "ad_id" + | "stat_time_day" + | "stat_time_hour"; + +export interface ReportMetrics { + spend?: number; + impressions?: number; + clicks?: number; + ctr?: number; + cpc?: number; + cpm?: number; + reach?: number; + frequency?: number; + conversion?: number; + cost_per_conversion?: number; + conversion_rate?: number; + video_play_actions?: number; + video_watched_2s?: number; + video_watched_6s?: number; + average_video_play?: number; + average_video_play_per_user?: number; + profile_visits?: number; + likes?: number; + comments?: number; + shares?: number; + follows?: number; +} + +export interface ReportRow extends ReportMetrics { + dimensions: { + advertiser_id?: string; + campaign_id?: string; + adgroup_id?: string; + ad_id?: string; + stat_time_day?: string; + stat_time_hour?: string; + }; +} + +export interface GetReportInput { + advertiser_id: string; + data_level: ReportDataLevel; + dimensions: ReportDimension[]; + metrics: string[]; + start_date: string; + end_date: string; + filters?: { + campaign_ids?: string[]; + adgroup_ids?: string[]; + ad_ids?: string[]; + }; + page?: number; + page_size?: number; +} + +export interface ReportResponse { + code: number; + message: string; + data: { + list: ReportRow[]; + page_info: PageInfo; + }; +} + +// ============================================================================ +// Advertiser Types +// ============================================================================ + +export type AdvertiserStatus = + | "STATUS_ENABLE" + | "STATUS_DISABLE" + | "STATUS_PENDING_CONFIRM" + | "STATUS_PENDING_VERIFIED" + | "STATUS_CONFIRM_FAIL" + | "STATUS_CONFIRM_FAIL_END" + | "STATUS_LIMIT" + | "STATUS_WAIT_FOR_BPM_AUDIT" + | "STATUS_WAIT_FOR_PUBLIC_AUTH" + | "STATUS_SELF_SERVICE_UNAUDITED"; + +export interface Advertiser { + advertiser_id: string; + advertiser_name: string; + status: AdvertiserStatus; + company?: string; + contacter?: string; + email?: string; + telephone?: string; + address?: string; + industry?: string; + balance?: number; + currency?: string; + timezone?: string; + create_time: string; +} + +export interface GetAdvertiserInfoInput { + advertiser_ids: string[]; + fields?: string[]; +} + +export interface AdvertiserInfoResponse { + code: number; + message: string; + data: { + list: Advertiser[]; + }; +} + +// ============================================================================ +// Common Types +// ============================================================================ + +export interface PageInfo { + page: number; + page_size: number; + total_number: number; + total_page: number; +} + +export interface ApiResponse { + code: number; + message: string; + request_id: string; + data: T; +} diff --git a/tiktok-ads/server/main.ts b/tiktok-ads/server/main.ts new file mode 100644 index 0000000..e1cfc9c --- /dev/null +++ b/tiktok-ads/server/main.ts @@ -0,0 +1,122 @@ +/** + * TikTok Ads MCP Server + * + * This MCP provides tools for interacting with TikTok Marketing API, + * including campaign management, ad groups, ads, and performance reports. + */ +import { withRuntime } from "@decocms/runtime"; +import { serve } from "@decocms/mcps-shared/serve"; + +import { tools } from "./tools/index.ts"; +import type { Env } from "../shared/deco.gen.ts"; + +export type { Env }; + +// TikTok OAuth scopes (for future OAuth implementation) +const TIKTOK_ADS_SCOPES = [ + "ad.operation.read", + "ad.operation.write", + "report.read", +].join(","); + +// Store the last used redirect_uri for token exchange +let lastRedirectUri: string | null = null; + +const runtime = withRuntime({ + tools: (env: Env) => tools.map((createTool) => createTool(env)), + + // OAuth configuration (prepared for future use when TikTok app is approved) + // For now, users should use TIKTOK_ACCESS_TOKEN environment variable + oauth: { + mode: "PKCE", + // Used in protected resource metadata to point to the auth server + authorizationServer: "https://business-api.tiktok.com", + + // Generates the URL to redirect users to for authorization + authorizationUrl: (callbackUrl) => { + // Parse the callback URL to extract base URL and state parameter + const callbackUrlObj = new URL(callbackUrl); + const state = callbackUrlObj.searchParams.get("state"); + + // Remove state from redirect_uri + callbackUrlObj.searchParams.delete("state"); + const cleanRedirectUri = callbackUrlObj.toString(); + + // Store for later use in exchangeCode + lastRedirectUri = cleanRedirectUri; + + const url = new URL("https://business-api.tiktok.com/portal/auth"); + url.searchParams.set("redirect_uri", cleanRedirectUri); + url.searchParams.set("app_id", process.env.TIKTOK_APP_ID!); + url.searchParams.set("scope", TIKTOK_ADS_SCOPES); + + // Pass state as a separate OAuth parameter + if (state) { + url.searchParams.set("state", state); + } + + return url.toString(); + }, + + // Exchanges the authorization code for access token + exchangeCode: async ({ + code, + code_verifier: _code_verifier, + code_challenge_method: _code_challenge_method, + }: any) => { + // Use the stored redirect_uri from authorizationUrl + const cleanRedirectUri = lastRedirectUri; + + if (!cleanRedirectUri) { + throw new Error( + "redirect_uri is required for TikTok OAuth token exchange", + ); + } + + const body = { + app_id: process.env.TIKTOK_APP_ID!, + secret: process.env.TIKTOK_APP_SECRET!, + auth_code: code, + grant_type: "authorization_code", + }; + + const response = await fetch( + "https://business-api.tiktok.com/open_api/v1.3/oauth2/access_token/", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`TikTok OAuth failed: ${response.status} - ${error}`); + } + + const result = (await response.json()) as { + code: number; + message: string; + data: { + access_token: string; + refresh_token?: string; + token_type: string; + advertiser_ids?: string[]; + scope?: string[]; + }; + }; + + if (result.code !== 0) { + throw new Error(`TikTok OAuth failed: ${result.message}`); + } + + return { + access_token: result.data.access_token, + refresh_token: result.data.refresh_token, + token_type: result.data.token_type || "Bearer", + }; + }, + }, +}); + +serve(runtime.fetch); diff --git a/tiktok-ads/server/tools/adgroups.ts b/tiktok-ads/server/tools/adgroups.ts new file mode 100644 index 0000000..0a07ed7 --- /dev/null +++ b/tiktok-ads/server/tools/adgroups.ts @@ -0,0 +1,334 @@ +/** + * Ad Group Management Tools + * + * Tools for listing, creating, and updating ad groups + */ + +import { createPrivateTool } from "@decocms/runtime/tools"; +import { z } from "zod"; +import type { Env } from "../main.ts"; +import { TikTokClient, getAccessToken } from "../lib/tiktok-client.ts"; + +// ============================================================================ +// Schema Definitions +// ============================================================================ + +const OperationStatusSchema = z.enum(["ENABLE", "DISABLE", "DELETE"]); + +const BudgetModeSchema = z.enum([ + "BUDGET_MODE_INFINITE", + "BUDGET_MODE_DAY", + "BUDGET_MODE_TOTAL", +]); + +const BidTypeSchema = z.enum(["BID_TYPE_NO_BID", "BID_TYPE_CUSTOM"]); + +const OptimizationGoalSchema = z.enum([ + "CLICK", + "CONVERT", + "SHOW", + "REACH", + "VIDEO_VIEW", + "LEAD_GENERATION", + "ENGAGEMENT", +]); + +const PlacementSchema = z.enum([ + "PLACEMENT_TIKTOK", + "PLACEMENT_PANGLE", + "PLACEMENT_GLOBAL_APP_BUNDLE", +]); + +const ScheduleTypeSchema = z.enum(["SCHEDULE_START_END", "SCHEDULE_FROM_NOW"]); + +const GenderSchema = z.enum([ + "GENDER_UNLIMITED", + "GENDER_MALE", + "GENDER_FEMALE", +]); + +const AdGroupSchema = z.object({ + adgroup_id: z.string().describe("Ad Group ID"), + adgroup_name: z.string().describe("Ad Group name"), + advertiser_id: z.string().describe("Advertiser ID"), + campaign_id: z.string().describe("Campaign ID"), + operation_status: OperationStatusSchema.describe("Operation status"), + secondary_status: z.string().describe("Secondary status"), + placement_type: z.string().describe("Placement type"), + placements: z.array(PlacementSchema).optional().describe("Placements"), + optimization_goal: OptimizationGoalSchema.describe("Optimization goal"), + bid_type: BidTypeSchema.describe("Bid type"), + bid_price: z.number().optional().describe("Bid price"), + budget_mode: BudgetModeSchema.describe("Budget mode"), + budget: z.number().optional().describe("Budget amount"), + schedule_type: ScheduleTypeSchema.describe("Schedule type"), + schedule_start_time: z.string().optional().describe("Schedule start time"), + schedule_end_time: z.string().optional().describe("Schedule end time"), + create_time: z.string().describe("Creation timestamp"), + modify_time: z.string().describe("Last modification timestamp"), +}); + +const PageInfoSchema = z.object({ + page: z.number().describe("Current page number"), + page_size: z.number().describe("Items per page"), + total_number: z.number().describe("Total number of items"), + total_page: z.number().describe("Total number of pages"), +}); + +// ============================================================================ +// List Ad Groups Tool +// ============================================================================ + +export const createListAdGroupsTool = (env: Env) => + createPrivateTool({ + id: "list_adgroups", + description: + "List all ad groups for an advertiser with optional filters for campaign, name, and status.", + inputSchema: z.object({ + advertiser_id: z.string().describe("Advertiser ID (required)"), + campaign_ids: z + .array(z.string()) + .optional() + .describe("Filter by campaign IDs"), + adgroup_ids: z + .array(z.string()) + .optional() + .describe("Filter by specific ad group IDs"), + adgroup_name: z + .string() + .optional() + .describe("Filter by ad group name (partial match)"), + page: z.coerce + .number() + .int() + .min(1) + .optional() + .describe("Page number (default: 1)"), + page_size: z.coerce + .number() + .int() + .min(1) + .max(1000) + .optional() + .describe("Items per page (default: 50, max: 1000)"), + }), + outputSchema: z.object({ + adgroups: z.array(AdGroupSchema).describe("List of ad groups"), + page_info: PageInfoSchema.describe("Pagination info"), + }), + execute: async ({ context }) => { + const client = new TikTokClient({ + accessToken: getAccessToken(env), + }); + + const result = await client.listAdGroups({ + advertiser_id: context.advertiser_id, + campaign_ids: context.campaign_ids, + adgroup_ids: context.adgroup_ids, + filtering: { + adgroup_name: context.adgroup_name, + }, + page: context.page, + page_size: context.page_size, + }); + + return { + adgroups: result.adgroups, + page_info: result.page_info, + }; + }, + }); + +// ============================================================================ +// Get Ad Group Tool +// ============================================================================ + +export const createGetAdGroupTool = (env: Env) => + createPrivateTool({ + id: "get_adgroup", + description: + "Get detailed information about a specific ad group by its ID.", + inputSchema: z.object({ + advertiser_id: z.string().describe("Advertiser ID (required)"), + adgroup_id: z.string().describe("Ad Group ID to retrieve"), + }), + outputSchema: z.object({ + adgroup: AdGroupSchema.nullable().describe("Ad Group details"), + }), + execute: async ({ context }) => { + const client = new TikTokClient({ + accessToken: getAccessToken(env), + }); + + const result = await client.listAdGroups({ + advertiser_id: context.advertiser_id, + adgroup_ids: [context.adgroup_id], + }); + + return { + adgroup: result.adgroups[0] || null, + }; + }, + }); + +// ============================================================================ +// Create Ad Group Tool +// ============================================================================ + +export const createCreateAdGroupTool = (env: Env) => + createPrivateTool({ + id: "create_adgroup", + description: + "Create a new ad group within a campaign. Requires advertiser ID, campaign ID, name, and optimization goal.", + inputSchema: z.object({ + advertiser_id: z.string().describe("Advertiser ID (required)"), + campaign_id: z.string().describe("Campaign ID (required)"), + adgroup_name: z.string().describe("Ad Group name (required)"), + optimization_goal: OptimizationGoalSchema.describe( + "Optimization goal (required). Options: CLICK, CONVERT, SHOW, REACH, VIDEO_VIEW, LEAD_GENERATION, ENGAGEMENT", + ), + placements: z + .array(PlacementSchema) + .optional() + .describe( + "Placements: PLACEMENT_TIKTOK, PLACEMENT_PANGLE, PLACEMENT_GLOBAL_APP_BUNDLE", + ), + bid_type: BidTypeSchema.optional().describe( + "Bid type: BID_TYPE_NO_BID or BID_TYPE_CUSTOM", + ), + bid_price: z.coerce + .number() + .positive() + .optional() + .describe("Bid price (required if bid_type is CUSTOM)"), + budget_mode: BudgetModeSchema.optional().describe("Budget mode"), + budget: z.coerce.number().positive().optional().describe("Budget amount"), + schedule_type: ScheduleTypeSchema.optional().describe( + "Schedule type: SCHEDULE_START_END or SCHEDULE_FROM_NOW", + ), + schedule_start_time: z + .string() + .optional() + .describe("Start time (format: YYYY-MM-DD HH:mm:ss)"), + schedule_end_time: z + .string() + .optional() + .describe("End time (format: YYYY-MM-DD HH:mm:ss)"), + location_ids: z + .array(z.string()) + .optional() + .describe("Target location IDs"), + gender: GenderSchema.optional().describe("Target gender"), + age_groups: z.array(z.string()).optional().describe("Target age groups"), + languages: z.array(z.string()).optional().describe("Target languages"), + operation_status: OperationStatusSchema.optional().describe( + "Initial status: ENABLE or DISABLE", + ), + }), + outputSchema: z.object({ + adgroup_id: z.string().describe("ID of the created ad group"), + success: z.boolean().describe("Whether creation was successful"), + message: z.string().describe("Result message"), + }), + execute: async ({ context }) => { + const client = new TikTokClient({ + accessToken: getAccessToken(env), + }); + + const result = await client.createAdGroup({ + advertiser_id: context.advertiser_id, + campaign_id: context.campaign_id, + adgroup_name: context.adgroup_name, + optimization_goal: context.optimization_goal, + placements: context.placements, + bid_type: context.bid_type, + bid_price: context.bid_price, + budget_mode: context.budget_mode, + budget: context.budget, + schedule_type: context.schedule_type, + schedule_start_time: context.schedule_start_time, + schedule_end_time: context.schedule_end_time, + location_ids: context.location_ids, + gender: context.gender, + age_groups: context.age_groups, + languages: context.languages, + operation_status: context.operation_status, + }); + + return { + adgroup_id: result.adgroup_id, + success: true, + message: `Ad Group "${context.adgroup_name}" created successfully`, + }; + }, + }); + +// ============================================================================ +// Update Ad Group Tool +// ============================================================================ + +export const createUpdateAdGroupTool = (env: Env) => + createPrivateTool({ + id: "update_adgroup", + description: + "Update an existing ad group. Only provided fields will be updated.", + inputSchema: z.object({ + advertiser_id: z.string().describe("Advertiser ID (required)"), + adgroup_id: z.string().describe("Ad Group ID to update (required)"), + adgroup_name: z.string().optional().describe("New ad group name"), + bid_price: z.coerce + .number() + .positive() + .optional() + .describe("New bid price"), + budget: z.coerce + .number() + .positive() + .optional() + .describe("New budget amount"), + schedule_end_time: z + .string() + .optional() + .describe("New end time (format: YYYY-MM-DD HH:mm:ss)"), + operation_status: OperationStatusSchema.optional().describe( + "New status: ENABLE, DISABLE, or DELETE", + ), + }), + outputSchema: z.object({ + adgroup_id: z.string().describe("ID of the updated ad group"), + success: z.boolean().describe("Whether update was successful"), + message: z.string().describe("Result message"), + }), + execute: async ({ context }) => { + const client = new TikTokClient({ + accessToken: getAccessToken(env), + }); + + const result = await client.updateAdGroup({ + advertiser_id: context.advertiser_id, + adgroup_id: context.adgroup_id, + adgroup_name: context.adgroup_name, + bid_price: context.bid_price, + budget: context.budget, + schedule_end_time: context.schedule_end_time, + operation_status: context.operation_status, + }); + + return { + adgroup_id: result.adgroup_id, + success: true, + message: `Ad Group ${context.adgroup_id} updated successfully`, + }; + }, + }); + +// ============================================================================ +// Export all ad group tools +// ============================================================================ + +export const adgroupTools = [ + createListAdGroupsTool, + createGetAdGroupTool, + createCreateAdGroupTool, + createUpdateAdGroupTool, +]; diff --git a/tiktok-ads/server/tools/ads.ts b/tiktok-ads/server/tools/ads.ts new file mode 100644 index 0000000..3107cbb --- /dev/null +++ b/tiktok-ads/server/tools/ads.ts @@ -0,0 +1,301 @@ +/** + * Ad Management Tools + * + * Tools for listing, creating, and updating ads + */ + +import { createPrivateTool } from "@decocms/runtime/tools"; +import { z } from "zod"; +import type { Env } from "../main.ts"; +import { TikTokClient, getAccessToken } from "../lib/tiktok-client.ts"; + +// ============================================================================ +// Schema Definitions +// ============================================================================ + +const OperationStatusSchema = z.enum(["ENABLE", "DISABLE", "DELETE"]); + +const AdFormatSchema = z.enum([ + "SINGLE_VIDEO", + "SINGLE_IMAGE", + "VIDEO_CAROUSEL", + "IMAGE_CAROUSEL", + "SPARK_ADS", +]); + +const AdSchema = z.object({ + ad_id: z.string().describe("Ad ID"), + ad_name: z.string().describe("Ad name"), + advertiser_id: z.string().describe("Advertiser ID"), + campaign_id: z.string().describe("Campaign ID"), + adgroup_id: z.string().describe("Ad Group ID"), + operation_status: OperationStatusSchema.describe("Operation status"), + secondary_status: z.string().describe("Secondary status"), + ad_format: AdFormatSchema.describe("Ad format"), + ad_text: z.string().optional().describe("Ad text/caption"), + call_to_action: z.string().optional().describe("Call to action text"), + landing_page_url: z.string().optional().describe("Landing page URL"), + display_name: z.string().optional().describe("Display name"), + video_id: z.string().optional().describe("Video ID"), + image_ids: z.array(z.string()).optional().describe("Image IDs"), + create_time: z.string().describe("Creation timestamp"), + modify_time: z.string().describe("Last modification timestamp"), +}); + +const PageInfoSchema = z.object({ + page: z.number().describe("Current page number"), + page_size: z.number().describe("Items per page"), + total_number: z.number().describe("Total number of items"), + total_page: z.number().describe("Total number of pages"), +}); + +// ============================================================================ +// List Ads Tool +// ============================================================================ + +export const createListAdsTool = (env: Env) => + createPrivateTool({ + id: "list_ads", + description: + "List all ads for an advertiser with optional filters for campaign, ad group, name, and status.", + inputSchema: z.object({ + advertiser_id: z.string().describe("Advertiser ID (required)"), + campaign_ids: z + .array(z.string()) + .optional() + .describe("Filter by campaign IDs"), + adgroup_ids: z + .array(z.string()) + .optional() + .describe("Filter by ad group IDs"), + ad_ids: z + .array(z.string()) + .optional() + .describe("Filter by specific ad IDs"), + ad_name: z + .string() + .optional() + .describe("Filter by ad name (partial match)"), + page: z.coerce + .number() + .int() + .min(1) + .optional() + .describe("Page number (default: 1)"), + page_size: z.coerce + .number() + .int() + .min(1) + .max(1000) + .optional() + .describe("Items per page (default: 50, max: 1000)"), + }), + outputSchema: z.object({ + ads: z.array(AdSchema).describe("List of ads"), + page_info: PageInfoSchema.describe("Pagination info"), + }), + execute: async ({ context }) => { + const client = new TikTokClient({ + accessToken: getAccessToken(env), + }); + + const result = await client.listAds({ + advertiser_id: context.advertiser_id, + campaign_ids: context.campaign_ids, + adgroup_ids: context.adgroup_ids, + ad_ids: context.ad_ids, + filtering: { + ad_name: context.ad_name, + }, + page: context.page, + page_size: context.page_size, + }); + + return { + ads: result.ads, + page_info: result.page_info, + }; + }, + }); + +// ============================================================================ +// Get Ad Tool +// ============================================================================ + +export const createGetAdTool = (env: Env) => + createPrivateTool({ + id: "get_ad", + description: "Get detailed information about a specific ad by its ID.", + inputSchema: z.object({ + advertiser_id: z.string().describe("Advertiser ID (required)"), + ad_id: z.string().describe("Ad ID to retrieve"), + }), + outputSchema: z.object({ + ad: AdSchema.nullable().describe("Ad details"), + }), + execute: async ({ context }) => { + const client = new TikTokClient({ + accessToken: getAccessToken(env), + }); + + const result = await client.listAds({ + advertiser_id: context.advertiser_id, + ad_ids: [context.ad_id], + }); + + return { + ad: result.ads[0] || null, + }; + }, + }); + +// ============================================================================ +// Create Ad Tool +// ============================================================================ + +export const createCreateAdTool = (env: Env) => + createPrivateTool({ + id: "create_ad", + description: + "Create a new ad within an ad group. Requires advertiser ID, ad group ID, name, and ad format.", + inputSchema: z.object({ + advertiser_id: z.string().describe("Advertiser ID (required)"), + adgroup_id: z.string().describe("Ad Group ID (required)"), + ad_name: z.string().describe("Ad name (required)"), + ad_format: AdFormatSchema.describe( + "Ad format (required). Options: SINGLE_VIDEO, SINGLE_IMAGE, VIDEO_CAROUSEL, IMAGE_CAROUSEL, SPARK_ADS", + ), + ad_text: z + .string() + .optional() + .describe("Ad text/caption (max 100 characters)"), + call_to_action: z + .string() + .optional() + .describe( + "Call to action (e.g., 'Learn More', 'Shop Now', 'Sign Up', 'Download')", + ), + landing_page_url: z + .string() + .url() + .optional() + .describe("Landing page URL"), + display_name: z + .string() + .optional() + .describe("Display name shown on the ad"), + video_id: z + .string() + .optional() + .describe("Video ID (required for video ads)"), + image_ids: z + .array(z.string()) + .optional() + .describe("Image IDs (required for image ads)"), + identity_id: z + .string() + .optional() + .describe("TikTok account identity ID (for Spark Ads)"), + identity_type: z + .string() + .optional() + .describe("Identity type: AUTH_CODE, TT_USER"), + operation_status: OperationStatusSchema.optional().describe( + "Initial status: ENABLE or DISABLE", + ), + }), + outputSchema: z.object({ + ad_id: z.string().describe("ID of the created ad"), + success: z.boolean().describe("Whether creation was successful"), + message: z.string().describe("Result message"), + }), + execute: async ({ context }) => { + const client = new TikTokClient({ + accessToken: getAccessToken(env), + }); + + const result = await client.createAd({ + advertiser_id: context.advertiser_id, + adgroup_id: context.adgroup_id, + ad_name: context.ad_name, + ad_format: context.ad_format, + ad_text: context.ad_text, + call_to_action: context.call_to_action, + landing_page_url: context.landing_page_url, + display_name: context.display_name, + video_id: context.video_id, + image_ids: context.image_ids, + identity_id: context.identity_id, + identity_type: context.identity_type, + operation_status: context.operation_status, + }); + + return { + ad_id: result.ad_id, + success: true, + message: `Ad "${context.ad_name}" created successfully`, + }; + }, + }); + +// ============================================================================ +// Update Ad Tool +// ============================================================================ + +export const createUpdateAdTool = (env: Env) => + createPrivateTool({ + id: "update_ad", + description: "Update an existing ad. Only provided fields will be updated.", + inputSchema: z.object({ + advertiser_id: z.string().describe("Advertiser ID (required)"), + ad_id: z.string().describe("Ad ID to update (required)"), + ad_name: z.string().optional().describe("New ad name"), + ad_text: z.string().optional().describe("New ad text/caption"), + call_to_action: z.string().optional().describe("New call to action"), + landing_page_url: z + .string() + .url() + .optional() + .describe("New landing page URL"), + operation_status: OperationStatusSchema.optional().describe( + "New status: ENABLE, DISABLE, or DELETE", + ), + }), + outputSchema: z.object({ + ad_id: z.string().describe("ID of the updated ad"), + success: z.boolean().describe("Whether update was successful"), + message: z.string().describe("Result message"), + }), + execute: async ({ context }) => { + const client = new TikTokClient({ + accessToken: getAccessToken(env), + }); + + const result = await client.updateAd({ + advertiser_id: context.advertiser_id, + ad_id: context.ad_id, + ad_name: context.ad_name, + ad_text: context.ad_text, + call_to_action: context.call_to_action, + landing_page_url: context.landing_page_url, + operation_status: context.operation_status, + }); + + return { + ad_id: result.ad_id, + success: true, + message: `Ad ${context.ad_id} updated successfully`, + }; + }, + }); + +// ============================================================================ +// Export all ad tools +// ============================================================================ + +export const adTools = [ + createListAdsTool, + createGetAdTool, + createCreateAdTool, + createUpdateAdTool, +]; diff --git a/tiktok-ads/server/tools/campaigns.ts b/tiktok-ads/server/tools/campaigns.ts new file mode 100644 index 0000000..89ce155 --- /dev/null +++ b/tiktok-ads/server/tools/campaigns.ts @@ -0,0 +1,263 @@ +/** + * Campaign Management Tools + * + * Tools for listing, creating, and updating campaigns + */ + +import { createPrivateTool } from "@decocms/runtime/tools"; +import { z } from "zod"; +import type { Env } from "../main.ts"; +import { TikTokClient, getAccessToken } from "../lib/tiktok-client.ts"; + +// ============================================================================ +// Schema Definitions +// ============================================================================ + +const CampaignObjectiveSchema = z.enum([ + "TRAFFIC", + "APP_PROMOTION", + "WEB_CONVERSIONS", + "PRODUCT_SALES", + "REACH", + "VIDEO_VIEWS", + "LEAD_GENERATION", + "COMMUNITY_INTERACTION", +]); + +const BudgetModeSchema = z.enum([ + "BUDGET_MODE_INFINITE", + "BUDGET_MODE_DAY", + "BUDGET_MODE_TOTAL", +]); + +const OperationStatusSchema = z.enum(["ENABLE", "DISABLE", "DELETE"]); + +const CampaignSchema = z.object({ + campaign_id: z.string().describe("Campaign ID"), + campaign_name: z.string().describe("Campaign name"), + advertiser_id: z.string().describe("Advertiser ID"), + objective_type: CampaignObjectiveSchema.describe("Campaign objective"), + operation_status: OperationStatusSchema.describe("Operation status"), + secondary_status: z.string().describe("Secondary status"), + budget_mode: BudgetModeSchema.describe("Budget mode"), + budget: z.number().optional().describe("Budget amount"), + create_time: z.string().describe("Creation timestamp"), + modify_time: z.string().describe("Last modification timestamp"), +}); + +const PageInfoSchema = z.object({ + page: z.number().describe("Current page number"), + page_size: z.number().describe("Items per page"), + total_number: z.number().describe("Total number of items"), + total_page: z.number().describe("Total number of pages"), +}); + +// ============================================================================ +// List Campaigns Tool +// ============================================================================ + +export const createListCampaignsTool = (env: Env) => + createPrivateTool({ + id: "list_campaigns", + description: + "List all campaigns for an advertiser with optional filters for name, objective, and status.", + inputSchema: z.object({ + advertiser_id: z.string().describe("Advertiser ID (required)"), + campaign_ids: z + .array(z.string()) + .optional() + .describe("Filter by specific campaign IDs"), + campaign_name: z + .string() + .optional() + .describe("Filter by campaign name (partial match)"), + objective_type: CampaignObjectiveSchema.optional().describe( + "Filter by objective type", + ), + page: z.coerce + .number() + .int() + .min(1) + .optional() + .describe("Page number (default: 1)"), + page_size: z.coerce + .number() + .int() + .min(1) + .max(1000) + .optional() + .describe("Items per page (default: 50, max: 1000)"), + }), + outputSchema: z.object({ + campaigns: z.array(CampaignSchema).describe("List of campaigns"), + page_info: PageInfoSchema.describe("Pagination info"), + }), + execute: async ({ context }) => { + const client = new TikTokClient({ + accessToken: getAccessToken(env), + }); + + const result = await client.listCampaigns({ + advertiser_id: context.advertiser_id, + campaign_ids: context.campaign_ids, + filtering: { + campaign_name: context.campaign_name, + objective_type: context.objective_type, + }, + page: context.page, + page_size: context.page_size, + }); + + return { + campaigns: result.campaigns, + page_info: result.page_info, + }; + }, + }); + +// ============================================================================ +// Get Campaign Tool +// ============================================================================ + +export const createGetCampaignTool = (env: Env) => + createPrivateTool({ + id: "get_campaign", + description: + "Get detailed information about a specific campaign by its ID.", + inputSchema: z.object({ + advertiser_id: z.string().describe("Advertiser ID (required)"), + campaign_id: z.string().describe("Campaign ID to retrieve"), + }), + outputSchema: z.object({ + campaign: CampaignSchema.nullable().describe("Campaign details"), + }), + execute: async ({ context }) => { + const client = new TikTokClient({ + accessToken: getAccessToken(env), + }); + + const result = await client.listCampaigns({ + advertiser_id: context.advertiser_id, + campaign_ids: [context.campaign_id], + }); + + return { + campaign: result.campaigns[0] || null, + }; + }, + }); + +// ============================================================================ +// Create Campaign Tool +// ============================================================================ + +export const createCreateCampaignTool = (env: Env) => + createPrivateTool({ + id: "create_campaign", + description: + "Create a new advertising campaign. Requires advertiser ID, name, and objective type.", + inputSchema: z.object({ + advertiser_id: z.string().describe("Advertiser ID (required)"), + campaign_name: z.string().describe("Campaign name (required)"), + objective_type: CampaignObjectiveSchema.describe( + "Campaign objective (required). Options: TRAFFIC, APP_PROMOTION, WEB_CONVERSIONS, PRODUCT_SALES, REACH, VIDEO_VIEWS, LEAD_GENERATION, COMMUNITY_INTERACTION", + ), + budget_mode: BudgetModeSchema.optional().describe( + "Budget mode: BUDGET_MODE_INFINITE (no limit), BUDGET_MODE_DAY (daily), BUDGET_MODE_TOTAL (lifetime)", + ), + budget: z.coerce + .number() + .positive() + .optional() + .describe("Budget amount (required if budget_mode is DAY or TOTAL)"), + operation_status: OperationStatusSchema.optional().describe( + "Initial status: ENABLE or DISABLE (default: ENABLE)", + ), + }), + outputSchema: z.object({ + campaign_id: z.string().describe("ID of the created campaign"), + success: z.boolean().describe("Whether creation was successful"), + message: z.string().describe("Result message"), + }), + execute: async ({ context }) => { + const client = new TikTokClient({ + accessToken: getAccessToken(env), + }); + + const result = await client.createCampaign({ + advertiser_id: context.advertiser_id, + campaign_name: context.campaign_name, + objective_type: context.objective_type, + budget_mode: context.budget_mode, + budget: context.budget, + operation_status: context.operation_status, + }); + + return { + campaign_id: result.campaign_id, + success: true, + message: `Campaign "${context.campaign_name}" created successfully`, + }; + }, + }); + +// ============================================================================ +// Update Campaign Tool +// ============================================================================ + +export const createUpdateCampaignTool = (env: Env) => + createPrivateTool({ + id: "update_campaign", + description: + "Update an existing campaign. Only provided fields will be updated.", + inputSchema: z.object({ + advertiser_id: z.string().describe("Advertiser ID (required)"), + campaign_id: z.string().describe("Campaign ID to update (required)"), + campaign_name: z.string().optional().describe("New campaign name"), + budget_mode: BudgetModeSchema.optional().describe("New budget mode"), + budget: z.coerce + .number() + .positive() + .optional() + .describe("New budget amount"), + operation_status: OperationStatusSchema.optional().describe( + "New status: ENABLE, DISABLE, or DELETE", + ), + }), + outputSchema: z.object({ + campaign_id: z.string().describe("ID of the updated campaign"), + success: z.boolean().describe("Whether update was successful"), + message: z.string().describe("Result message"), + }), + execute: async ({ context }) => { + const client = new TikTokClient({ + accessToken: getAccessToken(env), + }); + + const result = await client.updateCampaign({ + advertiser_id: context.advertiser_id, + campaign_id: context.campaign_id, + campaign_name: context.campaign_name, + budget_mode: context.budget_mode, + budget: context.budget, + operation_status: context.operation_status, + }); + + return { + campaign_id: result.campaign_id, + success: true, + message: `Campaign ${context.campaign_id} updated successfully`, + }; + }, + }); + +// ============================================================================ +// Export all campaign tools +// ============================================================================ + +export const campaignTools = [ + createListCampaignsTool, + createGetCampaignTool, + createCreateCampaignTool, + createUpdateCampaignTool, +]; diff --git a/tiktok-ads/server/tools/index.ts b/tiktok-ads/server/tools/index.ts new file mode 100644 index 0000000..75c520b --- /dev/null +++ b/tiktok-ads/server/tools/index.ts @@ -0,0 +1,29 @@ +/** + * Central export point for all TikTok Ads tools + * + * This file aggregates all tools from different modules into a single + * export, making it easy to import all tools in main.ts. + * + * Tools: + * - campaignTools: Campaign management (list, get, create, update) + * - adgroupTools: Ad Group management (list, get, create, update) + * - adTools: Ad management (list, get, create, update) + * - reportTools: Reports and analytics (get_report, campaign/adgroup/ad reports, advertiser info) + */ + +import { campaignTools } from "./campaigns.ts"; +import { adgroupTools } from "./adgroups.ts"; +import { adTools } from "./ads.ts"; +import { reportTools } from "./reports.ts"; + +// Export all tools from all modules +export const tools = [ + // Campaign management tools + ...campaignTools, + // Ad Group management tools + ...adgroupTools, + // Ad management tools + ...adTools, + // Report and analytics tools + ...reportTools, +]; diff --git a/tiktok-ads/server/tools/reports.ts b/tiktok-ads/server/tools/reports.ts new file mode 100644 index 0000000..a9cb919 --- /dev/null +++ b/tiktok-ads/server/tools/reports.ts @@ -0,0 +1,484 @@ +/** + * Report Tools + * + * Tools for getting performance reports at different levels + */ + +import { createPrivateTool } from "@decocms/runtime/tools"; +import { z } from "zod"; +import type { Env } from "../main.ts"; +import { TikTokClient, getAccessToken } from "../lib/tiktok-client.ts"; + +// ============================================================================ +// Schema Definitions +// ============================================================================ + +const DataLevelSchema = z.enum([ + "AUCTION_ADVERTISER", + "AUCTION_CAMPAIGN", + "AUCTION_ADGROUP", + "AUCTION_AD", +]); + +const DimensionSchema = z.enum([ + "advertiser_id", + "campaign_id", + "adgroup_id", + "ad_id", + "stat_time_day", + "stat_time_hour", +]); + +const MetricSchema = z.enum([ + "spend", + "impressions", + "clicks", + "ctr", + "cpc", + "cpm", + "reach", + "frequency", + "conversion", + "cost_per_conversion", + "conversion_rate", + "video_play_actions", + "video_watched_2s", + "video_watched_6s", + "average_video_play", + "average_video_play_per_user", + "profile_visits", + "likes", + "comments", + "shares", + "follows", +]); + +const ReportRowSchema = z.object({ + dimensions: z + .record(z.string()) + .describe("Dimension values (campaign_id, stat_time_day, etc.)"), + metrics: z + .record(z.number()) + .describe("Metric values (spend, impressions, clicks, etc.)"), +}); + +const PageInfoSchema = z.object({ + page: z.number().describe("Current page number"), + page_size: z.number().describe("Items per page"), + total_number: z.number().describe("Total number of items"), + total_page: z.number().describe("Total number of pages"), +}); + +// ============================================================================ +// Get Report Tool (Generic) +// ============================================================================ + +export const createGetReportTool = (env: Env) => + createPrivateTool({ + id: "get_report", + description: + "Get performance report data for campaigns, ad groups, or ads. Supports custom date ranges, dimensions, and metrics.", + inputSchema: z.object({ + advertiser_id: z.string().describe("Advertiser ID (required)"), + data_level: DataLevelSchema.describe( + "Data level: AUCTION_ADVERTISER, AUCTION_CAMPAIGN, AUCTION_ADGROUP, AUCTION_AD", + ), + start_date: z + .string() + .describe("Start date (format: YYYY-MM-DD, required)"), + end_date: z.string().describe("End date (format: YYYY-MM-DD, required)"), + dimensions: z + .array(DimensionSchema) + .optional() + .describe( + "Dimensions to include: advertiser_id, campaign_id, adgroup_id, ad_id, stat_time_day, stat_time_hour", + ), + metrics: z + .array(MetricSchema) + .optional() + .describe( + "Metrics to include: spend, impressions, clicks, ctr, cpc, cpm, reach, conversion, etc. Default: all common metrics", + ), + campaign_ids: z + .array(z.string()) + .optional() + .describe("Filter by campaign IDs"), + adgroup_ids: z + .array(z.string()) + .optional() + .describe("Filter by ad group IDs"), + ad_ids: z.array(z.string()).optional().describe("Filter by ad IDs"), + page: z.coerce + .number() + .int() + .min(1) + .optional() + .describe("Page number (default: 1)"), + page_size: z.coerce + .number() + .int() + .min(1) + .max(1000) + .optional() + .describe("Items per page (default: 50, max: 1000)"), + }), + outputSchema: z.object({ + rows: z.array(ReportRowSchema).describe("Report data rows"), + page_info: PageInfoSchema.describe("Pagination info"), + }), + execute: async ({ context }) => { + const client = new TikTokClient({ + accessToken: getAccessToken(env), + }); + + // Default dimensions based on data level + let dimensions = context.dimensions; + if (!dimensions || dimensions.length === 0) { + switch (context.data_level) { + case "AUCTION_CAMPAIGN": + dimensions = ["campaign_id", "stat_time_day"]; + break; + case "AUCTION_ADGROUP": + dimensions = ["adgroup_id", "stat_time_day"]; + break; + case "AUCTION_AD": + dimensions = ["ad_id", "stat_time_day"]; + break; + default: + dimensions = ["advertiser_id", "stat_time_day"]; + } + } + + // Default metrics + const metrics = + context.metrics && context.metrics.length > 0 + ? context.metrics + : [ + "spend", + "impressions", + "clicks", + "ctr", + "cpc", + "cpm", + "conversion", + ]; + + const result = await client.getReport({ + advertiser_id: context.advertiser_id, + data_level: context.data_level, + dimensions, + metrics, + start_date: context.start_date, + end_date: context.end_date, + filters: { + campaign_ids: context.campaign_ids, + adgroup_ids: context.adgroup_ids, + ad_ids: context.ad_ids, + }, + page: context.page, + page_size: context.page_size, + }); + + return { + rows: result.rows, + page_info: result.page_info, + }; + }, + }); + +// ============================================================================ +// Get Campaign Report Tool (Simplified) +// ============================================================================ + +export const createGetCampaignReportTool = (env: Env) => + createPrivateTool({ + id: "get_campaign_report", + description: + "Get performance report for campaigns. Returns spend, impressions, clicks, conversions and other metrics by day.", + inputSchema: z.object({ + advertiser_id: z.string().describe("Advertiser ID (required)"), + start_date: z + .string() + .describe("Start date (format: YYYY-MM-DD, required)"), + end_date: z.string().describe("End date (format: YYYY-MM-DD, required)"), + campaign_ids: z + .array(z.string()) + .optional() + .describe("Filter by specific campaign IDs"), + page: z.coerce + .number() + .int() + .min(1) + .optional() + .describe("Page number (default: 1)"), + page_size: z.coerce + .number() + .int() + .min(1) + .max(1000) + .optional() + .describe("Items per page (default: 50)"), + }), + outputSchema: z.object({ + rows: z.array(ReportRowSchema).describe("Campaign report data"), + page_info: PageInfoSchema.describe("Pagination info"), + }), + execute: async ({ context }) => { + const client = new TikTokClient({ + accessToken: getAccessToken(env), + }); + + const result = await client.getReport({ + advertiser_id: context.advertiser_id, + data_level: "AUCTION_CAMPAIGN", + dimensions: ["campaign_id", "stat_time_day"], + metrics: [ + "spend", + "impressions", + "clicks", + "ctr", + "cpc", + "cpm", + "reach", + "conversion", + "cost_per_conversion", + ], + start_date: context.start_date, + end_date: context.end_date, + filters: { + campaign_ids: context.campaign_ids, + }, + page: context.page, + page_size: context.page_size, + }); + + return { + rows: result.rows, + page_info: result.page_info, + }; + }, + }); + +// ============================================================================ +// Get Ad Group Report Tool (Simplified) +// ============================================================================ + +export const createGetAdGroupReportTool = (env: Env) => + createPrivateTool({ + id: "get_adgroup_report", + description: + "Get performance report for ad groups. Returns spend, impressions, clicks, conversions and other metrics by day.", + inputSchema: z.object({ + advertiser_id: z.string().describe("Advertiser ID (required)"), + start_date: z + .string() + .describe("Start date (format: YYYY-MM-DD, required)"), + end_date: z.string().describe("End date (format: YYYY-MM-DD, required)"), + campaign_ids: z + .array(z.string()) + .optional() + .describe("Filter by campaign IDs"), + adgroup_ids: z + .array(z.string()) + .optional() + .describe("Filter by specific ad group IDs"), + page: z.coerce + .number() + .int() + .min(1) + .optional() + .describe("Page number (default: 1)"), + page_size: z.coerce + .number() + .int() + .min(1) + .max(1000) + .optional() + .describe("Items per page (default: 50)"), + }), + outputSchema: z.object({ + rows: z.array(ReportRowSchema).describe("Ad group report data"), + page_info: PageInfoSchema.describe("Pagination info"), + }), + execute: async ({ context }) => { + const client = new TikTokClient({ + accessToken: getAccessToken(env), + }); + + const result = await client.getReport({ + advertiser_id: context.advertiser_id, + data_level: "AUCTION_ADGROUP", + dimensions: ["adgroup_id", "stat_time_day"], + metrics: [ + "spend", + "impressions", + "clicks", + "ctr", + "cpc", + "cpm", + "reach", + "conversion", + "cost_per_conversion", + ], + start_date: context.start_date, + end_date: context.end_date, + filters: { + campaign_ids: context.campaign_ids, + adgroup_ids: context.adgroup_ids, + }, + page: context.page, + page_size: context.page_size, + }); + + return { + rows: result.rows, + page_info: result.page_info, + }; + }, + }); + +// ============================================================================ +// Get Ad Report Tool (Simplified) +// ============================================================================ + +export const createGetAdReportTool = (env: Env) => + createPrivateTool({ + id: "get_ad_report", + description: + "Get performance report for individual ads. Returns spend, impressions, clicks, conversions and other metrics by day.", + inputSchema: z.object({ + advertiser_id: z.string().describe("Advertiser ID (required)"), + start_date: z + .string() + .describe("Start date (format: YYYY-MM-DD, required)"), + end_date: z.string().describe("End date (format: YYYY-MM-DD, required)"), + campaign_ids: z + .array(z.string()) + .optional() + .describe("Filter by campaign IDs"), + adgroup_ids: z + .array(z.string()) + .optional() + .describe("Filter by ad group IDs"), + ad_ids: z + .array(z.string()) + .optional() + .describe("Filter by specific ad IDs"), + page: z.coerce + .number() + .int() + .min(1) + .optional() + .describe("Page number (default: 1)"), + page_size: z.coerce + .number() + .int() + .min(1) + .max(1000) + .optional() + .describe("Items per page (default: 50)"), + }), + outputSchema: z.object({ + rows: z.array(ReportRowSchema).describe("Ad report data"), + page_info: PageInfoSchema.describe("Pagination info"), + }), + execute: async ({ context }) => { + const client = new TikTokClient({ + accessToken: getAccessToken(env), + }); + + const result = await client.getReport({ + advertiser_id: context.advertiser_id, + data_level: "AUCTION_AD", + dimensions: ["ad_id", "stat_time_day"], + metrics: [ + "spend", + "impressions", + "clicks", + "ctr", + "cpc", + "cpm", + "reach", + "conversion", + "cost_per_conversion", + "video_play_actions", + "video_watched_2s", + "video_watched_6s", + "likes", + "comments", + "shares", + ], + start_date: context.start_date, + end_date: context.end_date, + filters: { + campaign_ids: context.campaign_ids, + adgroup_ids: context.adgroup_ids, + ad_ids: context.ad_ids, + }, + page: context.page, + page_size: context.page_size, + }); + + return { + rows: result.rows, + page_info: result.page_info, + }; + }, + }); + +// ============================================================================ +// Get Advertiser Info Tool +// ============================================================================ + +export const createGetAdvertiserInfoTool = (env: Env) => + createPrivateTool({ + id: "get_advertiser_info", + description: + "Get information about one or more advertisers, including name, status, balance, and timezone.", + inputSchema: z.object({ + advertiser_ids: z + .array(z.string()) + .describe("List of advertiser IDs to retrieve (required)"), + }), + outputSchema: z.object({ + advertisers: z + .array( + z.object({ + advertiser_id: z.string().describe("Advertiser ID"), + advertiser_name: z.string().describe("Advertiser name"), + status: z.string().describe("Account status"), + company: z.string().optional().describe("Company name"), + balance: z.number().optional().describe("Account balance"), + currency: z.string().optional().describe("Currency code"), + timezone: z.string().optional().describe("Timezone"), + create_time: z.string().describe("Account creation time"), + }), + ) + .describe("List of advertiser information"), + }), + execute: async ({ context }) => { + const client = new TikTokClient({ + accessToken: getAccessToken(env), + }); + + const advertisers = await client.getAdvertiserInfo({ + advertiser_ids: context.advertiser_ids, + }); + + return { + advertisers, + }; + }, + }); + +// ============================================================================ +// Export all report tools +// ============================================================================ + +export const reportTools = [ + createGetReportTool, + createGetCampaignReportTool, + createGetAdGroupReportTool, + createGetAdReportTool, + createGetAdvertiserInfoTool, +]; diff --git a/tiktok-ads/shared/deco.gen.ts b/tiktok-ads/shared/deco.gen.ts new file mode 100644 index 0000000..2e62152 --- /dev/null +++ b/tiktok-ads/shared/deco.gen.ts @@ -0,0 +1,65 @@ +// Generated types for TikTok Ads MCP + +import { z } from "zod"; + +/** + * Mesh request context injected by the Deco runtime + * Contains authentication and metadata for the current request + */ +export interface MeshRequestContext { + /** OAuth access token from TikTok */ + authorization?: string; + /** Internal state for OAuth flow */ + state?: string; + /** JWT token for the request */ + token?: string; + /** URL of the mesh server */ + meshUrl?: string; + /** Connection ID for this session */ + connectionId?: string; + /** Function to ensure user is authenticated */ + ensureAuthenticated?: () => Promise; +} + +/** + * Environment type for TikTok Ads MCP + * Extends process env with Deco runtime context + */ +export interface Env { + /** TikTok App ID */ + TIKTOK_APP_ID?: string; + /** TikTok App Secret */ + TIKTOK_APP_SECRET?: string; + /** TikTok Access Token (for direct token auth) */ + TIKTOK_ACCESS_TOKEN?: string; + /** Mesh request context injected by runtime */ + MESH_REQUEST_CONTEXT: MeshRequestContext; + /** Self-reference MCP (if needed) */ + SELF?: unknown; + /** Whether running locally */ + IS_LOCAL?: boolean; +} + +/** + * State schema for OAuth flow validation + */ +export const StateSchema = z.object({}); + +/** + * MCP type helper for typed tool definitions + */ +export type Mcp Promise>> = { + [K in keyof T]: (( + input: Parameters[0], + ) => Promise>>) & { + asTool: () => Promise<{ + inputSchema: z.ZodType[0]>; + outputSchema?: z.ZodType>>; + description: string; + id: string; + execute: ( + input: Parameters[0], + ) => Promise>>; + }>; + }; +}; diff --git a/tiktok-ads/tsconfig.json b/tiktok-ads/tsconfig.json new file mode 100644 index 0000000..34882ab --- /dev/null +++ b/tiktok-ads/tsconfig.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "paths": { + "@decocms/mcps-shared/*": ["../shared/*"] + }, + "types": ["bun-types"] + }, + "include": [ + "server/**/*.ts", + "shared/**/*.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} +