사내 Mattermost의 Hermes 봇에게 "내 캘린더 다음 일정 알려줘"라고 물으면, 봇이 그 사용자의 Google Calendar를 보고 답해야 한다. 옆자리 동료가 물으면 동료의 캘린더를 봐야 한다. 이 당연한 요구가 MCP 세계에서는 당연하지 않았다. 이틀에 걸쳐 게이트웨이 MCP 하나를 세우고, 설계 가정이 두 번 무너지고, 인증 앱을 한 번 갈아치운 기록이다.
1. 문제 — MCP에는 "누가 물었는지"가 없다
Hermes Agent는 MCP 클라이언트를 내장하고 있어서 프로필(채널) 단위로 MCP 서버 URL을 등록하면 도구를 쓸 수 있다. 문제는 이 연결이 채널 단위라는 것이다. RAG 검색처럼 채널 전체가 같은 데이터를 봐도 되는 도구는 이 구조로 충분했고, 실제로 채널마다 다른 규정을 보게 하기 — 멀티 프로젝트 RAG 결선에서는 "서버 인스턴스 분리(URL이 곧 권한 경계)"로 격리를 풀었다.
그러나 캘린더, GitHub, Jira는 다르다. 같은 채널의 두 사용자가 같은 도구를 불러도 각자 자기 계정의 데이터를 봐야 한다. URL 분리로는 풀 수 없다. 사용자마다 토큰을 보관하고, 호출마다 "지금 누가 불렀는지"를 알아내 그 사람의 토큰을 꺼내 쓰는 무언가가 필요하다. 그 무언가가 이번에 만든 사용자별 인증 게이트웨이 MCP다.
구조는 이렇다.
사용자 → Mattermost → Hermes → 게이트웨이 MCP(/mcp) ─┬→ Google Calendar REST (직접 호출)
│ ├→ GitHub 공식 원격 MCP (프록시)
│ └→ Atlassian 공식 원격 MCP (프록시)
└ DB에서 그 사용자의 토큰(암호화 저장)을 꺼내 주입
- Hermes는 이 게이트웨이 하나만 연결한다. GitHub과 Atlassian은 벤더가 운영하는 공식 원격 MCP에 사용자 토큰을 실어 프록시하고, Google Calendar는 REST를 직접 부른다.
- 벤더 MCP의 도구 수십 개를 그대로 미러링하면 에이전트의 도구 목록이 폭발하므로, 도구 표면은
9개로 눌렀다: 연결 관리 3개(
list_integrations,connect_integration,disconnect_integration), 캘린더 2개, GitHub/Atlassian은 "도구 목록 조회 + 범용 호출" 각 2개. - 토큰은 PostgreSQL에 Fernet 암호문으로만 저장한다. 스키마는 관리 콘솔 프로젝트의 Prisma가 관리하고, 게이트웨이(FastAPI + SQLAlchemy)는 같은 테이블을 읽고 쓰기만 한다.
여기까지가 계획이었다. 계획은 첫날 저녁에 무너지기 시작했다.
2. 첫 번째 벽 — 에이전트는 사용자가 누구인지 모른다
당초 설계는 소박했다. "모든 도구가 mattermost_user_id 인자를 받고, 시스템 프롬프트에 '항상
세션 사용자의 ID를 전달하라'고 못박는다." 문서상 Hermes의 세션 키는
agent:main:mattermost:channel:<채널ID>:<사용자ID> 형태라 에이전트가 사용자를 안다고 가정했다.
구현을 끝내고 연동 직전에 Hermes 소스를 열어보고서야 가정이 틀렸다는 것을 알았다. LLM이 사용자 ID를 알 수 있는 경로가 하나도 없었다.
| 후보 경로 | 실제 동작 |
|---|---|
메시지 앞 [사용자명] 프리픽스 |
공유 세션에서만 붙는다. 기본 설정은 사용자별 세션 분리라 안 붙고, 붙어도 이름뿐이다 |
| 시스템 프롬프트 | 프롬프트 빌더에 사용자 정보를 넣는 코드가 없다 |
| 셸 환경변수 | 세션 변수는 contextvar 전용이라 터미널 서브프로세스의 os.environ으로 나가지 않는다 |
| 훅 시스템 | fire-and-forget으로 반환값을 버린다. 메시지를 변조해 신원을 끼워 넣을 수 없다 |
세션 키는 게이트웨이 내부에만 존재하고 LLM의 컨텍스트에는 실리지 않는다. "프롬프트 규약"은 에이전트가 알 수 없는 값을 강요하는 규약이었던 셈이다. 설계를 받치는 가정은 문서가 아니라 소스로 검증했어야 했다. 문서와 코드가 어긋나는 문제는 문서가 코드와 어긋난 네 곳 — 게이트웨이 인가 경로를 실측으로 다시 그렸다에서 이미 한 번 데인 적이 있는데, 같은 교훈을 다른 각도에서 다시 배웠다.
해법 — 신원을 LLM 밖에서 나르기
발상을 뒤집었다. LLM이 신원을 전달할 수 없다면, LLM을 거치지 않고 전달하면 된다.
Hermes의 MCP 도구 호출 지점은 에이전트 태스크의 async 컨텍스트에서 실행된다. 그 지점을 한 곳
패치해서, 세션 contextvar에 있는 사용자 ID를 MCP 요청의 _meta["x-user-id"]로 실었다.
# Hermes tools/mcp_tool.py 패치 (요지)
_uid = get_session_env("HERMES_SESSION_USER_ID", "")
_meta = {"x-user-id": _uid} if _uid else None
result = await server.session.call_tool(tool_name, arguments=args, meta=_meta)
게이트웨이 쪽은 요청 meta에서 신원을 읽고, 도구 인자에서는 사용자 ID를 아예 제거했다. 효과는 세 겹이다.
- LLM이 신원을 만들 수 없다. 프롬프트 인젝션으로 "나는 다른 사용자다"라고 우겨도 meta는 런타임이 채우므로 소용없다. 자기신고 방식이었다면 뚫렸을 공격이다.
- 작은 모델도 안전하다. 도구 시그니처에서 신원 인자가 사라지니 모델이 실수할 자리도 없다.
- Fail-closed다. 세션 컨텍스트가 없는 경로(cron 등)에서는 meta가 생략되고, 게이트웨이는 명시적 오류를 낼 뿐 다른 사용자로 오인하지 않는다.
시도했다가 버린 것도 있다. 요청별 HTTP 헤더 주입은 불가능했다. MCP 세션은 상시 유지형이고
실제 POST는 SDK의 전송 백그라운드 태스크에서 나가므로, 호출 시점의 contextvar가 거기까지
전파되지 않는다. 요청 단위 메타데이터의 표준 통로는 헤더가 아니라 _meta였다.
패치는 멱등 스크립트로 만들어 저장소에 넣었다. 컨테이너 이미지가 재생성되면 다시 적용해야 한다는 운영 부채가 남는데, 장기적으로는 업스트림에 "플랫폼 사용자 신원을 meta로 전달" 옵션을 제안할 생각이다.
3. 두 번째 벽 — LLM은 긴 토큰을 잘라먹는다
사용자가 서비스를 처음 연결할 때는 OAuth 동의가 필요하다. 봇이 연결 링크를 채팅으로 주고, 사용자가 브라우저에서 열어 동의하는 흐름이다. 이 "링크 전달"이 두 번째 벽이었다.
| 시도 | 링크 형태 | 결과 |
|---|---|---|
| 1차 | /oauth/github/start?s=<43자 랜덤 토큰> |
모델이 마크다운 링크로 감싸며 ?s= 이하를 잘라먹음 |
| 2차 | /oauth/github/start/<토큰> (경로 세그먼트) |
역시 잘림 |
| 3차 | 링크를 채팅 대신 DM으로 직접 발송 | 동작하지만 폐기. 발신 봇이 채널과 무관해지는 확장성 문제 |
| 4차 | /connect/github 정적 URL |
가변 부분이 없으니 잘릴 것도 없다 |
1~2차 실패에서 얻은 결론은 이렇다. 고엔트로피 문자열을 LLM이 받아써야 성립하는 설계는 모델 성능과 무관하게 취약하다. 재시도할 때마다 어떤 링크는 살아남고 어떤 링크는 잘렸다. 확률에 기대는 설계였던 것이다.
4차의 정적 URL이 가능해진 열쇠는 Mattermost의 OAuth 서버 기능이었다. 1회용 토큰을 URL에 싣는 대신, "누가 클릭했는지"를 Mattermost가 증명하게 했다.
/connect/github
→ Mattermost /oauth/authorize (브라우저의 기존 MM 로그인 세션, 신뢰 앱이라 클릭 없이 통과)
→ 게이트웨이 콜백: code 교환 → /users/me 로 신원 확정 → 이 시점에 state 생성
→ GitHub 동의 화면 → 프로바이더 콜백 → 토큰 암호화 저장 → "연결 완료"
부수 효과로 보안이 오히려 올라갔다. 연결 시점의 신원이 "에이전트의 주장"에서 "Mattermost 인증"으로 격상됐고, 링크가 채널에 공개돼도 클릭한 본인 계정에만 바인딩된다. 다만 한 가지 운영 주의가 생겼다. 신원은 링크를 연 브라우저의 Mattermost 로그인 계정이다. 같은 PC에서 다른 계정으로 로그인된 브라우저로 열면 그 계정에 연결된다. 실제로 테스트 중에 한 번 겪었다.
4. 프로바이더 3사, 3색 인증
Google Calendar — 표준 OAuth의 함정은 콘솔에 있다
셋 중 가장 평범했다. PKCE 웹 플로우에 access_type=offline&prompt=consent를 붙여 refresh
token을 보장받고, 만료 2분 전부터 온디맨드로 갱신한다. 유일한 함정은 코드 밖에 있었다.
OAuth 동의까지 끝났는데 조회가 403으로 죽어서 보니, GCP 프로젝트에서 Calendar API 자체를
활성화하지 않았던 것이다. 토큰이 멀쩡해도 API가 꺼져 있으면 소용없다.
GitHub — OAuth App을 버리고 GitHub App으로
처음엔 OAuth App으로 붙였고, GitHub 공식 원격 MCP(api.githubcopilot.com/mcp)가 OAuth App
토큰을 수락하는 것까지 확인했다. 문제는 범위였다. repo scope는 전부 아니면 전무라서, 동의한
사용자의 모든 사설 저장소가 봇에 노출된다. 실사용 첫날 동료 계정의 사설 저장소 12개가
봇 응답에 그대로 나오는 것을 보고 전환을 결정했다.
GitHub App은 설치(installation)가 관문이다. 저장소 소유자가 계정에 앱을 설치하며 노출할 저장소를 고르고, 사용자 토큰의 접근 범위는 "앱 권한 ∩ 설치된 저장소 ∩ 사용자 본인 권한"의 교집합이 된다. 전환 후 실측:
| 조회 | OAuth App 시절 | GitHub App 전환 후 |
|---|---|---|
| 동료 → 동료 본인의 사설 저장소 | 12개 노출 | 0개 (설치 안 함 = 차단) |
| 동료 → 관리자 계정의 사설 저장소 | (전부 가능했을 것) | 1개 (설치 ∩ collaborator인 것만) |
| 관리자 → 본인 사설 저장소 | 전부 | 설치에서 선택한 것만 |
토큰도 무만료에서 8시간 만료 + refresh 회전으로 바뀌는데, 갱신 인프라는 Google 때 만든 것이
그대로 처리했다. 전환 자체는 authorize에 scope 파라미터를 안 보내는 것 정도라 코드 변경이
의외로 작았다. 대신 시행착오가 하나 있었다. 토큰 교환이 incorrect_client_credentials로
계속 거부됐는데, 원인은 다른 앱의 client secret을 넣은 것이었다. OAuth App과 GitHub App의
secret이 똑같이 40자 16진수라 겉으로 구분이 안 된다.
남은 구멍은 public 저장소였다. 공개 저장소는 GitHub 차원에서 전 세계 공개라 어떤 앱 설정으로도
막을 수 없다. "봇에서는 특정 계정의 저장소만 보여야 한다"는 요구는 결국 게이트웨이가
프록시라는 점으로 풀었다. 허용 소유자 목록을 환경변수로 받아, 저장소 단위 도구는 owner
인자를 검증하고, 검색 도구는 쿼리의 user:/org:/repo: 한정자를 검증한 뒤 없을 때만 허용
소유자 한정자를 주입한다. 주입 순서가 중요했다. GitHub 검색에서 같은 종류의 한정자는 OR로
묶이기 때문에(user:a user:b는 "a 또는 b"), 검증 없이 한정자만 추가하면 오히려 뚫린다.
정리하면 GitHub은 2층 방어가 됐다. 설치는 능력의 상한(GitHub이 강제), 허용목록은 노출의 상한(게이트웨이가 강제)이다. 어느 한 층만으로는 부족했다.
Atlassian — 공식 문서의 앱이 아니라 MCP 서버의 자체 인가 서버
가장 큰 반전이었다. developer console에서 3LO 앱을 만들어 붙였더니 도구 목록 조회까지는 통과하고 도구 실행에서만 거부됐다. 목록이 나오니 인증이 된 줄 알기 딱 좋은 함정이다.
원인을 파보니 Atlassian 공식 원격 MCP(mcp.atlassian.com)는 developer console 앱이 아니라
자체 인가 서버를 쓴다. .well-known/oauth-authorization-server 메타데이터를 조회하면
authorize/token/register 엔드포인트가 나오고, 클라이언트는 DCR(동적 클라이언트 등록)로 1회
등록한다. 공개 클라이언트 + PKCE, secret 없음, 만료 없음. scope는 요청하지 않는다. 서버가
알아서 부여하기 때문인데, 이 "알아서"가 다음 함정으로 이어졌다.
동료 계정으로 Jira 이슈 생성이 권한 오류로 죽었다. 토큰 스코프를 까보니 Confluence 스코프만 있고 Jira 스코프가 하나도 없었다. 이 인가 서버는 사용자가 그 사이트에서 실제로 사용 권한을 가진 제품의 스코프만 발급한다. 동료 계정에 Jira 제품 액세스가 없었던 것이다. 관리 콘솔에서 제품 액세스를 부여해도 기존 토큰에는 소급되지 않으므로 재연결까지 해야 스코프가 들어온다. Jira 도구만 권한 오류가 나면 앱 설정이 아니라 사이트의 제품 액세스를 먼저 볼 것.
덤으로, 실행 중에 Atlassian이 응답에 SSE 엔드포인트 지원 종료 공지를 실어 보냈다. 접속
엔드포인트를 /v1/sse에서 /v1/mcp(streamable-http)로 바꿔 해결했다.
5. Hermes 연동에서 밟은 지뢰 다섯 개
게이트웨이가 완성돼도 봇이 그걸 쓰게 만드는 것은 별개의 일이었다. 전부 실측으로 밟은 것들이다.
- MCP 등록은 게이트웨이 부팅 시가 아니라 에이전트 턴 시작 시 일어난다. 재시작 직후 로그에
MCP: registered가 없다고 실패가 아니다. 봇에게 메시지를 하나 보내야 등록 로그가 찍힌다. - 번들 스킬이 요청을 가로챈다. 캘린더를 물었더니 MCP 대신 내장 google-workspace 스킬이 깨어나 "로컬에 토큰 파일을 만들자"는 엉뚱한 안내를 했다. 프로필의 해당 스킬 디렉터리를 비활성 폴더로 옮기고, 시스템 프롬프트(SOUL.md)에 라우팅 지침을 박아 해결했다.
- 에이전트는 이전 대화의 낡은 링크를 재사용한다. 링크 형식을 바꿔도 봇이 옛 형식을 계속 주길래 보니, 도구를 다시 부르지 않고 대화 기억 속 링크를 꺼내 쓰고 있었다. 세션을 지우고 "연결 링크는 매번 도구를 호출해 최신 응답만 전달하라"는 지침을 추가했다.
- MCP 연결 실패는 무음이다. 등록 도구가 0개여도 봇은 그럴듯하게 대답한다. "도구가 목록에 없으면 다른 수단으로 대체하지 말고 사용 불가라고 답하라"는 지침이 반드시 필요하다. Hermes 내부를 열어보다 — 프롬프트 3계층, API 모드 분기, 게이트웨이를 하나만 띄워야 하는 이유에서 본 구조 그대로다.
- 등록은 프로필(채널) 단위다. dev 채널에 등록했다고 다른 채널 봇이 아는 게 아니다. 프로필마다 등록해야 한다.
6. 구현 메모 — 다시 만들 때 기억할 것
MCP SDK 2.x로 서버와 클라이언트를 다 만들면서 확인한 사실들이다. SDK 2.x 이관 자체는 벤치마크가 못 본 것 — MCP SDK 2.0 이관과 직렬로 돌고 있던 검색 서버에서 다뤘고, 아래는 이번에 추가로 배운 것들이다.
- FastAPI에 MCP 앱을 얹을 때,
streamable_http_app(host="0.0.0.0")을 명시하지 않으면 DNS 리바인딩 보호가 켜져 컨테이너 밖 요청이 421로 죽는다. 또 mount된 서브앱의 lifespan은 자동 실행되지 않으므로session_manager.run()을 FastAPI lifespan에서 직접 잡아야 한다. - 서버가 받는 요청 meta(
RequestParamsMeta)는 pydantic 모델이 아니라 dict 서브클래스다.model_extra를 뒤지면 영원히 빈 값만 나온다. 이걸로 한 시간을 썼다. - SDK 2.x의 클라이언트는 httpx가 아니라 httpx2를 쓴다. 자체 REST 호출(일반 httpx)과 한 프로세스에서 공존은 된다.
- Prisma 마이그레이션에서 섀도 DB가 pgvector
CREATE EXTENSION을 재생하다 superuser 권한이 없어 실패한다. superuser로migrate dev --create-only(섀도 검증만) 하고 앱 계정으로migrate deploy(실제 적용) 하면 테이블 소유권을 앱 계정으로 유지하면서 통과한다. - 토큰 갱신은 connection 단위 asyncio.Lock으로 직렬화했다. refresh token이 회전하는 프로바이더(Atlassian, GitHub App)에서 동시 갱신은 곧 토큰 무효화다. 단일 인스턴스 전제이며, 복제 배포 시 DB advisory lock으로 바꿔야 한다.
- 연결 해제 시 토큰과 함께 scopes 컬럼도 지워야 한다. 앱을 갈아탄 뒤 재연결했는데 옛 scope가 화면에 유령처럼 남았다. 반대로 "저장 시 항상 덮어쓰기"로 고치면 scope를 안 주는 refresh 응답이 기존 값을 지우는 회귀가 생긴다. 정리 지점은 revoke가 맞다.
7. 결과와 남은 일
이틀간의 결과. 실사용자 2명(관리자, 동료)으로 검증했다.
- 3사 모두 엔드투엔드 동작: 캘린더 실일정 조회·생성, GitHub 저장소 조회, Jira 이슈 생성
- 같은 봇, 같은 채널에서 두 사용자가 각자 자기 계정 권한으로 동작 (사용자 격리 실증)
- GitHub 노출 범위: 허용 소유자의 public + "설치 ∩ collaborator" 사설만. 타 계정은 public조차 게이트웨이가 거부
- 신원 전달: 호출은 meta 주입(LLM 개입 불가), 연결은 Mattermost OAuth 증명
남은 일은 운영 서버 배포다. nginx에 /oauth/와 /connect/만 노출하고(/mcp는 내부망 전용),
각 벤더 콘솔에 운영 도메인 콜백을 추가하고, meta의 x-user-id에 HMAC 서명을 얹어 내부망의
위장 가능성까지 닫을 계획이다. 그 과정은 별도의 글로 남긴다.
관련 문서
- 채널마다 다른 규정을 보게 하기 — 멀티 프로젝트 RAG 결선 — "URL이 곧 권한 경계"였던 채널 단위 격리. 이번 글은 그 모델로 풀 수 없는 사용자 단위 격리를 다룬다
- 문서가 코드와 어긋난 네 곳 — 게이트웨이 인가 경로를 실측으로 다시 그렸다 — "문서 말고 소스로 검증"이라는 같은 교훈의 전편
- Hermes 내부를 열어보다 — 프롬프트 3계층, API 모드 분기, 게이트웨이를 하나만 띄워야 하는 이유 — 이번에 패치한 도구 호출 경로와 세션 구조의 배경
- 벤치마크가 못 본 것 — MCP SDK 2.0 이관과 직렬로 돌고 있던 검색 서버 — MCP SDK 2.x의 동작 특성