adloop 0.29.0__tar.gz → 0.29.2__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 (72) hide show
  1. {adloop-0.29.0 → adloop-0.29.2}/PKG-INFO +1 -1
  2. {adloop-0.29.0 → adloop-0.29.2}/pyproject.toml +1 -1
  3. {adloop-0.29.0 → adloop-0.29.2}/pyproject.toml.orig +1 -1
  4. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/safety/approval.py +35 -0
  5. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/server.py +31 -4
  6. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/upload_card.html +7 -1
  7. {adloop-0.29.0 → adloop-0.29.2}/README.md +0 -0
  8. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/__init__.py +0 -0
  9. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/__main__.py +0 -0
  10. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/_mcp_patches.py +0 -0
  11. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/ads/__init__.py +0 -0
  12. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/ads/ai_max.py +0 -0
  13. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/ads/audiences.py +0 -0
  14. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/ads/brands.py +0 -0
  15. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/ads/client.py +0 -0
  16. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/ads/conversion_actions.py +0 -0
  17. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/ads/currency.py +0 -0
  18. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/ads/custom_conversion_goals.py +0 -0
  19. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/ads/enums.py +0 -0
  20. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/ads/forecast.py +0 -0
  21. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/ads/gaql.py +0 -0
  22. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/ads/image_fetch.py +0 -0
  23. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/ads/pmax.py +0 -0
  24. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/ads/read.py +0 -0
  25. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/ads/shopping.py +0 -0
  26. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/ads/validate_only.py +0 -0
  27. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/ads/write.py +0 -0
  28. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/auth.py +0 -0
  29. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/cli.py +0 -0
  30. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/config.py +0 -0
  31. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/crossref.py +0 -0
  32. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/diagnostics.py +0 -0
  33. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/ga4/__init__.py +0 -0
  34. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/ga4/client.py +0 -0
  35. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/ga4/reports.py +0 -0
  36. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/ga4/tracking.py +0 -0
  37. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/ga4/write.py +0 -0
  38. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/gsc/__init__.py +0 -0
  39. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/gsc/client.py +0 -0
  40. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/gsc/reports.py +0 -0
  41. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/gtm/__init__.py +0 -0
  42. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/gtm/client.py +0 -0
  43. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/gtm/entities.py +0 -0
  44. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/gtm/read.py +0 -0
  45. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/gtm/write.py +0 -0
  46. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/merchant/__init__.py +0 -0
  47. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/merchant/client.py +0 -0
  48. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/merchant/read.py +0 -0
  49. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/pagespeed.py +0 -0
  50. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/reddit/__init__.py +0 -0
  51. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/reddit/auth.py +0 -0
  52. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/reddit/client.py +0 -0
  53. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/reddit/read.py +0 -0
  54. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/reddit/schedule.py +0 -0
  55. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/reddit/write.py +0 -0
  56. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/rules/__init__.py +0 -0
  57. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/rules/adloop.md +0 -0
  58. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/rules/commands/analyze-performance.md +0 -0
  59. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/rules/commands/budget-plan.md +0 -0
  60. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/rules/commands/create-ad.md +0 -0
  61. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/rules/commands/create-campaign.md +0 -0
  62. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
  63. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/rules/commands/optimize-campaign.md +0 -0
  64. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/rules_install.py +0 -0
  65. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/runtime.py +0 -0
  66. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/safety/__init__.py +0 -0
  67. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/safety/approval_card.html +0 -0
  68. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/safety/audit.py +0 -0
  69. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/safety/guards.py +0 -0
  70. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/safety/preview.py +0 -0
  71. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/tracking.py +0 -0
  72. {adloop-0.29.0 → adloop-0.29.2}/src/adloop/uploads.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: adloop
3
- Version: 0.29.0
3
+ Version: 0.29.2
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.29.0"
3
+ version = "0.29.2"
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.29.0"
3
+ version = "0.29.2"
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,6 +91,41 @@ def code_matches(plan: ChangePlan, code: str) -> bool:
91
91
  return hmac.compare_digest(approval_code(plan), str(code or ""))
92
92
 
93
93
 
94
+ UPLOAD_META_KEY = "adloop/upload"
95
+ # The upload card's code is good for this long after upload_images opened it.
96
+ _UPLOAD_CODE_SECONDS = 2 * 3600
97
+
98
+
99
+ def upload_code(now: float | None = None) -> str:
100
+ """The code an upload card presents with each file: "<expiry>.<signature>".
101
+
102
+ Some apps send a card's own calls without declaring MCP Apps (the Claude
103
+ iOS app), so the upload tool cannot rely on that declaration. The code
104
+ goes to the card in the result's _meta, never into the model's context,
105
+ and is bound to the workspace and an expiry.
106
+ """
107
+ import time
108
+
109
+ from adloop.runtime import current_tenant
110
+
111
+ expiry = int((now if now is not None else time.time()) + _UPLOAD_CODE_SECONDS)
112
+ message = "\x00".join(("upload", current_tenant(), str(expiry)))
113
+ return f"{expiry}.{hmac.new(_secret, message.encode(), hashlib.sha256).hexdigest()[:32]}"
114
+
115
+
116
+ def upload_code_valid(code: str, now: float | None = None) -> bool:
117
+ import time
118
+
119
+ from adloop.runtime import current_tenant
120
+
121
+ expiry, _, signature = str(code or "").partition(".")
122
+ if not expiry.isdigit() or int(expiry) < (now if now is not None else time.time()):
123
+ return False
124
+ message = "\x00".join(("upload", current_tenant(), expiry))
125
+ expected = hmac.new(_secret, message.encode(), hashlib.sha256).hexdigest()[:32]
126
+ return hmac.compare_digest(expected, signature)
127
+
128
+
94
129
  def card_click_active() -> bool:
95
130
  """True while ``approve_change`` runs a click from the card."""
96
131
  return _card_click.get()
@@ -252,11 +252,18 @@ _APP_ONLY_TOOLS = {
252
252
 
253
253
 
254
254
  class _AppOnlyToolsForCardClients(Middleware):
255
- """List and run app-only tools only for apps that render cards.
255
+ """List app-only tools only to apps that render cards.
256
256
 
257
257
  The MCP Apps spec makes a card-capable app hide app-only tools from its
258
258
  model. An app without the extension knows nothing about that rule and
259
259
  would hand them to its model, so they are not offered to it at all.
260
+
261
+ Calls are a different matter. Some apps send a card's own click without
262
+ the extension declaration (the Claude iOS app does), so refusing every
263
+ call from an "app without cards" breaks the card itself. approve_change
264
+ needs no such check: it only acts on the plan's one-time code, which
265
+ goes to the card and never into the model's context. The upload card's
266
+ tool passes with the declaration or its own code from upload_images.
260
267
  """
261
268
 
262
269
  async def on_list_tools(self, context, call_next):
@@ -267,7 +274,12 @@ class _AppOnlyToolsForCardClients(Middleware):
267
274
 
268
275
  async def on_call_tool(self, context, call_next):
269
276
  card = _APP_ONLY_TOOLS.get(context.message.name)
270
- if card and not client_shows_cards():
277
+ if card and context.message.name == STORE_UPLOAD_TOOL:
278
+ from adloop.safety.approval import upload_code_valid
279
+
280
+ if upload_code_valid((context.message.arguments or {}).get("code", "")):
281
+ card = None
282
+ if card and context.message.name != APPROVE_TOOL and not client_shows_cards():
271
283
  from fastmcp.exceptions import ToolError
272
284
 
273
285
  raise ToolError(f"{context.message.name} can only be called from the {card}.")
@@ -4437,7 +4449,21 @@ def upload_images() -> dict:
4437
4449
  """
4438
4450
  from adloop.uploads import upload_instructions
4439
4451
 
4440
- return upload_instructions(client_shows_cards())
4452
+ shows_cards = client_shows_cards()
4453
+ result = upload_instructions(shows_cards)
4454
+ if not shows_cards:
4455
+ return result
4456
+ from fastmcp.tools import ToolResult
4457
+ from mcp.types import TextContent
4458
+
4459
+ from adloop.safety.approval import UPLOAD_META_KEY, upload_code
4460
+
4461
+ # The card sends this code with every file (see store_image_upload).
4462
+ return ToolResult(
4463
+ content=[TextContent(type="text", text=json.dumps(result, default=str))],
4464
+ structured_content=result,
4465
+ meta={UPLOAD_META_KEY: {"code": upload_code()}},
4466
+ )
4441
4467
 
4442
4468
 
4443
4469
  @_tool(
@@ -4447,7 +4473,7 @@ def upload_images() -> dict:
4447
4473
  meta={"ui": {"resourceUri": UPLOAD_CARD_URI, "visibility": ["app"]}},
4448
4474
  )
4449
4475
  @_safe
4450
- def store_image_upload(name: str, data_base64: str) -> dict:
4476
+ def store_image_upload(name: str, data_base64: str, code: str = "") -> dict:
4451
4477
  """Store an image dropped on the AdLoop upload card.
4452
4478
 
4453
4479
  Called only by the upload card; AI apps keep this tool out of the
@@ -4456,6 +4482,7 @@ def store_image_upload(name: str, data_base64: str) -> dict:
4456
4482
  Args:
4457
4483
  name: The file name, used for the asset name.
4458
4484
  data_base64: The file content, base64-encoded.
4485
+ code: The upload card's code from upload_images.
4459
4486
  """
4460
4487
  import base64
4461
4488
  import binascii
@@ -77,6 +77,10 @@
77
77
  (() => {
78
78
  "use strict";
79
79
  const TOOL = "store_image_upload";
80
+ const META_KEY = "adloop/upload";
81
+ // From upload_images: some apps send the card's calls without declaring
82
+ // MCP Apps, so each file carries the card's code instead.
83
+ let code = "";
80
84
  const MAX_BYTES = 5120 * 1024;
81
85
  const TYPES = ["image/png", "image/jpeg", "image/gif"];
82
86
  const STRINGS = {
@@ -168,7 +172,7 @@
168
172
  if (file.size > MAX_BYTES) { row.state.textContent = t("tooBig"); row.state.classList.add("bad"); continue; }
169
173
  row.state.textContent = t("uploading");
170
174
  try {
171
- const result = await request("tools/call", { name: TOOL, arguments: { name: file.name, data_base64: await readBase64(file) } });
175
+ const result = await request("tools/call", { name: TOOL, arguments: { name: file.name, data_base64: await readBase64(file), code } });
172
176
  const r = payloadOf(result);
173
177
  if ((result && result.isError) || r.error || !r.upload_id) throw new Error(r.error || "upload failed");
174
178
  row.meta.textContent = describe(r);
@@ -200,6 +204,8 @@
200
204
 
201
205
  let shownExisting = false;
202
206
  function onToolResult(result) {
207
+ const meta = result && result._meta && result._meta[META_KEY];
208
+ if (meta && meta.code) code = meta.code;
203
209
  if (shownExisting || !result) return;
204
210
  shownExisting = true;
205
211
  const r = payloadOf(result);
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