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.
- mcp_elk-1.0.0/.gitignore +7 -0
- mcp_elk-1.0.0/LICENSE +21 -0
- mcp_elk-1.0.0/PKG-INFO +100 -0
- mcp_elk-1.0.0/README.md +87 -0
- mcp_elk-1.0.0/mcp_elk/__init__.py +0 -0
- mcp_elk-1.0.0/mcp_elk/config.py +52 -0
- mcp_elk-1.0.0/mcp_elk/server.py +441 -0
- mcp_elk-1.0.0/pyproject.toml +30 -0
- mcp_elk-1.0.0/tests/test_server.py +245 -0
mcp_elk-1.0.0/.gitignore
ADDED
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.
|
mcp_elk-1.0.0/README.md
ADDED
|
@@ -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")
|