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.
- {adloop-0.6.2 → adloop-0.6.4}/PKG-INFO +2 -1
- {adloop-0.6.2 → adloop-0.6.4}/README.md +1 -0
- {adloop-0.6.2 → adloop-0.6.4}/pyproject.toml +1 -1
- {adloop-0.6.2 → adloop-0.6.4}/src/adloop/__init__.py +1 -1
- adloop-0.6.4/src/adloop/_mcp_patches.py +161 -0
- {adloop-0.6.2 → adloop-0.6.4}/src/adloop/ads/write.py +29 -4
- {adloop-0.6.2 → adloop-0.6.4}/src/adloop/config.py +7 -1
- {adloop-0.6.2 → adloop-0.6.4}/src/adloop/server.py +11 -1
- {adloop-0.6.2 → adloop-0.6.4}/src/adloop/__main__.py +0 -0
- {adloop-0.6.2 → adloop-0.6.4}/src/adloop/ads/__init__.py +0 -0
- {adloop-0.6.2 → adloop-0.6.4}/src/adloop/ads/client.py +0 -0
- {adloop-0.6.2 → adloop-0.6.4}/src/adloop/ads/currency.py +0 -0
- {adloop-0.6.2 → adloop-0.6.4}/src/adloop/ads/forecast.py +0 -0
- {adloop-0.6.2 → adloop-0.6.4}/src/adloop/ads/gaql.py +0 -0
- {adloop-0.6.2 → adloop-0.6.4}/src/adloop/ads/pmax.py +0 -0
- {adloop-0.6.2 → adloop-0.6.4}/src/adloop/ads/read.py +0 -0
- {adloop-0.6.2 → adloop-0.6.4}/src/adloop/auth.py +0 -0
- {adloop-0.6.2 → adloop-0.6.4}/src/adloop/bundled_credentials.json +0 -0
- {adloop-0.6.2 → adloop-0.6.4}/src/adloop/cli.py +0 -0
- {adloop-0.6.2 → adloop-0.6.4}/src/adloop/crossref.py +0 -0
- {adloop-0.6.2 → adloop-0.6.4}/src/adloop/diagnostics.py +0 -0
- {adloop-0.6.2 → adloop-0.6.4}/src/adloop/ga4/__init__.py +0 -0
- {adloop-0.6.2 → adloop-0.6.4}/src/adloop/ga4/client.py +0 -0
- {adloop-0.6.2 → adloop-0.6.4}/src/adloop/ga4/reports.py +0 -0
- {adloop-0.6.2 → adloop-0.6.4}/src/adloop/ga4/tracking.py +0 -0
- {adloop-0.6.2 → adloop-0.6.4}/src/adloop/safety/__init__.py +0 -0
- {adloop-0.6.2 → adloop-0.6.4}/src/adloop/safety/audit.py +0 -0
- {adloop-0.6.2 → adloop-0.6.4}/src/adloop/safety/guards.py +0 -0
- {adloop-0.6.2 → adloop-0.6.4}/src/adloop/safety/preview.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|