CircleCIと連携するCLIやMCPサーバーを自作する場合、API認証はpersonal API token(PAT)の手動発行が前提になりがちです。Webアプリでトークンを作成し、環境変数や設定ファイルに配布する運用は、ツールごとに繰り返すと手間がかかります。
2026年8月、CircleCIはOAuth 2.0 authorization code flow + Dynamic Client Registration(DCR)をAPI v2/v3向けに提供しました。client secretを事前共有せず、PKCE(S256)だけで認可フローを完結できます。取得したトークンは通常のpersonal API tokenと同じ形式で、既存のAPI呼び出しに使えます。
Remote MCP を Cursor に登録する記事は、エンドユーザーがOAuth同意画面で接続する手順を説明しています。
連携ツールの開発者がDCRで自前のOAuthクライアントを登録し、personal API token(PAT)を取得する場合は、Web UIから手動作成したPATとは発行経路が異なります。
手動作成 PAT と OAuth 取得 PAT の違い
| 取得方法 | 権限 | 発行場所 | 向いている用途 |
|---|---|---|---|
| Web UI から手動作成 | ユーザーのフル権限 | Personal API Tokens | 個人のスクリプト、一度きりの API 呼び出し |
| OAuth DCR フロー | 同意時に選んだ Read/Write/Admin(ユーザーのロール以下) | ツール側の認可フロー | CLI、ローカル連携ツール、MCP サーバー自作 |
OAuthフローで発行されるトークンもpersonal API tokenです。有効期限は90日(expires_in: 7,776,000秒)で、refresh tokenはありません。期限切れやローテーション時は、認可フローを再実行します。
登録はPOST /oauth/registerだけで完了し、承認プロセスは不要です。client secretは発行されず、PKCEがフローを保護します。同意画面ではRead/Write/Adminを選べます(Managing API Tokens)。
1. クライアント登録
CLIやMCPサーバーなど、配布するツール単位で一度だけ登録します。ユーザーが認可するたびに登録し直す必要はありません。認証ヘッダーも不要です。
curl -s -X POST https://app.circleci.com/oauth/register \
-H "Content-Type: application/json" \
-d '{
"client_name": "qiita-oauth-test",
"redirect_uris": ["http://127.0.0.1:9090/callback"],
"grant_types": ["authorization_code"],
"response_types": ["code"]
}' | jq .
redirect_urisにはloopbackアドレス(127.0.0.1/localhost/::1、ポート1025–65535、クエリなし)だけが使えます。本番サーバーのURLは指定できません。
実行結果の例です(client_idはマスク)。
{
"client_id": "01a07f9e-...5902",
"client_name": "qiita-oauth-test",
"token_endpoint_auth_method": "none"
}
token_endpoint_auth_method: noneは、client secretを使わないことを意味します。client_idはPKCEで保護されるため、機密情報として扱う必要はありません。同じツールでは再登録せず、設定に保存して再利用します。
CLIENT_ID="01a07f9e-...5902"
REDIRECT_URI="http://127.0.0.1:9090/callback"
2. PKCE 値の生成とリダイレクト待ち受け
別ターミナルで次のlistenerを起動します。code_verifier、code_challenge(S256)、stateを表示し、http://127.0.0.1:9090で待ち受けます。code_challenge_methodはS256必須です(plainは拒否されます)。
python3 -c "
import http.server, urllib.parse, threading, secrets, hashlib, base64
code_verifier = secrets.token_urlsafe(48)
code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode()).digest()
).rstrip(b'=').decode()
state = secrets.token_urlsafe(16)
print('verifier :', code_verifier)
print('challenge:', code_challenge)
print('state :', state)
print()
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
params = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
code = params.get('code', [''])[0]
state = params.get('state', [''])[0]
error = params.get('error', [''])[0]
print()
if error:
print('ERROR:', error)
print('description:', params.get('error_description', [''])[0])
else:
print('CODE :', code)
print('STATE:', state)
self.send_response(200)
self.end_headers()
self.wfile.write(b'You can close this tab.')
threading.Thread(target=self.server.shutdown, daemon=True).start()
def log_message(self, *args):
pass
with http.server.HTTPServer(('127.0.0.1', 9090), Handler) as s:
print('Listening on http://127.0.0.1:9090 ...')
s.serve_forever()
"
表示された値を環境変数に控えます。
CODE_VERIFIER="<listener が表示した verifier>"
CODE_CHALLENGE="<listener が表示した challenge>"
STATE="<listener が表示した state>"
3. 認可 URL を開いて同意する
client_idとPKCE値から認可URLを組み立てます。stateを省略すると400になります。
printf 'https://app.circleci.com/oauth/authorize?response_type=code&client_id=%s&redirect_uri=%s&code_challenge=%s&code_challenge_method=S256&state=%s\n' \
"$CLIENT_ID" \
"$(python3 -c "import urllib.parse; print(urllib.parse.quote('$REDIRECT_URI', safe=''))")" \
"$CODE_CHALLENGE" \
"$STATE"
出力されたURLを、CircleCIにログイン済みのブラウザで開きます。
同意画面では、アクセスレベル(Read/Write/Admin)を選び Allow を押します。
| アクセスレベル | 内容 |
|---|---|
| Read | ユーザーが読み取れるリソースへの読み取り |
| Write | 読み取り・書き込み |
| Admin | ユーザーが持つ権限の上限(org 管理・請求設定を含む場合あり) |
選択したレベルは、ユーザーのCircleCIロールを超えられません。ContributorがAdminを選んでも、Contributor相当の操作に制限されます。
Allow後、listener側にauthorization codeが表示されます。STATEが送信値と一致することを確認してから進みます。
CODE : <authorization_code>
STATE: <一致>
4. authorization code をトークンに交換する
authorization codeは一度しか使えず、すぐに失効します。listenerで受け取ったら、すぐトークンに交換します。
CODE="<listener に表示された code>"
curl -s -X POST https://app.circleci.com/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=$CODE" \
-d "client_id=$CLIENT_ID" \
-d "redirect_uri=$REDIRECT_URI" \
-d "code_verifier=$CODE_VERIFIER" | jq .
成功時のレスポンス例です(トークンはマスク)。
{
"access_token": "CCIPAT_…",
"token_type": "Bearer",
"expires_in": 7776000,
"scope": "device_id=…&os=unknown&perm=admin"
}
公式ドキュメントの例ではトークンがccipat_(小文字)ですが、実測では CCIPAT_(大文字) で返りました。文字列比較は大文字小文字を区別しない実装にしておくと安全です。scopeフィールドにはperm=read|write|adminが入り、同意画面で選んだアクセスレベルが反映されます(ドキュメントのJSON例にはscopeがありません)。
TOKEN="CCIPAT_…"
5. API を呼び出して確認する
取得したトークンでCircleCI API v2を呼び出します。Circle-TokenヘッダーとAuthorization: Bearerのどちらでも動作しました。
curl -s https://circleci.com/api/v2/me \
-H "Circle-Token: $TOKEN" | jq .
{
"login": "hidetaka-cci",
...
}
HTTP 200が返れば、OAuthフローによる認証は完了です。連携ツールでは、このトークンを安全に保管し、API呼び出し時にヘッダーへ付与します。
トークンのローテーション
refresh tokenは発行されません。期限前に同じclient_idで認可フローを再実行すると、旧トークンは使えなくなり、新トークンだけが有効になります。
同一client_idで再認可した結果は次のとおりです。
| トークン | GET /api/v2/me |
|---|---|
| 1 回目(再認可前) |
401 Invalid token provided.
|
| 2 回目(再認可後) | 200 |
90日より短い周期で再認可すれば、運用を途切れさせずにローテーションできます。ユーザーは Personal API Tokens 画面からOAuth発行トークンをいつでも削除できます。
まとめ
CircleCI連携ツールやMCPサーバーを自作する場合、Web UIでのPAT手動発行に代わり、OAuth 2.0(DCR + PKCE)でpersonal API token(PAT)を取得できます。client secretを配布せずにローカルツールへ組み込め、同意画面でRead/Write/Adminを選べます。

