GoogleProvider で MCP サーバーを立てると、OAuth は最初から動く。認可も通るしツールも呼べる。refresh も効いているように見える。サーバーを再起動するまでは。
これは後から直すバグというより、最初に決めておく設定の話だ。OAuthProxy の refresh の実装がそういう作りになっている。この記事では、refresh token を成立させるためにデプロイ側が満たすべき条件と、その満たし方を書く。
検証環境は fastmcp==3.4.2 と py-key-value-aio==0.4.5。
OAuthProxy の refresh はストレージの参照
まず押さえるべき点。GoogleProvider は OAuthProxy を継承していて、MCP クライアントが持っているトークンは上流 Google のトークンではない。プロキシが自前で発行した credential で、その実体は key-value ストアへの参照 だ。Google のトークンはサーバー側に留まる。
この設計自体はプロキシの利点そのもので、クライアントに上流の credential を一切見せずに、プロキシが代理でリフレッシュできる。同時に refresh には強い前提が生まれる。ストアにエントリが残っていること。
FastMCP 3.4.2 のプロキシは6つのコレクションを持つ。
default_collection="mcp-upstream-tokens" # 上流 Google のアクセス/リフレッシュトークン
default_collection="mcp-oauth-proxy-clients" # DCR のクライアント登録
default_collection="mcp-oauth-transactions" # IdP コールバックの途中状態
default_collection="mcp-authorization-codes"
default_collection="mcp-jti-mappings"
default_collection="mcp-refresh-tokens" # リフレッシュトークンのメタデータ(ハッシュキー)
リフレッシュが来ると、プロキシは受け取ったトークンをハッシュ化してメタデータを引く。
token_hash = _hash_token(refresh_token)
metadata = await self._refresh_token_store.get(key=token_hash)
if not metadata:
logger.warning(
"Refresh token not found for client=%s (token_hash=%s); it was "
"already rotated, expired, or revoked. Rejecting with invalid_grant, "
"which forces the client to re-authenticate.", ...
)
エントリが無ければ refresh は成立しない。invalid_grant を返し、クライアントはフルの再認可に落ちる。6つのうち2つ、mcp-refresh-tokens(grant 本体)と mcp-oauth-proxy-clients(その grant が紐づく DCR のクライアント登録)が、すべての refresh のゲートになっている。
つまり「refresh token に対応する」は、突き詰めると一つの問いになる。そのストアはどこにあって、誰から見えるのか。
既定のストアはサーバー用途では答えを間違える
client_storage を渡さないと、プロキシが勝手に作る。
# fastmcp/server/auth/oauth_proxy/proxy.py
if client_storage is None:
storage_encryption_key = derive_jwt_key(
high_entropy_material=jwt_signing_key.decode(),
salt="fastmcp-storage-encryption-key",
)
key_fingerprint = hashlib.sha256(storage_encryption_key).hexdigest()[:12]
storage_dir = settings.home / "oauth-proxy" / key_fingerprint
storage_dir.mkdir(parents=True, exist_ok=True)
file_store = FileTreeStore(data_directory=storage_dir, ...)
client_storage = FernetEncryptionWrapper(
key_value=file_store,
fernet=Fernet(key=storage_encryption_key),
raise_on_decryption_error=False,
)
ローカルディスク上の暗号化ファイルストア。長生きする単一プロセスなら理想的だが、再デプロイや水平スケールがある環境では成立しない。Cloud Run / Fly / ECS / Kubernetes のいずれもコンテナ FS はリビジョンごと・レプリカごとなので、refresh の状態はデプロイで消えるか、リクエストを捌いているインスタンスから見えないかのどちらかになる。
ここが直感に反するポイントなので触れておくと、JWT の署名鍵の方は揮発しない。
if jwt_signing_key is None:
if upstream_client_secret is None:
raise ValueError(...)
jwt_signing_key = derive_jwt_key(
high_entropy_material=upstream_client_secret,
salt="fastmcp-jwt-signing-key",
)
上流クライアントシークレットから決定的に導出していて、シークレットは Secret Manager にある。だから再起動後もトークンの検証自体は通る。ただ参照先が無い。症状は署名エラーではなく /token の 401 として出る。
12:53:44 200 /.well-known/oauth-authorization-server
12:53:44 401 https://mcp-dev.example.com/token ← リフレッシュが拒否されている
12:54:37 401 /mcp
同じストアが全インスタンスの土台なので、デプロイしていなくてもスケールアウトだけで同じ失敗が起きる。
共有ストアを設定する
client_storage は py-key-value-aio の AsyncKeyValue なら何でも受け取る。FastMCP が依存として既に引いているので追加インストールは不要。0.4.5 には Firestore / PostgreSQL / Redis / S3 / DynamoDB / MongoDB のストアが同梱されていて、バックエンド選定はコードではなくデプロイの都合で決められる。
うちは Firestore にした。ランタイムのサービスアカウントの ADC で認証できるので、接続文字列もローテーション対象のシークレットも増えないのが決め手。
from cryptography.fernet import Fernet
from fastmcp.server.auth.jwt_issuer import derive_jwt_key
from key_value.aio.protocols import AsyncKeyValue
_JWT_SIGNING_KEY_SALT = "fastmcp-jwt-signing-key"
_STORAGE_ENCRYPTION_KEY_SALT = "fastmcp-storage-encryption-key"
def _derive_storage_encryption_key(client_secret: str) -> bytes:
"""FastMCP と同じ導出チェーンを再現する"""
jwt_signing_key = derive_jwt_key(
high_entropy_material=client_secret,
salt=_JWT_SIGNING_KEY_SALT,
)
return derive_jwt_key(
high_entropy_material=jwt_signing_key.decode(),
salt=_STORAGE_ENCRYPTION_KEY_SALT,
)
def build_client_storage(database: str, namespace: str, project: str, client_secret: str) -> AsyncKeyValue:
from key_value.aio.stores.firestore import (
FirestoreStore,
FirestoreV1CollectionSanitizationStrategy,
FirestoreV1KeySanitizationStrategy,
)
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
from key_value.aio.wrappers.prefix_collections import PrefixCollectionsWrapper
return FernetEncryptionWrapper(
key_value=PrefixCollectionsWrapper(
key_value=FirestoreStore(
project=project,
database=database,
key_sanitization_strategy=FirestoreV1KeySanitizationStrategy(),
collection_sanitization_strategy=FirestoreV1CollectionSanitizationStrategy(),
),
prefix=namespace,
),
fernet=Fernet(key=_derive_storage_encryption_key(client_secret)),
raise_on_decryption_error=False,
)
プロバイダに差し込む。
auth_provider = GoogleProvider(
client_id=os.getenv("FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_ID"),
client_secret=os.getenv("FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_SECRET"),
base_url=os.getenv("FASTMCP_SERVER_AUTH_GOOGLE_BASE_URL"),
client_storage=build_client_storage(...),
)
このラッパの積み方は全部意味がある。以下、層ごとの理由。
暗号化は自分で被せ直す
さっきの既定パスをもう一度見てほしい。FernetEncryptionWrapper は if client_storage is None の中にいる。自前のストアを渡した瞬間、あの暗号化は適用されない。上流 Google のアクセストークンとリフレッシュトークンが mcp-upstream-tokens に、バックエンドが書く生の形(Firestore ならただのドキュメント)で載る。
鍵を同じクライアントシークレットから導出しておけば、管理対象のシークレットは増えず、守っている当の credential と一緒にローテートされる。
raise_on_decryption_error=False を明示する
FastMCP は自前のラッパに False を渡している。FernetEncryptionWrapper の既定は True。
この差はクライアントシークレットをローテートした日に効いてくる。True だと古い暗号文で例外が飛んで 500 を返す。False ならキャッシュミス扱いになり、クライアントが再登録・再認証する。壊れるより劣化で済ませたいので FastMCP に合わせる。
バックエンドに合わせてキーをサニタイズする
OAuthProxy は CIMD(Client ID Metadata Document)を既定で有効にしている(enable_cimd: bool = True)。そして CIMD の client_id は URL だ。
if self._cimd_manager is not None and self._cimd_manager.is_cimd_client_id(client_id):
cimd_client = await self._cimd_manager.get_client(client_id)
if cimd_client is not None:
await self._client_store.put(key=client_id, value=cimd_client)
この URL がそのままストレージのキーになる。Firestore のドキュメント ID に / は使えない。FirestoreStore の既定は無変換のパススルーなので、こちらから指定する必要がある。FastMCP の既定ストアが FileTreeV1KeySanitizationStrategy をわざわざ明示しているのも同じ理由で、どのバックエンドを選ぶにせよキーの制約は確認しておく。
DB を共有するならコレクションを prefix する
さっきの6つのコレクション名は定数だ。2つの MCP サーバーが同じ DB を向くと mcp-oauth-proxy-clients もその他も全部共有してしまう。PrefixCollectionsWrapper(prefix=<サービス名>) で名前空間を切る。Google OAuth の MCP を今後増やす前提なら、これがあって初めて「共有 DB 1個」という構成が成立する。
揮発ストアへフォールバックしない
ストレージのエラーを握って既定のファイルストアに落とす実装は書きたくなるが、やめたほうがいい。黙ってフォールバックすると、ストアを設定して避けたはずの挙動がそのまま戻る。しかも今度は見えない。サービスは健康に見えるのに、次のデプロイで全セッションが静かに消える。設定不備は起動時に落とし、実行時のストレージエラーは表に出す。
まとめ
-
OAuthProxyではクライアントが持つトークンは key-value ストアへの参照。refresh はmcp-refresh-tokensの引き当てと、mcp-oauth-proxy-clientsのクライアント登録で成立する - 既定のストアはコンテナローカルのファイルストアなので、再デプロイにもスケールアウトにも耐えない。JWT の署名鍵はクライアントシークレット由来で生き残るため、症状は署名エラーではなく
/tokenの 401 になる - refresh token に対応するとは、共有の
client_storageを渡すこと。そのうえで暗号化ラッパを被せ直し、raise_on_decryption_error=Falseを合わせ、バックエンドに合ったキーのサニタイズを指定する - DB を複数サーバーで共有するならコレクションを prefix で分離する。揮発ストアへの暗黙のフォールバックは作らない