17. 클라이언트가 서버를 발견·연결·사용하는 법

🎯 이 장의 목표
  • Python으로 stdio·HTTP 서버에 붙는 최소 클라이언트를 작성한다
  • 발견(list)→호출(call) 흐름과 결과 처리를 이해한다
  • 클라이언트가 서버의 sampling·elicitation 요청을 처리하는 콜백 구조를 안다

5부에서 서버를 만들었습니다. 이제 반대편 — 그 서버를 쓰는 클라이언트를 봅니다. 직접 호스트(데스크톱 앱 등)를 만들 일은 드물지만, 클라이언트 코드를 이해하면 "내 서버가 상대에게 어떻게 보이는지", "왜 도구 설명이 중요한지"가 분명해집니다. 서버 테스트용 미니 클라이언트로도 유용합니다.

클라이언트의 일

02장에서 클라이언트는 "서버 하나와 1:1로 말하는 연결 담당"이라 했습니다. 구체적으로 클라이언트는: (1) 서버에 연결(전송 띄우기), (2) 핸드셰이크(initialize), (3) 능력 발견(list_tools 등), (4) 호출(call_tool 등), (5) 서버의 역방향 요청(sampling·elicitation) 처리, (6) 종료를 담당합니다.

sequenceDiagram
    participant App as 클라이언트 코드
    participant T as 전송(stdio/HTTP)
    participant S as 서버
    App->>T: 연결 (서버 spawn 또는 URL)
    App->>S: initialize → initialized
    App->>S: list_tools / list_resources / list_prompts
    S-->>App: 능력 목록
    App->>S: call_tool(name, args)
    S-->>App: 결과 (content, isError)
    Note over App,S: (서버가 sampling/elicitation 요청 시 콜백으로 처리)
    App->>T: 종료 (스트림 닫기)

stdio 서버에 붙기

공식 mcp SDK 기준 최소 클라이언트입니다. stdio_client가 서버를 자식 프로세스로 띄우고 read/write 스트림을 주면, ClientSession이 그 위에서 프로토콜을 말합니다.

PYTHON
# client.py  —  공식 mcp SDK
import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

# 서버를 어떻게 띄울지 (07장의 "실행 명령 등록"과 같은 정보)
server_params = StdioServerParameters(
    command="python",          # 실행 파일
    args=["server.py"],        # 인자
    env=None,                  # 환경 변수 (비밀값 주입 지점)
)

async def main():
    async with stdio_client(server_params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()                 # 핸드셰이크 (05장)

            # 발견
            tools = (await session.list_tools()).tools
            print("도구:", [t.name for t in tools])

            # 호출
            result = await session.call_tool("add", {"a": 3, "b": 4})
            print("결과:", result.content[0].text)      # "7"

            # 리소스 읽기 (11장)
            greeting = await session.read_resource("greeting://Alice")
            # 프롬프트 가져오기 (12장)
            prompt = await session.get_prompt("greet_user", {"name": "Alice"})

asyncio.run(main())

흐름을 2~5부]와 연결해 보면: initialize는 [05장 핸드셰이크, list_tools/call_tool10장, read_resource11장, get_prompt12장 그대로입니다. SDK가 JSON-RPC 메시지를 만들어 주니, 클라이언트 코드는 메서드 호출처럼 보입니다.

💡 팁
결과는 블록 배열: call_tool 결과의 content는 블록 리스트입니다(텍스트·이미지 등). 텍스트는 result.content[0].text로 꺼냅니다. result.isError로 실행 에러 여부를 확인하세요(10장). 구조화 출력이 있으면 result.structuredContent도 봅니다.

HTTP 서버에 붙기

원격 서버는 streamablehttp_client를 쓰고 URL을 줍니다. 반환값이 세 개(read, write, get_session_id)인 점만 다르고, 그 뒤는 동일합니다.

PYTHON
# http_client.py  —  공식 mcp SDK
import asyncio
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client

URL = "http://127.0.0.1:8000/mcp"

async def main():
    async with streamablehttp_client(URL) as (read, write, get_session_id):
        async with ClientSession(read, write) as session:
            await session.initialize()
            if (sid := get_session_id()) is not None:
                print("세션 ID:", sid)          # Mcp-Session-Id (08장)
            tools = (await session.list_tools()).tools
            result = await session.call_tool("add", {"a": 2, "b": 5})
            print(result.content)

asyncio.run(main())
📌 핵심
전송만 다르고 나머지는 같다: 09장의 "전송 무관성"이 클라이언트에서도 똑같이 보입니다. stdio_clientstreamablehttp_client만 교체하면, ClientSession 이후 코드는 거의 동일합니다. 인증이 필요한 원격 서버라면 헤더에 토큰을 실어 보냅니다(7부).
PYTHON
# 인증 헤더 예 (원격)
headers = {"Authorization": f"Bearer {TOKEN}"}
async with streamablehttp_client(URL, headers=headers) as (read, write, _):
    ...

페이지네이션 — 도구·리소스가 많을 때

서버가 도구나 리소스를 수백 개 노출하면, 목록은 커서 기반으로 페이지를 나눠 옵니다. 한 번에 다 못 받으면 nextCursor를 따라 반복합니다.

PYTHON
all_resources = []
cursor = None
while True:
    page = await session.list_resources(cursor=cursor)  # 버전에 따라 params 형태 다를 수 있음
    all_resources.extend(page.resources)
    if page.nextCursor:
        cursor = page.nextCursor
    else:
        break
💡 팁
API 시그니처(파라미터 형태)는 SDK 버전에 따라 다를 수 있으니, 사용하는 버전의 예제를 확인하세요. 핵심 개념은 "nextCursor가 없을 때까지 반복"입니다.

서버의 역방향 요청 처리 — sampling 콜백

13장에서 본 sampling은 서버가 클라이언트에 요청하는 역방향입니다. 클라이언트가 이를 처리하려면, 세션 생성 시 콜백을 등록합니다. 이 콜백이 "서버가 모델 추론을 요청하면 어떻게 응답할지"를 정합니다.

PYTHON
from mcp import ClientSession, types

async def handle_sampling(message: types.CreateMessageRequestParams) -> types.CreateMessageResult:
    # 여기서 실제 LLM API를 호출하고 (보통 사용자 승인 후) 결과를 돌려준다
    return types.CreateMessageResult(
        role="assistant",
        content=types.TextContent(type="text", text="모델이 생성한 응답"),
        model="some-model",
        stopReason="endTurn",
    )

async with ClientSession(read, write, sampling_callback=handle_sampling) as session:
    await session.initialize()
    # 이제 세션이 서버의 sampling/createMessage 요청을 처리할 수 있다
🔒 콜백은 신뢰 경계다: sampling 콜백은 서버가 보낸 프롬프트를 호스트 LLM에 전달합니다. 서버가 악의적이면 이 프롬프트에 주입을 심을 수 있으므로(21장), 콜백 안에서 사용자 승인과 내용 검토를 거치는 게 안전합니다. 클라이언트가 sampling capability를 광고했기에 서버가 요청할 수 있는 것이니, 광고 자체를 신중히 결정하세요.

elicitation도 비슷하게 클라이언트가 처리 콜백/핸들러를 제공해 사용자에게 입력 UI를 띄우고 accept/decline/cancel을 돌려줍니다.

여러 서버 다루기

02장에서 "클라이언트 1개 = 서버 1개"라 했습니다. 여러 서버를 쓰려면 세션을 여러 개 만들어 유지합니다. 각 연결을 맺은 뒤 ClientSession을 보관·재사용하면 됩니다.

PYTHON
sessions = {}
# 각 서버마다 (stdio_client/streamablehttp_client → ClientSession)를 열어 보관
sessions["files"] = file_session
sessions["github"] = gh_session
# 사용할 때 적절한 세션을 골라 호출
await sessions["github"].call_tool("search_issues", {"query": "bug"})
⚠️ 흔한 실수
수명 관리: 여러 비동기 컨텍스트(전송·세션)를 안전하게 열고 닫으려면 contextlib.AsyncExitStack을 쓰면 편합니다. 종료 시 한 번에 정리할 수 있어 누수를 막습니다.

이 장에서 배운 것

  • 클라이언트는 연결 → initialize → 발견(list) → 호출(call) → (역방향 요청 처리) → 종료를 담당한다.
  • stdio는 stdio_client+StdioServerParameters, HTTP는 streamablehttp_client(read, write, get_session_id). 그 뒤 ClientSession 코드는 거의 동일하다(전송 무관성).
  • 결과는 content 블록 배열 + isError(+structuredContent). 목록이 크면 커서 페이지네이션.
  • sampling/elicitation은 콜백으로 처리하며 신뢰 경계다 — 사용자 승인·검토를 권장. 여러 서버는 세션을 여러 개 유지한다.

✍️ 확인 문제

  1. stdio 클라이언트를 HTTP 클라이언트로 바꾸려면 무엇을 교체하나? ClientSession 이후 코드는 얼마나 바뀌나?
  2. call_tool 결과에서 텍스트 결과와 "실행 실패 여부"는 각각 어디서 확인하나?
  3. 서버가 sampling을 요청할 수 있으려면 클라이언트가 무엇을 해야 하고, 그 처리에 왜 사용자 승인이 권장되나?
다음 장: 18. 호스트 통합과 컨텍스트 비용, 여러 서버 다루기