@ppcassist/amazon-ads-mcp 1.0.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 +67 -0
- package/index.js +127 -0
- package/lib/auth.js +85 -0
- package/lib/proxy.js +70 -0
- package/package.json +27 -0
package/README.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# @ppcassist/amazon-ads-mcp
|
|
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.
|
|
4
|
+
|
|
5
|
+
## How it works
|
|
6
|
+
|
|
7
|
+
1. On startup, exchanges your refresh token for an access token via Amazon OAuth
|
|
8
|
+
2. Auto-refreshes the token every 50 minutes
|
|
9
|
+
3. Proxies all MCP requests from Claude Desktop to the Amazon Ads MCP server
|
|
10
|
+
4. Injects required authentication headers on every request
|
|
11
|
+
5. Streams SSE responses back to Claude without buffering
|
|
12
|
+
|
|
13
|
+
## Prerequisites
|
|
14
|
+
|
|
15
|
+
You need Amazon Ads API credentials:
|
|
16
|
+
|
|
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`
|
|
22
|
+
|
|
23
|
+
## Setup with Claude Desktop
|
|
24
|
+
|
|
25
|
+
Add to your Claude Desktop configuration (`claude_desktop_config.json`):
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"mcpServers": {
|
|
30
|
+
"amazon-ads": {
|
|
31
|
+
"command": "npx",
|
|
32
|
+
"args": ["-y", "@ppcassist/amazon-ads-mcp"],
|
|
33
|
+
"env": {
|
|
34
|
+
"CLIENT_ID": "amzn1.application-oa2-client.xxxxx",
|
|
35
|
+
"CLIENT_SECRET": "your-client-secret",
|
|
36
|
+
"REFRESH_TOKEN": "Atzr|your-refresh-token",
|
|
37
|
+
"PROFILE_ID": "your-profile-id",
|
|
38
|
+
"REGION": "EU"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Supported regions
|
|
46
|
+
|
|
47
|
+
| Region | Endpoint |
|
|
48
|
+
|--------|----------|
|
|
49
|
+
| NA | `https://advertising-ai.amazon.com/mcp` |
|
|
50
|
+
| EU | `https://advertising-ai-eu.amazon.com/mcp` |
|
|
51
|
+
| FE | `https://advertising-ai-fe.amazon.com/mcp` |
|
|
52
|
+
|
|
53
|
+
## How it proxies
|
|
54
|
+
|
|
55
|
+
All MCP JSON-RPC messages (`initialize`, `tools/list`, `tools/call`, etc.) are forwarded to Amazon's MCP server with these headers injected:
|
|
56
|
+
|
|
57
|
+
- `Authorization: Bearer <access_token>`
|
|
58
|
+
- `Amazon-Ads-ClientId: <CLIENT_ID>`
|
|
59
|
+
- `Amazon-Advertising-API-Scope: <PROFILE_ID>`
|
|
60
|
+
- `Amazon-Ads-AI-Account-Selection-Mode: FIXED`
|
|
61
|
+
- `Accept: application/json, text/event-stream`
|
|
62
|
+
|
|
63
|
+
Responses (including SSE streams) are forwarded back to Claude as-is.
|
|
64
|
+
|
|
65
|
+
## License
|
|
66
|
+
|
|
67
|
+
MIT
|
package/index.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { TokenManager } = require("./lib/auth");
|
|
4
|
+
const { McpProxy } = require("./lib/proxy");
|
|
5
|
+
|
|
6
|
+
const log = (msg) => process.stderr.write(`[amazon-ads-mcp] ${msg}\n`);
|
|
7
|
+
|
|
8
|
+
function requireEnv(name) {
|
|
9
|
+
const value = process.env[name];
|
|
10
|
+
if (!value) {
|
|
11
|
+
log(`ERROR: Missing required environment variable: ${name}`);
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function main() {
|
|
18
|
+
const clientId = requireEnv("CLIENT_ID");
|
|
19
|
+
const clientSecret = requireEnv("CLIENT_SECRET");
|
|
20
|
+
const refreshToken = requireEnv("REFRESH_TOKEN");
|
|
21
|
+
const profileId = requireEnv("PROFILE_ID");
|
|
22
|
+
const region = requireEnv("REGION");
|
|
23
|
+
|
|
24
|
+
// Initialize OAuth token manager
|
|
25
|
+
const tokenManager = new TokenManager({ clientId, clientSecret, refreshToken });
|
|
26
|
+
try {
|
|
27
|
+
await tokenManager.init();
|
|
28
|
+
} catch (err) {
|
|
29
|
+
log(`Failed to obtain access token: ${err.message}`);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Initialize MCP proxy
|
|
34
|
+
const proxy = new McpProxy({ tokenManager, clientId, profileId, region });
|
|
35
|
+
|
|
36
|
+
log(`Started (region=${region})`);
|
|
37
|
+
|
|
38
|
+
// Read JSON-RPC messages from stdin (newline-delimited)
|
|
39
|
+
let buffer = "";
|
|
40
|
+
|
|
41
|
+
process.stdin.setEncoding("utf8");
|
|
42
|
+
process.stdin.on("data", (chunk) => {
|
|
43
|
+
buffer += chunk;
|
|
44
|
+
|
|
45
|
+
// Process all complete lines in the buffer
|
|
46
|
+
let newlineIdx;
|
|
47
|
+
while ((newlineIdx = buffer.indexOf("\n")) !== -1) {
|
|
48
|
+
const line = buffer.slice(0, newlineIdx).trim();
|
|
49
|
+
buffer = buffer.slice(newlineIdx + 1);
|
|
50
|
+
|
|
51
|
+
if (!line) continue;
|
|
52
|
+
|
|
53
|
+
let request;
|
|
54
|
+
try {
|
|
55
|
+
request = JSON.parse(line);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
log(`Failed to parse JSON-RPC message: ${err.message}`);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
handleRequest(proxy, request);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
process.stdin.on("end", () => {
|
|
66
|
+
log("stdin closed, shutting down");
|
|
67
|
+
tokenManager.destroy();
|
|
68
|
+
process.exit(0);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function handleRequest(proxy, request) {
|
|
73
|
+
const { id, method } = request;
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const result = await proxy.forward(request);
|
|
77
|
+
|
|
78
|
+
if (result.type === "stream") {
|
|
79
|
+
// SSE stream: pipe each event line directly to stdout
|
|
80
|
+
result.stream.on("data", (chunk) => {
|
|
81
|
+
process.stdout.write(chunk);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
result.stream.on("end", () => {
|
|
85
|
+
// SSE streams end naturally; nothing extra needed
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
result.stream.on("error", (err) => {
|
|
89
|
+
log(`SSE stream error (id=${id}): ${err.message}`);
|
|
90
|
+
writeJsonRpc({
|
|
91
|
+
jsonrpc: "2.0",
|
|
92
|
+
id,
|
|
93
|
+
error: { code: -32000, message: `Stream error: ${err.message}` },
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
} else if (result.type === "json") {
|
|
97
|
+
writeJsonRpc(result.data);
|
|
98
|
+
} else {
|
|
99
|
+
// Raw / unexpected response
|
|
100
|
+
log(`Unexpected response (status=${result.statusCode}): ${result.data}`);
|
|
101
|
+
writeJsonRpc({
|
|
102
|
+
jsonrpc: "2.0",
|
|
103
|
+
id,
|
|
104
|
+
error: {
|
|
105
|
+
code: -32000,
|
|
106
|
+
message: `Upstream error (HTTP ${result.statusCode})`,
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
} catch (err) {
|
|
111
|
+
log(`Proxy error (method=${method}, id=${id}): ${err.message}`);
|
|
112
|
+
writeJsonRpc({
|
|
113
|
+
jsonrpc: "2.0",
|
|
114
|
+
id,
|
|
115
|
+
error: { code: -32000, message: err.message },
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function writeJsonRpc(obj) {
|
|
121
|
+
process.stdout.write(JSON.stringify(obj) + "\n");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
main().catch((err) => {
|
|
125
|
+
log(`Fatal: ${err.message}`);
|
|
126
|
+
process.exit(1);
|
|
127
|
+
});
|
package/lib/auth.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const https = require("https");
|
|
2
|
+
|
|
3
|
+
const TOKEN_URL = "https://api.amazon.com/auth/o2/token";
|
|
4
|
+
const REFRESH_INTERVAL_MS = 50 * 60 * 1000; // 50 minutes
|
|
5
|
+
|
|
6
|
+
class TokenManager {
|
|
7
|
+
constructor({ clientId, clientSecret, refreshToken }) {
|
|
8
|
+
this.clientId = clientId;
|
|
9
|
+
this.clientSecret = clientSecret;
|
|
10
|
+
this.refreshToken = refreshToken;
|
|
11
|
+
this.accessToken = null;
|
|
12
|
+
this.timer = null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async init() {
|
|
16
|
+
await this.refresh();
|
|
17
|
+
this.timer = setInterval(() => this.refresh(), REFRESH_INTERVAL_MS);
|
|
18
|
+
this.timer.unref(); // don't keep process alive just for refresh
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async refresh() {
|
|
22
|
+
const body = new URLSearchParams({
|
|
23
|
+
grant_type: "refresh_token",
|
|
24
|
+
client_id: this.clientId,
|
|
25
|
+
client_secret: this.clientSecret,
|
|
26
|
+
refresh_token: this.refreshToken,
|
|
27
|
+
}).toString();
|
|
28
|
+
|
|
29
|
+
const data = await this._post(TOKEN_URL, body);
|
|
30
|
+
|
|
31
|
+
if (data.error) {
|
|
32
|
+
throw new Error(`OAuth error: ${data.error} - ${data.error_description || ""}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
this.accessToken = data.access_token;
|
|
36
|
+
process.stderr.write(`[auth] Token refreshed successfully\n`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
getToken() {
|
|
40
|
+
if (!this.accessToken) {
|
|
41
|
+
throw new Error("Access token not available - call init() first");
|
|
42
|
+
}
|
|
43
|
+
return this.accessToken;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
destroy() {
|
|
47
|
+
if (this.timer) {
|
|
48
|
+
clearInterval(this.timer);
|
|
49
|
+
this.timer = null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
_post(urlStr, body) {
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
const url = new URL(urlStr);
|
|
56
|
+
const options = {
|
|
57
|
+
hostname: url.hostname,
|
|
58
|
+
path: url.pathname,
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers: {
|
|
61
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
62
|
+
"Content-Length": Buffer.byteLength(body),
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const req = https.request(options, (res) => {
|
|
67
|
+
let chunks = [];
|
|
68
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
69
|
+
res.on("end", () => {
|
|
70
|
+
try {
|
|
71
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString()));
|
|
72
|
+
} catch (e) {
|
|
73
|
+
reject(new Error(`Failed to parse token response: ${e.message}`));
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
req.on("error", reject);
|
|
79
|
+
req.write(body);
|
|
80
|
+
req.end();
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { TokenManager };
|
package/lib/proxy.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const https = require("https");
|
|
2
|
+
|
|
3
|
+
const ENDPOINTS = {
|
|
4
|
+
EU: "https://advertising-ai-eu.amazon.com/mcp",
|
|
5
|
+
NA: "https://advertising-ai.amazon.com/mcp",
|
|
6
|
+
FE: "https://advertising-ai-fe.amazon.com/mcp",
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
class McpProxy {
|
|
10
|
+
constructor({ tokenManager, clientId, profileId, region }) {
|
|
11
|
+
this.tokenManager = tokenManager;
|
|
12
|
+
this.clientId = clientId;
|
|
13
|
+
this.profileId = profileId;
|
|
14
|
+
|
|
15
|
+
const endpoint = ENDPOINTS[region.toUpperCase()];
|
|
16
|
+
if (!endpoint) {
|
|
17
|
+
throw new Error(`Invalid REGION "${region}". Must be EU, NA, or FE.`);
|
|
18
|
+
}
|
|
19
|
+
this.endpoint = new URL(endpoint);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async forward(jsonRpcRequest) {
|
|
23
|
+
const body = JSON.stringify(jsonRpcRequest);
|
|
24
|
+
const token = this.tokenManager.getToken();
|
|
25
|
+
|
|
26
|
+
const options = {
|
|
27
|
+
hostname: this.endpoint.hostname,
|
|
28
|
+
path: this.endpoint.pathname,
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: {
|
|
31
|
+
"Content-Type": "application/json",
|
|
32
|
+
"Content-Length": Buffer.byteLength(body),
|
|
33
|
+
Authorization: `Bearer ${token}`,
|
|
34
|
+
"Amazon-Ads-ClientId": this.clientId,
|
|
35
|
+
"Amazon-Advertising-API-Scope": this.profileId,
|
|
36
|
+
"Amazon-Ads-AI-Account-Selection-Mode": "FIXED",
|
|
37
|
+
Accept: "application/json, text/event-stream",
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
const req = https.request(options, (res) => {
|
|
43
|
+
const contentType = res.headers["content-type"] || "";
|
|
44
|
+
const isSSE = contentType.includes("text/event-stream");
|
|
45
|
+
|
|
46
|
+
if (isSSE) {
|
|
47
|
+
resolve({ type: "stream", stream: res });
|
|
48
|
+
} else {
|
|
49
|
+
let chunks = [];
|
|
50
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
51
|
+
res.on("end", () => {
|
|
52
|
+
const raw = Buffer.concat(chunks).toString();
|
|
53
|
+
try {
|
|
54
|
+
resolve({ type: "json", data: JSON.parse(raw) });
|
|
55
|
+
} catch {
|
|
56
|
+
// Return raw text if not valid JSON (error pages, etc.)
|
|
57
|
+
resolve({ type: "raw", data: raw, statusCode: res.statusCode });
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
req.on("error", reject);
|
|
64
|
+
req.write(body);
|
|
65
|
+
req.end();
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = { McpProxy };
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ppcassist/amazon-ads-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Local MCP proxy for Amazon Ads API - authenticates and forwards requests to Amazon's official MCP server",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"amazon-ads-mcp": "./index.js"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"amazon-ads",
|
|
11
|
+
"mcp",
|
|
12
|
+
"model-context-protocol",
|
|
13
|
+
"ppc",
|
|
14
|
+
"advertising",
|
|
15
|
+
"proxy"
|
|
16
|
+
],
|
|
17
|
+
"author": "PPCAssist",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=18.0.0"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"index.js",
|
|
24
|
+
"lib/",
|
|
25
|
+
"README.md"
|
|
26
|
+
]
|
|
27
|
+
}
|