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.
Files changed (43) hide show
  1. {adloop-0.9.0 → adloop-0.10.0}/PKG-INFO +10 -13
  2. {adloop-0.9.0 → adloop-0.10.0}/README.md +9 -12
  3. {adloop-0.9.0 → adloop-0.10.0}/pyproject.toml +1 -1
  4. {adloop-0.9.0 → adloop-0.10.0}/src/adloop/__init__.py +9 -0
  5. {adloop-0.9.0 → adloop-0.10.0}/src/adloop/ads/gaql.py +38 -1
  6. {adloop-0.9.0 → adloop-0.10.0}/src/adloop/ads/write.py +106 -12
  7. {adloop-0.9.0 → adloop-0.10.0}/src/adloop/auth.py +110 -45
  8. {adloop-0.9.0 → adloop-0.10.0}/src/adloop/cli.py +26 -50
  9. {adloop-0.9.0 → adloop-0.10.0}/src/adloop/config.py +1 -1
  10. {adloop-0.9.0 → adloop-0.10.0}/src/adloop/rules/adloop.md +14 -0
  11. adloop-0.10.0/src/adloop/runtime.py +127 -0
  12. adloop-0.10.0/src/adloop/safety/audit.py +78 -0
  13. adloop-0.10.0/src/adloop/safety/preview.py +110 -0
  14. {adloop-0.9.0 → adloop-0.10.0}/src/adloop/server.py +94 -99
  15. adloop-0.9.0/src/adloop/bundled_credentials.json +0 -13
  16. adloop-0.9.0/src/adloop/safety/audit.py +0 -40
  17. adloop-0.9.0/src/adloop/safety/preview.py +0 -58
  18. {adloop-0.9.0 → adloop-0.10.0}/src/adloop/__main__.py +0 -0
  19. {adloop-0.9.0 → adloop-0.10.0}/src/adloop/_mcp_patches.py +0 -0
  20. {adloop-0.9.0 → adloop-0.10.0}/src/adloop/ads/__init__.py +0 -0
  21. {adloop-0.9.0 → adloop-0.10.0}/src/adloop/ads/client.py +0 -0
  22. {adloop-0.9.0 → adloop-0.10.0}/src/adloop/ads/currency.py +0 -0
  23. {adloop-0.9.0 → adloop-0.10.0}/src/adloop/ads/enums.py +0 -0
  24. {adloop-0.9.0 → adloop-0.10.0}/src/adloop/ads/forecast.py +0 -0
  25. {adloop-0.9.0 → adloop-0.10.0}/src/adloop/ads/pmax.py +0 -0
  26. {adloop-0.9.0 → adloop-0.10.0}/src/adloop/ads/read.py +0 -0
  27. {adloop-0.9.0 → adloop-0.10.0}/src/adloop/crossref.py +0 -0
  28. {adloop-0.9.0 → adloop-0.10.0}/src/adloop/diagnostics.py +0 -0
  29. {adloop-0.9.0 → adloop-0.10.0}/src/adloop/ga4/__init__.py +0 -0
  30. {adloop-0.9.0 → adloop-0.10.0}/src/adloop/ga4/client.py +0 -0
  31. {adloop-0.9.0 → adloop-0.10.0}/src/adloop/ga4/reports.py +0 -0
  32. {adloop-0.9.0 → adloop-0.10.0}/src/adloop/ga4/tracking.py +0 -0
  33. {adloop-0.9.0 → adloop-0.10.0}/src/adloop/rules/__init__.py +0 -0
  34. {adloop-0.9.0 → adloop-0.10.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
  35. {adloop-0.9.0 → adloop-0.10.0}/src/adloop/rules/commands/budget-plan.md +0 -0
  36. {adloop-0.9.0 → adloop-0.10.0}/src/adloop/rules/commands/create-ad.md +0 -0
  37. {adloop-0.9.0 → adloop-0.10.0}/src/adloop/rules/commands/create-campaign.md +0 -0
  38. {adloop-0.9.0 → adloop-0.10.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
  39. {adloop-0.9.0 → adloop-0.10.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
  40. {adloop-0.9.0 → adloop-0.10.0}/src/adloop/rules_install.py +0 -0
  41. {adloop-0.9.0 → adloop-0.10.0}/src/adloop/safety/__init__.py +0 -0
  42. {adloop-0.9.0 → adloop-0.10.0}/src/adloop/safety/guards.py +0 -0
  43. {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.9.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
- > **⚠️ Built-in OAuth credentials are temporarily unavailable while Google verification is pending.**
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
- > **What this means for you:** until Google completes verification, **bring your own Google Cloud project** — it takes ~5 minutes, has no user cap, and the `adloop init` wizard guides you through it. Status updates: [Discussion #13](https://github.com/kLOsk/adloop/discussions/13).
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
- > *(Existing users whose tokens were already issued before the cap continue to work — only first-time sign-ins are blocked.)*
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 defaults to the "bring your own Google Cloud project" path while verification is pending. It walks you through:
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** — 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.
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
- This is the default path while built-in credentials are blocked by Google's 100-user cap. The wizard refers to these steps — do them in your browser before running `adloop init` (or while it waits at the OAuth prompt).
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 — 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 (**currently blocked at 100-user cap** pending Google verification; `adloop init` defaults to the [Custom Google Cloud Project Setup](#custom-google-cloud-project-setup) path until verification completes)
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
- > **⚠️ Built-in OAuth credentials are temporarily unavailable while Google verification is pending.**
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
- > **What this means for you:** until Google completes verification, **bring your own Google Cloud project** — it takes ~5 minutes, has no user cap, and the `adloop init` wizard guides you through it. Status updates: [Discussion #13](https://github.com/kLOsk/adloop/discussions/13).
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
- > *(Existing users whose tokens were already issued before the cap continue to work — only first-time sign-ins are blocked.)*
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 defaults to the "bring your own Google Cloud project" path while verification is pending. It walks you through:
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** — 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.
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
- This is the default path while built-in credentials are blocked by Google's 100-user cap. The wizard refers to these steps — do them in your browser before running `adloop init` (or while it waits at the OAuth prompt).
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 — 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 (**currently blocked at 100-user cap** pending Google verification; `adloop init` defaults to the [Custom Google Cloud Project Setup](#custom-google-cloud-project-setup) path until verification completes)
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.9.0"
3
+ version = "0.10.0"
4
4
  description = "The AI command center for Google Ads, GA4, and tracking code."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -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 = 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)
@@ -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 importlib.resources
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
- def _get_credentials_path(config: AdLoopConfig) -> Path | None:
34
- """Resolve OAuth client credentials using a priority chain.
42
+ class CredentialsProvider(Protocol):
43
+ """Source of authenticated Google credentials for the current context."""
35
44
 
36
- 1. User-provided credentials_path in config (if non-empty and file exists)
37
- 2. ~/.adloop/credentials.json (if file exists — legacy or manually placed)
38
- 3. Bundled credentials shipped with the package
39
- 4. None (caller falls back to Application Default Credentials)
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
- local_path = Path("~/.adloop/credentials.json").expanduser()
47
- if local_path.exists():
48
- return local_path
58
+ def _guard_local_only(self) -> None:
59
+ from adloop.runtime import deployment_mode
49
60
 
50
- try:
51
- ref = importlib.resources.files("adloop").joinpath("bundled_credentials.json")
52
- with importlib.resources.as_file(ref) as bundled:
53
- if bundled.exists():
54
- return Path(bundled)
55
- except (FileNotFoundError, TypeError):
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
- return None
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
- if creds_path is not None:
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
- if creds_info.get("type") == "service_account":
72
- from google.oauth2 import service_account
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
- return _oauth_flow(config, creds_path)
86
+ def get_credentials_provider() -> CredentialsProvider:
87
+ return _active_provider
80
88
 
81
- import google.auth
82
89
 
83
- credentials, _ = google.auth.default(scopes=_GA4_SCOPES)
84
- return credentials
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=_ADS_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
- credentials, _ = google.auth.default(scopes=_ADS_SCOPES)
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
- return flow.run_local_server(port=0) # type: ignore[union-attr]
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.")