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.
- g4_data_mcp-0.1.0/.gitignore +7 -0
- g4_data_mcp-0.1.0/INSTALL_G4OS.md +99 -0
- g4_data_mcp-0.1.0/PKG-INFO +9 -0
- g4_data_mcp-0.1.0/README.md +73 -0
- g4_data_mcp-0.1.0/g4_data_mcp/__init__.py +0 -0
- g4_data_mcp-0.1.0/g4_data_mcp/allowlist.py +11 -0
- g4_data_mcp-0.1.0/g4_data_mcp/auth.py +47 -0
- g4_data_mcp-0.1.0/g4_data_mcp/commercial.py +194 -0
- g4_data_mcp-0.1.0/g4_data_mcp/config.py +60 -0
- g4_data_mcp-0.1.0/g4_data_mcp/databricks.py +123 -0
- g4_data_mcp-0.1.0/g4_data_mcp/looker_oauth.py +152 -0
- g4_data_mcp-0.1.0/g4_data_mcp/server.py +76 -0
- g4_data_mcp-0.1.0/g4os-skill/SKILL.md +123 -0
- g4_data_mcp-0.1.0/pyproject.toml +21 -0
- g4_data_mcp-0.1.0/scripts/smoke.py +40 -0
|
@@ -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:])
|