Claude Desktop の WordPress MCP を op run で認証したら、Windows Hello が 10 分ごとに出る問題を潰した話

Claude Desktop から WordPress を操作するための MCP サーバ(Claude がツールを呼ぶための常駐プロセス)を、1Password CLI(op)経由で認証するように組んでみました。ローカルに平文のパスワードを置かずに済むので、見た目はキレイな構成です。

ところが使い始めると、10 分ごとに Windows Hello の生体認証プロンプトが飛んでくる。ブログを書いている最中に顔認証を求められるのは、地味に集中が切れます。

原因は一つではなく、サービスアカウント方式への切替と、その過程で踏む 4 つの罠を順番に潰す必要がありました。最終的には launch_mcp.cmd というラッパーバッチを噛ませて、HKCU\Environment(Windows のユーザー環境変数が格納されるレジストリキー)からサービスアカウントトークンを直読みする構成で落ち着いています。この記事は、同じ構成を組む人向けの地雷マップです。

目次

この記事の想定読者

  • Claude Desktop で自作の MCP サーバを常駐運用している人
  • 1Password CLI(op)でシークレットを管理したい/しているが、claude_desktop_config.json に平文を置きたくない人
  • Windows 11 で上記構成を動かしていて、生体認証プロンプトが 10 分おきに出る問題を踏み抜いた人

「そもそも MCP サーバって何?」という段階の方は、まず Claude Desktop 公式ドキュメントの MCP セクションを先に眺めると後半が読みやすくなります。

TL;DR(先に結論)

  • op run のデスクトップアプリ連携は、10 分の非アクティブで再認可が必要になる仕様
  • Claude Desktop が stdio 型 MCP サーバを再起動するたびに op run が再評価され、Windows Hello が出ているように見えた(挙動からの推測)
  • 回避策は 1Password のサービスアカウントを使うこと
  • ただし、サービスアカウント化には以下の罠がある
  • サービスアカウントは Personal Vault を読めない(専用 Vault が必要)
  • Claude Desktop の env ブロックは親プロセスの環境変数を継承しない
  • op run のマスキングがログ中の部分文字列に反応して壊すことがある
  • バッチファイルは CRLF で保存しておいた方が安全
  • 最終構成は cmd.exe → ラッパー .cmd(CRLF で保存)→ HKCU\Environment 直読み → op run --no-masking
  • 動く claude_desktop_config.jsonlaunch_mcp.cmd の全文は本記事末尾の「最終構成」に掲載

以下、順番に罠を潰していきます。

環境

  • Windows 11(OMEN16)
  • Claude Desktop
  • 1Password 8 デスクトップアプリ + 1Password CLI(op
  • WordPress MCP サーバ(自作、Python + uv で起動)
  • WordPress サイト: 本ブログ(kwrkb.com)
  • 認証方式: WordPress のアプリケーションパスワード(WordPress がユーザーごとに発行できる API 専用パスワード)

起きていたこと

最初の claude_desktop_config.json はこんな形でした。

"wordpress": {
  "command": "C:\\Users\\<USER>\\AppData\\Local\\Microsoft\\WinGet\\Links\\op.exe",
  "args": [
    "run",
    "--",
    "C:\\...\\uv.exe",
    "run",
    "--directory",
    "C:\\Users\\<USER>\\Code\\WordPressMCP",
    "python",
    "wordpress_mcp.py"
  ],
  "env": {
    "WP_URL": "https://kwrkb.com/",
    "WP_USERNAME": "op://Personal/<item-id>/username",
    "WP_APP_PASSWORD": "op://Personal/<item-id>/password"
  }
}

op runop:// の秘密参照を解決してから MCP サーバを起動する、教科書どおりの構成です。MCP 側は環境変数 WP_USERNAME / WP_APP_PASSWORD を読むだけにしてあります。

これで動かすと、10 分ごとに Windows Hello が出続けました。使用中は出ないものの、少し席を立って戻ってくると必ず出ます。

10 分という数字の根拠

まず仕様を確認しました。1Password の公式ドキュメントによると、デスクトップアプリ連携で CLI を認可した場合、10 分間のセッションが確立され、コマンド実行のたびに自動的にリフレッシュされる仕様です(参照: 1Password app integration security)。

「10 分経ったら強制ログアウト」ではなく「10 分間非アクティブだと失効」する挙動で、少なくとも公式ドキュメント上、このタイムアウトをユーザー側で延長する設定は見当たりません。1Password コミュニティでも要望は上がっていますが、現時点で環境変数や config で変更する手段は確認できませんでした。

op run 自体は起動時に 1 回だけ秘密参照を解決するので、MCP サーバが生きているあいだは再認証の要求は本来発生しないはず。なのに 10 分周期で出るということは、手元の挙動からは、Claude Desktop が必要に応じて stdio 型(標準入出力で通信するタイプ)MCP サーバを再起動しており、そのたびに op run が再評価されているように見えました。あくまで外から観測した挙動ベースの推測で、Claude Desktop 側の内部仕様としての確証はありません。

ここで方針は決まりました。アプリ連携をやめて、サービスアカウントトークンに切り替える。これなら生体認証プロンプト自体が発生しません。

ここからが本題の罠地獄です。

罠 1: サービスアカウントは Personal Vault にアクセスできない

1Password Web でサービスアカウントを作成し、トークンを発行。Windows のユーザー環境変数 OP_SERVICE_ACCOUNT_TOKEN にセット。Claude Desktop を再起動。

まだ Windows Hello が出ます。

切り分けのために PowerShell で op whoami を叩いたら、ちゃんと User Type: SERVICE_ACCOUNT と返ります。トークン自体は生きている。続いて op vault list を叩くと、出てきたのは Dev Vault だけ。WordPress のアイテムが入っている Personal Vault はリストにすら出てこない

1Password の公式仕様で、サービスアカウントには組み込みの Personal / Private / Employee Vault、および default Shared Vault へのアクセス権を付与できないことが明記されています(参照: service-account コマンドリファレンス / Get started with 1Password Service Accounts)。

Vault の設定で変えられる類のものではなく、ハードな仕様制約です。

対応:

  1. 1Password アプリで新規 Vault(筆者の場合は Dev)を作成
  2. WordPress 認証情報のアイテムを Personal から Dev移動
  3. サービスアカウントに Dev Vault の Read 権限を付与

補足として注意点が 2 つ

  • サービスアカウントは作成後にアクセス可能な Vault を追加・変更できません。既存のサービスアカウントで新 Vault を読みたい場合は、新しいサービスアカウントを作り直して旧トークンを revoke します(参照: Manage service accounts
  • Vault 間でアイテムを移動するとアイテム ID が振り直されます。移動後に op item list --vault Dev で新 ID を確認してから op:// 参照パスを書き換えます

罠 2(最大の罠): Claude Desktop の env ブロックは親環境を継承しない

Vault を移し、参照パスを op://Dev/<item-id>/... に書き換え、Claude Desktop を再起動。

まだ Windows Hello が出ます。

原因はここでした。Claude Desktop は MCP サーバを起動する際、親プロセスの環境変数を継承せず、env ブロックで指定されたものだけをサブプロセスに渡します(完全置換)

この記事の中で、ここが一番時間を溶かした場所であり、かつ公式ドキュメントに書かれていない独自の発見ポイントです。

手元での検証手順はこうです。

  1. Windows のユーザー環境変数に CLAUDE_MCP_DEBUG=hellosetx でセットしてログオンし直す
  2. MCP サーバ側の起動直後に print("CLAUDE_MCP_DEBUG=", os.environ.get("CLAUDE_MCP_DEBUG")) を仕込む
  3. Claude Desktop 経由で MCP サーバを起動 → ログには None が出る
  4. claude_desktop_config.jsonenv ブロックに "CLAUDE_MCP_DEBUG": "from-config" を追加 → from-config が出る

つまり OS 側のユーザー環境変数はサブプロセスに届いていません。どれだけ OP_SERVICE_ACCOUNT_TOKEN を OS 側に仕込んでも、op run から見たら存在しない扱いになります。トークンが見えない op は自動的にデスクトップアプリ連携にフォールバックし、結果的に Windows Hello が出続ける、という動きでした。

最初は env ブロックに直接書く対応を試しました。

"env": {
  "OP_SERVICE_ACCOUNT_TOKEN": "ops_eyJh...(実トークン)",
  ...
}

これで動きます。動くのですが、設定ファイルに平文でトークンが載るclaude_desktop_config.json%APPDATA%\Claude\ 配下で、OneDrive 同期や dotfiles リポジトリへのうっかりコミット、スクショ共有などで漏洩するリスクが拭えません。

そこで、ラッパーバッチで HKCU\Environment から直接レジストリを読む構成に切り替えました。完全置換される env ブロックの外側で、バッチ自身がトークンを取得してから op run を起動する流れです。

罠 3: op run のマスキングが部分文字列に反応する

ラッパーを書く前に、もう一つ別の罠を踏んでいました。

op run はデフォルトで、解決した秘密情報が標準出力・標準エラーに出ないようマスキングをかけます。セキュリティとしては正しい挙動ですが、これが部分文字列マッチで動くため、想定外のマスクが発生します。

たとえば WordPress のユーザー名が kwrkb の場合、MCP サーバのデバッグログで

INFO: connecting to https://kwrkb.com/wp-json

と出すと、実際のログには

INFO: connecting to https://<concealed by 1Password>.com/wp-json

と出ます。ユーザー名に一致する kwrkb の部分が食われ、ドメイン名まで巻き添えで <concealed> に置換されるわけです。エラーメッセージやスタックトレースも同様に壊れるので、デバッグ中に何が起きているかが極端に見えづらくなります。

今回のように、ユーザー名がドメイン名など通常ログに含まれる文字列と重なる場合は、--no-masking フラグを付けないとデバッグがかなり難しくなります。

op run --no-masking -- <command>

ただしマスキングを切る場合、MCP サーバ側では認証情報を絶対にログに出さない前提で運用します。特に起動時の os.environ ダンプや、例外時に環境変数をそのまま吐く実装は避けます。クリティカルな秘密はそもそもログに流さない設計にしておけば、--no-masking を付けても実害はありません。

罠 4: バッチファイルの改行コードは CRLF に固定する

ラッパーバッチを書いて、いざ動かすと、なぜか op がサービスアカウントモードに入らず Hello が出る。set で設定したはずの OP_SERVICE_ACCOUNT_TOKEN が空。

原因は、バッチファイルを LF で保存していたことでした。VS Code や他のエディタで作業していると、.gitattributes(リポジトリごとに改行コードを指定するファイル)や Git の core.autocrlf の設定次第で LF のまま保存されがちです。Windows の cmd.exe は CRLF 前提の挙動が残っているため、バッチファイルは CRLF で保存しておくのが安全です。手元では LF 保存のままだと for /f(コマンド出力を変数に束縛するバッチ構文)や set が期待通りに動かず、結果的にトークン未設定のまま op run が呼ばれて Hello フォールバックに戻っていました。

対応:

  • バッチファイル(.cmd / .bat)は CRLF で保存
  • リポジトリに置くなら .gitattributes で明示しておく
*.cmd text eol=crlf
*.bat text eol=crlf

最終構成

ここまでの罠をすべて回避した最終形です。

ポイントは、claude_desktop_config.jsonOP_SERVICE_ACCOUNT_TOKEN を直接書かないこと。env に書けば動きますが、Claude の設定ファイルはスクショ・同期・バックアップ・git 管理に混ざりやすいため、今回は避けました。代わりに、ラッパーバッチ側で HKCU\Environment からトークンを取り出してから op run を呼びます。

C:\Users\<USER>\AppData\Roaming\Claude\claude_desktop_config.json

"wordpress": {
  "command": "C:\\Windows\\System32\\cmd.exe",
  "args": [
    "/c",
    "C:\\Users\\<USER>\\Code\\WordPressMCP\\launch_mcp.cmd"
  ],
  "env": {
    "WP_URL": "https://kwrkb.com/",
    "WP_USERNAME": "op://Dev/<item-id>/username",
    "WP_APP_PASSWORD": "op://Dev/<item-id>/password"
  }
}

C:\Users\<USER>\Code\WordPressMCP\launch_mcp.cmd(CRLF で保存)

@echo off
rem Claude Desktop は env を完全置換するため、HKCU\Environment から OP_SERVICE_ACCOUNT_TOKEN を直接読み出す
for /f "tokens=2,*" %%a in ('reg query "HKCU\Environment" /v OP_SERVICE_ACCOUNT_TOKEN 2^>nul ^| findstr /C:"REG_SZ"') do set "OP_SERVICE_ACCOUNT_TOKEN=%%b"

"C:\Users\<USER>\AppData\Local\Microsoft\WinGet\Links\op.exe" run --no-masking -- "C:\Users\<USER>\AppData\Local\Microsoft\WinGet\Packages\astral-sh.uv_Microsoft.Winget.Source_8wekyb3d8bbwe\uv.exe" run --directory "C:\Users\<USER>\Code\WordPressMCP" python wordpress_mcp.py

Windows のユーザー環境変数

OP_SERVICE_ACCOUNT_TOKEN=ops_... を GUI の環境変数設定(システムのプロパティ → 環境変数)から登録しておきます(HKCU\Environment に格納されます)。setx OP_SERVICE_ACCOUNT_TOKEN ... でも登録できますが、コマンド履歴にトークンが残ってしまうため、今回は GUI 経由で登録しました。

パスは環境ごとに違うので置き換える

op.exeuv.exe のパスは WinGet のバージョンやインストール方法で変わります。自分の環境では PowerShell で where.exe op / where.exe uv を叩いて実パスを確認し、上記スニペットの該当箇所に置き換えてください。

動作の流れ

  1. Claude Desktop が cmd /c launch_mcp.cmd を起動(envWP_*op:// 参照文字列を渡す)
  2. バッチが HKCU\Environment からサービスアカウントトークンをレジストリ直読み
  3. op run --no-masking がサービスアカウントモードで認証 → WP_USERNAME / WP_APP_PASSWORDop:// 参照を Dev Vault から解決
  4. 解決済みの実値で wordpress_mcp.py 起動 → WordPress と通信

起動後、Claude Desktop で wp_site_info を叩くと、Windows Hello プロンプトが一切出ずにサイト情報が返ってきます。10 分以上放置してから別のツールを呼んでも、やはり無言で通ります。想定どおりの挙動になりました。

ハマりどころと回避策(まとめ表)

症状回避策
envop:// を Claude Desktop が解釈しないデスクトップ起動の env では参照が文字列のまま渡るop run -- でラップして子プロセス側で展開
op run のマスキングが部分文字列に反応WP_USERNAME=kwrkbkwrkb.com ドメインまで <concealed>op run --no-masking(+ ログに秘密を出さない設計)
Claude Desktop の env は完全置換OS のユーザー環境変数(setx で設定した SA トークン)が子に継承されないラッパーバッチでレジストリ HKCU\Environment から直読み
バッチの改行が LF だと動かないことがあるfor /fset が期待通りに動かず結果的にトークン未設定 → Hello フォールバックCRLF に固定(エディタのデフォルト LF に注意)

セキュリティ設計の整理

この記事は「すでに 1Password を使っている前提」で書いています。別のシークレット管理ツール(Windows 資格情報マネージャ、VS Code Secret Storage、外部 Vault など)をお使いの方は別構成が組めるはずですが、ここでは op 前提の最小変更での落とし所を示しています。

この構成のセキュリティ境界は以下のとおりです。

  • claude_desktop_config.json にはトークン本体が載らない。レビュー・スクショ・git 事故に強い
  • トークンは HKCU\Environment に保存されており、同一 Windows ユーザーのプロセスからは読める
  • OneDrive の既定同期対象はファイル単位で、HKCU\Environment はレジストリに格納されるため、少なくとも claude_desktop_config.json のようにファイルとして同期や dotfiles に混ざるリスクは避けられる(バックアップソフトや環境移行ツールまで含めると経路は残り得るため、断定はしない)

一方で注意点として、この構成は「同一 Windows ユーザー権限で動くプロセスを信頼する」前提です。マルウェアや不審な常駐アプリが同一ユーザー権限で動いている場合、HKCU\Environment に置いたトークンは防御境界になりません。ここは DPAPI(Windows がユーザー単位で暗号化・復号する仕組み)で暗号化した外部ファイルに置いても、user scope である以上は同じ制約が残ります。運用コストと防御力のバランスで、今回はレジストリ直読みに落ち着きました。

加えて運用レベルで以下を押さえておくと安心です。

  • サービスアカウントのアクセス権は必要最小限の Vault のみ(Dev Vault の Read Only など)に絞る
  • WordPress のアプリケーションパスワードは WordPress 側で個別 revoke 可能(万一トークンが漏れても App Password 失効で被害を止められる)
  • サービスアカウントトークン自体も定期ローテーションを運用に組み込む
  • --no-masking を使う以上、MCP サーバ側で 認証情報をログに出さないos.environ ダンプや例外時の環境変数出力は封じておく

デスクトップアプリ連携と違い、サービスアカウントトークンは失効まで 1Password 側に握られ続けます。ローテーションは忘れずに。

まとめ

Claude Desktop + 1Password CLI + 常駐 MCP サーバという組み合わせは、見た目よりも踏む地雷が多い構成でした。順番通りに並べると、

  1. op run のアプリ連携は 10 分の非アクティブで再認可が必要になり、公式ドキュメント上は延長設定が見当たらない → サービスアカウント方式へ
  2. サービスアカウントは Personal / Private / Employee / default Shared Vault にアクセスできない → 専用 Vault へアイテム移動
  3. サービスアカウントは作成後に Vault アクセス変更不可 → 差し替えは新規作成 + 旧 revoke
  4. Claude Desktop の env ブロックは完全置換 → 親プロセスの環境変数は継承されない
  5. ユーザー環境変数を届けるにはラッパーバッチ経由でレジストリ直読み
  6. op run のマスキングは部分文字列マッチなので、ユーザー名がドメインと衝突する等の場合は --no-masking が現実的
  7. バッチは CRLF で保存

1 番目の仕様を読んだ時点で「じゃあサービスアカウント」までは辿り着きやすいのですが、4〜7 番目で粘ります。特に「env 完全置換」と「バッチの CRLF」は、気づくまでに時間を溶かしました。

同じ構成を組んでいて生体認証が止まらない方、あるいは「トークンを平文で置きたくないがどう逃がすか」で悩んでいる方の参考になれば幸いです。

参考資料

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

コメント

コメントする

目次