adloop 0.8.1__tar.gz → 0.10.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. {adloop-0.8.1 → adloop-0.10.0}/PKG-INFO +28 -31
  2. {adloop-0.8.1 → adloop-0.10.0}/README.md +26 -29
  3. {adloop-0.8.1 → adloop-0.10.0}/pyproject.toml +2 -2
  4. {adloop-0.8.1 → adloop-0.10.0}/src/adloop/__init__.py +10 -1
  5. {adloop-0.8.1 → adloop-0.10.0}/src/adloop/ads/forecast.py +27 -9
  6. {adloop-0.8.1 → adloop-0.10.0}/src/adloop/ads/gaql.py +38 -1
  7. {adloop-0.8.1 → adloop-0.10.0}/src/adloop/ads/write.py +106 -12
  8. {adloop-0.8.1 → adloop-0.10.0}/src/adloop/auth.py +110 -45
  9. {adloop-0.8.1 → adloop-0.10.0}/src/adloop/cli.py +27 -30
  10. {adloop-0.8.1 → adloop-0.10.0}/src/adloop/config.py +1 -1
  11. {adloop-0.8.1 → adloop-0.10.0}/src/adloop/rules/adloop.md +14 -0
  12. adloop-0.10.0/src/adloop/runtime.py +127 -0
  13. adloop-0.10.0/src/adloop/safety/audit.py +78 -0
  14. adloop-0.10.0/src/adloop/safety/preview.py +110 -0
  15. {adloop-0.8.1 → adloop-0.10.0}/src/adloop/server.py +94 -99
  16. adloop-0.8.1/src/adloop/bundled_credentials.json +0 -13
  17. adloop-0.8.1/src/adloop/safety/audit.py +0 -40
  18. adloop-0.8.1/src/adloop/safety/preview.py +0 -58
  19. {adloop-0.8.1 → adloop-0.10.0}/src/adloop/__main__.py +0 -0
  20. {adloop-0.8.1 → adloop-0.10.0}/src/adloop/_mcp_patches.py +0 -0
  21. {adloop-0.8.1 → adloop-0.10.0}/src/adloop/ads/__init__.py +0 -0
  22. {adloop-0.8.1 → adloop-0.10.0}/src/adloop/ads/client.py +0 -0
  23. {adloop-0.8.1 → adloop-0.10.0}/src/adloop/ads/currency.py +0 -0
  24. {adloop-0.8.1 → adloop-0.10.0}/src/adloop/ads/enums.py +0 -0
  25. {adloop-0.8.1 → adloop-0.10.0}/src/adloop/ads/pmax.py +0 -0
  26. {adloop-0.8.1 → adloop-0.10.0}/src/adloop/ads/read.py +0 -0
  27. {adloop-0.8.1 → adloop-0.10.0}/src/adloop/crossref.py +0 -0
  28. {adloop-0.8.1 → adloop-0.10.0}/src/adloop/diagnostics.py +0 -0
  29. {adloop-0.8.1 → adloop-0.10.0}/src/adloop/ga4/__init__.py +0 -0
  30. {adloop-0.8.1 → adloop-0.10.0}/src/adloop/ga4/client.py +0 -0
  31. {adloop-0.8.1 → adloop-0.10.0}/src/adloop/ga4/reports.py +0 -0
  32. {adloop-0.8.1 → adloop-0.10.0}/src/adloop/ga4/tracking.py +0 -0
  33. {adloop-0.8.1 → adloop-0.10.0}/src/adloop/rules/__init__.py +0 -0
  34. {adloop-0.8.1 → adloop-0.10.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
  35. {adloop-0.8.1 → adloop-0.10.0}/src/adloop/rules/commands/budget-plan.md +0 -0
  36. {adloop-0.8.1 → adloop-0.10.0}/src/adloop/rules/commands/create-ad.md +0 -0
  37. {adloop-0.8.1 → adloop-0.10.0}/src/adloop/rules/commands/create-campaign.md +0 -0
  38. {adloop-0.8.1 → adloop-0.10.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
  39. {adloop-0.8.1 → adloop-0.10.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
  40. {adloop-0.8.1 → adloop-0.10.0}/src/adloop/rules_install.py +0 -0
  41. {adloop-0.8.1 → adloop-0.10.0}/src/adloop/safety/__init__.py +0 -0
  42. {adloop-0.8.1 → adloop-0.10.0}/src/adloop/safety/guards.py +0 -0
  43. {adloop-0.8.1 → adloop-0.10.0}/src/adloop/tracking.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: adloop
3
- Version: 0.8.1
4
- Summary: Stop switching between Google Ads, GA4, and your code editor to figure out why conversions dropped.
3
+ Version: 0.10.0
4
+ Summary: The AI command center for Google Ads, GA4, and tracking code.
5
5
  Keywords: mcp,google-ads,google-analytics,ga4,cursor,marketing
6
6
  Author: Daniel Klose
7
7
  Author-email: Daniel Klose <info@daniel-klose.com>
@@ -26,7 +26,7 @@ Description-Content-Type: text/markdown
26
26
 
27
27
  # AdLoop
28
28
 
29
- **Stop switching between Google Ads, GA4, and your code editor to figure out why conversions dropped.**
29
+ **The AI command center for Google Ads, GA4, and tracking code.**
30
30
 
31
31
  [![PyPI](https://img.shields.io/pypi/v/adloop.svg)](https://pypi.org/project/adloop/)
32
32
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
@@ -196,16 +196,22 @@ AdLoop manages real ad spend, so safety is not optional.
196
196
 
197
197
  ## Setup
198
198
 
199
- ### Quick Start (Recommended)
199
+ > **AdLoop uses your own (free) Google Cloud project for OAuth.** The `adloop init` wizard walks you through it — a one-time setup of about 5 minutes, with no shared user caps and no waiting on anyone's verification review. AdLoop does not ship built-in OAuth credentials.
200
+ >
201
+ > **Prefer zero setup?** [**AdLoop Cloud**](https://getadloop.com) is the hosted version: connect Google in two clicks — no Cloud project, no developer token, EU-hosted.
202
+ >
203
+ > *(Upgrading from ≤0.9 with built-in credentials? Those sign-ins were retired in 0.10 — run `adloop init` once to switch to your own project.)*
200
204
 
201
- **Option A — Install from PyPI:**
205
+ ### Install
206
+
207
+ **From PyPI:**
202
208
 
203
209
  ```bash
204
210
  pip install adloop
205
211
  adloop init
206
212
  ```
207
213
 
208
- **Option B — Install from source:**
214
+ **From source:**
209
215
 
210
216
  ```bash
211
217
  git clone https://github.com/kLOsk/adloop.git
@@ -214,21 +220,17 @@ uv sync
214
220
  uv run adloop init
215
221
  ```
216
222
 
217
- The `adloop init` wizard walks you through everything. AdLoop ships with built-in Google OAuth credentials, so you don't need to create a Google Cloud project.
223
+ ### What `adloop init` does
218
224
 
219
- > **⚠️ Built-in credentials temporarily unavailable — Google verification pending.**
220
- > Google limits unverified OAuth apps to 100 users. AdLoop has reached that cap while awaiting Google's app verification. Until verification is complete, the built-in credentials will show a **"This app is blocked"** error for new users.
221
- >
222
- > **Workaround:** set up your own Google Cloud project using the [Advanced Setup](#advanced-setup-custom-google-cloud-project) instructions below (takes ~5 minutes). Your own project has no user cap and is the recommended setup path in the meantime.
225
+ The wizard walks you through:
223
226
 
224
- The wizard:
225
-
226
- 1. **Developer token** — from your Google Ads MCC ([API Center](https://ads.google.com/aw/apicenter))
227
- 2. **MCC Account ID** — your Manager Account ID (top bar in the MCC UI)
228
- 3. **OAuth sign-in** — opens a browser to sign in with Google (or prints a URL for headless servers)
229
- 4. **Auto-discovers your accounts** — finds your GA4 properties and Ads accounts automatically
230
- 5. **Safety defaults** — budget cap and dry-run preference
231
- 6. **Editor config snippets** — prints MCP configuration for both Cursor and Claude Code
227
+ 1. **Google Cloud setup** — creates a project, enables the three APIs, generates an OAuth client (see [Custom Google Cloud Project Setup](#custom-google-cloud-project-setup) below for the exact steps the wizard refers you to)
228
+ 2. **Developer token** — from your Google Ads MCC ([API Center](https://ads.google.com/aw/apicenter))
229
+ 3. **MCC Account ID** — your Manager Account ID (top bar in the MCC UI)
230
+ 4. **OAuth sign-in** — opens a browser to sign in with Google (or prints a URL for headless servers)
231
+ 5. **Auto-discovers your accounts** — finds your GA4 properties and Ads accounts automatically
232
+ 6. **Safety defaults** — budget cap and dry-run preference
233
+ 7. **Editor config snippets** — prints MCP configuration for both Cursor and Claude Code
232
234
 
233
235
  ### Requirements
234
236
 
@@ -238,7 +240,7 @@ The wizard:
238
240
 
239
241
  ### Google Ads Developer Token
240
242
 
241
- A developer token is **always required** — even when using AdLoop's built-in OAuth credentials. The built-in credentials handle Google sign-in; the developer token is a separate key that grants API access to your Google Ads data.
243
+ A developer token is **always required**. Your OAuth client handles Google sign-in; the developer token is a separate key that grants API access to your Google Ads data.
242
244
 
243
245
  1. **Create an MCC** (free) at [ads.google.com/home/tools/manager-accounts](https://ads.google.com/home/tools/manager-accounts/) if you don't have one. Link your regular Google Ads account to it.
244
246
  2. In the MCC, go to **Tools & Settings → API Center**
@@ -258,12 +260,9 @@ A developer token is **always required** — even when using AdLoop's built-in O
258
260
 
259
261
  Running on a server without a browser (VMs, Docker, SSH)? The wizard automatically detects this and falls back to a manual flow: it prints an authorization URL you can open on any device, then you paste the redirect URL back into the terminal.
260
262
 
261
- ### Advanced Setup (Custom Google Cloud Project)
262
-
263
- <details>
264
- <summary>Click to expand — only needed if you want to use your own GCP project instead of AdLoop's built-in credentials</summary>
263
+ ### Custom Google Cloud Project Setup
265
264
 
266
- When you run `adloop init`, choose "No" when asked about built-in credentials. The wizard will guide you through:
265
+ The wizard refers to these steps — do them in your browser before running `adloop init` (or while it waits at the OAuth prompt).
267
266
 
268
267
  #### Step 1 — Google Cloud Project
269
268
 
@@ -335,8 +334,6 @@ If you'd rather manage things by hand instead, copy `.claude/rules/adloop.md` an
335
334
 
336
335
  **Claude Desktop / claude.ai** has no programmatic rules location. Run `adloop install-rules` and it will print the rules content for you to paste into Project settings → Custom instructions on claude.ai.
337
336
 
338
- </details>
339
-
340
337
  ### Use It
341
338
 
342
339
  Ask your AI assistant things like:
@@ -358,7 +355,7 @@ All configuration lives in `~/.adloop/config.yaml`. See [`config.yaml.example`](
358
355
  | Section | Key | Default | Description |
359
356
  |---------|-----|---------|-------------|
360
357
  | `google` | `project_id` | *(empty)* | Google Cloud project ID (only needed with custom credentials) |
361
- | `google` | `credentials_path` | *(empty — uses built-in)* | Path to OAuth client JSON or service account key. Leave empty to use AdLoop's built-in credentials. |
358
+ | `google` | `credentials_path` | *(empty)* | Path to OAuth client JSON or service account key. Empty = `~/.adloop/credentials.json`, else Application Default Credentials. |
362
359
  | `google` | `token_path` | `~/.adloop/token.json` | Where to store the OAuth token (auto-created) |
363
360
  | `ga4` | `property_id` | — | Your GA4 property ID (auto-discovered by `adloop init`) |
364
361
  | `ads` | `developer_token` | — | Your Google Ads API developer token |
@@ -375,7 +372,7 @@ src/adloop/
375
372
  ├── __init__.py # Entry point — routes 'adloop init' to wizard, otherwise starts MCP server
376
373
  ├── server.py # FastMCP server — 43 tool registrations with safety annotations
377
374
  ├── config.py # Config loader (~/.adloop/config.yaml)
378
- ├── auth.py # OAuth 2.0 flow (bundled + custom credentials, headless fallback) + service accounts
375
+ ├── auth.py # OAuth 2.0 flow (user-supplied credentials, headless fallback) + service accounts
379
376
  ├── cli.py # Interactive 'adloop init' setup wizard
380
377
  ├── crossref.py # Cross-reference tools (GA4 + Ads combined analysis)
381
378
  ├── tracking.py # Tracking validation + code generation tools
@@ -411,7 +408,7 @@ What's been shipped and what's next:
411
408
  - ~~Claude Code support~~ ✓ — `CLAUDE.md`, `.mcp.json`, `.claude/rules/`, `.claude/commands/`, CLI wizard snippets
412
409
  - **Claude Desktop one-click install** — `adloop install claude-desktop` (and/or a `.dxt` extension bundle) that writes the AdLoop MCP entry into `claude_desktop_config.json` automatically, so Claude Desktop + Cowork users don't have to hand-edit JSON
413
410
  - ~~PyPI package~~ ✓ — `pip install adloop`
414
- - ~~Bundled OAuth credentials~~ ✓ — no Google Cloud project required, auto-discovery of GA4/Ads accounts (currently capped at 100 users pending Google verification — use [Advanced Setup](#advanced-setup-custom-google-cloud-project) in the meantime)
411
+ - **[AdLoop Cloud](https://getadloop.com)** — the hosted version: no Google Cloud project, no developer token, connect Google in two clicks (EU-hosted, GDPR-first)
415
412
  - ~~Headless server support~~ ✓ — manual URL copy-paste flow for servers without a browser
416
413
  - ~~Behavioral eval suites~~ ✓ — 28 prompt-and-expectation tests covering read, write, tracking, and planning workflows
417
414
  - **Community launch** — HN, Indie Hackers, r/cursor, Twitter
@@ -433,7 +430,7 @@ AdLoop runs entirely on your machine. No data is collected, stored, or transmitt
433
430
 
434
431
  <div align="center">
435
432
 
436
- **If AdLoop saves you from switching between Google Ads, GA4, and your code editor — [give it a star](https://github.com/kLOsk/adloop).**
433
+ **If AdLoop helps you run Google Ads, GA4, and tracking code from one place — [give it a star](https://github.com/kLOsk/adloop).**
437
434
 
438
435
  Made by [@kLOsk](https://github.com/kLOsk) | [Privacy Policy](PRIVACY.md)
439
436
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  # AdLoop
4
4
 
5
- **Stop switching between Google Ads, GA4, and your code editor to figure out why conversions dropped.**
5
+ **The AI command center for Google Ads, GA4, and tracking code.**
6
6
 
7
7
  [![PyPI](https://img.shields.io/pypi/v/adloop.svg)](https://pypi.org/project/adloop/)
8
8
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
@@ -172,16 +172,22 @@ AdLoop manages real ad spend, so safety is not optional.
172
172
 
173
173
  ## Setup
174
174
 
175
- ### Quick Start (Recommended)
175
+ > **AdLoop uses your own (free) Google Cloud project for OAuth.** The `adloop init` wizard walks you through it — a one-time setup of about 5 minutes, with no shared user caps and no waiting on anyone's verification review. AdLoop does not ship built-in OAuth credentials.
176
+ >
177
+ > **Prefer zero setup?** [**AdLoop Cloud**](https://getadloop.com) is the hosted version: connect Google in two clicks — no Cloud project, no developer token, EU-hosted.
178
+ >
179
+ > *(Upgrading from ≤0.9 with built-in credentials? Those sign-ins were retired in 0.10 — run `adloop init` once to switch to your own project.)*
176
180
 
177
- **Option A — Install from PyPI:**
181
+ ### Install
182
+
183
+ **From PyPI:**
178
184
 
179
185
  ```bash
180
186
  pip install adloop
181
187
  adloop init
182
188
  ```
183
189
 
184
- **Option B — Install from source:**
190
+ **From source:**
185
191
 
186
192
  ```bash
187
193
  git clone https://github.com/kLOsk/adloop.git
@@ -190,21 +196,17 @@ uv sync
190
196
  uv run adloop init
191
197
  ```
192
198
 
193
- The `adloop init` wizard walks you through everything. AdLoop ships with built-in Google OAuth credentials, so you don't need to create a Google Cloud project.
199
+ ### What `adloop init` does
194
200
 
195
- > **⚠️ Built-in credentials temporarily unavailable — Google verification pending.**
196
- > Google limits unverified OAuth apps to 100 users. AdLoop has reached that cap while awaiting Google's app verification. Until verification is complete, the built-in credentials will show a **"This app is blocked"** error for new users.
197
- >
198
- > **Workaround:** set up your own Google Cloud project using the [Advanced Setup](#advanced-setup-custom-google-cloud-project) instructions below (takes ~5 minutes). Your own project has no user cap and is the recommended setup path in the meantime.
201
+ The wizard walks you through:
199
202
 
200
- The wizard:
201
-
202
- 1. **Developer token** — from your Google Ads MCC ([API Center](https://ads.google.com/aw/apicenter))
203
- 2. **MCC Account ID** — your Manager Account ID (top bar in the MCC UI)
204
- 3. **OAuth sign-in** — opens a browser to sign in with Google (or prints a URL for headless servers)
205
- 4. **Auto-discovers your accounts** — finds your GA4 properties and Ads accounts automatically
206
- 5. **Safety defaults** — budget cap and dry-run preference
207
- 6. **Editor config snippets** — prints MCP configuration for both Cursor and Claude Code
203
+ 1. **Google Cloud setup** — creates a project, enables the three APIs, generates an OAuth client (see [Custom Google Cloud Project Setup](#custom-google-cloud-project-setup) below for the exact steps the wizard refers you to)
204
+ 2. **Developer token** — from your Google Ads MCC ([API Center](https://ads.google.com/aw/apicenter))
205
+ 3. **MCC Account ID** — your Manager Account ID (top bar in the MCC UI)
206
+ 4. **OAuth sign-in** — opens a browser to sign in with Google (or prints a URL for headless servers)
207
+ 5. **Auto-discovers your accounts** — finds your GA4 properties and Ads accounts automatically
208
+ 6. **Safety defaults** — budget cap and dry-run preference
209
+ 7. **Editor config snippets** — prints MCP configuration for both Cursor and Claude Code
208
210
 
209
211
  ### Requirements
210
212
 
@@ -214,7 +216,7 @@ The wizard:
214
216
 
215
217
  ### Google Ads Developer Token
216
218
 
217
- A developer token is **always required** — even when using AdLoop's built-in OAuth credentials. The built-in credentials handle Google sign-in; the developer token is a separate key that grants API access to your Google Ads data.
219
+ A developer token is **always required**. Your OAuth client handles Google sign-in; the developer token is a separate key that grants API access to your Google Ads data.
218
220
 
219
221
  1. **Create an MCC** (free) at [ads.google.com/home/tools/manager-accounts](https://ads.google.com/home/tools/manager-accounts/) if you don't have one. Link your regular Google Ads account to it.
220
222
  2. In the MCC, go to **Tools & Settings → API Center**
@@ -234,12 +236,9 @@ A developer token is **always required** — even when using AdLoop's built-in O
234
236
 
235
237
  Running on a server without a browser (VMs, Docker, SSH)? The wizard automatically detects this and falls back to a manual flow: it prints an authorization URL you can open on any device, then you paste the redirect URL back into the terminal.
236
238
 
237
- ### Advanced Setup (Custom Google Cloud Project)
238
-
239
- <details>
240
- <summary>Click to expand — only needed if you want to use your own GCP project instead of AdLoop's built-in credentials</summary>
239
+ ### Custom Google Cloud Project Setup
241
240
 
242
- When you run `adloop init`, choose "No" when asked about built-in credentials. The wizard will guide you through:
241
+ The wizard refers to these steps — do them in your browser before running `adloop init` (or while it waits at the OAuth prompt).
243
242
 
244
243
  #### Step 1 — Google Cloud Project
245
244
 
@@ -311,8 +310,6 @@ If you'd rather manage things by hand instead, copy `.claude/rules/adloop.md` an
311
310
 
312
311
  **Claude Desktop / claude.ai** has no programmatic rules location. Run `adloop install-rules` and it will print the rules content for you to paste into Project settings → Custom instructions on claude.ai.
313
312
 
314
- </details>
315
-
316
313
  ### Use It
317
314
 
318
315
  Ask your AI assistant things like:
@@ -334,7 +331,7 @@ All configuration lives in `~/.adloop/config.yaml`. See [`config.yaml.example`](
334
331
  | Section | Key | Default | Description |
335
332
  |---------|-----|---------|-------------|
336
333
  | `google` | `project_id` | *(empty)* | Google Cloud project ID (only needed with custom credentials) |
337
- | `google` | `credentials_path` | *(empty — uses built-in)* | Path to OAuth client JSON or service account key. Leave empty to use AdLoop's built-in credentials. |
334
+ | `google` | `credentials_path` | *(empty)* | Path to OAuth client JSON or service account key. Empty = `~/.adloop/credentials.json`, else Application Default Credentials. |
338
335
  | `google` | `token_path` | `~/.adloop/token.json` | Where to store the OAuth token (auto-created) |
339
336
  | `ga4` | `property_id` | — | Your GA4 property ID (auto-discovered by `adloop init`) |
340
337
  | `ads` | `developer_token` | — | Your Google Ads API developer token |
@@ -351,7 +348,7 @@ src/adloop/
351
348
  ├── __init__.py # Entry point — routes 'adloop init' to wizard, otherwise starts MCP server
352
349
  ├── server.py # FastMCP server — 43 tool registrations with safety annotations
353
350
  ├── config.py # Config loader (~/.adloop/config.yaml)
354
- ├── auth.py # OAuth 2.0 flow (bundled + custom credentials, headless fallback) + service accounts
351
+ ├── auth.py # OAuth 2.0 flow (user-supplied credentials, headless fallback) + service accounts
355
352
  ├── cli.py # Interactive 'adloop init' setup wizard
356
353
  ├── crossref.py # Cross-reference tools (GA4 + Ads combined analysis)
357
354
  ├── tracking.py # Tracking validation + code generation tools
@@ -387,7 +384,7 @@ What's been shipped and what's next:
387
384
  - ~~Claude Code support~~ ✓ — `CLAUDE.md`, `.mcp.json`, `.claude/rules/`, `.claude/commands/`, CLI wizard snippets
388
385
  - **Claude Desktop one-click install** — `adloop install claude-desktop` (and/or a `.dxt` extension bundle) that writes the AdLoop MCP entry into `claude_desktop_config.json` automatically, so Claude Desktop + Cowork users don't have to hand-edit JSON
389
386
  - ~~PyPI package~~ ✓ — `pip install adloop`
390
- - ~~Bundled OAuth credentials~~ ✓ — no Google Cloud project required, auto-discovery of GA4/Ads accounts (currently capped at 100 users pending Google verification — use [Advanced Setup](#advanced-setup-custom-google-cloud-project) in the meantime)
387
+ - **[AdLoop Cloud](https://getadloop.com)** — the hosted version: no Google Cloud project, no developer token, connect Google in two clicks (EU-hosted, GDPR-first)
391
388
  - ~~Headless server support~~ ✓ — manual URL copy-paste flow for servers without a browser
392
389
  - ~~Behavioral eval suites~~ ✓ — 28 prompt-and-expectation tests covering read, write, tracking, and planning workflows
393
390
  - **Community launch** — HN, Indie Hackers, r/cursor, Twitter
@@ -409,7 +406,7 @@ AdLoop runs entirely on your machine. No data is collected, stored, or transmitt
409
406
 
410
407
  <div align="center">
411
408
 
412
- **If AdLoop saves you from switching between Google Ads, GA4, and your code editor — [give it a star](https://github.com/kLOsk/adloop).**
409
+ **If AdLoop helps you run Google Ads, GA4, and tracking code from one place — [give it a star](https://github.com/kLOsk/adloop).**
413
410
 
414
411
  Made by [@kLOsk](https://github.com/kLOsk) | [Privacy Policy](PRIVACY.md)
415
412
 
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.8.1"
4
- description = "Stop switching between Google Ads, GA4, and your code editor to figure out why conversions dropped."
3
+ version = "0.10.0"
4
+ description = "The AI command center for Google Ads, GA4, and tracking code."
5
5
  readme = "README.md"
6
6
  authors = [
7
7
  { name = "Daniel Klose", email = "info@daniel-klose.com" }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import sys
4
4
 
5
- __version__ = "0.8.1"
5
+ __version__ = "0.9.0"
6
6
 
7
7
 
8
8
  def main() -> None:
@@ -42,6 +42,15 @@ def main() -> None:
42
42
  sys.exit(130)
43
43
  return
44
44
 
45
+ # Process-global side effects (signal handlers, heartbeat thread, and
46
+ # the stdio cancellation-race monkeypatch) are deliberately installed
47
+ # here — in the stdio entry point — rather than at adloop.server import
48
+ # time, so embedding the server in an ASGI app stays side-effect-free.
49
+ from adloop import _mcp_patches, diagnostics
50
+
51
+ diagnostics.install()
52
+ _mcp_patches.install()
53
+
45
54
  from adloop.server import mcp
46
55
 
47
56
  mcp.run()
@@ -98,30 +98,43 @@ def estimate_budget(
98
98
  response = kp_service.generate_keyword_forecast_metrics(request=request)
99
99
  metrics = response.campaign_forecast_metrics
100
100
 
101
+ # KeywordForecastMetrics fields are ``optional`` in v23, so the SDK
102
+ # returns ``None`` for unset fields and the actual integer (including
103
+ # 0) otherwise. Falsy checks like ``int(v) if v else None`` would
104
+ # silently map a real 0-click or 0-cost forecast to None and the
105
+ # caller couldn't tell "no data" apart from "data says zero" — same
106
+ # bug class as discover_keywords (issue Bug 2). Use ``is not None``
107
+ # throughout and the shared ``_micros_to_currency`` helper for the
108
+ # micros→currency conversions.
101
109
  clicks = getattr(metrics, "clicks", None)
102
110
  impressions = getattr(metrics, "impressions", None)
103
111
  avg_cpc_micros = getattr(metrics, "average_cpc_micros", None)
104
112
  cost_micros = getattr(metrics, "cost_micros", None)
105
113
  ctr = getattr(metrics, "click_through_rate", None)
106
114
 
107
- total_cost = round(cost_micros / 1_000_000, 2) if cost_micros else None
108
- avg_cpc = round(avg_cpc_micros / 1_000_000, 2) if avg_cpc_micros else None
115
+ total_cost = _micros_to_currency(cost_micros)
116
+ avg_cpc = _micros_to_currency(avg_cpc_micros)
109
117
 
110
118
  days = max(forecast_days, 1)
111
119
  daily = {
112
- "clicks": round(clicks / days, 1) if clicks else None,
113
- "impressions": round(impressions / days, 1) if impressions else None,
114
- "cost": round(total_cost / days, 2) if total_cost else None,
120
+ "clicks": round(clicks / days, 1) if clicks is not None else None,
121
+ "impressions": (
122
+ round(impressions / days, 1) if impressions is not None else None
123
+ ),
124
+ "cost": round(total_cost / days, 2) if total_cost is not None else None,
115
125
  }
116
126
 
117
127
  insights = []
128
+ # Only emit the headline insight when there's actually something to
129
+ # report (real clicks AND real cost) — a zero-click forecast gets its
130
+ # own dedicated insight below, and a None forecast shouldn't trigger
131
+ # either path.
118
132
  if total_cost is not None and clicks is not None and clicks > 0:
119
- effective_cpa_budget = total_cost / clicks * 10
120
133
  insights.append(
121
134
  f"Estimated {clicks:.0f} clicks over {forecast_days} days at "
122
135
  f"~{avg_cpc} avg CPC. Total estimated cost: {total_cost:.2f}."
123
136
  )
124
- if daily_budget > 0 and daily["cost"] is not None:
137
+ if daily_budget > 0 and daily["cost"] is not None and daily["cost"] > 0:
125
138
  if daily_budget < daily["cost"]:
126
139
  capture_pct = round(daily_budget / daily["cost"] * 100)
127
140
  insights.append(
@@ -134,7 +147,12 @@ def estimate_budget(
134
147
  f"most available traffic (estimated daily cost: {daily['cost']:.2f})."
135
148
  )
136
149
 
137
- if impressions is not None and clicks is not None and impressions > 0 and clicks == 0:
150
+ if (
151
+ impressions is not None
152
+ and clicks is not None
153
+ and impressions > 0
154
+ and clicks == 0
155
+ ):
138
156
  insights.append(
139
157
  "Forecast shows impressions but zero clicks — keywords may be too "
140
158
  "generic or CPCs too low for competitive positions."
@@ -149,7 +167,7 @@ def estimate_budget(
149
167
  "estimated_impressions": impressions,
150
168
  "estimated_cost": total_cost,
151
169
  "estimated_avg_cpc": avg_cpc,
152
- "estimated_ctr": round(ctr, 4) if ctr else None,
170
+ "estimated_ctr": round(ctr, 4) if ctr is not None else None,
153
171
  "daily_estimates": daily,
154
172
  "keywords_used": len([kw for kw in keywords if kw.get("text")]),
155
173
  "insights": insights,
@@ -89,7 +89,29 @@ _GAQL_ERROR_HINTS = {
89
89
 
90
90
 
91
91
  def _parse_gaql_error(exc: Exception) -> str:
92
- """Extract a human-readable message from Google Ads gRPC errors."""
92
+ """Extract a human-readable message from Google Ads gRPC errors.
93
+
94
+ Prefers the structured ``failure.errors[]`` a GoogleAdsException
95
+ carries — Google's own message names the exact field/clause at
96
+ fault (e.g. PROHIBITED_FIELD_IN_SELECT_CLAUSE with the field name),
97
+ which is far more actionable than a generic hint. Known error codes
98
+ still get the hint appended as a suffix.
99
+ """
100
+ failure = getattr(exc, "failure", None)
101
+ errors = getattr(failure, "errors", None) if failure is not None else None
102
+ if errors:
103
+ parts = []
104
+ for err in errors:
105
+ code = str(getattr(err, "error_code", "") or "").strip()
106
+ message = str(getattr(err, "message", "") or "").strip()
107
+ parts.append(" — ".join(p for p in (code, message) if p))
108
+ detail = " | ".join(p for p in parts if p)
109
+ if detail:
110
+ for known, hint in _GAQL_ERROR_HINTS.items():
111
+ if known in detail:
112
+ return f"{detail} (hint: {hint})"
113
+ return detail
114
+
93
115
  raw = str(exc)
94
116
  for code, hint in _GAQL_ERROR_HINTS.items():
95
117
  if code in raw:
@@ -137,6 +159,21 @@ def _to_python(obj: object) -> object:
137
159
  # AdTextAsset and similar message types
138
160
  if hasattr(obj, "text") and isinstance(getattr(obj, "text", None), str):
139
161
  return obj.text
162
+ # Nested proto-plus messages (targeting settings, criteria, ...) —
163
+ # serialize to a dict instead of collapsing to their str() repr.
164
+ try:
165
+ import proto
166
+
167
+ if isinstance(obj, proto.Message):
168
+ return type(obj).to_dict(
169
+ obj, preserving_proto_field_name=True, use_integers_for_enums=False
170
+ )
171
+ except ImportError:
172
+ pass
173
+ if hasattr(obj, "DESCRIPTOR"):
174
+ from google.protobuf.json_format import MessageToDict
175
+
176
+ return MessageToDict(obj, preserving_proto_field_name=True)
140
177
  return str(obj)
141
178
 
142
179
 
@@ -71,6 +71,79 @@ def _normalize_rsa_assets(items: list) -> list[dict]:
71
71
  # ---------------------------------------------------------------------------
72
72
 
73
73
 
74
+ def _ssrf_error(url: str) -> str | None:
75
+ """Reject URLs that would make the validation fetch reach non-public hosts.
76
+
77
+ User-supplied URLs are fetched from the machine running AdLoop; on a
78
+ hosted multi-tenant server that request originates inside our network,
79
+ so private/loopback/link-local/metadata targets must be refused. Ads
80
+ pointing at such addresses could never serve anyway. Returns an error
81
+ string, or None if the URL looks safe to fetch.
82
+
83
+ Note: the fetch re-resolves DNS after this check, so a hostile DNS
84
+ server could still rebind between check and fetch — acceptable here
85
+ because the fetch result is only an up/down signal, never returned
86
+ to the caller.
87
+ """
88
+ import ipaddress
89
+ import socket
90
+ from urllib.parse import urlparse
91
+
92
+ try:
93
+ parsed = urlparse(url)
94
+ except ValueError as e:
95
+ return f"unparseable URL: {e}"
96
+
97
+ if parsed.scheme not in ("http", "https"):
98
+ return f"unsupported URL scheme '{parsed.scheme}' (only http/https)"
99
+
100
+ host = parsed.hostname
101
+ if not host:
102
+ return "URL has no hostname"
103
+
104
+ try:
105
+ addresses = [ipaddress.ip_address(host)]
106
+ except ValueError:
107
+ try:
108
+ addr_info = socket.getaddrinfo(host, None)
109
+ except OSError as e:
110
+ return f"hostname does not resolve: {e}"
111
+ addresses = [
112
+ ipaddress.ip_address(info[4][0]) for info in addr_info
113
+ ]
114
+
115
+ for addr in addresses:
116
+ if (
117
+ addr.is_private
118
+ or addr.is_loopback
119
+ or addr.is_link_local
120
+ or addr.is_multicast
121
+ or addr.is_reserved
122
+ or addr.is_unspecified
123
+ ):
124
+ return (
125
+ f"URL resolves to a non-public address ({addr}) — "
126
+ "refusing to fetch"
127
+ )
128
+
129
+ return None
130
+
131
+
132
+ def _build_public_only_opener():
133
+ """Return a urllib opener that re-checks every redirect hop for SSRF."""
134
+ import urllib.error
135
+ import urllib.request
136
+
137
+ class _PublicOnlyRedirectHandler(urllib.request.HTTPRedirectHandler):
138
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
139
+ err = _ssrf_error(newurl)
140
+ if err is not None:
141
+ raise urllib.error.URLError(f"redirect blocked: {err}")
142
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
143
+
144
+ return urllib.request.build_opener(_PublicOnlyRedirectHandler)
145
+
146
+
74
147
  def _validate_urls(urls: list[str], timeout: int = 10) -> dict[str, str | None]:
75
148
  """Check that each URL returns a 2xx/3xx status.
76
149
 
@@ -79,14 +152,20 @@ def _validate_urls(urls: list[str], timeout: int = 10) -> dict[str, str | None]:
79
152
  import urllib.request
80
153
  import urllib.error
81
154
 
155
+ opener = _build_public_only_opener()
156
+
82
157
  results = {}
83
158
  for url in urls:
84
159
  if not url:
85
160
  continue
161
+ ssrf = _ssrf_error(url)
162
+ if ssrf is not None:
163
+ results[url] = ssrf
164
+ continue
86
165
  try:
87
166
  req = urllib.request.Request(url, method="HEAD")
88
167
  req.add_header("User-Agent", "AdLoop-URLCheck/1.0")
89
- resp = urllib.request.urlopen(req, timeout=timeout)
168
+ resp = opener.open(req, timeout=timeout)
90
169
  if resp.status >= 400:
91
170
  results[url] = f"HTTP {resp.status}"
92
171
  else:
@@ -97,7 +176,7 @@ def _validate_urls(urls: list[str], timeout: int = 10) -> dict[str, str | None]:
97
176
  try:
98
177
  req = urllib.request.Request(url, method="GET")
99
178
  req.add_header("User-Agent", "AdLoop-URLCheck/1.0")
100
- resp = urllib.request.urlopen(req, timeout=timeout)
179
+ resp = opener.open(req, timeout=timeout)
101
180
  if resp.status >= 400:
102
181
  results[url] = f"HTTP {resp.status}"
103
182
  else:
@@ -1221,9 +1300,19 @@ def draft_image_assets(
1221
1300
  image_paths: list[str] | None = None,
1222
1301
  ) -> dict:
1223
1302
  """Draft campaign image assets from local files."""
1303
+ from adloop.runtime import deployment_mode
1224
1304
  from adloop.safety.guards import SafetyViolation, check_blocked_operation
1225
1305
  from adloop.safety.preview import ChangePlan, store_plan
1226
1306
 
1307
+ if deployment_mode() == "server":
1308
+ return {
1309
+ "error": (
1310
+ "draft_image_assets reads image files from the local "
1311
+ "filesystem and is not available on the hosted server. "
1312
+ "Use the self-hosted AdLoop MCP server for image assets."
1313
+ )
1314
+ }
1315
+
1227
1316
  try:
1228
1317
  check_blocked_operation("create_image_assets", config.safety)
1229
1318
  except SafetyViolation as e:
@@ -3146,11 +3235,14 @@ def _apply_attach_shared_set_to_campaigns(
3146
3235
  css.shared_set = shared_set_resource
3147
3236
  operations.append(op)
3148
3237
 
3149
- response = css_service.mutate_campaign_shared_sets(
3150
- customer_id=cid,
3151
- operations=operations,
3152
- partial_failure=True,
3153
- )
3238
+ # CampaignSharedSetService.mutate_campaign_shared_sets does NOT accept a
3239
+ # flattened ``partial_failure`` kwarg (unlike GoogleAdsService.mutate);
3240
+ # it must be set on the request object.
3241
+ request = client.get_type("MutateCampaignSharedSetsRequest")
3242
+ request.customer_id = cid
3243
+ request.operations.extend(operations)
3244
+ request.partial_failure = True
3245
+ response = css_service.mutate_campaign_shared_sets(request=request)
3154
3246
 
3155
3247
  pf_error = getattr(response, "partial_failure_error", None)
3156
3248
  per_op_errors = _parse_partial_failure_per_op(client, pf_error)
@@ -3213,11 +3305,13 @@ def _apply_detach_shared_set_from_campaigns(
3213
3305
  )
3214
3306
  operations.append(op)
3215
3307
 
3216
- response = css_service.mutate_campaign_shared_sets(
3217
- customer_id=cid,
3218
- operations=operations,
3219
- partial_failure=True,
3220
- )
3308
+ # partial_failure must be set on the request object — the flattened
3309
+ # kwarg is not supported by CampaignSharedSetService (see attach above).
3310
+ request = client.get_type("MutateCampaignSharedSetsRequest")
3311
+ request.customer_id = cid
3312
+ request.operations.extend(operations)
3313
+ request.partial_failure = True
3314
+ response = css_service.mutate_campaign_shared_sets(request=request)
3221
3315
 
3222
3316
  pf_error = getattr(response, "partial_failure_error", None)
3223
3317
  per_op_errors = _parse_partial_failure_per_op(client, pf_error)