スマホ完結のブログ公開と、claude.aiが自分のブログを検索できる自作MCPコネクタ(OAuth 2.1 + Cloudflare Tunnel + launchd)を作った話

シリーズ: AIが読めるブログ(前回: WordPressとnoteをやめて、AIが読めるブログをAstro + Cloudflare Pagesで作った話)

前回、ブログの器を Markdown + Git + Astro + Cloudflare Pages で建て直しました。今回はその運用編です。ゴールは2つ置きました。

  1. 人間側: 記事の執筆から公開・note転載まで、スマホだけで完結させる
  2. AI側: チャットのClaude(claude.ai)が、会話の中で私のブログを自分から検索・参照できるようにする(自作MCPコネクタ)

どちらも達成し、さらにおまけとして「再起動検収で設計の穴が露見して直す」という教訓付きの顛末になったので、まとめて記録します。

全体像

スマホ完結の公開経路とMCP閉ループの全体構成

  • 書く: スマホのObsidian(Gitプラグイン)でブログのリポジトリを直接編集。自動同期
  • 画像: Windows機の画像生成(Forge)→ NAS → スマホで選定 → リポジトリの inbox/
  • 公開: Mac Studio上のClaude Codeにスマホからリモートで /post を指示
  • 読む(AI): claude.aiのカスタムコネクタとして自作MCPサーバを登録。ブログ原文を検索するツールをClaudeが会話中に呼ぶ

Part 1: スマホ完結の公開経路

Obsidian + Gitプラグインを「本命の編集手段」にする

編集手段はいくつか試せますが、本命はスマホのObsidianにブログリポジトリそのものをvaultとして持たせる構成にしました。Live Previewで装飾表示のまま書け、Gitプラグインで直接pushできます。

同期設定は2つだけです。

  • Pull on startup: オン。開いた瞬間に最新化。分岐・コンフリクトの主対策はこれ
  • Auto commit-and-sync: 編集停止から3分後。commit→pull→pushを自動実行

この設定だと作法は「開く→書く→閉じる」だけになります。書いた直後に閉じても変更は消えず、次回起動時の自動commitで回収されます(到着が遅れるだけ)。リスクが顕在化するのは「スマホに未push分がある状態でPC側が同じファイルを編集」したときだけで、一人運用ではほぼ起きません。

認証はFine-grained PATで、対象リポジトリはブログ1つ・権限はContents Read/Writeのみ・期限90日に絞りました。スマホに載せる資格情報は漏洩前提で最小に切るのが安心です。

踏んだ地雷: symlinkとゴミ箱

  • リポジトリ内の CLAUDE.mdAGENTS.md へのsymlinkにしていたら、モバイル版Obsidianがsymlinkを扱えず削除してしまう問題を踏みました。実ファイル化で解決
  • .obsidian/(端末ごとの設定)と .trash/(Obsidianのゴミ箱)は .gitignore へ。端末ローカルの状態をコミットに混ぜないための基本です

画像パイプライン: 生成PCからスマホ経由で記事へ

アイキャッチはWindows機のStable Diffusion(Forge)で生成します。経路はこうです。

Windows機 Forge(生成)
  → NASの共有フォルダへ一方向同期(Synology Drive)
  → スマホのDriveアプリで閲覧・選定・ダウンロード
  → <記事slug>.png にリネームして Obsidian vault の inbox/ に配置
  → 自動同期でpush
  → Mac側の /post コマンドが inbox/ から自動取り込み(取り込み後は削除)

ポイントは inbox/git管理下に置いたことです。当初は「inboxはgit管理外にして、NAS同期などで届ける」運用でしたが、スマホからの受け渡しルートとしてはgitに乗せてしまう方が確実で、経路も1本に統一できます。バイナリがリポジトリに一時的に入りますが、取り込み後に削除されるフローなので肥大は限定的、と割り切りました。

小ネタ: 生成中にスマホのブラウザを離れるとGradioのギャラリー表示は失われますが、生成物はサーバ側に保存済みなので慌てる必要はありません。NAS側から拾えます。

note転載もスマホで完結させる

このブログはnoteを転載チャネルとして残しています。note用の変換コマンド(/note-ver)の出力先は当初クリップボード(pbcopy)でしたが、2つの理由でファイル出力に変更しました。

  1. pbcopy経由だと日本語が化けるバグを踏んだ
  2. クリップボードはMacのローカルにしか存在せず、スマホから使えない

変換結果を inbox/note-<slug>.md に書き出す方式にしたことで、スマホのObsidianで開いてコピーし、noteアプリに貼るだけになりました。これが「スマホ完結」の最後のピースでした(noteへの画像添付だけは手動です)。

Part 2: claude.aiに自分のブログを読ませる(自作MCPコネクタ)

動機: llms.txtの一歩先へ

前回記事のとおり、このブログは llms.txt と記事URL+.md でAIが原文を読める作りにしてあります。ただこれは「AIにURLを渡せば読める」であって、会話中のClaudeが自分から過去記事を探しに行くことはできません。「前にブログに書いた気がする」をClaude自身に検索させたい。それがMCPコネクタ化の動機です。

構成

  • MCPサーバ: Mac Studio上に自作(TypeScript SDK)。ツールは search_blog / search_chatlog / get_document の3つ。すべて読み取り専用で、対象ディレクトリ外へのパストラバーサルは拒否
  • 公開経路: Cloudflare Named Tunnelで、所有ドメインのサブドメインに固定公開。自宅へのポート開放はなし
  • DNS: ドメインのネームサーバをお名前.comからCloudflareへ移管。既存のWordPressとメールはさくらのサーバに残したままなので、**全レコードをDNS only(プロキシなし)**にして無停止で移行

claude.aiカスタムコネクタの「実測仕様」

ここが本記事でいちばん検索価値のある部分だと思います。公式ドキュメントだけでは分からず、アクセスログの実測で確定させたclaude.ai側の挙動が4つあります(2026年7月時点)。

  1. 認証はOAuthのみ。カスタムコネクタのGUIにBearerトークンを入力する欄はなく、静的トークン方式は選べない。OAuth 2.1を実装するしかない
  2. Dynamic Client Registration(DCR)を無条件に開始する。事前にclient_idを渡す手段はなく、/register エンドポイントの実装が必須
  3. MCPリクエストは「登録したURLのルート」にPOSTされる/mcp のようなパスを掘っても無視されるため、MCPエンドポイントはルート / に置く必要がある
  4. ツール権限を「承認が必要」にすると、承認ダイアログは表示されず、モデルは黙ってツールをスキップする(代替手段としてllms.txtのfetchに逃げる)。コネクタを機能させるには「常に許可」で運用するしかない

特に4は気づきにくい挙動です。「接続済みなのにツールが使われない」ときは、権限設定を疑ってください。

OAuth 2.1実装の落とし穴

SDKの mcpAuthRouter を土台に、DCR・PKCE(S256)・認可サーバ同居の構成で実装しました。動くまでに踏んだ落とし穴を列挙します。

  • 無トークンの401に WWW-Authenticate ヘッダで resource_metadata を返すこと。これがないとclaude.aiが認可サーバを発見できない
  • /.well-known/oauth-authorization-server/.well-known/oauth-protected-resource を正しい場所に置くこと
  • issuerと公開URLの完全一致。開発時の検証用トンネル(URLが毎回変わる)では「先にトンネルを起動してURLを確定→そのURLを PUBLIC_URL としてサーバを起動」という順序が必須でした。逆順だとissuerがlocalhostになり認可が壊れます
  • 認可エンドポイント(/authorize)にはBasic認証を追加。DCRは誰でもクライアント登録できてしまうため、認可の瞬間に人間しか知らないパスワードを挟むゲートとして機能します

ローカルでOAuthのフル往復(DCR→authorize→token→ツール呼び出し)をcurlで通してから固定URLに移行したことで、「実装の問題」と「claude.ai固有の挙動」を切り分けられたのは進め方として正解でした。

接続後の試金石クエリは「search_blogで魚油を検索して」。チャットのClaudeが私のブログの過去記事を検索して答える——閉ループの一周目です。

Part 3: 常駐化と再起動検収 —「メモリだけの状態」は必ず露見する

launchdで常駐化(pm2は選ばなかった)

手動でターミナル2枚(サーバとトンネル)を開いておく運用は単一障害点なので、常駐化しました。方式は**launchd(LaunchAgent)**です。pm2も検討しましたが、「Node製の常駐マネージャを常駐させるための追加レイヤー」になるため、OSネイティブのlaunchdに任せる方が素直と判断しました。

構成はLaunchAgent 2本(サーバ用・トンネル用)で、共通設定は:

  • RunAtLoad(ログイン時起動)+ KeepAlive(異常終了で自動再起動)
  • ThrottleInterval 30(クラッシュループ抑止)
  • 秘密情報はplistに直書きせず、既存の .env から読む
  • 2本は互いに依存させない(個別に再起動可能)

なお cloudflared service install という公式の常駐化コマンドもありますが、sudo(root権限のLaunchDaemon)が必要だったため、ユーザー権限のLaunchAgentで統一しました。

再起動が怖い問題は「棚卸し」で作業に変える

検収には実際のMac再起動が必要ですが、常駐物が多い機体の再起動は心理的な障壁があります。ここで先に常駐サービスの棚卸し(読み取りのみ・変更なし)をやったのが効きました。全常駐物を次の3分類に仕分けます。

分類 内容 再起動後
A LaunchDaemon(ログイン不要) 全自動で復旧
B LaunchAgent+Docker(要ログイン) ログインすれば全自動
C tmuxセッション 手動で再作成

結果、手動復旧が必要なのはtmux 1件だけと判明。「怖い」の正体は「何が死ぬか分からない」であって、リストにした瞬間、勇気は単なる作業手順に変わりました。自動ログインをONにしてあるため(据え置きの常駐サーバ用途なので割り切り)、停電からの無人復帰でもBまで自力で戻ります。

検収: ほぼ合格、ただし一つ沈黙

再起動後、healthzの外部応答・Docker・同期系まですべて自動復旧。トンネル側にexit code 1が1回記録されていましたが、これはネットワーク確立前の接続失敗をKeepAliveが拾って再起動した痕跡で、むしろ自動復旧が機能した証拠です(以後も起動直後の1回は正常挙動として扱っています)。

ところが、claude.aiのコネクタだけが沈黙しました。設定画面では「接続済み・常に許可」なのに、チャットのClaudeは「そんなツールは存在しない」という前提で振る舞う。

原因は実装仕様にありました。DCRのクライアント登録と発行済みトークンを、サーバがメモリにしか持っていなかったのです。launchdでプロセスは復活しても、claude.aiが持っているトークンをサーバ側が「知らない」ので、認証が成立しない。応急処置はclaude.ai側でコネクタを切断→再接続(新規登録のやり直し)でした。

OAuth状態のファイル永続化

恒久対策として、OAuth状態をファイルに永続化しました。

  • SDKは永続化ストアを提供しておらず、OAuthServerProvider / OAuthRegisteredClientsStore というインターフェースの実装が利用者側の責務。既存Providerをインターフェース準拠のままファイルバックドに改修(パス未指定なら従来どおりメモリのみ、という後戻り可能な設計)
  • 永続化対象は4種: DCRクライアント登録、アクセストークン、refreshトークン、未使用の認可コード
  • 書き込みは一時ファイル+renameのアトミック置換。保存先はディレクトリ700・ファイル600、当然gitignore
  • トークンの有効期限・失効ロジックは一切変えない(access 1時間、refresh回転式)

検証は「プロセス再起動3回またぎ+refreshトークン回転またぎで、claude.ai側の操作なしにツール呼び出しが通ること」を合否基準にし、クリアしました。

余談として、切り替え時に旧プロセスがメモリに持っていた現行トークンを、稼働中のNodeプロセスにインスペクタを繋いで抽出し、新形式のファイルに種入れしてから再起動する、という移行をやってのけたため、このデプロイ自体も再接続ゼロで完了しました(一回限りの荒技であり、運用手順ではありません)。

再起動をまたいだOAuth状態の永続化

教訓

サーバの状態(トークン等)をメモリのみに持つ実装は、再起動検収で必ず露見する。常駐化と永続化はセットで考える。

逆に言えば、常駐化したら必ず実再起動で検収するべきです。「plistをロードして動いた」は検収の半分でしかなく、今回の穴は実再起動なしには見つかりませんでした。

現在の姿とこれから

  • 書く: スマホのObsidianで書き、/draft→プレビュー→/post→note転載までスマホだけで完結
  • 読む: 人間はブログとnoteで、AIはllms.txt・記事.md・そしてMCPコネクタ経由の能動検索で

会話ログアーカイブの検索ツール(search_chatlog)は実装済みですが、プライバシー面の判断が残るため現在は無効化しています。「承認が必要」設定が機能しない以上、解禁するなら「常に許可」前提で露出範囲を考える必要がある——これは数日運用を寝かせてから決める予定です。

構築の実装はすべてClaude Code、私は要件定義と検収というPM分業スタイルは前回から変わらず。今回もっとも価値があったのは、AIの実装力そのものより「再起動検収を面倒がらずにやる」という人間側の一手だった気がします。