@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.
- package/LICENSE +21 -0
- package/README.md +176 -0
- package/package.json +47 -0
- package/server/auth.js +59 -0
- package/server/http.js +201 -0
- package/server/index.js +247 -0
- package/server/prompts/templates.js +127 -0
- package/server/resources/business-api-reference.md +72 -0
- package/server/resources/index.js +65 -0
- package/server/resources/rate-limits-and-quotas.md +39 -0
- package/server/resources/reporting-reference.md +60 -0
- package/server/tools/list-accounts.js +109 -0
- package/server/tools/mutate.js +129 -0
- package/server/tools/query.js +149 -0
- package/server/tools/report.js +195 -0
- package/server/utils/errors.js +12 -0
- package/server/utils/mutate-operations.js +168 -0
- package/server/utils/response-format.js +63 -0
- package/server/utils/validation.js +70 -0
|
@@ -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
|
+
}
|