adloop 0.6.2__tar.gz → 0.6.4__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 (30) hide show
  1. {adloop-0.6.2 → adloop-0.6.4}/PKG-INFO +2 -1
  2. {adloop-0.6.2 → adloop-0.6.4}/README.md +1 -0
  3. {adloop-0.6.2 → adloop-0.6.4}/pyproject.toml +1 -1
  4. {adloop-0.6.2 → adloop-0.6.4}/src/adloop/__init__.py +1 -1
  5. adloop-0.6.4/src/adloop/_mcp_patches.py +161 -0
  6. {adloop-0.6.2 → adloop-0.6.4}/src/adloop/ads/write.py +29 -4
  7. {adloop-0.6.2 → adloop-0.6.4}/src/adloop/config.py +7 -1
  8. {adloop-0.6.2 → adloop-0.6.4}/src/adloop/server.py +11 -1
  9. {adloop-0.6.2 → adloop-0.6.4}/src/adloop/__main__.py +0 -0
  10. {adloop-0.6.2 → adloop-0.6.4}/src/adloop/ads/__init__.py +0 -0
  11. {adloop-0.6.2 → adloop-0.6.4}/src/adloop/ads/client.py +0 -0
  12. {adloop-0.6.2 → adloop-0.6.4}/src/adloop/ads/currency.py +0 -0
  13. {adloop-0.6.2 → adloop-0.6.4}/src/adloop/ads/forecast.py +0 -0
  14. {adloop-0.6.2 → adloop-0.6.4}/src/adloop/ads/gaql.py +0 -0
  15. {adloop-0.6.2 → adloop-0.6.4}/src/adloop/ads/pmax.py +0 -0
  16. {adloop-0.6.2 → adloop-0.6.4}/src/adloop/ads/read.py +0 -0
  17. {adloop-0.6.2 → adloop-0.6.4}/src/adloop/auth.py +0 -0
  18. {adloop-0.6.2 → adloop-0.6.4}/src/adloop/bundled_credentials.json +0 -0
  19. {adloop-0.6.2 → adloop-0.6.4}/src/adloop/cli.py +0 -0
  20. {adloop-0.6.2 → adloop-0.6.4}/src/adloop/crossref.py +0 -0
  21. {adloop-0.6.2 → adloop-0.6.4}/src/adloop/diagnostics.py +0 -0
  22. {adloop-0.6.2 → adloop-0.6.4}/src/adloop/ga4/__init__.py +0 -0
  23. {adloop-0.6.2 → adloop-0.6.4}/src/adloop/ga4/client.py +0 -0
  24. {adloop-0.6.2 → adloop-0.6.4}/src/adloop/ga4/reports.py +0 -0
  25. {adloop-0.6.2 → adloop-0.6.4}/src/adloop/ga4/tracking.py +0 -0
  26. {adloop-0.6.2 → adloop-0.6.4}/src/adloop/safety/__init__.py +0 -0
  27. {adloop-0.6.2 → adloop-0.6.4}/src/adloop/safety/audit.py +0 -0
  28. {adloop-0.6.2 → adloop-0.6.4}/src/adloop/safety/guards.py +0 -0
  29. {adloop-0.6.2 → adloop-0.6.4}/src/adloop/safety/preview.py +0 -0
  30. {adloop-0.6.2 → adloop-0.6.4}/src/adloop/tracking.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: adloop
3
- Version: 0.6.2
3
+ Version: 0.6.4
4
4
  Summary: Stop switching between Google Ads, GA4, and your code editor to figure out why conversions dropped.
5
5
  Keywords: mcp,google-ads,google-analytics,ga4,cursor,marketing
6
6
  Author: Daniel Klose
@@ -394,6 +394,7 @@ What's been shipped and what's next:
394
394
  - ~~Retry/backoff for API rate limits~~ ✓
395
395
  - ~~Setup wizard (`adloop init`)~~ ✓
396
396
  - ~~Claude Code support~~ ✓ — `CLAUDE.md`, `.mcp.json`, `.claude/rules/`, `.claude/commands/`, CLI wizard snippets
397
+ - **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
397
398
  - ~~PyPI package~~ ✓ — `pip install adloop`
398
399
  - ~~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)
399
400
  - ~~Headless server support~~ ✓ — manual URL copy-paste flow for servers without a browser
@@ -370,6 +370,7 @@ What's been shipped and what's next:
370
370
  - ~~Retry/backoff for API rate limits~~ ✓
371
371
  - ~~Setup wizard (`adloop init`)~~ ✓
372
372
  - ~~Claude Code support~~ ✓ — `CLAUDE.md`, `.mcp.json`, `.claude/rules/`, `.claude/commands/`, CLI wizard snippets
373
+ - **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
373
374
  - ~~PyPI package~~ ✓ — `pip install adloop`
374
375
  - ~~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)
375
376
  - ~~Headless server support~~ ✓ — manual URL copy-paste flow for servers without a browser
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.6.2"
3
+ version = "0.6.4"
4
4
  description = "Stop switching between Google Ads, GA4, and your code editor to figure out why conversions dropped."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -2,7 +2,7 @@
2
2
 
3
3
  import sys
4
4
 
5
- __version__ = "0.6.2"
5
+ __version__ = "0.6.4"
6
6
 
7
7
 
8
8
  def main() -> None:
@@ -0,0 +1,161 @@
1
+ """Runtime patches for upstream ``mcp`` / ``fastmcp`` bugs.
2
+
3
+ This module exists to work around specific upstream defects that affect AdLoop
4
+ users in the wild. Every patch in here is a **temporary workaround** — the long-
5
+ term fix belongs upstream. Each entry below records exactly which upstream issue
6
+ it tracks and the condition under which this file should stop patching.
7
+
8
+ Currently tracked:
9
+
10
+ - **modelcontextprotocol/python-sdk#2416** — ``AssertionError: Request already
11
+ responded to`` cancellation race in ``mcp`` 1.27.0. Reliably crashes the MCP
12
+ server whenever the host (Claude Cowork, Claude Code post-update, etc.) sends
13
+ ``notifications/cancelled`` while a synchronous tool handler is still running.
14
+ We replace the two racing methods (``respond`` / ``cancel``) with guard-based
15
+ versions that cannot double-send a JSON-RPC response.
16
+
17
+ **Remove this patch** once ``mcp`` upstream ships a fix (watch the issue,
18
+ check the installed source for the ``assert not self._completed`` line — if
19
+ it's gone, we detect that automatically and skip patching).
20
+
21
+ Design goals:
22
+
23
+ 1. **Self-removing.** Each patch inspects the upstream source first and bails
24
+ out silently when the bug appears fixed. Upgrading the dependency is then
25
+ enough to deactivate the workaround — no code changes needed in AdLoop.
26
+ 2. **Idempotent.** ``install()`` is safe to call multiple times.
27
+ 3. **Never fatal.** If patching itself fails (e.g. upstream restructured the
28
+ module so inspection breaks), we log and continue — the server must start
29
+ even if the patch can't be applied.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import inspect
35
+ import sys
36
+ from typing import Any
37
+
38
+ from adloop import diagnostics
39
+
40
+
41
+ def _log(message: str) -> None:
42
+ """Write a single patch-status line via the diagnostics channel.
43
+
44
+ Falls back to a plain ``stderr`` write when diagnostics are disabled so
45
+ maintainers can still see patch activity if they go looking.
46
+ """
47
+ line = f"[adloop-patches] {message}"
48
+ if diagnostics.enabled():
49
+ diagnostics._emit("patch", message=message) # type: ignore[attr-defined]
50
+ return
51
+ try:
52
+ sys.stderr.write(line + "\n")
53
+ sys.stderr.flush()
54
+ except Exception:
55
+ pass
56
+
57
+
58
+ def _patch_request_responder_cancel_race() -> None:
59
+ """Apply the fix for python-sdk issue #2416.
60
+
61
+ Replaces ``RequestResponder.respond`` and ``RequestResponder.cancel`` with
62
+ versions that guard against double-responding via a synchronous
63
+ ``self._completed`` check before any ``await``. Exactly one of the two
64
+ methods wins the race; the loser returns without sending a second JSON-RPC
65
+ response, which in turn prevents the anyio TaskGroup from tearing the
66
+ entire stdio transport down.
67
+
68
+ Autodetects the upstream fix by inspecting the installed ``respond``
69
+ source: if the signature ``assert not self._completed`` is no longer
70
+ present, we assume upstream landed a fix and skip patching.
71
+ """
72
+ try:
73
+ from mcp.shared import session as _session_module
74
+ except Exception as exc: # pragma: no cover — mcp is a hard dep
75
+ _log(f"skip pysdk-2416: cannot import mcp.shared.session ({exc})")
76
+ return
77
+
78
+ responder_cls = getattr(_session_module, "RequestResponder", None)
79
+ if responder_cls is None:
80
+ _log("skip pysdk-2416: RequestResponder class not found")
81
+ return
82
+
83
+ try:
84
+ original_respond_src = inspect.getsource(responder_cls.respond)
85
+ except (OSError, TypeError) as exc:
86
+ _log(f"skip pysdk-2416: cannot inspect respond() source ({exc})")
87
+ return
88
+
89
+ if "assert not self._completed" not in original_respond_src:
90
+ # Upstream fixed the race (or moved it). No need to patch further.
91
+ _log("skip pysdk-2416: upstream source no longer contains the racey assert")
92
+ return
93
+
94
+ if getattr(responder_cls, "_adloop_2416_patched", False):
95
+ return
96
+
97
+ async def respond(self: Any, response: Any) -> None:
98
+ """Patched RequestResponder.respond — see python-sdk#2416."""
99
+ if not self._entered:
100
+ raise RuntimeError("RequestResponder must be used as a context manager")
101
+ # Upstream asserts here; we return early instead. If a concurrent
102
+ # cancel() already marked the request complete, silently drop this
103
+ # response — the cancel path has already sent one error response and a
104
+ # second JSON-RPC reply for the same request ID would be a protocol
105
+ # violation as well as crashing the TaskGroup on the assert.
106
+ if self._completed:
107
+ return
108
+ if not self.cancelled:
109
+ self._completed = True
110
+ await self._session._send_response(
111
+ request_id=self.request_id, response=response
112
+ )
113
+
114
+ async def cancel(self: Any) -> None:
115
+ """Patched RequestResponder.cancel — see python-sdk#2416."""
116
+ if not self._entered:
117
+ raise RuntimeError("RequestResponder must be used as a context manager")
118
+ if not self._cancel_scope:
119
+ raise RuntimeError("No active cancel scope")
120
+
121
+ self._cancel_scope.cancel()
122
+ # Symmetrical guard: if the handler's respond() already ran before the
123
+ # cancel notification arrived, don't send a second response.
124
+ if self._completed:
125
+ return
126
+ self._completed = True
127
+
128
+ from mcp.types import ErrorData # imported lazily to avoid startup cost
129
+
130
+ await self._session._send_response(
131
+ request_id=self.request_id,
132
+ response=ErrorData(code=0, message="Request cancelled", data=None),
133
+ )
134
+
135
+ responder_cls.respond = respond
136
+ responder_cls.cancel = cancel
137
+ responder_cls._adloop_2416_patched = True # type: ignore[attr-defined]
138
+
139
+ _log(
140
+ "applied pysdk-2416: RequestResponder.respond/cancel guarded against "
141
+ "cancellation double-respond race"
142
+ )
143
+
144
+
145
+ _INSTALLED = False
146
+
147
+
148
+ def install() -> None:
149
+ """Apply all AdLoop runtime patches. Idempotent.
150
+
151
+ Call this exactly once at server startup, before ``FastMCP`` begins
152
+ accepting messages. Calling it multiple times is safe — we gate on a
153
+ module-level flag and each individual patch also has its own reentry
154
+ guard.
155
+ """
156
+ global _INSTALLED
157
+ if _INSTALLED:
158
+ return
159
+ _INSTALLED = True
160
+
161
+ _patch_request_responder_cancel_race()
@@ -1183,6 +1183,7 @@ def confirm_and_apply(
1183
1183
  "Plans expire when the MCP server restarts.",
1184
1184
  }
1185
1185
 
1186
+ forced_by_config = bool(config.safety.require_dry_run) and not dry_run
1186
1187
  if config.safety.require_dry_run:
1187
1188
  dry_run = True
1188
1189
 
@@ -1197,16 +1198,40 @@ def confirm_and_apply(
1197
1198
  dry_run=True,
1198
1199
  result="dry_run_success",
1199
1200
  )
1200
- return {
1201
+ response = {
1201
1202
  "status": "DRY_RUN_SUCCESS",
1202
1203
  "plan_id": plan.plan_id,
1203
1204
  "operation": plan.operation,
1204
1205
  "changes": plan.changes,
1205
- "message": (
1206
+ }
1207
+ if forced_by_config:
1208
+ # The caller passed dry_run=false but safety.require_dry_run
1209
+ # forced it back on. Tell them exactly why and how to unlock
1210
+ # real writes — without this, agents (e.g. Claude Code) retry
1211
+ # in an infinite loop because the old message said to "call
1212
+ # again with dry_run=false", which they already did.
1213
+ config_path = config.source_path or "~/.adloop/config.yaml"
1214
+ response["dry_run_forced_by"] = "config.safety.require_dry_run"
1215
+ response["config_path"] = config_path
1216
+ response["remediation"] = (
1217
+ f"Edit {config_path}, set 'require_dry_run: false' under "
1218
+ "'safety:', then restart the AdLoop MCP server. Passing "
1219
+ "dry_run=false on this tool will keep being overridden "
1220
+ "until that flag is flipped."
1221
+ )
1222
+ response["message"] = (
1223
+ f"dry_run=false was IGNORED because 'safety.require_dry_run: true' "
1224
+ f"is set in {config_path}. No changes were made. To apply real "
1225
+ f"changes, flip that flag to false and restart the AdLoop MCP "
1226
+ f"server — retrying this tool with dry_run=false alone will "
1227
+ f"never succeed while the flag is on."
1228
+ )
1229
+ else:
1230
+ response["message"] = (
1206
1231
  "Dry run completed — no changes were made to your Google Ads account. "
1207
1232
  "To apply for real, call confirm_and_apply again with dry_run=false."
1208
- ),
1209
- }
1233
+ )
1234
+ return response
1210
1235
 
1211
1236
  try:
1212
1237
  result = _execute_plan(config, plan)
@@ -47,6 +47,10 @@ class AdLoopConfig:
47
47
  ga4: GA4Config = field(default_factory=GA4Config)
48
48
  ads: AdsConfig = field(default_factory=AdsConfig)
49
49
  safety: SafetyConfig = field(default_factory=SafetyConfig)
50
+ # Absolute path the config was resolved from (even if it did not exist
51
+ # on disk when loaded). Used by the runtime to tell callers exactly
52
+ # which file to edit when a safety flag overrides their request.
53
+ source_path: str = ""
50
54
 
51
55
 
52
56
  def _resolve_path(path_str: str) -> Path:
@@ -66,9 +70,10 @@ def load_config(config_path: str | None = None) -> AdLoopConfig:
66
70
  config_path = os.environ.get("ADLOOP_CONFIG", "~/.adloop/config.yaml")
67
71
 
68
72
  path = _resolve_path(config_path)
73
+ resolved = str(path)
69
74
 
70
75
  if not path.exists():
71
- return AdLoopConfig()
76
+ return AdLoopConfig(source_path=resolved)
72
77
 
73
78
  with open(path) as f:
74
79
  raw = yaml.safe_load(f) or {}
@@ -99,4 +104,5 @@ def load_config(config_path: str | None = None) -> AdLoopConfig:
99
104
  log_file=safety_raw.get("log_file", "~/.adloop/audit.log"),
100
105
  blocked_operations=safety_raw.get("blocked_operations", []),
101
106
  ),
107
+ source_path=resolved,
102
108
  )
@@ -8,10 +8,11 @@ from typing import Callable
8
8
  from fastmcp import FastMCP
9
9
  from mcp.types import ToolAnnotations
10
10
 
11
- from adloop import diagnostics
11
+ from adloop import _mcp_patches, diagnostics
12
12
  from adloop.config import load_config
13
13
 
14
14
  diagnostics.install()
15
+ _mcp_patches.install()
15
16
 
16
17
  _READONLY = ToolAnnotations(readOnlyHint=True, destructiveHint=False)
17
18
  _WRITE = ToolAnnotations(readOnlyHint=False, destructiveHint=False)
@@ -1242,6 +1243,15 @@ def confirm_and_apply(
1242
1243
  IMPORTANT: Defaults to dry_run=True. You MUST explicitly pass dry_run=false
1243
1244
  to make real changes to the Google Ads account.
1244
1245
 
1246
+ Config override: if 'safety.require_dry_run: true' is set in the user's
1247
+ config file (default ~/.adloop/config.yaml), dry_run=false is IGNORED
1248
+ and this tool will keep returning DRY_RUN_SUCCESS. When that happens the
1249
+ response includes 'dry_run_forced_by', 'config_path', and 'remediation'
1250
+ fields — surface those to the user verbatim and STOP retrying. Calling
1251
+ this tool again with dry_run=false will not change anything until the
1252
+ user edits the config file, sets 'require_dry_run: false', and restarts
1253
+ the AdLoop MCP server.
1254
+
1245
1255
  The plan_id comes from a prior draft_* or pause/enable tool call.
1246
1256
  """
1247
1257
  from adloop.ads.write import confirm_and_apply as _impl
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes