@ppcassist/amazon-ads-mcp 1.0.7 → 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.
- package/README.md +92 -18
- package/index.js +17 -3
- package/lib/proxy.js +52 -13
- package/package.json +2 -2
- package/setup.js +205 -129
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
|
-
##
|
|
13
|
+
## Quick start
|
|
14
14
|
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 |
|
|
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`,
|
|
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({
|
|
44
|
+
const proxy = new McpProxy({
|
|
45
|
+
tokenManager,
|
|
46
|
+
clientId,
|
|
47
|
+
region,
|
|
48
|
+
profileId,
|
|
49
|
+
accountId,
|
|
50
|
+
managerAccountId,
|
|
51
|
+
});
|
|
39
52
|
|
|
40
|
-
|
|
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,
|
|
12
|
+
constructor({ tokenManager, clientId, region, profileId, accountId, managerAccountId }) {
|
|
11
13
|
this.tokenManager = tokenManager;
|
|
12
14
|
this.clientId = clientId;
|
|
13
|
-
|
|
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
|
|
4
|
-
"description": "Local MCP proxy for Amazon Ads API
|
|
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
|
-
|
|
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
|
|
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
|
|
96
|
-
path:
|
|
97
|
-
method
|
|
101
|
+
hostname,
|
|
102
|
+
path: reqPath,
|
|
103
|
+
method,
|
|
98
104
|
headers: {
|
|
99
|
-
"Content-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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,8 +292,8 @@ async function main() {
|
|
|
255
292
|
|
|
256
293
|
print("");
|
|
257
294
|
|
|
258
|
-
//
|
|
259
|
-
let
|
|
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}`;
|
|
@@ -267,11 +304,8 @@ async function main() {
|
|
|
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
|
-
|
|
323
|
+
oauthResult = quick === "waiting" ? await promise : quick;
|
|
291
324
|
break;
|
|
292
325
|
}
|
|
293
326
|
|
|
294
|
-
if (!
|
|
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
|
|
302
|
-
const redirectUri = `${REDIRECT_URI_BASE}:${
|
|
303
|
-
|
|
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
|
-
|
|
338
|
+
tokens = await exchangeCodeForTokens({
|
|
339
|
+
code: oauthResult.code,
|
|
340
|
+
clientId,
|
|
341
|
+
clientSecret,
|
|
342
|
+
redirectUri,
|
|
343
|
+
});
|
|
314
344
|
} catch (err) {
|
|
315
|
-
print(`\n\u274C
|
|
345
|
+
print(`\n\u274C ${err.message}`);
|
|
316
346
|
process.exit(1);
|
|
317
347
|
}
|
|
318
348
|
|
|
319
|
-
|
|
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
|
-
|
|
351
|
+
// ── Step 4: Fetch advertiser accounts across all regions ──
|
|
352
|
+
print("\u2192 Fetching your Amazon Ads accounts...");
|
|
325
353
|
|
|
326
|
-
|
|
327
|
-
|
|
354
|
+
const accounts = await fetchAccountsAllRegions({
|
|
355
|
+
accessToken: access_token,
|
|
356
|
+
clientId,
|
|
357
|
+
});
|
|
328
358
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
346
|
-
|
|
379
|
+
const modeAnswer = await ask("\u2192 Choose mode [1 = flexible (default), 2 = pinned]: ");
|
|
380
|
+
const mode = modeAnswer === "2" ? "pinned" : "flexible";
|
|
347
381
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
|
360
|
-
|
|
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
|
-
|
|
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
|
-
|
|
371
|
-
const profileId = String(selectedProfile.profileId);
|
|
372
|
-
const region = detectRegion([selectedProfile]);
|
|
450
|
+
config.REGION = chosenRegion;
|
|
373
451
|
|
|
374
|
-
print(
|
|
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
|
|
461
|
+
let existingConfig = {};
|
|
381
462
|
if (fs.existsSync(configPath)) {
|
|
382
463
|
try {
|
|
383
|
-
|
|
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 (!
|
|
390
|
-
|
|
470
|
+
if (!existingConfig.mcpServers) {
|
|
471
|
+
existingConfig.mcpServers = {};
|
|
391
472
|
}
|
|
392
473
|
|
|
393
|
-
|
|
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(
|
|
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));
|