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이 그 위에서 프로토콜을 말합니다.
# 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_tool은 10장, read_resource는 11장, get_prompt은 12장 그대로입니다. SDK가 JSON-RPC 메시지를 만들어 주니, 클라이언트 코드는 메서드 호출처럼 보입니다.
call_tool 결과의 content는 블록 리스트입니다(텍스트·이미지 등). 텍스트는 result.content[0].text로 꺼냅니다. result.isError로 실행 에러 여부를 확인하세요(10장). 구조화 출력이 있으면 result.structuredContent도 봅니다.HTTP 서버에 붙기
원격 서버는 streamablehttp_client를 쓰고 URL을 줍니다. 반환값이 세 개(read, write, get_session_id)인 점만 다르고, 그 뒤는 동일합니다.
# 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())
stdio_client ↔ streamablehttp_client만 교체하면, ClientSession 이후 코드는 거의 동일합니다. 인증이 필요한 원격 서버라면 헤더에 토큰을 실어 보냅니다(7부).# 인증 헤더 예 (원격) headers = {"Authorization": f"Bearer {TOKEN}"} async with streamablehttp_client(URL, headers=headers) as (read, write, _): ...
페이지네이션 — 도구·리소스가 많을 때
서버가 도구나 리소스를 수백 개 노출하면, 목록은 커서 기반으로 페이지를 나눠 옵니다. 한 번에 다 못 받으면 nextCursor를 따라 반복합니다.
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
nextCursor가 없을 때까지 반복"입니다.서버의 역방향 요청 처리 — sampling 콜백
13장에서 본 sampling은 서버가 클라이언트에 요청하는 역방향입니다. 클라이언트가 이를 처리하려면, 세션 생성 시 콜백을 등록합니다. 이 콜백이 "서버가 모델 추론을 요청하면 어떻게 응답할지"를 정합니다.
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을 보관·재사용하면 됩니다.
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은 콜백으로 처리하며 신뢰 경계다 — 사용자 승인·검토를 권장. 여러 서버는 세션을 여러 개 유지한다.
✍️ 확인 문제
- stdio 클라이언트를 HTTP 클라이언트로 바꾸려면 무엇을 교체하나?
ClientSession이후 코드는 얼마나 바뀌나? call_tool결과에서 텍스트 결과와 "실행 실패 여부"는 각각 어디서 확인하나?- 서버가 sampling을 요청할 수 있으려면 클라이언트가 무엇을 해야 하고, 그 처리에 왜 사용자 승인이 권장되나?
다음 장: 18. 호스트 통합과 컨텍스트 비용, 여러 서버 다루기