adloop 0.22.0__tar.gz → 0.22.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 (66) hide show
  1. {adloop-0.22.0 → adloop-0.22.2}/PKG-INFO +1 -1
  2. {adloop-0.22.0 → adloop-0.22.2}/pyproject.toml +1 -1
  3. {adloop-0.22.0 → adloop-0.22.2}/pyproject.toml.orig +1 -1
  4. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/gsc/reports.py +6 -2
  5. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/merchant/read.py +5 -2
  6. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/reddit/read.py +6 -2
  7. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/rules/adloop.md +2 -0
  8. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/runtime.py +33 -1
  9. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/server.py +65 -7
  10. {adloop-0.22.0 → adloop-0.22.2}/README.md +0 -0
  11. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/__init__.py +0 -0
  12. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/__main__.py +0 -0
  13. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/_mcp_patches.py +0 -0
  14. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/ads/__init__.py +0 -0
  15. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/ads/ai_max.py +0 -0
  16. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/ads/brands.py +0 -0
  17. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/ads/client.py +0 -0
  18. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/ads/conversion_actions.py +0 -0
  19. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/ads/currency.py +0 -0
  20. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/ads/custom_conversion_goals.py +0 -0
  21. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/ads/enums.py +0 -0
  22. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/ads/forecast.py +0 -0
  23. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/ads/gaql.py +0 -0
  24. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/ads/image_fetch.py +0 -0
  25. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/ads/pmax.py +0 -0
  26. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/ads/read.py +0 -0
  27. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/ads/validate_only.py +0 -0
  28. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/ads/write.py +0 -0
  29. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/auth.py +0 -0
  30. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/cli.py +0 -0
  31. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/config.py +0 -0
  32. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/crossref.py +0 -0
  33. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/diagnostics.py +0 -0
  34. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/ga4/__init__.py +0 -0
  35. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/ga4/client.py +0 -0
  36. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/ga4/reports.py +0 -0
  37. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/ga4/tracking.py +0 -0
  38. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/ga4/write.py +0 -0
  39. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/gsc/__init__.py +0 -0
  40. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/gsc/client.py +0 -0
  41. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/gtm/__init__.py +0 -0
  42. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/gtm/client.py +0 -0
  43. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/gtm/entities.py +0 -0
  44. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/gtm/read.py +0 -0
  45. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/gtm/write.py +0 -0
  46. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/merchant/__init__.py +0 -0
  47. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/merchant/client.py +0 -0
  48. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/pagespeed.py +0 -0
  49. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/reddit/__init__.py +0 -0
  50. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/reddit/auth.py +0 -0
  51. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/reddit/client.py +0 -0
  52. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/reddit/schedule.py +0 -0
  53. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/reddit/write.py +0 -0
  54. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/rules/__init__.py +0 -0
  55. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/rules/commands/analyze-performance.md +0 -0
  56. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/rules/commands/budget-plan.md +0 -0
  57. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/rules/commands/create-ad.md +0 -0
  58. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/rules/commands/create-campaign.md +0 -0
  59. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
  60. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/rules/commands/optimize-campaign.md +0 -0
  61. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/rules_install.py +0 -0
  62. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/safety/__init__.py +0 -0
  63. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/safety/audit.py +0 -0
  64. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/safety/guards.py +0 -0
  65. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/safety/preview.py +0 -0
  66. {adloop-0.22.0 → adloop-0.22.2}/src/adloop/tracking.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: adloop
3
- Version: 0.22.0
3
+ Version: 0.22.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.22.0"
3
+ version = "0.22.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.22.0"
3
+ version = "0.22.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 = [
@@ -101,9 +101,13 @@ def run_gsc_report(
101
101
  site_url = config.gsc.site_url
102
102
 
103
103
  if not site_url:
104
+ from adloop.runtime import default_setting_hint
105
+
104
106
  return {
105
- "error": "site_url is required. Pass it as an argument or set "
106
- "gsc.site_url in ~/.adloop/config.yaml."
107
+ "error": "site_url is required. Pass it as an argument (see "
108
+ "list_gsc_sites) or "
109
+ + default_setting_hint("gsc.site_url", "Settings → Google & accounts")
110
+ + "."
107
111
  }
108
112
 
109
113
  dimensions = [_normalize_dimension(d) for d in (dimensions or ["query"])]
@@ -126,6 +126,8 @@ def get_merchant_feed_health(
126
126
 
127
127
  account_id = str(account_id or "").strip() or config.merchant.account_id.strip()
128
128
  if not account_id:
129
+ from adloop.runtime import default_setting_hint
130
+
129
131
  return {
130
132
  "error": (
131
133
  "account_id is required: no Merchant Center account was "
@@ -133,8 +135,9 @@ def get_merchant_feed_health(
133
135
  ),
134
136
  "hint": (
135
137
  "Call list_merchant_accounts to find the numeric Merchant "
136
- "Center ID and pass it as account_id, or set "
137
- "merchant.account_id in the config to make it the default."
138
+ "Center ID and pass it as account_id, or "
139
+ + default_setting_hint("merchant.account_id", "Settings → Google & accounts")
140
+ + "."
138
141
  ),
139
142
  }
140
143
  if not account_id.isdigit():
@@ -103,9 +103,13 @@ def resolve_account(config: AdLoopConfig, ad_account_id: str = "") -> str:
103
103
  """Explicit id wins, then ``reddit.ad_account_id`` from the config."""
104
104
  account = (ad_account_id or config.reddit.ad_account_id or "").strip()
105
105
  if not account:
106
+ from adloop.runtime import default_setting_hint
107
+
106
108
  raise ValueError(
107
- "ad_account_id is required — pass it explicitly (see "
108
- "list_reddit_accounts) or set reddit.ad_account_id in the config."
109
+ "ad_account_id is required: pass it explicitly (see "
110
+ "list_reddit_accounts) or "
111
+ + default_setting_hint("reddit.ad_account_id", "Settings → Reddit Ads")
112
+ + "."
109
113
  )
110
114
  return account
111
115
 
@@ -14,6 +14,8 @@ You have access to AdLoop MCP tools that connect Google Ads, Reddit Ads and Goog
14
14
  |------|-------------|----------------|
15
15
  | `health_check` | First thing to run when tools are failing — tests OAuth token, GA4, Ads, and (when configured) Reddit Ads connectivity | (none) |
16
16
 
17
+ **A tool you expect is missing?** Apps such as claude.ai, ChatGPT and Perplexity cache AdLoop's tool list from when the connection was made. `health_check` returns `adloop_version` and `tools_offered`, the tools this server offers to you. If the user asks for something a tool in `tools_offered` does and that tool isn't in your own tool list, don't improvise with other tools: tell the user their app shows an older list and to refresh the AdLoop connection (claude.ai: Settings → Connectors → AdLoop; ChatGPT: the AdLoop plugin's settings; Perplexity: remove and add the connector again; coding agents: reconnect the MCP server or start a new session).
18
+
17
19
  **GA4 has two surfaces in health_check:** `ga4_admin` (listing properties) and `ga4_data` (every report). `ga4_data: error` with `SERVICE_DISABLED` means the Google Analytics Data API is not enabled in the Google Cloud project — tell the user to enable it; it is not a permissions or consent problem.
18
20
 
19
21
  **If health_check reports Google auth errors:** Tell the user to delete `~/.adloop/token.json` and re-run any tool to trigger re-authorization. If tokens keep expiring weekly, the GCP consent screen needs to be published from "Testing" to "In production". **Reddit auth errors** (`REDDIT_INVALID_GRANT`, `REDDIT_NOT_CONNECTED`) are fixed by re-running the Reddit step of `adloop init` (self-hosted) or reconnecting under Settings → Reddit Ads (AdLoop Cloud); the Google token is unrelated.
@@ -26,7 +26,7 @@ from __future__ import annotations
26
26
  import threading
27
27
  from contextlib import contextmanager
28
28
  from contextvars import ContextVar
29
- from typing import TYPE_CHECKING, Iterator, Literal
29
+ from typing import TYPE_CHECKING, Callable, Iterable, Iterator, Literal
30
30
 
31
31
  if TYPE_CHECKING:
32
32
  from adloop.config import AdLoopConfig
@@ -46,6 +46,38 @@ _default_config_lock = threading.Lock()
46
46
  _deployment_mode: DeploymentMode = "local"
47
47
 
48
48
 
49
+ # Which registered tools the current caller is actually offered, given each
50
+ # tool's tags. Self-hosted, every enabled tool is; a hosted runtime narrows it
51
+ # per request (a key's toolsets, tools it cannot serve) so health_check can
52
+ # report the same list the caller's tools/list shows.
53
+ ToolVisibility = Callable[[dict[str, frozenset[str]]], Iterable[str]]
54
+ _tool_visibility: ToolVisibility | None = None
55
+
56
+
57
+ def set_tool_visibility(fn: ToolVisibility | None) -> None:
58
+ """Install the per-request tool filter. Called once at startup."""
59
+ global _tool_visibility
60
+ _tool_visibility = fn
61
+
62
+
63
+ def tool_visibility() -> ToolVisibility | None:
64
+ return _tool_visibility
65
+
66
+
67
+ def default_setting_hint(config_key: str, dashboard_page: str) -> str:
68
+ """How to set a missing default, phrased for where AdLoop runs.
69
+
70
+ Self-hosted it is a key in the config file; a hosted runtime has no
71
+ config file the user can edit, so it names the dashboard page instead.
72
+ """
73
+ if _deployment_mode == "server":
74
+ return (
75
+ f"choose a default under {dashboard_page} in the AdLoop Cloud "
76
+ "dashboard (agencies: per client under Settings → Clients)"
77
+ )
78
+ return f"set {config_key} in the config (~/.adloop/config.yaml)"
79
+
80
+
49
81
  def deployment_mode() -> DeploymentMode:
50
82
  """Return the process-wide deployment mode ("local" or "server")."""
51
83
  return _deployment_mode
@@ -9,7 +9,7 @@ from typing import Annotated, Callable
9
9
 
10
10
  from fastmcp import FastMCP
11
11
  from fastmcp.utilities.docstring_parsing import parse_docstring
12
- from mcp.types import ToolAnnotations
12
+ from mcp.types import Icon, ToolAnnotations
13
13
  from pydantic import BeforeValidator
14
14
 
15
15
  from adloop import diagnostics
@@ -155,10 +155,13 @@ def _gtm_defaults(account_id: str, container_id: str) -> tuple[str, str]:
155
155
  account_id = account_id or cfg.account_id
156
156
  container_id = container_id or cfg.container_id
157
157
  if not account_id or not container_id:
158
+ from adloop.runtime import default_setting_hint
159
+
158
160
  raise ValueError(
159
- "gtm_account_id and gtm_container_id are required — pass them "
160
- "explicitly (see list_gtm_accounts / list_gtm_containers) or set "
161
- "gtm.account_id / gtm.container_id in the config."
161
+ "gtm_account_id and gtm_container_id are required: pass them "
162
+ "explicitly (see list_gtm_accounts / list_gtm_containers) or "
163
+ + default_setting_hint("gtm.account_id / gtm.container_id", "Settings → Google & accounts")
164
+ + "."
162
165
  )
163
166
  return account_id, container_id
164
167
 
@@ -166,8 +169,22 @@ def _gtm_defaults(account_id: str, container_id: str) -> tuple[str, str]:
166
169
  mcp = FastMCP(
167
170
  "AdLoop",
168
171
  instructions=_build_orchestration_instructions(),
172
+ website_url="https://getadloop.com",
173
+ # Clients that support MCP server icons show these instead of guessing
174
+ # from the domain's favicon. Square and full-bleed, so a client's own
175
+ # rounded mask leaves no stray corners.
176
+ icons=[
177
+ Icon(src="https://getadloop.com/icon-512.png", mimeType="image/png", sizes=["512x512"]),
178
+ Icon(src="https://getadloop.com/favicon.svg", mimeType="image/svg+xml", sizes=["any"]),
179
+ ],
169
180
  )
170
181
 
182
+ # name -> tags of every registered tool, so health_check can say which tools
183
+ # this server offers without reaching into FastMCP internals.
184
+ _TOOL_TAGS: dict[str, frozenset[str]] = {}
185
+ # Tags enabled by ADLOOP_TOOLSETS (None: the full catalog).
186
+ _ENABLED_TAGS: set[str] | None = None
187
+
171
188
 
172
189
  # The API each toolset calls, linked at the end of every tool description:
173
190
  # directory reviews ask descriptions to reference the API they target.
@@ -217,6 +234,7 @@ def _tool(*, title: str, annotations: ToolAnnotations, tags: set[str], **kwargs)
217
234
  description = parse_docstring(fn).description or inspect.cleandoc(fn.__doc__ or "")
218
235
  if docs and "https://" not in description:
219
236
  description = f"{description}\n\n{docs}"
237
+ _TOOL_TAGS[kwargs.get("name") or fn.__name__] = frozenset(tags)
220
238
  return mcp.tool(
221
239
  title=title,
222
240
  annotations=annotations,
@@ -585,9 +603,44 @@ def health_check() -> dict:
585
603
  elif status.get("reddit") == "error" and status.get("reddit_hint"):
586
604
  status["hint"] = status["reddit_hint"]
587
605
 
606
+ status["adloop_version"] = _adloop_version()
607
+ status["tools_offered"] = _offered_tool_names()
608
+ status["tools_note"] = (
609
+ "These are the tools this AdLoop server offers to you. If one of "
610
+ "them is missing from your own tool list, your app is showing a "
611
+ "cached list from an earlier version: ask the user to refresh the "
612
+ "AdLoop connection (claude.ai: Settings → Connectors → AdLoop; "
613
+ "ChatGPT: the AdLoop plugin's settings; Perplexity: remove and add "
614
+ "the connector again; coding agents: reconnect the MCP server or "
615
+ "start a new session)."
616
+ )
588
617
  return status
589
618
 
590
619
 
620
+ def _adloop_version() -> str:
621
+ from importlib.metadata import PackageNotFoundError, version
622
+
623
+ try:
624
+ return version("adloop")
625
+ except PackageNotFoundError:
626
+ return "unknown"
627
+
628
+
629
+ def _offered_tool_names() -> list[str]:
630
+ """The tools the current caller sees in tools/list: enabled by
631
+ ADLOOP_TOOLSETS, then narrowed by the runtime's visibility hook."""
632
+ from adloop.runtime import tool_visibility
633
+
634
+ tools = {
635
+ name: tags
636
+ for name, tags in _TOOL_TAGS.items()
637
+ if _ENABLED_TAGS is None or tags & _ENABLED_TAGS
638
+ }
639
+ hook = tool_visibility()
640
+ names = hook(tools) if hook is not None else tools
641
+ return sorted(names)
642
+
643
+
591
644
  # ---------------------------------------------------------------------------
592
645
  # GA4 Read Tools
593
646
  # ---------------------------------------------------------------------------
@@ -1740,9 +1793,12 @@ def list_gtm_containers(gtm_account_id: str = "") -> dict:
1740
1793
 
1741
1794
  gtm_account_id = gtm_account_id or current_config().gtm.account_id
1742
1795
  if not gtm_account_id:
1796
+ from adloop.runtime import default_setting_hint
1797
+
1743
1798
  raise ValueError(
1744
- "gtm_account_id is required — call list_gtm_accounts first or "
1745
- "set gtm.account_id in the config."
1799
+ "gtm_account_id is required: call list_gtm_accounts first or "
1800
+ + default_setting_hint("gtm.account_id", "Settings → Google & accounts")
1801
+ + "."
1746
1802
  )
1747
1803
  return _impl(current_config(), account_id=gtm_account_id)
1748
1804
 
@@ -5728,7 +5784,9 @@ def _apply_toolsets_env() -> None:
5728
5784
  f"ADLOOP_TOOLSETS names unknown toolset(s): {', '.join(unknown)}. "
5729
5785
  f"Valid toolsets: {', '.join(TOOLSETS)}. Example: ADLOOP_TOOLSETS=ads,ga4"
5730
5786
  )
5731
- mcp.enable(tags=requested | {"core"}, only=True)
5787
+ global _ENABLED_TAGS
5788
+ _ENABLED_TAGS = requested | {"core"}
5789
+ mcp.enable(tags=_ENABLED_TAGS, only=True)
5732
5790
 
5733
5791
 
5734
5792
  _apply_toolsets_env()
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