このブログの記事を、Claudeから検索できるようにした。

チャットの途中で「これ、前にブログに書いたっけ?」と思うことがよくある。そのたびにサイト内検索を開くのは面倒なので、Claudeに聞けば記事を探して読んでくれるようにしたかった。そのためのMCPサーバーを作り、自分のGoogleアカウントでしか使えないようにした。

作ったあと、claude.aiのコネクタにURLを貼って「連携」を押すと、Googleのログイン画面が出て、許可したらもう使えるようになっていた。こちらはClaude用のOAuthクライアントを登録した覚えがない。なのに、Claudeが勝手にクライアントを作ってくれたように見えた。

この記事では、その裏で何が起きていたのかを、実際のアクセス記録で順に追う。

作ったもの

https://omikuji.dev/mcp に、道具が3つあるMCPサーバーを置いた。

  • search_posts: 言葉で記事を探す。全部の語を含む記事が無ければ、一部だけ当たった記事を返す
  • get_post: 1本の本文を読む。長い記事は見出しの一覧を返し、節を指定して読める
  • list_posts: 期間・カテゴリ・タグ・連載で並べる。「10月に何本書いた?」にも答えられる

返すのは公開済みの記事だけだ。中身はもともと誰でも読める記事なので、守りたいのは秘密ではない。「自分の道具を他人に使わせない」ことだ。

認証の選択肢は3つあった

最初に、どう守るかを考えた。

  1. 推測できない長いURLにする(認証なし)。claude.aiにはURLを貼るだけで済む。漏れても読まれるのは公開済みの記事だけ
  2. Cloudflare Accessで、自分のGoogleアカウントだけを通す
  3. 認証なしで、どこにも案内しない

2を選んだ。ただし、MCPでOAuthを自前で書くと結構な量になる。前に書いたMCPの解説で整理したとおり、MCPの認証は次の3つの標準の組み合わせだ。

  • OAuth 2.1(認可コード + PKCE)
  • 保護リソースメタデータ(RFC 9728)
  • 動的クライアント登録(RFC 7591)

認可サーバーを自分で持つと、発行したトークンや登録されたクライアントをどこかに保存しなければならない。Cloudflareのworkers-oauth-providerを使うならKVだ。このブログは、従量課金のデータストアを新しく増やさないと決めている。KVを足すのは避けたかった。

Managed OAuth: 認可サーバーをAccessに任せる

調べると、Cloudflare AccessにManaged OAuthという機能があった(2026年3月の変更履歴に載っている)。

Accessは普通、ブラウザで来た人をログイン画面へ302でリダイレクトする。ところが、MCPのクライアントやCLIはブラウザではないので、302を返されても何もできない。Managed OAuthを有効にすると、Access自身がOAuthの認可サーバーとして振る舞うようになる。

  • 守っている場所は、302の代わりに401とWWW-Authenticateヘッダーを返す
  • 認可・トークン・クライアント登録の各エンドポイントを、Access側で持つ
  • トークンの保存も、Access側でやる

つまり、MCPサーバー側で書くのは「Accessが通したリクエストを受ける」部分だけになる。OAuthのコードは1行も書いていない。

claude.aiが裏でやっていたこと

接続した日の、Cloudflareのアクセス記録から/mcpまわりを抜き出した(時刻は日本時間)。

時刻誰が何を結果
15:48claude.aiのサーバーPOST /mcp(トークンなし)401
15:48claude.aiのサーバー401が指した保護リソースメタデータを取得200
15:49自分のブラウザAccessの同意画面・ログイン200 / 302
15:49Claude(Claude-User)POST /mcp(トークンつき)200

claude.aiのサーバーはpython-httpxとして記録されていた。ログイン後に道具を呼んだのはClaude-Userだ。流れを図にするとこうなる。

自分のブラウザWorker(/mcp)Cloudflare Accessclaude.ai自分のブラウザWorker(/mcp)Cloudflare Accessclaude.aiPOST /mcp(トークンなし)401 + WWW-Authenticate保護リソースメタデータを取得認可サーバーはここ認可サーバーのメタデータを取得認可・トークン・登録のURLクライアント登録(リダイレクト先を申告)client_idログイン画面を開くGoogleでログインして許可認可コード → トークンPOST /mcp + Bearerトークン本人確認済みの署名つきJWTを付けて転送200(道具の一覧・結果)

順に見ていく。

1. 401で「認証の案内はここ」と教える

最初の呼び出しはトークンなしで来る。Accessはこれを401で返し、次のヘッダーを付ける。

www-authenticate: Bearer realm="OAuth", error="invalid_token",
  error_description="Missing or invalid access token",
  resource_metadata="https://omikuji.dev/.well-known/cloudflare-access-protected-resource/mcp"

resource_metadataが指す先(保護リソースメタデータ)には、「このURLを守っている認可サーバーはここ」と書いてある。

{
  "resource": "https://omikuji.dev/mcp",
  "authorization_servers": ["https://<チーム名>.cloudflareaccess.com"]
}

この案内はAccessが返している。Worker側では何も書いていない。

2. 認可サーバーのメタデータ

claude.aiは、教わった認可サーバーの/.well-known/oauth-authorization-serverを読む。ここに、認可・トークン・クライアント登録のURLが並んでいる。

{
  "authorization_endpoint": "https://<チーム名>.cloudflareaccess.com/cdn-cgi/access/oauth/authorization",
  "token_endpoint": "https://<チーム名>.cloudflareaccess.com/cdn-cgi/access/oauth/token",
  "registration_endpoint": "https://<チーム名>.cloudflareaccess.com/cdn-cgi/access/oauth/registration",
  "code_challenge_methods_supported": ["S256"]
}

3. 「勝手にできたOAuthクライアント」の正体

ここが、クライアントが勝手にできたように見えた部分だ。

普通のOAuthでは、開発者が管理画面でアプリを登録し、client_idを受け取ってからでないと始まらない。MCPでは、クライアントが接続のたびにregistration_endpointへPOSTし、その場でclient_idを受け取る。これが動的クライアント登録だ。claude.aiはこれを自動でやっていた。

登録はAccess側のドメインで行われるので、このサイトのアクセス記録には出てこない。

では、誰でも好きなクライアントを登録できるのかというと、そうではない。Accessの設定で、ログイン後に戻ってよい先(リダイレクトURI)を許可リストで絞ってある。試しに、claude.aiの戻り先と、関係ないURLとで登録を送ってみた。

# claude.ai の戻り先 → 登録される
curl -s -X POST https://<チーム名>.cloudflareaccess.com/cdn-cgi/access/oauth/registration \
  -H 'content-type: application/json' \
  -d '{"client_name":"dcr-check","redirect_uris":["https://claude.ai/api/mcp/auth_callback"],"token_endpoint_auth_method":"none"}'
# → client_id が返る

# 関係ない戻り先 → 断られる
# → {"error":"invalid_client_metadata","error_description":"redirect_uri is not allowed by the account configuration"}

仮に誰かがクライアントを登録できても、ログインはAccessのポリシーを通る。通すのは自分のGoogleアカウントだけだ。

4. ログインと同意

claude.aiがブラウザでAccessの認可画面を開き、Googleでログインし、同意画面で許可する。認可コードがclaude.aiの戻り先に渡り、claude.aiがそれをトークンに交換する。

PKCEは必須だ。リクエストにはトークンの宛先(resource)も付く。Accessの前提条件にも「RFC 8707(resource indicators)に対応したクライアント」とあり、claude.aiはこれを満たしていた。

5. トークンつきの呼び出しと、Worker側の検証

あとはAuthorization: Bearer <トークン>つきで/mcpを呼ぶだけだ。

Accessが発行するトークンは中身を読めない形式(oauth:...)で、Workerには届かない。Accessはトークンを検証し、ブラウザでログインしたときと同じ署名つきのJWT(Cf-Access-Jwt-Assertionヘッダー)を付けて、Workerへ転送する。

Worker側では、念のためこのJWTの署名と宛先(AUD)を確かめている。Accessの設定を誤って外したときの保険で、JWTが無ければ403を返す。Accessを通さない呼び出しは、ここで止まる。

設定したこと

Accessのアプリ(パス指定)

omikuji.dev/mcpのパスだけを守るAccessのアプリを、Terraform(OpenTofu)で作った。

resource "cloudflare_zero_trust_access_application" "blog_mcp" {
  name                      = "Blog MCP (omikuji.dev/mcp)"
  domain                    = "omikuji.dev/mcp"
  type                      = "self_hosted"
  auto_redirect_to_identity = true
  allowed_idps              = [cloudflare_zero_trust_access_identity_provider.google.id]
  # account_id など省略
}

ポリシーは「自分のメールアドレスだけ許可」の1行だ。

Managed OAuthはAPIで有効にした

使っているCloudflareのTerraformプロバイダ(v4系)には、Managed OAuthの設定項目が無かった。そこで、アプリを作ったあとにAPIで足した。

"oauth_configuration": {
  "enabled": true,
  "dynamic_client_registration": {
    "enabled": true,
    "allow_any_on_localhost": true,
    "allow_any_on_loopback": true,
    "allowed_uris": [
      "https://claude.ai/api/mcp/auth_callback",
      "https://claude.com/api/mcp/auth_callback"
    ]
  },
  "grant": { "access_token_lifetime": "15m", "session_duration": "720h" }
}
  • allowed_uris: 上で試した、戻り先の許可リスト
  • allow_any_on_localhost: Claude Codeのような、手元のCLIの戻り先(localhost)を許す
  • access_token_lifetime: アクセストークンは15分。切れたらリフレッシュトークンで取り直し、そのたびにAccessのポリシーを評価し直す
  • session_duration: 再ログインは30日に1回

注意点が1つある。APIの更新はPUTで丸ごと置き換えなので、いったんGETした中身にoauth_configurationだけを足して送り返した。足したあとにtofu planを流しても、差分は出なかった。ただし、v4のプロバイダはこの設定を知らない。Terraformでこのアプリを変更すると消える可能性があるので、変更したら有効化のスクリプトを流し直すことにしている。

Worker側: 記事の索引は/mcpのときだけ読む

ブログのWorkerの入口で、パスが/mcpのときだけMCPの処理を読み込む。

const MCP_PATH = '/mcp';

export default {
  async fetch(request, env, ctx) {
    if (new URL(request.url).pathname === MCP_PATH) {
      const { handleBlogMcp } = await import('./mcp/entry.ts');
      return handleBlogMcp(request, env);
    }
    // 以下、いつものブログの処理
  },
};

記事の索引(本文込みで約1.9MB)は、ビルド時にJSONで作ってWorkerに同梱している。静的に読み込むと、ブログを読みに来た人のリクエストでも毎回この1.9MBを読むことになる。そこで動的importにした。ビルドの出力では、索引が別のファイル(チャンク)に分かれていて、/mcpが呼ばれたときだけ読まれることを確かめた。手元のNodeで計ると、索引の読み込みは41ms、検索は1msだった。

予約中の記事は索引に入っているが、問い合わせのたびに日付で落としている。公開日の0時までは検索にも出ないし、get_postで名前を指定しても「無い」と返す。

最初は別のドメインに置いていた

実は最初、blog-mcp.omikuji.devという専用のサブドメインに置いた。理由は、Accessのドキュメントがドメイン全体を守るアプリを前提に書かれていたことだ。パスだけを守る形でManaged OAuthが動くかどうかの記述が見当たらなかった。OAuthの案内(/.well-known/...)はドメインの直下に出るものなので、パス指定ではうまく噛み合わないかもしれない、と考えた。公開ブログの全体を誤ってAccessの後ろに入れてしまう事故も避けたかった。

ところが「余計なドメインを増やしたくない」と思い直して、パス指定で試し直した。結果は、普通に動いた。401のresource_metadataが、パスつきの案内(/.well-known/cloudflare-access-protected-resource/mcp)を指していたからだ。Accessはパス単位の案内を自分で返してくれる。

ドメイン直下の/.well-known/oauth-protected-resourceは、ブログの「存在しないページ」(410)のままだ。それでも、MCPの仕様ではクライアントはWWW-Authenticateのresource_metadataを先に使うので、claude.aiは迷わず接続できた。

しかも、パス指定のAccessには前例があった。このブログの/admin/(予約記事の一覧。今は別の集計ページに移した)は、前からパス指定のAccessで守っていた。先にそれを思い出していれば、サブドメインを作らずに済んだ。

まとめ

  • claude.aiがOAuthクライアントを「勝手に作った」ように見えたのは、動的クライアント登録(RFC 7591)。接続のたびにクライアントが自分で登録する、MCPの標準の仕組み
  • 勝手に登録できても安全なのは、戻り先の許可リストとログイン時のAccessのポリシーの2段で絞っているから
  • Cloudflare Access の Managed OAuth を使えば、認可サーバー・トークンの発行と保存・クライアント登録を全部Accessが持つ。MCPサーバー側はOAuthのコードを書かずに済み、KVのような保存先も要らない
  • Worker側では、Accessが付けるJWTを念のため確かめる
  • Accessはパスだけを守る形でも動く。サブドメインを増やす必要はなかった