MCP 서버가 다른 호스트로 보낼 때: Python SDK 2.2의 리다이렉트 경계

편리했던 자동 리다이렉트가 경계를 흐렸다

MCP 서버 주소에 /mcp를 넣었는데 실제 애플리케이션은 /mcp/에 마운트돼 있다면, 서버는 보통 307 응답으로 슬래시가 붙은 주소를 안내한다. 이런 이동을 자동으로 따라가는 것은 편리하다. 문제는 기존 Python SDK의 기본 HTTP 클라이언트가 목적지의 호스트와 포트가 달라져도 리다이렉트를 따라갔다는 점이다.

MCP Python SDK 2.2.0은 이 동작을 바꿨다. 이제 Client, streamable_http_client, sse_client와 OAuth 요청은 처음 설정한 endpoint의 origin 안에서만 리다이렉트를 따라간다. 여기서 origin은 scheme, host, port의 조합이다. 다른 호스트, 다른 포트, 하위 도메인, HTTPS에서 HTTP로 내려가는 주소는 자동 이동 대상이 아니다.

MCP 요청에는 원래 endpoint에 맞춰 넣은 헤더, 인증 정보, 요청 본문이 있다. 서버의 Location만 믿고 다른 origin으로 이동하면 그 요청을 의도하지 않은 서버에 다시 보낼 수 있다. SDK 2.2는 그런 이동을 실패로 돌려주고, 정말 그 주소가 맞다면 호출자가 endpoint를 직접 바꾸도록 요구한다.

MCP 클라이언트가 같은 origin의 리다이렉트는 따르지만 다른 호스트나 포트로는 요청을 자동 전송하지 않는 경계

그림 1. Python SDK 2.2는 같은 endpoint origin과 기본 포트의 HTTP에서 HTTPS로 가는 이동만 자동으로 따른다. 출처: github.com/modelcontextprotocol/python-sdk/pull/3397.

같은 서버 안의 이동은 그대로 작동한다

규칙이 모든 리다이렉트를 막는 것은 아니다. 상대 경로나 같은 scheme·host·port의 주소는 따라간다. 기본 포트를 쓰는 같은 호스트에서 HTTP가 HTTPS로 올라가는 이동도 허용한다. 그래서 /mcp에서 /mcp/로 보내는 일반적인 307은 기존처럼 작동한다.

메서드도 따진다. GET은 허용된 origin 안이라면 301, 302, 303, 307, 308을 처리할 수 있다. POST는 307이나 308만 따라간다. 301, 302, 303은 클라이언트가 POST를 본문 없는 GET으로 바꿀 수 있기 때문이다. 사용자 정보가 들어간 Location과 리다이렉트 횟수 한도를 넘긴 응답도 거부한다.

호출자가 httpx2.AsyncClient(follow_redirects=True)를 넘겨도 이 경계는 넓어지지 않는다. SDK가 MCP transport 안에서 리다이렉트를 직접 처리하기 때문이다. 반대로 같은 origin의 trailing-slash 이동을 위해 별도로 follow_redirects=True를 줄 필요도 없어졌다.

실패한 한 요청과 죽은 세션은 다르다

다른 origin으로 향하는 리다이렉트를 만나면 message POST나 resumption GET은 MCPError로 끝난다. 그러나 그 한 번의 실패가 기존 세션을 자동으로 폐기하지는 않는다. SDK의 테스트에는 다른 origin으로 향한 요청이 실패한 뒤 같은 세션의 다음 요청이 정상 처리되는 경우가 포함돼 있다.

HTTPS endpoint가 같은 호스트의 HTTP 주소로 내려가려 하면 오류 메시지도 원인을 구분한다. TLS 종료 프록시가 전달 헤더를 신뢰하지 않거나 trailing slash 처리가 어긋난 경우가 흔하므로, SDK는 HTTP 주소를 그대로 권하지 않고 HTTPS 형태의 주소나 프록시 설정을 확인하라고 안내한다.

이번 조사에서는 v2.2.0 태그의 공식 소스를 고정해 리다이렉트 관련 테스트를 직접 실행했다. 같은 origin, 상대 경로, 기본 포트의 HTTP→HTTPS는 통과했고, 다른 호스트·포트·하위 도메인과 HTTPS→HTTP는 이동하지 않았다. 관련 선택 테스트 34개가 통과했다. 이는 배포된 코드를 재현한 검사이지, 별도 구현으로 보안성을 독립 증명한 것은 아니다.

리다이렉트 응답의 origin, HTTP method 보존, hop 한도를 차례로 확인하는 세 단계 결정 흐름

그림 2. origin, method, redirect budget 중 하나라도 조건을 벗어나면 SDK는 새 주소로 요청을 보내지 않는다. 출처: github.com/modelcontextprotocol/python-sdk/pull/3397.

오래된 stateful 세션에는 새 수명 제한도 생겼다

2.2.0은 legacy stateful Streamable HTTP 세션의 기본값도 바꿨다. 아무 요청도 처리 중이지 않은 세션은 1,800초, 즉 30분 뒤 닫힌다. 한 process의 session manager는 기본 10,000개까지만 보유한다. 한도를 넘겨 새 세션을 열면 기존 세션을 밀어내지 않고 503을 반환한다.

여기서 idle은 단순히 마지막 요청을 받은 시각으로 계산하지 않는다. 열려 있는 GET stream이나 아직 응답 중인 tool call이 있으면 in-flight 상태라서 만료시키지 않는다. 마지막 in-flight 요청이 끝난 뒤에 countdown이 시작된다. 만료된 ID로 요청하면 404가 오며, client는 새로 initialize해야 한다.

이 기본값은 모든 연결에 적용되지 않는다. stateless_http=True와 2026-07-28 연결에는 legacy session 자체가 없으므로 두 설정도 적용되지 않는다. 호환성 때문에 예전 세션을 계속 써야 한다면 session_idle_timeout과 max_sessions를 조정할 수 있지만, 둘 다 None으로 꺼 버리기 전에 process당 메모리와 고아 세션 정리 방식을 먼저 정해야 한다. 같은 태그에서 session lifecycle 선택 테스트 19개도 통과했다.

legacy stateful MCP 세션이 처리 중일 때는 유지되고 1800초 idle 뒤 만료되며 manager 기본 상한이 10000개인 흐름

그림 3. 열린 GET stream과 처리 중 요청은 idle이 아니며, 만료된 세션 ID는 404를 받고 초과 신규 세션은 503을 받는다. 출처: github.com/modelcontextprotocol/python-sdk/pull/3395.

OAuth issuer 검사도 같은 방향으로 좁아졌다

리다이렉트만 고친 것은 아니다. 2.2.0의 OAuth client는 protected resource metadata가 없는 legacy discovery 경로에서도 authorization server metadata의 issuer를 검사한다. metadata가 자신을 엉뚱한 issuer로 선언하면 OAuthFlowError로 중단한다. protected resource metadata 조회가 5xx나 429로 실패했을 때도 이를 "metadata가 없음"으로 간주해 오래된 endpoint로 조용히 넘어가지 않는다.

이 세 변경은 서로 다른 코드 경로지만 운영 원칙은 같다. 네트워크가 알려 준 다음 주소나 identity를 자동으로 신뢰하지 않고, 처음 설정한 origin과 issuer에 다시 묶는다. 다만 이것만으로 인증과 권한 검사가 끝나는 것은 아니다. TLS 인증서 검증, 정확한 endpoint 설정, server-side authorization은 여전히 별도 책임이다.

업그레이드 전에 확인할 네 가지

첫째, MCP endpoint가 로그인 페이지나 별도 gateway host로 리다이렉트되는지 확인한다. 의도한 서버라면 최종 URL을 endpoint로 직접 설정하는 편이 낫다. 둘째, reverse proxy의 trailing slash와 X-Forwarded-Proto 신뢰 설정을 점검한다. HTTPS 요청이 내부 HTTP 주소로 되돌아가면 2.2에서 바로 드러난다.

셋째, 오래 쉬었다가 같은 session ID를 재사용하는 client는 404에서 재initialize하도록 만든다. 404를 같은 ID로 무한 재시도하면 복구되지 않는다. 넷째, 여러 worker를 쓰는 stateful 배포라면 session affinity나 외부 routing 구조를 점검한다. 30분 만료는 다른 worker로 잘못 전달되는 문제를 해결하지 않는다.

2.2.0의 변경은 "리다이렉트를 끄라"는 얘기가 아니다. 자동 이동이 허용되는 범위를 endpoint의 origin으로 줄이고, 세션과 OAuth identity에도 명시적인 수명과 소유 경계를 둔 것이다. 업그레이드 후 오류가 늘었다면 우회 플래그부터 찾기보다, 그 오류가 지금까지 감춰진 잘못된 URL이나 proxy 설정을 가리키는지 먼저 보는 편이 낫다.

참고 자료

댓글

이 블로그의 인기 게시물

체크아웃은 분리됐는데 Git 상태는 공유된다: Codex 0.154 worktree의 세 경계

잠근 작업이 커밋 뒤 다시 나온다: PostgreSQL 에이전트 큐의 소유권 설계

코딩 에이전트는 API 문서보다 AGENTS.md를 먼저 읽었다