@ppcassist/amazon-ads-mcp 1.0.6 → 1.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.
Files changed (5) hide show
  1. package/README.md +92 -18
  2. package/index.js +17 -3
  3. package/lib/proxy.js +52 -13
  4. package/package.json +2 -2
  5. package/setup.js +206 -130
package/README.md CHANGED
@@ -1,28 +1,80 @@
1
1
  # @ppcassist/amazon-ads-mcp
2
2
 
3
- Local MCP proxy for the Amazon Ads API. Sits between Claude Desktop and Amazon's official Advertising MCP server, handling OAuth authentication and token refresh automatically.
3
+ Local MCP proxy for the Amazon Ads API. Sits between Claude Desktop and Amazon's official Advertising MCP server, handling OAuth authentication and access-token refresh automatically.
4
4
 
5
5
  ## How it works
6
6
 
7
7
  1. On startup, exchanges your refresh token for an access token via Amazon OAuth
8
- 2. Auto-refreshes the token every 50 minutes
8
+ 2. Auto-refreshes the token every 50 minutes (plus an on-the-fly retry if Amazon returns 401)
9
9
  3. Proxies all MCP requests from Claude Desktop to the Amazon Ads MCP server
10
- 4. Injects required authentication headers on every request
10
+ 4. Injects the required authentication headers on every request
11
11
  5. Streams SSE responses back to Claude without buffering
12
12
 
13
- ## Prerequisites
13
+ ## Quick start
14
14
 
15
- You need Amazon Ads API credentials:
15
+ ```
16
+ npx @ppcassist/amazon-ads-mcp setup
17
+ ```
18
+
19
+ The setup command handles the OAuth flow end-to-end, asks whether you want **flexible mode** (operate across all your marketplaces) or **pinned mode** (lock to one marketplace), fetches your advertiser accounts, and writes the Claude Desktop config for you.
20
+
21
+ Restart Claude Desktop after setup completes.
22
+
23
+ ## Operating modes
24
+
25
+ The proxy supports the two account-context modes defined by Amazon's MCP server.
26
+
27
+ ### Flexible mode (default)
28
+
29
+ No scope is pinned in the config. Claude picks the account and marketplace at runtime, per question. Best if you manage multiple marketplaces or multiple accounts.
30
+
31
+ Config written by `setup`:
32
+
33
+ ```json
34
+ {
35
+ "CLIENT_ID": "amzn1.application-oa2-client...",
36
+ "CLIENT_SECRET": "...",
37
+ "REFRESH_TOKEN": "Atzr|...",
38
+ "REGION": "EU"
39
+ }
40
+ ```
41
+
42
+ ### Pinned mode
43
+
44
+ One marketplace profile is locked in. Every tool call is scoped to that profile. Simpler mental model if you only manage one marketplace.
45
+
46
+ Config written by `setup`:
16
47
 
17
- - **CLIENT_ID** - Your Amazon Ads API client ID (Login with Amazon)
18
- - **CLIENT_SECRET** - Your Amazon Ads API client secret
19
- - **REFRESH_TOKEN** - OAuth refresh token for your Amazon Ads account
20
- - **PROFILE_ID** - Your Amazon Advertising profile ID
21
- - **REGION** - One of `EU`, `NA`, or `FE`
48
+ ```json
49
+ {
50
+ "CLIENT_ID": "amzn1.application-oa2-client...",
51
+ "CLIENT_SECRET": "...",
52
+ "REFRESH_TOKEN": "Atzr|...",
53
+ "PROFILE_ID": "1234567890",
54
+ "ACCOUNT_ID": "amzn1.ads-account.g.xxxxxxxxx",
55
+ "REGION": "EU"
56
+ }
57
+ ```
58
+
59
+ ## Environment variables
22
60
 
23
- ## Setup with Claude Desktop
61
+ | Variable | Required | Description |
62
+ |----------|----------|-------------|
63
+ | `CLIENT_ID` | yes | Your Amazon LWA application client id |
64
+ | `CLIENT_SECRET` | yes | Your Amazon LWA application client secret |
65
+ | `REFRESH_TOKEN` | yes | OAuth refresh token for an Amazon Ads user |
66
+ | `REGION` | yes | `NA`, `EU`, or `FE` |
67
+ | `PROFILE_ID` | optional | Amazon Advertising profile id — sent as `Amazon-Advertising-API-Scope` |
68
+ | `ACCOUNT_ID` | optional | Global advertiser account id — sent as `Amazon-Ads-AccountID` (needed for the reporting tools) |
69
+ | `MANAGER_ACCOUNT_ID` | optional | Sent as `Amazon-Ads-Manager-AccountID` |
24
70
 
25
- Add to your Claude Desktop configuration (`claude_desktop_config.json`):
71
+ If **any** of `PROFILE_ID` / `ACCOUNT_ID` / `MANAGER_ACCOUNT_ID` is set, the proxy operates in **fixed account context** (sending `Amazon-Ads-AI-Account-Selection-Mode: FIXED`). If none are set, the proxy operates in **dynamic account context**, and Claude passes account identifiers per tool call.
72
+
73
+ > **Note**: the Amazon reporting tools require `Amazon-Ads-AccountID` to be present. In pinned mode, `setup` writes both `PROFILE_ID` and `ACCOUNT_ID` for you so both campaign tools and reporting tools work out of the box.
74
+
75
+ ## Manual configuration
76
+
77
+ If you prefer to edit your Claude Desktop config by hand:
26
78
 
27
79
  ```json
28
80
  {
@@ -34,7 +86,6 @@ Add to your Claude Desktop configuration (`claude_desktop_config.json`):
34
86
  "CLIENT_ID": "amzn1.application-oa2-client.xxxxx",
35
87
  "CLIENT_SECRET": "your-client-secret",
36
88
  "REFRESH_TOKEN": "Atzr|your-refresh-token",
37
- "PROFILE_ID": "your-profile-id",
38
89
  "REGION": "EU"
39
90
  }
40
91
  }
@@ -42,26 +93,49 @@ Add to your Claude Desktop configuration (`claude_desktop_config.json`):
42
93
  }
43
94
  ```
44
95
 
96
+ Add `PROFILE_ID` / `ACCOUNT_ID` / `MANAGER_ACCOUNT_ID` to `env` to pin a scope.
97
+
45
98
  ## Supported regions
46
99
 
47
- | Region | Endpoint |
48
- |--------|----------|
100
+ | Region | Amazon Ads MCP endpoint |
101
+ |--------|-------------------------|
49
102
  | NA | `https://advertising-ai.amazon.com/mcp` |
50
103
  | EU | `https://advertising-ai-eu.amazon.com/mcp` |
51
104
  | FE | `https://advertising-ai-fe.amazon.com/mcp` |
52
105
 
106
+ Amazon's endpoints are region-scoped — use the one that matches your marketplaces.
107
+
53
108
  ## How it proxies
54
109
 
55
- All MCP JSON-RPC messages (`initialize`, `tools/list`, `tools/call`, etc.) are forwarded to Amazon's MCP server with these headers injected:
110
+ All MCP JSON-RPC messages (`initialize`, `tools/list`, `tools/call`, ) are forwarded to Amazon's MCP server with these headers injected:
56
111
 
57
112
  - `Authorization: Bearer <access_token>`
58
113
  - `Amazon-Ads-ClientId: <CLIENT_ID>`
59
- - `Amazon-Advertising-API-Scope: <PROFILE_ID>`
60
- - `Amazon-Ads-AI-Account-Selection-Mode: FIXED`
61
114
  - `Accept: application/json, text/event-stream`
115
+ - `Amazon-Advertising-API-Scope: <PROFILE_ID>` *(if set)*
116
+ - `Amazon-Ads-AccountID: <ACCOUNT_ID>` *(if set)*
117
+ - `Amazon-Ads-Manager-AccountID: <MANAGER_ACCOUNT_ID>` *(if set)*
118
+ - `Amazon-Ads-AI-Account-Selection-Mode: FIXED` *(if any scope header is set)*
62
119
 
63
120
  Responses (including SSE streams) are forwarded back to Claude as-is.
64
121
 
122
+ ## Troubleshooting
123
+
124
+ **"The provided account identifier header is not supported for this tool"**
125
+ The tool you called requires a different scope header than what you have configured. The most common case is calling a reporting tool with only `PROFILE_ID` set. Add `ACCOUNT_ID` to your config, or re-run `setup` and pick pinned mode (which writes both).
126
+
127
+ **"400 Bad Request — redirect URI not whitelisted"**
128
+ During `setup`, Amazon rejected the OAuth callback URL. In your LWA Security Profile → Web Settings → Allowed Return URLs, add:
129
+
130
+ ```
131
+ http://localhost:8080/callback
132
+ http://localhost:8081/callback
133
+ http://localhost:8082/callback
134
+ ```
135
+
136
+ **Token refresh failed after the 50-minute timer**
137
+ The proxy will retry once on a 401 before giving up. If it persists, check that your refresh token hasn't been revoked and that your LWA app still has the `advertising::campaign_management` scope granted.
138
+
65
139
  ## License
66
140
 
67
141
  MIT
package/index.js CHANGED
@@ -24,9 +24,15 @@ if (process.argv[2] === "setup") {
24
24
  const clientId = requireEnv("CLIENT_ID");
25
25
  const clientSecret = requireEnv("CLIENT_SECRET");
26
26
  const refreshToken = requireEnv("REFRESH_TOKEN");
27
- const profileId = requireEnv("PROFILE_ID");
28
27
  const region = requireEnv("REGION");
29
28
 
29
+ // All scope identifiers are optional. Any combination is valid.
30
+ // - None set -> Dynamic Account Context mode (Claude picks per call)
31
+ // - One+ set -> Fixed Account Context mode (headers pin the scope)
32
+ const profileId = process.env.PROFILE_ID || null;
33
+ const accountId = process.env.ACCOUNT_ID || null;
34
+ const managerAccountId = process.env.MANAGER_ACCOUNT_ID || null;
35
+
30
36
  const tokenManager = new TokenManager({ clientId, clientSecret, refreshToken });
31
37
  try {
32
38
  await tokenManager.init();
@@ -35,9 +41,17 @@ if (process.argv[2] === "setup") {
35
41
  process.exit(1);
36
42
  }
37
43
 
38
- const proxy = new McpProxy({ tokenManager, clientId, profileId, region });
44
+ const proxy = new McpProxy({
45
+ tokenManager,
46
+ clientId,
47
+ region,
48
+ profileId,
49
+ accountId,
50
+ managerAccountId,
51
+ });
39
52
 
40
- log(`Started (region=${region})`);
53
+ const mode = proxy.hasFixedScope() ? "FIXED" : "DYNAMIC";
54
+ log(`Started (region=${region}, mode=${mode})`);
41
55
 
42
56
  let buffer = "";
43
57
 
package/lib/proxy.js CHANGED
@@ -6,11 +6,19 @@ const ENDPOINTS = {
6
6
  FE: "https://advertising-ai-fe.amazon.com/mcp",
7
7
  };
8
8
 
9
+ const REQUEST_TIMEOUT_MS = 30 * 1000;
10
+
9
11
  class McpProxy {
10
- constructor({ tokenManager, clientId, profileId, region }) {
12
+ constructor({ tokenManager, clientId, region, profileId, accountId, managerAccountId }) {
11
13
  this.tokenManager = tokenManager;
12
14
  this.clientId = clientId;
13
- this.profileId = profileId;
15
+ // All three scope identifiers are optional. Any combination is valid.
16
+ // If NONE are set, the proxy runs in Amazon's Dynamic Account Context mode
17
+ // (the MCP server expects account identifiers to be passed in the request
18
+ // body by the LLM per tool call, and the FIXED header is omitted).
19
+ this.profileId = profileId || null;
20
+ this.accountId = accountId || null;
21
+ this.managerAccountId = managerAccountId || null;
14
22
 
15
23
  const endpoint = ENDPOINTS[region.toUpperCase()];
16
24
  if (!endpoint) {
@@ -19,6 +27,10 @@ class McpProxy {
19
27
  this.endpoint = new URL(endpoint);
20
28
  }
21
29
 
30
+ hasFixedScope() {
31
+ return Boolean(this.profileId || this.accountId || this.managerAccountId);
32
+ }
33
+
22
34
  async forward(jsonRpcRequest, _isRetry) {
23
35
  const result = await this._doRequest(jsonRpcRequest);
24
36
 
@@ -32,23 +44,47 @@ class McpProxy {
32
44
  return result;
33
45
  }
34
46
 
47
+ _buildHeaders(bodyLength) {
48
+ const token = this.tokenManager.getToken();
49
+
50
+ const headers = {
51
+ "Content-Type": "application/json",
52
+ "Content-Length": bodyLength,
53
+ Authorization: `Bearer ${token}`,
54
+ "Amazon-Ads-ClientId": this.clientId,
55
+ Accept: "application/json, text/event-stream",
56
+ };
57
+
58
+ // Inject only scope headers that are actually configured.
59
+ // Amazon's doc: "not all three account identifier headers need to be
60
+ // included, but at least one is required" when FIXED mode is used.
61
+ if (this.profileId) {
62
+ headers["Amazon-Advertising-API-Scope"] = this.profileId;
63
+ }
64
+ if (this.accountId) {
65
+ headers["Amazon-Ads-AccountID"] = this.accountId;
66
+ }
67
+ if (this.managerAccountId) {
68
+ headers["Amazon-Ads-Manager-AccountID"] = this.managerAccountId;
69
+ }
70
+ // FIXED mode is signaled only when at least one scope identifier is set.
71
+ // Omitting this header puts the server in Dynamic Account Context mode,
72
+ // where the LLM is expected to pass account identifiers in each tool call.
73
+ if (this.hasFixedScope()) {
74
+ headers["Amazon-Ads-AI-Account-Selection-Mode"] = "FIXED";
75
+ }
76
+
77
+ return headers;
78
+ }
79
+
35
80
  _doRequest(jsonRpcRequest) {
36
81
  const body = JSON.stringify(jsonRpcRequest);
37
- const token = this.tokenManager.getToken();
38
82
 
39
83
  const options = {
40
84
  hostname: this.endpoint.hostname,
41
85
  path: this.endpoint.pathname,
42
86
  method: "POST",
43
- headers: {
44
- "Content-Type": "application/json",
45
- "Content-Length": Buffer.byteLength(body),
46
- Authorization: `Bearer ${token}`,
47
- "Amazon-Ads-ClientId": this.clientId,
48
- "Amazon-Advertising-API-Scope": this.profileId,
49
- "Amazon-Ads-AI-Account-Selection-Mode": "FIXED",
50
- Accept: "application/json, text/event-stream",
51
- },
87
+ headers: this._buildHeaders(Buffer.byteLength(body)),
52
88
  };
53
89
 
54
90
  return new Promise((resolve, reject) => {
@@ -57,7 +93,7 @@ class McpProxy {
57
93
  const isSSE = contentType.includes("text/event-stream");
58
94
 
59
95
  if (isSSE) {
60
- resolve({ type: "stream", stream: res });
96
+ resolve({ type: "stream", stream: res, statusCode: res.statusCode });
61
97
  } else {
62
98
  let chunks = [];
63
99
  res.on("data", (chunk) => chunks.push(chunk));
@@ -72,6 +108,9 @@ class McpProxy {
72
108
  }
73
109
  });
74
110
 
111
+ req.setTimeout(REQUEST_TIMEOUT_MS, () => {
112
+ req.destroy(new Error(`Upstream request timed out after ${REQUEST_TIMEOUT_MS}ms`));
113
+ });
75
114
  req.on("error", reject);
76
115
  req.write(body);
77
116
  req.end();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ppcassist/amazon-ads-mcp",
3
- "version": "1.0.6",
4
- "description": "Local MCP proxy for Amazon Ads API - authenticates and forwards requests to Amazon's official MCP server",
3
+ "version": "1.1.0",
4
+ "description": "Local MCP proxy for Amazon Ads API handles OAuth refresh, supports flexible (dynamic) and pinned (fixed) account modes",
5
5
  "main": "index.js",
6
6
  "bin": {
7
7
  "amazon-ads-mcp": "./index.js"
package/setup.js CHANGED
@@ -11,7 +11,14 @@ const CALLBACK_PATH = "/callback";
11
11
 
12
12
  const OAUTH_AUTHORIZE_URL = "https://www.amazon.com/ap/oa";
13
13
  const OAUTH_TOKEN_URL = "https://api.amazon.com/auth/o2/token";
14
- const PROFILES_URL = "https://advertising-api-eu.amazon.com/v2/profiles";
14
+
15
+ // Per-region endpoints for Amazon Ads API. The setup probes all three to
16
+ // discover which region(s) this LWA app has access to.
17
+ const REGION_HOSTS = {
18
+ NA: "advertising-api.amazon.com",
19
+ EU: "advertising-api-eu.amazon.com",
20
+ FE: "advertising-api-fe.amazon.com",
21
+ };
15
22
 
16
23
  const TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes
17
24
 
@@ -88,61 +95,29 @@ function openBrowser(url) {
88
95
  });
89
96
  }
90
97
 
91
- function httpPost(urlStr, body, headers) {
98
+ function httpRequest({ hostname, path: reqPath, method, headers, body }) {
92
99
  return new Promise((resolve, reject) => {
93
- const url = new URL(urlStr);
94
100
  const options = {
95
- hostname: url.hostname,
96
- path: url.pathname,
97
- method: "POST",
101
+ hostname,
102
+ path: reqPath,
103
+ method,
98
104
  headers: {
99
- "Content-Type": "application/x-www-form-urlencoded",
100
- "Content-Length": Buffer.byteLength(body),
105
+ ...(body ? { "Content-Length": Buffer.byteLength(body) } : {}),
101
106
  ...headers,
102
107
  },
103
108
  };
104
-
105
109
  const req = https.request(options, (res) => {
106
110
  let chunks = [];
107
111
  res.on("data", (c) => chunks.push(c));
108
112
  res.on("end", () => {
109
113
  const raw = Buffer.concat(chunks).toString();
110
- try {
111
- resolve(JSON.parse(raw));
112
- } catch {
113
- reject(new Error(`Invalid JSON from ${urlStr}: ${raw.slice(0, 200)}`));
114
- }
115
- });
116
- });
117
- req.on("error", reject);
118
- req.write(body);
119
- req.end();
120
- });
121
- }
122
-
123
- function httpGet(urlStr, headers) {
124
- return new Promise((resolve, reject) => {
125
- const url = new URL(urlStr);
126
- const options = {
127
- hostname: url.hostname,
128
- path: url.pathname + url.search,
129
- method: "GET",
130
- headers,
131
- };
132
-
133
- const req = https.request(options, (res) => {
134
- let chunks = [];
135
- res.on("data", (c) => chunks.push(c));
136
- res.on("end", () => {
137
- const raw = Buffer.concat(chunks).toString();
138
- try {
139
- resolve(JSON.parse(raw));
140
- } catch {
141
- reject(new Error(`Invalid JSON from ${urlStr}: ${raw.slice(0, 200)}`));
142
- }
114
+ let parsed = null;
115
+ try { parsed = raw ? JSON.parse(raw) : null; } catch { /* keep raw */ }
116
+ resolve({ statusCode: res.statusCode, raw, json: parsed });
143
117
  });
144
118
  });
145
119
  req.on("error", reject);
120
+ if (body) req.write(body);
146
121
  req.end();
147
122
  });
148
123
  }
@@ -157,19 +132,7 @@ function getConfigPath() {
157
132
  return path.join(os.homedir(), ".config", "Claude", "claude_desktop_config.json");
158
133
  }
159
134
 
160
- function detectRegion(profiles) {
161
- const euCountries = ["UK", "GB", "DE", "FR", "IT", "ES", "NL", "SE", "PL", "BE", "TR", "AE", "SA", "EG", "IN"];
162
- const feCountries = ["JP", "AU", "SG"];
163
-
164
- for (const p of profiles) {
165
- const cc = (p.countryCode || "").toUpperCase();
166
- if (euCountries.includes(cc)) return "EU";
167
- if (feCountries.includes(cc)) return "FE";
168
- }
169
- return "NA";
170
- }
171
-
172
- // ─── Start local server and wait for callback ───
135
+ // ─── OAuth callback server ───
173
136
 
174
137
  function startCallbackServer(port) {
175
138
  return new Promise((resolve, reject) => {
@@ -233,6 +196,80 @@ function startCallbackServer(port) {
233
196
  });
234
197
  }
235
198
 
199
+ // ─── Amazon Ads API calls ───
200
+
201
+ async function exchangeCodeForTokens({ code, clientId, clientSecret, redirectUri }) {
202
+ const body = new URLSearchParams({
203
+ grant_type: "authorization_code",
204
+ code,
205
+ redirect_uri: redirectUri,
206
+ client_id: clientId,
207
+ client_secret: clientSecret,
208
+ }).toString();
209
+
210
+ const res = await httpRequest({
211
+ hostname: "api.amazon.com",
212
+ path: "/auth/o2/token",
213
+ method: "POST",
214
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
215
+ body,
216
+ });
217
+
218
+ if (res.json?.error) {
219
+ throw new Error(`Amazon OAuth error: ${res.json.error} — ${res.json.error_description || ""}`);
220
+ }
221
+ if (!res.json?.access_token) {
222
+ throw new Error(`Unexpected token response: ${res.raw.slice(0, 200)}`);
223
+ }
224
+ return res.json;
225
+ }
226
+
227
+ /**
228
+ * Fetch all advertiser accounts accessible to this LWA application, across
229
+ * every Amazon Ads region the app has access to (NA / EU / FE).
230
+ *
231
+ * Uses Amazon Ads API v3 POST /adsAccounts/list. Returns a flat array of
232
+ * { accountName, adsAccountId, region, alternateIds[] } objects. Probes all
233
+ * three regions so users with multi-region accounts see everything.
234
+ */
235
+ async function fetchAccountsAllRegions({ accessToken, clientId }) {
236
+ const all = [];
237
+
238
+ for (const [region, hostname] of Object.entries(REGION_HOSTS)) {
239
+ try {
240
+ const body = JSON.stringify({ maxResults: 100 });
241
+ const res = await httpRequest({
242
+ hostname,
243
+ path: "/adsAccounts/list",
244
+ method: "POST",
245
+ headers: {
246
+ Authorization: `Bearer ${accessToken}`,
247
+ "Amazon-Advertising-API-ClientId": clientId,
248
+ "Content-Type": "application/vnd.listaccountsresource.v1+json",
249
+ Accept: "application/vnd.listaccountsresource.v1+json",
250
+ },
251
+ body,
252
+ });
253
+
254
+ if (res.statusCode === 200 && Array.isArray(res.json?.adsAccounts)) {
255
+ for (const a of res.json.adsAccounts) {
256
+ all.push({
257
+ accountName: a.accountName || "(unnamed)",
258
+ adsAccountId: a.adsAccountId,
259
+ region,
260
+ alternateIds: Array.isArray(a.alternateIds) ? a.alternateIds : [],
261
+ });
262
+ }
263
+ }
264
+ // Non-200 responses are usually "not authorized in this region" — silent skip.
265
+ } catch {
266
+ // Network error per region is not fatal — other regions may still work.
267
+ }
268
+ }
269
+
270
+ return all;
271
+ }
272
+
236
273
  // ─── Main ───
237
274
 
238
275
  async function main() {
@@ -240,7 +277,7 @@ async function main() {
240
277
  print("\u{1F680} Amazon Ads MCP Setup");
241
278
  print("\u2500".repeat(35));
242
279
 
243
- // Ask for credentials
280
+ // ── Step 1: Credentials ──
244
281
  const clientId = await ask("\u2192 Enter your Amazon Ads Client ID: ");
245
282
  if (!clientId) {
246
283
  print("\n\u274C Client ID is required.");
@@ -255,23 +292,20 @@ async function main() {
255
292
 
256
293
  print("");
257
294
 
258
- // Find an available port and start callback server
259
- let result = null;
295
+ // ── Step 2: OAuth code flow via localhost callback ──
296
+ let oauthResult = null;
260
297
 
261
298
  for (let port = 8080; port <= 8082; port++) {
262
299
  const redirectUri = `${REDIRECT_URI_BASE}:${port}${CALLBACK_PATH}`;
263
300
  const authUrl =
264
301
  `${OAUTH_AUTHORIZE_URL}?client_id=${encodeURIComponent(clientId)}` +
265
- `&scope=cpc_advertising:campaign_management` +
302
+ `&scope=${encodeURIComponent("profile advertising::campaign_management")}` +
266
303
  `&response_type=code` +
267
304
  `&redirect_uri=${encodeURIComponent(redirectUri)}`;
268
305
 
269
306
  const promise = startCallbackServer(port);
270
-
271
- // Give server a moment to bind or fail
272
307
  await new Promise((r) => setTimeout(r, 150));
273
308
 
274
- // Quick check — if port was in use, promise resolves to null immediately
275
309
  const quick = await Promise.race([
276
310
  promise,
277
311
  new Promise((r) => setTimeout(() => r("waiting"), 300)),
@@ -282,129 +316,171 @@ async function main() {
282
316
  continue;
283
317
  }
284
318
 
285
- // Server is listening — open browser
286
319
  print(`\u2192 Opening Amazon authorization in your browser...`);
287
320
  openBrowser(authUrl);
288
321
  print(`\u2192 Waiting for authorization... (press Ctrl+C to cancel)`);
289
322
 
290
- result = quick === "waiting" ? await promise : quick;
323
+ oauthResult = quick === "waiting" ? await promise : quick;
291
324
  break;
292
325
  }
293
326
 
294
- if (!result) {
327
+ if (!oauthResult) {
295
328
  print("\n\u274C Could not find an available port (tried 8080-8082). Please free one and try again.");
296
329
  process.exit(1);
297
330
  }
298
331
 
299
332
  print("\u2705 Authorization received");
300
333
 
301
- // Exchange code for tokens using the actual port that was bound
302
- const redirectUri = `${REDIRECT_URI_BASE}:${result.port}${CALLBACK_PATH}`;
303
- const tokenBody = new URLSearchParams({
304
- grant_type: "authorization_code",
305
- code: result.code,
306
- redirect_uri: redirectUri,
307
- client_id: clientId,
308
- client_secret: clientSecret,
309
- }).toString();
310
-
311
- let tokenData;
334
+ // ── Step 3: Exchange code for tokens ──
335
+ const redirectUri = `${REDIRECT_URI_BASE}:${oauthResult.port}${CALLBACK_PATH}`;
336
+ let tokens;
312
337
  try {
313
- tokenData = await httpPost(OAUTH_TOKEN_URL, tokenBody);
338
+ tokens = await exchangeCodeForTokens({
339
+ code: oauthResult.code,
340
+ clientId,
341
+ clientSecret,
342
+ redirectUri,
343
+ });
314
344
  } catch (err) {
315
- print(`\n\u274C Token exchange failed: ${err.message}`);
345
+ print(`\n\u274C ${err.message}`);
316
346
  process.exit(1);
317
347
  }
318
348
 
319
- if (tokenData.error) {
320
- print(`\n\u274C Amazon OAuth error: ${tokenData.error} - ${tokenData.error_description || ""}`);
321
- process.exit(1);
322
- }
349
+ const { access_token, refresh_token } = tokens;
323
350
 
324
- const { access_token, refresh_token } = tokenData;
351
+ // ── Step 4: Fetch advertiser accounts across all regions ──
352
+ print("\u2192 Fetching your Amazon Ads accounts...");
325
353
 
326
- // Fetch advertiser profiles
327
- print("\u2192 Fetching your Amazon Ads profiles...");
354
+ const accounts = await fetchAccountsAllRegions({
355
+ accessToken: access_token,
356
+ clientId,
357
+ });
328
358
 
329
- let profiles;
330
- try {
331
- profiles = await httpGet(PROFILES_URL, {
332
- Authorization: `Bearer ${access_token}`,
333
- "Amazon-Advertising-API-ClientId": clientId,
334
- });
335
- } catch (err) {
336
- print(`\n\u274C Failed to fetch profiles: ${err.message}`);
359
+ if (accounts.length === 0) {
360
+ print("\n\u274C No advertiser accounts found for this LWA application.");
361
+ print(" Make sure your app has Amazon Ads API access approved.");
337
362
  process.exit(1);
338
363
  }
339
364
 
340
- if (!Array.isArray(profiles) || profiles.length === 0) {
341
- print("\n\u274C No advertising profiles found on this account.");
342
- process.exit(1);
343
- }
365
+ print(`\u2192 Found ${accounts.length} advertiser account(s)`);
366
+ print("");
367
+
368
+ // ── Step 5: Ask the user which mode they want ──
369
+ print("\u{1F4CD} Account scope — two ways to run the MCP:");
370
+ print("");
371
+ print(" 1. \u{1F517} Flexible mode (recommended if you manage multiple marketplaces)");
372
+ print(" Claude picks the marketplace per question at runtime.");
373
+ print(" Works across every account + marketplace your LWA app can access.");
374
+ print("");
375
+ print(" 2. \u{1F4CD} Pinned mode (recommended if you only manage one marketplace)");
376
+ print(" One marketplace is locked in. Simpler, no per-question selection.");
377
+ print("");
344
378
 
345
- // Select profile
346
- let selectedProfile;
379
+ const modeAnswer = await ask("\u2192 Choose mode [1 = flexible (default), 2 = pinned]: ");
380
+ const mode = modeAnswer === "2" ? "pinned" : "flexible";
347
381
 
348
- if (profiles.length === 1) {
349
- selectedProfile = profiles[0];
350
- print(`\u2192 Found 1 profile: ${selectedProfile.accountInfo?.name || "Account"} (${selectedProfile.profileId})`);
382
+ let config = {
383
+ CLIENT_ID: clientId,
384
+ CLIENT_SECRET: clientSecret,
385
+ REFRESH_TOKEN: refresh_token,
386
+ };
387
+
388
+ let displayLabel;
389
+ let chosenRegion;
390
+
391
+ if (mode === "flexible") {
392
+ // In flexible/dynamic mode, we do NOT pin any scope — the proxy will omit
393
+ // the FIXED header, and Claude will pass account identifiers per call.
394
+ // We still need to pick a REGION for the MCP endpoint URL. Use the region
395
+ // that hosts the most accounts for this user (arbitrary sensible default).
396
+ const regionCounts = accounts.reduce((acc, a) => {
397
+ acc[a.region] = (acc[a.region] || 0) + 1;
398
+ return acc;
399
+ }, {});
400
+ chosenRegion = Object.entries(regionCounts).sort((a, b) => b[1] - a[1])[0][0];
401
+ displayLabel = `Flexible mode — all accounts (${accounts.length}) across ${Object.keys(regionCounts).join(", ")}`;
351
402
  } else {
352
- print(`\u2192 Found ${profiles.length} profiles:`);
353
- profiles.forEach((p, i) => {
354
- const name = p.accountInfo?.name || `Profile ${p.profileId}`;
355
- const cc = p.countryCode || "";
356
- print(` ${i + 1}. ${name} ${cc ? `(${cc})` : ""} \u2014 profile_id: ${p.profileId}`);
403
+ // Pinned mode: user picks an account, then one of its marketplace profiles.
404
+ const sorted = [...accounts].sort((a, b) => a.accountName.localeCompare(b.accountName));
405
+ print("");
406
+ print("\u2192 Select an account to pin:");
407
+ sorted.forEach((a, i) => {
408
+ const marketplaces = [...new Set(a.alternateIds.map((x) => x.countryCode))].join(", ");
409
+ print(` ${i + 1}. ${a.accountName} \u2014 ${marketplaces || a.region}`);
357
410
  });
358
411
 
359
- const answer = await ask(`\u2192 Select a profile [1-${profiles.length}]: `);
360
- const idx = parseInt(answer, 10) - 1;
361
-
362
- if (isNaN(idx) || idx < 0 || idx >= profiles.length) {
412
+ const accIdx = parseInt(await ask(`\u2192 Select account [1-${sorted.length}]: `), 10) - 1;
413
+ if (isNaN(accIdx) || accIdx < 0 || accIdx >= sorted.length) {
363
414
  print("\n\u274C Invalid selection.");
364
415
  process.exit(1);
365
416
  }
417
+ const selectedAccount = sorted[accIdx];
366
418
 
367
- selectedProfile = profiles[idx];
419
+ // Pick a marketplace within that account (always, so query_campaign works)
420
+ const profiles = selectedAccount.alternateIds.filter((x) => x.profileId);
421
+ if (profiles.length === 0) {
422
+ print("\n\u274C Selected account has no advertising profiles.");
423
+ process.exit(1);
424
+ }
425
+
426
+ let selectedProfile;
427
+ if (profiles.length === 1) {
428
+ selectedProfile = profiles[0];
429
+ print(`\u2192 Only one marketplace available: ${selectedProfile.countryCode}`);
430
+ } else {
431
+ print("");
432
+ print(`\u2192 Select a marketplace within ${selectedAccount.accountName}:`);
433
+ profiles.forEach((p, i) => {
434
+ print(` ${i + 1}. ${p.countryCode} \u2014 profile ${p.profileId}`);
435
+ });
436
+ const pIdx = parseInt(await ask(`\u2192 Select marketplace [1-${profiles.length}]: `), 10) - 1;
437
+ if (isNaN(pIdx) || pIdx < 0 || pIdx >= profiles.length) {
438
+ print("\n\u274C Invalid selection.");
439
+ process.exit(1);
440
+ }
441
+ selectedProfile = profiles[pIdx];
442
+ }
443
+
444
+ config.PROFILE_ID = String(selectedProfile.profileId);
445
+ config.ACCOUNT_ID = selectedAccount.adsAccountId;
446
+ chosenRegion = selectedAccount.region;
447
+ displayLabel = `${selectedAccount.accountName} — ${selectedProfile.countryCode}`;
368
448
  }
369
449
 
370
- const profileName = selectedProfile.accountInfo?.name || "Amazon Ads";
371
- const profileId = String(selectedProfile.profileId);
372
- const region = detectRegion([selectedProfile]);
450
+ config.REGION = chosenRegion;
373
451
 
374
- print(`\u2705 Profile selected: ${profileName} (${region})`);
452
+ print("");
453
+ print(`\u2705 Mode: ${mode === "flexible" ? "Flexible" : "Pinned"}`);
454
+ print(` ${displayLabel}`);
455
+ print(` Region: ${chosenRegion}`);
375
456
 
376
- // Write Claude Desktop config
457
+ // ── Step 6: Write Claude Desktop config ──
377
458
  const configPath = getConfigPath();
378
459
  const configDir = path.dirname(configPath);
379
460
 
380
- let config = {};
461
+ let existingConfig = {};
381
462
  if (fs.existsSync(configPath)) {
382
463
  try {
383
- config = JSON.parse(fs.readFileSync(configPath, "utf8"));
464
+ existingConfig = JSON.parse(fs.readFileSync(configPath, "utf8"));
384
465
  } catch {
385
466
  print(` Warning: existing config was invalid JSON, creating fresh.`);
386
467
  }
387
468
  }
388
469
 
389
- if (!config.mcpServers) {
390
- config.mcpServers = {};
470
+ if (!existingConfig.mcpServers) {
471
+ existingConfig.mcpServers = {};
391
472
  }
392
473
 
393
- config.mcpServers["amazon-ads"] = {
474
+ existingConfig.mcpServers["amazon-ads"] = {
394
475
  command: "npx",
395
476
  args: ["-y", "@ppcassist/amazon-ads-mcp"],
396
- env: {
397
- CLIENT_ID: clientId,
398
- CLIENT_SECRET: clientSecret,
399
- REFRESH_TOKEN: refresh_token,
400
- PROFILE_ID: profileId,
401
- REGION: region,
402
- },
477
+ env: config,
403
478
  };
404
479
 
405
480
  fs.mkdirSync(configDir, { recursive: true });
406
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
481
+ fs.writeFileSync(configPath, JSON.stringify(existingConfig, null, 2) + "\n");
407
482
 
483
+ print("");
408
484
  print(`\u2705 Claude Desktop config updated`);
409
485
  print(` ${configPath}`);
410
486
  print("\u2500".repeat(35));