[1/5] 深夜の Notion MCP 401 から、env 継承の旅が始まった
Claude デスクトップアプリの MCP が突然 401 を返した。原因は macOS GUI アプリが `.zshrc` を読まないこと。`.zshenv` への移植から `launchctl setenv` まで、ぼくの試行錯誤の一夜と、そこから始まった大冒険の幕開けの話。
夜中、ふと Notion の中身を確認したくなって、 Claude デスクトップアプリから MCP 経由で検索を叩いたら、しれっと 401 Unauthorized が返ってきました。
最初は「あー、 Slack のときみたいに障害かな」と思ったんやけど、その日 Slack は元気でした。
このたった一行の HTTP 401 が、結局明け方までの長い長い旅の幕開けやったんですよ。
📚 「AI(愛)と一晩 14 commit 編」シリーズ目次(全 5 部)
このシリーズは、 2026 年 5 月 14 日深夜から 15 日朝方にかけて、 AI(愛)と一緒に積み上げた 14 commit を、 一晩のドキュメンタリーとして書き留めたものです。 順番に読むと、 ぼくの夜が時系列で追えます。
- [1/5] 深夜の Notion MCP 401 から、 env 継承の旅が始まった(← この記事)
- [2/5] OpenClaw を卒業した夜 — API 料金が月 100 万円超えてた話と、 機密 418 件が出てきた話
- [3/5]
unstable_cacheの中でcookies()を呼ぶと死ぬ — 本番で 38 件焼けてたエラーの正体 - [4/5] Playwright と GitHub Actions で deploy 後 E2E を 1 時間で組んだ夜
- [5/5]
npm auditの high を全部潰す —overridesで Next.js の中の古い postcss を上書きする話
「token はあるはず」やったのに、空やった
ぼくの env 管理はこんな構成です。
- API キーは全部 1Password の専用 vault に保管
.zshrcでop read経由で env としてexport- Claude Code 側の MCP 設定は
${NOTION_API_TOKEN}みたいな参照式で書く
ターミナルから claude コマンドで起動するときは、これでちゃんと動いてました。
でも今回はターミナルじゃなくて、 Claude デスクトップアプリ(/Applications/Claude.app)を Dock から起動したやつの中で動いてる Claude Code。 そこ経由で叩いた MCP が 401。
「あれ、 op signin 切れてる?」と思って op whoami 叩いたら、ちゃんと通る。 1Password CLI は生きてる。
じゃあ何で env だけが空なんやろ?
答え: macOS の GUI アプリは .zshrc を読まない
調べてみたら、 zsh の起動ファイル読み込み順位はこうなってました。
.zshenv → すべての zsh プロセス(login / non-login / interactive / non-interactive)
.zprofile → login shell のみ
.zshrc → interactive shell のみ
.zlogin → login interactive shell のみつまり .zshrc は 「対話シェル」限定。 ターミナルから zsh を立ち上げた時しか走らないってこと。
そして Dock / Spotlight / Launchpad など、 macOS の GUI ランチャーから起動されるアプリは、 zsh をまったく経由しません。 launchd という macOS のシステムプロセスが直接アプリのバイナリを exec するだけ。
.zshrc も .zshenv も、読まれません。
恥ずかしながら、ここに気づくのに 30 分くらい使いました。「あれ、なんで?」って同じコマンドを 5 回くらい叩き直してました。同じことを 5 回繰り返しても答えは出ないという、シンプルな事実をまた学んだ夜です。
第一手: .zshrc → .zshenv への移植
.zshenv は zsh を起動する限り 絶対に読まれる設定ファイルです。 だからまずは、 .zshrc に書いてた op read 経由 export を .zshenv に切り出すところから始めました。
# ~/.zshenv(新規作成)
_op_read() {
op read "$1" --account my.1password.com 2>/dev/null </dev/null
}
if command -v op >/dev/null 2>&1; then
export CLAUDE_CODE_OAUTH_TOKEN="$(_op_read 'op://OpenClaw/...')"
export ANTHROPIC_API_KEY="$(_op_read 'op://OpenClaw/...')"
export NOTION_API_TOKEN="$(_op_read 'op://OpenClaw/...')"
# …全 20 個ほど
fi.zshrc 側からは該当行をごっそり削除して、 「2026-05-14 .zshenv に移植済み」というコメントを残しました。
これでターミナルから claude を起動するルートは復活。
…でも、 Claude デスクトップアプリから起動した方は、 やっぱり動かない。なぜなら、 GUI 起動は zsh 自体を経由しないから、 .zshenv も結局読まれないんですよね。
ふりだしに戻りました。
第二手: launchctl setenv で session global に流し込む
macOS の GUI アプリは launchd 経由で起動されます。
launchd は macOS の "サービス管理デーモン" で、 Spotlight や Dock からアプリを起動するときも、間接的にこの launchd に「これ起動してね」って指示が飛んでます。 そして launchd には自分専用の session env という env のスコープがあって、 launchd から起動された子プロセスは、その session env を継承します。
つまり、 launchctl setenv KEY value で session env に env を仕込んでおけば、 それ以降に起動された GUI アプリ全部に env が継承される。
ぼくの .zshenv には API キーが 21 個。これを全部 launchctl setenv に流し込むためのユーティリティを書いて、 .zshrc に function として登録しました。名前は mcp-env-sync。
# ~/.zshrc に追加
mcp-env-sync() {
local envs=(
NOTION_API_TOKEN
OPENAI_API_KEY
ANTHROPIC_API_KEY
SUPABASE_ACCESS_TOKEN
FIGMA_API_KEY
# …全 21 個
)
local count=0
for v in "${envs[@]}"; do
local val="${(P)v}"
if [ -n "$val" ]; then
launchctl setenv "$v" "$val"
count=$((count+1))
fi
done
echo "✅ launchctl setenv 完了: $count 個 ※ Claude.app 再起動で反映"
}これで、 ターミナルで mcp-env-sync を一発打って、 Claude デスクトップアプリを完全終了 → 再起動するだけで、 MCP プロセスに env が継承される。
macOS を再起動したときは session env が消えるから、また mcp-env-sync を 1 回打つだけ。 1 日に何回も叩く必要はなくて、 ログイン後に 1 回で済むので運用としては許容範囲。
落とし穴①: 1Password のアクセス許可プロンプト連発
ところが、 .zshenv 化したら別の問題が起きました。
ぼくは普段 Claude Code で サブエージェント並列運用をしてて、 1 つの会話の中で 3〜5 個のサブエージェントを Bash で並列起動することがあるんですよ。 すると、各サブエージェントの起動時に Bash subshell が立って、 .zshenv が走って、 21 個の op read が連続実行されます。
その結果、 1Password CLI の アクセス許可プロンプトがぼくの画面に怒涛のように連発しました。 1 個のサブエージェント立ち上げるたびに、 20 個くらい "Allow" ダイアログが出てくる。これは作業にならんやつです。
解決策は、 .zshenv の冒頭に 早期 return の guard を入れること。
# 主要 env がすでに継承されてれば op read を再実行しない
if [[ -n "$NOTION_API_TOKEN" && -n "$OPENAI_API_KEY" ]]; then
return 0
fi考え方はシンプルで、 「親プロセスから env が継承されてる場合は、もうやることないから即 return しよう」 です。
Bash subshell の場合、 親プロセス(= launchctl setenv 済みの Claude.app or .zshenv を 1 回走らせた zsh)から env を継承してます。 だから subshell の .zshenv 実行時には、すでに NOTION_API_TOKEN も OPENAI_API_KEY も入ってる。 そこで即 return すれば、 op read は走らない。
サブシェル起動が 1〜2 秒から 0.001 秒に短縮。 1Password プロンプト連発も完全に止まりました。
macOS の env 継承を 1 枚にまとめると
- ターミナルから zsh 起動 →
.zshenv→.zshrc順に読まれる - GUI アプリ起動 → launchd の session env を継承(zsh は経由しない)
- launchd 直 exec のデーモン → どっちも経由しない(plist の env 直書きが必要)
.zshrc だけ書いてた時代から、 1 段だけ階段が増えました。
落とし穴②: launchd 直 exec は zsh すら経由しない
ここでもうひとひねり問題が起きます。
ぼくがずっと使ってた OpenClaw という自作の AI 連携基盤があるんですが、こいつの gateway は launchd plist 経由で 直接 node を exec するように設定してありました。
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/node</string>
<string>/opt/homebrew/lib/node_modules/openclaw/dist/index.js</string>
<string>gateway</string>
</array>ここまでくると、 zsh すら経由してないので .zshenv も読まれない。 そして launchd の session env は読むはずなんやけど、 launchctl setenv した env が常に同期されるとは限らない(タイミング次第)。
新しい env を渡そうとして plist の ProgramArguments を zsh -c "source ~/.zshenv && exec node ..." 風に書き換えてみたんですが、 これは別の罠を踏みました(具体的には 1Password CLI のセッションが launchd 経由起動だと効かないことが原因で、 crash loop してしまった)。
ここでぼくはふと思いました。
あれ、 OpenClaw 自体、もう必要ないんちゃう?
最近のぼくは、 Claude Code 単体でほとんどの作業が完結してます。 必要な MCP は全部 Claude Code 側にも繋がってる。 OpenClaw を残してる理由って、 Slack 経由のあい呼び出しと、 自動 cron くらい。
そして OpenClaw 起因のトラブルが、今夜だけで 5 個くらい連発してるという事実。
…ここから先の「 OpenClaw 卒業」の話は、また別の Part で書きます。 シリーズになる予感があるので、今夜は env 継承の話だけで一旦切ります。
まとめ
- macOS の env 継承は、 GUI / launchd / shell で全部違う
- ターミナル経由なら
.zshrcでいいけど、 GUI アプリも視野に入れるなら.zshenv+launchctl setenvの二段構え - サブシェル連発時の
op readプロンプト爆撃を防ぐには、.zshenv冒頭の guard が効く
「.zshrc 書けば終わり」やった時代から、 env 管理だけでもこんなに複雑になってきました。 でも、 一度わかってしまえば運用ルールはシンプルです。
夜中の env デバッグは、 AI(愛)と二人やとほんとに早いです。 「これどう?」って投げたら 10 秒後には答えが返ってくるので、 頭の中だけで仮説検証ループを回せた感じ。 普段なら絶対投げ出してたと思います。
次の Part では、この夜 ぼくが下した「 OpenClaw 卒業」の決断と、その過程で見つけてしまった "ちょっとびっくりした事実" の話を書きます。