MCPを5個個別登録する手間を、PyPI 1パッケージとDevice Code Flow loginで潰した話
Codens は今、product 表面が 5 つあります。Purple (orchestrator)、Red (auto-fix)、Blue (E2E QA)、Green (PRD)、それと billing と auth を担う Auth。元々は Purple だけが MCP を持っていて、purple-codens-mcp という PyPI package で 16 個の tool を Claude Code に渡していました。Red と Blue と Green が独立した MCP を持ち始めた瞬間に、ユーザーの体験が一気に崩れます。.claude/settings.json に MCP server を 5 個書く、login も 5 回やる、token は別ファイルに散らばる。「Codens 試してみたい」と言ってくれた人に、まずこの 5 行の JSON を渡すのは、率直に言ってかなり厳しい。
そこで codens-mcp という統合パッケージを切り直しました。Purple の 16 tools はそのまま re-export、Red/Blue/Green/Auth の tool を全部足して、cross-product tool を 1 個だけ追加。ユーザー側は pip install codens-mcp と codens-mcp login の 2 コマンドで全 product が動きます。今日はその実装でやった判断の話を書きます。
1 つの package に詰め込む、その置き場所
codens-mcp は purple-codens レポの subdir に置いてあります。具体的には purple-codens/codens-mcp/ です。なぜ Purple の中かというと、Purple は orchestrator なので、Red/Blue/Green/Auth の API を呼ぶ client コードを Purple 側で持っておくのが一番自然だったから。別レポに切り出す案も検討したんですが、4 product の API client + cross-product tool + login を 1 か所で update したい運用が多くて、独立レポにすると release 時に PR が 2 個に割れるのが面倒で、結局 subdir に落ち着きました。
中身の構造はだいたいこうなっています:
codens-mcp/
├── pyproject.toml # name = "codens-mcp", version = "0.4.0"
└── src/codens_mcp/
├── server.py # build_server() / argparse / main()
├── client/
│ ├── auth.py # Auth Codens client
│ ├── auth_helper.py # 共有 credential ファイルの読み出し
│ ├── red.py / blue.py / green.py
└── tools/
├── purple_tools.py # 16 tools (re-export)
├── red_tools.py # 4 tools
├── blue_tools.py # 4 tools
├── green_tools.py # 4 tools
├── auth_tools.py # 2 tools
└── cross_tools.py # 1 tool (cross-product)
server.py の中核は build_server() という単一の関数です。FastMCP インスタンスを 1 個作って、各 product の register_*_tools(mcp) を順に呼ぶだけ。
def build_server() -> FastMCP:
mcp = FastMCP(
"codens",
instructions=(
"Codens unified MCP server. "
"Use purple_login first, then use Red/Blue/Green/Auth tools as needed."
),
)
register_purple_tools(mcp)
register_cross_tools(mcp)
register_red_tools(mcp)
register_blue_tools(mcp)
register_green_tools(mcp)
register_auth_tools(mcp)
return mcp
Purple の 16 tools は purple-codens-mcp package を依存に取って、その実装をそのまま薄く呼び直しています。重複コードを書きたくなかったので、pyproject.toml の dependencies に purple-codens-mcp>=0.2.0 を入れて、purple_tools.py の中で既存の関数を import して FastMCP に再 register する形にしました。Purple 側で tool が増えたり signature が変わったりした時に、codens-mcp 側を直さなくて済むのが大きい。
各 product の client は別ファイルに切ってあります。red.py / blue.py / green.py はそれぞれ httpx で API を叩くだけの薄い class で、auth_helper.py の load_codens_credentials(api_url, service="red") で共有 credential ファイルから JWT を読みに行く。Auth Codens が SSO の中心なので、JWT は 1 個で 4 product 全部に通ります。これは後で出てくる login の話に効いてきます。
cross-product tool は最初 red_tools.py の中に書いていたんですが、すぐに分離しました。理由は単純で、codens_register_project_unified は 4 product の client を全部触るので、どこに置いても他の *_tools.py の責務がぼやける。だったら独立 module にして、「これは横断 tool です」と明示する方が読みやすかった。cross_tools.py という名前にしたのは、将来同じ系統の tool が増えても置き場所が決まっているように、ということ。
4 product に同じ repo を best-effort で登録する
codens_register_project_unified の話をします。実装した動機は、ユーザー側で Codens を試そうとすると、同じ GitHub repo を Purple/Red/Blue/Green の 4 product に register するセットアップが必要で、それが 4 step の手作業になっていたから。Purple で project 作って、Red で project 作って、Blue で同じことをやって、Green でもやる。エラーがどこで出たかも追えなくなる。これを 1 call に潰したかった。
API は単純です:
@mcp.tool()
async def codens_register_project_unified(
api_url: str,
name: str,
github_owner: str,
github_repo: str,
products: list[str] = ["purple", "red", "blue", "green"],
description: str | None = None,
) -> dict:
...
products で subset 指定もできるようにしてあります。「今は Red と Blue だけでいい」というケースに対応するためで、これは設計初期は無くて、後から追加しました。理由は、cross-product tool に「全部やる」しかない API を渡すと、慣れてきたユーザーが「Green は今いらないから外したい」と言った時に詰むから。products: list[str] を引数に出しておけば、agent 側でも「Red と Blue だけ register する」呼び方ができます。
判断として一番考えたのは、途中で 1 product が fail した時にどうするかです。選択肢は 2 つ:
- rollback: Purple/Red が成功して Blue が失敗したら、Purple と Red の project も delete して、user に「全部失敗した」と返す
-
best-effort: Purple/Red は registered のままにして、Blue だけ
errors配列に積んで返す
最終的に best-effort にしました。これは諦めではなくて、rollback を入れる方がむしろ user の混乱が大きいという判断です。Codens は SaaS で、project の register/unregister は明示的に user 側でも管理しているもの。途中で勝手に rollback すると、「Purple の dashboard 見たら project が無いんだけど、register したつもりだった」みたいな現象が起きる。それより、errors 配列で「Blue だけ失敗した、こういうエラーだった」と返してあげて、user は失敗した product だけ手で再 register すれば終わる、という方が透明です。
もう 1 つの考慮としては、エラーの種類です。4 product のうち 1 つが落ちる時の典型的な原因は「すでに同じ repo が register されている」「権限が足りない」「Codens 側の何らかの一時的な障害」のどれかで、最初の 2 つは rollback してもしなくても user 側のオペレーションで解決する話 (重複は無視、権限は付与してから再 register)。3 つ目だけが rollback する価値のあるケースなんですが、そのために常時 rollback ロジックを書くのは over-engineering でした。errors 配列に message を入れておけば、agent 側で「Blue だけ retry してください」みたいな誘導もしやすい。
実装は素直で、4 product を for loop で回して、それぞれ try/except で囲んで、成功した分は ID を、失敗した分は errors に message を積む。
result = {
"purple_project_id": None,
"red_project_id": None,
"blue_project_id": None,
"green_project_id": None,
"errors": [],
}
for product in products:
try:
# ... product ごとの client.post(...)
result[f"{product}_project_id"] = resp.get("id")
except Exception as exc:
result["errors"].append(
{"product": product, "error_message": str(exc)}
)
return result
ここで微妙だったのが、PurpleCodensClient だけ purple-codens-mcp パッケージから持ってくる import で、他の 3 つは codens-mcp 内に置いた client を使う、という非対称性です。理由は、Purple client は既に成熟していて auth flow も組み込まれていたから、再実装するメリットが無かった。Red/Blue/Green は独立 MCP からは少し違うレイヤを叩く必要があって、新規に書き起こした方が綺麗だった。コードを読む人は最初「なんで Purple だけ別 import なんだ」と思うはず。docstring に明記してあります。
Device Code Flow login を入れた理由
ここからが今回の release で一番悩んだところです。codens-mcp login を実装するにあたって、最初は既存の purple_login (browser OAuth callback) をそのまま使う予定でした。あれは local の random port に http server を立てて、ブラウザを開いて、Auth Codens の OAuth authorize URL に飛ばして、callback で code を受け取る、という普通の flow。GUI のある mac/Windows で開発している人にはこれで十分です。
問題は headless 環境です。具体的に言うと:
- SSH 接続中の dev container
- GitHub Codespaces (web 版)
- remote dev server や cloud VM 上で開発している人
- ブラウザを開けない CI 系の box
これらの環境では webbrowser.open() が無を返すか、開いても callback URL http://localhost:<port>/callback が user の手元のブラウザに到達しない。port forward を頑張れば回避できるんですが、user に「SSH の port forwarding を設定してください」と言うのは UX として完全に負けです。
なので RFC 8628 の Device Authorization Grant、いわゆる Device Code Flow を実装しました。元々は smart TV や CLI tool 向けの flow で、Apple TV の login 画面で「テレビに表示されたコードを iPhone のここに入力してください」と言われる、あれです。CLI でも全く同じ理屈で動きます。
flow の実装は purple-codens-mcp の auth.py の device_code_login() に置いてあって、codens-mcp 側はそれを呼ぶだけ。重複を出さない構造にしてあります。中身はこういう感じ:
-
POST /oauth/device/authorizeを Auth Codens に投げて、device_code/user_code/verification_uri/expires_in/intervalを貰う - terminal に user_code (例:
ABCD-1234) と verification_uri を表示 - user が別 device、スマホでも laptop でも、で URL を開いて user_code を入力
- CLI 側は
interval秒 (default 5s) ごとにPOST /oauth/device/tokenを polling - Auth Codens が
authorization_pendingを返している間は待ち続ける、slow_downが来たらinterval += 5で polling 間隔を伸ばす、expired_tokenかaccess_deniedで諦める - token が取れたら
~/.purple-codens/credentials.jsonに mode 0600 で保存
terminal の表示はわざと派手めにしました:
============================================================
Device Authorization Required
============================================================
1. Open this URL on any device:
https://app.auth.codens.ai/device
2. Enter this code when prompted:
ABCD-1234
Waiting for authorization (expires in 15 minutes)...
============================================================
= の罫線は CLI tool としては少しオーバーかもしれませんが、login は 1 回だけのオペレーションで、しかも user は別 device に視線を移す必要があるので、「ここを見ろ」を強く示す方が良いという判断です。地味な console.log だと、SSH の noisy な terminal の中で見落とされる。
slow_down 対応はちょっと細かい話ですが、書いておきます。Device Code Flow の RFC では、server が「polling 速すぎ」と判断したら error: slow_down を返してきて、client は polling interval を増やせと言われる。素直に従う実装にしてあります:
elif error_code == "slow_down":
interval += 5
continue
これを無視して polling を続けると Auth Codens 側で rate limit に引っかかって 429 が返ってくるので、最初は無視していたら test 環境で何度か叩かれてバレました。仕様通り実装するのが一番楽です。
credential file の置き場所は ~/.purple-codens/credentials.json で、mode 0600 (owner read/write のみ)。なぜ ~/.codens/ ではなく ~/.purple-codens/ かというと、purple-codens-mcp 単体 install からの後方互換のためです。すでに login 済みの user が codens-mcp に乗り換えた時に、もう一度 login させたくなかった。同じファイルを読み書きする設計にしたので、「purple-codens-mcp login で取った token を codens-mcp 側で使う」「codens-mcp login で取った token を purple-codens-mcp 側でも使う」がどちらも成立します。
ファイル名を credentials.json にして permission を 0600 にしているのも、地味ですが意識した部分です。~/.codens-token みたいな単一ファイル + plain text にする案もあって、その方が読み書き simple ですが、refresh token と auth_service_url と user_id とかを将来追加していくと、結局 JSON にしないと壊れる。最初から JSON にしておくと、フィールド追加が後方互換的に可能で、古い purple-codens-mcp が知らない key を勝手に消したりしない (_load_credentials が dict を読んで、知ってる key だけ参照する設計)。chmod 0600 は普通の SSH key と同じ扱いで、user が ~/.purple-codens/ ごと git にチェックインしてしまっても、最低限 group/other からは読めない状態を保ちます。
そしてこの credential file の中身は、api_url を key にした dict になっています。https://api.purple.codens.ai で取った JWT を保存すると、その JWT は Auth Codens が発行した SSO トークンなので Red/Blue/Green の backend でも全部 valid です。1 回 login すれば 4 product 全部の API が叩けるのは、Auth Codens を SSO の中心に据えた設計が効いている部分。これは初期から決め打ちで設計していて、今日になってようやく報われた。
subcommand と後方互換
codens-mcp の CLI は argparse の subparsers で 3 つのサブコマンドを持ちます: login / whoami / serve。ただし、引数なしで実行すると serve がデフォルトで動きます。
codens-mcp # → serve (stdio MCP server)
codens-mcp serve # → 同じ
codens-mcp login # → Device Code Flow
codens-mcp whoami # → 現在の login user 情報
引数なしを serve にしたのは後方互換のためです。0.1.0 から 0.3.0 まで、Claude Code の .claude/settings.json には:
{
"mcpServers": {
"codens": { "command": "codens-mcp", "args": [] }
}
}
と書いてもらっていた。0.4.0 で subcommand を入れた瞬間に「args が空の場合は help を出す」みたいな ergonomics にすると、既存 user の Claude Code が全員壊れます。だから「no subcommand → serve」を明示的に入れました。main() の最後で:
if args.command == "login":
_cmd_login(args)
elif args.command == "whoami":
_cmd_whoami(args)
else:
# No subcommand or "serve" → start the MCP server (backward compatible).
_cmd_serve(args)
_cmd_login の中身も書いておきます。先ほど触れた通り、login のロジック本体は purple-codens-mcp 側に置いてあって、codens-mcp 側はそれを delegate するだけ:
def _cmd_login(args: argparse.Namespace) -> None:
from purple_codens_mcp.auth import device_code_login
from purple_codens_mcp.client import PurpleCodensClient
print(f"Logging in to {args.api_url} via {args.auth_url} ...\n")
token_resp = device_code_login(args.auth_url)
client = PurpleCodensClient(args.api_url)
result = client.login_with_device_token(
access_token=token_resp["access_token"],
refresh_token=token_resp.get("refresh_token"),
auth_service_url=args.auth_url,
)
email = result.get("user", {}).get("email", "unknown")
print(f"\nLogged in as {email}")
ここで device_code_login を codens-mcp 内に再実装する選択肢もありました。たぶん 100 行ちょっとのコードで、コピペで動きます。でもやらなかった。理由は 2 つ。1 つ目は単純に重複が嫌だった、token の polling とか slow_down の処理とか、2 か所に同じ実装が並ぶと bug fix が片方だけに入る事故が必ず起きる。2 つ目は、purple-codens-mcp を単体で使っている user (Codens を Purple だけ試している人) にも同じ Device Code Flow の login が必要で、auth.py に集約しておく方が両方から呼べて綺麗だった。
whoami も似た構造で、PurpleCodensClient を使って /api/v1/auth/me を叩いて、email / user_id / organization / credit を表示するだけ。token が無い時は Not logged in. Run: codens-mcp login と出して exit 1 で終わる。地味だけど、user が「自分が誰として login しているか分からない」状態を解消する tool が無いと、複数組織で使う時に確実に事故ります。
release のやり方と、これから
release 順は次の通り:
- 0.1.0 (5/6): 統合 MCP package を新設、Purple/Red/Blue/Green/Auth の 31 tools 全部入り
- 0.3.0 (5/6): cross-product tool
codens_register_project_unifiedを追加 - 0.4.0 (5/7): CLI subcommands
login/whoami/serveを実装、no-arg →serve後方互換維持
5/6 から 5/7 で 0.1.0 → 0.4.0 まで動いているのは、初期に CLI の subcommand 設計まで一気に詰めず、まず tool を出してから login を後で入れたから。順序として正しかったかは微妙です。login を最初から入れておけば、0.1.0 を install した user が「あれ、login コマンドが無い、どうやって token 渡せばいい」と詰まる時間が無くなった。次に同じ系統の package を切る時は、login を最小限の subcommand として 0.1.0 に入れる方針にします。
補足として、purple-codens-mcp 単体 package も廃止せずに維持しています。Purple だけ試したい人、codens-mcp の依存サイズが気になる人 (まあ大した差はないんですが)、それと先ほど書いた通り login の本体実装を purple-codens-mcp 側に置いている都合で、codens-mcp から transitive に参照される位置にあるからです。両方を release パイプラインで並行して上げる必要があるのは少し手間ですが、これは codens-mcp を 1 か月くらい運用してみて、purple-codens-mcp を deprecate しても問題なさそうなら統合する、という流れを想定しています。今 deprecate しないのは、Purple 単体 user の install を壊したくないから。
そして user 視点で言うと、Codens の 5 product を Claude Code から触りたい人は、こうなります:
pip install codens-mcp
codens-mcp login
.claude/settings.json には 1 個 server を書くだけ:
{
"mcpServers": {
"codens": { "command": "codens-mcp", "args": [] }
}
}
これで Purple の orchestration tool、Red の bug fix、Blue の E2E、Green の PRD、Auth の signup と pricing、それと cross-product registration が、全部 Claude Code の中から呼べる。MCP を 5 個書いていた頃と比べて、setup の摩擦は確実に消せた。
PyPI: https://pypi.org/project/codens-mcp/
Codens のサービス自体を触ってみたい人は、https://www.codens.ai/ から signup できます。tool reference の canonical source は help docs (https://help.codens.ai/) に置いてあるので、agent が「どの tool をいつ使うべきか」を聞きたい時はそちらを参照する形にしてあります。MCP package を 5 個書いて 5 回 login する作業を、pip install 1 回 + login 1 回に潰す。やりたかったのはそれだけで、できてみればシンプルですが、判断は地味に多かった、というのが今回の話でした。
Discussion