adloop 0.9.0__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.
- {adloop-0.9.0 → adloop-0.10.0}/PKG-INFO +10 -13
- {adloop-0.9.0 → adloop-0.10.0}/README.md +9 -12
- {adloop-0.9.0 → adloop-0.10.0}/pyproject.toml +1 -1
- {adloop-0.9.0 → adloop-0.10.0}/src/adloop/__init__.py +9 -0
- {adloop-0.9.0 → adloop-0.10.0}/src/adloop/ads/gaql.py +38 -1
- {adloop-0.9.0 → adloop-0.10.0}/src/adloop/ads/write.py +106 -12
- {adloop-0.9.0 → adloop-0.10.0}/src/adloop/auth.py +110 -45
- {adloop-0.9.0 → adloop-0.10.0}/src/adloop/cli.py +26 -50
- {adloop-0.9.0 → adloop-0.10.0}/src/adloop/config.py +1 -1
- {adloop-0.9.0 → adloop-0.10.0}/src/adloop/rules/adloop.md +14 -0
- adloop-0.10.0/src/adloop/runtime.py +127 -0
- adloop-0.10.0/src/adloop/safety/audit.py +78 -0
- adloop-0.10.0/src/adloop/safety/preview.py +110 -0
- {adloop-0.9.0 → adloop-0.10.0}/src/adloop/server.py +94 -99
- adloop-0.9.0/src/adloop/bundled_credentials.json +0 -13
- adloop-0.9.0/src/adloop/safety/audit.py +0 -40
- adloop-0.9.0/src/adloop/safety/preview.py +0 -58
- {adloop-0.9.0 → adloop-0.10.0}/src/adloop/__main__.py +0 -0
- {adloop-0.9.0 → adloop-0.10.0}/src/adloop/_mcp_patches.py +0 -0
- {adloop-0.9.0 → adloop-0.10.0}/src/adloop/ads/__init__.py +0 -0
- {adloop-0.9.0 → adloop-0.10.0}/src/adloop/ads/client.py +0 -0
- {adloop-0.9.0 → adloop-0.10.0}/src/adloop/ads/currency.py +0 -0
- {adloop-0.9.0 → adloop-0.10.0}/src/adloop/ads/enums.py +0 -0
- {adloop-0.9.0 → adloop-0.10.0}/src/adloop/ads/forecast.py +0 -0
- {adloop-0.9.0 → adloop-0.10.0}/src/adloop/ads/pmax.py +0 -0
- {adloop-0.9.0 → adloop-0.10.0}/src/adloop/ads/read.py +0 -0
- {adloop-0.9.0 → adloop-0.10.0}/src/adloop/crossref.py +0 -0
- {adloop-0.9.0 → adloop-0.10.0}/src/adloop/diagnostics.py +0 -0
- {adloop-0.9.0 → adloop-0.10.0}/src/adloop/ga4/__init__.py +0 -0
- {adloop-0.9.0 → adloop-0.10.0}/src/adloop/ga4/client.py +0 -0
- {adloop-0.9.0 → adloop-0.10.0}/src/adloop/ga4/reports.py +0 -0
- {adloop-0.9.0 → adloop-0.10.0}/src/adloop/ga4/tracking.py +0 -0
- {adloop-0.9.0 → adloop-0.10.0}/src/adloop/rules/__init__.py +0 -0
- {adloop-0.9.0 → adloop-0.10.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
- {adloop-0.9.0 → adloop-0.10.0}/src/adloop/rules/commands/budget-plan.md +0 -0
- {adloop-0.9.0 → adloop-0.10.0}/src/adloop/rules/commands/create-ad.md +0 -0
- {adloop-0.9.0 → adloop-0.10.0}/src/adloop/rules/commands/create-campaign.md +0 -0
- {adloop-0.9.0 → adloop-0.10.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
- {adloop-0.9.0 → adloop-0.10.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
- {adloop-0.9.0 → adloop-0.10.0}/src/adloop/rules_install.py +0 -0
- {adloop-0.9.0 → adloop-0.10.0}/src/adloop/safety/__init__.py +0 -0
- {adloop-0.9.0 → adloop-0.10.0}/src/adloop/safety/guards.py +0 -0
- {adloop-0.9.0 → adloop-0.10.0}/src/adloop/tracking.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: adloop
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.0
|
|
4
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
|
|
@@ -196,12 +196,11 @@ AdLoop manages real ad spend, so safety is not optional.
|
|
|
196
196
|
|
|
197
197
|
## Setup
|
|
198
198
|
|
|
199
|
-
>
|
|
200
|
-
> Google limits unverified OAuth apps to 100 users, and AdLoop has reached that cap. New users will see a **"This app is blocked"** error if they pick the built-in option in the wizard.
|
|
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.
|
|
201
200
|
>
|
|
202
|
-
> **
|
|
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.
|
|
203
202
|
>
|
|
204
|
-
> *(
|
|
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.)*
|
|
205
204
|
|
|
206
205
|
### Install
|
|
207
206
|
|
|
@@ -223,7 +222,7 @@ uv run adloop init
|
|
|
223
222
|
|
|
224
223
|
### What `adloop init` does
|
|
225
224
|
|
|
226
|
-
The wizard
|
|
225
|
+
The wizard walks you through:
|
|
227
226
|
|
|
228
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)
|
|
229
228
|
2. **Developer token** — from your Google Ads MCC ([API Center](https://ads.google.com/aw/apicenter))
|
|
@@ -233,8 +232,6 @@ The wizard defaults to the "bring your own Google Cloud project" path while veri
|
|
|
233
232
|
6. **Safety defaults** — budget cap and dry-run preference
|
|
234
233
|
7. **Editor config snippets** — prints MCP configuration for both Cursor and Claude Code
|
|
235
234
|
|
|
236
|
-
The wizard does still offer AdLoop's built-in credentials as a non-default option for existing users whose tokens predate the cap. Picking that option for a brand-new Google account will fail at the consent screen — the wizard warns you about this before you choose.
|
|
237
|
-
|
|
238
235
|
### Requirements
|
|
239
236
|
|
|
240
237
|
- Python 3.11+
|
|
@@ -243,7 +240,7 @@ The wizard does still offer AdLoop's built-in credentials as a non-default optio
|
|
|
243
240
|
|
|
244
241
|
### Google Ads Developer Token
|
|
245
242
|
|
|
246
|
-
A developer token is **always required
|
|
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.
|
|
247
244
|
|
|
248
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.
|
|
249
246
|
2. In the MCC, go to **Tools & Settings → API Center**
|
|
@@ -265,7 +262,7 @@ Running on a server without a browser (VMs, Docker, SSH)? The wizard automatical
|
|
|
265
262
|
|
|
266
263
|
### Custom Google Cloud Project Setup
|
|
267
264
|
|
|
268
|
-
|
|
265
|
+
The wizard refers to these steps — do them in your browser before running `adloop init` (or while it waits at the OAuth prompt).
|
|
269
266
|
|
|
270
267
|
#### Step 1 — Google Cloud Project
|
|
271
268
|
|
|
@@ -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
|
|
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 (
|
|
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
|
-
-
|
|
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
|
|
@@ -172,12 +172,11 @@ AdLoop manages real ad spend, so safety is not optional.
|
|
|
172
172
|
|
|
173
173
|
## Setup
|
|
174
174
|
|
|
175
|
-
>
|
|
176
|
-
> Google limits unverified OAuth apps to 100 users, and AdLoop has reached that cap. New users will see a **"This app is blocked"** error if they pick the built-in option in the wizard.
|
|
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.
|
|
177
176
|
>
|
|
178
|
-
> **
|
|
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.
|
|
179
178
|
>
|
|
180
|
-
> *(
|
|
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.)*
|
|
181
180
|
|
|
182
181
|
### Install
|
|
183
182
|
|
|
@@ -199,7 +198,7 @@ uv run adloop init
|
|
|
199
198
|
|
|
200
199
|
### What `adloop init` does
|
|
201
200
|
|
|
202
|
-
The wizard
|
|
201
|
+
The wizard walks you through:
|
|
203
202
|
|
|
204
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)
|
|
205
204
|
2. **Developer token** — from your Google Ads MCC ([API Center](https://ads.google.com/aw/apicenter))
|
|
@@ -209,8 +208,6 @@ The wizard defaults to the "bring your own Google Cloud project" path while veri
|
|
|
209
208
|
6. **Safety defaults** — budget cap and dry-run preference
|
|
210
209
|
7. **Editor config snippets** — prints MCP configuration for both Cursor and Claude Code
|
|
211
210
|
|
|
212
|
-
The wizard does still offer AdLoop's built-in credentials as a non-default option for existing users whose tokens predate the cap. Picking that option for a brand-new Google account will fail at the consent screen — the wizard warns you about this before you choose.
|
|
213
|
-
|
|
214
211
|
### Requirements
|
|
215
212
|
|
|
216
213
|
- Python 3.11+
|
|
@@ -219,7 +216,7 @@ The wizard does still offer AdLoop's built-in credentials as a non-default optio
|
|
|
219
216
|
|
|
220
217
|
### Google Ads Developer Token
|
|
221
218
|
|
|
222
|
-
A developer token is **always required
|
|
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.
|
|
223
220
|
|
|
224
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.
|
|
225
222
|
2. In the MCC, go to **Tools & Settings → API Center**
|
|
@@ -241,7 +238,7 @@ Running on a server without a browser (VMs, Docker, SSH)? The wizard automatical
|
|
|
241
238
|
|
|
242
239
|
### Custom Google Cloud Project Setup
|
|
243
240
|
|
|
244
|
-
|
|
241
|
+
The wizard refers to these steps — do them in your browser before running `adloop init` (or while it waits at the OAuth prompt).
|
|
245
242
|
|
|
246
243
|
#### Step 1 — Google Cloud Project
|
|
247
244
|
|
|
@@ -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
|
|
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 (
|
|
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
|
-
-
|
|
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
|
|
@@ -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()
|
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
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
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
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)
|
|
@@ -1,10 +1,19 @@
|
|
|
1
|
-
"""Google API authentication — OAuth 2.0 and service account support.
|
|
1
|
+
"""Google API authentication — OAuth 2.0 and service account support.
|
|
2
|
+
|
|
3
|
+
Credential acquisition is pluggable: :class:`LocalFileCredentialsProvider`
|
|
4
|
+
implements the OSS behavior (credentials.json chain, ``~/.adloop/token.json``,
|
|
5
|
+
interactive browser flow), and a hosted deployment swaps in its own provider
|
|
6
|
+
via :func:`set_credentials_provider` (e.g. tokens from an encrypted database,
|
|
7
|
+
refreshed out-of-band). All client construction goes through the module-level
|
|
8
|
+
:func:`get_ga4_credentials` / :func:`get_ads_credentials`, which delegate to
|
|
9
|
+
the active provider.
|
|
10
|
+
"""
|
|
2
11
|
|
|
3
12
|
from __future__ import annotations
|
|
4
13
|
|
|
5
|
-
import
|
|
14
|
+
import sys
|
|
6
15
|
from pathlib import Path
|
|
7
|
-
from typing import TYPE_CHECKING
|
|
16
|
+
from typing import TYPE_CHECKING, Protocol
|
|
8
17
|
|
|
9
18
|
if TYPE_CHECKING:
|
|
10
19
|
from google.auth.credentials import Credentials
|
|
@@ -30,62 +39,85 @@ _ADS_SCOPES = [
|
|
|
30
39
|
]
|
|
31
40
|
|
|
32
41
|
|
|
33
|
-
|
|
34
|
-
"""
|
|
42
|
+
class CredentialsProvider(Protocol):
|
|
43
|
+
"""Source of authenticated Google credentials for the current context."""
|
|
35
44
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
45
|
+
def ga4_credentials(self, config: AdLoopConfig) -> Credentials: ...
|
|
46
|
+
|
|
47
|
+
def ads_credentials(self, config: AdLoopConfig) -> Credentials: ...
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class LocalFileCredentialsProvider:
|
|
51
|
+
"""OSS default: local credential files + interactive OAuth.
|
|
52
|
+
|
|
53
|
+
Refuses to run in server mode — falling back to the operator's own
|
|
54
|
+
``~/.adloop`` tokens in a multi-tenant process would silently serve
|
|
55
|
+
one tenant's request with another identity's credentials.
|
|
40
56
|
"""
|
|
41
|
-
if config.google.credentials_path:
|
|
42
|
-
user_path = Path(config.google.credentials_path).expanduser()
|
|
43
|
-
if user_path.exists():
|
|
44
|
-
return user_path
|
|
45
57
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return local_path
|
|
58
|
+
def _guard_local_only(self) -> None:
|
|
59
|
+
from adloop.runtime import deployment_mode
|
|
49
60
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
pass
|
|
61
|
+
if deployment_mode() == "server":
|
|
62
|
+
raise RuntimeError(
|
|
63
|
+
"LocalFileCredentialsProvider cannot be used in server mode. "
|
|
64
|
+
"The hosted deployment must install its own provider via "
|
|
65
|
+
"adloop.auth.set_credentials_provider() at startup."
|
|
66
|
+
)
|
|
57
67
|
|
|
58
|
-
|
|
68
|
+
def ga4_credentials(self, config: AdLoopConfig) -> Credentials:
|
|
69
|
+
self._guard_local_only()
|
|
70
|
+
return _local_credentials(config, _GA4_SCOPES)
|
|
59
71
|
|
|
72
|
+
def ads_credentials(self, config: AdLoopConfig) -> Credentials:
|
|
73
|
+
self._guard_local_only()
|
|
74
|
+
return _local_credentials(config, _ADS_SCOPES)
|
|
60
75
|
|
|
61
|
-
def get_ga4_credentials(config: AdLoopConfig) -> Credentials:
|
|
62
|
-
"""Return authenticated credentials for GA4 APIs."""
|
|
63
|
-
creds_path = _get_credentials_path(config)
|
|
64
76
|
|
|
65
|
-
|
|
66
|
-
import json
|
|
77
|
+
_active_provider: CredentialsProvider = LocalFileCredentialsProvider()
|
|
67
78
|
|
|
68
|
-
with open(creds_path) as f:
|
|
69
|
-
creds_info = json.load(f)
|
|
70
79
|
|
|
71
|
-
|
|
72
|
-
|
|
80
|
+
def set_credentials_provider(provider: CredentialsProvider) -> None:
|
|
81
|
+
"""Swap the credentials provider (hosted deployments call this once)."""
|
|
82
|
+
global _active_provider
|
|
83
|
+
_active_provider = provider
|
|
73
84
|
|
|
74
|
-
return service_account.Credentials.from_service_account_file(
|
|
75
|
-
str(creds_path),
|
|
76
|
-
scopes=_GA4_SCOPES,
|
|
77
|
-
)
|
|
78
85
|
|
|
79
|
-
|
|
86
|
+
def get_credentials_provider() -> CredentialsProvider:
|
|
87
|
+
return _active_provider
|
|
80
88
|
|
|
81
|
-
import google.auth
|
|
82
89
|
|
|
83
|
-
|
|
84
|
-
|
|
90
|
+
def get_ga4_credentials(config: AdLoopConfig) -> Credentials:
|
|
91
|
+
"""Return authenticated credentials for GA4 APIs."""
|
|
92
|
+
return _active_provider.ga4_credentials(config)
|
|
85
93
|
|
|
86
94
|
|
|
87
95
|
def get_ads_credentials(config: AdLoopConfig) -> Credentials:
|
|
88
96
|
"""Return authenticated credentials for Google Ads API."""
|
|
97
|
+
return _active_provider.ads_credentials(config)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _get_credentials_path(config: AdLoopConfig) -> Path | None:
|
|
101
|
+
"""Resolve OAuth client credentials using a priority chain.
|
|
102
|
+
|
|
103
|
+
1. User-provided credentials_path in config (if non-empty and file exists)
|
|
104
|
+
2. ~/.adloop/credentials.json (if file exists — the wizard's default spot)
|
|
105
|
+
3. None (caller falls back to Application Default Credentials)
|
|
106
|
+
"""
|
|
107
|
+
if config.google.credentials_path:
|
|
108
|
+
user_path = Path(config.google.credentials_path).expanduser()
|
|
109
|
+
if user_path.exists():
|
|
110
|
+
return user_path
|
|
111
|
+
|
|
112
|
+
local_path = Path("~/.adloop/credentials.json").expanduser()
|
|
113
|
+
if local_path.exists():
|
|
114
|
+
return local_path
|
|
115
|
+
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _local_credentials(config: AdLoopConfig, scopes: list[str]) -> Credentials:
|
|
120
|
+
"""Resolve credentials from local files (service account or OAuth)."""
|
|
89
121
|
creds_path = _get_credentials_path(config)
|
|
90
122
|
|
|
91
123
|
if creds_path is not None:
|
|
@@ -99,14 +131,22 @@ def get_ads_credentials(config: AdLoopConfig) -> Credentials:
|
|
|
99
131
|
|
|
100
132
|
return service_account.Credentials.from_service_account_file(
|
|
101
133
|
str(creds_path),
|
|
102
|
-
scopes=
|
|
134
|
+
scopes=scopes,
|
|
103
135
|
)
|
|
104
136
|
|
|
105
137
|
return _oauth_flow(config, creds_path)
|
|
106
138
|
|
|
107
139
|
import google.auth
|
|
108
140
|
|
|
109
|
-
|
|
141
|
+
try:
|
|
142
|
+
credentials, _ = google.auth.default(scopes=scopes)
|
|
143
|
+
except Exception as exc:
|
|
144
|
+
raise RuntimeError(
|
|
145
|
+
"No Google OAuth credentials found. Run `adloop init` to set up "
|
|
146
|
+
"your own Google Cloud project, or place your OAuth client JSON "
|
|
147
|
+
"at ~/.adloop/credentials.json. Prefer zero setup? AdLoop Cloud "
|
|
148
|
+
"handles credentials for you: https://getadloop.com"
|
|
149
|
+
) from exc
|
|
110
150
|
return credentials
|
|
111
151
|
|
|
112
152
|
|
|
@@ -119,7 +159,9 @@ def _oauth_flow(
|
|
|
119
159
|
GA4 and Ads auth sharing the same token_path.
|
|
120
160
|
|
|
121
161
|
Falls back to a manual copy-paste flow when no browser is available
|
|
122
|
-
(headless servers, Docker containers, SSH sessions)
|
|
162
|
+
(headless servers, Docker containers, SSH sessions) — but only when
|
|
163
|
+
attached to a real terminal; under a stdio MCP server stdin/stdout
|
|
164
|
+
belong to the JSON-RPC stream and must not be touched.
|
|
123
165
|
"""
|
|
124
166
|
from google.auth.transport.requests import Request
|
|
125
167
|
from google.oauth2.credentials import Credentials as OAuthCredentials
|
|
@@ -172,13 +214,36 @@ def _oauth_flow(
|
|
|
172
214
|
return creds
|
|
173
215
|
|
|
174
216
|
|
|
217
|
+
def _is_interactive_terminal() -> bool:
|
|
218
|
+
"""True when stdin AND stdout are real TTYs (a human at a terminal).
|
|
219
|
+
|
|
220
|
+
Under a stdio MCP server both are pipes carrying JSON-RPC frames —
|
|
221
|
+
printing prompts or reading input there corrupts the protocol stream.
|
|
222
|
+
"""
|
|
223
|
+
try:
|
|
224
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
225
|
+
except (AttributeError, ValueError):
|
|
226
|
+
return False
|
|
227
|
+
|
|
228
|
+
|
|
175
229
|
def _run_oauth_with_fallback(flow: object) -> Credentials:
|
|
176
230
|
"""Try browser-based OAuth; fall back to manual URL copy-paste for headless."""
|
|
231
|
+
interactive = _is_interactive_terminal()
|
|
177
232
|
try:
|
|
178
|
-
|
|
233
|
+
# Suppress the library's stdout prompt unless a human terminal is
|
|
234
|
+
# attached — under stdio transport, stdout is the JSON-RPC stream.
|
|
235
|
+
kwargs = {} if interactive else {"authorization_prompt_message": ""}
|
|
236
|
+
return flow.run_local_server(port=0, **kwargs) # type: ignore[union-attr]
|
|
179
237
|
except Exception:
|
|
180
238
|
pass
|
|
181
239
|
|
|
240
|
+
if not interactive:
|
|
241
|
+
raise RuntimeError(
|
|
242
|
+
"OAuth authorization is required but no browser could be opened "
|
|
243
|
+
"and no interactive terminal is attached. Run 'adloop init' in a "
|
|
244
|
+
"terminal to complete authorization, then retry."
|
|
245
|
+
)
|
|
246
|
+
|
|
182
247
|
auth_url, _ = flow.authorization_url(prompt="consent") # type: ignore[union-attr]
|
|
183
248
|
print()
|
|
184
249
|
print(" No browser detected — using manual authorization.")
|