g4-data-mcp 0.1.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.
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.egg-info/
5
+ build/
6
+ dist/
7
+ .env
@@ -0,0 +1,99 @@
1
+ # Instalar o G4 Data MCP no G4OS
2
+
3
+ Onboarding do source `g4-data` (o MCP de dados) no G4OS. O MCP roda local (stdio) via `uvx`, em nome
4
+ do usuário: comercial (Looker/CA API), Genie One e SQL puro do Databricks. O usuário **não configura
5
+ nada** (hosts e projeto são default embutido; identidade e allowlist vêm do token). O G4OS executa os
6
+ comandos; a única ação do usuário é completar os dois logins no browser na primeira vez.
7
+
8
+ ## 0. Pré-requisito: `uv` (traz o `uvx`)
9
+
10
+ Se `uvx` não existir, instalar o `uv`:
11
+ - **Mac:** `curl -LsSf https://astral.sh/uv/install.sh | sh` (ou `brew install uv`)
12
+ - **Windows:** `irm https://astral.sh/uv/install.ps1 | iex` (ou `winget install --id=astral-sh.uv -e`)
13
+
14
+ Conferir: `uvx --version`.
15
+
16
+ ## 1. Criar o source `g4-data`
17
+
18
+ Criar `sources/g4-data/config.json` (o `id` é `g4-data_<8hex>`, gerar o random):
19
+
20
+ ```json
21
+ {
22
+ "id": "g4-data_0a1b2c3d",
23
+ "name": "G4 Data",
24
+ "slug": "g4-data",
25
+ "provider": "g4",
26
+ "type": "mcp",
27
+ "icon": "📊",
28
+ "tagline": "Pergunte dados do G4: comercial (Looker), Genie e SQL do Databricks, em nome do usuário.",
29
+ "mcp": {
30
+ "transport": "stdio",
31
+ "command": "uvx",
32
+ "args": ["g4-data-mcp"],
33
+ "authType": "none"
34
+ }
35
+ }
36
+ ```
37
+
38
+ > `authType: "none"` porque o **próprio pacote** faz o OAuth (Databricks U2M + Looker PKCE) no
39
+ > primeiro uso; não é o G4OS que dispara o fluxo de auth do source.
40
+
41
+ ## 2. Permissões (Explore mode, read-only)
42
+
43
+ Criar `sources/g4-data/permissions.json`:
44
+
45
+ ```json
46
+ {
47
+ "allowedMcpPatterns": [
48
+ { "pattern": "ask_commercial", "comment": "Pergunta ao agente comercial (Looker)" },
49
+ { "pattern": "question", "comment": "Pergunta de dados (Genie ou pede SQL)" },
50
+ { "pattern": "run_sql", "comment": "Executa SELECT read-only no Databricks" }
51
+ ]
52
+ }
53
+ ```
54
+
55
+ ## 3. guide.md
56
+
57
+ Criar `sources/g4-data/guide.md`:
58
+
59
+ ```markdown
60
+ # G4 Data
61
+
62
+ Dados do G4 em linguagem natural, em nome do usuário logado.
63
+
64
+ ## Tools
65
+ - `ask_commercial(question)` — funil comercial (Win Rate, conversões, deals, ranking de AE/SDR).
66
+ - `question(question)` — dados fora do comercial. Se o usuário estiver no grupo `MCP Genie Users` do
67
+ Databricks, responde via Genie; senão retorna `needs_sql` (então gere um SELECT e chame `run_sql`).
68
+ - `run_sql(statement)` — executa um SELECT (Unity Catalog, `catalog.schema.table`), read-only.
69
+
70
+ ## Notas
71
+ - Roda com as permissões do próprio usuário (audit por pessoa nos dois lados).
72
+ - Genie é restrito ao grupo `MCP Genie Users` (gerenciado no admin do Databricks).
73
+ ```
74
+
75
+ ## 4. Validar
76
+
77
+ ```
78
+ source_test({ sourceSlug: "g4-data" })
79
+ ```
80
+ Deve conectar e listar as 3 tools (`ask_commercial`, `question`, `run_sql`).
81
+
82
+ ## 5. Primeiro login (uma vez por usuário)
83
+
84
+ O OAuth é disparado pelo pacote no **primeiro uso** de cada backend, abrindo o browser:
85
+ - primeira chamada a `question`/`run_sql` → login **Databricks** (U2M, browser);
86
+ - primeira chamada a `ask_commercial` → login **Looker** (PKCE, browser).
87
+
88
+ Para pré-autenticar de uma vez (recomendado no onboarding), fazer duas perguntas de teste, uma de
89
+ cada tipo, e pedir ao usuário para concluir os dois logins no browser. Depois disso os tokens ficam
90
+ cacheados (`~/.config/g4-data-mcp/` e o cache do databricks-sdk) e renovam sozinhos.
91
+
92
+ ## Notas
93
+
94
+ - **Zero configuração do usuário:** nenhum host, chave ou e-mail é pedido. Tudo default ou derivado
95
+ do token.
96
+ - **Allowlist do Genie:** pertencer ao grupo `MCP Genie Users` no Databricks. Quem não está usa o SQL
97
+ puro (`run_sql`). Trocar o grupo: env `GENIE_GROUP` no `config.json`.
98
+ - **Atualização:** quando sai uma versão nova no PyPI, o `uvx` passa a usá-la (pode-se pinar com
99
+ `g4-data-mcp==<versão>` nos `args`).
@@ -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,73 @@
1
+ # g4-data-mcp
2
+
3
+ MCP de dados do G4, instalado no G4OS como pacote **stdio local**. Recebe perguntas e roteia, **em
4
+ nome do usuário** (OBO), para o agente comercial (Looker/CA API), o Genie ou o SQL puro do Databricks.
5
+
6
+ Sem IA de classificação aqui: o G4OS escolhe a tool pela descrição. Cada agente especializado é uma
7
+ tool; a decisão Genie-vs-SQL (allowlist) mora dentro deste MCP.
8
+
9
+ ## Tools
10
+
11
+ - **`ask_commercial(question)`** — funil comercial (oportunidades, Win Rate, conversões, deals,
12
+ faturamento, ranking de AE/SDR). Chama a Conversational Analytics API do Google sobre o DataAgent
13
+ `comercial-g4-v0` (explore `funil_comercial` do Looker), com o token do Looker **do usuário**.
14
+ - **`question(question)`** — perguntas de dados fora do comercial. Se o usuário está na allowlist do
15
+ Genie, responde via **Genie One** (managed MCP `/api/2.0/mcp/genie`, enterprise-wide, sem space).
16
+ Senão, retorna `status: needs_sql` e o G4OS escreve o SELECT.
17
+ - **`run_sql(statement)`** — executa o SELECT via o **managed MCP SQL** (`/api/2.0/mcp/sql`,
18
+ `execute_sql_read_only`) com as permissões do próprio usuário.
19
+
20
+ ## Auth (por usuário, OBO)
21
+
22
+ - **Databricks:** unified auth do `databricks-sdk` (OAuth U2M `external-browser`, `profile`, ou env).
23
+ O MCP vira **cliente** dos managed MCP do Databricks (Genie One e SQL) com o Bearer do usuário, como
24
+ o `uc-mcp-proxy`. A query roda como o usuário (`current_user()` confirma) → audit por pessoa.
25
+ - **Looker:** OAuth PKCE per-user (`looker_oauth.py`). O OAuth client app `g4-data-mcp` já está
26
+ registrado no Looker (`redirect_uri=http://localhost:47821/callback`, scope `cors_api`). O usuário
27
+ loga no browser (`{host}/auth`), troca o code em `{host}:19999/api/token` por um `access_token`
28
+ dele + `refresh_token`, cacheado em `~/.config/g4-data-mcp/looker_token.json`. Esse token vira a
29
+ credencial da CA API. Sem client_secret (é PKCE).
30
+ - **Identidade e allowlist:** o e-mail e os grupos vêm do **token do Databricks** do usuário
31
+ (`current_user().me()`), nada é passado por env. A allowlist do Genie = pertencer ao grupo
32
+ `genie_group` (default `MCP Genie Users`) no Databricks; gerenciado no admin do Databricks, vale para
33
+ todos na hora, sem republicar o pacote.
34
+
35
+ ## Rodar local
36
+
37
+ ```bash
38
+ uv venv --python 3.12 .venv && . .venv/bin/activate
39
+ uv pip install -e .
40
+
41
+ # SQL puro (read-only), em nome do usuário do profile (via managed MCP SQL):
42
+ DATABRICKS_PROFILE=g4 python -m scripts.smoke sql "SELECT current_user()"
43
+ # Genie One (custa):
44
+ DATABRICKS_PROFILE=g4 python -m scripts.smoke genie "quantos leads em janeiro de 2026?"
45
+ # Servir via stdio (o que o G4OS roda):
46
+ g4-data-mcp
47
+ ```
48
+
49
+ ## Config do source no G4OS
50
+
51
+ O pacote é publicado no PyPI, então o G4OS instala e roda com `uvx g4-data-mcp` (sem GitHub, sem
52
+ auth de índice). Hosts/projeto/location são default embutido e identidade/allowlist vêm do token, então
53
+ o source **não precisa de env nenhuma**:
54
+
55
+ ```json
56
+ {
57
+ "type": "mcp",
58
+ "mcp": {
59
+ "transport": "stdio",
60
+ "command": "uvx",
61
+ "args": ["g4-data-mcp"]
62
+ }
63
+ }
64
+ ```
65
+
66
+ O usuário não configura nada: na 1a vez faz OAuth U2M do Databricks (browser) e OAuth PKCE do Looker
67
+ (browser); os tokens ficam cacheados e renovam sozinhos. Qualquer default pode ser sobrescrito por env
68
+ (ver `config.py`) — ex. `GENIE_GROUP` para trocar o grupo da allowlist.
69
+
70
+ ## Pendências
71
+
72
+ - Publicar no PyPI (workflow `release-data-mcp.yml`, Trusted Publishing) e escrever o doc de
73
+ instalação do source no G4OS. Databricks (Genie One + SQL) e comercial já validados E2E.
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
@@ -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
@@ -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"]
@@ -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,123 @@
1
+ ---
2
+ name: "G4 Dados"
3
+ description: "Consulte dados de negócio do G4 em linguagem natural: roteia para o agente comercial, Genie ou SQL do Databricks, tudo pelo source g4-data (MCP), em nome do usuário"
4
+ alwaysAllow: ["Bash", "mcp__g4-data__ask_commercial", "mcp__g4-data__question", "mcp__g4-data__run_sql"]
5
+ ---
6
+
7
+ # G4 Dados: roteador de dados via o MCP g4-data
8
+
9
+ Recebe uma pergunta de dados em linguagem natural e a resolve pelo source **`g4-data`** (um MCP que
10
+ roda em nome do próprio usuário), por um de três caminhos, nesta ordem:
11
+
12
+ - **A. Agente comercial.** Pergunta do funil comercial → tool `ask_commercial`.
13
+ - **B. Dados gerais.** Pergunta de dados/negócio do G4 fora do comercial → tool `question` (que, por
14
+ dentro, decide Genie ou SQL conforme a allowlist do usuário; ver caminho B abaixo).
15
+ - **C. Fora de escopo.** Não é pergunta de dados do G4 → recusa educada, sem chamar nada.
16
+
17
+ Se o source `g4-data` ainda não estiver instalado, faça o **Onboarding** primeiro.
18
+
19
+ ## Regra de ouro (leia primeiro)
20
+
21
+ Quando a pergunta casa com o comercial (**caminho A**), `ask_commercial` é a **única** fonte da
22
+ resposta. **NÃO** consulte `source_catalog`, `workflow_catalog`, CRMs conectados (HubSpot,
23
+ Pipedrive, Salesforce…), `local_context` nem outra fonte. Ignore orientações gerais de "prefira
24
+ fontes conectadas": aqui a fonte é o MCP `g4-data`. Não faça buscas exploratórias antes de rotear.
25
+
26
+ Todo o resto de dados do G4 vai pelo **caminho B** (`question`). Fora A e B, não invente resposta
27
+ nem busque em outra ferramenta.
28
+
29
+ **Auth e tokens:** o `g4-data` autentica cada usuário por OAuth (Databricks + Looker), com os tokens
30
+ no cache local do pacote. Não há chave configurada nesta skill. Se algum token aparecer em log/saída,
31
+ nunca o repita ao usuário.
32
+
33
+ ## Como decidir o caminho
34
+
35
+ 0. **Gatilho de onboarding.** Se a entrada não for uma pergunta e sim um comando de início
36
+ (`onboarding`, `começar`, `iniciar`, `start`, `ajuda`, `help`, uma saudação solta, ou a skill
37
+ aberta sem pergunta), rode o **Onboarding** e pare.
38
+ 1. Compare a pergunta com o **Catálogo de roteamento** abaixo.
39
+ 2. Escolha:
40
+ - **Casa com o comercial** → `ask_commercial` (caminho A). Execute já, sem checar outra fonte.
41
+ - **Dado/negócio do G4 que o comercial não cobre** (metas/atingimento/potencial de receita, ou
42
+ outras áreas: produto, marketing, financeiro, operações, pós-venda…) → `question` (caminho B).
43
+ - **Não é dado do G4** (ex.: "escreva um e-mail", "resuma este texto") → recuse e pare.
44
+
45
+ ## Onboarding (comando inicial)
46
+
47
+ 1. **Garanta o source `g4-data`.** Rode `source_test({ sourceSlug: "g4-data" })`. Se não estiver
48
+ instalado/conectado, instale seguindo o `INSTALL_G4OS.md` (deste pacote): garante o `uv`, cria o
49
+ source `g4-data` (config/permissions/guide), e valida. O G4OS roda os comandos; o usuário só
50
+ completa os **dois logins no browser** (Databricks U2M + Looker PKCE) na primeira consulta de cada
51
+ tipo. Se o usuário preferir depois, avise que os dados ficam indisponíveis até instalar.
52
+ 2. **Mensagem de boas-vindas** (não chame nenhuma tool), curta e acionável, cobrindo:
53
+ - **O que faz:** ponto único para perguntar dados do G4 em linguagem natural; roteia sozinho.
54
+ - **Disponível hoje:** o Agente Comercial (resumo do Catálogo) e, para o resto, o `question`
55
+ (Genie para quem está no grupo `MCP Genie Users`; SQL para os demais).
56
+ - **Como usar melhor:** perguntas objetivas, com período (mês/ano) e recorte (por AE, SDR,
57
+ squad). Dê 2 ou 3 exemplos do catálogo.
58
+ - **Status:** se o `g4-data` está conectado ou o que falta.
59
+
60
+ ## Catálogo de roteamento
61
+
62
+ ### Agente Comercial → `ask_commercial`
63
+
64
+ Funil **comercial** do G4, pipeline Comercial (`pipeline_id = 1300511`). Roteie para cá quando for:
65
+
66
+ **Resolve:**
67
+ - Volumes do funil: oportunidades geradas, deals Won, MQL, SAL, agendamentos, calls, deals ativos.
68
+ - Conversões: CR01 (MQL→SAL), CR02 (SAL→Agendamento), CR03 (Show Rate), CR04 (Win Rate, Opp→Won).
69
+ - Financeiro do funil: faturamento real (Line Items dos Won), ticket médio (TM Won).
70
+ - Tempo entre etapas (Delta T, "Death Valley"). Atividade de pré-vendas (ligações, agendamentos).
71
+ - Rankings e quebras por **AE/proprietário** e **SDR/qualificador**, e comparação de períodos.
72
+
73
+ **Não resolve (vai para o caminho B):**
74
+ - Metas individuais e atingimento; ajustes/coparticipação.
75
+ - Potencial de Receita (BRL) e vazamento de faturamento.
76
+ - Organograma/RH comercial (SDRs/AEs, squads, hierarquia, rampagem, tenure).
77
+ - Qualquer coisa fora do funil: produto, plataforma, marketing puro, financeiro corporativo,
78
+ pós-venda, field sales.
79
+
80
+ **Exemplos que casam:**
81
+ - "Quantas oportunidades foram geradas neste mês?"
82
+ - "Win Rate (CR04) por AE em abril de 2026."
83
+ - "Quantos deals ativos há na pipeline este mês?"
84
+ - "Conversão de SAL em agendamento (CR02) neste mês?"
85
+ - "Faturamento (Won) por AE em abril de 2026."
86
+ - "Ranking de oportunidades por SDR (só elegíveis)."
87
+
88
+ _(Novo agente = nova tool no MCP `g4-data` + um bloco aqui, ver "Adicionando agentes".)_
89
+
90
+ ## Caminho A: `ask_commercial`
91
+
92
+ Avise que está consultando ("Consultando o agente comercial…") e chame
93
+ `mcp__g4-data__ask_commercial({ question: "<pergunta>" })`. Retorna `answer`, `sql`, `explore_used`,
94
+ `data`, `chart`. Apresente conforme "Formatação". Se falhar, informe e sugira tentar de novo; **não**
95
+ compense buscando em outra fonte.
96
+
97
+ ## Caminho B: `question` (Genie ou SQL, decidido dentro do MCP)
98
+
99
+ Chame `mcp__g4-data__question({ question: "<pergunta>" })`. O MCP decide pela allowlist (grupo
100
+ `MCP Genie Users` no Databricks):
101
+
102
+ - **Se o usuário pode Genie:** a resposta volta pronta (campo `answer` + `data`). Apresente.
103
+ - **Se retornar `status: "needs_sql"`:** o usuário não tem Genie. Então **você escreve o SELECT** e
104
+ chama `mcp__g4-data__run_sql({ statement: "<SELECT>" })`. Use Databricks SQL, Unity Catalog com
105
+ nome de três níveis (`catalog.schema.table`, ex. `production.gold.*`). Para descobrir os dados:
106
+ `SHOW SCHEMAS IN production`, `SHOW TABLES IN production.gold`, `DESCRIBE TABLE production.gold.<t>`.
107
+ O `run_sql` é read-only (roda com as permissões do usuário). Itere o SQL se precisar e apresente.
108
+
109
+ O `question`/Genie é um motor geral (sem a curadoria da camada semântica do comercial): deixe claro
110
+ que é consulta direta ao Databricks e confira a sanidade antes de afirmar.
111
+
112
+ ## Formatação da resposta
113
+
114
+ Markdown, com tabelas quando os dados forem tabulares (rankings, funil por etapa) e números-chave em
115
+ **negrito**. Se houver fórmula, mostre-a numa linha curta. Termine com 2 ou 3 próximos passos
116
+ acionáveis quando fizer sentido. Se vier um link (deep link do Genie), inclua-o.
117
+
118
+ ## Adicionando agentes
119
+
120
+ 1. No pacote `g4-data-mcp`, adicione uma tool nova (um agente = uma tool, ex. `ask_produto`) e
121
+ publique a versão.
122
+ 2. Acrescente um bloco no **Catálogo de roteamento** acima (o que resolve / não resolve / exemplos)
123
+ e a tool no `alwaysAllow` do frontmatter.
@@ -0,0 +1,21 @@
1
+ [project]
2
+ name = "g4-data-mcp"
3
+ version = "0.1.0"
4
+ description = "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
+ dependencies = [
7
+ "mcp>=1.2.0",
8
+ "databricks-sdk>=0.38.0",
9
+ "google-cloud-geminidataanalytics",
10
+ "pydantic-settings",
11
+ ]
12
+
13
+ [project.scripts]
14
+ g4-data-mcp = "g4_data_mcp.server:main"
15
+
16
+ [build-system]
17
+ requires = ["hatchling"]
18
+ build-backend = "hatchling.build"
19
+
20
+ [tool.hatch.build.targets.wheel]
21
+ packages = ["g4_data_mcp"]
@@ -0,0 +1,40 @@
1
+ """Fumaça das tools do MCP, sem depender do G4OS. Roda o que as env vars presentes permitirem.
2
+
3
+ Uso:
4
+ cd data-mcp && . .venv/bin/activate
5
+ # SQL puro (read-only), via managed MCP SQL, como o usuário do profile:
6
+ DATABRICKS_PROFILE=g4 python -m scripts.smoke sql "SELECT current_user()"
7
+ # Genie One (custa), via managed MCP genie:
8
+ DATABRICKS_PROFILE=g4 python -m scripts.smoke genie "quantos leads no mês?"
9
+ # Comercial: precisa GCP_PROJECT/GCP_LOCATION, Looker admin (client_id/secret) e G4OS_USER_EMAIL
10
+ ... python -m scripts.smoke commercial "Win Rate por AE em abril de 2026?"
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import sys
16
+
17
+ from g4_data_mcp import auth
18
+ from g4_data_mcp import databricks as dbx
19
+
20
+
21
+ def main(argv: list[str]) -> None:
22
+ cmd = argv[0] if argv else "sql"
23
+ arg = argv[1] if len(argv) > 1 else "SELECT current_user() AS whoami"
24
+
25
+ if cmd == "sql":
26
+ out = dbx.run_sql(auth.workspace(), arg)
27
+ elif cmd == "genie":
28
+ out = dbx.genie_ask(auth.workspace(), arg)
29
+ elif cmd == "commercial":
30
+ from g4_data_mcp import commercial
31
+
32
+ out = commercial.chat(arg, auth.ca_credentials()).__dict__
33
+ else:
34
+ raise SystemExit(f"comando desconhecido: {cmd}")
35
+
36
+ print(json.dumps(out, ensure_ascii=False, indent=2, default=str))
37
+
38
+
39
+ if __name__ == "__main__":
40
+ main(sys.argv[1:])