本文へ移動
📁

MCP serverを作っていると、「このserverにはどのディレクトリを見せればよいのか」で迷います。旧来のMCPでは、clientがroots/listでworkspaceのrootをserverへ渡せました。便利に見えますが、2026-07-28以降の設計では、そのまま新規採用しない方が安全です。

理由は単純です。rootsは「どこを見てよいかのヒント」であって、アクセス制御そのものではないからです。さらに2026-07-28仕様ではrootsがdeprecatedになり、server-initiated requestはMRTRへ寄せる流れになりました。この記事では、MCP rootsを新しく増やさず、既存のroots/list依存をtool parameters、resource URIs、server configurationへ移す設計だけを扱います。

2026-07-28全体の移行順は MCP 2026-07-28 移行ガイド、MRTRのinput_required / inputResponsesは MCP MRTR 設計ガイド、未信頼入力とwrite toolの分離は MCP prompt injection 対策ガイド、顧客データや社内docsの混在判断は AIエージェント データ境界設計ガイド に分けます。

先に結論: rootsは権限ではなく、参照範囲のヒント

私なら、2026-07-28以降のMCP serverでは次の方針にします。

状況判断
新規serverで作業対象が必要roots/listではなく、tool parameterかserver configurationで明示する
既存clientがrootsを返す互換として読むが、権限判定には使わない
ユーザーが毎回ファイルを選ぶMRTRで追加入力を求め、requestStateへbindする
固定の社内repositoryだけ読むserver configurationとdeployment権限で固定する
生成物から別pathへ書くroots外拒否ではなく、allowlistと承認で止める

rootsを「この範囲なら安全」と見なすと事故ります。server側でpath traversalを検証し、client側で公開してよいrootを絞り、実際の読み書き権限はOS、token、tool policy、承認UIで別に持つ必要があります。

なぜrootsを新規採用しないのか

MCPの公式仕様では、Roots featureは2026-07-28でdeprecatedです。少なくとも12か月は仕様に残りますが、新規実装では採用せず、既存実装は移行する前提です。移行先として示されているのは、directories / filesをtool parameters、resource URIs、server configurationで渡す形です。

もう1つ重要なのは、rootsの性質です。公式docsでも、rootsはserverへ関連ディレクトリを知らせるための情報であり、アクセス制御機構ではないと説明されています。つまり、serverがroots外を読まない保証はprotocolだけでは作れません。

ここを誤解すると、次のような設計になります。

bad_assumption:
  roots:
    - file:///repo/frontend
  server_behavior:
    - roots配下なら自動で読んでよい
    - roots配下ならwriteも許可してよい
    - rootsがあるのでpath validationは省略する

これは危険です。rootsが返ったことと、そのpathへ安全にアクセスしてよいことは別です。特にremote MCPやmulti-tenant MCPでは、clientが返したURI、server上のfilesystem、実際のcredential scopeが一致するとは限りません。

既存roots依存を棚卸しする

まず、codeとtraffic logからroots/list依存を一覧化します。

rg "roots/list|clientCapabilities.*roots|notifications/roots|rootUri|workspaceRoot" .

棚卸しでは、単に呼び出し箇所を数えるだけでは足りません。次の情報を1行ずつ残します。

mcp_roots_inventory:
  server: docs-search-mcp
  observed_protocols:
    - "2025-11-25"
    - "2026-07-28"
  roots_usage:
    - method: roots/list
      purpose: "repository内の検索対象を推定する"
      fallback: "tool argumentのrepoPath"
      reads_files: true
      writes_files: false
      trusts_roots_for_authz: false
  risk:
    path_traversal: medium
    tenant_mixup: low
    prompt_injection_bridge: medium
  migration_owner: platform

特に見るべきなのは、trusts_roots_for_authzです。ここがtrueなら、単なるdeprecated対応ではなく権限設計の修正です。

移行先1: tool parametersに作業対象を渡す

ユーザーやagentが「このrepositoryだけ検索して」と明示するなら、tool parameterに寄せるのが一番扱いやすいです。

{
  "name": "search_repo",
  "arguments": {
    "repoId": "frontend",
    "path": "src/components",
    "query": "payment validation"
  }
}

この形にすると、tool call単位で作業対象が見えます。modelもreviewerも、どのpathを対象にしたかを後から追えます。

ただし、pathをそのままfilesystem pathとして信じてはいけません。server側では必ず正規化します。

import fs from "node:fs/promises";
import path from "node:path";

const allowedRepos = {
  frontend: "/srv/workspaces/frontend",
  backend: "/srv/workspaces/backend",
} as const;

async function resolveWorkspacePath(
  repoId: keyof typeof allowedRepos,
  inputPath: string,
) {
  const base = await fs.realpath(allowedRepos[repoId]);
  const candidate = path.resolve(base, inputPath);
  const canonical = await fs.realpath(candidate);

  if (!canonical.startsWith(base + path.sep) && canonical !== base) {
    throw new Error("path outside allowed repository");
  }

  return canonical;
}

この例で重要なのは、文字列上のprefix一致だけで閉じないことです。許可済みworkspace内に外部pathへのsymlinkがあると、path.resolve()だけではlink-to-outside/fileを許可してしまいます。実際のread/write前にcanonical pathを検証するか、symlinkを辿らないdirectory traversalへ寄せます。

もう1つ重要なのは、clientからfile:// URIを受け取らないことです。clientが自由にfilesystem URIを渡せる形にすると、server上のpathとclient上のpathの対応が曖昧になります。remote serverなら、repoIdやworkspaceIdのようなserver側で解決できるIDへ寄せます。

移行先2: resource URIで参照対象を表す

ファイルそのものではなく、docs、issue、design artifact、log bundleのような参照対象を渡すなら、resource URIに寄せます。

{
  "name": "summarize_resource",
  "arguments": {
    "resourceUri": "mcp-resource://docs/frontend/payment-flow"
  }
}

resource URIにすると、serverは実体の保存場所を隠せます。filesystem pathをmodelへ見せず、authorization、cache、redaction、auditをserver側で管理できます。

私なら、次のように扱います。

対象rootsではなく使うもの
repository内の相対pathrepoId + relative path
共有docsresourceUri
顧客別データtenantIdにbindされたresourceUri
build log短命なartifactUri
手元のローカルファイルclient側で明示uploadまたはtool parameter化

file:// URIは便利ですが、remote serverでは特に危険です。clientのfile:///Users/...とserverの/srv/...は同じ意味ではありません。

移行先3: server configurationで固定する

社内のread-only search serverやdocs serverなら、毎回rootsを聞く必要はありません。server configurationで対象を固定します。

mcp_server:
  name: internal-docs-search
  mode: read_only
  workspaces:
    - id: frontend
      base_path: /srv/indexes/frontend
      allowed_operations:
        - search
        - read_snippet
    - id: handbook
      base_path: /srv/indexes/handbook
      allowed_operations:
        - search

この形は地味ですが、組織運用では強いです。deployment reviewで「このserverは何を読めるか」を確認でき、runtimeでclientが広いrootを返してもscopeが広がりません。

設定固定にすると、ユーザーごとのworkspace選択ができないという弱点はあります。そこで、workspaceIdだけをtool parameterで受け、実pathはserver configurationで解決する形にします。

{
  "name": "search_workspace",
  "arguments": {
    "workspaceId": "frontend",
    "query": "checkout retry policy"
  }
}

こうすると、modelがpathを作らず、serverが許可済みworkspaceだけを解決します。

MRTRでroots/listを互換する場合

2026-07-28では、旧来のserver-initiated requestはMRTRのinput_requiredへ寄せます。rootsも例外ではありません。互換が必要なら、serverは処理中にroots/listを直接投げるのではなく、追加入力として要求します。

{
  "resultType": "input_required",
  "inputRequests": {
    "workspace_roots": {
      "method": "roots/list"
    }
  },
  "requestState": "signed-short-lived-state"
}

clientは元のrequestをretryし、inputResponsesへrootsを付けて返します。

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "analyze_workspace",
    "arguments": {
      "scope": "current-repo"
    },
    "inputResponses": {
      "workspace_roots": {
        "roots": [
          {
            "uri": "file:///home/user/repos/frontend",
            "name": "Frontend"
          }
        ]
      }
    },
    "requestState": "signed-short-lived-state"
  }
}

ただし、この互換経路は新規設計の中心に置きません。使うなら、次の条件を満たす場合だけです。

  1. clientがroots capabilityを明示している
  2. requestStateが短命で、元tool、arguments、user、tenantへbindされている
  3. rootsをpath hintとしてだけ扱い、authorizationには使わない
  4. rootsが返らなくても縮退できる
  5. roots結果を監査ログへ保存しすぎない

特に5は見落とされます。file:///Users/...のようなローカルpathには、ユーザー名、組織名、顧客名が入ることがあります。監査に必要な粒度へ丸めます。

path validationを二重に置く

roots移行で一番大事なのは、代替入力の形ではなくpath validationです。私ならclient側とserver側で責務を分けます。

場所やること
clientユーザーに見せてよいrootだけを選ばせる
gatewaytenant、server、tool、methodの組み合わせを拒否できるようにする
serverpathを正規化し、許可済みbaseから出たら拒否する
toolread/writeごとに承認とaudit eventを分ける
reviewer実際に読んだpathと出力に混ざった内容を確認する

rootsを使っていた頃の実装には、「rootがあるから安全」という暗黙の前提が残りがちです。移行時には、あえて次のテストを入れます。

const cases = [
  "../secrets.env",
  "src/../../.ssh/id_rsa",
  "/etc/passwd",
  "subdir/%2e%2e/%2e%2e/secrets",
  "allowed/../allowed/file.md",
];

通すべきpathと拒否すべきpathを明示しないと、実装者ごとに判断が割れます。

write toolではrootsに頼らない

read-only searchなら、rootsの代替は比較的簡単です。危ないのはwrite toolです。

dangerous_tool:
  name: update_file
  arguments:
    path: "src/config.ts"
    patch: "..."

このtoolで「roots内だからwriteしてよい」と判断してはいけません。writeは別の承認境界です。

私なら、write toolでは次のようにします。

write_policy:
  allowed_workspace_ids:
    - frontend
  allowed_paths:
    - "src/**"
    - "tests/**"
  denied_paths:
    - ".env*"
    - "**/secrets/**"
    - "**/*.pem"
  requires_human_approval:
    - "package.json"
    - "infra/**"
  output:
    include_diff: true
    include_audit_event_id: true

rootsはこのpolicyの入力の1つにもなりません。許可済みworkspace、path pattern、risk tier、承認、diff reviewを別に持ちます。

監査ログにはraw rootをそのまま残さない

rootsやpath parameterは、監査には必要です。ただし、raw pathを全部残すと別の問題が出ます。

{
  "event": "mcp_workspace_scope_resolved",
  "server": "docs-search-mcp",
  "tool": "search_workspace",
  "workspaceId": "frontend",
  "pathScope": "src/components",
  "source": "tool_parameter",
  "rootCompatibilityUsed": false,
  "requestStateId": "mrs_20260825_001",
  "decision": "allowed"
}

監査ログでは、次を分けます。

残す残し方
workspace識別子workspaceIdやrepoId
path範囲相対pathまたは分類済みscope
raw filesystem path原則保存しない。incident hold時だけ制限付きで保存
roots互換の利用有無booleanで残す
拒否理由outside_allowed_baseなど定型値

「何を読んだか」は追える必要があります。ただし、ユーザーのローカルpathや社内構成を余計にばらまく必要はありません。

移行チェックリスト

既存serverを直すなら、この順で進めます。

  1. roots/list、notifications/roots/list_changed、clientCapabilities.rootsの利用箇所を検索する
  2. rootsをauthorizationとして使っている箇所を最優先で修正する
  3. 新規toolではrepoId、workspaceId、relative path、resourceUriへ寄せる
  4. remote serverではfile:// URIをserver側の実pathとして解釈しない
  5. path normalizationとdeny patternのテストを追加する
  6. write toolはrootsから切り離し、別policyと承認にする
  7. MRTR互換はcapability確認、短命requestState、縮退動作を必須にする
  8. 監査ログはraw pathを残しすぎず、scopeとdecisionを残す

よくある誤解

rootsがdeprecatedなら、ファイル操作MCPは作れないのか

作れます。変わるのは、workspace範囲の渡し方です。tool parameter、resource URI、server configurationで対象を明示し、実際の権限は別に検証します。

rootsを返したclientが悪いのか

違います。clientは関連rootを示しているだけです。serverがそれを権限として扱ったり、path validationを省略したりする設計が危険です。

local MCPなら気にしなくてよいのか

localでも必要です。ローカルpathには秘密情報や別repositoryが同居しやすく、prompt injectionを受けたagentがread toolからwrite toolへつなぐ事故も起きます。

MRTRでroots/listを使えるなら移行しなくてよいのか

互換のために使えることと、新規設計の中心にすることは別です。2026-07-28以降は、必要な対象をtool callに明示する設計へ寄せる方がレビューしやすいです。

まとめ

MCP rootsの移行は、deprecated APIを別APIへ置き換えるだけの作業ではありません。workspace、filesystem、resource、tenant、承認、監査をどこで結びつけるかを見直す作業です。

私なら、新規serverではroots/listを使いません。read-onlyならworkspaceIdとresource URI、write toolならpath policyと人間承認、既存client互換だけMRTRで受ける。この形にすると、MCP serverをstatelessに寄せても、作業対象と権限境界をレビュー可能なまま残せます。

参考資料

WRITTEN BY nidoneko

Full-stack engineer with 8+ years of experience in TypeScript, React, Node.js, and cloud-native development across healthcare, finance, HR, and IoT domains.

View Profile →