@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 channel47
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,176 @@
1
+ # @channel47/tiktok-ads-mcp
2
+
3
+ MCP server for TikTok Ads using the TikTok for Business API (`v1.3`).
4
+
5
+ This server exposes four tools expected by channel47 TikTok workflows:
6
+
7
+ - `list_accounts`
8
+ - `query`
9
+ - `report`
10
+ - `mutate`
11
+
12
+ ## Installation
13
+
14
+ ### Standalone
15
+
16
+ ```bash
17
+ npx @channel47/tiktok-ads-mcp@latest
18
+ ```
19
+
20
+ ### Claude Code
21
+
22
+ ```bash
23
+ claude mcp add tiktok-ads --env TIKTOK_ADS_ACCESS_TOKEN=<token> -- npx @channel47/tiktok-ads-mcp@latest
24
+ ```
25
+
26
+ Or as JSON config:
27
+
28
+ ```json
29
+ {
30
+ "mcpServers": {
31
+ "tiktok-ads": {
32
+ "command": "npx",
33
+ "args": ["@channel47/tiktok-ads-mcp@latest"],
34
+ "env": {
35
+ "TIKTOK_ADS_ACCESS_TOKEN": "<token>",
36
+ "TIKTOK_ADS_ADVERTISER_ID": "<advertiser id>"
37
+ }
38
+ }
39
+ }
40
+ }
41
+ ```
42
+
43
+ ### Monorepo Development
44
+
45
+ ```bash
46
+ cd mcps
47
+ npm install
48
+ npm run test
49
+ ```
50
+
51
+ ## Getting Credentials
52
+
53
+ 1. Create a developer app at [TikTok for Business Developers](https://business-api.tiktok.com/portal) (Marketing API).
54
+ 2. Authorize the app against your advertiser account(s) via the app's authorization URL.
55
+ 3. Exchange the returned `auth_code` for a long-lived access token at `POST /open_api/v1.3/oauth2/access_token/`.
56
+ 4. Use that token as `TIKTOK_ADS_ACCESS_TOKEN`. The token is sent as the `Access-Token` request header (not `Authorization: Bearer`).
57
+
58
+ ## Configuration
59
+
60
+ ### Required
61
+
62
+ | Variable | Description |
63
+ |----------|-------------|
64
+ | `TIKTOK_ADS_ACCESS_TOKEN` | TikTok Business API long-lived access token |
65
+
66
+ ### Optional
67
+
68
+ | Variable | Description |
69
+ |----------|-------------|
70
+ | `TIKTOK_ADS_ADVERTISER_ID` | Default advertiser ID used when `advertiser_id` is omitted |
71
+ | `TIKTOK_ADS_APP_ID` | Developer app ID — only needed so `list_accounts` can discover authorized advertisers via `/oauth2/advertiser/get/` |
72
+ | `TIKTOK_ADS_APP_SECRET` | Developer app secret (pairs with `TIKTOK_ADS_APP_ID`) |
73
+ | `TIKTOK_ADS_READ_ONLY` | Set to `true` to disable live mutations |
74
+ | `TIKTOK_ADS_REQUEST_TIMEOUT_MS` | HTTP request timeout in milliseconds (default `30000`) |
75
+
76
+ ## Tool Reference
77
+
78
+ ### `list_accounts`
79
+
80
+ List accessible TikTok ad accounts (advertisers).
81
+
82
+ **Params:**
83
+ - `advertiser_ids` (optional): explicit advertiser ids to look up
84
+
85
+ **Notes:**
86
+ - With `TIKTOK_ADS_APP_ID` + `TIKTOK_ADS_APP_SECRET` set, ids are discovered via `GET /oauth2/advertiser/get/`
87
+ - Without app credentials, pass `advertiser_ids` or set `TIKTOK_ADS_ADVERTISER_ID`
88
+ - Details (name, status, currency, timezone, company, country) come from `GET /advertiser/info/` (batched 100 ids per request)
89
+
90
+ ### `query`
91
+
92
+ Structured query wrapper for TikTok Business API entities:
93
+
94
+ - `campaigns` — `GET /campaign/get/`
95
+ - `adgroups` — `GET /adgroup/get/`
96
+ - `ads` — `GET /ad/get/`
97
+
98
+ **Params:**
99
+ - `entity` (required)
100
+ - `advertiser_id` (optional if `TIKTOK_ADS_ADVERTISER_ID` exists)
101
+ - `fields`: array or comma-separated string (entity-specific defaults)
102
+ - `filtering`: TikTok filtering object, e.g. `{ "campaign_ids": ["123"], "primary_status": "STATUS_DELIVERY_OK" }`
103
+ - `limit`: max rows (default `100`, max `1000`); pagination via `page`/`page_size` is handled automatically
104
+
105
+ ### `report`
106
+
107
+ Synchronous integrated reporting via `GET /report/integrated/get/`.
108
+
109
+ **Params:**
110
+ - `advertiser_id` (optional if `TIKTOK_ADS_ADVERTISER_ID` exists)
111
+ - `report_type`: `BASIC` (default) or `AUDIENCE`
112
+ - `data_level`: `AUCTION_ADVERTISER`, `AUCTION_CAMPAIGN` (default), `AUCTION_ADGROUP`, `AUCTION_AD`
113
+ - `dimensions`: defaults to the data_level id dimension plus `stat_time_day`
114
+ - `metrics`: defaults to `spend, impressions, clicks, ctr, cpc, cpm, conversion, cost_per_conversion, conversion_rate`
115
+ - `start_date` / `end_date` (`YYYY-MM-DD`, default trailing 7 days UTC) or `lifetime: true`
116
+ - `filtering`: array of `{ field_name, filter_type, filter_value }` clauses
117
+ - `order_field` / `order_type` (`ASC` / `DESC`)
118
+ - `limit`: max rows (default `100`, max `1000`)
119
+
120
+ Rows are returned with `dimensions` and `metrics` flattened into a single object per row.
121
+
122
+ ### `mutate`
123
+
124
+ Mutation tool with dry-run safety by default.
125
+
126
+ **Operation format:**
127
+
128
+ ```json
129
+ {
130
+ "entity": "campaign",
131
+ "action": "update",
132
+ "id": "1781234567890",
133
+ "params": {
134
+ "budget": 500
135
+ }
136
+ }
137
+ ```
138
+
139
+ **Supported entities:** `campaign`, `adgroup`, `ad`
140
+
141
+ **Supported actions:**
142
+ - `create` — `POST /<entity>/create/`; campaign and adgroup creates default `operation_status` to `DISABLE` (paused) — pass explicit `operation_status` to override
143
+ - `update` — `POST /<entity>/update/`; `id` maps to `campaign_id`/`adgroup_id`. Ad updates take the full `/ad/update/` body in `params` (ads are identified via `creatives[].ad_id`), so `id` is optional
144
+ - `pause` / `enable` / `delete` — `POST /<entity>/status/update/` with `operation_status` `DISABLE` / `ENABLE` / `DELETE`
145
+
146
+ **Top-level params:**
147
+ - `operations` (required)
148
+ - `advertiser_id` (optional if `TIKTOK_ADS_ADVERTISER_ID` exists)
149
+ - `dry_run` (default `true`)
150
+ - `partial_failure` (default `true`)
151
+
152
+ **Safety notes:**
153
+ - TikTok has **no server-side validate-only mode**. `dry_run: true` validates operations locally and returns a preview of the exact requests (method, path, body) without calling the API.
154
+ - `delete` is permanent and unrecoverable — prefer `pause`.
155
+ - Set `TIKTOK_ADS_READ_ONLY=true` to remove the mutate tool entirely.
156
+
157
+ ## Behavior Notes
158
+
159
+ - Auth is sent via the `Access-Token` request header
160
+ - TikTok returns HTTP `200` with an envelope `{ code, message, request_id, data }`; any non-zero `code` is surfaced as an error including the API code and message
161
+ - Complex GET params (`fields`, `filtering`, `dimensions`, `metrics`, `advertiser_ids`) are JSON-encoded into the query string automatically
162
+ - API retries once on HTTP `429` or envelope error code `40100` (rate limit), honoring `Retry-After` when present (fallback `60s`)
163
+ - Requests are aborted on timeout (default `30000ms`, configurable via `TIKTOK_ADS_REQUEST_TIMEOUT_MS`)
164
+ - List endpoints paginate with `page`/`page_size` (`data.page_info.total_page`)
165
+
166
+ ## Development Commands
167
+
168
+ ```bash
169
+ cd tiktok-ads
170
+ npm test
171
+ node server/index.js
172
+ ```
173
+
174
+ ## License
175
+
176
+ MIT
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@channel47/tiktok-ads-mcp",
3
+ "version": "0.1.0",
4
+ "description": "TikTok Ads MCP Server - Query and mutate TikTok for Business ads data via the Business API",
5
+ "main": "server/index.js",
6
+ "bin": {
7
+ "tiktok-ads-mcp": "server/index.js"
8
+ },
9
+ "type": "module",
10
+ "files": [
11
+ "server/**/*.js",
12
+ "server/**/*.md",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "scripts": {
17
+ "start": "node server/index.js",
18
+ "test": "node --test *.test.js test/*.test.js",
19
+ "prepublishOnly": "npm test"
20
+ },
21
+ "keywords": [
22
+ "mcp",
23
+ "model-context-protocol",
24
+ "tiktok-ads",
25
+ "tiktok-for-business",
26
+ "analytics",
27
+ "advertising",
28
+ "claude"
29
+ ],
30
+ "author": "channel47",
31
+ "license": "MIT",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/channel47/mcps.git",
35
+ "directory": "tiktok-ads"
36
+ },
37
+ "bugs": {
38
+ "url": "https://github.com/channel47/mcps/issues"
39
+ },
40
+ "homepage": "https://github.com/channel47/mcps/tree/main/tiktok-ads#readme",
41
+ "dependencies": {
42
+ "@modelcontextprotocol/sdk": "^1.29.0"
43
+ },
44
+ "engines": {
45
+ "node": ">=18.0.0"
46
+ }
47
+ }
package/server/auth.js ADDED
@@ -0,0 +1,59 @@
1
+ const REQUIRED_ENV_VARS = ['TIKTOK_ADS_ACCESS_TOKEN'];
2
+
3
+ let cachedAccessToken = null;
4
+
5
+ /**
6
+ * Validate required environment variables for TikTok Ads auth.
7
+ * @returns {{ valid: boolean, missing: string[] }}
8
+ */
9
+ export function validateEnvironment() {
10
+ const missing = REQUIRED_ENV_VARS.filter((name) => !process.env[name]);
11
+ return {
12
+ valid: missing.length === 0,
13
+ missing
14
+ };
15
+ }
16
+
17
+ /**
18
+ * Return the configured TikTok Ads access token with process-level caching.
19
+ * @returns {Promise<string>}
20
+ */
21
+ export async function getAccessToken() {
22
+ if (cachedAccessToken) {
23
+ return cachedAccessToken;
24
+ }
25
+
26
+ const { valid, missing } = validateEnvironment();
27
+ if (!valid) {
28
+ throw new Error(`Missing required environment variables: ${missing.join(', ')}`);
29
+ }
30
+
31
+ cachedAccessToken = String(process.env.TIKTOK_ADS_ACCESS_TOKEN);
32
+ return cachedAccessToken;
33
+ }
34
+
35
+ /**
36
+ * Return optional developer app credentials used for advertiser discovery
37
+ * via GET /oauth2/advertiser/get/, or null when not configured.
38
+ * @returns {{ appId: string, appSecret: string } | null}
39
+ */
40
+ export function getAppCredentials() {
41
+ const appId = process.env.TIKTOK_ADS_APP_ID;
42
+ const appSecret = process.env.TIKTOK_ADS_APP_SECRET;
43
+
44
+ if (!appId || !appSecret) {
45
+ return null;
46
+ }
47
+
48
+ return {
49
+ appId: String(appId),
50
+ appSecret: String(appSecret)
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Clear the in-memory token cache (used by tests and local dev flows).
56
+ */
57
+ export function clearAuthCache() {
58
+ cachedAccessToken = null;
59
+ }
package/server/http.js ADDED
@@ -0,0 +1,201 @@
1
+ import { getAccessToken } from './auth.js';
2
+ import { invalidParamsError } from './utils/errors.js';
3
+
4
+ /**
5
+ * Base URL for all TikTok for Business API requests used by this server.
6
+ */
7
+ export const TIKTOK_BASE_URL = 'https://business-api.tiktok.com/open_api/v1.3';
8
+ const RATE_LIMIT_ERROR_CODES = new Set([40100]);
9
+ const DEFAULT_TIMEOUT_MS = 30_000;
10
+ const DEFAULT_RATE_LIMIT_SLEEP_MS = 60_000;
11
+
12
+ function sleep(ms) {
13
+ return new Promise((resolve) => {
14
+ setTimeout(resolve, ms);
15
+ });
16
+ }
17
+
18
+ function normalizePath(path) {
19
+ if (!path) {
20
+ throw new Error('API path is required');
21
+ }
22
+
23
+ return path.startsWith('/') ? path : `/${path}`;
24
+ }
25
+
26
+ // TikTok GET endpoints expect complex params (arrays/objects such as
27
+ // filtering, fields, dimensions, metrics) as JSON-encoded strings.
28
+ function serializeParamValue(value) {
29
+ if (Array.isArray(value) || (value && typeof value === 'object')) {
30
+ return JSON.stringify(value);
31
+ }
32
+
33
+ return String(value);
34
+ }
35
+
36
+ function buildUrl(path, params = {}) {
37
+ const url = new URL(`${TIKTOK_BASE_URL}${normalizePath(path)}`);
38
+
39
+ for (const [key, value] of Object.entries(params)) {
40
+ if (value === undefined || value === null || value === '') {
41
+ continue;
42
+ }
43
+ url.searchParams.set(key, serializeParamValue(value));
44
+ }
45
+
46
+ return url;
47
+ }
48
+
49
+ async function parseResponse(response) {
50
+ const contentType = response.headers.get('content-type') || '';
51
+
52
+ if (contentType.includes('application/json')) {
53
+ return response.json();
54
+ }
55
+
56
+ const text = await response.text();
57
+ try {
58
+ return JSON.parse(text);
59
+ } catch {
60
+ return text;
61
+ }
62
+ }
63
+
64
+ function extractEnvelopeCode(payload) {
65
+ const code = Number(payload?.code);
66
+ return Number.isFinite(code) ? code : 0;
67
+ }
68
+
69
+ function extractErrorMessage(payload) {
70
+ if (payload?.message) {
71
+ return payload.message;
72
+ }
73
+
74
+ if (typeof payload === 'string' && payload) {
75
+ return payload;
76
+ }
77
+
78
+ return 'Unknown TikTok Ads API error';
79
+ }
80
+
81
+ function resolveTimeoutMs(timeoutMs) {
82
+ const candidate = timeoutMs ?? process.env.TIKTOK_ADS_REQUEST_TIMEOUT_MS ?? DEFAULT_TIMEOUT_MS;
83
+ const parsed = Number(candidate);
84
+ if (!Number.isFinite(parsed) || parsed <= 0) {
85
+ return DEFAULT_TIMEOUT_MS;
86
+ }
87
+ return Math.floor(parsed);
88
+ }
89
+
90
+ function getRateLimitSleepMs(response) {
91
+ const retryAfter = response?.headers?.get('retry-after');
92
+ if (!retryAfter) {
93
+ return DEFAULT_RATE_LIMIT_SLEEP_MS;
94
+ }
95
+
96
+ const seconds = Number(retryAfter);
97
+ if (Number.isFinite(seconds) && seconds > 0) {
98
+ return Math.floor(seconds * 1000);
99
+ }
100
+
101
+ const retryAtMs = Date.parse(retryAfter);
102
+ if (Number.isFinite(retryAtMs)) {
103
+ const delta = retryAtMs - Date.now();
104
+ if (delta > 0) {
105
+ return Math.floor(delta);
106
+ }
107
+ }
108
+
109
+ return DEFAULT_RATE_LIMIT_SLEEP_MS;
110
+ }
111
+
112
+ /**
113
+ * Execute an authenticated TikTok for Business API request with timeout and retry handling.
114
+ *
115
+ * TikTok returns HTTP 200 with an envelope `{ code, message, request_id, data }`;
116
+ * any non-zero `code` is treated as an error. The full envelope is returned on
117
+ * success so callers can read `data.list` and `data.page_info`.
118
+ * @param {string} path
119
+ * @param {Record<string, unknown>} [params]
120
+ * @param {{
121
+ * method?: string,
122
+ * body?: unknown,
123
+ * timeoutMs?: number,
124
+ * retryThrottled?: boolean,
125
+ * sleep?: (ms: number) => Promise<void>
126
+ * }} [options]
127
+ * @returns {Promise<unknown>}
128
+ */
129
+ export async function tiktokRequest(
130
+ path,
131
+ params = {},
132
+ {
133
+ method = 'GET',
134
+ body,
135
+ timeoutMs,
136
+ retryThrottled = true,
137
+ sleep: sleepFn = sleep
138
+ } = {}
139
+ ) {
140
+ const token = await getAccessToken();
141
+ const url = buildUrl(path, params);
142
+ const resolvedTimeoutMs = resolveTimeoutMs(timeoutMs);
143
+ const controller = new AbortController();
144
+ const timeoutId = setTimeout(() => {
145
+ controller.abort();
146
+ }, resolvedTimeoutMs);
147
+
148
+ const requestOptions = {
149
+ method,
150
+ headers: {
151
+ 'Content-Type': 'application/json',
152
+ 'Access-Token': token
153
+ },
154
+ signal: controller.signal
155
+ };
156
+
157
+ if (body !== undefined && body !== null) {
158
+ requestOptions.body = typeof body === 'string' ? body : JSON.stringify(body);
159
+ }
160
+
161
+ let response;
162
+ try {
163
+ response = await fetch(url, requestOptions);
164
+ } catch (error) {
165
+ if (error?.name === 'AbortError') {
166
+ throw new Error(`TikTok Ads API request timed out after ${resolvedTimeoutMs}ms`);
167
+ }
168
+ throw error;
169
+ } finally {
170
+ clearTimeout(timeoutId);
171
+ }
172
+
173
+ const payload = await parseResponse(response);
174
+ const envelopeCode = extractEnvelopeCode(payload);
175
+
176
+ if (response.ok && envelopeCode === 0) {
177
+ return payload;
178
+ }
179
+
180
+ if (response.status === 401) {
181
+ const message = extractErrorMessage(payload);
182
+ throw invalidParamsError(`TikTok Ads API request failed (${response.status}): ${message}`);
183
+ }
184
+
185
+ const isRateLimited = response.status === 429 || RATE_LIMIT_ERROR_CODES.has(envelopeCode);
186
+ if (isRateLimited && retryThrottled) {
187
+ await sleepFn(getRateLimitSleepMs(response));
188
+
189
+ return tiktokRequest(path, params, {
190
+ method,
191
+ body,
192
+ timeoutMs: resolvedTimeoutMs,
193
+ retryThrottled: false,
194
+ sleep: sleepFn
195
+ });
196
+ }
197
+
198
+ const message = extractErrorMessage(payload);
199
+ const codeSuffix = envelopeCode !== 0 ? ` (API error code ${envelopeCode})` : '';
200
+ throw new Error(`TikTok Ads API request failed (${response.status}): ${message}${codeSuffix}`);
201
+ }