장르: 구축 기록. 관리 화면에서 프로필별 MCP 연결 상태를 확인하는 API를 만들었다. 설계를 통째로 뒤집은 실측 하나와, 구현 중에 실제로 밟은 함정 두 개가 이 글의 본체다.

1. 문제 — 멀쩡해 보이는데 도구가 없다

Hermes 게이트웨이는 기동할 때 MCP 도구를 먼저 붙이고, 그다음에 메신저 어댑터를 연결한다. 순서가 그렇다는 게 문제의 전부다.

기동 시점에 MCP 서버가 죽어 있으면 게이트웨이는 도구 0개로 등록을 마치고 그대로 기동을 완료한다. 죽지 않는다. 오류도 내지 않는다. 그 상태의 에이전트에게 사내 규정을 물으면, 문서 검색 도구가 없으니 터미널과 파일 검색으로 폴백해서 그럴듯한 오답을 만들어 낸다.

그런데 관리 화면에는 이렇게 보인다.

{ "name": "dev", "status": "running", "mattermost_state": "connected" }

프로세스는 돌고 있고 메신저에도 붙어 있다. 전부 초록불이다.

Hermes profile 관리 REST API — ChatGPT 설계안과 실환경의 간극을 메운 wrapper에서 만든 이 API는 프로필 하나당 세 개의 파일을 읽는다. .env, gateway_state.json, profile.yaml. 셋 어디에도 MCP 이야기가 없다. 화면이 거짓말을 하는 게 아니라, 애초에 볼 수가 없었다.

작업을 시작한 날 로컬의 dev 프로필이 정확히 그 상태였다. 인증 게이트웨이 MCP가 죽은 채로 5분마다 재연결만 반복하고 있었는데, API 응답에는 흔적조차 없었다. 덕분에 살아 있는 검증 샘플이 공짜로 생겼다.

2. 어디를 봐야 하나 — 관측 지점 찾기

라이브 컨테이너를 뒤져서 확인한 것들이다.

MCP 설정은 프로필별 config.yamlmcp_servers 키에 있다. .envprofile.yaml도 아니다. 프로필마다 완전히 격리돼 있어서, 같은 이름의 서버가 프로필별로 다른 주소를 가리키기도 한다(채널마다 다른 규정을 보게 하기 — 멀티 프로젝트 RAG 결선에서 의도한 설계 그대로다).

런타임 상태는 어디에도 저장되지 않는다. gateway_state.json에는 메신저 어댑터 상태만 있고 MCP 항목이 없다. 유일한 외부 관측 지점은 로그였다.

INFO  tools.mcp_tool: MCP server 'docs' (HTTP): registered 7 tool(s): mcp__docs__search_docs, …
INFO  tools.mcp_tool: MCP: registered 7 tool(s) from 1 server(s) (1 failed)
WARN  tools.mcp_tool: MCP server 'integrations' failed initial connection after 3 attempts,
      parking until a reconnect is requested: ...

CLI에도 mcp list가 있지만 쓸 수 없었다. Status 컬럼이 ✓ enabled인데 이건 설정에서 켜져 있다는 뜻이지 연결됐다는 뜻이 아니다. 게다가 URL이 25자에서 잘리고 JSON 출력 옵션도 없다.

그래서 첫 설계는 "로그를 파싱한다"였다. 이건 곧 뒤집힌다.

3. 설계를 뒤집은 실측 — 5.3초가 아니라 54밀리초였다

로그 기반으로 계획을 다 짜놓고 보고했더니 질문이 하나 돌아왔다.

"단순히 로그가 아닌 health check를 통해 실시간 확인할 수 있어?"

실시간을 포기했던 이유는 명확했다. CLI의 mcp test 명령이 서버 하나당 5초 넘게 걸린다. 서버 두 개면 10초다. 목록 화면에서 쓸 수 있는 속도가 아니다.

그런데 5초인지는 재보지 않았었다. 재봤다.

방법 정상 서버 죽은 서버
CLI mcp test 5,300 ms 7,400 ms
내부 프로브 함수 직접 호출 5,326 ms
표준 라이브러리로 직접 핸드셰이크 54 ms 3 ms

5초는 MCP 프로토콜이 느려서가 아니라 래퍼의 재시도·백오프·프리플라이트·이벤트 루프 스레드 셋업 오버헤드였다. 순수 핸드셰이크만 하면 100배 빠르다. 로그 파일을 훑는 것(프로필 7개 전체 139ms)보다도 싸다.

프로브라고 부르니 거창하지만 하는 일은 단순하다. HTTP로 두 마디를 건다.

→ initialize        "안녕, 규격 버전 맞추자"
→ tools/list        "도구 뭐 있어?"

답이 오면 살아있는 것이고, 연결이 거부되면 죽은 것이다. 세션도 생기지 않아서 정리할 것도 없었다. 외부 라이브러리 없이 표준 urllib만으로 끝난다.

CLI에는 함정이 하나 더 있었다. 연결에 실패해도 종료 코드가 0이다.

✗ Connection failed (7408ms): All connection attempts failed
EXIT=0

종료 코드로 성패를 판정했다면 죽은 서버를 정상으로 볼 뻔했다. 어차피 안 쓰기로 했지만, 남의 CLI를 파이프에 물릴 때 종료 코드를 먼저 확인해야 한다는 걸 다시 배웠다.

4. 그런데 실시간만으로는 부족하다 — 두 축을 병기한 이유

프로브로 갈아타려는 순간 걸리는 게 있었다. 프로브는 "서버가 지금 살아있나" 에만 답한다. 정작 원래 문제였던 "게이트웨이가 도구를 들고 있나" 는 여전히 모른다.

이 둘은 자주 갈린다. Hermes는 기동 시 MCP 초기 연결에 세 번 실패하면 parked 상태로 영구 포기하고 스스로 복구하지 않기 때문이다. 서버를 고쳐도 게이트웨이는 모른다.

그래서 두 축을 모두 노출하기로 했다.

출처 답하는 질문 게이트웨이 중지 시
server_state 실시간 핸드셰이크 지금 서버가 살아있나 조회 가능
gateway_state 로그 이 게이트웨이가 도구를 로드했나 unknown

어긋나는 조합이 곧 조치 문장이 된다.

server_state gateway_state 의미 조치
ok connected 정상 없음
unreachable connected 서버가 방금 죽음 MCP 서버 복구
ok parked 서버는 고쳤는데 에이전트는 아직 못 씀 재시작
unreachable parked 서버가 계속 죽어 있음 서버부터 복구 (재시작해도 소용없다)

3행이 이 기능의 존재 이유다. needs_reload: true 하나로 표현했다.

4행도 중요하다. 두 축이 없으면 "parked니까 재시작하세요"라고 잘못 안내하게 되는데, 서버가 죽어 있는 동안 재시작은 아무것도 고치지 못한다. 틀린 조치를 권하지 않는 것도 진단 도구의 일이다.

5. 로그를 읽을 때 반드시 필요했던 것 — 시간 창

로그 기반 축에는 함정이 있다. 로그 파일은 게이트웨이가 재시작돼도 계속 이어 붙는다.

그냥 "마지막 MCP 줄"을 읽으면, 지금 MCP가 죽어 있어도 사흘 전 실행의 성공 기록을 현재 상태로 보고하게 된다. 그래서 현재 프로세스가 시작된 시각 이후의 줄만 판정에 쓴다. 프로세스 시작 시각은 /proc/<pid> 디렉터리의 수정 시각으로 얻는다.

같은 이유로 게이트웨이가 중지된 프로필은 로그를 아예 읽지 않고 전부 unknown으로 둔다. MCP 연결이 프로세스보다 오래 살 수는 없으니, 과거 로그로 "연결됨"이라고 말하는 건 거짓말이다. 반면 프로브는 게이트웨이와 무관하게 동작하므로 중지된 프로필에서도 서버 생사는 알 수 있다.

여기서 하나 더 신경 쓴 게 틀리는 방향이다.

gateway_state는 어디에도 저장돼 있지 않은 추론값이다. 로그 문장을 정규식으로 읽어 상태로 번역한 것이라, Hermes를 올리면서 로그 문구가 바뀌면 정규식이 안 맞을 수 있다.

이때 결과는 unknown이 되지 connected가 되지 않는다. 고장 나면 "모르겠다"고 말하지 "괜찮다"고 거짓말하지 않는다는 뜻이다. 이 기능이 애초에 잡으려는 게 "멀쩡해 보이는데 사실 고장난 상태"인데, 진단 도구가 같은 방식으로 실패하면 있으나 마나다.

6. 밟은 함정 ① — 시각 변환을 어디서 하느냐가 정확성 문제였다

로그 타임스탬프에는 타임존 표기가 없다.

2026-08-23 01:48:34,677 INFO tools.mcp_tool: ...

여기에 오프셋을 붙여 표준 형식으로 만드는 함수를 만들어 놓고, 프로브 시각에만 적용하고 로그 시각에는 빠뜨렸다. 고치다가 이게 취향 문제가 아니라는 걸 알았다. 같은 함수가 어디서 도느냐에 따라 다른 값을 찍는다.

호스트(KST)에서 실행   → 2026-08-23T01:48:34+09:00
컨테이너(UTC)에서 실행 → 2026-08-23T01:48:34+00:00

로그를 쓴 건 컨테이너다. 호스트에서 변환했다면 모든 MCP 시각이 9시간 틀렸을 것이다. 비교는 타임존 없는 원본 형식으로 하고(문자열 사전순이 곧 시간순이다), 변환은 반드시 컨테이너 안에서 하도록 옮겼다.

7. 밟은 함정 ② — 프로브가 너무 느슨하면 거짓말을 한다

종단 시나리오를 재현하려고 최소한의 가짜 MCP 서버를 세웠다. initializetools/list에 답만 하면 되겠지 싶었다.

그런데 내 프로브는 ok인데 Hermes는 계속 parked 였다. 게이트웨이 로그를 봤다.

WARNING mcp.client.streamable_http: Failed to parse initialization response
        as InitializeResult: 1 validation error for InitializeResult

가짜 서버가 initialize 응답에 protocolVersion을 빠뜨렸고, Hermes는 그걸 검증해서 거부하고 있었다. 내 프로브는 그 필드를 보지 않았다. 즉 Hermes가 절대 못 붙는 서버를 "살아있음"으로 보고하고 있었다.

이건 픽스처 버그가 아니라 실제 결함이다. needs_reload가 "재시작하면 고쳐집니다"라고 말하는 근거가 프로브인데, 프로브가 헐거우면 그 안내가 헛수고를 시킨다.

필드를 추가하고 다시 돌렸더니 다음 실패가 나왔다.

WARNING tools.mcp_tool: Failed to connect to MCP server 'integrations':
        3 validation errors for ListToolsResult

이번엔 도구 목록의 inputSchema 누락이었다.

결국 Hermes 클라이언트가 검증하는 것과 같은 두 지점(initializeprotocolVersion, tools/listnameinputSchema)을 보도록 조였다. 이건 "클라이언트를 통째로 재구현한다" (5초짜리, 기각한 방식)와 "그냥 연결만 본다"(거짓 양성) 사이의 타협점이다.

다만 완전히 같지는 않다. 그래서 문서에 명시했다. server_state: ok는 "엔드포인트가 MCP로 응답한다"는 뜻이지 "Hermes가 반드시 붙는다"는 보장이 아니다.

8. 조회 하나가 기동을 느리게 만들 뻔했다

성능 회귀를 하나 미리 막았다.

프로필 상태를 읽는 함수는 내부적으로 전체 프로필을 훑는데, 게이트웨이 기동·재시작 로직이 이걸 1초마다 최대 30초 폴링한다. MCP 수집을 무조건 켜두면 기동할 때마다 초당 네트워크 프로브가 나가게 된다. 응답 모델에 MCP 필드가 아예 없는 경로인데도 말이다.

수집을 끄는 플래그를 만들고 폴링 경로에서만 껐다. 기본값은 켜짐으로 뒀다. 새 조회 경로가 깜빡 잊었을 때 "필드 누락"(디버깅 어렵다)이 아니라 "조금 느림"(눈에 띄고 안전하다)으로 실패하게 만드는 쪽이 낫기 때문이다.

껐다는 사실보다 왜 껐는지가 중요해서 import 위에 주석으로 박아뒀다. 안 그러면 다음 사람이 "왜 여기만 다르지?" 하고 통일해 버린다.

실제로 확인했다. 16초짜리 재시작 동안 MCP 서버가 받은 요청은 3건이었다. 게이트웨이 자신의 재연결이고, 폴링이 샜다면 폴링 횟수만큼(약 48건) 늘었을 것이다.

한 가지 더. 프로브 결과는 엔드포인트 단위로 5초간 캐싱한다. 관리 화면이 1초마다 폴링해도 MCP 서버는 5초에 한 번만 맞는다. 여러 프로필이 같은 서버를 가리키는 경우도 흔해서(로컬에서 두 프로필이 같은 문서 서버를 봤다) 중복을 제거하니 항목 4개가 프로브 3회로 줄었다.

9. 검증 — 고장을 실제로 만들어서 고쳐봤다

이 기능의 존재 이유인 시나리오를 그대로 재현했다.

  1. MCP 서버를 살린다 → server_state: ok, gateway_state: parked, needs_reload: true
  2. 안내대로 프로필을 재시작한다
  3. → 양쪽 축 모두 connected, needs_reload: false, 로그에 10 tools from 2 servers, 0 failed

관리 화면용 요약도 {total: 2, healthy: 2, degraded: 0}으로 정리됐다.

컨테이너 안에서 도는 스크립트는 호스트에서 그대로 실행해 검증했다. 이 저장소에 이미 있던 패턴이라 그대로 따랐다. 도커 없이 3초면 도는 테스트 103개가 로그 파싱 5종, 프로브의 JSON·SSE· HTTP 500·연결 거부·규격 위반, 캐시, 시간 창을 덮는다.

10. 운영에서만 보인 것

로컬과 개발 서버를 지나 운영에 올려보니 거기에만 있는 설정이 나왔다.

mcp_servers:
  knowledge:
    url: http://sample-mcp:8080/mcp
    tools:
      include: [search_knowledge_base]
    headers:
      X-Hermes-Profile: <프로필 식별자>

헤더가 실제로 쓰이고 있었다. 프로브는 Hermes와 똑같이 그 헤더를 붙여 요청해야 한다. 안 붙이면 서버가 인증이나 스코핑에 그 값을 쓸 경우 다른 결과가 나와서 프로브가 거짓말을 하게 된다. 대신 응답에는 절대 실으면 안 된다. 자격 증명이 들어가는 자리다. (봇 토큰을 "설정됨" 불리언으로만 노출하는 기존 규약과 같은 이유다.)

tools.include도 처음 만났다. 이게 있으면 내가 문서에 써둔 규칙이 깨진다. Hermes는 서버의 도구에 래퍼 4개를 얹어 등록하므로 보통 "게이트웨이 도구 수 = 서버 도구 수 + 4"인데, include 필터가 걸리면 통과한 것에만 래퍼가 붙는다. 위 설정에서는 1개만 통과해 5개로 등록됐다. 프로브는 서버의 전체 도구를 세니 두 숫자가 +4로 설명되지 않는다.

응답에 필터 내용을 그대로 실어두길 잘했다. "서버엔 3개 있는데 게이트웨이엔 1개만 등록됨, 필터 때문"이라고 읽힌다. 없었으면 불일치를 버그로 오해했을 것이다.

11. 배포에서 걸린 별건 — 기본값이 한 서버에만 있는 이름이었다

운영 배포가 빌드 성공 직후 기동 실패로 막혔다.

network docker_default declared as external, but could not be found

며칠 전 다른 작업에서 같은 호스트의 관리 화면이 컨테이너 이름으로 API를 부를 수 있게 외부 네트워크를 붙였는데, 그 기본값이 개발 서버에만 존재하는 이름이었다. 운영은 메신저를 다른 호스트로 옮긴 뒤라 그 네트워크가 없다.

대체제로 흔히 떠올리는 기본 브리지도 안 된다.

invalid config for network bridge: invalid endpoint settings:
  network-scoped aliases are only supported for user-defined networks

Compose는 서비스 이름을 네트워크 별칭으로 붙이는데, 기본 브리지는 사용자 정의 네트워크가 아니라서 별칭을 지원하지 않는다. 존재하지만 Compose에서 쓸 수 없는 네트워크다.

운영에서는 관리 화면이 다른 호스트에 있어서 이 네트워크 join이 아무 역할도 하지 않는다. 일단 존재하는 네트워크 이름을 환경 변수로 넘겨 우회했지만, 제대로 된 해법은 기본 Compose 파일을 이식 가능하게 두고 같은 호스트 케이스만 오버라이드 파일로 분리하는 것이다. 안 그러면 새 서버에 배포할 때마다 같은 데서 걸린다. 아직 안 고쳤다.

12. 남은 것

  • 관리 화면 연동. 목록에 요약 배지, 상세에 서버별 두 축 표시, needs_reload면 "재시작하면 고쳐짐" 안내
  • Compose 네트워크 정리 (11절)
  • MCP 설정 변경(추가·삭제)은 이번 범위 밖이다. 설정 반영이 게이트웨이 재시작이 아니라 대화창 슬래시 명령으로 이뤄져서, 기존의 "재시작 필요" 신호와 의미가 다르다. 별도 설계가 필요하다
  • 그 슬래시 명령을 셸에서 부를 경로가 있는지도 아직 못 찾았다. 있으면 재시작보다 훨씬 가벼운 조치가 된다

관련 글