ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • MCP Roots 완전 분해: 서버가 클라이언트에게 먼저 묻는 역방향 설계
    IT 2026. 6. 13. 23:00
    MCP Roots 완전 분해: 서버가 클라이언트에게 먼저 묻는 역방향 설계

    MCP를 처음 배울 때 가장 충격적인 발견 중 하나가 있다. 서버가 클라이언트에게 요청을 보낸다는 것이다. 일반적인 클라이언트-서버 모델에서는 클라이언트가 요청하고 서버가 응답한다. 그런데 MCP에는 이 방향이 뒤집힌 메서드가 있다.

    roots/list가 그 하나다. 서버가 클라이언트에게 "너는 어떤 파일시스템 경로에 접근 권한이 있니?"를 묻는다. 이 글은 roots/list가 왜 필요한지, 어떻게 동작하는지, 그리고 이 역방향 설계가 MCP 전체에서 어떤 의미를 갖는지를 다룬다.

    역방향이 필요한 이유 — 서버는 클라이언트를 모른다

    파일시스템을 읽는 MCP 서버를 만든다고 가정하자. resources/list로 파일 목록을 노출하고 resources/read로 내용을 제공한다. 그런데 어느 경로를 노출해야 할까?

    하드코딩하면? /home/user/projects라고 박아두면 사용자마다 경로가 다를 수 있다. 설정 파일로 받으면? 서버를 띄울 때 경로를 알아야 하는데, 사용자가 IDE에서 어떤 프로젝트를 열지는 서버가 모른다. 그리고 사용자가 프로젝트를 바꾸면? 서버를 재시작해야 한다.

    이 문제의 근본은 클라이언트(IDE)가 파일시스템 범위를 알고 있는데, 서버는 모른다는 것이다. Roots는 이 정보 비대칭을 해결한다 — 서버가 클라이언트에게 물어보면 된다.

    diagram

    ▲ 문제: 서버가 클라이언트를 모른다

    diagram

    ▲ 해결: roots/list로 동적으로 파악

    다이어그램 설명: 위 — 서버가 클라이언트의 파일시스템 범위를 모르는 상태. 각 클라이언트가 다른 경로를 갖고 있어 하드코딩이 불가능하다. 아래 — 서버가 roots/list로 클라이언트에게 직접 물어보고, 응답에 따라 그 범위 내에서만 리소스를 노출한다. 클라이언트가 컨텍스트 범위를 통제하고 서버가 동적으로 따라간다.

    diagram
    그림: MCP의 두 가지 방향 — 대부분은 클라이언트가 요청하지만, roots/list는 서버가 클라이언트에게 요청한다

    다이어그램 설명: 위 섹션(파란색)이 일반적인 흐름 — 클라이언트가 요청하고 서버가 응답한다. 아래 섹션(빨간색)이 역방향 — 서버가 요청하고 클라이언트가 응답한다. 역방향이 가능한 이유는 MCP가 양방향 채널(주로 stdio나 SSE)을 쓰기 때문이다. 단방향 HTTP 요청-응답 모델이었다면 구현 불가능하다. roots/list 외에 sampling/createMessage(서버가 클라이언트에게 LLM 호출을 위임)도 같은 역방향 패턴이다.

    roots/list — 서버가 클라이언트에게 보내는 요청

    Roots는 MCP 명세에서 "클라이언트가 관리하는 파일시스템 경계"를 나타낸다. IDE에서는 현재 열린 워크스페이스 폴더가, Claude Code에서는 현재 작업 디렉토리가 root다.

    diagram

    다이어그램 설명: 서버가 클라이언트에게 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" }
        ]
      }
    }

    코드 설명: urifile:// 스키마가 일반적이지만 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거나 아예 roots capabilities가 없으면, 서버는 세션 시작 시 한 번만 roots/list를 요청하고 그 이후엔 변경을 추적하지 않는 방식으로 구현한다.

    notifications/roots/list_changed — 클라이언트가 서버에게 보내는 알림

    사용자가 IDE에서 새 프로젝트 폴더를 열거나 기존 폴더를 닫으면, 클라이언트가 서버에게 알린다. 이 알림의 방향이 특이하다 — 다른 알림들은 서버가 클라이언트에게 보내는데, 이건 클라이언트가 서버에게 보낸다.

    diagram

    다이어그램 설명: 사용자가 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가 바뀌면 노출되는 리소스 범위도 바뀐다.

    diagram

    다이어그램 설명: roots가 바뀌면 서버가 새 경로를 인덱싱하고, resources 목록도 달라진다. 서버는 notifications/resources/list_changed를 보내서 클라이언트에게 리소스 목록도 다시 조회하라고 알린다. roots 변경이 연쇄적으로 resources 변경을 유발하는 이 흐름이 "동적으로 확장되는 파일시스템 MCP 서버"의 핵심 동작 원리다.

    실전 시나리오 — Claude Code에서 roots가 작동하는 방식

    Claude Code를 IDE처럼 쓸 때 roots가 어떻게 작동하는지 보자. 사용자가 특정 디렉토리에서 세션을 시작하면, Claude Code가 그 디렉토리를 root로 MCP 서버에 노출한다.

    diagram

    다이어그램 설명: 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가 초안을 생성하고, 작성자가 검토·편집하였습니다.

Designed by Tistory.