mcp-elk 1.0.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
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ .pytest_cache/
5
+ dist/
6
+ .DS_Store
7
+ uv.lock
mcp_elk-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel Xavier Araújo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
mcp_elk-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,100 @@
1
+ Metadata-Version: 2.5
2
+ Name: mcp-elk
3
+ Version: 1.0.0
4
+ Summary: MCP server de leitura para Elasticsearch/Kibana (ELK): busca, contagem, consultas salvas e exportação CSV
5
+ Project-URL: Repository, https://github.com/dxaraujo/mcp-elk
6
+ Author-email: Daniel Xavier Araújo <danielxaraujo@gmail.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: mcp[cli]>=2.2
11
+ Requires-Dist: requests>=2.31
12
+ Description-Content-Type: text/markdown
13
+
14
+ # mcp-elk
15
+
16
+ Servidor MCP **de leitura** para o ELK (Elasticsearch + Kibana): busca, contagem com agrupamento, consultas prontas do
17
+ Kibana e exportação CSV. É **agnóstico**: índice, campos (ex.: sistema, categoria) e consultas do projeto chegam como
18
+ parâmetro.
19
+
20
+ ## Credenciais e servidores
21
+
22
+ | SO | Arquivo |
23
+ |---|---|
24
+ | Linux/Mac | `~/.config/mcp-elk/elk.properties` |
25
+ | Windows | `%APPDATA%\mcp-elk\elk.properties` |
26
+
27
+ Para usar outro caminho, defina a variável de ambiente `MCP_ELK_CONFIG`.
28
+
29
+ ```ini
30
+ [DEFAULT]
31
+ user = SEU_USUARIO
32
+ password = SUA_SENHA
33
+ prod = https://logs.SEU-SERVIDOR
34
+ homol = https://hlogs.SEU-SERVIDOR
35
+ dev = https://dlogs.SEU-SERVIDOR
36
+ ```
37
+
38
+ `prod`, `homol` e `dev` são as URLs do **Kibana**; só `prod` é obrigatória. Opcionalmente, `es_prod`, `es_homol` e
39
+ `es_dev` são as URLs do **Elasticsearch** direto: quando presentes, o MCP usa as APIs REST oficiais (`_search`,
40
+ `_field_caps`, PIT). Sem elas, a busca passa pelo Kibana em `POST /internal/search/es`, uma API **interna** do Kibana e
41
+ o primeiro ponto a revalidar num upgrade.
42
+
43
+ **Certificados:** o MCP confia nas CAs instaladas na máquina (no Windows, as do repositório do Windows) e, se existir,
44
+ em `elk-bundle.pem`, na mesma pasta do `elk.properties`: a cadeia PEM do Kibana, quando a CA
45
+ não estiver instalada. `REQUESTS_CA_BUNDLE` também funciona. Se o certificado não for aceito, a tool devolve uma mensagem
46
+ dizendo onde gravar a cadeia. A sessão aceita as cifras padrão do OpenSSL, porque alguns Kibana só oferecem
47
+ `AES256-SHA256`, que a lista do Python exclui.
48
+
49
+ ## Instalação
50
+
51
+ Publicado no [PyPI](https://pypi.org/project/mcp-elk/); requer o [uv](https://docs.astral.sh/uv/) (`uvx` no PATH).
52
+ No cliente MCP (`mcp.json`):
53
+
54
+ ```json
55
+ {
56
+ "mcpServers": {
57
+ "elk": {
58
+ "type": "stdio",
59
+ "command": "uvx",
60
+ "args": ["mcp-elk@latest"],
61
+ "env": { "UV_SYSTEM_CERTS": "true" }
62
+ }
63
+ }
64
+ }
65
+ ```
66
+
67
+ `UV_SYSTEM_CERTS` faz o `uvx` usar as CAs da máquina para baixar o pacote (proxy corporativo).
68
+
69
+ Desenvolvimento:
70
+
71
+ ```bash
72
+ uv sync
73
+ uv run pytest
74
+ uv run mcp dev mcp_elk/server.py # MCP Inspector
75
+ ```
76
+
77
+ ## Tools (só leitura)
78
+
79
+ Parâmetros comuns:
80
+ - `ambiente`: prod/produção (padrão), homol/homologação/hml ou dev/desenvolvimento;
81
+ - `inicio`/`fim`: `now-15m` ou ISO 8601. `inicio` é obrigatório;
82
+ - `campo_tempo`: padrão `@timestamp`.
83
+
84
+ | Tool | Para quê |
85
+ |---|---|
86
+ | `elk_buscar_logs(indice, inicio, consulta?, campos?, filtros?, limite≤100, retornar?)` | Investigar: itens achatados (`{"a.b": v}`), textos cortados em 2000 caracteres |
87
+ | `elk_contar(..., agrupar_por?, top=10)` | Dimensionar; os valores mais frequentes de um campo |
88
+ | `elk_exportar_csv(..., arquivo, retornar?, max_linhas=100000)` | Volume grande: pagina e grava o CSV em disco, sem passar documentos pela conversa |
89
+ | `elk_listar_consultas(busca?, espaco?, limite=50)` | Buscas salvas do Discover e saved queries do Kibana (todos os spaces, ou só `espaco`), com `consulta`, `filtros` (DSL) e `indice` prontos para reuso |
90
+ | `elk_listar_indices(busca?, espaco?)` | Data views (padrão de índice e campo de tempo) |
91
+ | `elk_listar_campos(indice, busca?)` | Campos, tipo e se são agregáveis |
92
+
93
+ - `consulta` é **Lucene** (`query_string`): `campo:valor AND msg:*timeout*`. O KQL do Kibana é convertido no navegador
94
+ e o ES 8.15 não o entende. Por isso o MCP passa `and`/`or`/`not` para maiúsculas fora de aspas: sem isso, `a: x and b: y`
95
+ virava OR e trazia centenas de vezes mais documentos. `campo:{...}` aninhado continua sem suporte.
96
+ - `campos` = filtros exatos `{campo: valor}` (`term`); `filtros` = Query DSL crua.
97
+ - Exportação: com ES direto, usa PIT + `search_after` (consistente). Só com o Kibana, usa `search_after` por
98
+ `campo_tempo` e descarta os `_id` repetidos na virada da página. Com documentos demais no mesmo timestamp, dá
99
+ erro e pede `es_<ambiente>`. A página cresce para acomodar os documentos repetidos, e o erro só aparece quando passa de
100
+ 10.000 documentos no mesmo timestamp.
@@ -0,0 +1,87 @@
1
+ # mcp-elk
2
+
3
+ Servidor MCP **de leitura** para o ELK (Elasticsearch + Kibana): busca, contagem com agrupamento, consultas prontas do
4
+ Kibana e exportação CSV. É **agnóstico**: índice, campos (ex.: sistema, categoria) e consultas do projeto chegam como
5
+ parâmetro.
6
+
7
+ ## Credenciais e servidores
8
+
9
+ | SO | Arquivo |
10
+ |---|---|
11
+ | Linux/Mac | `~/.config/mcp-elk/elk.properties` |
12
+ | Windows | `%APPDATA%\mcp-elk\elk.properties` |
13
+
14
+ Para usar outro caminho, defina a variável de ambiente `MCP_ELK_CONFIG`.
15
+
16
+ ```ini
17
+ [DEFAULT]
18
+ user = SEU_USUARIO
19
+ password = SUA_SENHA
20
+ prod = https://logs.SEU-SERVIDOR
21
+ homol = https://hlogs.SEU-SERVIDOR
22
+ dev = https://dlogs.SEU-SERVIDOR
23
+ ```
24
+
25
+ `prod`, `homol` e `dev` são as URLs do **Kibana**; só `prod` é obrigatória. Opcionalmente, `es_prod`, `es_homol` e
26
+ `es_dev` são as URLs do **Elasticsearch** direto: quando presentes, o MCP usa as APIs REST oficiais (`_search`,
27
+ `_field_caps`, PIT). Sem elas, a busca passa pelo Kibana em `POST /internal/search/es`, uma API **interna** do Kibana e
28
+ o primeiro ponto a revalidar num upgrade.
29
+
30
+ **Certificados:** o MCP confia nas CAs instaladas na máquina (no Windows, as do repositório do Windows) e, se existir,
31
+ em `elk-bundle.pem`, na mesma pasta do `elk.properties`: a cadeia PEM do Kibana, quando a CA
32
+ não estiver instalada. `REQUESTS_CA_BUNDLE` também funciona. Se o certificado não for aceito, a tool devolve uma mensagem
33
+ dizendo onde gravar a cadeia. A sessão aceita as cifras padrão do OpenSSL, porque alguns Kibana só oferecem
34
+ `AES256-SHA256`, que a lista do Python exclui.
35
+
36
+ ## Instalação
37
+
38
+ Publicado no [PyPI](https://pypi.org/project/mcp-elk/); requer o [uv](https://docs.astral.sh/uv/) (`uvx` no PATH).
39
+ No cliente MCP (`mcp.json`):
40
+
41
+ ```json
42
+ {
43
+ "mcpServers": {
44
+ "elk": {
45
+ "type": "stdio",
46
+ "command": "uvx",
47
+ "args": ["mcp-elk@latest"],
48
+ "env": { "UV_SYSTEM_CERTS": "true" }
49
+ }
50
+ }
51
+ }
52
+ ```
53
+
54
+ `UV_SYSTEM_CERTS` faz o `uvx` usar as CAs da máquina para baixar o pacote (proxy corporativo).
55
+
56
+ Desenvolvimento:
57
+
58
+ ```bash
59
+ uv sync
60
+ uv run pytest
61
+ uv run mcp dev mcp_elk/server.py # MCP Inspector
62
+ ```
63
+
64
+ ## Tools (só leitura)
65
+
66
+ Parâmetros comuns:
67
+ - `ambiente`: prod/produção (padrão), homol/homologação/hml ou dev/desenvolvimento;
68
+ - `inicio`/`fim`: `now-15m` ou ISO 8601. `inicio` é obrigatório;
69
+ - `campo_tempo`: padrão `@timestamp`.
70
+
71
+ | Tool | Para quê |
72
+ |---|---|
73
+ | `elk_buscar_logs(indice, inicio, consulta?, campos?, filtros?, limite≤100, retornar?)` | Investigar: itens achatados (`{"a.b": v}`), textos cortados em 2000 caracteres |
74
+ | `elk_contar(..., agrupar_por?, top=10)` | Dimensionar; os valores mais frequentes de um campo |
75
+ | `elk_exportar_csv(..., arquivo, retornar?, max_linhas=100000)` | Volume grande: pagina e grava o CSV em disco, sem passar documentos pela conversa |
76
+ | `elk_listar_consultas(busca?, espaco?, limite=50)` | Buscas salvas do Discover e saved queries do Kibana (todos os spaces, ou só `espaco`), com `consulta`, `filtros` (DSL) e `indice` prontos para reuso |
77
+ | `elk_listar_indices(busca?, espaco?)` | Data views (padrão de índice e campo de tempo) |
78
+ | `elk_listar_campos(indice, busca?)` | Campos, tipo e se são agregáveis |
79
+
80
+ - `consulta` é **Lucene** (`query_string`): `campo:valor AND msg:*timeout*`. O KQL do Kibana é convertido no navegador
81
+ e o ES 8.15 não o entende. Por isso o MCP passa `and`/`or`/`not` para maiúsculas fora de aspas: sem isso, `a: x and b: y`
82
+ virava OR e trazia centenas de vezes mais documentos. `campo:{...}` aninhado continua sem suporte.
83
+ - `campos` = filtros exatos `{campo: valor}` (`term`); `filtros` = Query DSL crua.
84
+ - Exportação: com ES direto, usa PIT + `search_after` (consistente). Só com o Kibana, usa `search_after` por
85
+ `campo_tempo` e descarta os `_id` repetidos na virada da página. Com documentos demais no mesmo timestamp, dá
86
+ erro e pede `es_<ambiente>`. A página cresce para acomodar os documentos repetidos, e o erro só aparece quando passa de
87
+ 10.000 documentos no mesmo timestamp.
File without changes
@@ -0,0 +1,52 @@
1
+ """Carrega credenciais e servidores do ELK a partir de elk.properties."""
2
+ from __future__ import annotations
3
+
4
+ import configparser
5
+ import os
6
+ import sys
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ REQUIRED = ("user", "password", "prod")
11
+ AMBIENTES = ("prod", "homol", "dev")
12
+ TEMPLATE = (
13
+ "[DEFAULT]\nuser = SEU_USUARIO\npassword = SUA_SENHA\n"
14
+ "prod = https://logs.SEU-SERVIDOR\nhomol = https://hlogs.SEU-SERVIDOR\ndev = https://dlogs.SEU-SERVIDOR"
15
+ )
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class ElkConfig:
20
+ user: str
21
+ password: str = field(repr=False)
22
+ kibana: dict = field(default_factory=dict) # {ambiente: url do Kibana}
23
+ es: dict = field(default_factory=dict) # {ambiente: url do Elasticsearch}, opcional (es_<ambiente>)
24
+
25
+
26
+ def default_path() -> Path:
27
+ if env := os.environ.get("MCP_ELK_CONFIG"):
28
+ return Path(env)
29
+ if sys.platform == "win32":
30
+ return Path(os.environ["APPDATA"]) / "mcp-elk" / "elk.properties"
31
+ return Path.home() / ".config" / "mcp-elk" / "elk.properties"
32
+
33
+
34
+ def load_config(path: Path | None = None) -> ElkConfig:
35
+ path = path or default_path()
36
+ if not path.is_file():
37
+ raise FileNotFoundError(f"Arquivo de credenciais não encontrado: {path}\nCrie-o com:\n{TEMPLATE}")
38
+ # interpolation=None: senhas podem conter '%'
39
+ parser = configparser.ConfigParser(interpolation=None)
40
+ try:
41
+ parser.read(path, encoding="utf-8")
42
+ except configparser.Error as exc: # ex.: chave repetida
43
+ raise ValueError(f"{path} inválido: {exc}") from exc
44
+ section = parser.defaults()
45
+ missing = [k for k in REQUIRED if not section.get(k, "").strip()]
46
+ if missing:
47
+ raise ValueError(f"Chave(s) ausente(s) em {path} [DEFAULT]: {', '.join(missing)}")
48
+
49
+ def urls(prefix: str) -> dict:
50
+ return {a: v.strip().rstrip("/") for a in AMBIENTES if (v := section.get(prefix + a, "")).strip()}
51
+
52
+ return ElkConfig(user=section["user"].strip(), password=section["password"], kibana=urls(""), es=urls("es_"))
@@ -0,0 +1,441 @@
1
+ """Servidor MCP de leitura para ELK (Elasticsearch + Kibana), agnóstico de índice e campos.
2
+
3
+ Índice, campos e consultas chegam como parâmetro; o elk.properties só tem credenciais e servidores.
4
+
5
+ Transporte: Elasticsearch direto quando `es_<ambiente>` está configurado (APIs REST oficiais); senão o Kibana
6
+ via POST /internal/search/es (API interna do Kibana, mesmo corpo Query DSL; é o primeiro lugar a revalidar num upgrade).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import csv
11
+ import functools
12
+ import json
13
+ import re
14
+ import threading
15
+ import unicodedata
16
+ from pathlib import Path
17
+
18
+ import requests
19
+ import ssl
20
+ from requests.adapters import HTTPAdapter
21
+ from mcp.server.mcpserver import MCPServer
22
+ from mcp.server.mcpserver.exceptions import ToolError
23
+
24
+ from .config import ElkConfig, default_path, load_config
25
+
26
+ mcp = MCPServer(
27
+ "elk",
28
+ instructions=(
29
+ "Leitura de logs no ELK (Elasticsearch/Kibana). Toda consulta exige `indice` e `inicio` (janela de tempo: "
30
+ "'now-15m' ou ISO 8601). `ambiente` aceita prod/produção, homol/homologação ou dev/desenvolvimento; sem ele, "
31
+ "produção. Descubra índices com elk_listar_indices, campos com elk_listar_campos e consultas prontas do Kibana "
32
+ "com elk_listar_consultas. Dimensione com elk_contar antes de buscar; volume grande vai para "
33
+ "elk_exportar_csv (grava em disco, nada passa pela conversa)."
34
+ ),
35
+ )
36
+
37
+ EXPECTED = (RuntimeError, OSError, LookupError, ValueError)
38
+ TIMEOUT = 120
39
+ LIMITE_TETO = 100
40
+ PAGINA = 1000
41
+ MAX_RESULT_WINDOW = 10_000 # index.max_result_window padrão do ES
42
+ TEXTO_MAX = 2000
43
+ ALIASES = {
44
+ "prod": "prod", "producao": "prod", "p": "prod",
45
+ "homol": "homol", "homologacao": "homol", "hml": "homol", "h": "homol",
46
+ "dev": "dev", "desenvolvimento": "dev", "desenv": "dev", "d": "dev",
47
+ }
48
+
49
+
50
+ def tool(fn):
51
+ """Registra `fn` como tool (ToolError com o texto da exceção) e devolve `fn` intacta."""
52
+ @functools.wraps(fn)
53
+ def wrapper(*args, **kwargs):
54
+ try:
55
+ return fn(*args, **kwargs)
56
+ except EXPECTED as exc:
57
+ raise ToolError(str(exc)) from exc
58
+
59
+ mcp.tool()(wrapper)
60
+ return fn
61
+
62
+
63
+ # ---------------------------------------------------------------- HTTP
64
+
65
+ class ElkHttpError(RuntimeError):
66
+ def __init__(self, resp: requests.Response):
67
+ super().__init__(f"HTTP {resp.status_code} em {resp.request.method} {resp.url}: {resp.text[:500].strip()}")
68
+ self.status_code = resp.status_code
69
+
70
+
71
+ def ca_bundle() -> Path:
72
+ """CAs extras (ex.: cadeia interna do Kibana), ao lado do elk.properties."""
73
+ return default_path().parent / "elk-bundle.pem"
74
+
75
+
76
+ class _TlsAdapter(HTTPAdapter):
77
+ """CAs do sistema (no Windows, o repositório do Windows) + elk-bundle.pem, se existir.
78
+ Cifras padrão do OpenSSL: a lista do Python/urllib3 exclui AES256-SHA256 (RSA estático), única oferecida por
79
+ alguns Kibana internos (handshake failure)."""
80
+
81
+ def init_poolmanager(self, *args, **kwargs):
82
+ ctx = ssl.create_default_context()
83
+ if ca_bundle().is_file():
84
+ ctx.load_verify_locations(ca_bundle())
85
+ ctx.set_ciphers("DEFAULT")
86
+ super().init_poolmanager(*args, ssl_context=ctx, **kwargs)
87
+
88
+
89
+ class ElkSession:
90
+ def __init__(self, cfg: ElkConfig):
91
+ self.cfg = cfg
92
+ self.session = requests.Session()
93
+ self.session.mount("https://", _TlsAdapter())
94
+ self.session.auth = (cfg.user, cfg.password)
95
+ self.session.headers.update({"kbn-xsrf": "true", "Accept": "application/json"})
96
+
97
+ def request(self, method: str, url: str, **kwargs) -> dict:
98
+ try:
99
+ resp = self.session.request(method, url, timeout=TIMEOUT, **kwargs)
100
+ except requests.exceptions.SSLError as exc:
101
+ raise RuntimeError(
102
+ f"Certificado do servidor não confiável em {url}. Instale a CA na máquina ou grave a cadeia de "
103
+ f"certificados (PEM) em {ca_bundle()} (ou aponte REQUESTS_CA_BUNDLE para ela) e reinicie o MCP. "
104
+ f"Detalhe: {exc}") from exc
105
+ if not resp.ok:
106
+ raise ElkHttpError(resp)
107
+ try:
108
+ return resp.json() if resp.content else {}
109
+ except ValueError as exc:
110
+ raise ValueError(f"Resposta não é JSON em {method} {url}: {resp.text[:300]}") from exc
111
+
112
+
113
+ _instance: ElkSession | None = None
114
+ _lock = threading.Lock()
115
+
116
+
117
+ def get_session() -> ElkSession:
118
+ """Sessão única por processo; carrega o elk.properties na primeira chamada."""
119
+ global _instance
120
+ with _lock:
121
+ if _instance is None:
122
+ _instance = ElkSession(load_config())
123
+ return _instance
124
+
125
+
126
+ def set_session(session: ElkSession | None) -> None:
127
+ """Substitui a sessão global (testes)."""
128
+ global _instance
129
+ _instance = session
130
+
131
+
132
+ def _ambiente(ambiente: str | None) -> str:
133
+ if not ambiente:
134
+ return "prod"
135
+ sem_acento = unicodedata.normalize("NFKD", ambiente.strip().lower()).encode("ascii", "ignore").decode()
136
+ if sem_acento not in ALIASES:
137
+ raise ValueError(f"Ambiente desconhecido: '{ambiente}'. Use prod/produção (padrão), homol/homologação ou "
138
+ "dev/desenvolvimento.")
139
+ return ALIASES[sem_acento]
140
+
141
+
142
+ def _kibana(amb: str) -> str:
143
+ url = get_session().cfg.kibana.get(amb)
144
+ if not url:
145
+ raise ValueError(f"Ambiente '{amb}' sem URL no elk.properties: adicione '{amb} = https://...'.")
146
+ return url
147
+
148
+
149
+ def _es(amb: str) -> str | None:
150
+ return get_session().cfg.es.get(amb)
151
+
152
+
153
+ def _search(amb: str, indice: str | None, body: dict) -> dict:
154
+ """POST _search pelo ES direto ou pelo Kibana. `indice=None` só com PIT (ES direto)."""
155
+ s = get_session()
156
+ if es := _es(amb):
157
+ return s.request("POST", f"{es}/{indice}/_search" if indice else f"{es}/_search", json=body)
158
+ resp = s.request("POST", f"{_kibana(amb)}/internal/search/es", json={"params": {"index": indice, "body": body}})
159
+ return resp.get("rawResponse", resp)
160
+
161
+
162
+ def _espacos(amb: str, espaco: str | None) -> list[str]:
163
+ """`espaco` informado, ou todos os spaces do Kibana visíveis ao usuário."""
164
+ if espaco:
165
+ return [espaco]
166
+ return [sp["id"] for sp in get_session().request("GET", f"{_kibana(amb)}/api/spaces/space")]
167
+
168
+
169
+ def _saved_objects(amb: str, espaco: str, tipos: list[str], busca: str | None) -> list[dict]:
170
+ params = {"type": tipos, "per_page": 10000}
171
+ if busca:
172
+ params |= {"search": f"{busca}*", "search_fields": "title"}
173
+ base = _kibana(amb) if espaco == "default" else f"{_kibana(amb)}/s/{espaco}"
174
+ return get_session().request("GET", f"{base}/api/saved_objects/_find", params=params)["saved_objects"]
175
+
176
+
177
+ # ---------------------------------------------------------------- consulta
178
+
179
+ _OPERADOR = re.compile(r'("(?:[^"\\]|\\.)*")|\b(and|or|not)\b', re.IGNORECASE)
180
+
181
+
182
+ def _lucene(consulta: str) -> str:
183
+ """Operadores em maiúsculas fora de aspas: no KQL `and` é operador, no Lucene vira termo (e o resultado explode)."""
184
+ return _OPERADOR.sub(lambda m: m.group(1) or m.group(2).upper(), consulta)
185
+
186
+
187
+ def _query(inicio: str, fim: str, consulta: str | None, campos: dict | None, filtros: list | None,
188
+ campo_tempo: str) -> dict:
189
+ if not (consulta or campos or filtros):
190
+ raise ValueError("Consulta vazia: informe `consulta`, `campos` ou `filtros`.")
191
+ must = [{"query_string": {"query": _lucene(consulta)}}] if consulta else []
192
+ filtro = [{"term": {campo: valor}} for campo, valor in (campos or {}).items()]
193
+ filtro += list(filtros or [])
194
+ filtro.append({"range": {campo_tempo: {"gte": inicio, "lte": fim}}})
195
+ return {"bool": {"must": must, "filter": filtro}}
196
+
197
+
198
+ def _total(resp: dict) -> int | None:
199
+ total = resp.get("hits", {}).get("total")
200
+ return total.get("value") if isinstance(total, dict) else total
201
+
202
+
203
+ def _flat(src: dict, prefix: str = "") -> dict:
204
+ """{'a': {'b': 1}} -> {'a.b': 1}; listas ficam como estão."""
205
+ out = {}
206
+ for k, v in src.items():
207
+ if isinstance(v, dict):
208
+ out |= _flat(v, f"{prefix}{k}.")
209
+ else:
210
+ out[f"{prefix}{k}"] = v
211
+ return out
212
+
213
+
214
+ def _cut(v):
215
+ return v[:TEXTO_MAX] + "…" if isinstance(v, str) and len(v) > TEXTO_MAX else v
216
+
217
+
218
+ def _janela(amb, indice, inicio, fim) -> dict:
219
+ return {"ambiente": amb, "indice": indice, "janela": {"inicio": inicio, "fim": fim}}
220
+
221
+
222
+ # ---------------------------------------------------------------- tools
223
+
224
+ @tool
225
+ def elk_buscar_logs(indice: str, inicio: str, consulta: str | None = None, campos: dict[str, str] | None = None,
226
+ filtros: list[dict] | None = None, fim: str = "now", limite: int = 20, mais_recentes: bool = True,
227
+ retornar: list[str] | None = None, campo_tempo: str = "@timestamp",
228
+ ambiente: str | None = None) -> dict:
229
+ """Busca documentos (só leitura). `consulta` é Lucene (query_string): `campo:valor AND msg:*timeout*`
230
+ (and/or/not viram maiúsculas fora de aspas, então KQL simples funciona; `campo:{...}` aninhado não).
231
+ `campos` = filtros exatos {campo: valor} (term); `filtros` = Query DSL
232
+ crua (como vem de elk_listar_consultas). `inicio`/`fim`: 'now-15m', 'now-1d' ou ISO 8601.
233
+ `limite` ≤ 100; `retornar` limita os campos de cada item. Itens achatados ({'a.b': v}), textos cortados em 2000.
234
+ Para muitos documentos use elk_exportar_csv."""
235
+ if not 1 <= limite <= LIMITE_TETO:
236
+ raise ValueError(f"`limite` deve estar entre 1 e {LIMITE_TETO}; para mais, use elk_exportar_csv.")
237
+ amb = _ambiente(ambiente)
238
+ body = {
239
+ "size": limite,
240
+ "track_total_hits": True,
241
+ "sort": [{campo_tempo: {"order": "desc" if mais_recentes else "asc"}}],
242
+ "query": _query(inicio, fim, consulta, campos, filtros, campo_tempo),
243
+ }
244
+ if retornar:
245
+ body["_source"] = retornar
246
+ resp = _search(amb, indice, body)
247
+ itens = [{"_id": h["_id"], "_index": h["_index"], **{k: _cut(v) for k, v in _flat(h.get("_source", {})).items()}}
248
+ for h in resp.get("hits", {}).get("hits", [])]
249
+ return {**_janela(amb, indice, inicio, fim), "total": _total(resp), "retornados": len(itens), "itens": itens}
250
+
251
+
252
+ @tool
253
+ def elk_contar(indice: str, inicio: str, consulta: str | None = None, campos: dict[str, str] | None = None,
254
+ filtros: list[dict] | None = None, fim: str = "now", agrupar_por: str | None = None, top: int = 10,
255
+ campo_tempo: str = "@timestamp", ambiente: str | None = None) -> dict:
256
+ """Conta documentos sem trazê-los (só leitura). Mesmos filtros de elk_buscar_logs. `agrupar_por` (campo
257
+ agregável, ex.: keyword) devolve os `top` valores mais frequentes: use para dimensionar um problema ou para
258
+ conhecer os valores reais de um campo."""
259
+ amb = _ambiente(ambiente)
260
+ body = {"size": 0, "track_total_hits": True, "query": _query(inicio, fim, consulta, campos, filtros, campo_tempo)}
261
+ if agrupar_por:
262
+ body["aggs"] = {"grupos": {"terms": {"field": agrupar_por, "size": top}}}
263
+ resp = _search(amb, indice, body)
264
+ out = {**_janela(amb, indice, inicio, fim), "total": _total(resp)}
265
+ if agrupar_por:
266
+ out["grupos"] = [{"valor": b["key"], "total": b["doc_count"]}
267
+ for b in resp.get("aggregations", {}).get("grupos", {}).get("buckets", [])]
268
+ return out
269
+
270
+
271
+ def _filtros_kibana(filters: list[dict]) -> list[dict]:
272
+ """Filtros do Kibana ({meta, query, $state}) -> Query DSL; ignora os desativados e aplica `negate`."""
273
+ out = []
274
+ for f in filters or []:
275
+ meta = f.get("meta", {})
276
+ if meta.get("disabled"):
277
+ continue
278
+ dsl = f.get("query") or {k: v for k, v in f.items() if k not in ("meta", "$state")}
279
+ out.append({"bool": {"must_not": [dsl]}} if meta.get("negate") else dsl)
280
+ return out
281
+
282
+
283
+ @tool
284
+ def elk_listar_consultas(busca: str | None = None, espaco: str | None = None, limite: int = 50,
285
+ ambiente: str | None = None) -> dict:
286
+ """Consultas prontas do Kibana: buscas salvas do Discover (tipo `search`) e saved queries (`query`).
287
+ `busca` filtra pelo início das palavras do título; `espaco` = space do Kibana (sem ele, todos).
288
+ Devolve {total, itens[:limite]}; total > limite → refine a `busca`. Cada item traz `consulta`, `filtros`
289
+ (Query DSL) e `indice` (padrão do data view, só em `search`), prontos para elk_buscar_logs/elk_contar/
290
+ elk_exportar_csv. Se `linguagem` = kuery, a consulta é KQL e vai como Lucene (operadores normalizados; `campo:{...}` não funciona)."""
291
+ amb = _ambiente(ambiente)
292
+ objetos, views = [], {}
293
+ for sp in _espacos(amb, espaco):
294
+ achados = [o | {"espaco": sp} for o in _saved_objects(amb, sp, ["search", "query"], busca)]
295
+ if any(o["type"] == "search" for o in achados[:limite]):
296
+ views |= {o["id"]: o["attributes"] for o in _saved_objects(amb, sp, ["index-pattern"], None)}
297
+ objetos += achados
298
+ out = []
299
+ for o in objetos[:limite]:
300
+ attrs, indice = o["attributes"], None
301
+ if o["type"] == "search":
302
+ src = json.loads(attrs.get("kibanaSavedObjectMeta", {}).get("searchSourceJSON") or "{}")
303
+ query, filters = src.get("query", {}), src.get("filter", [])
304
+ ref = next((r["id"] for r in o.get("references", []) if r.get("type") == "index-pattern"), None)
305
+ indice = views.get(ref, {}).get("title")
306
+ else:
307
+ query, filters = attrs.get("query", {}), attrs.get("filters", [])
308
+ out.append({
309
+ "id": o["id"], "espaco": o["espaco"], "tipo": o["type"], "titulo": attrs.get("title"), "descricao": attrs.get("description") or None,
310
+ "consulta": query.get("query") or None, "linguagem": query.get("language"),
311
+ "filtros": _filtros_kibana(filters), "indice": indice, "colunas": attrs.get("columns"),
312
+ })
313
+ return {"total": len(objetos), "itens": out}
314
+
315
+
316
+ @tool
317
+ def elk_listar_indices(busca: str | None = None, espaco: str | None = None, ambiente: str | None = None) -> list[dict]:
318
+ """Data views do Kibana: [{espaco, nome, padrao (use como `indice`), campo_tempo}], sem repetir padrão no mesmo
319
+ space. `busca` filtra pelo início das palavras do título; `espaco` = space do Kibana (sem ele, todos)."""
320
+ amb = _ambiente(ambiente)
321
+ out = {}
322
+ for sp in _espacos(amb, espaco):
323
+ for o in _saved_objects(amb, sp, ["index-pattern"], busca):
324
+ a = o["attributes"]
325
+ out[(sp, a["title"])] = {"espaco": sp, "nome": a.get("name") or a["title"], "padrao": a["title"],
326
+ "campo_tempo": a.get("timeFieldName")}
327
+ return sorted(out.values(), key=lambda d: (d["espaco"], d["padrao"]))
328
+
329
+
330
+ @tool
331
+ def elk_listar_campos(indice: str, busca: str | None = None, ambiente: str | None = None) -> list[dict]:
332
+ """Campos do índice: [{campo, tipo, agregavel}]. `busca` filtra por trecho do nome. `agregavel` = serve para
333
+ `agrupar_por` e `campos` (filtro exato)."""
334
+ amb = _ambiente(ambiente)
335
+ s = get_session()
336
+ if es := _es(amb):
337
+ caps = s.request("GET", f"{es}/{indice}/_field_caps", params={"fields": "*"})["fields"]
338
+ campos = [{"campo": nome, "tipo": "|".join(tipos), "agregavel": any(t.get("aggregatable") for t in tipos.values())}
339
+ for nome, tipos in caps.items()]
340
+ else:
341
+ # API interna do Kibana 8.x (a pública /api/index_patterns/... responde 404)
342
+ resp = s.request("GET", f"{_kibana(amb)}/internal/data_views/_fields_for_wildcard",
343
+ params={"pattern": indice}, headers={"elastic-api-version": "1"})
344
+ campos = [{"campo": f["name"], "tipo": "|".join(f.get("esTypes") or [f.get("type")]),
345
+ "agregavel": bool(f.get("aggregatable"))} for f in resp["fields"]]
346
+ return sorted((c for c in campos if not c["campo"].startswith("_") and (not busca or busca in c["campo"])),
347
+ key=lambda c: c["campo"])
348
+
349
+
350
+ def _cell(v):
351
+ return json.dumps(v, ensure_ascii=False) if isinstance(v, (list, dict)) else v
352
+
353
+
354
+ @tool
355
+ def elk_exportar_csv(indice: str, inicio: str, arquivo: str, consulta: str | None = None,
356
+ campos: dict[str, str] | None = None, filtros: list[dict] | None = None, fim: str = "now",
357
+ retornar: list[str] | None = None, max_linhas: int = 100_000, campo_tempo: str = "@timestamp",
358
+ ambiente: str | None = None) -> dict:
359
+ """Exporta para CSV todos os documentos da consulta, do mais antigo ao mais novo, sem passar pela conversa.
360
+ Mesmos filtros de elk_buscar_logs. `arquivo` = caminho absoluto (pastas criadas, arquivo sobrescrito; UTF-8
361
+ com BOM). Colunas = `retornar` ou os campos achatados da primeira página. Para em `max_linhas` (`truncado`).
362
+ Devolve só o resumo: {arquivo, linhas, total, truncado, colunas}."""
363
+ path = Path(arquivo)
364
+ if not path.is_absolute():
365
+ raise ValueError(f"`arquivo` deve ser um caminho absoluto: {arquivo}")
366
+ amb = _ambiente(ambiente)
367
+ s = get_session()
368
+ body = {"size": PAGINA, "track_total_hits": True,
369
+ "query": _query(inicio, fim, consulta, campos, filtros, campo_tempo)}
370
+ if retornar:
371
+ body["_source"] = retornar
372
+ es = _es(amb)
373
+ pit = None
374
+ if es: # PIT + _shard_doc: paginação oficial, sem perda nem duplicata
375
+ pit = s.request("POST", f"{es}/{indice}/_pit", params={"keep_alive": "2m"})["id"]
376
+ body["sort"] = [{campo_tempo: "asc"}, {"_shard_doc": "asc"}]
377
+ else:
378
+ body["sort"] = [{campo_tempo: "asc"}]
379
+
380
+ path.parent.mkdir(parents=True, exist_ok=True)
381
+ linhas, total, colunas, truncado = 0, None, list(retornar or []), False
382
+ borda, vistos = None, set() # sem PIT: _ids já gravados no último timestamp
383
+ try:
384
+ with path.open("w", newline="", encoding="utf-8-sig") as f:
385
+ writer = csv.DictWriter(f, colunas, extrasaction="ignore") if colunas else None
386
+ if writer:
387
+ writer.writeheader()
388
+ while not truncado:
389
+ if pit:
390
+ body["pit"] = {"id": pit, "keep_alive": "2m"}
391
+ resp = _search(amb, None if pit else indice, body)
392
+ pit = resp.get("pit_id", pit)
393
+ if total is None:
394
+ total = _total(resp)
395
+ body["track_total_hits"] = False
396
+ hits = resp.get("hits", {}).get("hits", [])
397
+ novos = [h for h in hits if h["_id"] not in vistos]
398
+ for h in novos:
399
+ row = _flat(h.get("_source", {}))
400
+ if writer is None:
401
+ colunas = list(row)
402
+ writer = csv.DictWriter(f, colunas, extrasaction="ignore")
403
+ writer.writeheader()
404
+ writer.writerow({k: _cell(v) for k, v in row.items()})
405
+ linhas += 1
406
+ if linhas >= max_linhas:
407
+ truncado = linhas < (total or 0)
408
+ break
409
+ if len(hits) < body["size"] or linhas >= max_linhas:
410
+ break
411
+ ultimo = hits[-1]["sort"]
412
+ if pit:
413
+ body["search_after"] = ultimo
414
+ continue
415
+ # sem PIT: recomeça em ts >= último ts e descarta os _ids já gravados nesse ts;
416
+ # a página cresce com os vistos para sempre trazer documentos novos
417
+ if ultimo[0] != borda:
418
+ borda, vistos = ultimo[0], set()
419
+ vistos |= {h["_id"] for h in hits if h["sort"][0] == borda}
420
+ body["search_after"] = [borda - 1]
421
+ body["size"] = PAGINA + len(vistos)
422
+ if body["size"] > MAX_RESULT_WINDOW:
423
+ raise RuntimeError(f"Documentos demais com o mesmo {campo_tempo} ({borda}): configure "
424
+ "es_<ambiente> (ES direto, com PIT) para exportar esta consulta.")
425
+ except EXPECTED as exc:
426
+ raise RuntimeError(f"Exportação interrompida após {linhas} linha(s) gravadas em {path}: {exc}") from exc
427
+ finally:
428
+ if pit and es:
429
+ try:
430
+ s.request("DELETE", f"{es}/_pit", json={"id": pit})
431
+ except EXPECTED:
432
+ pass # o PIT expira sozinho em 2m
433
+ return {"arquivo": str(path), "linhas": linhas, "total": total, "truncado": truncado, "colunas": colunas}
434
+
435
+
436
+ def main() -> None:
437
+ mcp.run()
438
+
439
+
440
+ if __name__ == "__main__":
441
+ main()
@@ -0,0 +1,30 @@
1
+ [project]
2
+ name = "mcp-elk"
3
+ version = "1.0.0"
4
+ description = "MCP server de leitura para Elasticsearch/Kibana (ELK): busca, contagem, consultas salvas e exportação CSV"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "Daniel Xavier Araújo", email = "danielxaraujo@gmail.com" }]
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "mcp[cli]>=2.2",
12
+ "requests>=2.31",
13
+ ]
14
+
15
+ [project.urls]
16
+ Repository = "https://github.com/dxaraujo/mcp-elk"
17
+
18
+ [project.scripts]
19
+ mcp-elk = "mcp_elk.server:main"
20
+
21
+ [dependency-groups]
22
+ dev = ["pytest>=8"]
23
+
24
+ [build-system]
25
+ requires = ["hatchling"]
26
+ build-backend = "hatchling.build"
27
+
28
+ [tool.pytest.ini_options]
29
+ testpaths = ["tests"]
30
+ pythonpath = ["."]
@@ -0,0 +1,245 @@
1
+ import csv
2
+ import json
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+ import requests
7
+ from requests.adapters import BaseAdapter
8
+
9
+ from mcp_elk import config, server
10
+ from mcp_elk.config import ElkConfig
11
+
12
+ KIBANA = "https://kibana.test"
13
+ ES = "https://es.test"
14
+ HIT = {"_id": "1", "_index": "logs-1", "_source": {"@timestamp": "2026-10-08T10:00:00Z", "app": {"nome": "x"},
15
+ "message": "m" * 3000}}
16
+
17
+
18
+ class FakeAdapter(BaseAdapter):
19
+ """routes: {(METODO, url_sem_query): handler(request) -> (status, dict|bytes) ou tupla}."""
20
+
21
+ def __init__(self, routes=None):
22
+ super().__init__()
23
+ self.routes = routes or {}
24
+ self.calls: list[requests.PreparedRequest] = []
25
+
26
+ def send(self, request, **kwargs):
27
+ self.calls.append(request)
28
+ route = self.routes.get((request.method, request.url.split("?")[0]))
29
+ status, body = (route(request) if callable(route) else route) if route else (404, b"not found")
30
+ resp = requests.Response()
31
+ resp.status_code = status
32
+ resp._content = body if isinstance(body, bytes) else json.dumps(body).encode()
33
+ resp.url, resp.request = request.url, request
34
+ return resp
35
+
36
+ def close(self):
37
+ pass
38
+
39
+
40
+ def body(req) -> dict:
41
+ return json.loads(req.body)
42
+
43
+
44
+ @pytest.fixture
45
+ def fake():
46
+ adapter = FakeAdapter()
47
+ session = server.ElkSession(ElkConfig(user="joao", password="segredo", kibana={"prod": KIBANA, "homol": KIBANA}))
48
+ session.session.mount("https://", adapter)
49
+ server.set_session(session)
50
+ yield adapter
51
+ server.set_session(None)
52
+
53
+
54
+ @pytest.fixture
55
+ def fake_es(fake):
56
+ server.get_session().cfg.es["prod"] = ES
57
+ return fake
58
+
59
+
60
+ # ---------------------------------------------------------------- config
61
+
62
+ def write(tmp_path: Path, text: str) -> Path:
63
+ p = tmp_path / "elk.properties"
64
+ p.write_text(text, encoding="utf-8")
65
+ return p
66
+
67
+
68
+ def test_load_config(tmp_path):
69
+ cfg = config.load_config(write(tmp_path, "[DEFAULT]\nuser = u\npassword = p%s#s\nprod = https://k/\n"
70
+ "es_prod = https://e:9200\n"))
71
+ assert (cfg.user, cfg.password, cfg.kibana, cfg.es) == ("u", "p%s#s", {"prod": "https://k"},
72
+ {"prod": "https://e:9200"})
73
+ assert "p%s#s" not in repr(cfg)
74
+
75
+
76
+ def test_load_config_errors(tmp_path):
77
+ with pytest.raises(ValueError, match="prod"):
78
+ config.load_config(write(tmp_path, "[DEFAULT]\nuser = u\npassword = p\n"))
79
+ with pytest.raises(FileNotFoundError):
80
+ config.load_config(tmp_path / "nao-existe")
81
+
82
+
83
+ # ---------------------------------------------------------------- ambiente e consulta
84
+
85
+ @pytest.mark.parametrize("entrada,esperado", [(None, "prod"), ("Produção", "prod"), ("homologação", "homol"),
86
+ ("HML", "homol"), ("desenvolvimento", "dev")])
87
+ def test_ambiente(entrada, esperado):
88
+ assert server._ambiente(entrada) == esperado
89
+
90
+
91
+ def test_ambiente_desconhecido_e_sem_url(fake):
92
+ with pytest.raises(ValueError, match="desconhecido"):
93
+ server._ambiente("qa")
94
+ with pytest.raises(ValueError, match="'dev' sem URL"):
95
+ server.elk_contar("logs-*", "now-1h", consulta="x", ambiente="dev")
96
+
97
+
98
+ def test_lucene():
99
+ assert server._lucene('a: x and b: "y and z" or not c') == 'a: x AND b: "y and z" OR NOT c'
100
+ assert server._lucene("android:1 AND brand:x") == "android:1 AND brand:x"
101
+
102
+
103
+ def test_query():
104
+ q = server._query("now-1h", "now", "msg:erro", {"sis.nome": "a", "sis.cat": "b"}, [{"exists": {"field": "f"}}],
105
+ "ts")
106
+ assert q == {"bool": {"must": [{"query_string": {"query": "msg:erro"}}], "filter": [
107
+ {"term": {"sis.nome": "a"}}, {"term": {"sis.cat": "b"}}, {"exists": {"field": "f"}},
108
+ {"range": {"ts": {"gte": "now-1h", "lte": "now"}}}]}}
109
+ with pytest.raises(ValueError, match="vazia"):
110
+ server._query("now-1h", "now", None, None, None, "ts")
111
+
112
+
113
+ # ---------------------------------------------------------------- busca e contagem
114
+
115
+ def test_buscar_logs_kibana(fake):
116
+ fake.routes[("POST", f"{KIBANA}/internal/search/es")] = (200, {"rawResponse": {
117
+ "hits": {"total": {"value": 7}, "hits": [HIT]}}})
118
+ out = server.elk_buscar_logs("logs-*", "now-15m", campos={"sis": "a"}, limite=5, retornar=["message"],
119
+ ambiente="homologação")
120
+ sent = body(fake.calls[0])
121
+ assert sent["params"]["index"] == "logs-*"
122
+ assert sent["params"]["body"]["size"] == 5 and sent["params"]["body"]["_source"] == ["message"]
123
+ assert sent["params"]["body"]["sort"] == [{"@timestamp": {"order": "desc"}}]
124
+ assert fake.calls[0].headers["kbn-xsrf"] == "true" and fake.calls[0].headers["Authorization"].startswith("Basic")
125
+ assert out["ambiente"] == "homol" and out["total"] == 7
126
+ item = out["itens"][0]
127
+ assert item["app.nome"] == "x" and len(item["message"]) == server.TEXTO_MAX + 1
128
+
129
+
130
+ def test_buscar_logs_limite():
131
+ with pytest.raises(ValueError, match="elk_exportar_csv"):
132
+ server.elk_buscar_logs("logs-*", "now-1h", consulta="x", limite=500)
133
+
134
+
135
+ def test_contar_agrupado_es_direto(fake_es):
136
+ fake_es.routes[("POST", f"{ES}/logs-*/_search")] = (200, {"hits": {"total": {"value": 30}, "hits": []},
137
+ "aggregations": {"grupos": {"buckets": [{"key": "app-a", "doc_count": 20}]}}})
138
+ out = server.elk_contar("logs-*", "now-1h", consulta="x", agrupar_por="sis.nome", top=3)
139
+ assert body(fake_es.calls[0])["aggs"] == {"grupos": {"terms": {"field": "sis.nome", "size": 3}}}
140
+ assert out["total"] == 30 and out["grupos"] == [{"valor": "app-a", "total": 20}]
141
+
142
+
143
+ def test_erro_de_certificado(fake, monkeypatch):
144
+ def ssl_error(req):
145
+ raise requests.exceptions.SSLError("CERTIFICATE_VERIFY_FAILED")
146
+
147
+ fake.routes[("POST", f"{KIBANA}/internal/search/es")] = ssl_error
148
+ with pytest.raises(RuntimeError, match="elk-bundle.pem"):
149
+ server.elk_contar("logs-*", "now-1h", consulta="x")
150
+
151
+
152
+ def test_http_403(fake):
153
+ fake.routes[("POST", f"{KIBANA}/internal/search/es")] = (403, b'{"error":"Forbidden"}')
154
+ with pytest.raises(server.ElkHttpError, match="HTTP 403 em POST"):
155
+ server.elk_contar("logs-*", "now-1h", consulta="x")
156
+
157
+
158
+ # ---------------------------------------------------------------- consultas salvas, índices e campos
159
+
160
+ def test_listar_consultas(fake):
161
+ search = {"id": "s1", "type": "search", "attributes": {"title": "Erros", "columns": ["message"],
162
+ "kibanaSavedObjectMeta": {"searchSourceJSON": json.dumps({
163
+ "query": {"query": "level:ERROR", "language": "kuery"},
164
+ "filter": [{"meta": {"negate": True}, "query": {"match_phrase": {"a": "b"}}},
165
+ {"meta": {"disabled": True}, "query": {"match_phrase": {"c": "d"}}}]})}},
166
+ "references": [{"type": "index-pattern", "id": "dv1"}]}
167
+ saved_query = {"id": "q1", "type": "query", "attributes": {"title": "Lentas", "description": "",
168
+ "query": {"query": "tempo:>1000", "language": "lucene"}, "filters": []}}
169
+ dv = {"id": "dv1", "type": "index-pattern", "attributes": {"title": "logs-*", "timeFieldName": "@timestamp"}}
170
+
171
+ def find(req):
172
+ return 200, {"saved_objects": [dv] if "index-pattern" in req.url else [search, saved_query]}
173
+
174
+ fake.routes[("GET", f"{KIBANA}/api/spaces/space")] = (200, [{"id": "default"}, {"id": "neg"}])
175
+ fake.routes[("GET", f"{KIBANA}/api/saved_objects/_find")] = find
176
+ fake.routes[("GET", f"{KIBANA}/s/neg/api/saved_objects/_find")] = (200, {"saved_objects": []})
177
+ resp = server.elk_listar_consultas(busca="Err")
178
+ assert "search=Err%2A" in fake.calls[1].url
179
+ out = resp["itens"]
180
+ assert resp["total"] == 2
181
+ assert out[0] == {"id": "s1", "espaco": "default", "tipo": "search", "titulo": "Erros", "descricao": None, "consulta": "level:ERROR",
182
+ "linguagem": "kuery", "filtros": [{"bool": {"must_not": [{"match_phrase": {"a": "b"}}]}}],
183
+ "indice": "logs-*", "colunas": ["message"]}
184
+ assert out[1]["tipo"] == "query" and out[1]["consulta"] == "tempo:>1000" and out[1]["indice"] is None
185
+ assert server.elk_listar_indices(espaco="default") == [{"espaco": "default", "nome": "logs-*", "padrao": "logs-*",
186
+ "campo_tempo": "@timestamp"}]
187
+ assert server.elk_listar_consultas(espaco="neg") == {"total": 0, "itens": []}
188
+
189
+
190
+ def test_listar_campos(fake):
191
+ fake.routes[("GET", f"{KIBANA}/internal/data_views/_fields_for_wildcard")] = (200, {"fields": [
192
+ {"name": "sis.nome", "esTypes": ["keyword"], "aggregatable": True},
193
+ {"name": "_id", "type": "string"}, {"name": "message", "esTypes": ["text"], "aggregatable": False}]})
194
+ assert server.elk_listar_campos("logs-*", busca="sis") == [{"campo": "sis.nome", "tipo": "keyword",
195
+ "agregavel": True}]
196
+
197
+
198
+ # ---------------------------------------------------------------- exportação CSV
199
+
200
+ def _hit(i, ts):
201
+ return {"_id": str(i), "_index": "l", "_source": {"ts": ts, "a": {"b": i}, "tags": ["x"]}, "sort": [ts]}
202
+
203
+
204
+ def test_exportar_csv_sem_pit_nao_perde_nem_duplica(fake, tmp_path, monkeypatch):
205
+ monkeypatch.setattr(server, "PAGINA", 3)
206
+ docs = [_hit(1, 10), _hit(2, 20), _hit(3, 20), _hit(4, 20), _hit(5, 30)]
207
+
208
+ def search(req): # simula o ES: ordena por ts e aplica search_after
209
+ b = body(req)["params"]["body"]
210
+ after = b.get("search_after", [float("-inf")])[0]
211
+ page = [d for d in docs if d["sort"][0] > after][:b["size"]]
212
+ return 200, {"rawResponse": {"hits": {"total": {"value": 5}, "hits": page}}}
213
+
214
+ fake.routes[("POST", f"{KIBANA}/internal/search/es")] = search
215
+ arquivo = tmp_path / "sub" / "out.csv"
216
+ out = server.elk_exportar_csv("l", "now-1h", str(arquivo), consulta="x", campo_tempo="ts")
217
+ rows = list(csv.DictReader(arquivo.open(encoding="utf-8-sig")))
218
+ assert [r["a.b"] for r in rows] == ["1", "2", "3", "4", "5"]
219
+ assert rows[0]["tags"] == '["x"]'
220
+ assert out == {"arquivo": str(arquivo), "linhas": 5, "total": 5, "truncado": False,
221
+ "colunas": ["ts", "a.b", "tags"]}
222
+
223
+
224
+ def test_exportar_csv_pit_e_truncado(fake_es, tmp_path, monkeypatch):
225
+ monkeypatch.setattr(server, "PAGINA", 2)
226
+ fake_es.routes[("POST", f"{ES}/l/_pit")] = (200, {"id": "pit1"})
227
+ fake_es.routes[("DELETE", f"{ES}/_pit")] = (200, {})
228
+ docs = [_hit(i, i) for i in range(1, 6)]
229
+
230
+ def search(req):
231
+ b = body(req)
232
+ assert b["pit"]["id"] == "pit1" and b["sort"][1] == {"_shard_doc": "asc"}
233
+ after = b.get("search_after", [0])[0]
234
+ return 200, {"pit_id": "pit1", "hits": {"total": {"value": 5}, "hits": [d for d in docs if d["sort"][0] > after][:2]}}
235
+
236
+ fake_es.routes[("POST", f"{ES}/_search")] = search
237
+ out = server.elk_exportar_csv("l", "now-1h", str(tmp_path / "o.csv"), consulta="x", retornar=["a.b"],
238
+ max_linhas=3, campo_tempo="ts")
239
+ assert out["linhas"] == 3 and out["truncado"] is True and out["colunas"] == ["a.b"]
240
+ assert fake_es.calls[-1].method == "DELETE"
241
+
242
+
243
+ def test_exportar_csv_caminho_relativo():
244
+ with pytest.raises(ValueError, match="absoluto"):
245
+ server.elk_exportar_csv("l", "now-1h", "out.csv", consulta="x")