3부 10장. stdio에서 Streamable HTTP로
3부 10장. stdio에서 Streamable HTTP로
지금까지의 PKM-MCP는 꽤 예의 바른 서버였다. Claude Desktop이나 Inspector가 실행해주면 조용히 켜지고, 일이 끝나면 같이 꺼졌다. 자기 방에서만 노는 착한 아이 같았다.
그런데 팀에서 같이 쓰기 시작하는 순간 분위기가 달라진다. 이제 서버는 누군가의 자식 프로세스가 아니라, 스스로 살아 있는 네트워크 서비스가 된다. 이 변화는 단순히 실행 명령 하나를 바꾸는 일이 아니다. 수명 주기, 인증, 확장성, 발견 가능성, 운영 방식이 한꺼번에 바뀐다.1
이 장의 핵심 문장은 하나다.
개인 장난감 서버와 팀이 쓰는 서버의 경계는 전송 방식이 바뀌는 순간부터 시작된다.
다행히도 겁먹을 필요는 없다. 좋은 소식부터 말하자면, 우리가 2부에서 공들여 만든 search_notes, semantic_search_notes, create_note, note://{id}, 이번 주 메모 요약 같은 기능은 거의 그대로 가져갈 수 있다. 바뀌는 것은 대체로 운반 수단이다. 내용물을 갈아엎는 것이 아니라, 손수레를 트럭으로 바꾸는 쪽에 가깝다.1

10.1 SSE는 왜 폐기되었나
원격 MCP 서버를 처음 다루던 시기에는 HTTP+SSE 방식이 중요한 선택지였다. 구조는 이랬다. 클라이언트는 한쪽에서는 HTTP POST로 메시지를 보내고, 다른 한쪽에서는 SSE 스트림을 열어 서버의 응답을 받는다. 얼핏 보면 꽤 합리적이다. 웹 인프라 위에서 돌아가고, 서버가 클라이언트에게 뭔가를 밀어 넣는 것도 가능하니까 말이다.
문제는 이 구조가 운영 단계에서 자꾸 삐걱거렸다는 데 있다.2 연결이 두 갈래라는 것은 곧 고장날 곳도 두 군데라는 뜻이다. POST는 살아 있는데 SSE 스트림이 끊길 수 있고, 반대로 스트림은 붙어 있는데 세션 라우팅이 어긋날 수도 있다. 프록시와 로드밸런서는 장시간 유지되는 연결을 썩 좋아하지 않았고, 서버리스 환경은 더 냉정했다. "오래 붙어 있네? 끊자." 하고 잘라버리기 딱 좋은 구조였다.2
특히 원격 MCP 서버에서 치명적이었던 문제는 세 가지였다.
연결이 길게 유지되어야 해서 인프라가 까다로웠다.
요청 채널과 응답 채널이 분리되어 세션 관리가 복잡했다.
중간에 연결이 끊겼을 때 이어받기와 재개가 어색했다.2
그래서 MCP 생태계는 원격 전송의 중심을 Streamable HTTP로 옮겼다. 새 방식은 "HTTP를 버리고 다른 걸 쓰자"가 아니다. 오히려 더 HTTP답게 간다. 하나의 엔드포인트로 요청을 받고, 상황에 따라 일반 JSON으로 짧게 응답하거나, 여러 메시지가 필요할 때만 스트리밍 응답을 연다.1
여기서 꼭 짚고 넘어갈 오해가 있다. SSE가 완전히 사라진 것은 아니다. 사라진 것은 "원격 전송 방식으로서의 독립적인 HTTP+SSE 운용 모델"에 가깝다. Streamable HTTP 안에서는 여전히 SSE가 응답 형식으로 사용될 수 있다. 즉, 무대에서 내려간 것은 배우가 아니라 배역이다. 예전에는 SSE가 전송 방식 자체의 주인공이었고, 이제는 필요할 때만 등장하는 조연이 되었다고 이해하면 쉽다.12
실무 감각으로 다시 말해보자. stdio는 같은 컴퓨터 안에서 파이프로 대화하는 방식이고, Streamable HTTP는 네트워크를 통해 정식 출입문으로 드나드는 방식이다. 전자는 빠르고 단순하지만 집 안에서만 통한다. 후자는 문단속과 안내판과 경비 체계까지 필요하지만, 대신 여러 사람이 함께 쓸 수 있다.
그리고 2026년 기준으로 새 원격 서버를 만든다면, 출발점은 사실상 Streamable HTTP라고 보는 편이 안전하다.1
10.2 stateless 서버 만들기 — 로드밸런서 뒤에서 살아남기
stdio 서버는 연결 하나와 프로세스 하나가 거의 붙어 다닌다. 그래서 "지금 이 메모리는 이 클라이언트 것"이라는 가정이 자연스럽다. 하지만 HTTP 서버는 다르다. 어떤 요청은 1번 인스턴스로 오고, 그다음 요청은 2번 인스턴스로 갈 수 있다. 로드밸런서는 사람 마음을 잘 모른다. 그냥 바쁜 곳을 피해 트래픽을 나눌 뿐이다.
바로 여기서 stateless라는 말이 중요해진다. stateless 서버란 "아무 상태도 없는 서버"가 아니다. 정확히는 정답을 만들기 위해 서버 프로세스 메모리에 의존하지 않는 서버에 가깝다.13
예를 들어 PKM-MCP가 다음 데이터를 외부 저장소에 두고 있다고 해보자.
실제 노트 본문은
data/notes폴더나 객체 저장소메타데이터와 사용자 정보는 SQLite 또는 PostgreSQL
임베딩과 벡터 검색 인덱스는 Chroma나 Pinecone
이 상태에서 search_notes("oauth") 요청이 들어오면, 어느 인스턴스가 받든 결과를 만들 수 있다. 필요한 진실이 전부 바깥에 있기 때문이다. 반대로 아래와 같은 설계는 금방 문제를 일으킨다.
노트 목록을 프로세스 시작 시 한 번만 읽어 메모리에 고정해두는 방식
다음 노트 ID를 전역 변수로 증가시키는 방식
직전 사용자 컨텍스트를 파이썬 딕셔너리에만 저장해두는 방식
이런 설계는 한 대에서는 멀쩡해 보여도, 두 대를 띄우는 순간 정체가 드러난다. 8000번 포트 서버는 노트가 101개라고 알고, 8001번 포트 서버는 99개라고 아는 웃픈 상황이 벌어진다. 둘 다 자신감은 넘치는데, 둘 중 하나는 틀렸다.
Streamable HTTP에서는 구현에 따라 세션 헤더를 써서 상태를 유지하는 모드도 가능하다. 하지만 수평 확장과 서버리스 배포까지 생각하면, 가능한 한 stateless를 기본값으로 두는 편이 운영이 쉽다는 것이 여러 가이드의 공통된 조언이다.13
PKM-MCP를 stateless하게 만들 때 기억할 규칙은 단순하다.
첫째, 정답의 출처는 외부 저장소여야 한다. 메모리는 캐시일 수는 있어도 진실의 원본이면 안 된다.
둘째, 쓰기 작업은 재시도에 견디도록 설계해야 한다. 네트워크 세계에서는 같은 요청이 한 번 더 올 수 있다. create_note나 import_github_issue 같은 도구는 중복 실행을 어떻게 막을지 미리 정해야 한다.
셋째, 인스턴스가 바뀌어도 같은 입력이면 같은 결과가 나와야 한다. 이 감각이 생기면 이미 절반은 성공이다.
물론 대가도 있다. 일부 SDK의 stateless 모드에서는 서버가 세션을 붙잡고 클라이언트에게 능동적으로 알림을 밀어 넣는 기능이 제한될 수 있다.1 하지만 대부분의 PKM 도구 호출은 "요청하면 응답한다"는 패턴이므로, 3부 초반의 PKM-MCP에는 이 제약이 큰 문제가 되지 않는다. 지금은 민첩함이 더 중요하다. 화려한 묘기보다, 어디에 올려도 죽지 않는 체력이 먼저다.
10.3 .well-known과 MCP Server Cards
stdio 시대에는 설명이 단순했다. "이 명령어 실행하세요." 끝이다. 하지만 HTTP 서버는 다르다. 누군가 우리 서버의 URL만 보고도 이런 정보를 알고 싶어 한다.
이 서버 이름이 뭔가?
어느 엔드포인트로 접속해야 하나?
인증이 필요한가?
Tools, Resources, Prompts를 대략 지원하나?
예전에는 이런 정보를 얻으려면 일단 연결하고 initialize를 해봐야 했다. 그러다 보니 레지스트리, 크롤러, 클라이언트 자동 설정이 모두 번거로웠다. 그래서 MCP 로드맵은 .well-known URL을 통한 Server Card 공개를 중요한 우선순위로 잡고 있다.3
Server Card는 말 그대로 서버 소개 카드다. 웹사이트의 robots.txt, 앱의 manifest.json 같은 것을 떠올리면 된다. 정식 연결 전에 "나는 이런 서버이고, 여기로 들어오면 되고, 이런 인증을 기대한다"를 미리 알려주는 문서다.4
초기 제안에서는 HTTP 기반 서버가 /.well-known/mcp/server-card.json 같은 경로에 카드를 공개하는 방식이 논의되었다.4 세부 경로와 스키마는 발전 중이지만, 개념은 분명하다. HTTP MCP 서버는 발견 가능해야 한다. 누군가 우리 도메인을 보았을 때, 최소한 어디 문인지 정도는 바로 알 수 있어야 한다.
PKM-MCP용 Server Card 예시는 이런 느낌이다.
{
"$schema": "https://static.modelcontextprotocol.io/schemas/mcp-server-card/v1.json",
"version": "1.0",
"protocolVersion": "2025-06-18",
"serverInfo": {
"name": "pkm-mcp",
"title": "PKM-MCP",
"version": "0.10.0"
},
"description": "Personal knowledge MCP server for note search, CRUD, and semantic retrieval.",
"transport": {
"type": "streamable-http",
"endpoint": "/mcp"
},
"capabilities": {
"tools": {
"listChanged": true
},
"resources": {
"listChanged": true
},
"prompts": {
"listChanged": true
}
},
"authentication": {
"required": false,
"schemes": []
},
"tools": ["dynamic"],
"resources": ["dynamic"],
"prompts": ["dynamic"]
}여기서 dynamic이라는 표기는 꽤 영리하다. PKM-MCP는 나중에 사용자 권한, OAuth 스코프, 실험 기능 플래그에 따라 보이는 도구가 달라질 수 있다. 이런 서버에서 모든 도구 목록을 정적 JSON에 박아두면 금방 낡는다. 그럴 때는 "목록은 동적으로 발견해라"라고 말하는 편이 정직하다.4
Server Card가 주는 이점은 작지만 강력하다. 클라이언트는 초기 설정을 더 자동화할 수 있고, 레지스트리는 서버를 더 쉽게 수집할 수 있으며, 운영자는 문서와 실제 엔드포인트가 어긋나는 일을 줄일 수 있다. 서버 문 앞에 명패를 붙이는 일은 사소해 보이지만, 팀 서버가 되는 순간 꽤 큰 차이를 만든다.
만들기: PKM-MCP를 HTTP 모드로 전환하고 로컬에서 두 인스턴스 띄우기
이제 진짜로 PKM-MCP를 stdio에서 Streamable HTTP로 옮겨보자. 핵심은 "서버를 다시 쓰는 것"이 아니라 "기존 기능을 다른 운반 수단에 태우는 것"이다.1
우선 마인드셋부터 바꾸자. 지금부터 PKM-MCP는 Claude Desktop이 필요할 때마다 잠깐 불러내는 조수가 아니다. 항상 켜져 있고, 여러 클라이언트가 접속할 수 있으며, 어느 순간에는 두 대 이상으로 늘어날 수도 있는 서비스다.
기존 도구 정의는 그대로 둔다
우리가 이미 만들어둔 기능은 유지한다.
search_notessemantic_search_notescreate_note,update_note,delete_notenote://{id}리소스이번 주 메모 요약프롬프트import_github_issues_as_notes같은 외부 연동 도구
바꾸는 것은 실행 진입점이다. 예를 들어 pkm_mcp/server.py를 아래처럼 정리할 수 있다.
import os
import sys
from mcp.server.fastmcp import FastMCP
from pkm_mcp.tools import register_tools
from pkm_mcp.resources import register_resources
from pkm_mcp.prompts import register_prompts
mcp = FastMCP(
"pkm-mcp",
stateless_http=True,
)
register_tools(mcp)
register_resources(mcp)
register_prompts(mcp)
def main() -> None:
host = os.getenv("HOST", "0.0.0.0")
port = int(os.getenv("PORT", "8000"))
if "--http" in sys.argv:
mcp.run(transport="streamable-http", host=host, port=port)
else:
mcp.run(transport="stdio")
if __name__ == "__main__":
main()이 코드의 좋은 점은 기존 등록 함수가 그대로라는 것이다. Tool, Resource, Prompt 정의를 다시 쓰지 않는다. 이 장에서의 변화는 거의 전부 mcp.run(...)과 서버 실행 방식에 집중되어 있다.1
진짜 상태를 프로세스 밖으로 밀어낸다
이제 설정값도 점검하자. 다음 정보는 인메모리 전역 변수보다 명시적인 환경 변수와 외부 저장소 쪽에 두는 편이 좋다.
export PKM_NOTES_DIR=./data/notes
export PKM_SQLITE_PATH=./data/pkm.db
export CHROMA_PATH=./data/chroma중요한 것은 "어디에 저장하느냐"보다 "두 인스턴스가 같은 진실을 바라보느냐"다. 예를 들어 8000번 서버와 8001번 서버가 서로 다른 data/notes 폴더를 본다면, 둘은 같은 PKM-MCP가 아니다. 이름만 같은 사촌이다.
첫 번째 인스턴스를 띄운다
첫 번째 터미널에서 이렇게 실행한다.
PORT=8000 uv run python -m pkm_mcp.server --http이제 PKM-MCP는 대체로 http://localhost:8000/mcp 같은 엔드포인트에서 응답할 준비를 한다.
두 번째 인스턴스를 띄운다
두 번째 터미널을 열고 같은 서버를 다른 포트로 한 번 더 띄운다.
PORT=8001 uv run python -m pkm_mcp.server --http이 순간이 중요하다. 예전 같으면 "같은 서버를 두 번 띄운다고?" 하고 조금 어색했을 것이다. 하지만 HTTP 서버 운영에서는 오히려 이게 자연스럽다. 둘은 경쟁자가 아니라 같은 역할을 나눠 가진 동료다.
최소한의 스모크 테스트를 한다
가장 쉬운 테스트는 Inspector나 HTTP를 지원하는 클라이언트로 각각 붙어보는 것이다. 먼저 8000번에 연결해서 tools/list와 search_notes를 시험하고, 그다음 8001번에서도 똑같이 해본다. 둘 다 같은 도구 목록을 내놓고, 같은 검색어에 대해 같은 저장소 기준의 결과를 돌려줘야 한다.
직접 HTTP가 살아 있는지 확인하고 싶다면, 간단한 초기화 요청을 날려볼 수도 있다.
curl -X POST http://localhost:8000/mcp \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {
"name": "smoke-test",
"version": "0.1.0"
}
}
}'같은 요청을 8001번에도 보내보자.
curl -X POST http://localhost:8001/mcp \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {
"name": "smoke-test",
"version": "0.1.0"
}
}
}'이 테스트는 자동차로 치면 시동이 걸리는지 보는 정도다. 완전한 주행 테스트는 아니다. 하지만 적어도 "문이 열리는지"는 확인할 수 있다.
여기서 많이 드러나는 문제들
두 인스턴스를 띄우면 숨어 있던 설계 문제가 잘 보인다.
8000번에서는 검색이 되는데 8001번에서는 최신 메모가 안 보인다면, 어딘가에 프로세스 로컬 캐시를 진실처럼 쓰고 있을 가능성이 크다.
create_note를 번갈아 호출했더니 ID 충돌이 난다면, 노트 ID 생성 로직이 메모리 전역 변수에 묶여 있을 가능성이 높다.한쪽 인스턴스만 벡터 검색이 잘 된다면, 임베딩 인덱스가 공용 저장소가 아니라 로컬 임시 폴더에 놓였을 수 있다.
좋은 소식은, 이런 문제들이 지금 보인다는 것이다. 로컬 두 인스턴스 실험은 작은 운영 리허설이다. 나중에 클라우드에서 처음 보느니, 지금 보는 편이 훨씬 싸다. 아주 많이 싸다.
정리
이번 장에서 우리는 PKM-MCP를 "내 컴퓨터에서만 통하는 로컬 도구"에서 "네트워크를 통해 접근하는 서비스"로 바라보는 관점을 배웠다.
핵심만 다시 붙잡아보자.
HTTP+SSE가 뒤로 물러난 이유는 기술 유행이 바뀌어서가 아니라, 운영 현실에서 너무 많은 마찰을 만들었기 때문이다.2
Streamable HTTP의 강점은 하나의 엔드포인트, 더 단순한 배포 모델, 그리고 stateless 운영과의 궁합에 있다.13
stateless 설계의 본질은 "상태를 없애는 것"이 아니라 "프로세스 메모리에 진실을 두지 않는 것"이다.
Server Card와 .well-known은 서버를 사람뿐 아니라 클라이언트와 레지스트리에게도 이해 가능한 대상으로 만든다.34
그리고 무엇보다 중요하게, Transport가 바뀌어도 Tool, Resource, Prompt의 본질은 바뀌지 않는다. 우리가 2부에서 정성껏 만든 기능들은 헛수고가 아니었다. 이제 그 기능들이 집 밖으로 나갈 준비를 마쳤을 뿐이다.
다음 장에서는 이 문 앞에 진짜 자물쇠를 단다. Streamable HTTP가 문이라면, OAuth 2.1은 출입 통제다. 이제 PKM-MCP는 혼자 쓰는 메모 검색기를 넘어, 사용자별로 안전하게 분리되는 원격 서비스로 진화할 차례다.
참고
1Migrating Your MCP Server from stdio to Streamable HTTP: A Step-by-Step Guide
2[Server] MCP 서버 프로토콜, SSE에서 Streamable HTTP 방식으로의 대변경
3Roadmap - Model Context Protocol
4SEP-1649: MCP Server Cards - HTTP Server Discovery via .well-known
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.