adloop 0.23.0__tar.gz → 0.24.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 (69) hide show
  1. {adloop-0.23.0 → adloop-0.24.0}/PKG-INFO +1 -1
  2. {adloop-0.23.0 → adloop-0.24.0}/pyproject.toml +1 -1
  3. {adloop-0.23.0 → adloop-0.24.0}/pyproject.toml.orig +1 -1
  4. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/config.py +2 -1
  5. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/rules/adloop.md +3 -1
  6. adloop-0.24.0/src/adloop/safety/approval.py +380 -0
  7. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/server.py +56 -1
  8. adloop-0.23.0/src/adloop/safety/approval.py +0 -191
  9. {adloop-0.23.0 → adloop-0.24.0}/README.md +0 -0
  10. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/__init__.py +0 -0
  11. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/__main__.py +0 -0
  12. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/_mcp_patches.py +0 -0
  13. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/ads/__init__.py +0 -0
  14. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/ads/ai_max.py +0 -0
  15. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/ads/brands.py +0 -0
  16. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/ads/client.py +0 -0
  17. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/ads/conversion_actions.py +0 -0
  18. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/ads/currency.py +0 -0
  19. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/ads/custom_conversion_goals.py +0 -0
  20. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/ads/enums.py +0 -0
  21. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/ads/forecast.py +0 -0
  22. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/ads/gaql.py +0 -0
  23. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/ads/image_fetch.py +0 -0
  24. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/ads/pmax.py +0 -0
  25. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/ads/read.py +0 -0
  26. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/ads/validate_only.py +0 -0
  27. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/ads/write.py +0 -0
  28. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/auth.py +0 -0
  29. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/cli.py +0 -0
  30. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/crossref.py +0 -0
  31. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/diagnostics.py +0 -0
  32. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/ga4/__init__.py +0 -0
  33. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/ga4/client.py +0 -0
  34. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/ga4/reports.py +0 -0
  35. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/ga4/tracking.py +0 -0
  36. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/ga4/write.py +0 -0
  37. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/gsc/__init__.py +0 -0
  38. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/gsc/client.py +0 -0
  39. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/gsc/reports.py +0 -0
  40. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/gtm/__init__.py +0 -0
  41. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/gtm/client.py +0 -0
  42. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/gtm/entities.py +0 -0
  43. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/gtm/read.py +0 -0
  44. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/gtm/write.py +0 -0
  45. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/merchant/__init__.py +0 -0
  46. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/merchant/client.py +0 -0
  47. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/merchant/read.py +0 -0
  48. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/pagespeed.py +0 -0
  49. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/reddit/__init__.py +0 -0
  50. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/reddit/auth.py +0 -0
  51. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/reddit/client.py +0 -0
  52. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/reddit/read.py +0 -0
  53. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/reddit/schedule.py +0 -0
  54. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/reddit/write.py +0 -0
  55. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/rules/__init__.py +0 -0
  56. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
  57. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/rules/commands/budget-plan.md +0 -0
  58. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/rules/commands/create-ad.md +0 -0
  59. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/rules/commands/create-campaign.md +0 -0
  60. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
  61. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
  62. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/rules_install.py +0 -0
  63. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/runtime.py +0 -0
  64. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/safety/__init__.py +0 -0
  65. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/safety/approval_card.html +0 -0
  66. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/safety/audit.py +0 -0
  67. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/safety/guards.py +0 -0
  68. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/safety/preview.py +0 -0
  69. {adloop-0.23.0 → adloop-0.24.0}/src/adloop/tracking.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: adloop
3
- Version: 0.23.0
3
+ Version: 0.24.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.23.0"
3
+ version = "0.24.0"
4
4
  description = "The AI command center for Google Ads, Reddit Ads, GA4, and tracking code."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.23.0"
3
+ version = "0.24.0"
4
4
  description = "The AI command center for Google Ads, Reddit Ads, GA4, and tracking code."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -91,7 +91,8 @@ class SafetyConfig:
91
91
  # Apps), only a click on the card applies a change, and
92
92
  # confirm_and_apply(dry_run=false) from the model is refused. True also
93
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.
94
+ # but do not render it. Apps without cards that support confirmation
95
+ # dialogs ask in a dialog before a real apply; True skips that as well.
95
96
  allow_chat_approval: bool = False
96
97
  log_file: str = "~/.adloop/audit.log"
97
98
  blocked_operations: list[str] = field(default_factory=list)
@@ -296,7 +296,9 @@ 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.
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).
300
+
301
+ **Confirmation dialogs (apps without cards that support elicitation):** when you call `confirm_and_apply(dry_run=false)` after the user's go-ahead, AdLoop itself asks the user in the app's confirmation dialog before anything changes; irreversible changes need an extra "cannot be undone" tick there. You do not answer that dialog. `NOT_CONFIRMED` means the user declined or closed it: nothing was changed, the preview stays pending, so ask what they want instead of retrying. Apps with neither cards nor dialogs keep the chat flow above.
300
302
 
301
303
  **Safety behaviors:**
302
304
  - New campaigns and RSAs (and every Reddit campaign, ad group and ad) are created as PAUSED — user must explicitly enable them after review.
@@ -0,0 +1,380 @@
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.
15
+
16
+ Apps without cards that support elicitation (confirmation dialogs) get a
17
+ dialog instead: before ``confirm_and_apply(dry_run=false)`` runs, the server
18
+ asks the user directly, and a decline changes nothing. On the 2026-07-28
19
+ protocol the dialog is an ``InputRequiredResult`` round trip, which works on
20
+ a stateless server; on older protocols it is a server-initiated request,
21
+ used only self-hosted, where the connection can carry it. Apps with neither
22
+ keep the chat flow unchanged.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import contextvars
28
+ import hashlib
29
+ import hmac
30
+ import json
31
+ import secrets
32
+ from dataclasses import dataclass
33
+ from collections.abc import Iterator
34
+ from contextlib import contextmanager
35
+ from functools import lru_cache
36
+ from importlib import resources
37
+ from typing import TYPE_CHECKING, Any
38
+
39
+ if TYPE_CHECKING:
40
+ from adloop.safety.preview import ChangePlan
41
+
42
+ UI_EXTENSION_ID = "io.modelcontextprotocol/ui"
43
+ CARD_URI = "ui://adloop/approval-card.html"
44
+ CARD_MIME_TYPE = "text/html;profile=mcp-app"
45
+ META_KEY = "adloop/approval"
46
+ APPROVE_TOOL = "approve_change"
47
+
48
+ _secret: bytes = secrets.token_bytes(32)
49
+ _card_click: contextvars.ContextVar[bool] = contextvars.ContextVar(
50
+ "adloop_card_click", default=False
51
+ )
52
+
53
+
54
+ def set_approval_secret(secret: bytes) -> None:
55
+ """Key the approval codes.
56
+
57
+ Self-hosted, plans live in memory, so a random key per process is
58
+ enough. A hosted runtime keeps plans across restarts and workers and
59
+ sets a stable key derived from its own secrets.
60
+ """
61
+ global _secret
62
+ if len(secret) < 32:
63
+ raise ValueError("approval secret must be at least 32 bytes")
64
+ _secret = bytes(secret)
65
+
66
+
67
+ def client_shows_cards() -> bool:
68
+ """Whether the calling AI app declared MCP Apps support.
69
+
70
+ Read per request: on the 2026-07-28 protocol the capabilities ride every
71
+ request, so this also works on a stateless server. Anything that cannot
72
+ be read counts as "no cards", which keeps the chat flow.
73
+ """
74
+ try:
75
+ from fastmcp.server.dependencies import get_context
76
+
77
+ return bool(get_context().client_supports_extension(UI_EXTENSION_ID))
78
+ except Exception:
79
+ return False
80
+
81
+
82
+ def approval_code(plan: ChangePlan) -> str:
83
+ """The code the card must present to run or apply ``plan``."""
84
+ from adloop.runtime import current_tenant
85
+
86
+ message = "\x00".join((current_tenant(), plan.plan_id, plan.created_at))
87
+ return hmac.new(_secret, message.encode(), hashlib.sha256).hexdigest()[:32]
88
+
89
+
90
+ def code_matches(plan: ChangePlan, code: str) -> bool:
91
+ return hmac.compare_digest(approval_code(plan), str(code or ""))
92
+
93
+
94
+ def card_click_active() -> bool:
95
+ """True while ``approve_change`` runs a click from the card."""
96
+ return _card_click.get()
97
+
98
+
99
+ @contextmanager
100
+ def card_click() -> Iterator[None]:
101
+ token = _card_click.set(True)
102
+ try:
103
+ yield
104
+ finally:
105
+ _card_click.reset(token)
106
+
107
+
108
+ def chat_apply_refusal(plan_id: str) -> dict | None:
109
+ """The refusal for a model-issued real apply, or None when it may run."""
110
+ from adloop.runtime import current_config
111
+
112
+ if card_click_active() or not client_shows_cards():
113
+ return None
114
+ if current_config().safety.allow_chat_approval:
115
+ return None
116
+ return {
117
+ "status": "APPROVE_ON_CARD",
118
+ "plan_id": plan_id,
119
+ "message": (
120
+ "Nothing was changed. In this app, changes are applied only by the "
121
+ "user's click on the AdLoop approval card shown with the preview "
122
+ "(Test run, then Apply). Ask the user to approve it there; do not "
123
+ "retry confirm_and_apply with dry_run=false. If the card is not "
124
+ "visible in their app, the workspace owner can allow approval in "
125
+ "chat (AdLoop Cloud: Settings → Safety; self-hosted: "
126
+ "safety.allow_chat_approval in the config)."
127
+ ),
128
+ }
129
+
130
+
131
+ def _instructions(plan_id: str) -> str:
132
+ from adloop.runtime import current_config
133
+
134
+ text = (
135
+ "The user sees this preview as an AdLoop approval card with Test run "
136
+ "and Apply buttons, and approves it there. Summarize the change and "
137
+ "any warnings, then let the user decide on the card."
138
+ )
139
+ if current_config().safety.allow_chat_approval:
140
+ return text + (
141
+ " Approval in chat is also allowed: only if the user explicitly "
142
+ f"says so, call confirm_and_apply with plan_id='{plan_id}'."
143
+ )
144
+ return text + (
145
+ " Do not call confirm_and_apply with dry_run=false: in this app it "
146
+ "is refused, only the click applies. A dry run "
147
+ f"(confirm_and_apply plan_id='{plan_id}', dry_run=true) is allowed."
148
+ )
149
+
150
+
151
+ def with_card(result: Any) -> Any:
152
+ """Return a write tool's preview as a card result for card-capable apps.
153
+
154
+ Anything that is not a pending preview (errors, refusals, notes) and
155
+ every result for an app without cards passes through unchanged.
156
+ """
157
+ if not isinstance(result, dict) or result.get("status") != "PENDING_CONFIRMATION":
158
+ return result
159
+ if not client_shows_cards():
160
+ return result
161
+ from adloop.safety.preview import get_plan
162
+
163
+ plan = get_plan(str(result.get("plan_id") or ""))
164
+ if plan is None:
165
+ return result
166
+
167
+ from fastmcp.tools import ToolResult
168
+ from mcp.types import TextContent
169
+
170
+ preview = {**result, "approval": "card", "instructions": _instructions(plan.plan_id)}
171
+ return ToolResult(
172
+ content=[TextContent(type="text", text=json.dumps(preview, default=str))],
173
+ structured_content=preview,
174
+ meta={
175
+ META_KEY: {
176
+ "plan_id": plan.plan_id,
177
+ "code": approval_code(plan),
178
+ "tool": APPROVE_TOOL,
179
+ }
180
+ },
181
+ )
182
+
183
+
184
+ @lru_cache(maxsize=1)
185
+ def card_html() -> str:
186
+ return (
187
+ resources.files("adloop.safety")
188
+ .joinpath("approval_card.html")
189
+ .read_text(encoding="utf-8")
190
+ )
191
+
192
+
193
+ def is_app_only(meta: dict | None) -> bool:
194
+ """True for tools the MCP Apps spec keeps out of the model's tool list."""
195
+ ui = (meta or {}).get("ui")
196
+ if not isinstance(ui, dict):
197
+ return False
198
+ visibility = ui.get("visibility")
199
+ return isinstance(visibility, list) and "model" not in visibility
200
+
201
+
202
+ # ---------------------------------------------------------------------------
203
+ # Confirmation dialogs (elicitation) for apps without cards
204
+ # ---------------------------------------------------------------------------
205
+
206
+ DIALOG_KEY = "adloop_confirm"
207
+ _MAX_DIALOG_FIELDS = 10
208
+
209
+
210
+ @dataclass
211
+ class ApplyConfirmation:
212
+ apply: bool = False
213
+
214
+
215
+ @dataclass
216
+ class IrreversibleConfirmation:
217
+ apply: bool = False
218
+ cannot_be_undone: bool = False
219
+
220
+
221
+ def _confirmation_type(plan: ChangePlan) -> type:
222
+ return IrreversibleConfirmation if plan.requires_double_confirm else ApplyConfirmation
223
+
224
+
225
+ def client_can_elicit() -> bool:
226
+ """Whether the calling app declared form elicitation (confirmation dialogs)."""
227
+ try:
228
+ from fastmcp.server.dependencies import get_context
229
+
230
+ caps = get_context().session.client_capabilities
231
+ except Exception:
232
+ return False
233
+ elicitation = getattr(caps, "elicitation", None) if caps is not None else None
234
+ if elicitation is None:
235
+ return False
236
+ # An empty capability object means form mode (the spec's default).
237
+ form = getattr(elicitation, "form", None)
238
+ url = getattr(elicitation, "url", None)
239
+ return form is not None or url is None
240
+
241
+
242
+ def _platform_label(plan: ChangePlan) -> str:
243
+ op = plan.operation
244
+ if op.startswith("reddit_"):
245
+ return "Reddit Ads"
246
+ if op.startswith("gtm_"):
247
+ return "Google Tag Manager"
248
+ if "key_event" in op:
249
+ return "Google Analytics"
250
+ return "Google Ads"
251
+
252
+
253
+ def confirmation_message(plan: ChangePlan) -> str:
254
+ """The dialog text: what changes, where, and what confirming does."""
255
+ title = plan.operation.replace("reddit_", "").replace("gtm_", "").replace("_", " ")
256
+ target = " ".join(
257
+ part for part in (plan.entity_type.replace("_", " "), plan.entity_id) if part
258
+ )
259
+ lines = [f"AdLoop: apply this change in {_platform_label(plan)}?", ""]
260
+ lines.append(title.capitalize() + (f" ({target})" if target else ""))
261
+ for key, value in list(plan.changes.items())[:_MAX_DIALOG_FIELDS]:
262
+ text = value if isinstance(value, str) else json.dumps(value, default=str)
263
+ if len(text) > 160:
264
+ text = text[:157] + "..."
265
+ lines.append(f"- {key.replace('_', ' ')}: {text}")
266
+ if len(plan.changes) > _MAX_DIALOG_FIELDS:
267
+ lines.append(f"- ... {len(plan.changes) - _MAX_DIALOG_FIELDS} more fields in the preview")
268
+ lines.append("")
269
+ if plan.requires_double_confirm:
270
+ lines.append("This cannot be undone. Tick both boxes to apply it for real.")
271
+ else:
272
+ lines.append("Tick the box to apply it for real. Declining changes nothing.")
273
+ return "\n".join(lines)
274
+
275
+
276
+ def _accepted(plan: ChangePlan, action: str | None, content: Any) -> bool:
277
+ if action != "accept":
278
+ return False
279
+ if content is None:
280
+ data: dict = {}
281
+ elif isinstance(content, dict):
282
+ data = content
283
+ else:
284
+ data = vars(content)
285
+ if not data.get("apply"):
286
+ return False
287
+ return not plan.requires_double_confirm or bool(data.get("cannot_be_undone"))
288
+
289
+
290
+ def _not_confirmed(plan: ChangePlan) -> dict:
291
+ return {
292
+ "status": "NOT_CONFIRMED",
293
+ "plan_id": plan.plan_id,
294
+ "operation": plan.operation,
295
+ "message": (
296
+ "The user did not confirm the change in the confirmation dialog, so "
297
+ "nothing was changed. Do not retry on your own; ask the user what "
298
+ "they want to do. The preview stays pending."
299
+ ),
300
+ }
301
+
302
+
303
+ def _dialog_applies(plan_id: str) -> ChangePlan | None:
304
+ """The plan when a real apply must be confirmed in a dialog, else None."""
305
+ from adloop.runtime import current_config
306
+ from adloop.safety.preview import get_plan
307
+
308
+ if card_click_active() or client_shows_cards() or not client_can_elicit():
309
+ return None
310
+ safety = current_config().safety
311
+ if safety.allow_chat_approval or safety.require_dry_run:
312
+ # Chat approval is allowed, or the apply turns into a dry run anyway.
313
+ return None
314
+ plan = get_plan(plan_id)
315
+ if plan is None:
316
+ return None
317
+ if safety.two_phase_apply and plan.dry_run_result is None:
318
+ # confirm_and_apply refuses with DRY_RUN_REQUIRED; don't ask first.
319
+ return None
320
+ return plan
321
+
322
+
323
+ async def confirm_in_dialog(context: Any, plan_id: str) -> Any | None:
324
+ """Ask the user before a real apply; None means "go ahead".
325
+
326
+ Otherwise returns an ``InputRequiredToolResult`` (2026-07-28: the app
327
+ shows the dialog and repeats the call with the answer) or a
328
+ NOT_CONFIRMED result when the user declined.
329
+ """
330
+ plan = _dialog_applies(plan_id)
331
+ if plan is None:
332
+ return None
333
+
334
+ from fastmcp.server.elicitation import parse_elicit_response_type
335
+ from fastmcp.tools import ToolResult
336
+
337
+ response_type = _confirmation_type(plan)
338
+ message = confirmation_message(plan)
339
+
340
+ if context._is_modern_protocol():
341
+ responses = context.input_responses
342
+ if responses is None:
343
+ import mcp_types
344
+ from fastmcp.tools.base import InputRequiredToolResult
345
+
346
+ schema = parse_elicit_response_type(response_type).schema
347
+ request = mcp_types.ElicitRequest(
348
+ params=mcp_types.ElicitRequestFormParams(
349
+ mode="form", message=message, requested_schema=schema
350
+ )
351
+ )
352
+ state = json.dumps({"plan_id": plan.plan_id, "code": approval_code(plan)})
353
+ return InputRequiredToolResult(
354
+ mcp_types.InputRequiredResult(
355
+ input_requests={DIALOG_KEY: request}, request_state=state
356
+ )
357
+ )
358
+ try:
359
+ state = json.loads(context.request_state or "{}")
360
+ except ValueError:
361
+ state = {}
362
+ if state.get("plan_id") != plan.plan_id or not code_matches(plan, state.get("code", "")):
363
+ return ToolResult(structured_content=_not_confirmed(plan))
364
+ answer = responses.get(DIALOG_KEY) if hasattr(responses, "get") else None
365
+ action = getattr(answer, "action", None)
366
+ content = getattr(answer, "content", None)
367
+ else:
368
+ from adloop.runtime import deployment_mode
369
+
370
+ if deployment_mode() == "server":
371
+ # A stateless hosted server cannot push a request to the app on
372
+ # the older protocol; that app keeps the chat flow.
373
+ return None
374
+ answer = await context.elicit(message, response_type)
375
+ action = getattr(answer, "action", None)
376
+ content = getattr(answer, "data", None)
377
+
378
+ if _accepted(plan, action, content):
379
+ return None
380
+ return ToolResult(structured_content=_not_confirmed(plan))
@@ -129,7 +129,10 @@ def _build_orchestration_instructions() -> str:
129
129
  "preview (`approval: \"card\"`), the user approves with Test run and "
130
130
  "Apply on the card. Summarize the change and let them decide there; "
131
131
  "`confirm_and_apply` with `dry_run=false` is refused in those apps "
132
- "unless the workspace allows approval in chat.\n"
132
+ "unless the workspace allows approval in chat. In apps without cards that "
133
+ "support confirmation dialogs, AdLoop asks the user in a dialog before a "
134
+ "real apply; `NOT_CONFIRMED` means they declined and nothing changed, so "
135
+ "ask instead of retrying.\n"
133
136
  "- Respect the config's `max_daily_budget` cap.\n"
134
137
  "- New Google Ads campaigns and RSAs, and every new Reddit campaign, "
135
138
  "ad group and ad, are created PAUSED. A new Google Ads ad group is "
@@ -232,6 +235,58 @@ class _AppOnlyToolsForCardClients(Middleware):
232
235
 
233
236
  mcp.add_middleware(_AppOnlyToolsForCardClients())
234
237
 
238
+
239
+ def _wants_real_apply(arguments: dict | None) -> bool:
240
+ value = (arguments or {}).get("dry_run", True)
241
+ if isinstance(value, str):
242
+ return value.strip().lower() in {"false", "0", "no"}
243
+ return value is False or value == 0
244
+
245
+
246
+ class _ConfirmationDialog(Middleware):
247
+ """Ask the user in a dialog before a real apply, in apps without cards.
248
+
249
+ Apps that declared elicitation get the question from the server itself,
250
+ so the AI cannot answer it on the user's behalf. Apps with cards keep
251
+ the card, apps with neither keep the chat flow.
252
+ """
253
+
254
+ async def on_call_tool(self, context, call_next):
255
+ message = context.message
256
+ if message.name == "confirm_and_apply" and _wants_real_apply(message.arguments):
257
+ from adloop.safety.approval import confirm_in_dialog
258
+
259
+ outcome = await confirm_in_dialog(
260
+ context.fastmcp_context, str((message.arguments or {}).get("plan_id") or "")
261
+ )
262
+ if outcome is not None:
263
+ return outcome
264
+ return await call_next(context)
265
+
266
+
267
+ mcp.add_middleware(_ConfirmationDialog())
268
+
269
+
270
+ def set_request_state_keys(keys: list[bytes]) -> None:
271
+ """Seal multi-round-trip state (confirmation dialogs) with stable keys.
272
+
273
+ FastMCP seals the state a tool hands to the app between rounds with a
274
+ per-process key unless told otherwise. A hosted runtime whose next round
275
+ can reach another worker, or land after a restart, passes shared keys
276
+ here (``keys[0]`` seals, every key unseals, for rotation).
277
+ """
278
+ from mcp.server.request_state import RequestStateBoundary, RequestStateSecurity
279
+
280
+ security = RequestStateSecurity(keys=keys)
281
+ mcp._request_state_security = security
282
+ low_level = mcp._mcp_server
283
+ low_level.middleware[:] = [
284
+ RequestStateBoundary(security, default_audience=low_level.name)
285
+ if isinstance(m, RequestStateBoundary)
286
+ else m
287
+ for m in low_level.middleware
288
+ ]
289
+
235
290
  # name -> tags of every registered tool, so health_check can say which tools
236
291
  # this server offers without reaching into FastMCP internals.
237
292
  _TOOL_TAGS: dict[str, frozenset[str]] = {}
@@ -1,191 +0,0 @@
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
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