marketing-data-platform 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,5 @@
1
+ """Local agent tools for the Data Platform API."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
@@ -0,0 +1,87 @@
1
+ from functools import lru_cache
2
+ from pathlib import Path
3
+
4
+ from pydantic import Field, field_validator
5
+ from pydantic_settings import BaseSettings, SettingsConfigDict
6
+
7
+ DEFAULT_SESSION_FILE = "~/.config/data-platform/mcp.env"
8
+
9
+
10
+ class Settings(BaseSettings):
11
+ model_config = SettingsConfigDict(extra="ignore")
12
+
13
+ api_base_url: str = Field(default="http://127.0.0.1:8002", alias="DATA_PLATFORM_API_BASE_URL")
14
+ api_session_token: str = Field(default="", alias="DATA_PLATFORM_API_SESSION_TOKEN")
15
+ session_file: str = Field(default=DEFAULT_SESSION_FILE, alias="DATA_PLATFORM_MCP_SESSION_FILE")
16
+ default_limit: int = Field(default=100, alias="DATA_PLATFORM_MCP_DEFAULT_LIMIT")
17
+ max_limit: int = Field(default=1000, alias="DATA_PLATFORM_MCP_MAX_LIMIT")
18
+
19
+ @field_validator("api_base_url", "session_file")
20
+ @classmethod
21
+ def non_empty_string(cls, value: str) -> str:
22
+ value = value.strip()
23
+ if not value:
24
+ raise ValueError("value must not be empty")
25
+ return value
26
+
27
+ @field_validator("default_limit", "max_limit")
28
+ @classmethod
29
+ def positive_int(cls, value: int) -> int:
30
+ if value < 1:
31
+ raise ValueError("value must be positive")
32
+ return value
33
+
34
+
35
+ @lru_cache
36
+ def get_settings() -> Settings:
37
+ settings = Settings()
38
+ file_values = read_env_file(Path(settings.session_file).expanduser())
39
+ fallback_file = read_env_file(Path(DEFAULT_SESSION_FILE).expanduser())
40
+
41
+ return Settings(
42
+ DATA_PLATFORM_API_BASE_URL=first_value(
43
+ file_values.get("DATA_PLATFORM_API_BASE_URL"),
44
+ fallback_file.get("DATA_PLATFORM_API_BASE_URL"),
45
+ settings.api_base_url,
46
+ ),
47
+ DATA_PLATFORM_API_SESSION_TOKEN=first_value(
48
+ file_values.get("DATA_PLATFORM_API_SESSION_TOKEN"),
49
+ fallback_file.get("DATA_PLATFORM_API_SESSION_TOKEN"),
50
+ settings.api_session_token,
51
+ ),
52
+ DATA_PLATFORM_MCP_SESSION_FILE=settings.session_file,
53
+ DATA_PLATFORM_MCP_DEFAULT_LIMIT=int(
54
+ first_value(
55
+ file_values.get("DATA_PLATFORM_MCP_DEFAULT_LIMIT"),
56
+ fallback_file.get("DATA_PLATFORM_MCP_DEFAULT_LIMIT"),
57
+ str(settings.default_limit),
58
+ )
59
+ ),
60
+ DATA_PLATFORM_MCP_MAX_LIMIT=int(
61
+ first_value(
62
+ file_values.get("DATA_PLATFORM_MCP_MAX_LIMIT"),
63
+ fallback_file.get("DATA_PLATFORM_MCP_MAX_LIMIT"),
64
+ str(settings.max_limit),
65
+ )
66
+ ),
67
+ )
68
+
69
+
70
+ def first_value(*values: str | None) -> str:
71
+ for value in values:
72
+ if value:
73
+ return value
74
+ return ""
75
+
76
+
77
+ def read_env_file(path: Path) -> dict[str, str]:
78
+ if not path.exists():
79
+ return {}
80
+ values: dict[str, str] = {}
81
+ for line in path.read_text(encoding="utf-8").splitlines():
82
+ stripped = line.strip()
83
+ if not stripped or stripped.startswith("#") or "=" not in stripped:
84
+ continue
85
+ key, value = stripped.split("=", 1)
86
+ values[key.strip()] = value.strip().strip('"').strip("'")
87
+ return values
@@ -0,0 +1,295 @@
1
+ import argparse
2
+ import secrets
3
+ import sys
4
+ import threading
5
+ import urllib.parse
6
+ import webbrowser
7
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import httpx
12
+
13
+ AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
14
+ TOKEN_URL = "https://oauth2.googleapis.com/token"
15
+ DEFAULT_SCOPES = "openid email profile"
16
+ DEFAULT_MCP_ENV_PATH = "~/.config/data-platform/mcp.env"
17
+
18
+
19
+ class OAuthCallbackState:
20
+ def __init__(self, expected_state: str) -> None:
21
+ self.expected_state = expected_state
22
+ self.code: str | None = None
23
+ self.error: str | None = None
24
+ self.event = threading.Event()
25
+
26
+
27
+ def main() -> None:
28
+ args = parse_args()
29
+
30
+ client_id = args.client_id or read_env_value("DATA_PLATFORM_GOOGLE_OAUTH_CLIENT_ID")
31
+ client_secret = args.client_secret or read_env_value("DATA_PLATFORM_GOOGLE_OAUTH_CLIENT_SECRET")
32
+ if not client_id:
33
+ raise SystemExit("DATA_PLATFORM_GOOGLE_OAUTH_CLIENT_ID or --client-id is required.")
34
+ if not client_secret:
35
+ raise SystemExit("DATA_PLATFORM_GOOGLE_OAUTH_CLIENT_SECRET or --client-secret is required.")
36
+
37
+ redirect_uri = f"http://127.0.0.1:{args.port}/callback"
38
+ state = secrets.token_urlsafe(24)
39
+ callback_state = OAuthCallbackState(expected_state=state)
40
+
41
+ server = build_callback_server(args.port, callback_state)
42
+ server_thread = threading.Thread(target=server.serve_forever, daemon=True)
43
+ server_thread.start()
44
+
45
+ auth_url = build_auth_url(
46
+ client_id=client_id,
47
+ redirect_uri=redirect_uri,
48
+ state=state,
49
+ )
50
+
51
+ print("Opening browser for Google login...")
52
+ if not webbrowser.open(auth_url):
53
+ print(f"Open this URL manually:\n{auth_url}")
54
+
55
+ if not callback_state.event.wait(timeout=args.timeout_seconds):
56
+ server.shutdown()
57
+ raise SystemExit("Timed out waiting for Google OAuth callback.")
58
+ server.shutdown()
59
+
60
+ if callback_state.error:
61
+ raise SystemExit(f"Google OAuth failed: {callback_state.error}")
62
+ if not callback_state.code:
63
+ raise SystemExit("Google OAuth callback did not include an authorization code.")
64
+
65
+ id_token = exchange_code_for_id_token(
66
+ code=callback_state.code,
67
+ client_id=client_id,
68
+ client_secret=client_secret,
69
+ redirect_uri=redirect_uri,
70
+ )
71
+ session = exchange_id_token_for_session(
72
+ api_base_url=args.api_base_url,
73
+ id_token=id_token,
74
+ )
75
+
76
+ write_mcp_env(
77
+ output_path=args.output,
78
+ api_base_url=args.api_base_url,
79
+ session_token=session["access_token"],
80
+ default_limit=args.default_limit,
81
+ max_limit=args.max_limit,
82
+ )
83
+
84
+ print(f"Logged in as {session.get('email')} ({session.get('role')})")
85
+ print(f"Saved MCP session config to {args.output}")
86
+
87
+
88
+ def parse_args() -> argparse.Namespace:
89
+ parser = argparse.ArgumentParser(
90
+ description="Login with Google and save a Data Platform API session for MCP."
91
+ )
92
+ parser.add_argument(
93
+ "--api-base-url",
94
+ default=read_env_value("DATA_PLATFORM_API_BASE_URL") or "http://127.0.0.1:8002",
95
+ help="Data Platform API base URL.",
96
+ )
97
+ parser.add_argument(
98
+ "--client-id",
99
+ default=None,
100
+ help="Google OAuth client id. Defaults to DATA_PLATFORM_GOOGLE_OAUTH_CLIENT_ID.",
101
+ )
102
+ parser.add_argument(
103
+ "--client-secret",
104
+ default=None,
105
+ help="Google OAuth client secret. Defaults to DATA_PLATFORM_GOOGLE_OAUTH_CLIENT_SECRET.",
106
+ )
107
+ parser.add_argument(
108
+ "--output",
109
+ default=DEFAULT_MCP_ENV_PATH,
110
+ help="Path to write MCP environment config.",
111
+ )
112
+ parser.add_argument("--port", type=int, default=8765, help="Local OAuth callback port.")
113
+ parser.add_argument(
114
+ "--timeout-seconds",
115
+ type=int,
116
+ default=180,
117
+ help="Seconds to wait for OAuth callback.",
118
+ )
119
+ parser.add_argument("--default-limit", type=int, default=100)
120
+ parser.add_argument("--max-limit", type=int, default=1000)
121
+ return parser.parse_args()
122
+
123
+
124
+ def build_callback_server(port: int, callback_state: OAuthCallbackState) -> ThreadingHTTPServer:
125
+ class CallbackHandler(BaseHTTPRequestHandler):
126
+ def do_GET(self) -> None: # noqa: N802
127
+ parsed = urllib.parse.urlparse(self.path)
128
+ params = urllib.parse.parse_qs(parsed.query)
129
+ received_state = first(params.get("state"))
130
+ if parsed.path != "/callback":
131
+ self.respond(404, "Not found.")
132
+ return
133
+ if received_state != callback_state.expected_state:
134
+ callback_state.error = "OAuth state mismatch."
135
+ callback_state.event.set()
136
+ self.respond(400, "Login failed: state mismatch.")
137
+ return
138
+
139
+ error = first(params.get("error"))
140
+ if error:
141
+ callback_state.error = error
142
+ callback_state.event.set()
143
+ self.respond(400, "Login failed. You can close this tab.")
144
+ return
145
+
146
+ callback_state.code = first(params.get("code"))
147
+ callback_state.event.set()
148
+ self.respond(200, "Login complete. You can close this tab.")
149
+
150
+ def log_message(self, format: str, *args: object) -> None:
151
+ return
152
+
153
+ def respond(self, status_code: int, body: str) -> None:
154
+ encoded = body.encode("utf-8")
155
+ self.send_response(status_code)
156
+ self.send_header("Content-Type", "text/plain; charset=utf-8")
157
+ self.send_header("Content-Length", str(len(encoded)))
158
+ self.end_headers()
159
+ self.wfile.write(encoded)
160
+
161
+ return ThreadingHTTPServer(("127.0.0.1", port), CallbackHandler)
162
+
163
+
164
+ def build_auth_url(*, client_id: str, redirect_uri: str, state: str) -> str:
165
+ query = urllib.parse.urlencode(
166
+ {
167
+ "client_id": client_id,
168
+ "redirect_uri": redirect_uri,
169
+ "response_type": "code",
170
+ "scope": DEFAULT_SCOPES,
171
+ "state": state,
172
+ "access_type": "offline",
173
+ "prompt": "select_account",
174
+ }
175
+ )
176
+ return f"{AUTH_URL}?{query}"
177
+
178
+
179
+ def exchange_code_for_id_token(
180
+ *,
181
+ code: str,
182
+ client_id: str,
183
+ client_secret: str,
184
+ redirect_uri: str,
185
+ ) -> str:
186
+ response = httpx.post(
187
+ TOKEN_URL,
188
+ data={
189
+ "code": code,
190
+ "client_id": client_id,
191
+ "client_secret": client_secret,
192
+ "redirect_uri": redirect_uri,
193
+ "grant_type": "authorization_code",
194
+ },
195
+ timeout=30,
196
+ )
197
+ response.raise_for_status()
198
+ payload = response.json()
199
+ id_token = payload.get("id_token")
200
+ if not id_token:
201
+ raise SystemExit("Google token response did not include id_token.")
202
+ return str(id_token)
203
+
204
+
205
+ def exchange_id_token_for_session(*, api_base_url: str, id_token: str) -> dict[str, Any]:
206
+ session_url = f"{api_base_url.rstrip('/')}/auth/google/session"
207
+ try:
208
+ response = httpx.post(
209
+ session_url,
210
+ json={"id_token": id_token},
211
+ timeout=30,
212
+ )
213
+ except httpx.ConnectError as exc:
214
+ raise SystemExit(
215
+ "Could not connect to Data Platform API. "
216
+ f"Start it first, or pass the correct --api-base-url. Tried: {session_url}"
217
+ ) from exc
218
+ if response.status_code == 403:
219
+ raise SystemExit("Login succeeded, but this email is not allowlisted.")
220
+ response.raise_for_status()
221
+ payload = response.json()
222
+ if "access_token" not in payload:
223
+ raise SystemExit("Data Platform API response did not include access_token.")
224
+ return payload
225
+
226
+
227
+ def write_mcp_env(
228
+ *,
229
+ output_path: str,
230
+ api_base_url: str,
231
+ session_token: str,
232
+ default_limit: int,
233
+ max_limit: int,
234
+ ) -> None:
235
+ path = Path(output_path).expanduser()
236
+ path.parent.mkdir(parents=True, exist_ok=True)
237
+ path.write_text(
238
+ "\n".join(
239
+ [
240
+ f"DATA_PLATFORM_API_BASE_URL={api_base_url.rstrip('/')}",
241
+ f"DATA_PLATFORM_API_SESSION_TOKEN={session_token}",
242
+ f"DATA_PLATFORM_MCP_DEFAULT_LIMIT={default_limit}",
243
+ f"DATA_PLATFORM_MCP_MAX_LIMIT={max_limit}",
244
+ "",
245
+ ]
246
+ ),
247
+ encoding="utf-8",
248
+ )
249
+ path.chmod(0o600)
250
+
251
+
252
+ def read_env_value(name: str) -> str | None:
253
+ import os
254
+
255
+ value = os.getenv(name)
256
+ if value:
257
+ return value.strip()
258
+
259
+ for env_path in [
260
+ Path(".env"),
261
+ Path(".env.local"),
262
+ Path("apps/data-platform-api/.env"),
263
+ Path("apps/data-platform-api/.env.local"),
264
+ ]:
265
+ value = read_env_file_value(env_path, name)
266
+ if value:
267
+ return value
268
+ return None
269
+
270
+
271
+ def read_env_file_value(path: Path, name: str) -> str | None:
272
+ if not path.exists():
273
+ return None
274
+ for line in path.read_text(encoding="utf-8").splitlines():
275
+ stripped = line.strip()
276
+ if not stripped or stripped.startswith("#") or "=" not in stripped:
277
+ continue
278
+ key, value = stripped.split("=", 1)
279
+ if key.strip() == name:
280
+ return value.strip().strip('"').strip("'")
281
+ return None
282
+
283
+
284
+ def first(values: list[str] | None) -> str | None:
285
+ if not values:
286
+ return None
287
+ return values[0]
288
+
289
+
290
+ if __name__ == "__main__":
291
+ try:
292
+ main()
293
+ except httpx.HTTPStatusError as exc:
294
+ print(f"HTTP error: {exc.response.status_code} {exc.response.text}", file=sys.stderr)
295
+ raise SystemExit(1) from exc
@@ -0,0 +1,74 @@
1
+ from typing import Any
2
+
3
+ import httpx
4
+ from mcp.server.fastmcp import FastMCP
5
+
6
+ from data_platform_agent_tools.config import get_settings
7
+
8
+ mcp = FastMCP("data-platform-mart")
9
+
10
+
11
+ def bounded_limit(limit: int | None) -> int:
12
+ settings = get_settings()
13
+ value = limit or settings.default_limit
14
+ return min(max(value, 1), settings.max_limit)
15
+
16
+
17
+ @mcp.tool()
18
+ def get_daily_ad_performance(
19
+ start_date: str,
20
+ end_date: str,
21
+ client_name: str | None = None,
22
+ project_id: str | None = None,
23
+ media: str | None = None,
24
+ campaign_id: str | None = None,
25
+ external_campaign_id: str | None = None,
26
+ ad_group_id: str | None = None,
27
+ external_ad_group_id: str | None = None,
28
+ ad_id: str | None = None,
29
+ external_ad_id: str | None = None,
30
+ limit: int | None = None,
31
+ ) -> list[dict[str, Any]]:
32
+ """Read daily ad performance rows through the Data Platform API."""
33
+ settings = get_settings()
34
+ if not settings.api_session_token:
35
+ raise RuntimeError(
36
+ "MCP session token is missing. Run `data-platform-login` first, "
37
+ "or set DATA_PLATFORM_API_SESSION_TOKEN."
38
+ )
39
+ params: dict[str, str | int] = {
40
+ "start_date": start_date,
41
+ "end_date": end_date,
42
+ "limit": bounded_limit(limit),
43
+ }
44
+ for key, value in {
45
+ "client_name": client_name,
46
+ "project_id": project_id,
47
+ "media": media,
48
+ "campaign_id": campaign_id,
49
+ "external_campaign_id": external_campaign_id,
50
+ "ad_group_id": ad_group_id,
51
+ "external_ad_group_id": external_ad_group_id,
52
+ "ad_id": ad_id,
53
+ "external_ad_id": external_ad_id,
54
+ }.items():
55
+ if value is not None:
56
+ params[key] = value
57
+
58
+ response = httpx.get(
59
+ f"{settings.api_base_url.rstrip('/')}/agent/mart/daily-ad-performance",
60
+ params=params,
61
+ headers={"Authorization": f"Bearer {settings.api_session_token}"},
62
+ timeout=30,
63
+ )
64
+ response.raise_for_status()
65
+ payload = response.json()
66
+ return payload["rows"]
67
+
68
+
69
+ def main() -> None:
70
+ mcp.run()
71
+
72
+
73
+ if __name__ == "__main__":
74
+ main()
@@ -0,0 +1,94 @@
1
+ Metadata-Version: 2.4
2
+ Name: marketing-data-platform
3
+ Version: 0.1.0
4
+ Summary: Login CLI and MCP server for the Data Platform API.
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: httpx>=0.27.0
7
+ Requires-Dist: mcp>=1.2.0
8
+ Requires-Dist: pydantic-settings>=2.6.0
9
+ Description-Content-Type: text/markdown
10
+
11
+ # Marketing Data Platform
12
+
13
+ Python package for local agents and MCP clients that need to access the Data Platform API.
14
+
15
+ It provides two commands:
16
+
17
+ - `data-platform-login`: login with Google and save a local Data Platform API session.
18
+ - `data-platform-mcp`: start the MCP server that exposes mart access tools.
19
+
20
+ The MCP server does not connect to BigQuery directly. It calls the Data Platform API, and the API owns BigQuery credentials, authorization, and tenant access control.
21
+
22
+ ## Install
23
+
24
+ For local development:
25
+
26
+ ```bash
27
+ uv run --package marketing-data-platform data-platform-login --help
28
+ uv run --package marketing-data-platform data-platform-mcp
29
+ ```
30
+
31
+ After publishing to PyPI:
32
+
33
+ ```bash
34
+ uvx --from marketing-data-platform data-platform-login --help
35
+ uvx --from marketing-data-platform data-platform-mcp
36
+ ```
37
+
38
+ ## Login
39
+
40
+ The Data Platform API must be running and configured with Google OAuth.
41
+
42
+ ```bash
43
+ uvx --from marketing-data-platform data-platform-login \
44
+ --api-base-url https://data-platform-api.example.com
45
+ ```
46
+
47
+ By default, the command writes the MCP session file to:
48
+
49
+ ```text
50
+ ~/.config/data-platform/mcp.env
51
+ ```
52
+
53
+ The session token is saved locally with file mode `600` and is not printed.
54
+
55
+ ## MCP Config
56
+
57
+ For a published package:
58
+
59
+ ```toml
60
+ [mcp_servers.data-platform-mart]
61
+ command = "uvx"
62
+ args = ["--from", "marketing-data-platform", "data-platform-mcp"]
63
+ startup_timeout_sec = 120
64
+ ```
65
+
66
+ For this monorepo during development:
67
+
68
+ ```toml
69
+ [mcp_servers.data-platform-mart]
70
+ command = "uv"
71
+ args = [
72
+ "run",
73
+ "--directory",
74
+ "/path/to/data-platform",
75
+ "--package",
76
+ "marketing-data-platform",
77
+ "data-platform-mcp",
78
+ ]
79
+ startup_timeout_sec = 120
80
+ ```
81
+
82
+ ## MCP Tools
83
+
84
+ - `get_daily_ad_performance`
85
+
86
+ ## Environment Variables
87
+
88
+ | Name | Required | Description |
89
+ | --- | --- | --- |
90
+ | `DATA_PLATFORM_API_BASE_URL` | optional | Data Platform API base URL. Default: `http://127.0.0.1:8002` |
91
+ | `DATA_PLATFORM_API_SESSION_TOKEN` | optional | Session JWT issued by login. Usually read from the session file. |
92
+ | `DATA_PLATFORM_MCP_SESSION_FILE` | optional | Session env file. Default: `~/.config/data-platform/mcp.env` |
93
+ | `DATA_PLATFORM_MCP_DEFAULT_LIMIT` | optional | Default row limit. Default: `100` |
94
+ | `DATA_PLATFORM_MCP_MAX_LIMIT` | optional | Max row limit. Default: `1000` |
@@ -0,0 +1,8 @@
1
+ data_platform_agent_tools/__init__.py,sha256=bvvRHhIbw1cKJ6YOmLJq6fYN8HE2X9F5MIofFK1sgMw,101
2
+ data_platform_agent_tools/config.py,sha256=gYP5Sjpc2FpsYiYJj-hzVRFOCX1vo6-0XoIAU9HpVWU,3097
3
+ data_platform_agent_tools/login_cli.py,sha256=H8WJ1SyouA52MpmDVj_utFsOkRNqVwCApTq_wgrh3u4,9558
4
+ data_platform_agent_tools/mcp_server.py,sha256=SrnJasmV-Ba8ShFTDrHQ1N-A93eKcHaG987ipy9hYaI,2110
5
+ marketing_data_platform-0.1.0.dist-info/METADATA,sha256=0xi8qIVEEEVfOohWXhqKs-YZFo8uGMuWVPWUQzl8bu8,2558
6
+ marketing_data_platform-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
7
+ marketing_data_platform-0.1.0.dist-info/entry_points.txt,sha256=f2PmBpsGyLspVepGo2v9UD3UNFtmGJVqZZCCkjtxEMY,143
8
+ marketing_data_platform-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ data-platform-login = data_platform_agent_tools.login_cli:main
3
+ data-platform-mcp = data_platform_agent_tools.mcp_server:main