adloop 0.22.2__tar.gz → 0.23.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.22.2 → adloop-0.23.0}/PKG-INFO +2 -2
- {adloop-0.22.2 → adloop-0.23.0}/README.md +1 -1
- {adloop-0.22.2 → adloop-0.23.0}/pyproject.toml +1 -1
- {adloop-0.22.2 → adloop-0.23.0}/pyproject.toml.orig +1 -1
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/config.py +7 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/rules/adloop.md +2 -0
- adloop-0.23.0/src/adloop/safety/approval.py +191 -0
- adloop-0.23.0/src/adloop/safety/approval_card.html +360 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/server.py +150 -2
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/__init__.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/__main__.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/_mcp_patches.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/ads/__init__.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/ads/ai_max.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/ads/brands.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/ads/client.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/ads/conversion_actions.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/ads/currency.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/ads/custom_conversion_goals.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/ads/enums.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/ads/forecast.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/ads/gaql.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/ads/image_fetch.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/ads/pmax.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/ads/read.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/ads/validate_only.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/ads/write.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/auth.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/cli.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/crossref.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/diagnostics.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/ga4/__init__.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/ga4/client.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/ga4/reports.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/ga4/tracking.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/ga4/write.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/gsc/__init__.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/gsc/client.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/gsc/reports.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/gtm/__init__.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/gtm/client.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/gtm/entities.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/gtm/read.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/gtm/write.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/merchant/__init__.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/merchant/client.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/merchant/read.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/pagespeed.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/reddit/__init__.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/reddit/auth.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/reddit/client.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/reddit/read.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/reddit/schedule.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/reddit/write.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/rules/__init__.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/rules/commands/budget-plan.md +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/rules/commands/create-ad.md +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/rules/commands/create-campaign.md +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/rules_install.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/runtime.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/safety/__init__.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/safety/audit.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/safety/guards.py +0 -0
- {adloop-0.22.2 → adloop-0.23.0}/src/adloop/safety/preview.py +0 -0
- {adloop-0.22.2 → adloop-0.23.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.23.0
|
|
4
4
|
Summary: The AI command center for Google Ads, Reddit Ads, GA4, and tracking code.
|
|
5
5
|
Keywords: mcp,google-ads,reddit-ads,google-analytics,ga4,cursor,marketing
|
|
6
6
|
Author: Daniel Klose
|
|
@@ -81,7 +81,7 @@ AdLoop exists because managing Google Ads alongside your code is a mess. These a
|
|
|
81
81
|
|
|
82
82
|
- **"Is my tracking even working?"** Compare the event names in your actual codebase against what GA4 is receiving. Find the mismatches: events you fire that GA4 never sees, events GA4 records that you didn't know about.
|
|
83
83
|
|
|
84
|
-
- **"I need to create ads but the Google Ads UI is hostile."** Draft responsive search ads, create campaigns, add keywords — all through natural language. Every change shows a preview first. Nothing goes live without your explicit confirmation. New ads and campaigns start paused.
|
|
84
|
+
- **"I need to create ads but the Google Ads UI is hostile."** Draft responsive search ads, create campaigns, add keywords — all through natural language. Every change shows a preview first. Nothing goes live without your explicit confirmation: in Claude, ChatGPT and VS Code the preview is an approval card, and only your click on Apply changes the account (the AI cannot press it). New ads and campaigns start paused.
|
|
85
85
|
|
|
86
86
|
- **"My landing page gets paid traffic but nobody converts."** AdLoop joins your ad final URLs with GA4 page-level data. See which pages get clicks but no conversions, which have high bounce rates, and which ones are orphaned from any ad campaign.
|
|
87
87
|
|
|
@@ -53,7 +53,7 @@ AdLoop exists because managing Google Ads alongside your code is a mess. These a
|
|
|
53
53
|
|
|
54
54
|
- **"Is my tracking even working?"** Compare the event names in your actual codebase against what GA4 is receiving. Find the mismatches: events you fire that GA4 never sees, events GA4 records that you didn't know about.
|
|
55
55
|
|
|
56
|
-
- **"I need to create ads but the Google Ads UI is hostile."** Draft responsive search ads, create campaigns, add keywords — all through natural language. Every change shows a preview first. Nothing goes live without your explicit confirmation. New ads and campaigns start paused.
|
|
56
|
+
- **"I need to create ads but the Google Ads UI is hostile."** Draft responsive search ads, create campaigns, add keywords — all through natural language. Every change shows a preview first. Nothing goes live without your explicit confirmation: in Claude, ChatGPT and VS Code the preview is an approval card, and only your click on Apply changes the account (the AI cannot press it). New ads and campaigns start paused.
|
|
57
57
|
|
|
58
58
|
- **"My landing page gets paid traffic but nobody converts."** AdLoop joins your ad final URLs with GA4 page-level data. See which pages get clicks but no conversions, which have high bounce rates, and which ones are orphaned from any ad campaign.
|
|
59
59
|
|
|
@@ -87,6 +87,12 @@ class SafetyConfig:
|
|
|
87
87
|
# (which blocks real writes entirely), this only enforces the order:
|
|
88
88
|
# preview first, apply second.
|
|
89
89
|
two_phase_apply: bool = False
|
|
90
|
+
# Approval cards: when the AI app shows AdLoop's approval card (MCP
|
|
91
|
+
# Apps), only a click on the card applies a change, and
|
|
92
|
+
# confirm_and_apply(dry_run=false) from the model is refused. True also
|
|
93
|
+
# accepts the approval in chat, for app surfaces that claim card support
|
|
94
|
+
# but do not render it. Apps without cards always use the chat flow.
|
|
95
|
+
allow_chat_approval: bool = False
|
|
90
96
|
log_file: str = "~/.adloop/audit.log"
|
|
91
97
|
blocked_operations: list[str] = field(default_factory=list)
|
|
92
98
|
|
|
@@ -217,6 +223,7 @@ def load_config(config_path: str | None = None) -> AdLoopConfig:
|
|
|
217
223
|
max_bid_increase_pct=safety_raw.get("max_bid_increase_pct", 100),
|
|
218
224
|
require_dry_run=safety_raw.get("require_dry_run", True),
|
|
219
225
|
two_phase_apply=safety_raw.get("two_phase_apply", False),
|
|
226
|
+
allow_chat_approval=safety_raw.get("allow_chat_approval", False),
|
|
220
227
|
log_file=_text(safety_raw, "log_file", "~/.adloop/audit.log"),
|
|
221
228
|
blocked_operations=safety_raw.get("blocked_operations", []),
|
|
222
229
|
),
|
|
@@ -296,6 +296,8 @@ Same draft → preview → `confirm_and_apply` gate; the plan carries `platform:
|
|
|
296
296
|
3. Call `confirm_and_apply(plan_id=..., dry_run=true)` first to test
|
|
297
297
|
4. Only call with `dry_run=false` after explicit user confirmation
|
|
298
298
|
|
|
299
|
+
**Approval cards (Claude, ChatGPT, VS Code and other apps with MCP Apps):** when the preview comes back with `approval: "card"`, the user sees it as an AdLoop approval card with Test run, Apply and Discard buttons, and approves there. Summarize the change and its warnings, then let the user decide on the card instead of asking for a "yes" in the chat. In those apps `confirm_and_apply(dry_run=false)` is refused with status `APPROVE_ON_CARD` (a dry run still works), because only the click applies; the AI cannot press the button. Do not retry. If the user says the card is not visible, tell them the workspace owner can allow approval in chat (AdLoop Cloud: Settings → Safety; self-hosted: `safety.allow_chat_approval`). After a click the card posts a message such as "Applied on the AdLoop approval card: ..."; continue from there (e.g. remind them to enable a new paused ad). Apps without cards keep the chat flow above.
|
|
300
|
+
|
|
299
301
|
**Safety behaviors:**
|
|
300
302
|
- New campaigns and RSAs (and every Reddit campaign, ad group and ad) are created as PAUSED — user must explicitly enable them after review.
|
|
301
303
|
- `draft_campaign` REQUIRES `geo_target_ids` and `language_ids` — campaigns without targeting waste budget. The tool rejects drafts with missing targeting.
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Approval cards: a change preview that only a human click applies.
|
|
2
|
+
|
|
3
|
+
AI apps that support MCP Apps (the ``io.modelcontextprotocol/ui``
|
|
4
|
+
extension) render every write tool's preview as AdLoop's approval card,
|
|
5
|
+
with "Test run" and "Apply" buttons. The buttons call ``approve_change``,
|
|
6
|
+
an app-only tool: the MCP Apps spec requires the app to keep it out of the
|
|
7
|
+
model's tool list, AdLoop lists and runs it only for card-capable clients,
|
|
8
|
+
and every call needs the plan's approval code. That code travels in the
|
|
9
|
+
preview result's ``_meta``, which goes to the card and is not meant for the
|
|
10
|
+
model's context, and it is an HMAC the model cannot compute.
|
|
11
|
+
|
|
12
|
+
While a card can be shown, ``confirm_and_apply(dry_run=false)`` from the
|
|
13
|
+
model is refused unless ``safety.allow_chat_approval`` is on, so "the user
|
|
14
|
+
said yes" in the chat is not enough to change an account. Clients without
|
|
15
|
+
the extension keep the chat flow unchanged.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import contextvars
|
|
21
|
+
import hashlib
|
|
22
|
+
import hmac
|
|
23
|
+
import json
|
|
24
|
+
import secrets
|
|
25
|
+
from collections.abc import Iterator
|
|
26
|
+
from contextlib import contextmanager
|
|
27
|
+
from functools import lru_cache
|
|
28
|
+
from importlib import resources
|
|
29
|
+
from typing import TYPE_CHECKING, Any
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from adloop.safety.preview import ChangePlan
|
|
33
|
+
|
|
34
|
+
UI_EXTENSION_ID = "io.modelcontextprotocol/ui"
|
|
35
|
+
CARD_URI = "ui://adloop/approval-card.html"
|
|
36
|
+
CARD_MIME_TYPE = "text/html;profile=mcp-app"
|
|
37
|
+
META_KEY = "adloop/approval"
|
|
38
|
+
APPROVE_TOOL = "approve_change"
|
|
39
|
+
|
|
40
|
+
_secret: bytes = secrets.token_bytes(32)
|
|
41
|
+
_card_click: contextvars.ContextVar[bool] = contextvars.ContextVar(
|
|
42
|
+
"adloop_card_click", default=False
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def set_approval_secret(secret: bytes) -> None:
|
|
47
|
+
"""Key the approval codes.
|
|
48
|
+
|
|
49
|
+
Self-hosted, plans live in memory, so a random key per process is
|
|
50
|
+
enough. A hosted runtime keeps plans across restarts and workers and
|
|
51
|
+
sets a stable key derived from its own secrets.
|
|
52
|
+
"""
|
|
53
|
+
global _secret
|
|
54
|
+
if len(secret) < 32:
|
|
55
|
+
raise ValueError("approval secret must be at least 32 bytes")
|
|
56
|
+
_secret = bytes(secret)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def client_shows_cards() -> bool:
|
|
60
|
+
"""Whether the calling AI app declared MCP Apps support.
|
|
61
|
+
|
|
62
|
+
Read per request: on the 2026-07-28 protocol the capabilities ride every
|
|
63
|
+
request, so this also works on a stateless server. Anything that cannot
|
|
64
|
+
be read counts as "no cards", which keeps the chat flow.
|
|
65
|
+
"""
|
|
66
|
+
try:
|
|
67
|
+
from fastmcp.server.dependencies import get_context
|
|
68
|
+
|
|
69
|
+
return bool(get_context().client_supports_extension(UI_EXTENSION_ID))
|
|
70
|
+
except Exception:
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def approval_code(plan: ChangePlan) -> str:
|
|
75
|
+
"""The code the card must present to run or apply ``plan``."""
|
|
76
|
+
from adloop.runtime import current_tenant
|
|
77
|
+
|
|
78
|
+
message = "\x00".join((current_tenant(), plan.plan_id, plan.created_at))
|
|
79
|
+
return hmac.new(_secret, message.encode(), hashlib.sha256).hexdigest()[:32]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def code_matches(plan: ChangePlan, code: str) -> bool:
|
|
83
|
+
return hmac.compare_digest(approval_code(plan), str(code or ""))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def card_click_active() -> bool:
|
|
87
|
+
"""True while ``approve_change`` runs a click from the card."""
|
|
88
|
+
return _card_click.get()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@contextmanager
|
|
92
|
+
def card_click() -> Iterator[None]:
|
|
93
|
+
token = _card_click.set(True)
|
|
94
|
+
try:
|
|
95
|
+
yield
|
|
96
|
+
finally:
|
|
97
|
+
_card_click.reset(token)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def chat_apply_refusal(plan_id: str) -> dict | None:
|
|
101
|
+
"""The refusal for a model-issued real apply, or None when it may run."""
|
|
102
|
+
from adloop.runtime import current_config
|
|
103
|
+
|
|
104
|
+
if card_click_active() or not client_shows_cards():
|
|
105
|
+
return None
|
|
106
|
+
if current_config().safety.allow_chat_approval:
|
|
107
|
+
return None
|
|
108
|
+
return {
|
|
109
|
+
"status": "APPROVE_ON_CARD",
|
|
110
|
+
"plan_id": plan_id,
|
|
111
|
+
"message": (
|
|
112
|
+
"Nothing was changed. In this app, changes are applied only by the "
|
|
113
|
+
"user's click on the AdLoop approval card shown with the preview "
|
|
114
|
+
"(Test run, then Apply). Ask the user to approve it there; do not "
|
|
115
|
+
"retry confirm_and_apply with dry_run=false. If the card is not "
|
|
116
|
+
"visible in their app, the workspace owner can allow approval in "
|
|
117
|
+
"chat (AdLoop Cloud: Settings → Safety; self-hosted: "
|
|
118
|
+
"safety.allow_chat_approval in the config)."
|
|
119
|
+
),
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _instructions(plan_id: str) -> str:
|
|
124
|
+
from adloop.runtime import current_config
|
|
125
|
+
|
|
126
|
+
text = (
|
|
127
|
+
"The user sees this preview as an AdLoop approval card with Test run "
|
|
128
|
+
"and Apply buttons, and approves it there. Summarize the change and "
|
|
129
|
+
"any warnings, then let the user decide on the card."
|
|
130
|
+
)
|
|
131
|
+
if current_config().safety.allow_chat_approval:
|
|
132
|
+
return text + (
|
|
133
|
+
" Approval in chat is also allowed: only if the user explicitly "
|
|
134
|
+
f"says so, call confirm_and_apply with plan_id='{plan_id}'."
|
|
135
|
+
)
|
|
136
|
+
return text + (
|
|
137
|
+
" Do not call confirm_and_apply with dry_run=false: in this app it "
|
|
138
|
+
"is refused, only the click applies. A dry run "
|
|
139
|
+
f"(confirm_and_apply plan_id='{plan_id}', dry_run=true) is allowed."
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def with_card(result: Any) -> Any:
|
|
144
|
+
"""Return a write tool's preview as a card result for card-capable apps.
|
|
145
|
+
|
|
146
|
+
Anything that is not a pending preview (errors, refusals, notes) and
|
|
147
|
+
every result for an app without cards passes through unchanged.
|
|
148
|
+
"""
|
|
149
|
+
if not isinstance(result, dict) or result.get("status") != "PENDING_CONFIRMATION":
|
|
150
|
+
return result
|
|
151
|
+
if not client_shows_cards():
|
|
152
|
+
return result
|
|
153
|
+
from adloop.safety.preview import get_plan
|
|
154
|
+
|
|
155
|
+
plan = get_plan(str(result.get("plan_id") or ""))
|
|
156
|
+
if plan is None:
|
|
157
|
+
return result
|
|
158
|
+
|
|
159
|
+
from fastmcp.tools import ToolResult
|
|
160
|
+
from mcp.types import TextContent
|
|
161
|
+
|
|
162
|
+
preview = {**result, "approval": "card", "instructions": _instructions(plan.plan_id)}
|
|
163
|
+
return ToolResult(
|
|
164
|
+
content=[TextContent(type="text", text=json.dumps(preview, default=str))],
|
|
165
|
+
structured_content=preview,
|
|
166
|
+
meta={
|
|
167
|
+
META_KEY: {
|
|
168
|
+
"plan_id": plan.plan_id,
|
|
169
|
+
"code": approval_code(plan),
|
|
170
|
+
"tool": APPROVE_TOOL,
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@lru_cache(maxsize=1)
|
|
177
|
+
def card_html() -> str:
|
|
178
|
+
return (
|
|
179
|
+
resources.files("adloop.safety")
|
|
180
|
+
.joinpath("approval_card.html")
|
|
181
|
+
.read_text(encoding="utf-8")
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def is_app_only(meta: dict | None) -> bool:
|
|
186
|
+
"""True for tools the MCP Apps spec keeps out of the model's tool list."""
|
|
187
|
+
ui = (meta or {}).get("ui")
|
|
188
|
+
if not isinstance(ui, dict):
|
|
189
|
+
return False
|
|
190
|
+
visibility = ui.get("visibility")
|
|
191
|
+
return isinstance(visibility, list) and "model" not in visibility
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>AdLoop approval</title>
|
|
7
|
+
<style>
|
|
8
|
+
/* Hosts may send their colours as light-dark() values, which resolve by
|
|
9
|
+
color-scheme; the script sets it from the host theme. */
|
|
10
|
+
:root { color-scheme: light dark; }
|
|
11
|
+
:root[data-theme="light"] { color-scheme: light; }
|
|
12
|
+
:root[data-theme="dark"] { color-scheme: dark; }
|
|
13
|
+
:root {
|
|
14
|
+
--bg: var(--color-background-primary, #ffffff);
|
|
15
|
+
--bg-2: var(--color-background-secondary, #f4f4f5);
|
|
16
|
+
--fg: var(--color-text-primary, #18181b);
|
|
17
|
+
--fg-2: var(--color-text-secondary, #52525b);
|
|
18
|
+
--border: var(--color-border-primary, #e4e4e7);
|
|
19
|
+
--accent: #2563eb;
|
|
20
|
+
--accent-fg: #ffffff;
|
|
21
|
+
--ok: var(--color-text-success, #15803d);
|
|
22
|
+
--ok-bg: var(--color-background-success, #f0fdf4);
|
|
23
|
+
--warn: var(--color-text-warning, #a16207);
|
|
24
|
+
--warn-bg: var(--color-background-warning, #fefce8);
|
|
25
|
+
--bad: var(--color-text-danger, #b91c1c);
|
|
26
|
+
--bad-bg: var(--color-background-danger, #fef2f2);
|
|
27
|
+
--radius: var(--border-radius-lg, 12px);
|
|
28
|
+
--font: var(--font-sans, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif);
|
|
29
|
+
--mono: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
30
|
+
}
|
|
31
|
+
:root[data-theme="dark"] {
|
|
32
|
+
--bg: var(--color-background-primary, #18181b);
|
|
33
|
+
--bg-2: var(--color-background-secondary, #27272a);
|
|
34
|
+
--fg: var(--color-text-primary, #fafafa);
|
|
35
|
+
--fg-2: var(--color-text-secondary, #a1a1aa);
|
|
36
|
+
--border: var(--color-border-primary, #3f3f46);
|
|
37
|
+
--accent: #3b82f6;
|
|
38
|
+
--ok: var(--color-text-success, #4ade80);
|
|
39
|
+
--ok-bg: var(--color-background-success, #052e16);
|
|
40
|
+
--warn: var(--color-text-warning, #facc15);
|
|
41
|
+
--warn-bg: var(--color-background-warning, #2a2205);
|
|
42
|
+
--bad: var(--color-text-danger, #f87171);
|
|
43
|
+
--bad-bg: var(--color-background-danger, #2a0b0b);
|
|
44
|
+
}
|
|
45
|
+
* { box-sizing: border-box; }
|
|
46
|
+
html, body { margin: 0; background: transparent; }
|
|
47
|
+
body { font: 14px/1.45 var(--font); color: var(--fg); }
|
|
48
|
+
.card { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; }
|
|
49
|
+
.hidden { display: none !important; }
|
|
50
|
+
.head { display: flex; align-items: center; gap: 8px; color: var(--fg-2); font-size: 12px; }
|
|
51
|
+
.head svg { flex: none; }
|
|
52
|
+
.badge { margin-left: auto; border: 1px solid var(--border); border-radius: 999px; padding: 1px 8px; font-size: 11px; }
|
|
53
|
+
h1 { font-size: 16px; font-weight: 600; margin: 10px 0 2px; }
|
|
54
|
+
.sub { color: var(--fg-2); font-size: 12px; margin: 0 0 12px; overflow-wrap: anywhere; }
|
|
55
|
+
dl { display: grid; grid-template-columns: minmax(90px, max-content) 1fr; gap: 6px 12px; margin: 0; padding: 12px; background: var(--bg-2); border-radius: calc(var(--radius) - 4px); }
|
|
56
|
+
dt { color: var(--fg-2); font-size: 12px; overflow-wrap: anywhere; }
|
|
57
|
+
dd { margin: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
|
|
58
|
+
dd.mono { font: 12px/1.4 var(--mono); }
|
|
59
|
+
.more { grid-column: 1 / -1; color: var(--fg-2); font-size: 12px; }
|
|
60
|
+
.note { margin-top: 10px; padding: 8px 10px; border-radius: 8px; font-size: 13px; overflow-wrap: anywhere; }
|
|
61
|
+
.note.warn { color: var(--warn); background: var(--warn-bg); }
|
|
62
|
+
.note.ok { color: var(--ok); background: var(--ok-bg); }
|
|
63
|
+
.note.bad { color: var(--bad); background: var(--bad-bg); }
|
|
64
|
+
.note.info { color: var(--fg-2); background: var(--bg-2); }
|
|
65
|
+
label.ack { display: flex; gap: 8px; align-items: flex-start; margin-top: 12px; font-size: 13px; }
|
|
66
|
+
.actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 14px; flex-wrap: wrap; }
|
|
67
|
+
button { font: inherit; font-size: 13px; font-weight: 500; border-radius: 8px; padding: 7px 14px; cursor: pointer; border: 1px solid var(--border); background: transparent; color: var(--fg); }
|
|
68
|
+
button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); }
|
|
69
|
+
button.danger { background: var(--bad); border-color: var(--bad); color: #fff; }
|
|
70
|
+
button:disabled { opacity: .45; cursor: default; }
|
|
71
|
+
.steps { display: flex; gap: 6px; margin-top: 12px; font-size: 12px; color: var(--fg-2); }
|
|
72
|
+
.steps span.done { color: var(--ok); }
|
|
73
|
+
</style>
|
|
74
|
+
</head>
|
|
75
|
+
<body>
|
|
76
|
+
<div class="card hidden" id="card">
|
|
77
|
+
<div class="head">
|
|
78
|
+
<svg width="18" height="18" viewBox="0 0 32 32" fill="none" aria-hidden="true"><rect width="32" height="32" rx="7" fill="#09090b"/><path d="M16 16 C 13.5 11.5, 6.5 11.5, 6.5 16 C 6.5 20.5, 13.5 20.5, 16 16 C 18.5 11.5, 25.5 11.5, 25.5 16 C 25.5 20.5, 18.5 20.5, 16 16 Z" stroke="#60a5fa" stroke-width="2.6" stroke-linejoin="round" stroke-linecap="round"/></svg>
|
|
79
|
+
<span data-t="brand">AdLoop · approval required</span>
|
|
80
|
+
<span class="badge" id="platform"></span>
|
|
81
|
+
</div>
|
|
82
|
+
<h1 id="title"></h1>
|
|
83
|
+
<p class="sub" id="subtitle"></p>
|
|
84
|
+
<dl id="changes"></dl>
|
|
85
|
+
<div id="warnings"></div>
|
|
86
|
+
<label class="ack hidden" id="ackRow"><input type="checkbox" id="ack"> <span data-t="ack">I understand that this cannot be undone.</span></label>
|
|
87
|
+
<div class="steps"><span id="stepTest" data-t="stepTest">1 · Test run</span><span>→</span><span id="stepApply" data-t="stepApply">2 · Apply</span></div>
|
|
88
|
+
<div id="status"></div>
|
|
89
|
+
<div class="actions" id="actions">
|
|
90
|
+
<button id="discard" data-t="discard">Discard</button>
|
|
91
|
+
<button id="test" class="primary" data-t="test">Test run</button>
|
|
92
|
+
<button id="apply" class="primary hidden" data-t="apply">Apply</button>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
<script>
|
|
96
|
+
(() => {
|
|
97
|
+
"use strict";
|
|
98
|
+
const META_KEY = "adloop/approval";
|
|
99
|
+
const STRINGS = {
|
|
100
|
+
en: {
|
|
101
|
+
brand: "AdLoop · approval required", ack: "I understand that this cannot be undone.",
|
|
102
|
+
stepTest: "1 · Test run", stepApply: "2 · Apply", discard: "Discard", test: "Test run", apply: "Apply",
|
|
103
|
+
running: "Running the test…", applying: "Applying…",
|
|
104
|
+
testOk: "Test run passed. Nothing was changed yet. Apply makes the change live.",
|
|
105
|
+
testForced: "Live changes are switched off, so only test runs are possible.",
|
|
106
|
+
applied: "Applied.", discarded: "Discarded. Nothing was changed.",
|
|
107
|
+
failed: "Not applied:", more: "more fields not shown",
|
|
108
|
+
msgApplied: "Applied on the AdLoop approval card: {op} (plan {id}).",
|
|
109
|
+
msgDiscarded: "Discarded on the AdLoop approval card: {op} (plan {id}). Nothing was changed.",
|
|
110
|
+
gone: "This change is no longer pending (applied, discarded or expired).",
|
|
111
|
+
},
|
|
112
|
+
de: {
|
|
113
|
+
brand: "AdLoop · Freigabe nötig", ack: "Mir ist klar, dass sich das nicht rückgängig machen lässt.",
|
|
114
|
+
stepTest: "1 · Testlauf", stepApply: "2 · Anwenden", discard: "Verwerfen", test: "Testlauf", apply: "Anwenden",
|
|
115
|
+
running: "Testlauf läuft…", applying: "Wird angewendet…",
|
|
116
|
+
testOk: "Testlauf bestanden. Noch ist nichts geändert. Anwenden macht die Änderung live.",
|
|
117
|
+
testForced: "Echte Änderungen sind ausgeschaltet, daher sind nur Testläufe möglich.",
|
|
118
|
+
applied: "Angewendet.", discarded: "Verworfen. Nichts wurde geändert.",
|
|
119
|
+
failed: "Nicht angewendet:", more: "weitere Felder nicht angezeigt",
|
|
120
|
+
msgApplied: "Auf der AdLoop-Freigabekarte angewendet: {op} (Plan {id}).",
|
|
121
|
+
msgDiscarded: "Auf der AdLoop-Freigabekarte verworfen: {op} (Plan {id}). Nichts wurde geändert.",
|
|
122
|
+
gone: "Diese Änderung ist nicht mehr offen (angewendet, verworfen oder abgelaufen).",
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
const PLATFORMS = { google: "Google Ads", reddit: "Reddit Ads", ga4: "Google Analytics", gtm: "Tag Manager" };
|
|
126
|
+
const MAX_ROWS = 14;
|
|
127
|
+
|
|
128
|
+
let lang = "en";
|
|
129
|
+
// Until the host says otherwise, follow the system theme.
|
|
130
|
+
document.documentElement.dataset.theme =
|
|
131
|
+
window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
132
|
+
let nextId = 1;
|
|
133
|
+
const pending = new Map();
|
|
134
|
+
let plan = null; // { planId, code, tool, preview }
|
|
135
|
+
let busy = false;
|
|
136
|
+
|
|
137
|
+
const $ = (id) => document.getElementById(id);
|
|
138
|
+
const t = (key) => (STRINGS[lang] || STRINGS.en)[key] || STRINGS.en[key] || key;
|
|
139
|
+
|
|
140
|
+
function post(message) { window.parent.postMessage(message, "*"); }
|
|
141
|
+
function request(method, params) {
|
|
142
|
+
const id = nextId++;
|
|
143
|
+
post({ jsonrpc: "2.0", id, method, params });
|
|
144
|
+
return new Promise((resolve, reject) => pending.set(id, { resolve, reject }));
|
|
145
|
+
}
|
|
146
|
+
function notify(method, params) { post({ jsonrpc: "2.0", method, params }); }
|
|
147
|
+
|
|
148
|
+
function applyContext(ctx) {
|
|
149
|
+
if (!ctx) return;
|
|
150
|
+
if (ctx.theme === "dark" || ctx.theme === "light") document.documentElement.dataset.theme = ctx.theme;
|
|
151
|
+
const vars = ctx.styles && ctx.styles.variables;
|
|
152
|
+
if (vars) for (const [k, v] of Object.entries(vars)) if (v) document.documentElement.style.setProperty(k, v);
|
|
153
|
+
if (typeof ctx.locale === "string" && ctx.locale.toLowerCase().startsWith("de")) lang = "de";
|
|
154
|
+
for (const el of document.querySelectorAll("[data-t]")) el.textContent = t(el.dataset.t);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function reportSize() {
|
|
158
|
+
const el = document.documentElement;
|
|
159
|
+
notify("ui/notifications/size-changed", { width: el.scrollWidth, height: el.scrollHeight });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function humanize(op) {
|
|
163
|
+
const s = String(op || "change").replace(/^(reddit|gtm|ga4)_/, "").replace(/_/g, " ");
|
|
164
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function platformOf(preview) {
|
|
168
|
+
const op = String(preview.operation || "");
|
|
169
|
+
if (preview.platform === "reddit" || op.startsWith("reddit_")) return "reddit";
|
|
170
|
+
if (op.startsWith("gtm_")) return "gtm";
|
|
171
|
+
if (op.includes("key_event")) return "ga4";
|
|
172
|
+
return "google";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function formatValue(value) {
|
|
176
|
+
if (value === null || value === undefined) return { text: "–", mono: false };
|
|
177
|
+
if (typeof value === "boolean") return { text: value ? "✓" : "✗", mono: false };
|
|
178
|
+
if (typeof value !== "object") return { text: String(value), mono: false };
|
|
179
|
+
if (Array.isArray(value) && value.every((v) => typeof v !== "object" || v === null)) {
|
|
180
|
+
return { text: value.map(String).join("\n"), mono: false };
|
|
181
|
+
}
|
|
182
|
+
return { text: JSON.stringify(value, null, 2), mono: true };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function row(dl, key, value) {
|
|
186
|
+
const dt = document.createElement("dt");
|
|
187
|
+
dt.textContent = String(key).replace(/_/g, " ");
|
|
188
|
+
const dd = document.createElement("dd");
|
|
189
|
+
const v = formatValue(value);
|
|
190
|
+
dd.textContent = v.text;
|
|
191
|
+
if (v.mono) dd.classList.add("mono");
|
|
192
|
+
dl.append(dt, dd);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function note(container, kind, text) {
|
|
196
|
+
const div = document.createElement("div");
|
|
197
|
+
div.className = "note " + kind;
|
|
198
|
+
div.textContent = text;
|
|
199
|
+
container.append(div);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function render() {
|
|
203
|
+
const p = plan.preview;
|
|
204
|
+
$("platform").textContent = PLATFORMS[platformOf(p)];
|
|
205
|
+
$("title").textContent = humanize(p.operation);
|
|
206
|
+
const sub = [];
|
|
207
|
+
if (p.entity_type) sub.push(String(p.entity_type).replace(/_/g, " ") + (p.entity_id ? " " + p.entity_id : ""));
|
|
208
|
+
if (p.customer_id) sub.push(String(p.customer_id));
|
|
209
|
+
sub.push("plan " + plan.planId);
|
|
210
|
+
$("subtitle").textContent = sub.join(" · ");
|
|
211
|
+
|
|
212
|
+
const dl = $("changes");
|
|
213
|
+
dl.replaceChildren();
|
|
214
|
+
const entries = Object.entries(p.changes || {});
|
|
215
|
+
entries.slice(0, MAX_ROWS).forEach(([k, v]) => row(dl, k, v));
|
|
216
|
+
if (entries.length > MAX_ROWS) {
|
|
217
|
+
const more = document.createElement("div");
|
|
218
|
+
more.className = "more";
|
|
219
|
+
more.textContent = (entries.length - MAX_ROWS) + " " + t("more");
|
|
220
|
+
dl.append(more);
|
|
221
|
+
}
|
|
222
|
+
if (!entries.length) dl.classList.add("hidden");
|
|
223
|
+
|
|
224
|
+
const warnings = $("warnings");
|
|
225
|
+
warnings.replaceChildren();
|
|
226
|
+
const list = [].concat(p.warnings || [], (p.changes && p.changes.warnings) || []);
|
|
227
|
+
for (const w of list) note(warnings, "warn", typeof w === "string" ? w : JSON.stringify(w));
|
|
228
|
+
|
|
229
|
+
$("ackRow").classList.toggle("hidden", !p.requires_double_confirm);
|
|
230
|
+
$("card").classList.remove("hidden");
|
|
231
|
+
updateButtons();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
let tested = false;
|
|
235
|
+
let finished = false;
|
|
236
|
+
function updateButtons() {
|
|
237
|
+
const needsAck = plan.preview.requires_double_confirm && !$("ack").checked;
|
|
238
|
+
$("test").classList.toggle("hidden", tested || finished);
|
|
239
|
+
$("apply").classList.toggle("hidden", !tested || finished);
|
|
240
|
+
$("apply").classList.toggle("danger", !!plan.preview.requires_double_confirm);
|
|
241
|
+
$("apply").classList.toggle("primary", !plan.preview.requires_double_confirm);
|
|
242
|
+
$("discard").classList.toggle("hidden", finished);
|
|
243
|
+
$("test").disabled = busy;
|
|
244
|
+
$("apply").disabled = busy || needsAck;
|
|
245
|
+
$("discard").disabled = busy;
|
|
246
|
+
$("stepTest").classList.toggle("done", tested);
|
|
247
|
+
$("stepApply").classList.toggle("done", finished && tested);
|
|
248
|
+
reportSize();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function setStatus(kind, text) {
|
|
252
|
+
const s = $("status");
|
|
253
|
+
s.replaceChildren();
|
|
254
|
+
if (text) note(s, kind, text);
|
|
255
|
+
reportSize();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function resultPayload(result) {
|
|
259
|
+
if (!result) return {};
|
|
260
|
+
if (result.structuredContent && typeof result.structuredContent === "object") return result.structuredContent;
|
|
261
|
+
const text = (result.content || []).filter((c) => c && c.type === "text").map((c) => c.text).join("");
|
|
262
|
+
try { return JSON.parse(text); } catch (_) { return { message: text }; }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function act(action) {
|
|
266
|
+
if (busy || !plan) return;
|
|
267
|
+
busy = true;
|
|
268
|
+
setStatus("info", action === "test" ? t("running") : action === "apply" ? t("applying") : "");
|
|
269
|
+
updateButtons();
|
|
270
|
+
try {
|
|
271
|
+
const result = await request("tools/call", {
|
|
272
|
+
name: plan.tool,
|
|
273
|
+
arguments: { plan_id: plan.planId, code: plan.code, action, acknowledged: $("ack").checked },
|
|
274
|
+
});
|
|
275
|
+
const r = resultPayload(result);
|
|
276
|
+
const status = r.status || "";
|
|
277
|
+
if (result && result.isError || r.error) {
|
|
278
|
+
setStatus("bad", t("failed") + " " + (r.error || r.message || JSON.stringify(r)));
|
|
279
|
+
if (/no pending plan|no longer pending/i.test(String(r.error || ""))) { finished = true; setStatus("info", t("gone")); }
|
|
280
|
+
} else if (action === "test") {
|
|
281
|
+
if (status === "DRY_RUN_SUCCESS") {
|
|
282
|
+
tested = !r.dry_run_forced_by;
|
|
283
|
+
setStatus(r.dry_run_forced_by ? "warn" : "ok", r.dry_run_forced_by ? (r.remediation || t("testForced")) : (t("testOk") + (r.note ? " " + r.note : "")));
|
|
284
|
+
} else {
|
|
285
|
+
setStatus("bad", t("failed") + " " + (r.error || r.message || status));
|
|
286
|
+
}
|
|
287
|
+
} else if (action === "apply") {
|
|
288
|
+
if (status === "APPLIED" || status === "PARTIAL_UPLOAD") {
|
|
289
|
+
finished = true;
|
|
290
|
+
setStatus("ok", t("applied") + (r.message ? " " + r.message : ""));
|
|
291
|
+
tell(t("msgApplied"));
|
|
292
|
+
} else {
|
|
293
|
+
setStatus(status === "DRY_RUN_SUCCESS" ? "warn" : "bad", r.remediation || r.message || r.error || status);
|
|
294
|
+
}
|
|
295
|
+
} else if (action === "discard") {
|
|
296
|
+
finished = true;
|
|
297
|
+
setStatus("info", t("discarded"));
|
|
298
|
+
tell(t("msgDiscarded"));
|
|
299
|
+
}
|
|
300
|
+
} catch (err) {
|
|
301
|
+
setStatus("bad", t("failed") + " " + (err && err.message ? err.message : String(err)));
|
|
302
|
+
} finally {
|
|
303
|
+
busy = false;
|
|
304
|
+
updateButtons();
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function tell(template) {
|
|
309
|
+
const text = template.replace("{op}", humanize(plan.preview.operation)).replace("{id}", plan.planId);
|
|
310
|
+
request("ui/message", { role: "user", content: { type: "text", text } }).catch(() => {});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function onToolResult(result) {
|
|
314
|
+
if (plan || !result) return; // later results come from the card's own calls
|
|
315
|
+
const meta = (result._meta && result._meta[META_KEY]) || null;
|
|
316
|
+
if (!meta || !meta.code || !meta.plan_id) { reportSize(); return; }
|
|
317
|
+
const preview = resultPayload(result);
|
|
318
|
+
plan = { planId: meta.plan_id, code: meta.code, tool: meta.tool || "approve_change", preview };
|
|
319
|
+
render();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
window.addEventListener("message", (event) => {
|
|
323
|
+
if (event.source !== window.parent) return;
|
|
324
|
+
const msg = event.data;
|
|
325
|
+
if (!msg || msg.jsonrpc !== "2.0") return;
|
|
326
|
+
if (msg.id !== undefined && pending.has(msg.id) && !msg.method) {
|
|
327
|
+
const { resolve, reject } = pending.get(msg.id);
|
|
328
|
+
pending.delete(msg.id);
|
|
329
|
+
if (msg.error) reject(new Error(msg.error.message || "error")); else resolve(msg.result);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
switch (msg.method) {
|
|
333
|
+
case "ui/notifications/tool-result": onToolResult(msg.params); break;
|
|
334
|
+
case "ui/notifications/host-context-changed": applyContext(msg.params); break;
|
|
335
|
+
case "ui/resource-teardown": post({ jsonrpc: "2.0", id: msg.id, result: {} }); break;
|
|
336
|
+
case "ping": post({ jsonrpc: "2.0", id: msg.id, result: {} }); break;
|
|
337
|
+
default:
|
|
338
|
+
if (msg.id !== undefined && msg.method) post({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: "Method not found" } });
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
$("test").addEventListener("click", () => act("test"));
|
|
343
|
+
$("apply").addEventListener("click", () => act("apply"));
|
|
344
|
+
$("discard").addEventListener("click", () => act("discard"));
|
|
345
|
+
$("ack").addEventListener("change", updateButtons);
|
|
346
|
+
new ResizeObserver(reportSize).observe(document.documentElement);
|
|
347
|
+
|
|
348
|
+
request("ui/initialize", {
|
|
349
|
+
protocolVersion: "2026-01-26",
|
|
350
|
+
appCapabilities: { availableDisplayModes: ["inline"] },
|
|
351
|
+
clientInfo: { name: "AdLoop approval card", version: "1" },
|
|
352
|
+
}).then((result) => {
|
|
353
|
+
applyContext(result && result.hostContext);
|
|
354
|
+
notify("ui/notifications/initialized", {});
|
|
355
|
+
reportSize();
|
|
356
|
+
}).catch(() => notify("ui/notifications/initialized", {}));
|
|
357
|
+
})();
|
|
358
|
+
</script>
|
|
359
|
+
</body>
|
|
360
|
+
</html>
|
|
@@ -8,12 +8,22 @@ import json
|
|
|
8
8
|
from typing import Annotated, Callable
|
|
9
9
|
|
|
10
10
|
from fastmcp import FastMCP
|
|
11
|
+
from fastmcp.server.middleware import Middleware
|
|
11
12
|
from fastmcp.utilities.docstring_parsing import parse_docstring
|
|
12
13
|
from mcp.types import Icon, ToolAnnotations
|
|
13
14
|
from pydantic import BeforeValidator
|
|
14
15
|
|
|
15
16
|
from adloop import diagnostics
|
|
16
17
|
from adloop.runtime import current_config
|
|
18
|
+
from adloop.safety.approval import (
|
|
19
|
+
APPROVE_TOOL,
|
|
20
|
+
CARD_MIME_TYPE,
|
|
21
|
+
CARD_URI,
|
|
22
|
+
card_html,
|
|
23
|
+
client_shows_cards,
|
|
24
|
+
is_app_only,
|
|
25
|
+
with_card,
|
|
26
|
+
)
|
|
17
27
|
|
|
18
28
|
# openWorldHint: every tool talks to an external service (Google, Reddit,
|
|
19
29
|
# a live web page), which directories such as ChatGPT's require to be said.
|
|
@@ -115,6 +125,11 @@ def _build_orchestration_instructions() -> str:
|
|
|
115
125
|
"- Default to `dry_run=true` on `confirm_and_apply`. Only set "
|
|
116
126
|
"`dry_run=false` after the user explicitly approves the preview. "
|
|
117
127
|
"`require_dry_run` in config can override this.\n"
|
|
128
|
+
"- Approval cards: in apps that show AdLoop's approval card with a "
|
|
129
|
+
"preview (`approval: \"card\"`), the user approves with Test run and "
|
|
130
|
+
"Apply on the card. Summarize the change and let them decide there; "
|
|
131
|
+
"`confirm_and_apply` with `dry_run=false` is refused in those apps "
|
|
132
|
+
"unless the workspace allows approval in chat.\n"
|
|
118
133
|
"- Respect the config's `max_daily_budget` cap.\n"
|
|
119
134
|
"- New Google Ads campaigns and RSAs, and every new Reddit campaign, "
|
|
120
135
|
"ad group and ad, are created PAUSED. A new Google Ads ad group is "
|
|
@@ -179,6 +194,44 @@ mcp = FastMCP(
|
|
|
179
194
|
],
|
|
180
195
|
)
|
|
181
196
|
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@mcp.resource(
|
|
200
|
+
CARD_URI,
|
|
201
|
+
name="approval_card",
|
|
202
|
+
title="AdLoop approval card",
|
|
203
|
+
description="Renders a change preview with Test run and Apply buttons.",
|
|
204
|
+
mime_type=CARD_MIME_TYPE,
|
|
205
|
+
meta={"ui": {"prefersBorder": False}},
|
|
206
|
+
)
|
|
207
|
+
def approval_card() -> str:
|
|
208
|
+
return card_html()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class _AppOnlyToolsForCardClients(Middleware):
|
|
212
|
+
"""List and run app-only tools only for apps that render cards.
|
|
213
|
+
|
|
214
|
+
The MCP Apps spec makes a card-capable app hide app-only tools from its
|
|
215
|
+
model. An app without the extension knows nothing about that rule and
|
|
216
|
+
would hand them to its model, so they are not offered to it at all.
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
async def on_list_tools(self, context, call_next):
|
|
220
|
+
tools = await call_next(context)
|
|
221
|
+
if client_shows_cards():
|
|
222
|
+
return tools
|
|
223
|
+
return [tool for tool in tools if not is_app_only(tool.meta)]
|
|
224
|
+
|
|
225
|
+
async def on_call_tool(self, context, call_next):
|
|
226
|
+
if context.message.name == APPROVE_TOOL and not client_shows_cards():
|
|
227
|
+
from fastmcp.exceptions import ToolError
|
|
228
|
+
|
|
229
|
+
raise ToolError(f"{APPROVE_TOOL} can only be called from the AdLoop approval card.")
|
|
230
|
+
return await call_next(context)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
mcp.add_middleware(_AppOnlyToolsForCardClients())
|
|
234
|
+
|
|
182
235
|
# name -> tags of every registered tool, so health_check can say which tools
|
|
183
236
|
# this server offers without reaching into FastMCP internals.
|
|
184
237
|
_TOOL_TAGS: dict[str, frozenset[str]] = {}
|
|
@@ -215,6 +268,20 @@ _API_DOCS: dict[str, str] = {
|
|
|
215
268
|
}
|
|
216
269
|
|
|
217
270
|
|
|
271
|
+
# Write tools that do not produce a preview of their own.
|
|
272
|
+
_NO_CARD_TOOLS = frozenset({"confirm_and_apply", APPROVE_TOOL})
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _with_approval_card(fn: Callable) -> Callable:
|
|
276
|
+
"""Hand a write tool's preview to the approval card when the app shows one."""
|
|
277
|
+
|
|
278
|
+
@functools.wraps(fn)
|
|
279
|
+
def wrapper(*args, **kwargs):
|
|
280
|
+
return with_card(fn(*args, **kwargs))
|
|
281
|
+
|
|
282
|
+
return wrapper
|
|
283
|
+
|
|
284
|
+
|
|
218
285
|
def _tool(*, title: str, annotations: ToolAnnotations, tags: set[str], **kwargs):
|
|
219
286
|
"""Register a tool with its title in both places MCP defines one, and
|
|
220
287
|
with a link to the API it calls.
|
|
@@ -234,13 +301,21 @@ def _tool(*, title: str, annotations: ToolAnnotations, tags: set[str], **kwargs)
|
|
|
234
301
|
description = parse_docstring(fn).description or inspect.cleandoc(fn.__doc__ or "")
|
|
235
302
|
if docs and "https://" not in description:
|
|
236
303
|
description = f"{description}\n\n{docs}"
|
|
237
|
-
|
|
304
|
+
name = kwargs.get("name") or fn.__name__
|
|
305
|
+
options = dict(kwargs)
|
|
306
|
+
if not annotations.read_only_hint and name not in _NO_CARD_TOOLS:
|
|
307
|
+
# Write tools return a preview; apps with MCP Apps support
|
|
308
|
+
# render it as the approval card.
|
|
309
|
+
options["meta"] = {**(options.get("meta") or {}), "ui": {"resourceUri": CARD_URI}}
|
|
310
|
+
fn = _with_approval_card(fn)
|
|
311
|
+
if not is_app_only(options.get("meta")):
|
|
312
|
+
_TOOL_TAGS[name] = frozenset(tags)
|
|
238
313
|
return mcp.tool(
|
|
239
314
|
title=title,
|
|
240
315
|
annotations=annotations,
|
|
241
316
|
tags=tags,
|
|
242
317
|
description=description,
|
|
243
|
-
**
|
|
318
|
+
**options,
|
|
244
319
|
)(fn)
|
|
245
320
|
|
|
246
321
|
return register
|
|
@@ -3773,6 +3848,11 @@ def confirm_and_apply(
|
|
|
3773
3848
|
APPLY_IN_PROGRESS means an earlier call claimed this plan first, so this
|
|
3774
3849
|
call executed nothing.
|
|
3775
3850
|
|
|
3851
|
+
Approval cards: in AI apps that show AdLoop's approval card with the
|
|
3852
|
+
preview, dry_run=false is REFUSED with status APPROVE_ON_CARD, because
|
|
3853
|
+
only the user's click on the card applies the change. Dry runs still
|
|
3854
|
+
work. The workspace owner can allow approval in chat as well.
|
|
3855
|
+
|
|
3776
3856
|
Args:
|
|
3777
3857
|
plan_id: The plan_id returned by a prior draft_*, update_*, add_*,
|
|
3778
3858
|
pause/enable/remove or other preview tool call. An unknown or
|
|
@@ -3781,10 +3861,78 @@ def confirm_and_apply(
|
|
|
3781
3861
|
applies the change for real.
|
|
3782
3862
|
"""
|
|
3783
3863
|
from adloop.ads.write import confirm_and_apply as _impl
|
|
3864
|
+
from adloop.safety.approval import chat_apply_refusal
|
|
3784
3865
|
|
|
3866
|
+
if not dry_run:
|
|
3867
|
+
refusal = chat_apply_refusal(plan_id)
|
|
3868
|
+
if refusal is not None:
|
|
3869
|
+
return refusal
|
|
3785
3870
|
return _impl(current_config(), plan_id=plan_id, dry_run=dry_run)
|
|
3786
3871
|
|
|
3787
3872
|
|
|
3873
|
+
@_tool(
|
|
3874
|
+
title="Approve a change on its card",
|
|
3875
|
+
annotations=_DESTRUCTIVE,
|
|
3876
|
+
tags={"core"},
|
|
3877
|
+
meta={"ui": {"resourceUri": CARD_URI, "visibility": ["app"]}},
|
|
3878
|
+
)
|
|
3879
|
+
@_safe
|
|
3880
|
+
def approve_change(
|
|
3881
|
+
plan_id: str,
|
|
3882
|
+
code: str,
|
|
3883
|
+
action: str = "test",
|
|
3884
|
+
acknowledged: bool = False,
|
|
3885
|
+
) -> dict:
|
|
3886
|
+
"""Run, apply or discard a previewed change from its approval card.
|
|
3887
|
+
|
|
3888
|
+
Called only by the AdLoop approval card when the user clicks a button;
|
|
3889
|
+
AI apps keep this tool out of the model's tool list.
|
|
3890
|
+
|
|
3891
|
+
Args:
|
|
3892
|
+
plan_id: The plan shown on the card.
|
|
3893
|
+
code: The card's approval code for this plan.
|
|
3894
|
+
action: "test" (dry run), "apply" (real change) or "discard".
|
|
3895
|
+
acknowledged: The user ticked "cannot be undone"; required to apply a
|
|
3896
|
+
plan that needs double confirmation.
|
|
3897
|
+
"""
|
|
3898
|
+
from adloop.ads.write import confirm_and_apply as _impl
|
|
3899
|
+
from adloop.safety.approval import card_click, code_matches
|
|
3900
|
+
from adloop.safety.audit import log_mutation
|
|
3901
|
+
from adloop.safety.preview import get_plan, remove_plan
|
|
3902
|
+
|
|
3903
|
+
if action not in {"test", "apply", "discard"}:
|
|
3904
|
+
return {"error": "action must be test, apply or discard."}
|
|
3905
|
+
plan = get_plan(plan_id)
|
|
3906
|
+
if plan is None:
|
|
3907
|
+
return {
|
|
3908
|
+
"error": f"No pending plan '{plan_id}': it is no longer pending "
|
|
3909
|
+
"(applied, discarded or expired)."
|
|
3910
|
+
}
|
|
3911
|
+
if not code_matches(plan, code):
|
|
3912
|
+
return {"error": "This card does not match the change. Draft the change again."}
|
|
3913
|
+
config = current_config()
|
|
3914
|
+
if action == "discard":
|
|
3915
|
+
remove_plan(plan_id)
|
|
3916
|
+
log_mutation(
|
|
3917
|
+
config.safety.log_file,
|
|
3918
|
+
operation=plan.operation,
|
|
3919
|
+
customer_id=plan.customer_id,
|
|
3920
|
+
entity_type=plan.entity_type,
|
|
3921
|
+
entity_id=plan.entity_id,
|
|
3922
|
+
changes=plan.changes,
|
|
3923
|
+
dry_run=True,
|
|
3924
|
+
result="discarded",
|
|
3925
|
+
)
|
|
3926
|
+
return {"status": "DISCARDED", "plan_id": plan_id, "operation": plan.operation}
|
|
3927
|
+
if action == "apply" and plan.requires_double_confirm and not acknowledged:
|
|
3928
|
+
return {
|
|
3929
|
+
"error": "This change cannot be undone: tick the confirmation on the "
|
|
3930
|
+
"card before applying."
|
|
3931
|
+
}
|
|
3932
|
+
with card_click():
|
|
3933
|
+
return _impl(config, plan_id=plan_id, dry_run=action == "test")
|
|
3934
|
+
|
|
3935
|
+
|
|
3788
3936
|
# ---------------------------------------------------------------------------
|
|
3789
3937
|
# Reddit Ads Tools
|
|
3790
3938
|
# ---------------------------------------------------------------------------
|
|
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
|
|
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
|
|
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
|