콘텐츠로 이동

레거시 클라이언트 지원

기계 번역

이 페이지는 영어 문서를 자동으로 번역한 것이며, 영어 페이지가 기준이 되는 정식 버전입니다. 어색하거나 잘못된 부분이 있다면 번역 페이지에서 제보하는 방법을 확인하세요.

MCP에는 두 가지 프로토콜 시대가 있습니다. 사양 버전 2025-11-25까지의 initialize 핸드셰이크 시대와 현대 시대인 2026-07-28입니다. 이 구분 자체를 다루는 페이지는 프로토콜 버전입니다.

이 페이지는 그 구분의 서버 쪽을 다루며, 답은 한 문장이면 충분합니다. 이미 배포하고 있는 streamable_http_app()이 두 시대를 모두 지원합니다.

SDK는 모든 요청을 MCP-Protocol-Version 헤더에 따라 라우팅합니다. 2026-07-28 버전을 명시한 요청은 현대 핸들러로 갑니다. 핸드셰이크 시대의 버전을 명시한 요청이나 헤더가 아예 없는 요청(2026 이전 클라이언트의 initialize가 바로 이렇게 도착합니다)은 그런 클라이언트가 기대하는 트랜스포트로 갑니다. initialize 핸드셰이크, 세션 등 모든 것을 갖춘 트랜스포트입니다. 이 라우팅은 요청마다, 작성한 코드보다 먼저, 하나의 앱 안에서 일어납니다.

따라서 레거시 클라이언트는 따로 대비해서 만들어야 하는 대상이 아닙니다. 이미 작성한 서버에 접속해 오는 존재일 뿐입니다. 설정할 것은 아무것도 없습니다.

Note

말 그대로 아무것도 없습니다. legacy= 옵션도, 버전 허용 목록도, 특정 시대를 거부하거나 비활성화하는 방법도 없습니다. streamable_http_app()에도, run()에도, 세션 매니저에도 없습니다. 두 시대는 항상 켜져 있습니다. 그 시그니처에서 시대별 스위치에 가장 가까운 것은 stateless_http이며, 이 페이지의 대부분이 이 옵션을 다룹니다.

하나의 핸들러, 두 시대

다음은 사용자에게 무언가를 물어봐야 하는 도구와, 그 도구를 호출하는 두 시대의 클라이언트입니다.

server.py
from typing import Annotated

from pydantic import BaseModel

from mcp import Client
from mcp.client import ClientRequestContext
from mcp.server import MCPServer
from mcp.server.mcpserver import AcceptedElicitation, Elicit, ElicitationResult, Resolve
from mcp.types import ElicitRequestParams, ElicitResult

mcp = MCPServer("Bookshop")


class Quantity(BaseModel):
    copies: int


async def ask_quantity() -> Elicit[Quantity]:
    """Resolver: ask the user how many copies to put aside."""
    return Elicit("How many copies?", Quantity)


@mcp.tool()
async def reserve(title: str, quantity: Annotated[ElicitationResult[Quantity], Resolve(ask_quantity)]) -> str:
    """Reserve copies of a book, asking the user how many."""
    if isinstance(quantity, AcceptedElicitation):
        return f"Reserved {quantity.data.copies} of {title!r}."
    return "Nothing reserved."


async def answer(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
    return ElicitResult(action="accept", content={"copies": 2})


async def main() -> None:
    async with (
        Client(mcp, mode="legacy", elicitation_callback=answer) as legacy,
        Client(mcp, elicitation_callback=answer) as modern,
    ):
        for client in (legacy, modern):
            result = await client.call_tool("reserve", {"title": "Dune"})
            print(client.protocol_version, result.structured_content)

reserve에는 모델이 제공하지 않은 정보가 하나 필요합니다. 몇 권인지입니다. Annotated[..., Resolve(ask_quantity)]는 도구가 이를 선언하는 방법입니다(자세한 내용은 의존성에서 확인하세요). reserve 안에는 버전을 명시하거나, 기능을 확인하거나, 분기하는 코드가 전혀 없습니다.

두 클라이언트는 같은 mcp 객체에 동시에 열려 있습니다. mode="legacy"initialize 핸드셰이크를 실행합니다. 2026 이전 클라이언트가 여는 바로 그 연결입니다. 다른 하나는 기본값을 사용해 2026-07-28 버전으로 연결됩니다.

2025-11-25 {'result': "Reserved 2 of 'Dune'."}
2026-07-28 {'result': "Reserved 2 of 'Dune'."}

같은 서버, 같은 핸들러, 같은 답입니다. 이 기능은 이것이 전부입니다.

어떻게 가능한지는 잠시 짚고 넘어갈 만합니다. 두 클라이언트는 완전히 다른 두 가지 전송 경로를 통해 같은 질문을 받았기 때문입니다. 2026-07-28 연결에는 서버가 요청을 보낼 채널이 없으므로, Resolve는 질문을 도구 결과 안에 담아 반환했고 클라이언트는 답을 담아 호출을 재시도했습니다(다중 왕복 요청). 2025-11-25 연결에는 그런 것이 없습니다. 거기서는 Resolve가 호출 도중에 실제 elicitation/create 요청을 보내고 기다렸습니다. 둘 다 직접 작성한 것이 아닙니다. Resolve는 연결에서 협상된 버전을 읽고 방식을 고릅니다. 도구 본문은 어느 쪽이든 AcceptedElicitation을 받습니다.

Tip

이러한 시대 이식성이 바로 Resolve를 기반으로 삼아야 하는 이유입니다. 더 오래된 형제 격인 ctx.elicit() (엘리시테이션(elicitation))은 언제나 elicitation/create만 보내므로 레거시 연결에서만 동작합니다. 2026-07-28 연결에서는 호출이 실패합니다. 아직 이를 사용하는 도구가 있다면, 해결책은 버전 확인이 아니라 위에서 본 방식입니다.

레거시 세션의 비용

라우팅은 공짜입니다. 세션은 그렇지 않습니다.

2026-07-28 연결은 세션이 없습니다. 모든 요청이 독립적이며, 현대 핸들러는 Mcp-Session-Id를 발급하지 않습니다. 레거시 연결은 정반대입니다. 2026 이전 클라이언트가 initialize를 보내는 순간 SDK는 Mcp-Session-Id를 발급해 응답 헤더에 담아 돌려주고, 클라이언트의 이후 요청이 찾을 수 있도록 그 뒤에 살아 있는 기록을 유지합니다. 협상된 버전, 열린 스트림, 세션을 구동하는 백그라운드 작업이 그 기록입니다.

그 기록은 평범한 프로세스 내부 dict입니다. 분산 세션 저장소는 없으며 연결할 방법도 없습니다.

워커가 하나일 때는 보이지 않습니다. 둘이면 이것이 문제의 전부입니다. Mcp-Session-Id를 가진 요청이 그 ID를 발급하지 않은 워커에 도착하면 해당 dict에서 아무것도 찾지 못하고, 응답은 도구 결과가 아니라 404(Session not found)입니다. 따라서 워커를 둘 이상 실행하는 순간 레거시 클라이언트에는 스티키 라우팅이 필요합니다. 한 세션의 모든 요청은 그 세션을 시작한 프로세스에 도달해야 합니다. 현대 클라이언트는 그럴 필요가 전혀 없습니다. 고정될 세션 자체가 없기 때문입니다. 스티키 라우팅을 비롯해 둘 이상을 실행하는 데 관한 모든 내용은 배포와 확장에서 다룹니다.

Warning

event_store= 옵션은 해결책처럼 보이지만 아닙니다. 이것은 세션 저장소가 아니라 재개 기능(같은 세션에 다시 연결하는 클라이언트에게 놓친 SSE 이벤트를 재생하는 것)입니다. 다른 프로세스에서 세션에 도달할 수 있게 해 주는 일은 결코 없습니다.

유일한 옵션: stateless_http

스티키 라우팅이 치르기 싫은 비용이라면, 바꿀 수 있는 것은 정확히 하나입니다.

server.py
from typing import Annotated

from pydantic import BaseModel

from mcp.server import MCPServer
from mcp.server.mcpserver import AcceptedElicitation, Elicit, ElicitationResult, Resolve

mcp = MCPServer("Bookshop")


class Quantity(BaseModel):
    copies: int


async def ask_quantity() -> Elicit[Quantity]:
    """Resolver: ask the user how many copies to put aside."""
    return Elicit("How many copies?", Quantity)


@mcp.tool()
async def reserve(title: str, quantity: Annotated[ElicitationResult[Quantity], Resolve(ask_quantity)]) -> str:
    """Reserve copies of a book, asking the user how many."""
    if isinstance(quantity, AcceptedElicitation):
        return f"Reserved {quantity.data.copies} of {title!r}."
    return "Nothing reserved."


app = mcp.streamable_http_app(stateless_http=True)

페이지 맨 위의 서버에 키워드 하나를 더한 것입니다. stateless_http=True는 레거시 경로가 요청마다 쓰고 버리는 세션을 만들게 합니다. Mcp-Session-Id를 발급하지 않고 요청 사이에 아무것도 기억하지 않으므로, 어느 워커든 어느 요청이나 처리할 수 있고 로드 밸런서는 마음대로 분배할 수 있습니다.

이 옵션에 관해서는 무엇을 하는지보다 더 중요한 두 가지가 있습니다.

레거시 경로에만 영향을 줍니다. 요청은 stateless_http를 읽기 전에 버전 헤더로 라우팅되므로 현대 경로는 이 옵션을 보지 못합니다. 2026-07-28 연결은 이미 세션이 없으며 어느 값이든 완전히 똑같습니다.

그 경로에서 서버에서 클라이언트로 가는 두 채널을 모두 잃습니다. POST 하나 동안만 사는 세션에는 서버가 요청을 밀어 보낼 스트림도, 알림을 밀어 보낼 독립 스트림도 없습니다. 서버가 시작하는 모든 요청은 NoBackChannelError를 일으킵니다. ctx.elicit(), 이제 은퇴한 샘플링과 루트 호출(지원 중단 예정 기능), 그리고 Resolve레거시 클라이언트에게 질문하는 경우도 마찬가지입니다. 알림은 오류조차 나지 않고 조용히 버려집니다.

Note

json_response=True는 그 옵션이 아니지만, 모든 레거시 세션에서 같은 비용의 절반을 치릅니다. JSON 본문 하나로 응답하는 POST에는 요청 범위 채널을 위한 스트림이 없으므로, 요청 도중의 ctx.elicit()은 같은 NoBackChannelError를 일으키고 요청에 묶인 알림은 버려집니다. 세션의 독립 스트림은 영향을 받지 않으므로 관련 없는 알림은 여전히 도착합니다.

Check

일부러 잘못된 설정을 해 보세요. reserve는 방금 두 클라이언트를 모두 지원한 바로 그 도구입니다. stateless_http=True로 배포하고, 같은 두 클라이언트를 HTTP로 연결한 뒤, 각각에서 호출해 보세요.

현대 클라이언트는 여전히 Reserved 2 of 'Dune'.을 받습니다. 현대 경로는 바뀌지 않았습니다.

레거시 클라이언트의 호출은 모델이 읽을 수 있는 is_error 결과로 돌아오지 않습니다. 요청 전체가 최상위 프로토콜 오류로 실패합니다.

mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests.

Resolve도 구해 주지 못했습니다. 2025-11-25 연결에서는 elicitation/create반드시 보내야 하며, 그때 필요한 채널이 바로 stateless_http=True가 포기한 것입니다. 시대 이식성이 있는 코드라고 해서 백 채널이 필요 없는 코드인 것은 아닙니다.

따라서 이것은 실제 트레이드오프이며, 레거시 경로에만 존재합니다. 세션을 유지하고 스티키로 가거나, 상태 없이 단방향으로 가거나입니다. 도구가 클라이언트를 되불러 호출하는 일이 전혀 없다면 stateless_http=True는 공짜이니 선택하세요. 그런 일이 있다면 세션을 유지하고 라우팅도 스티키로 유지하세요.

코드가 실제로 갈라지는 지점

거의 없습니다.

도구, 리소스, 프롬프트, 구조화된 출력, 진행 상황, 오류 중 어느 것도 어느 시대가 호출했는지 신경 쓰지 않습니다. initialize 핸드셰이크, Mcp-Session-Id, 독립 스트림, 세션을 끝내는 DELETE는 모두 SDK가 소유하며 핸들러는 그 어느 것도 보지 못합니다. 대화형 입력은 두 시대가 전송 수준에서 실제로 다른 유일한 지점이며, 그것이 신경 쓸 문제가 되지 않도록 Resolve가 존재합니다. 방금 하나의 도구가 두 시대를 모두 지원하는 것을 보았습니다.

남은 것은 정확히 하나, 변경 알림입니다. 두 시대가 서로 다른 통로에서 듣기 때문입니다.

  • 2026-07-28 클라이언트는 subscriptions/listen 스트림을 열고 구독 버스를 읽습니다. ctx.notify_resource_updated()(그리고 notify_tools_changed(), notify_prompts_changed(), notify_resources_changed())는 거기에, 오직 거기에만 게시합니다. 자세한 내용은 구독에서 확인하세요.
  • 레거시 클라이언트는 세션이 열어 두는 독립 스트림을 읽습니다. ctx.session.send_resource_updated()(그리고 send_tool_list_changed() 등)는 요청을 실어 온 연결에 씁니다. 레거시 세션에서는 그것이 독립 스트림입니다. 현대 연결에는 이를 받을 곳이 없습니다. HTTP에서는 그런 채널이 없고, stdio에서는 네 가지 변경 알림이 subscriptions/listen 스트림으로만 전달되므로, 현대 연결에서는 알림이 조용히 버려집니다.

HTTP에서는 어느 호출도 다른 시대의 클라이언트에 도달하지 않습니다. 모두에게 알리려면 둘 다 호출하세요.

server.py
from mcp.server import MCPServer
from mcp.server.mcpserver import Context

mcp = MCPServer("Bookshop")

STOCK = {"Dune": 3}


@mcp.resource("stock://{title}")
def stock(title: str) -> str:
    """How many copies of one book are on the shelf."""
    return f"{STOCK[title]} in stock"


@mcp.tool()
async def restock(title: str, copies: int, ctx: Context) -> str:
    """Put copies of a book back on the shelf."""
    STOCK[title] = STOCK.get(title, 0) + copies
    await ctx.notify_resource_updated(f"stock://{title}")
    await ctx.session.send_resource_updated(f"stock://{title}")
    return f"{STOCK[title]} in stock"

두 줄, if도 버전 확인도 없이 끝입니다. 레거시 클라이언트가 존재한다는 이유로 핸들러가 다르게 하는 일은 이것이 전부입니다.

요약

  • 하나의 streamable_http_app()이 두 프로토콜 시대를 모두 지원합니다. SDK가 각 요청을 MCP-Protocol-Version 헤더에 따라 라우팅하며, 설정할 것도 찾아볼 시대별 옵션도 없습니다.
  • 레거시 클라이언트의 비용은 세션입니다. 뒤에 분산 저장소가 없는 프로세스 내부 Mcp-Session-Id 기록입니다. 워커가 둘 이상이면 스티키 라우팅이 필요하며, 그렇지 않으면 엉뚱한 워커가 404 Session not found로 응답합니다. 다중 워커에 관한 자세한 내용은 배포와 확장에서 확인하세요.
  • stateless_http=True가 유일한 옵션이며, 레거시 경로에만 적용됩니다. 레거시 클라이언트에 자유로운 로드 밸런싱을 제공하는 대신 그 경로에서 서버에서 클라이언트로 가는 두 채널을 모두 잃습니다. 서버가 시작하는 요청은 NoBackChannelError를 일으키고(클라이언트에서는 is_error 결과가 아니라 최상위 오류), 알림은 버려집니다.
  • 2026-07-28 연결은 어느 쪽이든 세션이 없습니다. stateless_http는 이 연결을 건드리지 않습니다.
  • 핸들러 코드가 시대에 따라 갈라지는 곳은 정확히 한 군데, 변경 알림입니다. ctx.notify_* 계열은 subscriptions/listen 클라이언트에 도달하고, ctx.session.send_* 계열은 레거시 세션에 도달합니다. 둘 다 호출하세요.
  • 그 밖의 모든 것(Resolve를 통해 사용자에게 입력을 요청하는 것 포함)은 설계상 시대 이식성을 갖습니다. 현대 방식으로 한 번만 작성하세요.