@channel47/tiktok-ads-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,109 @@
1
+ import { tiktokRequest } from '../http.js';
2
+ import { getAppCredentials } from '../auth.js';
3
+ import { formatError, formatSuccess } from '../utils/response-format.js';
4
+ import { invalidParamsError } from '../utils/errors.js';
5
+
6
+ // GET /advertiser/info/ accepts at most 100 advertiser ids per request.
7
+ const INFO_BATCH_SIZE = 100;
8
+ const INFO_FIELDS = [
9
+ 'advertiser_id',
10
+ 'name',
11
+ 'status',
12
+ 'currency',
13
+ 'timezone',
14
+ 'company',
15
+ 'country',
16
+ 'create_time'
17
+ ];
18
+
19
+ function normalizeAdvertiserIds(value) {
20
+ if (value === undefined || value === null || value === '') {
21
+ return [];
22
+ }
23
+
24
+ const values = Array.isArray(value) ? value : String(value).split(',');
25
+ return values.map((id) => String(id).trim()).filter(Boolean);
26
+ }
27
+
28
+ function mapAccount(account) {
29
+ return {
30
+ advertiser_id: String(account?.advertiser_id || ''),
31
+ name: account?.name || '',
32
+ status: account?.status || 'UNKNOWN',
33
+ currency: account?.currency || null,
34
+ timezone: account?.timezone || null,
35
+ company: account?.company || null,
36
+ country: account?.country || null
37
+ };
38
+ }
39
+
40
+ function chunk(values, size) {
41
+ const chunks = [];
42
+ for (let index = 0; index < values.length; index += size) {
43
+ chunks.push(values.slice(index, index + size));
44
+ }
45
+ return chunks;
46
+ }
47
+
48
+ /**
49
+ * List accessible TikTok ad accounts (advertisers) with detail lookup.
50
+ *
51
+ * When TIKTOK_ADS_APP_ID and TIKTOK_ADS_APP_SECRET are configured, advertiser
52
+ * ids are discovered via GET /oauth2/advertiser/get/; otherwise ids must come
53
+ * from the advertiser_ids param or TIKTOK_ADS_ADVERTISER_ID.
54
+ * @param {{ advertiser_ids?: string[] | string }} [params]
55
+ * @param {{ request?: (path: string, params: Record<string, unknown>, options?: Record<string, unknown>) => Promise<any> }} [dependencies]
56
+ * @returns {Promise<import('@modelcontextprotocol/sdk/types.js').CallToolResult>}
57
+ */
58
+ export async function listAccounts(params = {}, dependencies = {}) {
59
+ const request = dependencies.request || tiktokRequest;
60
+
61
+ try {
62
+ let advertiserIds = normalizeAdvertiserIds(params.advertiser_ids);
63
+ let discoveredViaOauth = false;
64
+
65
+ if (advertiserIds.length === 0) {
66
+ const credentials = getAppCredentials();
67
+
68
+ if (credentials) {
69
+ const response = await request('/oauth2/advertiser/get/', {
70
+ app_id: credentials.appId,
71
+ secret: credentials.appSecret
72
+ });
73
+ const list = Array.isArray(response?.data?.list) ? response.data.list : [];
74
+ advertiserIds = list.map((entry) => String(entry?.advertiser_id || '')).filter(Boolean);
75
+ discoveredViaOauth = true;
76
+ } else if (process.env.TIKTOK_ADS_ADVERTISER_ID) {
77
+ advertiserIds = [String(process.env.TIKTOK_ADS_ADVERTISER_ID)];
78
+ }
79
+ }
80
+
81
+ if (advertiserIds.length === 0) {
82
+ throw invalidParamsError(
83
+ 'No advertiser ids available. Either pass advertiser_ids, set TIKTOK_ADS_ADVERTISER_ID, '
84
+ + 'or set TIKTOK_ADS_APP_ID and TIKTOK_ADS_APP_SECRET to enable discovery via /oauth2/advertiser/get/.'
85
+ );
86
+ }
87
+
88
+ const accounts = [];
89
+ for (const batch of chunk(advertiserIds, INFO_BATCH_SIZE)) {
90
+ const response = await request('/advertiser/info/', {
91
+ advertiser_ids: batch,
92
+ fields: INFO_FIELDS
93
+ });
94
+ const list = Array.isArray(response?.data?.list) ? response.data.list : [];
95
+ accounts.push(...list.map(mapAccount));
96
+ }
97
+
98
+ return formatSuccess({
99
+ summary: `Found ${accounts.length} accessible TikTok ad account${accounts.length === 1 ? '' : 's'}`,
100
+ data: accounts,
101
+ metadata: {
102
+ totalAccounts: accounts.length,
103
+ discoveredViaOauth
104
+ }
105
+ });
106
+ } catch (error) {
107
+ return formatError(error);
108
+ }
109
+ }
@@ -0,0 +1,129 @@
1
+ import { tiktokRequest } from '../http.js';
2
+ import { formatError, formatSuccess } from '../utils/response-format.js';
3
+ import { invalidParamsError } from '../utils/errors.js';
4
+ import { getAdvertiserId } from '../utils/validation.js';
5
+ import {
6
+ buildApiRequest,
7
+ buildRequestPreview,
8
+ validateOperations
9
+ } from '../utils/mutate-operations.js';
10
+
11
+ function extractResultId(response, operation) {
12
+ const data = response?.data;
13
+ return (
14
+ data?.campaign_id
15
+ ?? data?.adgroup_id
16
+ ?? data?.ad_ids
17
+ ?? operation.id
18
+ ?? null
19
+ );
20
+ }
21
+
22
+ /**
23
+ * Validate and execute TikTok Ads mutation operations with dry-run safety defaults.
24
+ *
25
+ * TikTok has no server-side validate-only mode, so dry_run (default true)
26
+ * performs local validation and returns a preview of the exact requests
27
+ * (method, path, body) without calling the API.
28
+ * @param {Record<string, unknown>} [params]
29
+ * @param {{ request?: (path: string, params: Record<string, unknown>, options?: Record<string, unknown>) => Promise<any> }} [dependencies]
30
+ * @returns {Promise<import('@modelcontextprotocol/sdk/types.js').CallToolResult>}
31
+ */
32
+ export async function mutate(params = {}, dependencies = {}) {
33
+ const request = dependencies.request || tiktokRequest;
34
+
35
+ try {
36
+ const operations = params.operations;
37
+ const advertiserId = getAdvertiserId(params);
38
+ const partialFailure = params.partial_failure === undefined ? true : Boolean(params.partial_failure);
39
+ const dryRun = params.dry_run === undefined ? true : Boolean(params.dry_run);
40
+
41
+ const validationErrors = validateOperations(operations);
42
+ if (validationErrors.length > 0) {
43
+ const errorMessage = validationErrors.map((issue) => `[${issue.index}] ${issue.message}`).join('; ');
44
+ throw invalidParamsError(`Invalid operations: ${errorMessage}`);
45
+ }
46
+
47
+ if (dryRun) {
48
+ const preview = buildRequestPreview(operations, advertiserId);
49
+
50
+ return formatSuccess({
51
+ summary: `Dry run: ${operations.length} operation(s) validated locally. `
52
+ + `${preview.requests.length} request(s) would be sent to the TikTok Ads API. No changes applied. `
53
+ + 'TikTok has no server-side validate-only mode — pass dry_run: false to execute.',
54
+ data: preview.requests,
55
+ metadata: {
56
+ dryRun: true,
57
+ serverValidated: false,
58
+ operationCount: operations.length,
59
+ apiCallCount: preview.requests.length,
60
+ advertiserId
61
+ }
62
+ });
63
+ }
64
+
65
+ if (process.env.TIKTOK_ADS_READ_ONLY === 'true') {
66
+ throw new Error('mutate is disabled in read-only mode (TIKTOK_ADS_READ_ONLY=true)');
67
+ }
68
+
69
+ const results = [];
70
+ let succeeded = 0;
71
+ let failed = 0;
72
+
73
+ for (let index = 0; index < operations.length; index += 1) {
74
+ const operation = operations[index];
75
+ const apiRequest = buildApiRequest(operation, advertiserId);
76
+
77
+ try {
78
+ const response = await request(apiRequest.path, {}, {
79
+ method: apiRequest.method,
80
+ body: apiRequest.body
81
+ });
82
+
83
+ results.push({
84
+ index,
85
+ entity: operation.entity,
86
+ action: operation.action,
87
+ success: true,
88
+ id: extractResultId(response, operation),
89
+ response: response?.data ?? response
90
+ });
91
+ succeeded += 1;
92
+ } catch (error) {
93
+ results.push({
94
+ index,
95
+ entity: operation.entity,
96
+ action: operation.action,
97
+ success: false,
98
+ error: {
99
+ message: error.message
100
+ }
101
+ });
102
+ failed += 1;
103
+
104
+ if (!partialFailure) {
105
+ break;
106
+ }
107
+ }
108
+ }
109
+
110
+ if (succeeded === 0 && failed > 0) {
111
+ const firstError = results.find((entry) => !entry.success)?.error?.message || 'Unknown';
112
+ throw new Error(`All ${failed} operation(s) failed. First error: ${firstError}`);
113
+ }
114
+
115
+ return formatSuccess({
116
+ summary: `Executed ${results.length} operation(s): ${succeeded} succeeded, ${failed} failed`,
117
+ data: results,
118
+ metadata: {
119
+ dryRun: false,
120
+ operationCount: results.length,
121
+ succeeded,
122
+ failed,
123
+ advertiserId
124
+ }
125
+ });
126
+ } catch (error) {
127
+ return formatError(error);
128
+ }
129
+ }
@@ -0,0 +1,149 @@
1
+ import { tiktokRequest } from '../http.js';
2
+ import { formatError, formatSuccess } from '../utils/response-format.js';
3
+ import { invalidParamsError } from '../utils/errors.js';
4
+ import { getAdvertiserId, getRequestedLimit, validateEnum } from '../utils/validation.js';
5
+
6
+ /**
7
+ * Default field projections per query entity (TikTok Business API v1.3 field names).
8
+ */
9
+ export const ENTITY_FIELDS = {
10
+ campaigns: [
11
+ 'campaign_id',
12
+ 'campaign_name',
13
+ 'objective_type',
14
+ 'campaign_type',
15
+ 'budget',
16
+ 'budget_mode',
17
+ 'operation_status',
18
+ 'secondary_status',
19
+ 'create_time',
20
+ 'modify_time'
21
+ ],
22
+ adgroups: [
23
+ 'adgroup_id',
24
+ 'adgroup_name',
25
+ 'campaign_id',
26
+ 'operation_status',
27
+ 'secondary_status',
28
+ 'budget',
29
+ 'budget_mode',
30
+ 'optimization_goal',
31
+ 'billing_event',
32
+ 'bid_type',
33
+ 'bid_price',
34
+ 'schedule_type',
35
+ 'schedule_start_time',
36
+ 'schedule_end_time'
37
+ ],
38
+ ads: [
39
+ 'ad_id',
40
+ 'ad_name',
41
+ 'adgroup_id',
42
+ 'campaign_id',
43
+ 'operation_status',
44
+ 'secondary_status',
45
+ 'ad_format',
46
+ 'ad_text',
47
+ 'landing_page_url',
48
+ 'create_time',
49
+ 'modify_time'
50
+ ]
51
+ };
52
+
53
+ export const SUPPORTED_ENTITIES = Object.keys(ENTITY_FIELDS);
54
+
55
+ const ENTITY_PATHS = {
56
+ campaigns: '/campaign/get/',
57
+ adgroups: '/adgroup/get/',
58
+ ads: '/ad/get/'
59
+ };
60
+
61
+ function getRequestedFields(entity, fields) {
62
+ if (fields === undefined || fields === null || fields === '') {
63
+ return ENTITY_FIELDS[entity];
64
+ }
65
+
66
+ const values = Array.isArray(fields) ? fields : String(fields).split(',');
67
+ const cleaned = values.map((field) => String(field).trim()).filter(Boolean);
68
+ return cleaned.length > 0 ? cleaned : ENTITY_FIELDS[entity];
69
+ }
70
+
71
+ function getFiltering(filtering) {
72
+ if (filtering === undefined || filtering === null) {
73
+ return null;
74
+ }
75
+
76
+ if (typeof filtering !== 'object' || Array.isArray(filtering)) {
77
+ throw invalidParamsError('filtering must be an object (e.g. { "primary_status": "STATUS_DELIVERY_OK" })');
78
+ }
79
+
80
+ return filtering;
81
+ }
82
+
83
+ async function fetchAllPages(path, baseParams, requestedLimit, request) {
84
+ const rows = [];
85
+ let page = 1;
86
+
87
+ while (rows.length < requestedLimit) {
88
+ const response = await request(path, {
89
+ ...baseParams,
90
+ page,
91
+ page_size: Math.min(requestedLimit, 1000)
92
+ });
93
+
94
+ const list = Array.isArray(response?.data?.list) ? response.data.list : [];
95
+ rows.push(...list);
96
+
97
+ const totalPage = Number(response?.data?.page_info?.total_page || 0);
98
+ if (list.length === 0 || !totalPage || page >= totalPage) {
99
+ break;
100
+ }
101
+
102
+ page += 1;
103
+ }
104
+
105
+ return rows.slice(0, requestedLimit);
106
+ }
107
+
108
+ /**
109
+ * Query TikTok Ads campaigns, ad groups, or ads with page-based pagination.
110
+ * @param {Record<string, unknown>} [params]
111
+ * @param {{ request?: (path: string, params: Record<string, unknown>, options?: Record<string, unknown>) => Promise<any> }} [dependencies]
112
+ * @returns {Promise<import('@modelcontextprotocol/sdk/types.js').CallToolResult>}
113
+ */
114
+ export async function query(params = {}, dependencies = {}) {
115
+ const request = dependencies.request || tiktokRequest;
116
+
117
+ try {
118
+ validateEnum(params.entity, SUPPORTED_ENTITIES, 'entity');
119
+
120
+ const advertiserId = getAdvertiserId(params);
121
+ const entity = params.entity;
122
+ const requestedLimit = getRequestedLimit(params.limit);
123
+ const filtering = getFiltering(params.filtering);
124
+
125
+ const baseParams = {
126
+ advertiser_id: advertiserId,
127
+ fields: getRequestedFields(entity, params.fields)
128
+ };
129
+
130
+ if (filtering) {
131
+ baseParams.filtering = filtering;
132
+ }
133
+
134
+ const rows = await fetchAllPages(ENTITY_PATHS[entity], baseParams, requestedLimit, request);
135
+
136
+ return formatSuccess({
137
+ summary: `Returned ${rows.length} ${entity} row${rows.length === 1 ? '' : 's'}`,
138
+ data: rows,
139
+ metadata: {
140
+ entity,
141
+ advertiserId,
142
+ limit: requestedLimit,
143
+ returned: rows.length
144
+ }
145
+ });
146
+ } catch (error) {
147
+ return formatError(error);
148
+ }
149
+ }
@@ -0,0 +1,195 @@
1
+ import { tiktokRequest } from '../http.js';
2
+ import { formatError, formatSuccess } from '../utils/response-format.js';
3
+ import { invalidParamsError } from '../utils/errors.js';
4
+ import {
5
+ getAdvertiserId,
6
+ getRequestedLimit,
7
+ validateDate,
8
+ validateEnum
9
+ } from '../utils/validation.js';
10
+
11
+ export const SUPPORTED_REPORT_TYPES = ['BASIC', 'AUDIENCE'];
12
+ export const SUPPORTED_DATA_LEVELS = [
13
+ 'AUCTION_ADVERTISER',
14
+ 'AUCTION_CAMPAIGN',
15
+ 'AUCTION_ADGROUP',
16
+ 'AUCTION_AD'
17
+ ];
18
+
19
+ const LEVEL_ID_DIMENSIONS = {
20
+ AUCTION_ADVERTISER: 'advertiser_id',
21
+ AUCTION_CAMPAIGN: 'campaign_id',
22
+ AUCTION_ADGROUP: 'adgroup_id',
23
+ AUCTION_AD: 'ad_id'
24
+ };
25
+
26
+ /**
27
+ * Default metric selection for integrated reports.
28
+ */
29
+ export const DEFAULT_METRICS = [
30
+ 'spend',
31
+ 'impressions',
32
+ 'clicks',
33
+ 'ctr',
34
+ 'cpc',
35
+ 'cpm',
36
+ 'conversion',
37
+ 'cost_per_conversion',
38
+ 'conversion_rate'
39
+ ];
40
+
41
+ function toIsoDateUtc(date) {
42
+ return date.toISOString().slice(0, 10);
43
+ }
44
+
45
+ function offsetUtcDays(date, days) {
46
+ const copy = new Date(date);
47
+ copy.setUTCDate(copy.getUTCDate() + days);
48
+ return copy;
49
+ }
50
+
51
+ function normalizeStringArray(value) {
52
+ if (value === undefined || value === null || value === '') {
53
+ return [];
54
+ }
55
+
56
+ const values = Array.isArray(value) ? value : String(value).split(',');
57
+ return values.map((entry) => String(entry).trim()).filter(Boolean);
58
+ }
59
+
60
+ // query_lifetime=true cannot be combined with time dimensions or explicit dates.
61
+ function resolveDateWindow(params, lifetime, now) {
62
+ if (lifetime) {
63
+ if (params.start_date || params.end_date) {
64
+ throw invalidParamsError('start_date/end_date cannot be combined with lifetime=true');
65
+ }
66
+ return null;
67
+ }
68
+
69
+ if ((params.start_date && !params.end_date) || (!params.start_date && params.end_date)) {
70
+ throw invalidParamsError('start_date and end_date must be provided together');
71
+ }
72
+
73
+ if (params.start_date && params.end_date) {
74
+ validateDate(params.start_date, 'start_date');
75
+ validateDate(params.end_date, 'end_date');
76
+ return { start_date: String(params.start_date), end_date: String(params.end_date) };
77
+ }
78
+
79
+ return {
80
+ start_date: toIsoDateUtc(offsetUtcDays(now, -6)),
81
+ end_date: toIsoDateUtc(now)
82
+ };
83
+ }
84
+
85
+ function resolveDimensions(params, dataLevel, lifetime) {
86
+ const requested = normalizeStringArray(params.dimensions);
87
+ if (requested.length > 0) {
88
+ return requested;
89
+ }
90
+
91
+ const idDimension = LEVEL_ID_DIMENSIONS[dataLevel];
92
+ return lifetime ? [idDimension] : [idDimension, 'stat_time_day'];
93
+ }
94
+
95
+ function flattenRow(row) {
96
+ return {
97
+ ...(row?.dimensions || {}),
98
+ ...(row?.metrics || {})
99
+ };
100
+ }
101
+
102
+ async function fetchAllPages(baseParams, requestedLimit, request) {
103
+ const rows = [];
104
+ let page = 1;
105
+
106
+ while (rows.length < requestedLimit) {
107
+ const response = await request('/report/integrated/get/', {
108
+ ...baseParams,
109
+ page,
110
+ page_size: Math.min(requestedLimit, 1000)
111
+ });
112
+
113
+ const list = Array.isArray(response?.data?.list) ? response.data.list : [];
114
+ rows.push(...list.map(flattenRow));
115
+
116
+ const totalPage = Number(response?.data?.page_info?.total_page || 0);
117
+ if (list.length === 0 || !totalPage || page >= totalPage) {
118
+ break;
119
+ }
120
+
121
+ page += 1;
122
+ }
123
+
124
+ return rows.slice(0, requestedLimit);
125
+ }
126
+
127
+ /**
128
+ * Run a synchronous TikTok integrated report (GET /report/integrated/get/).
129
+ * @param {Record<string, unknown>} [params]
130
+ * @param {{ request?: (path: string, params: Record<string, unknown>, options?: Record<string, unknown>) => Promise<any> }} [dependencies]
131
+ * @param {Date} [now]
132
+ * @returns {Promise<import('@modelcontextprotocol/sdk/types.js').CallToolResult>}
133
+ */
134
+ export async function report(params = {}, dependencies = {}, now = new Date()) {
135
+ const request = dependencies.request || tiktokRequest;
136
+
137
+ try {
138
+ const advertiserId = getAdvertiserId(params);
139
+ const reportType = params.report_type ? String(params.report_type).toUpperCase() : 'BASIC';
140
+ validateEnum(reportType, SUPPORTED_REPORT_TYPES, 'report_type');
141
+
142
+ const dataLevel = params.data_level ? String(params.data_level).toUpperCase() : 'AUCTION_CAMPAIGN';
143
+ validateEnum(dataLevel, SUPPORTED_DATA_LEVELS, 'data_level');
144
+
145
+ const lifetime = params.lifetime === true || params.lifetime === 'true';
146
+ const requestedLimit = getRequestedLimit(params.limit);
147
+ const dateWindow = resolveDateWindow(params, lifetime, now);
148
+ const dimensions = resolveDimensions(params, dataLevel, lifetime);
149
+ const requestedMetrics = normalizeStringArray(params.metrics);
150
+ const metrics = requestedMetrics.length > 0 ? requestedMetrics : DEFAULT_METRICS;
151
+
152
+ const baseParams = {
153
+ advertiser_id: advertiserId,
154
+ report_type: reportType,
155
+ data_level: dataLevel,
156
+ dimensions,
157
+ metrics
158
+ };
159
+
160
+ if (lifetime) {
161
+ baseParams.query_lifetime = true;
162
+ } else {
163
+ baseParams.start_date = dateWindow.start_date;
164
+ baseParams.end_date = dateWindow.end_date;
165
+ }
166
+
167
+ if (Array.isArray(params.filtering) && params.filtering.length > 0) {
168
+ baseParams.filtering = params.filtering;
169
+ }
170
+
171
+ if (params.order_field) {
172
+ baseParams.order_field = String(params.order_field);
173
+ baseParams.order_type = params.order_type ? String(params.order_type).toUpperCase() : 'DESC';
174
+ }
175
+
176
+ const rows = await fetchAllPages(baseParams, requestedLimit, request);
177
+
178
+ return formatSuccess({
179
+ summary: `Returned ${rows.length} ${reportType} report row${rows.length === 1 ? '' : 's'} at ${dataLevel}`,
180
+ data: rows,
181
+ metadata: {
182
+ advertiserId,
183
+ reportType,
184
+ dataLevel,
185
+ dimensions,
186
+ metrics,
187
+ dateRange: lifetime ? 'lifetime' : dateWindow,
188
+ limit: requestedLimit,
189
+ returned: rows.length
190
+ }
191
+ });
192
+ } catch (error) {
193
+ return formatError(error);
194
+ }
195
+ }
@@ -0,0 +1,12 @@
1
+ function createTypedError(code, message) {
2
+ const error = new Error(message);
3
+ error.mcpCode = code;
4
+ return error;
5
+ }
6
+
7
+ /**
8
+ * Create an InvalidParams typed error.
9
+ */
10
+ export function invalidParamsError(message) {
11
+ return createTypedError('InvalidParams', message);
12
+ }