ブログ技術

リモートMCPサーバーのOAuth:本番運用で学んだこと

Supabase Authを認可サーバーとして使う、本番のMCPサーバーからのメモ。仕様が求めること、間違えたこと、まだ試していないことをまとめます。

執筆
公開日
更新日
長さ
約12分で読めます

先に結論

リモートMCPサーバーは、OAuthの保護されたリソースです。トークンがなければ401を返し、RFC 9728のメタデータを示します。クライアントはそれをたどって認可サーバーへ行き、登録し、PKCEとresourceパラメーターを使ってユーザーをログインさせ、サーバーのURL向けに発行されたベアラートークンを持って戻ってきます。この流れを、Supabase Authで本番運用しています。手間がかかった細部は3つです。スコープのヒントを付け、エラーコードは付けない401。Supabaseのトークンにオーディエンスとスコープを付けるトークンフック。そして動的クライアント登録です。ScoreStarlingの認可サーバーは、クライアントIDメタデータドキュメントに対応していないためです。

MCPサーバーがOAuthを使うとき、誰が何をしますか?

MCPサーバーの仕事がいちばん小さく、ベアラートークンを受け入れるか、拒否するかだけです。MCP認可仕様(バージョン2025-11-25)は、MCPサーバーをOAuth 2.1のリソースサーバー、アシスタントをOAuthクライアントと位置づけ、ユーザーのログインとトークンの発行を行う認可サーバーを、別のサービスにすることを認めています。

ScoreStarlingの認可サーバーは別になっています。Supabase AuthのOAuth 2.1サーバーがすべてのトークンを発行し、ログインと同意の画面はウェブサイトが用意し、https://mcp.scorestarling.com/mcpのエンドポイントはトークンを検証するだけです。MCPセッションを使わないStreamable HTTPで動き、リクエストのたびにトークンを確認します。ScoreStarlingは録音を編集できる楽譜にするサービスで、アシスタントはこのエンドポイントを通じてそこにアクセスします。ユーザーが踏む手順はセットアップガイドに載せています。

MCPクライアントが接続すると、何が起きますか?

クライアントは、URLだけを持って始めます。ログイン先を教えるのは、ScoreStarlingのサーバーからの2つの応答です(2026年10月3日時点のものを示します)。そのあと、さらに6つの手順でトークンが手に入ります。最初に、トークンのないリクエストには401が返り、そのWWW-Authenticateヘッダーが、メタデータと、要求するスコープを示します:

$ curl -si -X POST https://mcp.scorestarling.com/mcp \
    -H 'content-type: application/json' \
    -H 'accept: application/json, text/event-stream' -d '{}'
HTTP/2 401
content-type: application/json
www-authenticate: Bearer resource_metadata="https://mcp.scorestarling.com/.well-known/oauth-protected-resource/mcp", scope="openid email profile"

{"error_description": "Authentication required"}

次に、クライアントはそのメタデータを取得します。RFC 9728では、ホストとパスの間に/.well-known/oauth-protected-resourceを挿入してアドレスを作ります。また、resourceはクライアントが使っているURLと同一であることが求められ、違っていればクライアントは中止しなければなりません。Supabaseのプロジェクト参照は、ここでは置き換えてあります:

{
  "resource": "https://mcp.scorestarling.com/mcp",
  "authorization_servers": ["https://<project-ref>.supabase.co/auth/v1"],
  "scopes_supported": ["openid", "email", "profile"],
  "bearer_methods_supported": ["header"]
}

残りの6つの手順:

  1. クライアントは、認可サーバーのメタデータを読みます。Supabaseの発行者にはパスが含まれるため、RFC 8414では、その前にwell-knownのセグメントを置きます:https://<project-ref>.supabase.co/.well-known/oauth-authorization-server/auth/v1。クライアントは、issuerが完全に一致すること、そしてcode_challenge_methods_supportedにS256が載っていることを確認します。MCP仕様は、このフィールドがなければ中止するよう定めています。

  2. 自分のメタデータを、公開されているregistration_endpointに送信し(動的クライアント登録、RFC 7591)、client_idを受け取ります。テスト用のクライアントは、シークレットなしのパブリッククライアントとして登録します。Anthropicのドキュメントによると、Claudeも同じです。

  3. ブラウザで認可エンドポイントを開きます。このとき、PKCE S256のチャレンジ、401に含まれていたスコープ、resource=https://mcp.scorestarling.com/mcp(RFC 8707)を付けます。Supabaseはブラウザを、ScoreStarlingの同意画面に引き渡します。同意画面は、必要ならユーザーをログインさせ、そのクライアントを許可するか拒否するかを尋ねます。

  4. PKCEのverifierと同じresourceを添えてコードを交換し、1時間有効なアクセストークンと、リフレッシュトークンを受け取ります。

  5. MCPのリクエストのたびに、Authorization: Bearer …を送ります。ScoreStarlingでは、署名を発行者の公開鍵と照らし、さらに発行者、オーディエンス、有効期限、書き込みスコープを確認し、そのあとアカウントが無効にされていないかを確認します。

  6. トークンの有効期限が切れたとき、またはScoreStarlingがinvalid_token付きの401を返したときは、再びresourceを添えて更新し、新しいアクセストークンとリフレッシュトークンを受け取ります。

クライアントは、認可サーバーが無視する場合でも、認可リクエストとトークンリクエストにresourceを付けて送る必要があります。SupabaseのOAuthフローのドキュメントには、このパラメーターへの言及がありません(2026年10月3日確認)。実際の実行ではすべて、Supabaseはこれを受け付けました。トークンのオーディエンスは、フックから付いたものです。

401のWWW-Authenticateヘッダーには、何を入れるべきですか?

トークンが送られていないときは、メタデータの場所と、要求するスコープを入れ、エラーコードは入れません。RFC 6750 §3.1は、認証情報がまったくないリクエストには、エラーコードを付けるべきではないとしています。invalid_tokenは、送られたものの失敗したトークンのためにあり、新しいトークンを取得してやり直してよいことをクライアントに伝えます。MCP仕様はさらに、401にscopeのヒントを含めるべきだとしていて、クライアントはそれを、要求するスコープとして扱わなければなりません。

セットアップガイドを書いているときに、自分たちのサーバーがこれを間違えていることに気づきました。MCP Python SDK 1.30.0では、RequireAuthMiddlewareが、認証されていないすべてのリクエストに、スコープなしのerror="invalid_token"を返します。そのため、一度もトークンを送っていないクライアントが、トークンは無効だと告げられていました。現在は、MCPのルートを包む小さなASGIミドルウェアが、SDKの401だけを書き換えます。誰を通すかは今もSDKが決め、SDKが返す403のinsufficient_scopeには手を加えていません:

# No token, before the fix (the SDK's default; wrapped for reading)
WWW-Authenticate: Bearer error="invalid_token",
    error_description="Authentication required",
    resource_metadata="…/.well-known/oauth-protected-resource/mcp"

# No token, now
WWW-Authenticate: Bearer
    resource_metadata="…/.well-known/oauth-protected-resource/mcp",
    scope="openid email profile"

# Rejected token: expired, wrong audience, malformed or account turned off
WWW-Authenticate: Bearer error="invalid_token",
    error_description="Invalid or expired access token",
    resource_metadata="…/.well-known/oauth-protected-resource/mcp",
    scope="openid email profile"

ヒントに挙げているのはopenid email profileで、Supabaseが付与できるスコープであり、scopes_supportedとも一致します。デプロイ時のチェックは、トークンなしのチャレンジにエラーコードが付いていると、失敗するようになりました。この修正は、マージから4分以内の2026年10月2日に本番へ反映されました。Anthropicのコネクタのドキュメントには、Claudeについてのルールがさらに2つあります。ログインは401からしか始まらず(200で返されたWWW-Authenticateは無視されます)、authorization_serversの先頭の項目しか使いません。

SupabaseのトークンにMCPのオーディエンスとスコープを入れるには、どうしますか?

カスタムアクセストークンフックを使います。MCP仕様は、サーバーが自分宛てに発行されたトークンだけを受け入れなければならないとしていて、実際にはaudの確認を意味します。またScoreStarlingは独自の書き込みスコープも欲しかったのですが、Supabaseのドキュメントには、OAuthサーバーはカスタムスコープに対応していないと書かれています(2026年10月3日確認)。フックは、Supabase Authがトークンを発行する前に実行するPostgres関数で、クレームを書き換えられます。ScoreStarlingのフックを、マイグレーションから少し簡略化して示します:

create or replace function scorestarling.access_token_hook(event jsonb)
returns jsonb language plpgsql stable security invoker set search_path = '' as $$
declare claims jsonb := event->'claims';
begin
  -- Tokens issued to an OAuth client carry its client_id; website sessions don't.
  if coalesce(claims->>'client_id', '') <> '' then
    claims := jsonb_set(claims, '{aud}', '"https://mcp.scorestarling.com/mcp"');
    claims := jsonb_set(claims, '{scope}', '"openid email profile scorestarling:write"');
  end if;
  return jsonb_build_object('claims', claims);
end;
$$;
grant usage on schema scorestarling to supabase_auth_admin;
grant execute on function scorestarling.access_token_hook(jsonb) to supabase_auth_admin;
revoke all on function scorestarling.access_token_hook(jsonb) from public, anon, authenticated;

MCP側では、PyJWTでそれらのクレームを検証し、そのうえで独自のルールを2つ適用します:

claims = jwt.decode(token, key, algorithms=["RS256", "ES256"],
                    issuer=ISSUER, audience=ACCEPTED_AUDIENCES,
                    options={"require": ["exp", "iat", "iss", "aud", "sub"]})
if "scorestarling:write" not in claims.get("scope", "").split():
    return None  # the client gets 401 invalid_token
# then: has this account been turned off?

ACCEPTED_AUDIENCESはMCPのURLで、移行中は古いURLも加えます。クライアントIDの確認は、ウェブサイト自身のログインで得たトークンを通さないためのものです。同じSupabaseプロジェクトのブラウザセッションのトークンは、発行者も鍵も同じですが、client_idがないので、MCPのオーディエンスは付きません。

このフックには、2つの代償があります。1つは、クライアントのresourceに従わず、決まったオーディエンスを1つ付けることで、1つの認可サーバーが1つのMCPサーバーに対応している間は問題なく動きます。もう1つは、同意画面に表示できるのが、クライアントが要求したスコープだけで、クライアントを許可することが、書き込みスコープを与えることになる点です。

未解決の報告supabase/auth#2820(2026年9月20日に起票、10月3日の時点で返信なし)は、Supabaseの認可の詳細を取得するリクエストが、パブリッククライアント、offline_access、resourceのいずれかで400を返すと述べています。ScoreStarlingの環境では、どれも再現しませんでした。確認したSupabase Authのソースでは、この400が出るのは、保留中でなくなった認可に対してだけで、たとえば2回取得された認可がそうです。

動的クライアント登録とクライアントIDメタデータドキュメントのどちらを使いますか?

認可サーバーが公開しているほうを使います。現在のSupabaseでは、動的クライアント登録(DCR)です。2025-11-25のMCP仕様は、クライアントとサーバーが初対面の場合にはクライアントIDメタデータドキュメント(CIMD)を勧め、DCRは後方互換のために残しています。クライアントは、まず事前に渡されたクライアントIDを試し、次にCIMD、次にDCRを試し、それでもだめな場合にだけ、ユーザーに尋ねるべきです。

MCPクライアントがクライアントIDを得る方法
方式クライアントの名乗り方サーバーのメタデータ上のシグナルClaudeのフォームでの選択肢ScoreStarlingのサーバー(2026年10月3日)
事前登録あらかじめ作成したクライアントID。手で入力するか、組み込んでおく不要「Use your own OAuth client」(独自のOAuthクライアントを使う)提供なし
クライアントIDメタデータドキュメントclient_idが、クライアントがホストするJSONファイルへのHTTPSのURLで、サーバーがそれを取得するclient_id_metadata_document_supported「Use Claude’s published identity (recommended)」(Claudeが公開しているIDを使う、推奨)公開していない
動的クライアント登録メタデータを登録エンドポイントに送信し、client_idを受け取るregistration_endpoint「Register automatically」(自動登録)公開している

Supabaseのメタデータを2026年10月3日に読み直したところ、登録エンドポイント、S256、トークンエンドポイントの方式noneは載っていますが、CIMDのフラグはありません。SupabaseのMCPガイドでは、ダッシュボードで動的登録をオンにするよう案内されています。

これで、Claudeのカスタムコネクタのフォームで何を選ぶかが決まります。Anthropicのドキュメントは、推奨の選択肢であるClaudeの公開IDを、Anthropicがホストし、サーバー側が対応している必要があるCIMDだと説明しています。そこでScoreStarlingのガイドでは、アカウントなしで使えるツールがないため「Sign in now」(今すぐログイン)と、「Register automatically」(自動登録)を選んでもらっています。Anthropicの開発者向けドキュメントによると、CIMDが公開されていない場合、ClaudeはDCRにフォールバックします(CIMDには、token_endpoint_auth_methods_supportedにnoneがあることも必要です)。ただし、フォームの既定の選択のままでは試していないので、明示的に選ぶようお願いしています。2026年10月2日に読んだクライアント側のドキュメントには、ChatGPTの開発者モード、Cursor、VS Code、Gemini CLIでのDCRが記載されています。

DCRには代償があります。Anthropicによれば、Claudeは新しく接続するたびに新しいクライアントを登録しますし、Supabaseは、動的登録を使うと、どのMCPクライアントでもプロジェクトに登録できてしまうと警告しています。ただし、登録するだけでは何も許可されません。ユーザーがログインしてそのクライアントを許可する必要があり、トークンの検証側はリクエストのたびにアカウントを確認します。どのクライアントもどんな名前でも登録できるため、同意画面では、2026年10月6日から、許可するとユーザーがどこに送られるかを表示しています。また、アプリがChatGPTやClaudeを名乗っているのに、そのアシスタントのアドレスに戻らない場合は、警告を出します。

アクセストークンの期限が切れたあとも、MCPクライアントはログインしたままですか?

はい。アクセストークンの有効期間は1時間で、クライアントはリフレッシュトークンで更新します。これに頼る前に、Supabase Authのソース(2026年9月22日時点のmaster)を読みました:

  • コードを交換するたびに、offline_accessの有無にかかわらず、リフレッシュトークンが返る
  • 更新は、そのセッション自身のクライアントから行う必要があり、リフレッシュトークンをローテーションして、client_idクレームを引き継ぐ
  • フックは更新のときにも実行される(authentication_methodはtoken_refresh)ので、オーディエンスとスコープは毎回付く。

2026年10月2日の実環境での実行も、同じ結果でした。リフレッシュトークンはローテーションされ、更新されたアクセストークンも使えました。Anthropicのドキュメントによると、Claudeは401のあと、および有効期限の最大5分前に更新を行い、認可サーバーがscopes_supportedに載せている場合はoffline_accessを追加します。Supabaseのメタデータはそれを載せていて、offline_accessも要求した私たちのチェックも通りました。

次の場合は、セッションが終わります:

  • アカウントが無効にされたとき、またはユーザーが削除または停止されたとき(実環境での実行では、アカウントを無効にすると、ブラウザのセッションと2つのMCPトークンが拒否されました)
  • 古いリフレッシュトークンが、Supabaseの再利用間隔(既定では10秒)を過ぎてから再び使われ、セッション全体が失効したとき
  • セッションの時間制限、無操作タイムアウト、「Single session per user」(ユーザーごとに1セッション)のいずれかをオンにしたとき。最後のものはオフにしておいてください。オンだと、ウェブサイトでログインした時点で、アシスタントのセッションが終わります
  • ウェブサイトからのログアウトに、Supabaseの既定のlogoutが使われたとき。これはアシスタントを含め、アカウントのすべてのセッションを終了させます。ScoreStarlingは2026年10月6日までそうなっていて、あるChatGPTの接続がトークンを更新できなくなった原因は、おそらくこれです。現在は「ログアウト」がscope=localを送り、そのブラウザのセッションだけを終わらせます。

Supabaseは失効エンドポイントを公開していない(2026年10月3日確認)ため、アクセスを遮断するには、アカウントを無効にします。

発行者(issuer)の末尾のスラッシュが、問題になるのはなぜですか?

発行者は、1文字ずつ比較されるからです。RFC 8414は、メタデータのissuerが、クライアントがそのメタデータを見つけるときに使った発行者と同一であることを求め、RFC 9728もresourceに同じことを求めています。厳密なクライアントにとって、https://auth.example.comとhttps://auth.example.com/は、別の発行者です。

MCP Python SDKは発行者をpydanticのAnyHttpUrlとして保持しますが、pydantic 2.13.5(ScoreStarlingのロックファイルにあるバージョン)は、https://auth.example.comをhttps://auth.example.com/に変えます。これをもとに作ったメタデータは、認可サーバーが一度も公開していない発行者を名指ししてしまいます。そのため、サーバー側では、設定したとおりの発行者を使って、保護されたリソースのメタデータを自前で書いています。Supabaseの発行者はパスで終わり、pydanticはパスのあるURLには手を加えないので、本番で問題になったことはありません。ドメインだけでパスのない発行者なら、問題になります。デプロイ時のチェックは、認可サーバーのissuerがScoreStarlingの設定と完全に一致しないと、失敗します。

確認できたことと、確認できていないこと

最初の裏づけは、MCP仕様に従うスクリプトのクライアントから得ました。2026年10月2日の本番に対する1回の実行で、次を確かめました。トークンなしの401、パブリッククライアントの動的登録、resource付きの同意とコード交換、ローテーション付きの更新、2つのトークンでのtools/list、そしてアカウントを無効にすると、2つのトークンとブラウザのセッションが拒否されること。2回目の実行ではoffline_accessも要求し、どちらも通りました。この実行では以前のアドレスを使っています。mcp.scorestarling.comへの移行は、専用の記事にまとめています。

その後、実際のクライアントも接続しました。2026年10月3日には、ChatGPTの開発者モードのアプリがmcp.scorestarling.com経由でログインしてツールを呼び出し、10月6日には、Claudeが接続してツールを実行しました。まだ確認できていないのは、次の点です:

  • トークンが実際に期限切れになったあとの更新。スクリプトでの更新はログイン直後に実行したもので、実際のクライアントからの更新は追跡できていない
  • Supabaseのホスト型のセッション設定。こちらは読み戻して確認していない。

出典

  1. Authorization, MCP specification version 2025-11-25、Model Context Protocol
  2. RFC 9728: OAuth 2.0 Protected Resource Metadata、IETF
  3. RFC 6750: The OAuth 2.0 Authorization Framework: Bearer Token Usage, §3.1、IETF
  4. RFC 8707: Resource Indicators for OAuth 2.0、IETF
  5. RFC 7591: OAuth 2.0 Dynamic Client Registration Protocol、IETF
  6. RFC 8414: OAuth 2.0 Authorization Server Metadata、IETF
  7. OAuth Client ID Metadata Document (Internet-Draft)、IETF OAuthワーキンググループ
  8. Authentication for connectors、Anthropic
  9. Add a connector that isn’t in the directory、Anthropic
  10. OAuth 2.1 Flows、Supabase
  11. Custom Access Token Hook、Supabase
  12. Model Context Protocol (MCP) Authentication、Supabase
  13. User sessions、Supabase
  14. supabase/auth issue #2820、GitHub

よくある質問

リモートMCPサーバーには、専用の認可サーバーが必要ですか?

いいえ。MCPの認可仕様では、認可サーバーを別のサービスにできます。MCPサーバーは、その認可サーバーを示すRFC 9728のメタデータを公開し、あとは、認可サーバーが発行したトークンを、オーディエンス(audience)も含めて検証します。ScoreStarlingのサーバーはSupabase Authを指していて、トークンの検証だけを行います。

トークンが送られていないとき、MCPサーバーの401にinvalid_tokenエラーを含めるべきですか?

含めません。RFC 6750 §3.1は、認証情報のないリクエストには、エラーコードを付けないものとしています。代わりにresource_metadataとscopeのヒントを送ります。invalid_tokenは、送られたうえで拒否されたトークンのために取っておきます。その場合は、クライアントにトークンの更新を促す意味があります。

Supabase Authで、MCPサーバー用のカスタムスコープを発行できますか?

2026年10月3日時点では、できません。SupabaseのOAuth 2.1のドキュメントに、カスタムスコープは未対応と書かれています。ScoreStarlingはopenid email profileを要求し、独自のスコープとMCPのオーディエンスは、カスタムアクセストークンフックで追加しています。このフックは、トークンを更新するたびにも実行されます。

サーバーがCIMDに対応していない場合、Claudeのカスタムコネクタのフォームでは何を選べばいいですか?

「OAuth client」(OAuthクライアント)では、動的クライアント登録を使う「Register automatically」(自動登録)を選びます。すべてのツールにアカウントが必要なら、「Authentication」(認証)では「Sign in now」(今すぐログイン)を選びます。Claudeが推奨する、公開されているIDはクライアントIDメタデータドキュメントで、サーバーが対応している必要があります。Claudeの手順に、両方の選択を載せています。

Supabaseからリフレッシュトークンを得るのに、offline_accessスコープは必要ですか?

私たちのテストでは不要でした。Supabaseのソースコードでも、2026年10月2日の実環境での実行でも、offline_accessがなくても、コードを交換するたびにリフレッシュトークンが返りました。それでも要求するクライアントはあります。Anthropicのドキュメントによると、Claudeは認可サーバーがそれを一覧に載せているときに要求し、要求した場合のチェックも通りました。

どんな音楽も楽譜になって手元に