MCP 클라이언트 구축 — 발견, 호출, 세션 관리
대부분의 MCP(Model Context Protocol) 자료는 서버(server) 튜토리얼을 제공한 뒤 클라이언트(client)는 대충 넘어갑니다. 그러나 어려운 오케스트레이션(orchestration)은 클라이언트 코드 안에 있습니다. 프로세스 생성, 기능 협상(capability negotiation), 여러 서버의 도구 목록 병합, 샘플링 콜백(sampling callback), 재연결(reconnection), 이름공간 충돌(namespace collision) 해결이 모두 클라이언트의 책임입니다. 이 lesson에서는 서로 다른 MCP 서버 세 개를 모델이 사용할 수 있는 하나의 평평한 도구 이름공간으로 끌어올리는 멀티 서버 클라이언트를 만듭니다.
유형: Build
언어: Python (표준 라이브러리, 멀티 서버 MCP 클라이언트)
선수 학습: Phase 13 · 07 (MCP 서버 구축)
소요 시간: 약 75분
학습 목표
- MCP 서버를 자식 프로세스(child process)로 실행하고,
initialize를 완료한 뒤 notifications/initialized를 보냅니다.
- 서버별 세션 상태(session state)를 유지합니다. 여기에는 기능(capabilities), 도구 목록(tool list), 마지막으로 본 알림 ID(notification id)가 포함됩니다.
- 여러 서버의 도구 목록을 하나의 이름공간(namespace)으로 병합하고 충돌을 처리합니다.
- 도구 호출(tool call)을 해당 도구를 소유한 서버로 라우팅(routing)하고 응답을 다시 조립합니다.
문제
실제 에이전트 호스트(agent host)인 Claude Desktop, Cursor, Goose, Gemini CLI는 여러 MCP 서버를 한 번에 로드합니다. 사용자는 파일시스템 서버(filesystem server), Postgres 서버, GitHub 서버를 동시에 실행하고 있을 수 있습니다. 클라이언트가 해야 할 일은 다음과 같습니다.
- 각 서버를 실행합니다.
- 각 서버와 독립적으로 핸드셰이크(handshake)합니다.
- 각 서버에
tools/list를 호출하고 결과를 평평하게 만듭니다(flatten).
- 모델이
notes_search를 내보내면, 병합된 이름공간에서 이를 찾아 올바른 서버로 라우팅합니다.
- 어떤 서버에서 오든
tools/list_changed 같은 알림(notification)을 막힘 없이 처리합니다.
- 전송 계층(transport)이 실패하면 재연결합니다.
이 모든 것을 직접 구현할 수 있느냐가 "장난감"과 "실제로 쓸 만한 것"을 가릅니다. 공식 SDK는 이 과정을 감싸 주지만, 그 뒤에 있는 머릿속 모델(mental model)은 직접 가지고 있어야 합니다.
개념
자식 프로세스 생성
subprocess.Popen을 stdin=PIPE, stdout=PIPE, stderr=PIPE와 함께 사용합니다. 줄 단위 읽기를 위해 bufsize=1을 설정하고 텍스트 모드(text mode)를 사용합니다. 각 서버는 하나의 프로세스이며, 클라이언트는 서버마다 하나의 Popen 핸들을 가집니다.
서버별 세션 상태
서버마다 하나의 Session 객체가 다음 정보를 가집니다.
process — Popen 핸들입니다.
capabilities — 서버가 initialize에서 선언한 기능입니다.
tools — 마지막 tools/list 결과입니다.
pending — 요청 ID(request id)에서 응답을 기다리는 프라미스(promise) 또는 퓨처(future)로 이어지는 맵입니다.
요청은 본질적으로 비동기적(async)입니다. 서버 B가 호출 중이라고 해서 서버 A로 보낸 tools/call이 막히면 안 됩니다. 큐(queue)를 사용하는 스레드(thread)나 asyncio를 사용합니다.
병합된 이름공간
클라이언트가 집계된 도구 목록을 보면 이름이 충돌할 수 있습니다. 두 서버가 모두 search를 노출할 수 있습니다. 클라이언트에는 세 가지 선택지가 있습니다.
- 서버 이름으로 접두어를 붙입니다.
notes/search, files/search처럼 표시합니다. 명확하지만 다소 보기 좋지는 않습니다.
- 조용히 먼저 온 것을 사용합니다. 나중 서버의
search가 앞선 서버의 search를 덮어씁니다. 충돌을 숨기므로 위험합니다.
- 충돌을 거부합니다. 두 번째 서버 로드를 거부하고 사용자에게 알립니다. 보안에 민감한 호스트에서는 가장 안전합니다.
Claude Desktop은 서버 이름 접두어 방식을 사용합니다. Cursor는 명확한 오류와 함께 충돌을 거부합니다. VS Code MCP 역시 서버 이름 접두어 방식을 채택합니다.
라우팅
병합이 끝나면 디스패치 테이블(dispatch table)이 tool_name -> session을 매핑합니다. 모델은 이름으로 호출을 내보내고, 클라이언트는 해당 세션을 찾은 뒤 그 서버의 표준 입력(stdin)에 tools/call 메시지를 씁니다. 그런 다음 응답을 기다립니다.
샘플링 콜백
서버가 initialize에서 sampling 기능을 선언했다면, 서버는 클라이언트의 LLM을 실행해 달라고 sampling/createMessage를 보낼 수 있습니다. 클라이언트는 다음을 수행해야 합니다.
- 샘플(sample)이 해결될 때까지 해당 서버로 가는 추가 요청을 막습니다. 구현이 동시성(concurrency)을 지원한다면 파이프라인(pipeline)으로 처리할 수도 있습니다.
- 자신의 LLM 제공자(provider)를 호출합니다.
- 응답을 서버로 돌려보냅니다.
Lesson 11에서는 샘플링을 끝까지 다룹니다. 이 lesson에서는 완결성을 위해 이를 스텁(stub)으로만 둡니다.
알림 처리
notifications/tools/list_changed는 tools/list를 다시 호출해야 한다는 뜻입니다. notifications/resources/updated는 해당 리소스를 사용 중이라면 다시 읽어야 한다는 뜻입니다. 알림은 응답을 만들어서는 안 됩니다. 알림에 확인 응답(ack)을 보내려고 하면 안 됩니다.
흔한 클라이언트 버그는 tools/call을 처리하느라 읽기 루프(read loop)를 막아 둔 사이, 스트림(stream)에 알림이 쌓이는 것입니다. 서버 표준 출력(stdout)의 모든 메시지를 큐에 넣는 백그라운드 리더 스레드(background reader thread)를 사용하고, 메인 스레드는 큐에서 꺼내 디스패치합니다.
재연결
전송 계층은 실패할 수 있습니다. 서버가 크래시(crash)되거나, 운영체제가 프로세스를 종료하거나, 표준 입출력 파이프(stdio pipe)가 깨질 수 있습니다. 클라이언트는 stdout에서 파일 끝(EOF)을 감지하고 해당 세션을 죽은 상태로 간주합니다. 선택지는 다음과 같습니다.
- 서버를 조용히 다시 시작하고 다시 핸드셰이크합니다. 순수 읽기 전용 서버에는 괜찮습니다.
- 사용자에게 실패를 표시합니다. 사용자에게 보이는 세션을 가진 상태 저장(stateful) 서버에는 괜찮습니다.
Phase 13 · 09에서는 스트리머블 HTTP(Streamable HTTP)의 재연결 의미론(semantics)을 다룹니다. 표준 입출력(stdio)은 더 단순합니다.
Keepalive와 세션 ID
스트리머블 HTTP는 Mcp-Session-Id 헤더를 사용합니다. 표준 입출력에는 세션 ID가 없습니다. 프로세스의 정체성 자체가 세션입니다. Keepalive ping은 선택 사항입니다. 표준 입출력 파이프는 비활성 상태라고 해서 끊어지지 않습니다.
사용해 보기
code/main.py는 시뮬레이션된 MCP 서버 세 개를 서브프로세스처럼 실행하고, 각 서버와 핸드셰이크한 뒤, 도구 목록을 병합하고, 도구 호출을 올바른 서버로 라우팅합니다. 여기서 "서버"는 실제 LLM 없이 장난감 응답자(toy responder)를 실행하는 다른 Python 프로세스라고 생각하면 됩니다. 실행하면 다음을 볼 수 있습니다.
- 각자 다른 기능 집합(capability set)을 가진 세 번의 초기화(initialization)입니다.
- 세 개의
tools/list 결과가 7개 도구 이름공간으로 병합됩니다.
- 도구 이름을 기준으로 라우팅 결정을 내립니다.
- 이름공간 접두어(namespace prefixing)로 충돌을 방지합니다.
살펴볼 지점은 다음과 같습니다.
Session 데이터클래스(dataclass)는 서버별 상태를 깔끔하게 보관합니다.
- 백그라운드 리더 스레드는 메인 스레드를 막지 않고
stdout의 모든 줄을 큐에서 꺼낼 수 있게 합니다.
- 디스패치 테이블은 단순한
dict[str, Session]입니다.
- 충돌 처리는 명시적입니다. 두 서버가 같은 이름을 선언하면 나중에 온 도구는 접두어를 붙여 이름을 바꿉니다.
산출물 만들기
이 lesson은 outputs/skill-mcp-client-harness.md를 만듭니다. 선언형 MCP 서버 목록(이름, 명령, 인자)이 주어지면, 이 스킬(skill)은 서버를 실행하고, 도구 목록을 병합하고, 충돌 해결이 포함된 라우팅 함수를 제공하는 하네스(harness)를 만듭니다.
연습문제
-
code/main.py를 실행하고 서버 실행 로그를 관찰합니다. 시뮬레이션 서버 프로세스 중 하나를 SIGTERM으로 종료하고, 클라이언트가 EOF를 감지해 그 세션을 죽은 상태로 표시하는 방식을 관찰합니다.
-
이름공간 접두어 붙이기를 구현합니다. 두 서버가 search를 노출하면 두 번째 도구를 <server>/search로 이름 바꿉니다. 디스패치 테이블을 업데이트하고 도구 호출이 올바르게 라우팅되는지 확인합니다.
-
서버 재시작을 위한 연결 풀(connection pool) 스타일 백오프(backoff)를 추가합니다. 연속 실패에는 지수 백오프(exponential backoff)를 적용하고, 최대 30초로 제한하며, 세 번 실패한 뒤 사용자에게 알림을 내보냅니다.
-
100개의 동시 MCP 서버를 지원하는 클라이언트를 스케치합니다. 단순한 디스패치 딕셔너리 대신 어떤 자료구조를 사용해야 할까요? 힌트: 접두어 이름공간을 위한 트라이(trie)와 서버별 도구 수(tool-count-per-server) 메트릭(metric)을 함께 생각합니다.
-
클라이언트를 공식 MCP Python SDK로 포팅(porting)합니다. SDK는 stdio_client와 ClientSession을 감쌉니다. 멀티 서버 라우팅을 유지하면서 코드는 약 200줄에서 약 40줄로 줄어들어야 합니다.
핵심 용어
| 용어 | 흔한 설명 | 실제 의미 |
|---|
| MCP 클라이언트(MCP client) | "에이전트 호스트" | 서버를 실행하고 도구 호출을 오케스트레이션하는 프로세스 |
| 세션(Session) | "서버별 상태" | 기능, 도구 목록, 대기 중인 요청(pending request) 장부 |
| 병합된 이름공간(Merged namespace) | "하나의 도구 목록" | 활성 서버 전체에 걸친 평평한 도구 이름 집합 |
| 이름공간 충돌(Namespace collision) | "두 서버가 같은 도구를 가짐" | 클라이언트가 접두어를 붙이거나, 거부하거나, 먼저 온 것을 선택해야 하는 중복 |
| 라우팅(Routing) | "이 호출은 누가 받는가?" | 도구 이름에서 소유 서버로 보내는 디스패치 |
| 백그라운드 리더(Background reader) | "막히지 않는 stdout" | 서버 stdout을 큐로 비우는 스레드 또는 태스크 |
| 샘플링 콜백(Sampling callback) | "서비스로서의 LLM" | 서버의 sampling/createMessage를 처리하는 클라이언트 핸들러 |
notifications/*_changed | "프리미티브가 바뀜" | 클라이언트가 다시 발견하거나 다시 읽어야 한다는 신호 |
| 재연결 정책(Reconnection policy) | "서버가 죽었을 때" | 전송 계층 실패 시 다시 시작하는 의미론 |
| 표준 입출력 세션(Stdio session) | "프로세스 = 세션" | 세션 ID가 없고, 자식 프로세스 수명이 곧 세션임 |
더 읽을거리