-
MCP Roots 완전 분해: 서버가 클라이언트에게 먼저 묻는 역방향 설계IT 2026. 6. 13. 23:00
MCP를 처음 배울 때 가장 충격적인 발견 중 하나가 있다. 서버가 클라이언트에게 요청을 보낸다는 것이다. 일반적인 클라이언트-서버 모델에서는 클라이언트가 요청하고 서버가 응답한다. 그런데 MCP에는 이 방향이 뒤집힌 메서드가 있다.
roots/list가 그 하나다. 서버가 클라이언트에게 "너는 어떤 파일시스템 경로에 접근 권한이 있니?"를 묻는다. 이 글은 roots/list가 왜 필요한지, 어떻게 동작하는지, 그리고 이 역방향 설계가 MCP 전체에서 어떤 의미를 갖는지를 다룬다.역방향이 필요한 이유 — 서버는 클라이언트를 모른다
파일시스템을 읽는 MCP 서버를 만든다고 가정하자.
resources/list로 파일 목록을 노출하고resources/read로 내용을 제공한다. 그런데 어느 경로를 노출해야 할까?하드코딩하면?
/home/user/projects라고 박아두면 사용자마다 경로가 다를 수 있다. 설정 파일로 받으면? 서버를 띄울 때 경로를 알아야 하는데, 사용자가 IDE에서 어떤 프로젝트를 열지는 서버가 모른다. 그리고 사용자가 프로젝트를 바꾸면? 서버를 재시작해야 한다.이 문제의 근본은 클라이언트(IDE)가 파일시스템 범위를 알고 있는데, 서버는 모른다는 것이다. Roots는 이 정보 비대칭을 해결한다 — 서버가 클라이언트에게 물어보면 된다.
▲ 문제: 서버가 클라이언트를 모른다
▲ 해결: roots/list로 동적으로 파악
다이어그램 설명: 위 — 서버가 클라이언트의 파일시스템 범위를 모르는 상태. 각 클라이언트가 다른 경로를 갖고 있어 하드코딩이 불가능하다. 아래 — 서버가
roots/list로 클라이언트에게 직접 물어보고, 응답에 따라 그 범위 내에서만 리소스를 노출한다. 클라이언트가 컨텍스트 범위를 통제하고 서버가 동적으로 따라간다.그림: MCP의 두 가지 방향 — 대부분은 클라이언트가 요청하지만, roots/list는 서버가 클라이언트에게 요청한다 다이어그램 설명: 위 섹션(파란색)이 일반적인 흐름 — 클라이언트가 요청하고 서버가 응답한다. 아래 섹션(빨간색)이 역방향 — 서버가 요청하고 클라이언트가 응답한다. 역방향이 가능한 이유는 MCP가 양방향 채널(주로 stdio나 SSE)을 쓰기 때문이다. 단방향 HTTP 요청-응답 모델이었다면 구현 불가능하다.
roots/list외에sampling/createMessage(서버가 클라이언트에게 LLM 호출을 위임)도 같은 역방향 패턴이다.roots/list — 서버가 클라이언트에게 보내는 요청
Roots는 MCP 명세에서 "클라이언트가 관리하는 파일시스템 경계"를 나타낸다. IDE에서는 현재 열린 워크스페이스 폴더가, Claude Code에서는 현재 작업 디렉토리가 root다.
다이어그램 설명: 서버가 클라이언트에게
roots/list를 보내면 클라이언트가 현재 열린 프로젝트 경로 목록으로 응답한다. 서버는 이 범위를 기반으로 리소스를 노출한다. 프로젝트가 변경되면 클라이언트가notifications/roots/list_changed를 서버에 보내고, 서버가 다시roots/list를 요청해서 최신 범위를 파악한다. 이 ping-pong 구조가 서버와 클라이언트의 범위를 동기화된 상태로 유지한다.// 서버 → 클라이언트: roots/list 요청 (역방향) { "jsonrpc": "2.0", "id": 101, "method": "roots/list" // params 없음 — 단순히 "네 루트 목록을 알려줘" } // 클라이언트 → 서버: 응답 { "jsonrpc": "2.0", "id": 101, "result": { "roots": [ { "uri": "file:///home/user/projects/my-app", // 파일시스템 경로 "name": "my-app" // 사람이 읽기 좋은 이름 (선택) }, { "uri": "file:///home/user/projects/shared-lib", "name": "shared-lib" } ] } } // 여러 워크스페이스 폴더가 열린 경우 (VS Code Multi-root Workspace) { "result": { "roots": [ { "uri": "file:///workspace/frontend", "name": "frontend" }, { "uri": "file:///workspace/backend", "name": "backend" }, { "uri": "file:///workspace/infra", "name": "infra" } ] } }코드 설명:
uri는file://스키마가 일반적이지만 MCP가 강제하지는 않는다. 로컬 파일시스템 경로가 아닌 원격 저장소 URI를 쓰는 서버도 이론적으로 가능하다.name은 선택 필드지만 있으면 서버가 사람이 읽기 좋은 방식으로 리소스를 분류할 수 있다. 목록의 순서는 우선순위를 의미하지 않는다 — 서버가 필요에 따라 해석한다.capabilities.roots — 핸드셰이크에서 협상한다
roots/list를 사용하려면 초기화 핸드셰이크에서 클라이언트가 지원 의사를 먼저 선언해야 한다. 선언 없이 서버가roots/list를 보내면 클라이언트는-32601 Method not found로 거부한다.// initialize 요청 — 클라이언트가 roots 지원 선언 { "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2024-11-05", "capabilities": { "roots": { "listChanged": true // "루트 변경 시 내가 notifications/roots/list_changed를 보낼게" }, "sampling": {} // 선택 — LLM 위임도 받을 수 있음 }, "clientInfo": { "name": "my-ide-plugin", "version": "1.2.0" } } } // initialize 응답 — 서버가 지원 기능 확인 { "jsonrpc": "2.0", "id": 1, "result": { "protocolVersion": "2024-11-05", "capabilities": { "resources": { "subscribe": true, "listChanged": true }, "tools": { "listChanged": true } // roots 관련 서버 capability는 없음 — roots는 클라이언트의 역할 }, "serverInfo": { "name": "filesystem-mcp", "version": "0.5.0" } } }코드 설명:
capabilities.roots.listChanged: true는 "나(클라이언트)는 루트 목록이 바뀔 때notifications/roots/list_changed를 보낼 수 있다"는 선언이다. 이것이 true여야 서버가roots/list를 요청하는 게 의미 있다 — 바뀌어도 알림이 안 오면 서버가 언제 다시 물어봐야 할지 모르기 때문이다.listChanged: false거나 아예rootscapabilities가 없으면, 서버는 세션 시작 시 한 번만roots/list를 요청하고 그 이후엔 변경을 추적하지 않는 방식으로 구현한다.notifications/roots/list_changed — 클라이언트가 서버에게 보내는 알림
사용자가 IDE에서 새 프로젝트 폴더를 열거나 기존 폴더를 닫으면, 클라이언트가 서버에게 알린다. 이 알림의 방향이 특이하다 — 다른 알림들은 서버가 클라이언트에게 보내는데, 이건 클라이언트가 서버에게 보낸다.
다이어그램 설명: 사용자가 IDE에서 폴더를 추가/제거할 때마다 클라이언트가
notifications/roots/list_changed를 서버에 보낸다. 서버는 이를 받아roots/list를 재요청하고 범위를 갱신한다. 이 왕복 덕분에 서버의 리소스 범위가 사용자의 워크스페이스 상태와 항상 동기화된다. 사용자가 모르는 사이에 서버는 자동으로 "지금 열린 프로젝트들만"을 커버한다.// 클라이언트 → 서버: 루트 목록 변경 알림 (id 없음 = 알림) { "jsonrpc": "2.0", "method": "notifications/roots/list_changed" // params 없음 — "바뀌었으니 다시 물어봐"라는 신호만 } // 이 알림을 받은 서버가 roots/list를 다시 보낸다 { "jsonrpc": "2.0", "id": 102, "method": "roots/list" } // 클라이언트 응답 — 최신 상태 { "jsonrpc": "2.0", "id": 102, "result": { "roots": [ { "uri": "file:///workspace/project-b", "name": "project-b" } ] } }코드 설명:
notifications/roots/list_changed도 params가 없다 — "어떤 루트가 추가/제거됐는지" 알려주지 않는다. invalidation 신호만이다. 서버가roots/list를 다시 요청해서 전체 최신 상태를 받아간다. 이 패턴은 Resources의notifications/resources/list_changed와 동일한 "push invalidation + pull data" 설계다. MCP 전반에 걸쳐 일관되게 쓰이는 패턴이다.Roots → Resources 연동 — 서버가 범위를 동적으로 결정한다
Roots의 진짜 가치는
resources/list와의 연동에서 나온다. 서버가 roots를 파악하면, 그 범위 내의 파일들을resources/list로 노출할 수 있다. roots가 바뀌면 노출되는 리소스 범위도 바뀐다.다이어그램 설명: roots가 바뀌면 서버가 새 경로를 인덱싱하고, resources 목록도 달라진다. 서버는
notifications/resources/list_changed를 보내서 클라이언트에게 리소스 목록도 다시 조회하라고 알린다. roots 변경이 연쇄적으로 resources 변경을 유발하는 이 흐름이 "동적으로 확장되는 파일시스템 MCP 서버"의 핵심 동작 원리다.실전 시나리오 — Claude Code에서 roots가 작동하는 방식
Claude Code를 IDE처럼 쓸 때 roots가 어떻게 작동하는지 보자. 사용자가 특정 디렉토리에서 세션을 시작하면, Claude Code가 그 디렉토리를 root로 MCP 서버에 노출한다.
다이어그램 설명: Claude Code가
cd /workspace/my-app으로 시작하면 그 경로가 기본 root다. MCP 서버가 초기화 직후roots/list를 요청하면 Claude Code가 현재 작업 디렉토리를 알려준다. 나중에 개발자가 다른 경로를 추가하면 roots가 갱신되고 resources 범위도 따라서 넓어진다. 서버를 재시작하거나 설정을 바꿀 필요가 없다.보안 모델 — 클라이언트가 범위를 통제한다
Roots 설계에서 중요한 보안적 함의가 있다. 서버가 접근할 수 있는 파일시스템 범위를 클라이언트가 결정한다. 서버가 원하는 경로를 마음대로 접근할 수 없다 — 클라이언트가 허용한 roots 안에서만 작동한다.
// 악의적인 서버가 roots 밖의 경로를 리소스로 노출하려 해도... { "result": { "resources": [ { "uri": "file:///etc/passwd", // roots 밖의 민감한 파일 "name": "passwd", "mimeType": "text/plain" } ] } } // 클라이언트는 이 URI가 roots 범위 밖임을 확인하고 거부할 수 있다 // roots = ["file:///workspace/my-app"] // /etc/passwd는 이 범위에 포함되지 않음 → 클라이언트가 resources/read 거부코드 설명: MCP 명세 자체가 "roots 밖의 URI를 클라이언트가 거부해야 한다"고 강제하지는 않는다. 하지만 잘 구현된 클라이언트는 roots를 신뢰 경계로 써서 서버가 제안하는 URI가 허용 범위 내인지 검증한다. 이 패턴은
sampling/createMessage의 보안 모델과 같다 — 서버가 원하는 LLM 호출을 요청해도 클라이언트가 검토하고 허용/거부를 결정한다. "클라이언트가 보안 경계를 소유한다"는 원칙이 MCP 역방향 메서드 전반에 일관되게 적용된다.Roots 메서드가 주는 것 — 동적 컨텍스트 범위
Roots는 메서드 2개(roots/list + notifications/roots/list_changed)로 구성된 단순한 구조다. 그런데 이 단순한 구조가 해결하는 문제가 크다.
첫째, 서버 설정 없이 클라이언트 컨텍스트를 따라간다. 사용자가 어떤 프로젝트를 열어도, 서버는 자동으로 그 범위를 파악한다. 파일 경로를 설정 파일에 하드코딩할 필요가 없다.
둘째, 멀티 워크스페이스를 자연스럽게 지원한다. VS Code의 Multi-root Workspace처럼 여러 폴더가 동시에 열려 있는 환경에서, 서버가 여러 roots를 한꺼번에 처리한다. 각 root의 파일을 별도로 관리하거나 합쳐서 제공할 수 있다.
셋째, 보안 경계를 코드가 아닌 프로토콜이 명시한다. 어디까지 접근할 수 있는지가 런타임 설정이 아니라 세션 초기화 과정에서 명확하게 선언된다. 클라이언트가 허용한 범위만 서버에 노출된다는 것이 MCP 세션의 기본 계약이 된다.
roots/list는 MCP의 역방향 설계가 실제로 무엇을 가능하게 하는지를 가장 잘 보여주는 예시다. 서버가 클라이언트에게 묻는 이 작은 역전이, 서버와 클라이언트의 파일시스템 컨텍스트를 항상 동기화된 상태로 유지하게 한다 — 사용자가 무엇을 열고 닫든 상관없이.
이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.
'IT' 카테고리의 다른 글
MCP sampling/createMessage: AI 도구가 AI를 부르는 역방향 설계 (0) 2026.06.14 MCP Prompts의 멀티턴 messages — 서버가 모델의 첫 생각을 설계하는 방법 (0) 2026.06.13 MCP Prompts 완전 분해: 최적 프롬프트를 서버에 봉인하고 재사용하는 방법 (0) 2026.06.13 MCP Resources 완전 분해: URI로 AI에게 데이터를 공급하는 7가지 메서드 (0) 2026.06.12 MCP가 JSON-RPC 봉투 안에 채운 것들: 세 기본 단위와 20가지 메서드 전체 지도 (0) 2026.06.12 JSON-RPC 2.0이 정의하는 건 봉투 6단어뿐: MCP 사례로 그 안과 밖을 가른다 (1) 2026.06.12 서브에이전트 패키지를 직접 뜯어보다 — debug-pack 플러그인 해부 (0) 2026.06.11 Claude에 브라우저 눈과 손을 달다 — Playwright MCP 플러그인 (0) 2026.06.10 Claude에 GitHub 전체를 연결하다 — GitHub MCP 플러그인 실전 가이드 (0) 2026.06.10 Claude Code에서 나만의 AI 전문가 만들기 — 서브에이전트 제작 가이드 (0) 2026.06.10