elasticsearch-investigation-mcp
by Edrient17
README.md
# Elasticsearch Investigation MCP
Elasticsearch에 쌓인 로그를 n8n AI Agent가 인용할 수 있는 증거로 바꿔 주는 읽기
전용 MCP 서버입니다. Agent가 호스트·시간 범위·조건을 결정하고, 이 서버는 조회와
파싱, 집계를 결정론적으로 수행합니다.
Zabbix Investigation MCP와 짝을 이룹니다. 메트릭이 "언제 이상해졌는가"를
말한다면 로그는 "무엇이 실패했는가"를 말합니다. 두 서버가 같은 호스트 이름을
쓰기 때문에 한 조사 안에서 나란히 놓일 수 있습니다.
## 원본이 파싱되어 있지 않다는 전제
이 서버가 읽는 인덱스에는 로그 레벨·서비스·메시지가 필드로 분리되어 있지
않습니다. 수집기가 원문 한 줄을 `message`에 통째로 넣습니다. 따라서
- **레벨은 필터가 아니라 파싱 결과입니다.** Elasticsearch로는 "그 단어가 들어간
줄"까지만 좁히고, 그중 진짜 그 레벨인 줄은 이 서버가 앞부분을 해석해서
가려냅니다. 본문에 `ERROR`를 인용한 INFO 줄은 INFO로 셉니다.
- **서비스 이름도 파싱 결과입니다.** `service.name` 필드는 일부 문서에만 있고,
나머지는 원문 앞부분에서 읽어냅니다. 그래서 `by_service`에는 Zabbix가 아는
컨테이너 이름뿐 아니라 `sudo`·`cron`·커널처럼 **어떤 모니터링 시스템도
등록하지 않은 출처**가 함께 나옵니다. 사람이 무엇을 했는지는 대개 그쪽에
적혀 있습니다.
- **응답은 얼마나 이해했는지를 함께 보고합니다.** `data_quality.formats`와
`unlevelled_lines`가 그것입니다. 파싱률을 숨긴 채 정밀해 보이는 숫자만 주면
신뢰할 수 없는 근거가 됩니다.
인식하는 형식은 Spring Boot, `날짜 시각 LEVEL 본문`, syslog 세 가지이고, 어느
쪽도 아니면 `unrecognised`로 표시한 뒤 레벨 단어만 찾아봅니다.
`contains`와 `services`는 **필드·구절·부분 문자열 세 가지로 동시에** 맞춰
봅니다. 표준 분석기는 `com.example.DemoController`를 통째로 한 토큰으로 두고
`payment-service`는 둘로 쪼개므로, 구절 검색만으로는 5만 건이 있는 인덱스에서
0건이 나옵니다. 세 방식을 함께 쓰는 이유가 그것입니다.
## 제공 도구
### `summarize_logs` — 먼저 부르는 도구
한 호스트가 어떤 구간에 남긴 로그 전체가 무엇을 말하는지 요약합니다. **줄
자체는 반환하지 않습니다.** 바쁜 호스트는 시간당 수천 줄을 쓰기 때문에, 이
도구는 "어느 분, 어느 메시지를 봐야 하는가"까지만 알려 줍니다.
값마다 근거가 다르므로 응답은 **구간 전체를 센 값과 표본에서 나온 값을
구분해서** 돌려줍니다.
| 항목 | 근거 |
|---|---|
| `by_level` | 구간 전체. Elasticsearch 집계라 인출 상한과 무관합니다. `confirmed_ratio`가 그중 파싱으로 확인된 비율입니다. |
| `volume_over_time` | 구간 전체를 10개 구획으로 나눈 분포. 80배 급증 같은 것은 여기서 보입니다. |
| `by_service`, `repeated_messages` | 인출된 표본. `sampled_fraction`이 1보다 작으면 `analysed_window`가 가리키는 부분만 설명합니다. |
`by_level`이 표본이 아닌 이유는 겪어서 알게 된 것입니다. 3만 3천 줄짜리 폭주가
인출 상한을 다 쓰는 바람에, 실제로 38건 있던 ERROR가 **0으로 보고된** 적이
있습니다. 0은 "없음"으로 읽히지 "못 봤음"으로 읽히지 않습니다.
- `repeated_messages` — 변하는 부분(숫자·IP·16진수·따옴표 문자열·스택 프레임)을
치환해 같은 모양끼리 묶은 목록. 문제 레벨이 위로 옵니다.
- `data_quality.empty_because_filtered` — 비어 있지 않던 구간을 필터가 비웠을 때
나타나며, 그 구간에 실제로 몇 줄이 있었는지 함께 옵니다. **숫자가 크면 호스트가
조용했던 게 아니라 필터가 틀린 것입니다.**
- `window`, `window_local`, `query_kql` — 조회한 구간(UTC)과 같은 구간의 현지
시각, 그리고 Kibana Discover에 그대로 붙일 수 있는 질의.
### `search_logs` — 요약이 지목한 줄을 읽는 도구
`level`·`contains`·`services`로 좁혀서 실제 줄을 가져옵니다.
**시간순으로 돌아오고, 한도를 넘으면 양쪽 끝을 남깁니다.** 사건의 원인은 구간
앞쪽에, 복구는 뒤쪽에 있고 가운데는 대개 같은 줄의 반복이기 때문입니다. 버린
가운데 분량은 `data_quality.omitted_from_middle`에 적힙니다.
```text
101줄이 걸리고 6줄만 요청했을 때
02:15:10 02:16:14 02:17:08 …95줄 생략… 02:33:11 02:33:11 02:34:05
```
최신순으로 한도만큼 끊으면 원인이 통째로 사라집니다. `sudo docker stop`이
구간 앞머리에 있는데 뒤쪽 복구 로그만 돌아오는 식입니다.
반환되는 `message`는 원문 전체가 아니라 **파싱된 본문**입니다. Spring 접두부
118자(타임스탬프·레벨·pid·서비스·스레드·로거)는 이미 각각의 필드로 따로
돌려주므로, 그것까지 문자 예산에 넣으면 스택 트레이스가 시작되기도 전에 잘립니다.
긴 메시지는 잘라내고 `message_truncated`로 표시합니다.
쓰기 도구, 인덱스 변경, 클러스터 설정 도구는 제공하지 않습니다.
## 환경 변수
```powershell
Copy-Item .env.example .env
```
필수 설정:
- `ES_URL`: Elasticsearch 주소 (예: `http://elasticsearch.example.com:9200`)
- `ES_API_KEY`: 로그 인덱스에 `read`, `view_index_metadata`만 가진 API Key.
Base64 인코딩된 `id:secret` 형태 그대로 넣습니다.
- `ES_MCP_AUTH_TOKEN`: MCP 클라이언트가 사용할 Bearer Token
```powershell
[Convert]::ToHexString(
[Security.Cryptography.RandomNumberGenerator]::GetBytes(32)
).ToLower()
```
### 필드 이름
- `ES_HOST_FIELD` (기본 `host.name`) — **Zabbix가 부르는 이름과 같은 값이 들어
있어야 합니다.** 다르면 로그가 옆에 놓일 메트릭을 찾지 못합니다.
- `ES_TIMESTAMP_FIELD` (기본 `@timestamp`)
- `ES_MESSAGE_FIELD` (기본 `message`)
- `ES_SERVICE_FIELD` (기본 `service.name`)
- `ES_DEFAULT_INDEX` (기본 `vm-logs-*`) — 호출자가 인덱스를 지정하지 않았을 때
- `ES_TIMEOUT_MS` (기본 `30000`)
### 시각 표기
- `DEFAULT_TIMEZONE` (기본 `Asia/Seoul`) — 응답의 `window_local`에 쓰는 시간대.
조회 입력과 `window`는 UTC이고, `window_local`은 **같은 순간을 사람이 읽는
시각으로 한 번 더** 적은 것입니다. 둘 중 하나만 주면 `02:22:40Z`를 새벽 2시로
읽고 여기에 `+09:00`을 붙여 되묻는 일이 생기며, 그러면 조회가 9시간 어긋난
채로도 성공한 것처럼 보입니다.
### 조회 정책
- `ES_ALLOWED_INDEX_PATTERNS` (기본값 없음 = API Key가 허용하는 전부).
API Key도 범위를 정하지만 키는 한 번 크게 발급되므로, 이 목록은 **키를 다시
발급하지 않고 배포 단위로 좁힐 수 있는 경계**입니다. `*`만 와일드카드로
동작하고 `.`은 문자 그대로 취급합니다.
- `INVESTIGATION_MAX_WINDOW_HOURS` (기본 `26`) — 한 번의 호출이 덮을 수 있는
구간. **보존 기간이 무제한이면 이 값이 유일한 제동 장치입니다.** 호스트 한
대가 이미 시간당 약 4천 줄을 씁니다. 26은 하루치 조회에 시간대 차이만큼의
여유를 더한 값입니다.
- `INVESTIGATION_MAX_FETCH_DOCUMENTS` (기본 `10000`) — 답 하나를 만들려고
Elasticsearch에서 끌어오는 문서 수. 응답 크기가 아니라 작업량의 상한입니다.
- `INVESTIGATION_MAX_RETURNED_LINES` (기본 `100`), `INVESTIGATION_MAX_MESSAGE_CHARS`
(기본 `600`), `INVESTIGATION_MAX_SHAPES` (기본 `25`) — 모델이 실제로 값을
치르는 부분.
- `INVESTIGATION_MAX_FUTURE_HOURS` (기본 `2`) — 미래 구간은 아무것도 반환하지
않는데, 그것이 "조용했다"로 읽히면 안 되므로 오류로 처리합니다.
## Docker 실행
```powershell
docker compose up -d --build
docker compose ps
Invoke-RestMethod http://127.0.0.1:3001/healthz
```
기본 엔드포인트:
- MCP: `http://<host>:3001/mcp`
- 상태 확인: `http://<host>:3001/healthz`
```text
Authorization: Bearer <ES_MCP_AUTH_TOKEN>
```
Zabbix Investigation MCP와 같은 머신에 올릴 수 있습니다. compose project 이름과
게시 포트가 다르므로 서로 간섭하지 않습니다 (`MCP_PUBLISHED_PORT=3001`).
컨테이너 내부 포트는 양쪽 모두 3000입니다.
## 네트워크 노출 제한
컨테이너 포트가 게시되는 호스트 인터페이스는 `MCP_BIND_ADDRESS`가 정합니다.
```dotenv
MCP_BIND_ADDRESS=10.0.0.10 # 이 머신의 사설 IP
```
값을 지정하지 않으면 `127.0.0.1`로 게시되므로, 설정을 빠뜨려도 공인
인터페이스에 열리지 않습니다.
`MCP_HOST`와 혼동하지 않아야 합니다. `MCP_HOST`는 프로세스 자체의 바인드
주소이고 docker compose에서는 컨테이너 내부 기준 `0.0.0.0`으로 고정됩니다.
호스트 노출을 실제로 통제하는 값은 `MCP_BIND_ADDRESS`입니다.
Docker는 자신의 forwarding 규칙을 ufw보다 앞에 삽입하므로, `0.0.0.0`으로
게시한 포트는 호스트 방화벽으로 막히지 않습니다. 방화벽에 의존하지 말고 바인드
주소를 지정하십시오.
적용 결과는 실행 전에 확인할 수 있습니다.
```powershell
docker compose config
```
`MCP_ALLOWED_HOSTS`는 소스 IP가 아니라 요청의 `Host` 헤더를 검사합니다. DNS
rebinding 방어용이며 네트워크 접근 제어를 대신하지 않습니다.
## 로컬 개발
요구 사항은 Node.js 20 이상입니다.
```powershell
npm ci
npm run typecheck
npm test
npm run build
npm run dev
```
Elasticsearch가 사설망에 있으면 터널을 열고 붙습니다.
```powershell
ssh -N -L 19200:<elasticsearch-host>:9200 <jump-host>
```
## 저장소 구조
```text
.
├── src/
│ ├── log-parse.ts 원문 한 줄에서 레벨·서비스·본문을 읽어내는 곳
│ ├── policies.ts 구간·인덱스·건수 상한
│ ├── es-client.ts Elasticsearch REST 호출
│ ├── es-service.ts 두 도구의 실제 동작
│ └── register-tools.ts
├── tests/
├── Dockerfile
├── docker-compose.yml
├── package.json
└── .env.example
```
클라이언트의 `ES_MCP_URL`은 이 서버의 `/mcp` 주소를 가리켜야 하며, n8n HTTP
Bearer Auth credential에는 동일한 `ES_MCP_AUTH_TOKEN`을 입력합니다.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessSyncing