g4-data-mcp 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.
File without changes
@@ -0,0 +1,11 @@
1
+ """Allowlist do Genie: pertencer a um grupo do Databricks (`genie_group`), global e vivo.
2
+
3
+ O Lucas gerencia quem pode usar o Genie (caro) adicionando/removendo pessoas do grupo no admin do
4
+ Databricks; vale na hora, para todos, sem republicar o pacote. Quem não está no grupo cai no SQL
5
+ puro. Reforço real (defense-in-depth): grant no Genie space + Genie budget por grupo no Databricks.
6
+ """
7
+ from .config import get_settings
8
+
9
+
10
+ def can_use_genie(user_groups: set[str]) -> bool:
11
+ return get_settings().genie_group in user_groups
g4_data_mcp/auth.py ADDED
@@ -0,0 +1,47 @@
1
+ """Autenticação por usuário (on-behalf-of).
2
+
3
+ Databricks: unified auth do databricks-sdk (OAuth U2M external-browser/profile/env); o token roda as
4
+ REST/MCP como o próprio usuário. Looker: OAuth PKCE per-user (ver looker_oauth), o access_token do
5
+ usuário vira a credencial da CA API, atribuindo a atividade a ele.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from functools import lru_cache
10
+
11
+ from databricks.sdk import WorkspaceClient
12
+ from google.cloud import geminidataanalytics
13
+
14
+ from . import looker_oauth
15
+ from .config import get_settings
16
+
17
+
18
+ @lru_cache
19
+ def workspace() -> WorkspaceClient:
20
+ s = get_settings()
21
+ if s.databricks_profile:
22
+ return WorkspaceClient(profile=s.databricks_profile)
23
+ return WorkspaceClient(
24
+ host=s.databricks_host or None,
25
+ auth_type=s.databricks_auth_type or None,
26
+ )
27
+
28
+
29
+ @lru_cache
30
+ def _me():
31
+ return workspace().current_user.me()
32
+
33
+
34
+ def current_user_email() -> str:
35
+ return _me().user_name or ""
36
+
37
+
38
+ def user_groups() -> set[str]:
39
+ """Grupos do usuário no Databricks (do próprio token), para a allowlist do Genie."""
40
+ return {g.display for g in (_me().groups or []) if g.display}
41
+
42
+
43
+ def ca_credentials() -> geminidataanalytics.Credentials:
44
+ """Credencial da CA API = access_token do Looker do próprio usuário (OAuth PKCE)."""
45
+ creds = geminidataanalytics.Credentials()
46
+ creds.oauth.token.access_token = looker_oauth.access_token()
47
+ return creds
@@ -0,0 +1,194 @@
1
+ """Agente comercial via Conversational Analytics API (chat stateless).
2
+
3
+ Fala com o DataAgent `comercial-g4-v0` já provisionado (comercial/scripts/provision_agent.py),
4
+ que aponta para o explore `funil_comercial` do Looker. Lógica migrada de comercial/app/ca_client.py;
5
+ a diferença é que a credencial do Looker é a do usuário final (impersonation), não a de serviço.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any
11
+
12
+ from google.api_core import client_options as client_options_lib
13
+ from google.cloud import geminidataanalytics
14
+
15
+ from .config import Settings, get_settings
16
+
17
+
18
+ @dataclass
19
+ class AgentResult:
20
+ answer: str = ""
21
+ sql: str | None = None
22
+ explore_used: str | None = None
23
+ data: list[dict[str, Any]] | None = None
24
+ chart: dict[str, Any] | None = None
25
+ looker_queries: list[dict[str, Any]] = field(default_factory=list)
26
+ errors: list[str] = field(default_factory=list)
27
+
28
+
29
+ _client: geminidataanalytics.DataChatServiceClient | None = None
30
+
31
+
32
+ def _chat_client(settings: Settings) -> geminidataanalytics.DataChatServiceClient:
33
+ global _client
34
+ if _client is None:
35
+ opts = client_options_lib.ClientOptions(api_endpoint=settings.api_endpoint)
36
+ _client = geminidataanalytics.DataChatServiceClient(client_options=opts)
37
+ return _client
38
+
39
+
40
+ def chat(question: str, credentials: geminidataanalytics.Credentials, settings: Settings | None = None) -> AgentResult:
41
+ settings = settings or get_settings()
42
+ client = _chat_client(settings)
43
+
44
+ message = geminidataanalytics.Message()
45
+ message.user_message.text = question
46
+
47
+ data_agent_context = geminidataanalytics.DataAgentContext()
48
+ data_agent_context.data_agent = settings.data_agent_path
49
+
50
+ request = geminidataanalytics.ChatRequest(
51
+ parent=settings.parent_path,
52
+ messages=[message],
53
+ data_agent_context=data_agent_context,
54
+ credentials=credentials,
55
+ )
56
+ return _parse_stream(client.chat(request=request, timeout=300))
57
+
58
+
59
+ def _parse_stream(stream) -> AgentResult:
60
+ final_blocks: list[str] = []
61
+ errors: list[str] = []
62
+ looker_queries: list[dict[str, Any]] = []
63
+ sql: str | None = None
64
+ explore_used: str | None = None
65
+ data_rows: list[dict[str, Any]] | None = None
66
+ chart: dict[str, Any] | None = None
67
+
68
+ for response in stream:
69
+ sys_msg = getattr(response, "system_message", None)
70
+ if sys_msg is None:
71
+ continue
72
+
73
+ text = getattr(sys_msg, "text", None)
74
+ if text is not None:
75
+ joined = " ".join(str(p) for p in (getattr(text, "parts", []) or []) if p).strip()
76
+ if joined and _is_final(text):
77
+ final_blocks.append(joined)
78
+
79
+ data_obj = getattr(sys_msg, "data", None)
80
+ if data_obj is not None:
81
+ lq = _extract_looker_query(data_obj)
82
+ if lq:
83
+ looker_queries.append(lq)
84
+ explore_used = explore_used or lq.get("explore")
85
+ sql = sql or _first_str(
86
+ getattr(data_obj, "generated_sql", None), getattr(data_obj, "sql", None)
87
+ )
88
+ rows = _extract_rows(data_obj)
89
+ if rows is not None:
90
+ data_rows = rows
91
+
92
+ schema_obj = getattr(sys_msg, "schema", None)
93
+ if schema_obj is not None and explore_used is None:
94
+ explore_used = _explore_from_schema(schema_obj)
95
+
96
+ chart_obj = getattr(sys_msg, "chart", None)
97
+ if chart_obj is not None and chart is None:
98
+ chart = _extract_chart(chart_obj)
99
+
100
+ err = getattr(sys_msg, "error", None)
101
+ if err is not None:
102
+ s = str(err).replace("\n", " ").strip()
103
+ if s:
104
+ errors.append(s[:500])
105
+
106
+ deduped: list[str] = []
107
+ for block in final_blocks:
108
+ if block not in deduped:
109
+ deduped.append(block)
110
+
111
+ return AgentResult(
112
+ answer="\n\n".join(deduped).strip(),
113
+ sql=sql,
114
+ explore_used=explore_used,
115
+ data=data_rows,
116
+ chart=chart,
117
+ looker_queries=looker_queries,
118
+ errors=errors,
119
+ )
120
+
121
+
122
+ def _is_final(text_msg) -> bool:
123
+ return getattr(text_msg, "text_type", None) == (
124
+ geminidataanalytics.TextMessage.TextType.FINAL_RESPONSE
125
+ )
126
+
127
+
128
+ def _extract_looker_query(data_obj) -> dict[str, Any] | None:
129
+ query = getattr(data_obj, "query", None)
130
+ looker = getattr(query, "looker", None) if query is not None else None
131
+ if looker is None:
132
+ return None
133
+ fields = list(getattr(looker, "fields", []) or [])
134
+ filters = {f.field: f.value for f in (getattr(looker, "filters", []) or [])}
135
+ if not fields and not filters:
136
+ return None
137
+ return {
138
+ "model": _first_str(getattr(looker, "model", None)),
139
+ "explore": _first_str(getattr(looker, "explore", None)),
140
+ "fields": fields,
141
+ "filters": filters,
142
+ }
143
+
144
+
145
+ def _first_str(*values) -> str | None:
146
+ for v in values:
147
+ if v:
148
+ s = str(v).strip()
149
+ if s:
150
+ return s
151
+ return None
152
+
153
+
154
+ def _jsonable(value: Any) -> Any:
155
+ if value is None or isinstance(value, (str, int, float, bool)):
156
+ return value
157
+ if hasattr(value, "items"):
158
+ return {str(k): _jsonable(v) for k, v in value.items()}
159
+ if hasattr(value, "__iter__") and not isinstance(value, (str, bytes)):
160
+ return [_jsonable(v) for v in value]
161
+ return str(value)
162
+
163
+
164
+ def _extract_rows(data_obj) -> list[dict[str, Any]] | None:
165
+ result = getattr(data_obj, "result", None)
166
+ rows = getattr(result, "data", None) if result is not None else None
167
+ if not rows:
168
+ return None
169
+ try:
170
+ return [_jsonable(r) for r in rows]
171
+ except Exception:
172
+ return None
173
+
174
+
175
+ def _extract_chart(chart_obj) -> dict[str, Any] | None:
176
+ result = getattr(chart_obj, "result", None)
177
+ vega = getattr(result, "vega_config", None) if result is not None else None
178
+ if vega is None:
179
+ return None
180
+ try:
181
+ return _jsonable(vega)
182
+ except Exception:
183
+ return None
184
+
185
+
186
+ def _explore_from_schema(schema_obj) -> str | None:
187
+ result = getattr(schema_obj, "result", None)
188
+ datasources = getattr(result, "datasources", None) if result is not None else None
189
+ for ds in datasources or []:
190
+ ref = getattr(ds, "looker_explore_reference", None)
191
+ explore = getattr(ref, "explore", None) if ref is not None else None
192
+ if explore:
193
+ return str(explore)
194
+ return None
g4_data_mcp/config.py ADDED
@@ -0,0 +1,60 @@
1
+ """Configuração do MCP de dados, lida de variáveis de ambiente.
2
+
3
+ O G4OS roda o pacote como processo stdio local e injeta o contexto do usuário e os
4
+ endpoints via env (bloco `env` do source). Nada é hardcoded para manter o container portável.
5
+ """
6
+ from functools import lru_cache
7
+
8
+ from pydantic import Field
9
+ from pydantic_settings import BaseSettings, SettingsConfigDict
10
+
11
+
12
+ class Settings(BaseSettings):
13
+ model_config = SettingsConfigDict(env_file=".env", extra="ignore", populate_by_name=True)
14
+
15
+ # Databricks (Genie + SQL). auth via unified auth do databricks-sdk. Defaults embutidos para o
16
+ # usuário não configurar nada: só o OAuth U2M no browser na 1a vez. Protegido por login.
17
+ databricks_host: str = Field(
18
+ default="https://dbc-8acefaf9-a170.cloud.databricks.com", validation_alias="DATABRICKS_HOST"
19
+ )
20
+ databricks_profile: str = Field(default="", validation_alias="DATABRICKS_PROFILE")
21
+ databricks_auth_type: str = Field(default="external-browser", validation_alias="DATABRICKS_AUTH_TYPE")
22
+
23
+ # Allowlist do Genie = pertencer a este grupo do Databricks (global e vivo; o Lucas gerencia no
24
+ # admin do Databricks). Fonte única: o token do usuário já traz os grupos dele.
25
+ genie_group: str = Field(default="MCP Genie Users", validation_alias="GENIE_GROUP")
26
+
27
+ # Looker (fonte do agente comercial); auth = OAuth PKCE per-user (ver looker_oauth)
28
+ looker_instance_uri: str = Field(
29
+ default="https://g4educacao.cloud.looker.com", validation_alias="LOOKER_INSTANCE_URI"
30
+ )
31
+
32
+ # Conversational Analytics API (agente comercial)
33
+ gcp_project: str = Field(default="g4-data", validation_alias="GCP_PROJECT")
34
+ gcp_location: str = Field(default="us-east4", validation_alias="GCP_LOCATION")
35
+ ca_data_agent_id: str = Field(default="comercial-g4-v0", validation_alias="CA_DATA_AGENT_ID")
36
+
37
+ @property
38
+ def data_agent_path(self) -> str:
39
+ return (
40
+ f"projects/{self.gcp_project}/locations/{self.gcp_location}"
41
+ f"/dataAgents/{self.ca_data_agent_id}"
42
+ )
43
+
44
+ @property
45
+ def parent_path(self) -> str:
46
+ return f"projects/{self.gcp_project}/locations/{self.gcp_location}"
47
+
48
+ @property
49
+ def api_endpoint(self) -> str:
50
+ loc = self.gcp_location
51
+ if not loc or loc == "global":
52
+ return "geminidataanalytics.googleapis.com"
53
+ if "-" in loc:
54
+ return f"geminidataanalytics-{loc}.googleapis.com"
55
+ return f"geminidataanalytics.{loc}.rep.googleapis.com"
56
+
57
+
58
+ @lru_cache
59
+ def get_settings() -> Settings:
60
+ return Settings()
@@ -0,0 +1,123 @@
1
+ """Databricks em nome do usuário, via os managed MCP servers (cliente MCP + Bearer do usuário).
2
+
3
+ Ambos os paths rodam com a identidade e as permissões da pessoa (audit + Unity Catalog por usuário),
4
+ como o uc-mcp-proxy faz. É o padrão que o Databricks suporta para stdio local (o token OAuth U2M do
5
+ usuário, obtido pelo databricks-sdk, autentica o cliente MCP).
6
+
7
+ - Genie One: `/api/2.0/mcp/genie` (enterprise-wide, sem space), tools genie_ask/genie_poll_response.
8
+ - SQL puro: `/api/2.0/mcp/sql`, tool execute_sql_read_only/poll_sql_result (read-only nativo).
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ from contextlib import asynccontextmanager
14
+ from typing import Any
15
+
16
+ from databricks.sdk import WorkspaceClient
17
+ from mcp.client.session import ClientSession
18
+ from mcp.client.streamable_http import streamablehttp_client
19
+
20
+
21
+ def _bearer(w: WorkspaceClient) -> str:
22
+ return w.config.authenticate()["Authorization"].split(" ", 1)[1]
23
+
24
+
25
+ def _mcp_url(w: WorkspaceClient, suffix: str) -> str:
26
+ return w.config.host.rstrip("/") + suffix
27
+
28
+
29
+ def _text(res) -> str:
30
+ return "\n\n".join(c.text for c in (res.content or []) if getattr(c, "text", None)).strip()
31
+
32
+
33
+ @asynccontextmanager
34
+ async def _session(url: str, token: str):
35
+ headers = {"Authorization": f"Bearer {token}"}
36
+ async with streamablehttp_client(url, headers=headers) as (read, write, _):
37
+ async with ClientSession(read, write) as session:
38
+ await session.initialize()
39
+ yield session
40
+
41
+
42
+ async def _genie_call(url: str, token: str, question: str, *, poll_max: int = 120, poll_interval: float = 3.0) -> dict[str, Any]:
43
+ async with _session(url, token) as s:
44
+ res = await s.call_tool("genie_ask", {"question": question})
45
+ sc: dict[str, Any] = res.structuredContent or {}
46
+ cid, rid = sc.get("conversation_id"), sc.get("response_id")
47
+
48
+ for _ in range(poll_max):
49
+ status = sc.get("status")
50
+ if sc.get("final_answer") or (status and status != "in_progress"):
51
+ break
52
+ if not (cid and rid):
53
+ break
54
+ await asyncio.sleep(poll_interval)
55
+ res = await s.call_tool("genie_poll_response", {"conversation_id": cid, "response_id": rid})
56
+ sc = res.structuredContent or {}
57
+
58
+ return {
59
+ "answer": sc.get("final_answer") or _text(res),
60
+ "status": sc.get("status"),
61
+ "deep_link": sc.get("deep_link"),
62
+ "conversation_id": cid,
63
+ "response_id": rid,
64
+ }
65
+
66
+
67
+ def _sql_state(sc: dict[str, Any]) -> str:
68
+ return ((sc.get("status") or {}).get("state") or "").upper()
69
+
70
+
71
+ def _cell(v: Any) -> Any:
72
+ if isinstance(v, dict):
73
+ return next(iter(v.values()), None)
74
+ return v
75
+
76
+
77
+ def _sql_rows(sc: dict[str, Any]) -> dict[str, Any]:
78
+ manifest = sc.get("manifest") or {}
79
+ cols = [c["name"] for c in (manifest.get("schema") or {}).get("columns", [])]
80
+ raw = (sc.get("result") or {}).get("data_array") or []
81
+ rows = []
82
+ for r in raw:
83
+ values = r.get("values", []) if isinstance(r, dict) else r
84
+ rows.append(dict(zip(cols, (_cell(v) for v in values))))
85
+ return {
86
+ "columns": cols,
87
+ "rows": rows,
88
+ "row_count": manifest.get("total_row_count", len(rows)),
89
+ "truncated": bool(manifest.get("truncated")),
90
+ "statement_id": sc.get("statement_id"),
91
+ }
92
+
93
+
94
+ async def _sql_call(url: str, token: str, statement: str, *, poll_max: int = 100, poll_interval: float = 2.0) -> dict[str, Any]:
95
+ async with _session(url, token) as s:
96
+ res = await s.call_tool("execute_sql_read_only", {"query": statement})
97
+ sc: dict[str, Any] = res.structuredContent or {}
98
+
99
+ for _ in range(poll_max):
100
+ if _sql_state(sc) not in ("PENDING", "RUNNING"):
101
+ break
102
+ sid = sc.get("statement_id")
103
+ if not sid:
104
+ break
105
+ await asyncio.sleep(poll_interval)
106
+ res = await s.call_tool("poll_sql_result", {"statement_id": sid})
107
+ sc = res.structuredContent or {}
108
+
109
+ state = _sql_state(sc)
110
+ if state and state != "SUCCEEDED":
111
+ err = ((sc.get("status") or {}).get("error") or {}).get("message") or state
112
+ raise RuntimeError(f"SQL {state}: {err}")
113
+ return _sql_rows(sc)
114
+
115
+
116
+ def genie_ask(w: WorkspaceClient, question: str) -> dict[str, Any]:
117
+ """Pergunta ao Genie One (managed MCP enterprise-wide), como o usuário."""
118
+ return asyncio.run(_genie_call(_mcp_url(w, "/api/2.0/mcp/genie"), _bearer(w), question))
119
+
120
+
121
+ def run_sql(w: WorkspaceClient, statement: str) -> dict[str, Any]:
122
+ """Executa um SELECT read-only via o managed MCP SQL, como o usuário."""
123
+ return asyncio.run(_sql_call(_mcp_url(w, "/api/2.0/mcp/sql"), _bearer(w), statement))
@@ -0,0 +1,152 @@
1
+ """OAuth PKCE do Looker (per-user), para o agente comercial.
2
+
3
+ O OAuth client app foi registrado 1x no Looker (`client_guid=g4-data-mcp`). Aqui só o login do
4
+ usuário: browser (Authorization Code + PKCE) → `access_token` DELE + `refresh_token` (~1 mês),
5
+ cacheado local e renovado sozinho. O token vira a credencial da CA API, então a query roda como a
6
+ pessoa. Espelha o external-browser do databricks-sdk (nenhum client_secret, é PKCE).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import hashlib
12
+ import http.server
13
+ import json
14
+ import secrets
15
+ import threading
16
+ import time
17
+ import urllib.parse
18
+ import urllib.request
19
+ from pathlib import Path
20
+ from urllib.parse import urlparse
21
+
22
+ from .config import Settings, get_settings
23
+
24
+ _CLIENT_GUID = "g4-data-mcp"
25
+ _REDIRECT_PORT = 47821
26
+ _REDIRECT_URI = f"http://localhost:{_REDIRECT_PORT}/callback"
27
+ _SCOPE = "cors_api"
28
+
29
+
30
+ def _cache_path() -> Path:
31
+ d = Path.home() / ".config" / "g4-data-mcp"
32
+ d.mkdir(parents=True, exist_ok=True)
33
+ return d / "looker_token.json"
34
+
35
+
36
+ def _urls(settings: Settings) -> tuple[str, str]:
37
+ p = urlparse(settings.looker_instance_uri)
38
+ auth_url = f"{p.scheme}://{p.hostname}/auth"
39
+ token_url = f"{p.scheme}://{p.hostname}:19999/api/token"
40
+ return auth_url, token_url
41
+
42
+
43
+ def _pkce() -> tuple[str, str]:
44
+ verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
45
+ digest = hashlib.sha256(verifier.encode()).digest()
46
+ challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
47
+ return verifier, challenge
48
+
49
+
50
+ def _exchange(token_url: str, payload: dict) -> dict:
51
+ data = json.dumps(payload).encode()
52
+ req = urllib.request.Request(
53
+ token_url, data=data, method="POST",
54
+ headers={"Content-Type": "application/json;charset=UTF-8"},
55
+ )
56
+ with urllib.request.urlopen(req, timeout=30) as resp:
57
+ tok = json.loads(resp.read().decode())
58
+ tok["_obtained_at"] = int(time.time())
59
+ _cache_path().write_text(json.dumps(tok))
60
+ return tok
61
+
62
+
63
+ def _browser_login(auth_url: str, token_url: str) -> dict:
64
+ verifier, challenge = _pkce()
65
+ state = secrets.token_urlsafe(16)
66
+ query = urllib.parse.urlencode({
67
+ "response_type": "code",
68
+ "client_id": _CLIENT_GUID,
69
+ "redirect_uri": _REDIRECT_URI,
70
+ "scope": _SCOPE,
71
+ "state": state,
72
+ "code_challenge": challenge,
73
+ "code_challenge_method": "S256",
74
+ })
75
+
76
+ received: dict[str, str | None] = {}
77
+
78
+ class Handler(http.server.BaseHTTPRequestHandler):
79
+ def do_GET(self):
80
+ parsed = urllib.parse.urlparse(self.path)
81
+ if parsed.path != "/callback":
82
+ self.send_response(404)
83
+ self.end_headers()
84
+ return
85
+ qs = urllib.parse.parse_qs(parsed.query)
86
+ received["code"] = qs.get("code", [None])[0]
87
+ received["state"] = qs.get("state", [None])[0]
88
+ self.send_response(200)
89
+ self.send_header("Content-Type", "text/html; charset=utf-8")
90
+ self.end_headers()
91
+ self.wfile.write(b"<h3>Login concluido. Pode fechar esta aba.</h3>")
92
+
93
+ def log_message(self, *args):
94
+ pass
95
+
96
+ class _Server(http.server.HTTPServer):
97
+ allow_reuse_address = True
98
+ allow_reuse_port = True
99
+
100
+ httpd = _Server(("localhost", _REDIRECT_PORT), Handler)
101
+ threading.Thread(target=httpd.handle_request, daemon=True).start()
102
+
103
+ import webbrowser
104
+ webbrowser.open(f"{auth_url}?{query}")
105
+
106
+ for _ in range(300):
107
+ if received.get("code"):
108
+ break
109
+ time.sleep(1)
110
+ httpd.server_close()
111
+
112
+ if not received.get("code") or received.get("state") != state:
113
+ raise RuntimeError("Looker OAuth: callback sem code válido (ou state divergente)")
114
+
115
+ return _exchange(token_url, {
116
+ "grant_type": "authorization_code",
117
+ "client_id": _CLIENT_GUID,
118
+ "redirect_uri": _REDIRECT_URI,
119
+ "code": received["code"],
120
+ "code_verifier": verifier,
121
+ })
122
+
123
+
124
+ def access_token(settings: Settings | None = None) -> str:
125
+ """access_token do Looker do usuário (do cache com refresh, ou disparando o browser)."""
126
+ settings = settings or get_settings()
127
+ auth_url, token_url = _urls(settings)
128
+
129
+ cache = _cache_path()
130
+ tok = None
131
+ if cache.exists():
132
+ try:
133
+ tok = json.loads(cache.read_text())
134
+ except Exception:
135
+ tok = None
136
+
137
+ if tok and tok.get("access_token"):
138
+ valid_until = tok.get("_obtained_at", 0) + int(tok.get("expires_in", 3600)) - 60
139
+ if time.time() < valid_until:
140
+ return tok["access_token"]
141
+ if tok.get("refresh_token"):
142
+ try:
143
+ refreshed = _exchange(token_url, {
144
+ "grant_type": "refresh_token",
145
+ "client_id": _CLIENT_GUID,
146
+ "refresh_token": tok["refresh_token"],
147
+ })
148
+ return refreshed["access_token"]
149
+ except Exception:
150
+ pass
151
+
152
+ return _browser_login(auth_url, token_url)["access_token"]
g4_data_mcp/server.py ADDED
@@ -0,0 +1,76 @@
1
+ """MCP de dados do G4 (stdio).
2
+
3
+ O G4OS escolhe a tool pela descrição (sem IA de classificação aqui). Cada agente especializado é
4
+ uma tool; o fallback genérico decide, pela allowlist do usuário, entre Genie e SQL puro. Tudo roda
5
+ em nome do usuário (OBO), para dar rastreio por pessoa no Looker e no Databricks.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from mcp.server.fastmcp import FastMCP
12
+
13
+ from . import allowlist, auth, commercial
14
+ from . import databricks as dbx
15
+
16
+ mcp = FastMCP("g4-data")
17
+
18
+
19
+ @mcp.tool()
20
+ def ask_commercial(question: str) -> dict[str, Any]:
21
+ """Responde perguntas sobre o FUNIL COMERCIAL do G4.
22
+
23
+ Use para vendas/comercial: oportunidades, Win Rate, conversões (CR01..CR04), deals ativos,
24
+ faturamento, ranking de AE (proprietário) e SDR (qualificador), comparação de períodos.
25
+ Não use para metas/atingimento, Potencial de Receita nem dados fora do funil.
26
+ """
27
+ result = commercial.chat(question, auth.ca_credentials())
28
+ return {
29
+ "answer": result.answer,
30
+ "sql": result.sql,
31
+ "explore_used": result.explore_used,
32
+ "data": result.data,
33
+ "chart": result.chart,
34
+ "errors": result.errors or None,
35
+ }
36
+
37
+
38
+ @mcp.tool()
39
+ def question(question: str) -> dict[str, Any]:
40
+ """Responde perguntas gerais sobre os dados do G4 que NENHUM agente especializado cobre.
41
+
42
+ Use quando a pergunta não é do funil comercial (ex.: produto, marketing, financeiro, pós-venda).
43
+ Se o usuário puder usar o Genie, responde direto. Caso contrário, retorna status `needs_sql`:
44
+ escreva um SELECT (Databricks SQL, Unity Catalog `production.*`) e chame `run_sql`.
45
+ """
46
+ if not allowlist.can_use_genie(auth.user_groups()):
47
+ return {
48
+ "status": "needs_sql",
49
+ "message": (
50
+ "Você não tem acesso ao Genie. Escreva um SELECT (Databricks SQL, Unity Catalog, "
51
+ "namespace de três níveis catalog.schema.table, ex. production.gold.*) e chame a "
52
+ "tool run_sql. Para descobrir os dados: SHOW SCHEMAS IN production; "
53
+ "SHOW TABLES IN production.gold; DESCRIBE TABLE production.gold.<tabela>."
54
+ ),
55
+ }
56
+ result = dbx.genie_ask(auth.workspace(), question)
57
+ return {"status": "answered", **result}
58
+
59
+
60
+ @mcp.tool()
61
+ def run_sql(statement: str) -> dict[str, Any]:
62
+ """Executa um SELECT no Databricks (SQL Warehouse) e retorna as linhas.
63
+
64
+ Use APÓS a tool `question` retornar `needs_sql`: você escreve o SELECT e chama aqui. Roda com as
65
+ permissões do próprio usuário (Unity Catalog); só leitura conforme os grants dele. Use nomes
66
+ qualificados (catalog.schema.table).
67
+ """
68
+ return dbx.run_sql(auth.workspace(), statement)
69
+
70
+
71
+ def main() -> None:
72
+ mcp.run()
73
+
74
+
75
+ if __name__ == "__main__":
76
+ main()
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: g4-data-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP de dados do G4: roteia perguntas para o agente comercial (Looker/CA API), Genie ou SQL puro do Databricks, em nome do usuário.
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: databricks-sdk>=0.38.0
7
+ Requires-Dist: google-cloud-geminidataanalytics
8
+ Requires-Dist: mcp>=1.2.0
9
+ Requires-Dist: pydantic-settings
@@ -0,0 +1,12 @@
1
+ g4_data_mcp/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ g4_data_mcp/allowlist.py,sha256=HydafDifACAYDE97fJcNGThzeCabYLnuK3C8XUJL-yc,531
3
+ g4_data_mcp/auth.py,sha256=qFzg_VzOC94OPvhPpGhiqFo848Ch2ob36kblYeqUO1A,1427
4
+ g4_data_mcp/commercial.py,sha256=eNpd89nEOWHw-si3aHMk0RfDJNR2FXpSVbJ38wjetq4,6448
5
+ g4_data_mcp/config.py,sha256=mh5RmSm16A7rSphfiTjKzPORPc4PrMgQEiJiURGJJJY,2573
6
+ g4_data_mcp/databricks.py,sha256=s-pyAyw5-pChVmOhTiEUAHcOdfDVW-eDcr7UPD2qdyA,4793
7
+ g4_data_mcp/looker_oauth.py,sha256=GIyMffJ7vrOkP01hwB00hW5rRSTobNgIEaUMq8GBGW0,5060
8
+ g4_data_mcp/server.py,sha256=vGIuvS6lvV50FQtlzDWWcjvjCyIaK5GcDTAZ7hywaSA,2827
9
+ g4_data_mcp-0.1.0.dist-info/METADATA,sha256=wPUh8NxJtGf8ttvXlDZmaBmXFIJn2MaVTOCiuS9l134,365
10
+ g4_data_mcp-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
11
+ g4_data_mcp-0.1.0.dist-info/entry_points.txt,sha256=npUo04mwmRlf0lD15rNyQ34ruIpRoDEkuW4TCDKKFJg,56
12
+ g4_data_mcp-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ g4-data-mcp = g4_data_mcp.server:main