콘텐츠로 이동
Study Notekagent · kmcp

임직원 토큰을 MCP와 backend까지 전파

결론부터
  • 토큰은 구간마다 따로 넘겨야 한다. 한 구간의 설정이 다음 구간의 전달을 보장하지 않는다.
  • controller에서 Agent Pod까지는 자동이다. Agent Pod에서 MCP 서버로는 tool의 allowedHeaders에 Authorization을 적어야 넘어간다.
  • MCP 서버는 http 전송이어야 하고, 받은 header를 backend 호출에 싣는 코드는 우리가 쓴다.
  • kagent 구간 어디에서도 토큰을 검증하지 않는다. 서명·발급자·대상·만료를 검사하는 곳은 backend API다.
  • 토큰을 MCP 서버용으로 바꿔 주는 STS 교환은 v0.10.2에서 MCP 호출에 실리지 않는 문제가 열려 있다. 지금은 받은 토큰을 그대로 전달하는 방식으로 시작한다.
이 장에서 처음 나오는 말6개
token propagation
사용자의 토큰을 호출 사슬의 다음 서비스로 계속 넘기는 것이다. 마지막 서비스가 "누구의 요청인지"를 알 수 있게 된다.
JWKSJSON Web Key Set
토큰 발급자가 공개하는 서명 검증용 공개 키 목록이다. Keycloak realm마다 주소가 있다.
audAudience claim
JWT가 "누구에게 보여 주려고 발급됐는지"를 적은 claim이다. 받는 쪽은 자기가 대상인지 확인해야 한다.
token exchangeOAuth 2.0 Token Exchange (RFC 8693)
받은 토큰을 발급자에게 내고, 다른 대상(aud)용 새 토큰으로 바꿔 받는 절차다.
STSSecurity Token Service
token exchange를 수행하는 서버다. 이 덱의 구성에서는 Keycloak이 이 역할을 할 수 있다.
token passthrough
받은 토큰을 바꾸지 않고 다음 서비스로 그대로 넘기는 방식이다. 간단하지만 토큰의 aud가 최종 서비스와 맞지 않는다.

목표는 하나다. 임직원이 Agent에게 “내 휴가 신청 내역 보여 줘”라고 했을 때, MCP 서버가 backend API를 그 임직원의 권한으로 호출하는 것이다.

이 전파가 없으면 MCP 서버는 Secret에 넣어 둔 고정 자격으로 backend를 부른다. 그러면 backend는 “Agent가 호출했다”는 것만 알고, Agent를 쓰는 모든 임직원이 같은 권한을 갖는다. 인사팀장이 볼 수 있는 데이터를 모든 직원이 Agent를 통해 볼 수 있게 된다.

앞 페이지에서 임직원 토큰이 controller에 도착하는 데까지를 봤다. 이 페이지는 그 뒤를 잇는다.

임직원 토큰이 oauth2-proxy, backend, kagent controller, Agent Pod, MCP 서버를 차례로 지나 backend API 호출에 실리는 순서
구간넘기는 주체켜야 하는 것근거
② oauth2-proxy → backendoauth2-proxy--pass-authorization-header 또는 --pass-access-tokenoauth2-proxy 문서
③ backend → controller우리 backend 코드A2A 요청에 Authorization header를 싣는다우리 코드
④ controller → Agent Podkagent controller없음. 자동으로 넘긴다v0.10.2 소스
⑤ Agent Pod → MCP 서버kagent runtimetool의 allowedHeadersAPI reference
⑥ MCP 서버 → backend API우리 MCP 서버 코드http 전송 + header를 읽어 싣는 코드우리 코드
⑦ backend API우리 backendJWT 검증 경로우리 코드 또는 oauth2-proxy 설정

kagent가 해 주는 것은 ④와 ⑤ 두 구간뿐이다. 나머지는 우리 쪽 설정과 코드다. 그리고 ②부터 ⑥까지 어느 구간도 토큰을 검증하지 않는다. 전달만 한다.

controller는 A2A 요청을 Agent Pod로 중계할 때 header 두 개를 붙인다(v0.10.2 소스에서 확인).

header값
Authorization들어온 요청의 Authorization을 그대로
X-User-Idcontroller가 정한 사용자 ID. trusted-proxy에서는 JWT의 sub(또는 userIdClaim)

들어온 요청의 다른 header는 넘기지 않는다. backend가 A2A 요청에 X-Forwarded-Email 같은 header를 더 실어도 Agent Pod에는 닿지 않는다. 그러므로 뒤 구간에서 쓸 수 있는 신원 정보는 이 둘이다.

⑤ Agent Pod에서 MCP 서버까지 — allowedHeaders

섹션 제목: “⑤ Agent Pod에서 MCP 서버까지 — allowedHeaders”

Agent Pod는 받은 header를 기본적으로 어느 tool에도 넘기지 않는다. tool 서버마다 넘길 header 이름을 명시해야 한다.

# 설명용 예제
apiVersion: kagent.dev/v1alpha2
kind: Agent
metadata:
name: hr-helper
namespace: agents
spec:
type: Declarative
declarative:
runtime: go
modelConfig: litellm-default
systemMessage: |
너는 인사 규정을 안내하는 Agent다.
tools:
- type: McpServer
mcpServer:
name: portal-api-mcp
kind: MCPServer
toolNames:
- get_my_leave_requests
allowedHeaders:
- Authorization
- type: McpServer
mcpServer:
name: wiki-search
kind: RemoteMCPServer
toolNames:
- search
# allowedHeaders 없음 — 이 서버에는 임직원 토큰이 가지 않는다

기본이 “넘기지 않음”인 것이 이 필드의 가치다. 임직원 토큰은 backend를 부를 수 있는 자격이므로, 그 토큰을 받아도 되는 서버에만 보낸다. 외부 MCP 서버나 다른 팀이 운영하는 서버에 allowedHeaders를 붙이면 그 서버의 운영자가 임직원 토큰을 갖게 된다. PR 리뷰에서 이 필드를 따로 보는 이유다.

알아 둘 동작이 셋 있다.

  • header 이름은 대소문자를 구분하지 않는다.
  • 같은 tool의 headersFrom에 Authorization이 있으면 고정값이 이겨서 임직원 토큰이 덮어써진다 (Tool 연결).
  • Agent가 다른 Agent를 tool로 부를 때 x-user-id와 Authorization은 sub-agent로 넘어간다(Go runtime 소스에서 확인). sub-agent가 그것을 다시 MCP 서버로 넘길지는 sub-agent 자신의 allowedHeaders가 정한다.

Agent Deployment에 환경 변수 KAGENT_PROPAGATE_TOKEN=true를 주면 allowedHeaders 없이도 Authorization이 그 Agent의 모든 MCP 서버와 sub-agent로 넘어간다(소스에서 확인. 0.x 문서에는 설명이 없다). 서버별로 좁힐 수 없으므로 allowedHeaders를 쓴다.

allowedHeaders는 Declarative Agent의 필드다. BYO Agent는 만든 방식에 따라 다르다.

BYO Agent의 종류전파 방법
kagent init adk python으로 만든 project (kagent-adk image로 실행)byo.deployment.env에 KAGENT_PROPAGATE_TOKEN=true를 주면 runtime이 모든 MCP toolset에 Authorization을 싣는다(소스에서 확인). 서버별로 좁힐 수 없다
그 밖의 프레임워크·직접 만든 A2A 서버controller가 넘긴 Authorization·X-User-Id를 A2A 요청에서 직접 읽어 tool 호출에 싣는 코드를 쓴다

어느 쪽이든 image 안의 코드가 토큰을 어디로 보내는지는 manifest에 드러나지 않는다. BYO Agent에 임직원 토큰을 전파하게 하려면 그 Agent가 닿을 수 있는 MCP 서버를 egress로 제한해 둔다 (BYO Agent 로컬 개발 흐름).

MCP 서버가 header를 받으려면 http 전송이어야 한다. stdio 서버의 프로세스는 HTTP 요청을 보지 못한다. npx·uvx로 띄운 공개 서버로는 이 전파를 할 수 없다.

받은 header를 backend 호출에 싣는 것은 서버 코드의 일이다. 아래는 kmcp init python으로 만든 project에 tool 파일 하나를 추가한 모양이다. project 구조와 로컬 시험은 MCP 서버 로컬 개발 흐름에 있다.

# 설명용 예제 — kmcp project의 src/tools/get_my_leave_requests.py
import os
import httpx
from fastmcp.server.dependencies import get_http_request
from core.server import mcp
BACKEND_URL = os.environ["BACKEND_URL"]
@mcp.tool()
async def get_my_leave_requests() -> list[dict]:
"""로그인한 임직원 본인의 휴가 신청 내역을 조회한다."""
auth = get_http_request().headers.get("authorization")
if not auth:
raise RuntimeError("임직원 토큰이 전달되지 않았다")
async with httpx.AsyncClient(base_url=BACKEND_URL, timeout=10) as client:
resp = await client.get("/api/leave-requests/me", headers={"Authorization": auth})
resp.raise_for_status()
return resp.json()

이 코드에서 지킬 것이 셋이다.

  • 신원은 header에서만 얻는다. tool이 user_id를 인자로 받으면 그 값은 모델이 채운다. 대화에 섞인 악의적 문장이 모델을 유도해 다른 사람의 ID를 넣게 할 수 있다. 그래서 tool을 get_leave_requests(user_id)가 아니라 get_my_leave_requests()로 만든다.
  • 토큰이 없으면 실패한다. 고정 자격으로 대신 호출하는 fallback을 두지 않는다. 전파가 끊긴 것을 권한 상승으로 덮게 된다.
  • 토큰을 로그·응답에 남기지 않는다. tool의 반환값은 모델에 전달되고 대화 이력에 저장된다.

backend가 oauth2-proxy의 cookie session 뒤에 있다면, MCP 서버가 cookie 없이 Bearer 토큰만 들고 오는 요청은 로그인 화면으로 튕긴다. Bearer 토큰을 받는 길을 열어야 하고, 방법은 둘이다.

(a) backend가 직접 검증(b) oauth2-proxy가 Bearer를 통과시킴
하는 일backend에 JWT 검증 경로를 추가한다backend 앞 oauth2-proxy에 옵션을 켠다
설정Keycloak의 JWKS로 서명 검증, iss·aud·exp 검사--skip-jwt-bearer-tokens, 필요하면 --extra-jwt-issuers
backend 코드수정 필요수정 없음
MCP 서버가 호출하는 주소backend Serviceoauth2-proxy Service
맞는 시점장기. 검증 지점이 backend에 명확히 남는다데모·초기. 가장 빠르다

(b)의 --skip-jwt-bearer-tokens는 검증된 Bearer JWT를 가진 요청을 로그인 없이 통과시킨다. 조건은 토큰의 aud가 그 oauth2-proxy의 client ID와 같은 것이다. 다른 client가 발급받은 토큰도 받으려면 --extra-jwt-issuers에 issuer=audience 쌍을 추가한다.

어느 쪽이든 검증이 일어나는 곳은 여기 한 군데다. 서명·발급자·대상·만료 중 하나라도 빠지면 앞의 모든 구간이 “아무 문자열이나 전달”하는 통로가 된다.

그대로 전달할 때 생기는 두 문제

섹션 제목: “그대로 전달할 때 생기는 두 문제”

받은 토큰을 바꾸지 않고 넘기는 방식(token passthrough)은 간단하지만 두 가지가 따라온다.

oauth2-proxy가 --pass-authorization-header로 넘기는 것은 ID token이고, 그 aud는 로그인을 요청한 oauth2-proxy의 client ID다. backend API용으로 발급된 토큰이 아니다. backend가 이 aud를 받아들이도록 설정해야 한다. portal과 backend API가 같은 oauth2-proxy client 뒤에 있으면 (b)에서 aud가 그대로 맞는다.

MCP 스펙의 보안 지침은 자기 앞으로 발급되지 않은 토큰을 받아 넘기는 것을 금지 패턴으로 본다 (MCP proxy 페이지). 이 덱의 구성은 MCP 서버와 backend가 같은 팀의 같은 신뢰 경계 안에 있다고 가정하므로 초기에 허용할 수 있지만, 그 경계 밖의 서버에는 쓰지 않는다.

Agent가 오래 돌면 토큰이 만료된다

섹션 제목: “Agent가 오래 돌면 토큰이 만료된다”

토큰은 임직원이 메시지를 보낸 시점의 것이다. Agent가 여러 단계를 실행하다가 몇 분 뒤에 tool을 호출하면 그 사이에 토큰이 만료되어 backend가 401을 돌려줄 수 있다. Agent는 이것을 tool 실패로 받는다.

  • Keycloak client의 토큰 수명을 Agent의 일반적인 실행 시간보다 길게 잡는다.
  • oauth2-proxy가 토큰을 갱신하게 해서(cookie-refresh) 임직원의 다음 메시지에는 새 토큰이 실리게 한다.
  • MCP 서버가 401을 받으면 “다시 로그인이 필요하다”는 뜻이 분명한 오류를 돌려주게 한다.

정석은 token exchange — 아직 기대할 수 없다

섹션 제목: “정석은 token exchange — 아직 기대할 수 없다”

두 문제의 정석 해법은 Agent가 임직원 토큰을 Keycloak에 내고 MCP 서버용 aud를 가진 새 토큰으로 바꿔 받는 것이다(RFC 8693). kagent runtime에는 이를 위한 STS 연동 코드가 있다.

항목2026-10-02 확인 내용
설정 방법Agent Pod의 환경 변수 STS_WELL_KNOWN_URI, KAGENT_STS_AUDIENCE, KAGENT_STS_RESOURCE (소스)
공식 문서0.x 문서에 설정 가이드가 없다. API reference가 “STS가 켜지면 STS 토큰이 allowedHeaders의 Authorization보다 우선한다”고만 적는다
동작 상태교환은 성공하지만 교환된 토큰이 MCP 호출에 실리지 않는 문제를 고치는 PR #2803이 open이다
서버별 audienceRemoteMCPServer.spec.stsAudience 같은 필드는 오픈소스 API에 없다. 이를 제안한 PR #1431은 merge 없이 닫혔다. audience는 Agent 단위 환경 변수 하나다

그래서 순서는 이렇게 잡는다. 지금은 받은 토큰을 그대로 전달하는 방식으로 끝에서 끝까지를 먼저 연결하고, PR #2803이 포함된 release가 나오면 token exchange로 옮기는 것을 다시 검토한다.

더 단순한 대안 — 사용자 ID만 전파

섹션 제목: “더 단순한 대안 — 사용자 ID만 전파”

토큰 대신 X-User-Id만 넘기는 방법도 있다. allowedHeaders: [X-User-Id]로 두고 backend가 그 header로 사용자를 식별한다.

X-User-Id는 서명이 없는 문자열이라 누구나 만들 수 있다. 이 방식이 성립하려면 backend가 호출자가 우리 MCP 서버일 때만 이 header를 믿어야 한다.

  • NetworkPolicy로 그 backend 경로에 MCP 서버 Pod만 닿게 한다.
  • MCP 서버는 자기 신원(ServiceAccount 토큰이나 headersFrom의 서비스 key)을 함께 보내고, backend는 “MCP 서버의 자격 + X-User-Id” 조합일 때만 받아들인다.

데모 수준에서는 현실적이다. 다만 backend의 감사 기록에 남는 것이 “MCP 서버가 주장한 사용자”가 되므로, 장기적으로는 JWT 전파가 맞다. X-Forwarded-Email 같은 다른 header는 controller가 Agent Pod로 넘기지 않아 이 경로로 전파할 수 없다는 점도 기억한다.

설정을 다 한 뒤 “안 된다”가 되면 어느 구간인지 찾기 어렵다. 끝에서부터 하나씩 확인한다.

  1. backend API가 Bearer 토큰만으로 응답하는가. 브라우저에서 얻은 임직원 토큰으로 backend(또는 그 앞의 oauth2-proxy)에 직접 요청해 본다. 여기서 실패하면 kagent와 무관한 문제다.

    터미널 창
    curl -s -o /dev/null -w '%{http_code}\n' \
    -H "Authorization: Bearer $EMPLOYEE_TOKEN" \
    https://portal.example.com/api/leave-requests/me
  2. MCP 서버에 Authorization이 도착하는가. MCP 서버에 임시 로그를 넣는다. 토큰 값은 찍지 않고 header의 유무와 JWT payload의 sub만 찍는다. Agent를 호출해 tool이 실행되게 한 뒤 로그를 본다. 도착하지 않으면 allowedHeaders, transportType, 설치된 kagent 버전을 본다.

  3. backend 로그의 사용자가 호출한 임직원인가. 두 사람의 계정으로 같은 질문을 해서 backend가 각각 다른 사용자로 기록하는지 확인한다.

  4. 실패해야 하는 경우가 실패하는가. allowedHeaders를 뺀 Agent는 tool이 “토큰 없음”으로 실패해야 한다. 만료된 토큰은 401이어야 한다. 서명을 바꾼 토큰은 backend에서 거부되어야 한다.

2번이 가장 중요하다. header 전파는 버전에 따라 수정이 이어지는 영역이므로 문서나 이 덱의 설명보다 설치된 버전에서 실제로 도착하는지가 기준이다.

  • allowedHeaders를 적고 MCP 서버를 uvx package의 stdio로 띄웠다. backend는 누구의 권한으로 호출되는가? → 임직원 토큰은 서버 프로세스에 닿지 않는다. 서버가 Secret의 고정 자격을 쓴다면 모든 임직원이 그 권한을 갖는다.
  • controller가 trusted-proxy이고 allowedHeaders도 맞는데, backend API가 서명을 검증하지 않는다. 무엇이 가능한가? → controller에 닿을 수 있는 누구든 임의의 sub를 가진 문자열로 다른 임직원의 데이터를 받을 수 있다. kagent 구간은 전달만 한다.
  • tool 시그니처가 get_leave_requests(employee_id: str)이고 backend가 그 인자로 조회한다. 토큰 전파가 되어 있어도 생기는 문제는? → 모델이 채운 인자를 믿으면 본인 확인이 무력화된다. backend는 인자가 아니라 토큰의 사용자로 조회 범위를 정해야 한다.