08. Streamable HTTP — 원격의 표준
- Streamable HTTP의 "단일 엔드포인트, 두 응답 모드" 설계를 설명할 수 있다
- 세션 ID(
Mcp-Session-Id)가 어떻게 생기고 쓰이는지 안다 - Origin 검증 등 HTTP 전송 특유의 보안 요구를 이해한다
우체국 비유
stdio가 "같은 집 안에서 쪽지를 주고받는 것"이라면, Streamable HTTP는 "우체국을 통해 편지를 주고받는 것"입니다. 서버는 독립된 주소(URL)를 가진 별도 서비스로 살고, 여러 사람(클라이언트)이 그 주소로 편지를 보냅니다.
Streamable HTTP는 MCP가 정의하는 두 번째 표준 전송이며, 원격 서버의 현재 표준입니다. 2025-03-26 스펙에서 도입되어 구(舊) HTTP+SSE 전송을 대체했고, 2025-11-25에서도 유지됩니다. 이 전송에서 서버는 여러 클라이언트 연결을 처리할 수 있는 독립 프로세스로 동작하며, HTTP POST와 GET 요청을 씁니다.
핵심 설계 — 단일 엔드포인트, 두 응답 모드
옛 SSE 방식은 엔드포인트가 두 개(스트림용 GET /sse, 메시지용 POST /messages)라 상태 관리가 까다로웠습니다. Streamable HTTP는 이를 하나의 엔드포인트로 합쳤습니다.
서버는 POST와 GET을 모두 지원하는 단일 HTTP 엔드포인트 경로(예: https://example.com/mcp)를 제공해야 합니다. 그리고 요청마다 응답을 두 가지 방식 중 하나로 돌려줍니다:
flowchart TD
C["Client"] -->|"POST /mcp\n(JSON-RPC + Accept 헤더)"| S["Server"]
S --> D{"응답 방식 결정"}
D -->|"단순 요청/응답"| J["application/json\n단일 JSON 본문"]
D -->|"긴 작업·스트리밍"| E["text/event-stream\nSSE로 여러 메시지"]
D -->|"알림·응답 수신"| A["202 Accepted\n(본문 없음)"]
classDef client fill:#fde68a,stroke:#b45309,color:#000;
classDef server fill:#5eead4,stroke:#0f766e,color:#000;
classDef net fill:#bfdbfe,stroke:#1d4ed8,color:#000;
class C client;
class S server;
class J,E,A net;
- 클라이언트는 JSON-RPC 메시지를 POST로 보내며,
Accept헤더에application/json과text/event-stream을 둘 다 명시합니다. - 서버는 단순 교환이면
application/json단일 본문으로 응답하고, 긴 작업이면text/event-stream으로 전환해 SSE로 여러 메시지를 흘려보냅니다. 어떤 방식을 쓸지는 서버가 정합니다. - 클라이언트가 (요청이 아니라) 알림이나 응답을 보낸 경우, 서버는 본문 없이 202 Accepted를 돌려줍니다.
- 서버 → 클라이언트 방향의 스트림이 필요하면 클라이언트가 GET으로 SSE 스트림을 열 수 있습니다.
와이어 포맷(실제로 회선을 타고 흐르는 바이트의 모양)은 대략 이렇습니다. 맨 아래 JSON 한 줄이 04장에서 본 JSON-RPC 메시지이고, 그 위의 줄들은 HTTP 헤더입니다. Authorization: Bearer ...의 Bearer 토큰은 "이 토큰을 지닌(bear) 자에게 접근을 허용한다"는 방식의 인증 자격으로, 마치 출입증처럼 요청에 첨부합니다(19장에서 상세).
POST /mcp HTTP/1.1
Content-Type: application/json
Accept: application/json, text/event-stream
Mcp-Session-Id: 1d3f...e7c2
Authorization: Bearer ...
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"add","arguments":{"a":1,"b":2}}}
세션 — Mcp-Session-Id
상태가 있는(stateful) 서버를 위해, Streamable HTTP는 세션을 지원합니다. 서버는 초기화 시점에 InitializeResult를 담은 HTTP 응답에 Mcp-Session-Id 헤더를 실어 세션 ID를 부여할 수 있습니다. 세션 ID는 전역적으로 유일하고 암호학적으로 안전해야 하며(예: 안전하게 생성된 UUID, JWT, 암호학적 해시), 보이는 ASCII 문자(0x21~0x7E)만 포함해야 합니다. 참고로 UUID는 충돌이 사실상 없는 128비트 임의 식별자(예: 1d3f...e7c2), JWT(JSON Web Token)는 정보를 담아 서명한 토큰 형식, 암호학적 해시는 입력을 되돌릴 수 없는 고정 길이 값으로 바꾸는 함수입니다. 공통점은 추측·위조가 어렵다는 것입니다.
서버가 초기화 때 세션 ID를 부여했다면, 클라이언트는 이후 모든 HTTP 요청의 Mcp-Session-Id 헤더에 그 값을 포함해야 합니다.
sequenceDiagram
participant C as Client
participant S as Server
C->>S: POST /mcp initialize
S-->>C: 200 + Mcp-Session-Id: abc123 (InitializeResult)
C->>S: POST /mcp (Mcp-Session-Id: abc123) notifications/initialized
S-->>C: 202 Accepted
C->>S: POST /mcp (Mcp-Session-Id: abc123) tools/call
S-->>C: 결과
🔒 보안 — 세션은 인증이 아니다: 세션 ID는 대화 맥락 식별용입니다. 인증을 구현하는 서버라면, 세션 ID와 별개로 모든 요청이 독립적으로 유효한 인증 자격(예: Authorization 토큰)을 가져야 합니다. 또한 클라이언트는 세션 ID를 안전하게 다뤄야 하며(세션 하이재킹 방지), 서버는 추측 불가능한 값으로 발급해야 합니다.
stateful vs stateless
같은 Streamable HTTP라도 상태 유지 여부를 고를 수 있습니다. 공식 mcp SDK 기준:
# 공식 mcp SDK — Streamable HTTP from mcp.server.fastmcp import FastMCP # host/port는 생성자에서 설정 mcp = FastMCP("Demo", host="127.0.0.1", port=8000) @mcp.tool() def add(a: int, b: int) -> int: """두 정수를 더한다""" return a + b if __name__ == "__main__": mcp.run(transport="streamable-http") # 기본 경로는 보통 http://host:port/mcp
대규모 배포에서는 stateless 모드가 확장성에 유리합니다. 공식 SDK 문서는 프로덕션 배포에 Streamable HTTP를 권장하며, 최적의 확장성을 위해 stateless_http=True와 json_response=True를 함께 쓰라고 안내합니다.
mcp = FastMCP("Demo", stateless_http=True, json_response=True) mcp.run(transport="streamable-http")
stateless_http=True이면 각 요청이 독립적이라 서버 인스턴스(레플리카)가 세션 상태를 공유하지 않아도 됩니다. 여러 레플리카로 수평 확장할 때 "세션을 못 찾음" 오류를 피할 수 있어, 로드 밸런서 뒤에 두기 좋습니다.
fastmcp 3.x에서는 mcp.run(transport="http", host=..., port=...) 형태로 HTTP 서버를 띄우고, CLI(fastmcp run --transport=streamable-http --host=0.0.0.0 --port=8080 server.py:mcp)로 host/port를 지정할 수 있습니다. 버전마다 옵션 이름이 다를 수 있으니 사용하는 패키지 문서를 확인하세요.| stateful | stateless | |
|---|---|---|
| 세션 ID | 서버가 발급, 이후 요청에 포함 | 없음 (각 요청 독립) |
| 구독·서버→클라이언트 스트림 | 자연스러움 | 제한적 |
| 수평 확장 | 세션 친화 라우팅 필요 | 레플리카 자유롭게 추가 |
| 적합 | 상태 유지가 본질인 서버 | 대규모·로드밸런싱 환경 |
HTTP 전송 특유의 보안
네트워크에 노출되는 순간, stdio에 없던 위험이 생깁니다.
🔒 반드시 챙길 것:
- Origin 헤더 검증: 서버는 들어오는 모든 연결의
Origin헤더(요청이 어느 웹 출처에서 왔는지 브라우저가 붙이는 표시)를 검증해 DNS 리바인딩 공격을 막아야 합니다. DNS 리바인딩은 공격자 웹페이지가 도메인의 IP를 슬쩍 사용자의 로컬 주소로 바꿔치기해, 브라우저를 통해 사용자 컴퓨터의 로컬 서버를 몰래 호출하게 만드는 수법입니다. (스펙의 MUST 요구)- 로컬 바인딩 주의: 로컬 개발이면
0.0.0.0이 아니라127.0.0.1에 바인딩해, 의도치 않게 외부에 노출되지 않게 합니다.- TLS: 원격 서버는 HTTPS로 서비스합니다. 평문 HTTP로 토큰을 흘리지 마세요.
- 인증: 원격 서버의 인증은 OAuth 2.1 기반이 표준입니다(7부).
- 세션 하이재킹: 세션 ID를 안전하게 발급·보관하고, 인증과 분리해 매 요청 검증합니다.
이 장에서 배운 것
- Streamable HTTP는 원격의 표준 전송으로, 단일 엔드포인트(POST+GET)에서 JSON 단일 응답 / SSE 스트림 / 202 Accepted 세 가지 모드를 서버가 골라 응답한다.
- 상태 유지가 필요하면 서버가 초기화 때
Mcp-Session-Id를 발급하고, 클라이언트는 이후 모든 요청에 포함한다. - stateless + json_response 조합은 수평 확장에 유리하다.
- HTTP는 Origin 검증·TLS·인증·세션 보호가 필수 — stdio엔 없던 책임이다.
✍️ 확인 문제
- 클라이언트가
tools/call을 POST했다. 서버가 응답을application/json단일 본문으로 줄지,text/event-stream으로 줄지는 누가, 무엇을 보고 정하나? - 레플리카 3개로 수평 확장하려는데 "session not found"가 뜬다. 어떤 설정이 도움이 될까? 그 트레이드오프는?
- Origin 헤더를 검증하지 않으면 어떤 공격에 노출되나? 한 단어로 답해 보자.
다음 장: 09. 무엇을 언제 쓰나 + 구(舊) SSE 호환