chatgpt-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) 2026 chatgpt-ads-mcp contributors
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,274 @@
1
+ # ChatGPT Ads MCP Server
2
+
3
+ An [MCP](https://modelcontextprotocol.io) server that lets an AI assistant (Claude
4
+ Desktop, Claude Code, or any MCP client) manage your **ChatGPT Ads** campaigns
5
+ through plain conversation. It wraps the official
6
+ [OpenAI Advertiser API](https://developers.openai.com/ads/api-overview), so you
7
+ can say things like *"create a paused campaign with a $500 lifetime budget"* or
8
+ *"show me yesterday's clicks and spend by campaign"* and the assistant calls the
9
+ API for you.
10
+
11
+ > **You provide:** one API key from ChatGPT Ads Manager.
12
+ > **You get:** full campaign management without touching the dashboard.
13
+
14
+ ---
15
+
16
+ ## What it can do
17
+
18
+ | Area | Tools |
19
+ | --- | --- |
20
+ | **Campaigns** | `list_campaigns`, `get_campaign`, `create_campaign`, `update_campaign`, `activate_campaign`, `pause_campaign`, `archive_campaign` |
21
+ | **Ad groups** | `list_ad_groups`, `get_ad_group`, `create_ad_group`, `update_ad_group`, `activate_ad_group`, `pause_ad_group`, `archive_ad_group` |
22
+ | **Ads** | `list_ads`, `get_ad`, `create_ad`, `update_ad`, `activate_ad`, `pause_ad`, `archive_ad` |
23
+ | **Creatives** | `upload_creative` (image URL or local file → `file_id`) |
24
+ | **Reporting** | `get_insights` (account / campaign / ad-group / ad level: impressions, clicks, spend, CTR, CPC, CPM) |
25
+ | **Targeting** | `search_locations` (find country/region/DMA IDs by name; use with `include_location_ids` on campaigns) |
26
+ | **Find by name** | `find_campaigns`, `find_ad_groups`, `find_ads` (match by name across all pages — no ID needed) |
27
+ | **Accounts** | `list_accounts`, `get_ad_account` (account name, currency, timezone) |
28
+
29
+ That's full create-read-update plus the activate / pause / archive lifecycle — 29 tools in total. It supports **multiple ad accounts** from a single server (see below). List tools also accept `fetch_all: true` to return every page at once.
30
+
31
+ ## Documentation
32
+
33
+ - **[Tool reference](docs/TOOL_REFERENCE.md)** — every tool with its parameters (auto-generated from the server).
34
+ - **[Configuration](docs/CONFIGURATION.md)** — environment variables, single vs. multiple accounts, client setup, security.
35
+ - **[Architecture](docs/ARCHITECTURE.md)** — how the server is structured and why.
36
+ - **[Contributing](CONTRIBUTING.md)** — local setup and how to add a tool.
37
+ - **[Changelog](CHANGELOG.md)** — version history.
38
+
39
+ ---
40
+
41
+ ## Setup (step by step)
42
+
43
+ You don't need to be technical — just follow these in order.
44
+
45
+ > **macOS shortcut:** after installing Node.js and downloading this repo, run
46
+ > `bash setup.sh` from the project folder. It installs, builds, asks for your API key,
47
+ > and wires up the Claude Desktop config automatically. The manual steps below are the
48
+ > equivalent for any platform.
49
+
50
+ ### Easiest: run via `npx` (once published to npm)
51
+
52
+ If the package is published to npm (see [docs/PUBLISHING.md](docs/PUBLISHING.md)), you
53
+ don't need to clone or build anything — point Claude Desktop straight at it:
54
+
55
+ ```json
56
+ {
57
+ "mcpServers": {
58
+ "chatgpt-ads": {
59
+ "command": "npx",
60
+ "args": ["-y", "chatgpt-ads-mcp"],
61
+ "env": { "OPENAI_ADS_API_KEY": "sk-ads-..." }
62
+ }
63
+ }
64
+ }
65
+ ```
66
+
67
+ `npx` always fetches the latest published version, so you get fixes automatically (no
68
+ re-downloading). The manual build steps below remain available if you prefer running
69
+ from source.
70
+
71
+ ### 1. Get your API key
72
+
73
+ 1. Go to **ChatGPT Ads Manager → Settings → API keys**.
74
+ 2. Create a key. Each key is tied to **one ad account**.
75
+ 3. Copy it somewhere safe. You'll paste it into the config below — **never** put it
76
+ in a shared file or commit it to GitHub.
77
+
78
+ ### 2. Install Node.js
79
+
80
+ If you don't already have it, install **Node.js 18 or newer** from
81
+ [nodejs.org](https://nodejs.org). (This project is tested on Node 22.)
82
+
83
+ ### 3. Download and build this server
84
+
85
+ In a terminal:
86
+
87
+ ```bash
88
+ git clone https://github.com/codynguyen18/chatgpt-ads-mcp.git
89
+ cd chatgpt-ads-mcp
90
+ npm install
91
+ npm run build
92
+ ```
93
+
94
+ This creates a `dist/` folder containing the runnable server. Note the full path —
95
+ run `pwd` and copy it; you'll need it in the next step.
96
+
97
+ ### 4. Connect it to your AI assistant
98
+
99
+ **Claude Desktop** — open its config file:
100
+
101
+ - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
102
+ - Windows: `%APPDATA%\Claude\claude_desktop_config.json`
103
+
104
+ Add this (replace the path and the key with your own):
105
+
106
+ ```json
107
+ {
108
+ "mcpServers": {
109
+ "chatgpt-ads": {
110
+ "command": "node",
111
+ "args": ["/full/path/to/chatgpt-ads-mcp/dist/index.js"],
112
+ "env": {
113
+ "OPENAI_ADS_API_KEY": "paste-your-key-here"
114
+ }
115
+ }
116
+ }
117
+ }
118
+ ```
119
+
120
+ Restart Claude Desktop. You should see the ChatGPT Ads tools appear.
121
+
122
+ **Claude Code** — register it from the project folder:
123
+
124
+ ```bash
125
+ claude mcp add chatgpt-ads --env OPENAI_ADS_API_KEY=paste-your-key-here -- node /full/path/to/chatgpt-ads-mcp/dist/index.js
126
+ ```
127
+
128
+ ---
129
+
130
+ ## Multiple ad accounts
131
+
132
+ One server can manage several ad accounts. Because each API key is tied to one ad
133
+ account, you give each account a **name** and its own key, then tell the assistant
134
+ which one to use ("…in the client-a account").
135
+
136
+ Set `OPENAI_ADS_ACCOUNTS` to a JSON object of `name: key`, and optionally
137
+ `OPENAI_ADS_DEFAULT_ACCOUNT` for the one to use when you don't say:
138
+
139
+ ```json
140
+ {
141
+ "mcpServers": {
142
+ "chatgpt-ads": {
143
+ "command": "node",
144
+ "args": ["/full/path/to/chatgpt-ads-mcp/dist/index.js"],
145
+ "env": {
146
+ "OPENAI_ADS_ACCOUNTS": "{\"main\":\"sk-ads-aaa\",\"client-a\":\"sk-ads-bbb\",\"client-b\":\"sk-ads-ccc\"}",
147
+ "OPENAI_ADS_DEFAULT_ACCOUNT": "main"
148
+ }
149
+ }
150
+ }
151
+ }
152
+ ```
153
+
154
+ > The value of `OPENAI_ADS_ACCOUNTS` is JSON **inside a string**, so the inner
155
+ > quotes are escaped with `\"` — copy the shape above and swap in your names/keys.
156
+
157
+ Then in conversation:
158
+
159
+ - *"List campaigns in the **client-a** account."*
160
+ - *"Pause everything in **client-b**."*
161
+ - *"Which ad accounts do you have access to?"* → uses `list_accounts`.
162
+
163
+ If you don't name an account, the assistant uses your default (or, if you only
164
+ configured one, that one). If several are configured with no default, it will ask
165
+ you to pick. A single `OPENAI_ADS_API_KEY` still works and is registered as the
166
+ account named `default`.
167
+
168
+ ---
169
+
170
+ ## Try it
171
+
172
+ Once connected, just talk to your assistant:
173
+
174
+ - *"List my active campaigns."*
175
+ - *"Create a paused campaign called 'Summer Launch' with a $500 lifetime budget targeting California and Texas."* (it uses `search_locations`, then `include_location_ids`)
176
+ - *"In that campaign, make an ad group with a $4 max bid."*
177
+ - *"Upload this image https://example.com/banner.png and use it in a new ad titled 'Try it free' linking to https://mysite.com."*
178
+ - *"Pause the ad group named 'Test'."* (it uses `find_ad_groups` to resolve the name, then pauses)
179
+ - *"Show me impressions, clicks, and spend by campaign for the last 7 days."*
180
+ - *"What currency is my ad account in?"* (uses `get_ad_account`)
181
+
182
+ The assistant picks the right tool and fills in the details.
183
+
184
+ ---
185
+
186
+ ## Good to know
187
+
188
+ - **Money is in "micros."** The API measures budgets and bids in *micros* of your
189
+ account currency, where **1,000,000 micros = 1.00** (so $25 = `25000000`). You can
190
+ just say "$25" in conversation and the assistant converts it — this is only
191
+ relevant if you read the raw numbers.
192
+ - **Archiving is permanent.** `archive_*` cannot be undone. Pause instead if you
193
+ only want to stop delivery temporarily.
194
+ - **Real spend.** Activating campaigns/ads can spend real money on a live account.
195
+ A safe habit: create things as `paused`, review in the dashboard, then activate.
196
+ - **One key per ad account.** Each ChatGPT Ads API key is scoped to a single ad
197
+ account. To manage several, see **Multiple ad accounts** below.
198
+
199
+ ---
200
+
201
+ ## Configuration
202
+
203
+ | Environment variable | Required | Description |
204
+ | --- | --- | --- |
205
+ | `OPENAI_ADS_API_KEY` | Yes | Your ChatGPT Ads API key (scoped to one ad account). |
206
+ | `OPENAI_ADS_BASE_URL` | No | Override the API base URL. Defaults to `https://api.ads.openai.com/v1`. |
207
+
208
+ See `.env.example` for a template.
209
+
210
+ ---
211
+
212
+ ## Troubleshooting
213
+
214
+ - **"Missing OPENAI_ADS_API_KEY"** — the key isn't reaching the server. Double-check
215
+ the `env` block in your config and restart the client.
216
+ - **401 / 403 errors** — the key is wrong, revoked, or for a different ad account.
217
+ - **404 errors** — usually the resource ID doesn't exist, or the endpoint isn't
218
+ available for your account yet.
219
+ - **Tools don't appear** — confirm the `args` path points to the built
220
+ `dist/index.js` (not `src/`), and that you ran `npm run build`.
221
+
222
+ ---
223
+
224
+ ## For developers
225
+
226
+ ```bash
227
+ npm install # install dependencies
228
+ npm run build # compile TypeScript to dist/
229
+ npm run dev # recompile on change
230
+ npm start # run the built server (expects OPENAI_ADS_API_KEY in the env)
231
+ npm run docs:tools # regenerate docs/TOOL_REFERENCE.md from the server
232
+ ```
233
+
234
+ Source layout:
235
+
236
+ - `src/index.ts` — entry point; loads accounts and starts the stdio server.
237
+ - `src/config.ts` — parses the account configuration from environment variables.
238
+ - `src/client.ts` — multi-account HTTP client over the Advertiser API (auth, query/body, upload, errors).
239
+ - `src/tools.ts` — the 29 MCP tool definitions and their handlers.
240
+
241
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for conventions and how to add a tool, and
242
+ [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the design.
243
+
244
+ ---
245
+
246
+ ## Not included (and why)
247
+
248
+ OpenAI's Ads platform has two measurement features that are deliberately **out of
249
+ scope** for this server:
250
+
251
+ - **JavaScript pixel** — browser-side tracking code you paste into your website. It
252
+ isn't a server API, so it has no place in an MCP server.
253
+ - **Conversions API** — a server-to-server feed for sending conversion events
254
+ (purchases, sign-ups). It's designed to fire automatically from your backend the
255
+ moment an event happens, in real time and at volume — not to be triggered by hand
256
+ through a chat assistant. That logic belongs in your application code. (The only
257
+ niche fits would be backfilling a test event or logging an offline conversion.)
258
+
259
+ If you do want a tool for sending the occasional offline/test conversion, it's a
260
+ small addition — open an issue.
261
+
262
+ ## Notes & disclaimer
263
+
264
+ The OpenAI Advertiser API is new (2026) and still evolving. This server is built
265
+ against the published reference and may need small updates as endpoints change.
266
+ Where the docs are sparse, the create/update tools accept an optional `extra`
267
+ field so the assistant can pass additional raw API fields without a code change.
268
+
269
+ This is an unofficial, community project and is not affiliated with or endorsed by
270
+ OpenAI.
271
+
272
+ ## License
273
+
274
+ MIT
package/dist/client.js ADDED
@@ -0,0 +1,158 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { basename } from "node:path";
3
+ const DEFAULT_BASE_URL = "https://api.ads.openai.com/v1";
4
+ /** Error thrown when the Ads API returns a non-2xx response. */
5
+ export class AdsApiError extends Error {
6
+ status;
7
+ body;
8
+ constructor(status, message, body) {
9
+ super(message);
10
+ this.name = "AdsApiError";
11
+ this.status = status;
12
+ this.body = body;
13
+ }
14
+ }
15
+ /**
16
+ * Thin client over the OpenAI Advertiser API.
17
+ *
18
+ * Holds one or more ad accounts (each identified by name and backed by its own
19
+ * API key) and selects the right key per request. Handles auth, query/body
20
+ * serialization, multipart upload, and error parsing.
21
+ */
22
+ export class AdsClient {
23
+ accounts;
24
+ explicitDefault;
25
+ baseUrl;
26
+ rootUrl;
27
+ constructor(options) {
28
+ this.accounts = new Map(Object.entries(options.accounts));
29
+ if (this.accounts.size === 0) {
30
+ throw new Error("No ad accounts configured.");
31
+ }
32
+ if (options.defaultAccount && !this.accounts.has(options.defaultAccount)) {
33
+ throw new Error(`Default account '${options.defaultAccount}' is not one of the configured accounts: ` +
34
+ `${this.accountNames().join(", ")}.`);
35
+ }
36
+ this.explicitDefault = options.defaultAccount;
37
+ this.baseUrl = (options.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, "");
38
+ // Geo lookup is addressed from the v1 root. With the default base this equals
39
+ // baseUrl; the replace only matters if a caller overrides baseUrl with a /ads suffix.
40
+ this.rootUrl = this.baseUrl.replace(/\/ads$/, "");
41
+ }
42
+ /** Names of all configured ad accounts. */
43
+ accountNames() {
44
+ return [...this.accounts.keys()];
45
+ }
46
+ /** The account used when none is specified, or undefined if it must be chosen explicitly. */
47
+ getDefaultAccount() {
48
+ if (this.explicitDefault)
49
+ return this.explicitDefault;
50
+ if (this.accounts.size === 1)
51
+ return this.accountNames()[0];
52
+ return undefined;
53
+ }
54
+ resolveKey(account) {
55
+ if (account) {
56
+ const key = this.accounts.get(account);
57
+ if (!key) {
58
+ throw new Error(`Unknown ad account '${account}'. Configured accounts: ${this.accountNames().join(", ")}.`);
59
+ }
60
+ return key;
61
+ }
62
+ const fallback = this.getDefaultAccount();
63
+ if (!fallback) {
64
+ throw new Error(`Multiple ad accounts are configured (${this.accountNames().join(", ")}). ` +
65
+ "Specify which one with the 'account' parameter, or set a default account.");
66
+ }
67
+ return this.accounts.get(fallback);
68
+ }
69
+ buildUrl(path, query, root = false) {
70
+ const url = new URL((root ? this.rootUrl : this.baseUrl) + path);
71
+ if (query) {
72
+ for (const [key, value] of Object.entries(query)) {
73
+ if (value === undefined || value === null)
74
+ continue;
75
+ if (Array.isArray(value)) {
76
+ // The Ads API expects repeated array syntax: key[]=a&key[]=b
77
+ for (const item of value) {
78
+ url.searchParams.append(`${key}[]`, typeof item === "object" ? JSON.stringify(item) : String(item));
79
+ }
80
+ }
81
+ else if (typeof value === "object") {
82
+ url.searchParams.set(key, JSON.stringify(value));
83
+ }
84
+ else {
85
+ url.searchParams.set(key, String(value));
86
+ }
87
+ }
88
+ }
89
+ return url.toString();
90
+ }
91
+ async parseResponse(res) {
92
+ const text = await res.text();
93
+ let data = text;
94
+ if (text) {
95
+ try {
96
+ data = JSON.parse(text);
97
+ }
98
+ catch {
99
+ // Leave as raw text if not JSON.
100
+ }
101
+ }
102
+ if (!res.ok) {
103
+ throw new AdsApiError(res.status, extractErrorMessage(data, res), data);
104
+ }
105
+ return data;
106
+ }
107
+ /** Issue a JSON request against the Ads API. */
108
+ async request(method, path, options = {}) {
109
+ const apiKey = this.resolveKey(options.account);
110
+ const url = this.buildUrl(path, options.query, options.root);
111
+ const headers = {
112
+ Authorization: `Bearer ${apiKey}`,
113
+ Accept: "application/json",
114
+ };
115
+ let body;
116
+ if (options.body !== undefined) {
117
+ headers["Content-Type"] = "application/json";
118
+ body = JSON.stringify(options.body);
119
+ }
120
+ const res = await fetch(url, { method, headers, body });
121
+ return (await this.parseResponse(res));
122
+ }
123
+ /** Upload a creative image from a publicly reachable URL. */
124
+ async uploadCreativeFromUrl(imageUrl, account) {
125
+ return this.request("POST", "/upload", { body: { image_url: imageUrl }, account });
126
+ }
127
+ /** Upload a creative image from a local file path (multipart/form-data). */
128
+ async uploadCreativeFromFile(filePath, account) {
129
+ const apiKey = this.resolveKey(account);
130
+ const buffer = await readFile(filePath);
131
+ const form = new FormData();
132
+ // Node 18+ provides global FormData/Blob.
133
+ form.append("file", new Blob([buffer]), basename(filePath));
134
+ const res = await fetch(this.buildUrl("/upload"), {
135
+ method: "POST",
136
+ headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
137
+ body: form,
138
+ });
139
+ return this.parseResponse(res);
140
+ }
141
+ }
142
+ function extractErrorMessage(data, res) {
143
+ if (data && typeof data === "object") {
144
+ const err = data.error;
145
+ if (err && typeof err === "object") {
146
+ const message = err.message;
147
+ if (typeof message === "string")
148
+ return `${res.status} ${res.statusText}: ${message}`;
149
+ }
150
+ if (typeof data.message === "string") {
151
+ return `${res.status} ${res.statusText}: ${data.message}`;
152
+ }
153
+ }
154
+ if (typeof data === "string" && data.trim()) {
155
+ return `${res.status} ${res.statusText}: ${data.trim()}`;
156
+ }
157
+ return `${res.status} ${res.statusText}`;
158
+ }
package/dist/config.js ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Build the accounts configuration from environment variables:
3
+ *
4
+ * OPENAI_ADS_API_KEY Single account (registered under the name "default").
5
+ * OPENAI_ADS_ACCOUNTS Multiple accounts as JSON. Either an object
6
+ * { "main": "sk-...", "client-a": "sk-..." }
7
+ * or an array
8
+ * [ { "name": "main", "api_key": "sk-...", "default": true }, ... ].
9
+ * OPENAI_ADS_DEFAULT_ACCOUNT Name of the account to use when `account` is omitted.
10
+ *
11
+ * The single and multiple forms can be combined; OPENAI_ADS_API_KEY becomes the
12
+ * account named "default".
13
+ */
14
+ export function loadAccountsConfig(env = process.env) {
15
+ const accounts = {};
16
+ let defaultFromArray;
17
+ const single = env.OPENAI_ADS_API_KEY?.trim();
18
+ if (single) {
19
+ accounts["default"] = single;
20
+ }
21
+ const multi = env.OPENAI_ADS_ACCOUNTS?.trim();
22
+ if (multi) {
23
+ let parsed;
24
+ try {
25
+ parsed = JSON.parse(multi);
26
+ }
27
+ catch {
28
+ throw new Error('OPENAI_ADS_ACCOUNTS must be valid JSON, e.g. {"main":"sk-...","client-a":"sk-..."}');
29
+ }
30
+ if (Array.isArray(parsed)) {
31
+ for (const entry of parsed) {
32
+ if (!entry || typeof entry !== "object") {
33
+ throw new Error("Each entry in OPENAI_ADS_ACCOUNTS must be an object.");
34
+ }
35
+ const name = entry.name;
36
+ const key = entry.api_key ?? entry.key;
37
+ if (typeof name !== "string" || typeof key !== "string") {
38
+ throw new Error("Each account in OPENAI_ADS_ACCOUNTS needs a 'name' and an 'api_key'.");
39
+ }
40
+ accounts[name] = key;
41
+ if (entry.default === true)
42
+ defaultFromArray = name;
43
+ }
44
+ }
45
+ else if (parsed && typeof parsed === "object") {
46
+ for (const [name, key] of Object.entries(parsed)) {
47
+ if (typeof key !== "string") {
48
+ throw new Error(`Account '${name}' in OPENAI_ADS_ACCOUNTS must map to an API key string.`);
49
+ }
50
+ accounts[name] = key;
51
+ }
52
+ }
53
+ else {
54
+ throw new Error("OPENAI_ADS_ACCOUNTS must be a JSON object or array of accounts.");
55
+ }
56
+ }
57
+ const explicitDefault = env.OPENAI_ADS_DEFAULT_ACCOUNT?.trim();
58
+ const defaultAccount = explicitDefault || defaultFromArray || (single ? "default" : undefined);
59
+ if (defaultAccount && !accounts[defaultAccount]) {
60
+ throw new Error(`OPENAI_ADS_DEFAULT_ACCOUNT '${defaultAccount}' is not one of the configured accounts: ` +
61
+ `${Object.keys(accounts).join(", ") || "(none)"}.`);
62
+ }
63
+ return { accounts, defaultAccount };
64
+ }
package/dist/index.js ADDED
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { AdsClient } from "./client.js";
5
+ import { loadAccountsConfig } from "./config.js";
6
+ import { registerTools } from "./tools.js";
7
+ let config;
8
+ try {
9
+ config = loadAccountsConfig();
10
+ }
11
+ catch (err) {
12
+ console.error(`Configuration error: ${err instanceof Error ? err.message : String(err)}`);
13
+ process.exit(1);
14
+ }
15
+ if (Object.keys(config.accounts).length === 0) {
16
+ console.error("No ad accounts configured. Set OPENAI_ADS_API_KEY for a single account, " +
17
+ 'or OPENAI_ADS_ACCOUNTS (JSON, e.g. {"main":"sk-...","client-a":"sk-..."}) for several. ' +
18
+ "Get keys from Ads Manager -> Settings -> API keys (one key per ad account).");
19
+ process.exit(1);
20
+ }
21
+ const client = new AdsClient({
22
+ accounts: config.accounts,
23
+ defaultAccount: config.defaultAccount,
24
+ baseUrl: process.env.OPENAI_ADS_BASE_URL,
25
+ });
26
+ const server = new McpServer({
27
+ name: "chatgpt-ads-mcp",
28
+ version: "0.1.0",
29
+ });
30
+ registerTools(server, client);
31
+ async function main() {
32
+ const transport = new StdioServerTransport();
33
+ await server.connect(transport);
34
+ // Logs must go to stderr; stdout is reserved for the MCP protocol.
35
+ const names = client.accountNames();
36
+ console.error(`chatgpt-ads-mcp server running on stdio (${names.length} ad account${names.length === 1 ? "" : "s"}: ${names.join(", ")})`);
37
+ }
38
+ main().catch((err) => {
39
+ console.error("Fatal error starting chatgpt-ads-mcp:", err);
40
+ process.exit(1);
41
+ });
package/dist/tools.js ADDED
@@ -0,0 +1,645 @@
1
+ import { z } from "zod";
2
+ function jsonResult(data) {
3
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
4
+ }
5
+ function errorResult(err) {
6
+ const message = err instanceof Error ? err.message : String(err);
7
+ return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
8
+ }
9
+ /** Wrap an async handler so thrown errors become tool errors instead of crashes. */
10
+ function safe(fn) {
11
+ return async (args) => {
12
+ try {
13
+ return await fn(args);
14
+ }
15
+ catch (err) {
16
+ return errorResult(err);
17
+ }
18
+ };
19
+ }
20
+ /** Selects which configured ad account a tool call targets. Present on every tool. */
21
+ const accountShape = {
22
+ account: z
23
+ .string()
24
+ .optional()
25
+ .describe("Which ad account to use. Omit if only one account is configured (or to use the default); " +
26
+ "otherwise the configured account name (run list_accounts to see them)."),
27
+ };
28
+ const paginationShape = {
29
+ limit: z
30
+ .number()
31
+ .int()
32
+ .min(1)
33
+ .max(500)
34
+ .optional()
35
+ .describe("Max results per page (1-500, default 20)."),
36
+ after: z.string().optional().describe("Pagination cursor: return results after this ID."),
37
+ before: z.string().optional().describe("Pagination cursor: return results before this ID."),
38
+ order: z.enum(["asc", "desc"]).optional().describe("Sort by creation time, ascending or descending."),
39
+ };
40
+ const MICROS_NOTE = "Amounts are in micros of your ad account currency (1,000,000 micros = 1.00, so $25 = 25000000).";
41
+ export function registerTools(server, client) {
42
+ // ---------------------------------------------------------------------------
43
+ // Accounts
44
+ // ---------------------------------------------------------------------------
45
+ server.registerTool("list_accounts", {
46
+ title: "List configured ad accounts",
47
+ description: "Show which ad accounts this server is configured with and which is the default. " +
48
+ "Pass the returned name as the 'account' parameter on other tools.",
49
+ inputSchema: {},
50
+ }, safe(async () => jsonResult({
51
+ accounts: client.accountNames(),
52
+ default: client.getDefaultAccount() ?? null,
53
+ })));
54
+ server.registerTool("get_ad_account", {
55
+ title: "Get ad account details",
56
+ description: "Retrieve metadata for the ad account: display name, currency, timezone, and URLs. " +
57
+ "Budgets and bids are in micros of this account's currency.",
58
+ inputSchema: { ...accountShape },
59
+ }, safe(async (args) => jsonResult(await client.request("GET", "/ad_account", { account: args.account }))));
60
+ // ---------------------------------------------------------------------------
61
+ // Campaigns
62
+ // ---------------------------------------------------------------------------
63
+ registerList(server, client, {
64
+ resource: "campaigns",
65
+ path: "/campaigns",
66
+ description: "List campaigns in the ad account.",
67
+ });
68
+ registerGet(server, client, {
69
+ resource: "campaign",
70
+ path: "/campaigns",
71
+ idParam: "campaign_id",
72
+ });
73
+ server.registerTool("create_campaign", {
74
+ title: "Create campaign",
75
+ description: `Create a new ChatGPT Ads campaign. ${MICROS_NOTE}`,
76
+ inputSchema: {
77
+ ...accountShape,
78
+ name: z.string().min(3).max(1000).describe("Campaign name (3-1000 characters)."),
79
+ status: z.enum(["active", "paused"]).describe("Initial status."),
80
+ daily_budget_micros: z
81
+ .number()
82
+ .int()
83
+ .min(1)
84
+ .optional()
85
+ .describe(`Daily budget cap in micros. ${MICROS_NOTE} Provide this and/or lifetime_budget_micros.`),
86
+ lifetime_budget_micros: z
87
+ .number()
88
+ .int()
89
+ .min(1)
90
+ .optional()
91
+ .describe(`Lifetime budget cap in micros. ${MICROS_NOTE} Provide this and/or daily_budget_micros.`),
92
+ bidding_type: z.string().optional().describe("How bids are charged, e.g. 'clicks'."),
93
+ description: z.string().optional().describe("Optional internal description."),
94
+ start_time: z.number().int().optional().describe("Unix timestamp (seconds) for campaign start."),
95
+ end_time: z.number().int().optional().describe("Unix timestamp (seconds) for campaign end."),
96
+ target_countries: z
97
+ .array(z.string())
98
+ .optional()
99
+ .describe('ISO country codes to target, e.g. ["US"]. Builds targeting.locations.countries.'),
100
+ target_location_ids: z
101
+ .array(z.string())
102
+ .optional()
103
+ .describe("Location IDs from search_locations (countries/regions/DMAs) to target. Builds targeting.locations.include."),
104
+ targeting: z
105
+ .record(z.any())
106
+ .optional()
107
+ .describe('Optional raw targeting object for full control, e.g. { "locations": { "countries": ["US"] } }.'),
108
+ extra: z
109
+ .record(z.any())
110
+ .optional()
111
+ .describe("Advanced: extra raw fields merged into the request body."),
112
+ },
113
+ }, safe(async (args) => {
114
+ const budget = {};
115
+ if (args.daily_budget_micros !== undefined)
116
+ budget.daily_spend_limit_micros = args.daily_budget_micros;
117
+ if (args.lifetime_budget_micros !== undefined)
118
+ budget.lifetime_spend_limit_micros = args.lifetime_budget_micros;
119
+ if (Object.keys(budget).length === 0) {
120
+ throw new Error("Provide a budget: daily_budget_micros and/or lifetime_budget_micros.");
121
+ }
122
+ const body = {
123
+ name: args.name,
124
+ status: args.status,
125
+ budget,
126
+ };
127
+ if (args.bidding_type !== undefined)
128
+ body.bidding_type = args.bidding_type;
129
+ if (args.description !== undefined)
130
+ body.description = args.description;
131
+ if (args.start_time !== undefined)
132
+ body.start_time = args.start_time;
133
+ if (args.end_time !== undefined)
134
+ body.end_time = args.end_time;
135
+ const targeting = buildTargeting(args.targeting, args.target_countries, args.target_location_ids);
136
+ if (targeting)
137
+ body.targeting = targeting;
138
+ Object.assign(body, args.extra ?? {});
139
+ return jsonResult(await client.request("POST", "/campaigns", { body, account: args.account }));
140
+ }));
141
+ server.registerTool("update_campaign", {
142
+ title: "Update campaign",
143
+ description: `Update fields on an existing campaign. Only include the fields you want to change. ${MICROS_NOTE}`,
144
+ inputSchema: {
145
+ ...accountShape,
146
+ campaign_id: z.string().describe("ID of the campaign to update."),
147
+ name: z.string().min(3).max(1000).optional(),
148
+ status: z.enum(["active", "paused"]).optional(),
149
+ description: z.string().nullable().optional().describe("Set to null to clear."),
150
+ daily_budget_micros: z.number().int().min(1).optional().describe(`Daily budget cap in micros. ${MICROS_NOTE}`),
151
+ lifetime_budget_micros: z.number().int().min(1).optional().describe(`Lifetime budget cap in micros. ${MICROS_NOTE}`),
152
+ bidding_type: z.string().optional().describe("How bids are charged, e.g. 'clicks'."),
153
+ start_time: z.number().int().optional(),
154
+ end_time: z.number().int().optional(),
155
+ target_countries: z
156
+ .array(z.string())
157
+ .optional()
158
+ .describe('ISO country codes to target, e.g. ["US"]. Sets targeting.locations.countries.'),
159
+ target_location_ids: z
160
+ .array(z.string())
161
+ .optional()
162
+ .describe("Location IDs from search_locations to target. Sets targeting.locations.include."),
163
+ targeting: z.record(z.any()).optional(),
164
+ extra: z.record(z.any()).optional().describe("Advanced: extra raw fields merged into the request body."),
165
+ },
166
+ }, safe(async (args) => {
167
+ const body = {};
168
+ if (args.name !== undefined)
169
+ body.name = args.name;
170
+ if (args.status !== undefined)
171
+ body.status = args.status;
172
+ if (args.description !== undefined)
173
+ body.description = args.description;
174
+ if (args.daily_budget_micros !== undefined || args.lifetime_budget_micros !== undefined) {
175
+ const budget = {};
176
+ if (args.daily_budget_micros !== undefined)
177
+ budget.daily_spend_limit_micros = args.daily_budget_micros;
178
+ if (args.lifetime_budget_micros !== undefined)
179
+ budget.lifetime_spend_limit_micros = args.lifetime_budget_micros;
180
+ body.budget = budget;
181
+ }
182
+ if (args.bidding_type !== undefined)
183
+ body.bidding_type = args.bidding_type;
184
+ if (args.start_time !== undefined)
185
+ body.start_time = args.start_time;
186
+ if (args.end_time !== undefined)
187
+ body.end_time = args.end_time;
188
+ const targeting = buildTargeting(args.targeting, args.target_countries, args.target_location_ids);
189
+ if (targeting)
190
+ body.targeting = targeting;
191
+ Object.assign(body, args.extra ?? {});
192
+ return jsonResult(await client.request("POST", `/campaigns/${encodeURIComponent(args.campaign_id)}`, {
193
+ body,
194
+ account: args.account,
195
+ }));
196
+ }));
197
+ registerLifecycle(server, client, {
198
+ resource: "campaign",
199
+ path: "/campaigns",
200
+ idParam: "campaign_id",
201
+ });
202
+ // ---------------------------------------------------------------------------
203
+ // Ad groups
204
+ // ---------------------------------------------------------------------------
205
+ registerList(server, client, {
206
+ resource: "ad_groups",
207
+ path: "/ad_groups",
208
+ description: "List ad groups. Optionally filter by campaign.",
209
+ extraShape: {
210
+ campaign_id: z.string().optional().describe("Filter to ad groups within this campaign."),
211
+ },
212
+ });
213
+ registerGet(server, client, {
214
+ resource: "ad_group",
215
+ path: "/ad_groups",
216
+ idParam: "ad_group_id",
217
+ });
218
+ server.registerTool("create_ad_group", {
219
+ title: "Create ad group",
220
+ description: `Create an ad group inside a campaign. ${MICROS_NOTE}`,
221
+ inputSchema: {
222
+ ...accountShape,
223
+ campaign_id: z.string().describe("Parent campaign ID."),
224
+ name: z.string().min(3).max(1000).describe("Ad group name (3-1000 characters)."),
225
+ status: z.enum(["active", "paused"]).describe("Initial status."),
226
+ max_bid_micros: z
227
+ .number()
228
+ .int()
229
+ .min(1)
230
+ .max(100_000_000)
231
+ .describe(`Maximum bid in micros (1 - 100,000,000). ${MICROS_NOTE}`),
232
+ billing_event_type: z
233
+ .enum(["impression"])
234
+ .default("impression")
235
+ .describe("How you are billed. Currently only 'impression'."),
236
+ description: z.string().optional(),
237
+ context_hints: z
238
+ .array(z.string())
239
+ .optional()
240
+ .describe("Free-form audience or placement hints."),
241
+ extra: z.record(z.any()).optional().describe("Advanced: extra raw fields merged into the request body."),
242
+ },
243
+ }, safe(async (args) => {
244
+ const body = {
245
+ campaign_id: args.campaign_id,
246
+ name: args.name,
247
+ status: args.status,
248
+ bidding_config: {
249
+ billing_event_type: args.billing_event_type ?? "impression",
250
+ max_bid_micros: args.max_bid_micros,
251
+ },
252
+ };
253
+ if (args.description !== undefined)
254
+ body.description = args.description;
255
+ if (args.context_hints !== undefined)
256
+ body.context_hints = args.context_hints;
257
+ Object.assign(body, args.extra ?? {});
258
+ return jsonResult(await client.request("POST", "/ad_groups", { body, account: args.account }));
259
+ }));
260
+ server.registerTool("update_ad_group", {
261
+ title: "Update ad group",
262
+ description: `Update fields on an existing ad group. Only include the fields you want to change. ${MICROS_NOTE}`,
263
+ inputSchema: {
264
+ ...accountShape,
265
+ ad_group_id: z.string().describe("ID of the ad group to update."),
266
+ name: z.string().min(3).max(1000).optional(),
267
+ status: z.enum(["active", "paused"]).optional(),
268
+ max_bid_micros: z.number().int().min(1).max(100_000_000).optional(),
269
+ billing_event_type: z.enum(["impression"]).optional(),
270
+ description: z.string().nullable().optional().describe("Set to null to clear."),
271
+ context_hints: z.array(z.string()).optional(),
272
+ extra: z.record(z.any()).optional().describe("Advanced: extra raw fields merged into the request body."),
273
+ },
274
+ }, safe(async (args) => {
275
+ const body = {};
276
+ if (args.name !== undefined)
277
+ body.name = args.name;
278
+ if (args.status !== undefined)
279
+ body.status = args.status;
280
+ if (args.description !== undefined)
281
+ body.description = args.description;
282
+ if (args.context_hints !== undefined)
283
+ body.context_hints = args.context_hints;
284
+ if (args.max_bid_micros !== undefined || args.billing_event_type !== undefined) {
285
+ const bidding = {};
286
+ if (args.billing_event_type !== undefined)
287
+ bidding.billing_event_type = args.billing_event_type;
288
+ if (args.max_bid_micros !== undefined)
289
+ bidding.max_bid_micros = args.max_bid_micros;
290
+ body.bidding_config = bidding;
291
+ }
292
+ Object.assign(body, args.extra ?? {});
293
+ return jsonResult(await client.request("POST", `/ad_groups/${encodeURIComponent(args.ad_group_id)}`, {
294
+ body,
295
+ account: args.account,
296
+ }));
297
+ }));
298
+ registerLifecycle(server, client, {
299
+ resource: "ad_group",
300
+ path: "/ad_groups",
301
+ idParam: "ad_group_id",
302
+ });
303
+ // ---------------------------------------------------------------------------
304
+ // Ads
305
+ // ---------------------------------------------------------------------------
306
+ registerList(server, client, {
307
+ resource: "ads",
308
+ path: "/ads",
309
+ description: "List ads. Optionally filter by ad group or campaign.",
310
+ extraShape: {
311
+ ad_group_id: z.string().optional().describe("Filter to ads within this ad group."),
312
+ campaign_id: z.string().optional().describe("Filter to ads within this campaign."),
313
+ },
314
+ });
315
+ registerGet(server, client, {
316
+ resource: "ad",
317
+ path: "/ads",
318
+ idParam: "ad_id",
319
+ });
320
+ server.registerTool("create_ad", {
321
+ title: "Create ad",
322
+ description: "Create an ad inside an ad group. The creative is a 'chat_card' with a short title, body, destination URL, and an image (upload it first with upload_creative to get a file_id).",
323
+ inputSchema: {
324
+ ...accountShape,
325
+ ad_group_id: z.string().describe("Parent ad group ID."),
326
+ name: z.string().min(3).max(1000).describe("Internal ad name (3-1000 characters)."),
327
+ title: z.string().min(3).max(50).describe("Creative title shown to users (3-50 characters)."),
328
+ body: z.string().max(100).describe("Creative body text (max 100 characters)."),
329
+ target_url: z.string().url().describe("Destination URL the ad links to."),
330
+ file_id: z.string().describe("Image file_id returned by upload_creative."),
331
+ status: z.enum(["active", "paused"]).describe("Initial status."),
332
+ creative_type: z.enum(["chat_card"]).default("chat_card").describe("Creative type. Currently only 'chat_card'."),
333
+ extra: z.record(z.any()).optional().describe("Advanced: extra raw fields merged into the request body."),
334
+ },
335
+ }, safe(async (args) => {
336
+ const body = {
337
+ ad_group_id: args.ad_group_id,
338
+ name: args.name,
339
+ status: args.status,
340
+ creative: {
341
+ type: args.creative_type ?? "chat_card",
342
+ title: args.title,
343
+ body: args.body,
344
+ target_url: args.target_url,
345
+ file_id: args.file_id,
346
+ },
347
+ };
348
+ Object.assign(body, args.extra ?? {});
349
+ return jsonResult(await client.request("POST", "/ads", { body, account: args.account }));
350
+ }));
351
+ server.registerTool("update_ad", {
352
+ title: "Update ad",
353
+ description: "Update an ad. Only include the fields you want to change. If you change any creative field, include all creative fields (title, body, target_url, file_id).",
354
+ inputSchema: {
355
+ ...accountShape,
356
+ ad_id: z.string().describe("ID of the ad to update."),
357
+ name: z.string().min(3).max(1000).optional(),
358
+ status: z.enum(["active", "paused"]).optional(),
359
+ title: z.string().min(3).max(50).optional(),
360
+ body: z.string().max(100).optional(),
361
+ target_url: z.string().url().optional(),
362
+ file_id: z.string().optional(),
363
+ creative_type: z.enum(["chat_card"]).optional(),
364
+ extra: z.record(z.any()).optional().describe("Advanced: extra raw fields merged into the request body."),
365
+ },
366
+ }, safe(async (args) => {
367
+ const body = {};
368
+ if (args.name !== undefined)
369
+ body.name = args.name;
370
+ if (args.status !== undefined)
371
+ body.status = args.status;
372
+ const creativeKeys = ["title", "body", "target_url", "file_id", "creative_type"];
373
+ if (creativeKeys.some((k) => args[k] !== undefined)) {
374
+ const creative = { type: args.creative_type ?? "chat_card" };
375
+ if (args.title !== undefined)
376
+ creative.title = args.title;
377
+ if (args.body !== undefined)
378
+ creative.body = args.body;
379
+ if (args.target_url !== undefined)
380
+ creative.target_url = args.target_url;
381
+ if (args.file_id !== undefined)
382
+ creative.file_id = args.file_id;
383
+ body.creative = creative;
384
+ }
385
+ Object.assign(body, args.extra ?? {});
386
+ return jsonResult(await client.request("POST", `/ads/${encodeURIComponent(args.ad_id)}`, {
387
+ body,
388
+ account: args.account,
389
+ }));
390
+ }));
391
+ registerLifecycle(server, client, {
392
+ resource: "ad",
393
+ path: "/ads",
394
+ idParam: "ad_id",
395
+ });
396
+ // ---------------------------------------------------------------------------
397
+ // Targeting
398
+ // ---------------------------------------------------------------------------
399
+ server.registerTool("search_locations", {
400
+ title: "Search targeting locations",
401
+ description: "Look up geographic targeting locations (country, region, or DMA) by name and get their IDs. " +
402
+ "For country targeting, use the ISO code directly via target_countries; use this for region/DMA ids in a raw targeting object.",
403
+ inputSchema: {
404
+ ...accountShape,
405
+ q: z.string().describe("Location name to search for, e.g. 'California' or 'United Kingdom'."),
406
+ limit: z.number().int().min(1).max(100).optional().describe("Max results to return."),
407
+ },
408
+ }, safe(async (args) => jsonResult(await client.request("GET", "/geo_lookup/search", {
409
+ query: { q: args.q, limit: args.limit },
410
+ account: args.account,
411
+ root: true,
412
+ }))));
413
+ // ---------------------------------------------------------------------------
414
+ // Find by name (lists across all pages and filters client-side)
415
+ // ---------------------------------------------------------------------------
416
+ registerFind(server, client, { resource: "campaigns", path: "/campaigns" });
417
+ registerFind(server, client, {
418
+ resource: "ad_groups",
419
+ path: "/ad_groups",
420
+ extraShape: {
421
+ campaign_id: z.string().optional().describe("Limit the search to this campaign."),
422
+ },
423
+ });
424
+ registerFind(server, client, {
425
+ resource: "ads",
426
+ path: "/ads",
427
+ extraShape: {
428
+ ad_group_id: z.string().optional().describe("Limit the search to this ad group."),
429
+ campaign_id: z.string().optional().describe("Limit the search to this campaign."),
430
+ },
431
+ });
432
+ // ---------------------------------------------------------------------------
433
+ // Creatives (file upload)
434
+ // ---------------------------------------------------------------------------
435
+ server.registerTool("upload_creative", {
436
+ title: "Upload creative image",
437
+ description: "Upload an image to use in an ad creative. Provide either image_url (a publicly reachable URL) or file_path (a local file). Returns a file_id to pass to create_ad.",
438
+ inputSchema: {
439
+ ...accountShape,
440
+ image_url: z.string().url().optional().describe("Publicly reachable image URL."),
441
+ file_path: z.string().optional().describe("Absolute path to a local image file (e.g. PNG)."),
442
+ },
443
+ }, safe(async (args) => {
444
+ if (!args.image_url && !args.file_path) {
445
+ throw new Error("Provide either image_url or file_path.");
446
+ }
447
+ const data = args.image_url
448
+ ? await client.uploadCreativeFromUrl(args.image_url, args.account)
449
+ : await client.uploadCreativeFromFile(args.file_path, args.account);
450
+ return jsonResult(data);
451
+ }));
452
+ // ---------------------------------------------------------------------------
453
+ // Insights / reporting
454
+ // ---------------------------------------------------------------------------
455
+ server.registerTool("get_insights", {
456
+ title: "Get insights / reporting",
457
+ description: "Retrieve performance metrics. Returns impressions, clicks, spend, ctr, cpc, cpm by default (override with fields). Choose a level: 'account' (whole ad account), or 'campaign' / 'ad_group' / 'ad' (provide the matching id). Daily breakdown by default; set time_granularity 'none' for totals.",
458
+ inputSchema: {
459
+ ...accountShape,
460
+ level: z.enum(["account", "campaign", "ad_group", "ad"]).describe("Aggregation level."),
461
+ id: z
462
+ .string()
463
+ .optional()
464
+ .describe("Required for campaign/ad_group/ad levels: the resource ID. Omit for 'account'."),
465
+ since: z.string().optional().describe("Start date, YYYY-MM-DD."),
466
+ until: z.string().optional().describe("End date, YYYY-MM-DD."),
467
+ time_granularity: z.enum(["daily", "none"]).optional().describe("Break results down daily, or return totals."),
468
+ fields: z
469
+ .array(z.string())
470
+ .optional()
471
+ .describe("Metrics to return. Defaults to ['impressions','clicks','spend','ctr','cpc','cpm']. The API returns only impressions unless fields are requested."),
472
+ limit: z.number().int().min(1).max(10_000).optional().describe("Results per page (1-10,000)."),
473
+ sort: z.string().optional().describe("Sort expression, e.g. 'spend desc'."),
474
+ filters: z.any().optional().describe("Advanced filter expression(s)."),
475
+ after: z.string().optional().describe("Pagination cursor."),
476
+ before: z.string().optional().describe("Pagination cursor."),
477
+ },
478
+ }, safe(async (args) => {
479
+ let path;
480
+ switch (args.level) {
481
+ case "account":
482
+ path = "/ad_account/insights";
483
+ break;
484
+ case "campaign":
485
+ path = `/campaigns/${encodeURIComponent(requireId(args.id, "campaign"))}/insights`;
486
+ break;
487
+ case "ad_group":
488
+ path = `/ad_groups/${encodeURIComponent(requireId(args.id, "ad_group"))}/insights`;
489
+ break;
490
+ case "ad":
491
+ path = `/ads/${encodeURIComponent(requireId(args.id, "ad"))}/insights`;
492
+ break;
493
+ default:
494
+ throw new Error(`Unknown level: ${args.level}`);
495
+ }
496
+ const query = {
497
+ fields: args.fields ?? ["impressions", "clicks", "spend", "ctr", "cpc", "cpm"],
498
+ };
499
+ if (args.since)
500
+ query.start_date = args.since;
501
+ if (args.until)
502
+ query.end_date = args.until;
503
+ if (args.time_granularity)
504
+ query.time_granularity = args.time_granularity;
505
+ if (args.limit)
506
+ query.limit = args.limit;
507
+ if (args.sort)
508
+ query.sort = args.sort;
509
+ if (args.filters)
510
+ query.filters = args.filters;
511
+ if (args.after)
512
+ query.after = args.after;
513
+ if (args.before)
514
+ query.before = args.before;
515
+ return jsonResult(await client.request("GET", path, { query, account: args.account }));
516
+ }));
517
+ }
518
+ // -----------------------------------------------------------------------------
519
+ // Generic helpers
520
+ // -----------------------------------------------------------------------------
521
+ function requireId(id, level) {
522
+ if (!id)
523
+ throw new Error(`'id' is required when level is '${level}'.`);
524
+ return id;
525
+ }
526
+ function registerList(server, client, opts) {
527
+ server.registerTool(`list_${opts.resource}`, {
528
+ title: `List ${opts.resource.replace(/_/g, " ")}`,
529
+ description: opts.description,
530
+ inputSchema: {
531
+ ...accountShape,
532
+ ...paginationShape,
533
+ fetch_all: z
534
+ .boolean()
535
+ .optional()
536
+ .describe("Fetch ALL pages and return the combined list (ignores limit/cursors)."),
537
+ ...(opts.extraShape ?? {}),
538
+ },
539
+ }, safe(async (args) => {
540
+ const { account, fetch_all, ...query } = args;
541
+ if (fetch_all) {
542
+ const items = await listAll(client, opts.path, query, account);
543
+ return jsonResult({ count: items.length, data: items });
544
+ }
545
+ return jsonResult(await client.request("GET", opts.path, { query, account }));
546
+ }));
547
+ }
548
+ /** Merge a raw targeting object with a convenience list of country codes. */
549
+ function buildTargeting(targeting, countries, locationIds) {
550
+ const hasCountries = !!(countries && countries.length);
551
+ const hasIds = !!(locationIds && locationIds.length);
552
+ if (!targeting && !hasCountries && !hasIds)
553
+ return undefined;
554
+ const result = targeting ? { ...targeting } : {};
555
+ if (hasCountries || hasIds) {
556
+ const locations = result.locations && typeof result.locations === "object"
557
+ ? { ...result.locations }
558
+ : {};
559
+ if (hasCountries)
560
+ locations.countries = countries;
561
+ if (hasIds)
562
+ locations.include = locationIds.map((id) => ({ id }));
563
+ result.locations = locations;
564
+ }
565
+ return result;
566
+ }
567
+ /** Follow pagination cursors and return every item across all pages. */
568
+ async function listAll(client, path, query, account) {
569
+ const items = [];
570
+ let after;
571
+ for (let page = 0; page < 100; page++) {
572
+ const res = await client.request("GET", path, {
573
+ query: { ...query, limit: 500, after },
574
+ account,
575
+ });
576
+ const data = Array.isArray(res?.data) ? res.data : Array.isArray(res) ? res : [];
577
+ items.push(...data);
578
+ if (res?.has_more !== true)
579
+ break;
580
+ after = res?.last_id ?? data[data.length - 1]?.id;
581
+ if (!after)
582
+ break;
583
+ }
584
+ return items;
585
+ }
586
+ function registerFind(server, client, opts) {
587
+ const label = opts.resource.replace(/_/g, " ");
588
+ server.registerTool(`find_${opts.resource}`, {
589
+ title: `Find ${label} by name`,
590
+ description: `Find ${label} whose name matches a search string (case-insensitive). Scans all pages and ` +
591
+ "filters client-side — useful when you know a name but not an ID.",
592
+ inputSchema: {
593
+ ...accountShape,
594
+ name: z.string().describe("Name (or part of a name) to match, case-insensitive."),
595
+ exact: z
596
+ .boolean()
597
+ .optional()
598
+ .describe("Require an exact (case-insensitive) match instead of a substring match."),
599
+ ...(opts.extraShape ?? {}),
600
+ },
601
+ }, safe(async (args) => {
602
+ const { account, name, exact, ...filter } = args;
603
+ const items = await listAll(client, opts.path, filter, account);
604
+ const needle = String(name).toLowerCase();
605
+ const matches = items.filter((it) => {
606
+ const itemName = String(it?.name ?? "").toLowerCase();
607
+ return exact ? itemName === needle : itemName.includes(needle);
608
+ });
609
+ return jsonResult({ count: matches.length, matches });
610
+ }));
611
+ }
612
+ function registerGet(server, client, opts) {
613
+ server.registerTool(`get_${opts.resource}`, {
614
+ title: `Get ${opts.resource.replace(/_/g, " ")}`,
615
+ description: `Retrieve a single ${opts.resource.replace(/_/g, " ")} by ID.`,
616
+ inputSchema: {
617
+ ...accountShape,
618
+ [opts.idParam]: z.string().describe(`ID of the ${opts.resource.replace(/_/g, " ")}.`),
619
+ },
620
+ }, safe(async (args) => jsonResult(await client.request("GET", `${opts.path}/${encodeURIComponent(args[opts.idParam])}`, {
621
+ account: args.account,
622
+ }))));
623
+ }
624
+ function registerLifecycle(server, client, opts) {
625
+ const label = opts.resource.replace(/_/g, " ");
626
+ const actions = [
627
+ { action: "activate", verb: "Activate (resume delivery for)" },
628
+ { action: "pause", verb: "Pause delivery for" },
629
+ {
630
+ action: "archive",
631
+ verb: "Archive",
632
+ warn: " WARNING: archiving is permanent and cannot be undone.",
633
+ },
634
+ ];
635
+ for (const { action, verb, warn } of actions) {
636
+ server.registerTool(`${action}_${opts.resource}`, {
637
+ title: `${action[0].toUpperCase()}${action.slice(1)} ${label}`,
638
+ description: `${verb} a ${label}.${warn ?? ""}`,
639
+ inputSchema: {
640
+ ...accountShape,
641
+ [opts.idParam]: z.string().describe(`ID of the ${label}.`),
642
+ },
643
+ }, safe(async (args) => jsonResult(await client.request("POST", `${opts.path}/${encodeURIComponent(args[opts.idParam])}/${action}`, { account: args.account }))));
644
+ }
645
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "chatgpt-ads-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for the OpenAI ChatGPT Ads (Advertiser) API — manage campaigns, ad groups, ads, creatives, and reporting from any MCP client.",
5
+ "type": "module",
6
+ "bin": {
7
+ "chatgpt-ads-mcp": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "scripts": {
13
+ "build": "tsc",
14
+ "start": "node dist/index.js",
15
+ "dev": "tsc --watch",
16
+ "docs:tools": "node scripts/gen-tool-reference.mjs",
17
+ "prepublishOnly": "npm run build"
18
+ },
19
+ "keywords": [
20
+ "mcp",
21
+ "model-context-protocol",
22
+ "openai",
23
+ "chatgpt",
24
+ "ads",
25
+ "advertising"
26
+ ],
27
+ "author": "codynguyen18",
28
+ "license": "MIT",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/codynguyen18/chatgpt-ads-mcp.git"
32
+ },
33
+ "homepage": "https://github.com/codynguyen18/chatgpt-ads-mcp#readme",
34
+ "bugs": {
35
+ "url": "https://github.com/codynguyen18/chatgpt-ads-mcp/issues"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "dependencies": {
41
+ "@modelcontextprotocol/sdk": "^1.12.0",
42
+ "zod": "^3.23.8"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^20.14.0",
46
+ "typescript": "^5.5.4"
47
+ },
48
+ "engines": {
49
+ "node": ">=18"
50
+ }
51
+ }