URI テンプレートとパスの安全性
このページは、@mcp.resource が受け付ける URI テンプレート構文と、抽出した値に SDK が適用するパス安全性ポリシーのリファレンスです。リソースとは何か、いつ使うのかについては、まず リソース を参照してください。このページでは、リソースの宣言にはすでに慣れていて、演算子の全セットやセキュリティの設定項目、低レベルでの組み込み方を知りたい、という読者を想定しています。
テンプレート構文は RFC 6570 です。SDK がサポートするのは、受信する resources/read の URI のマッチング向けに選んだそのサブセットです。加えて、提供するつもりのディレクトリの外へ解決されてしまう値を拒否するセキュリティレイヤーを備えています。プロトコルレベルの詳細(メッセージ形式、ライフサイクル、ページネーション)については、MCP のリソース仕様 を参照してください。
演算子の全セット
単純なプレースホルダー {user_id} は、リソース で紹介したものです。演算子の形式はほかに 4 つあります。並べて見比べられるように、1 つのサーバーにまとめました。
from mcp.server import MCPServer
mcp = MCPServer("Bookshop")
BOOKS = {
"978-0441172719": {"title": "Dune", "author": "Frank Herbert"},
"978-0553293357": {"title": "Foundation", "author": "Isaac Asimov"},
}
MANUALS = {
"printing/setup.md": "# Printer setup\n\nLoad paper, then power on.",
"returns.md": "# Returns policy\n\nThirty days with a receipt.",
}
@mcp.resource("books://{isbn}")
def get_book(isbn: str) -> dict[str, str]:
"""A single book by ISBN."""
return BOOKS[isbn]
@mcp.resource("orders://{order_id}")
def get_order(order_id: int) -> dict[str, object]:
"""An order by its numeric id."""
return {"order_id": order_id, "next_order": order_id + 1, "status": "shipped"}
@mcp.resource("manuals://{+path}")
def read_manual(path: str) -> str:
"""A staff manual page. The path keeps its slashes."""
return MANUALS[path]
@mcp.resource("reviews://{isbn}{?limit,sort}")
def list_reviews(isbn: str, limit: int = 10, sort: str = "newest") -> str:
"""Reviews of a book, optionally limited and sorted."""
return f"{limit} {sort} reviews of {BOOKS[isbn]['title']}"
@mcp.resource("shelves://browse{/path*}")
def browse_shelf(path: list[str]) -> str:
"""A shelf in the category tree, addressed by segments."""
return " > ".join(["catalog", *path])
ハイライトされたデコレーターは、それぞれ異なる方法で URI を切り分けています。以下のセクションで上から順に説明します。
単純な展開:{name}
books://{isbn} は、日常的に使う単純な形式です。プレースホルダーは isbn パラメーターに対応するので、クライアントが books://978-0441172719 を読むと get_book("978-0441172719") が呼び出されます。
単純な {name} は最初の / で止まります。books://978/extra はマッチしません。978 の後ろのスラッシュでキャプチャが終わり、/extra が余ってしまうからです。
型変換
抽出した値は文字列として届きますが、より具体的な型を宣言すれば SDK が変換します。orders://{order_id} の値は、パラメーターが order_id: int の関数に渡るので、orders://12345 を読むと get_order("12345") ではなく get_order(12345) が呼び出されます。ハンドラーはキャストなしで、そのまま算術演算(order_id + 1)を行えます。
複数セグメントのパス:{+name}
スラッシュを含む値をキャプチャするには {+name} を使います。manuals://{+path} の場合は次のようになります。
manuals://returns.mdならpath = "returns.md"manuals://printing/setup.mdならpath = "printing/setup.md"
値が階層構造を持つときは、いつでも {+name} を使ってください。ファイルシステムのパス、ネストしたオブジェクトのキー、プロキシする URL のパスなどです。
クエリパラメーター:{?a,b,c}
reviews://{isbn}{?limit,sort} は、limit と sort を ? の後ろに置きます。パスは「どの」本かを特定し、クエリは「どのように」読むかを調整します。
クエリパラメーターのマッチングは緩やかです。順序は問わず、余分なものは無視され、省略されたパラメーターには関数のデフォルト値が使われます。つまり reviews://978-0441172719 では limit=10, sort="newest" が使われ、reviews://978-0441172719?sort=top では sort だけが上書きされます。
リストとしてのパスセグメント:{/name*}
スラッシュ入りの 1 つの文字列ではなく、パスの各セグメントを別々のリスト要素として受け取りたい場合は {/name*} を使います。shelves://browse{/path*} なら、クライアントが shelves://browse/fiction/sci-fi を読むと browse_shelf(["fiction", "sci-fi"]) が呼び出されます。
テンプレート早見表
よく使うパターンは次のとおりです。
| パターン | 入力例 | 得られる値 |
|---|---|---|
{name} |
alice |
"alice" |
{name} |
docs/intro.md |
マッチしない(/ で止まる) |
{+path} |
docs/intro.md |
"docs/intro.md" |
{.ext} |
.json |
"json" |
{/segment} |
/v2 |
"v2" |
{?key} |
?key=value |
"value" |
{?a,b} |
?a=1&b=2 |
"1", "2" |
{/path*} |
/a/b/c |
["a", "b", "c"] |
パーサーが拒否するもの
テンプレートの形によっては、最初のリクエストで失敗するのを待たず、事前に検出されるものがあります。@mcp.resource はデコレーターの実行時にテンプレートを解析するので、これらが稼働中のサーバーに到達することはありません。
UriTemplate.parse() は、次の場合に InvalidUriTemplate を送出します。
- 間に何もない 2 つの変数。
manuals://{+path}{ext}は拒否されます。マッチングでは、pathがどこで終わりextがどこで始まるのか判断できないからです。間にリテラルを挟む(manuals://{+path}/{ext})か、区切り文字を自前で持つ演算子を使ってください。manuals://{+path}{.ext}は、{.ext}自体が.を提供するので受け付けられます。 - 複数セグメントの変数が 2 つ以上ある場合。
{+var}、{#var}、explode 修飾子付きの変数({/var*}、{.var*}、{;var*})は、1 つのテンプレートにつき多くても 1 つです。2 つあると本質的にあいまいになります。余分なセグメントをどちらが吸収するのか、筋の通った決め方がないからです。 - よくある構文エラー:閉じていない波括弧、2 回使われている変数名、あるいは SDK がサポートしていない RFC 6570 の機能です。たとえばプレフィックス修飾子の
{var:3}や、クエリの explode である{?vars*}などが該当します。
これに加えて @mcp.resource は、ハンドラーのパラメーターがテンプレート末尾に連なる {?...}/{&...} のクエリ変数に束縛されているのに Python のデフォルト値を持たない場合、ValueError を送出します。これらの変数は緩やかにマッチングされる(クライアントはどれを省略してもかまいません)ので、デフォルト値のないパラメーターは、それを省略した最初のリクエストで、わかりにくい内部エラーとして表面化するだけになってしまいます。上のサーバーの reviews://{isbn}{?limit,sort} は正しく書かれた例で、limit と sort はどちらもデフォルト値を持っています。
セキュリティ
テンプレートのパラメーターはクライアントから届きます。チェックしないままファイルシステムやデータベースの操作に流し込むと、../../etc/passwd のような値が、提供するつもりだったディレクトリの外に解決されてしまうことがあります。
SDK がデフォルトでチェックする内容
SDK はハンドラーの実行前に、次のいずれかに当てはまるパラメーターを拒否します。
..の構成要素を使って開始ディレクトリの外に出てしまうもの。- 絶対パス(
/etc/passwd、C:\Windows)や Windows のドライブ相対パス(C:foo)のように見えるもの。ドライブ相対の値とx:yのような名前空間付きの識別子は、文字列としては区別できません。そのため、1 文字の後にコロンが続く形の値は、デフォルトではすべて拒否されます。そのような値を正当に受け取るパラメーターは、チェックの対象から除外してください。 - ヌルバイト(
\x00)を含むもの。
.. のチェックは部分文字列の走査ではなく、パスの構成要素単位で行われます。v1.0..v2.0 や HEAD~3..HEAD のような値は通ります。そこでの .. は独立したパスセグメントではないからです。
これらのチェックはデコード後の値に適用されるので、URI でどのようにエンコードされていてもトラバーサルを検出します(../etc、..%2Fetc、%2E%2E/etc、..%5Cetc、%00 はすべて検出されます)。
Check
上のサーバーから manuals://../etc/passwd を読むと、リクエストは即座に拒否されます。テンプレートのマッチングは最初の失敗で止まるので、後続の(より緩いかもしれない)テンプレートがフォールバックとして試されることはありません。クライアントには、どのテンプレートにもマッチしない URI の場合と同じ -32602 の「Unknown resource」エラーが返り、read_manual は実行されません。
ファイルシステムのハンドラー:safe_join を使う
組み込みのチェックはよくあるケースを止めますが、サンドボックスの境界までは知りようがありません。ファイルシステムにアクセスする場合は、safe_join を使ってパスを解決し、ベースディレクトリの内側に収まっていることを検証してください。
from pathlib import Path
from mcp.server import MCPServer
from mcp.shared.path_security import safe_join
mcp = MCPServer("Bookshop")
DOCS_ROOT = Path("./manuals")
@mcp.resource("manuals://{+path}")
def read_manual(path: str) -> str:
"""A staff manual page, served from a directory on disk."""
return safe_join(DOCS_ROOT, path).read_text()
safe_join は、単純な文字列チェックでは見逃してしまうシンボリックリンクによる脱出、.. の並び、絶対パスを使ったトリックを検出します。解決されたパスが DOCS_ROOT の外に出ると PathEscapeError を送出し、クライアントには ResourceError として伝わります。
デフォルトが妨げになるとき
チェックが正当な値をブロックしてしまうこともあります。カタログのインポートツールが意図的に絶対パスを受け取る場合や、パラメーターが ../sibling のような相対参照で、ハンドラーがファイルシステムに触れずに安全に解釈する場合などです。そのパラメーターをチェックの対象から除外するか、サーバー全体のポリシーを緩めてください。
from mcp.server import MCPServer
from mcp.server.mcpserver import ResourceSecurity
mcp = MCPServer("Bookshop")
@mcp.resource(
"imports://preview/{+source}",
security=ResourceSecurity(exempt_params={"source"}),
)
def preview_import(source: str) -> str:
"""Preview a catalog import. `source` may be an absolute path."""
return f"Would import from {source}"
relaxed = MCPServer(
"Bookshop",
resource_security=ResourceSecurity(reject_path_traversal=False),
)
@relaxed.resource("imports://preview/{+source}")
def preview_import_relaxed(source: str) -> str:
"""The server-wide flag exempts every resource on `relaxed`."""
return f"Would import from {source}"
- デコレーターに付けた
security=ResourceSecurity(exempt_params={"source"})は、その 1 つのリソースのその 1 つのパラメーターに限ってチェックをスキップします。サーバーのほかの部分はデフォルトのポリシーのままです。 MCPServerのコンストラクターに渡すresource_security=は、すべてのリソースのデフォルトを設定します。ここではrelaxedによって..のチェックが完全に無効になります。
設定できるチェックは次のとおりです。
| 設定 | デフォルト | 動作 |
|---|---|---|
reject_path_traversal |
True |
開始ディレクトリの外に出る .. の並びを拒否する |
reject_absolute_paths |
True |
/foo、C:\foo、UNC パス、ドライブ相対の C:foo を拒否する(x:y も対象になる) |
reject_null_bytes |
True |
\x00 を含む値を拒否する |
exempt_params |
空 | チェックをスキップするパラメーター名 |
これらのチェックはヒューリスティックな事前フィルターです。ファイルシステムへのアクセスでは、依然として safe_join が封じ込めの境界です。
Tip
ハンドラーがリクエストに応えられない場合(ファイルが存在しない、ID が不明など)は、例外を送出してください。SDK がそれをエラーレスポンスに変換します。プロトコルエラーとツールエラーの違いについては、エラーの処理 を参照してください。
低レベル Server でのリソース
低レベルの Server の上に構築している場合(低レベル Server を参照)は、プロトコルメソッド resources/list と resources/read のハンドラーを直接登録します。デコレーターはなく、プロトコルの型は自分で返します。
静的リソース
固定の URI については、レジストリを持っておき、完全一致でディスパッチします。
from mcp.server import Server, ServerRequestContext
from mcp.types import (
ListResourcesResult,
PaginatedRequestParams,
ReadResourceRequestParams,
ReadResourceResult,
Resource,
TextResourceContents,
)
RESOURCES = {
"config://shop": '{"currency": "USD", "tax_rate": 0.08}',
"status://health": "ok",
}
async def list_resources(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListResourcesResult:
return ListResourcesResult(resources=[Resource(name=uri, uri=uri) for uri in RESOURCES])
async def read_resource(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult:
if (text := RESOURCES.get(params.uri)) is not None:
return ReadResourceResult(contents=[TextResourceContents(uri=params.uri, text=text)])
raise ValueError(f"Unknown resource: {params.uri}")
server = Server("Bookshop", on_list_resources=list_resources, on_read_resource=read_resource)
一覧ハンドラーは利用できるものをクライアントに伝え、読み取りハンドラーはコンテンツを返します。まずレジストリを調べ、テンプレートがあればそちら(後述)に回し、それ以外はすべて例外を送出してください。
テンプレート
MCPServer が使っているテンプレートエンジンは mcp.shared.uri_template にあり、単独でも動作します。解析とマッチングは同じものが手に入ります。ルーティングとセキュリティポリシーの組み立ては自分で行います。
from mcp.server import Server, ServerRequestContext
from mcp.shared.path_security import contains_path_traversal, is_absolute_path
from mcp.shared.uri_template import UriTemplate
from mcp.types import (
ListResourceTemplatesResult,
PaginatedRequestParams,
ReadResourceRequestParams,
ReadResourceResult,
ResourceTemplate,
TextResourceContents,
)
TEMPLATES = {
"manuals": UriTemplate.parse("manuals://{+path}"),
"books": UriTemplate.parse("books://{isbn}"),
}
MANUALS = {"printing/setup.md": "# Printer setup", "returns.md": "# Returns policy"}
BOOKS = {"978-0441172719": "Dune by Frank Herbert"}
def read_manual_safely(path: str) -> str:
if contains_path_traversal(path) or is_absolute_path(path):
raise ValueError("rejected")
return MANUALS[path]
async def read_resource(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult:
if (matched := TEMPLATES["manuals"].match(params.uri)) is not None:
text = read_manual_safely(str(matched["path"]))
return ReadResourceResult(contents=[TextResourceContents(uri=params.uri, text=text)])
if (matched := TEMPLATES["books"].match(params.uri)) is not None:
text = BOOKS[str(matched["isbn"])]
return ReadResourceResult(contents=[TextResourceContents(uri=params.uri, text=text)])
raise ValueError(f"Unknown resource: {params.uri}")
async def list_resource_templates(
ctx: ServerRequestContext, params: PaginatedRequestParams | None
) -> ListResourceTemplatesResult:
return ListResourceTemplatesResult(
resource_templates=[
ResourceTemplate(name=name, uri_template=str(template)) for name, template in TEMPLATES.items()
]
)
server = Server(
"Bookshop",
on_read_resource=read_resource,
on_list_resource_templates=list_resource_templates,
)
ハイライトされた行では 3 つのことが行われています。
- 解析は一度、マッチングはリクエストごと。
UriTemplate.parse()がテンプレートを組み立て、template.match(uri)が抽出した変数をdictとして返します。URI が合わなければNoneです。URL のデコードはmatch()の内部で行われ、デコード後の値はパス安全性の検証なしにそのまま返されます。値は文字列として出てくるので、自分で変換してください(int(matched["id"])、Path(matched["path"]))。 - 安全性チェックは自分で適用する。
MCPServerがデフォルトで実行する..と絶対パスのチェックはmcp.shared.path_securityにあります。read_manual_safelyはMANUALSに触れる前にそれらを呼び出します。パラメーターがファイルシステムのパスでない場合(ISBN や検索クエリなど)は、その値のチェックをスキップしてください。ポリシーは設定オブジェクト経由ではなく、ハンドラーごとに制御します。 - テンプレートの一覧も同じ情報源から。 クライアントは
resources/templates/listを通じてテンプレートを見つけます。str(template)は元のテンプレート文字列を返すので、一覧とマッチャーは同じ 1 つの情報源を共有します。
まとめ
{name}は 1 つのセグメントにマッチし、{+name}はスラッシュを保持し、{?a,b}はクエリ文字列から値を取り出し、{/name*}はセグメントをリストに分割します。- 間に何もない 2 つの変数や、2 つ目の複数セグメント変数は、解析時に拒否されます。末尾の
{?...}/{&...}のクエリ変数に束縛されるパラメーターは、Python のデフォルト値を宣言しなければなりません。 - パラメーターに型注釈を付ければ(
order_id: int)、SDK が変換します。 - デフォルトのセキュリティポリシーは、ハンドラーの実行前に
..、絶対パス、ヌルバイトを拒否します。リソースごとに上書きするにはsecurity=ResourceSecurity(...)を、サーバー全体で上書きするにはresource_security=を使います。 - ファイルシステムへのアクセスでは、
safe_joinが封じ込めの境界です。 - 低レベルの
Serverでは、UriTemplate.parse()で解析し、.match()でマッチングし、mcp.shared.path_securityを自分で適用します。