Cursor を起動しただけで、Chrome に Salesforce の認証画面が一瞬だけ開いては閉じる——それを3回繰り返す。同じ現象がなぜか2度、姿を変えて再発した。原因を1つずつ剥がしていったら、最後に出てきたのは「そもそも直すべきだった箇所自体が存在しなかった」という種明かしだった…。
こんにちは!システム開発チームです。
残暑の厳しい日が続いていますが、始業早々怪奇現象を目の当たりしにて少し肝が冷えた朝の記録です。
mcp-remote にあり、今の Cursor はネイティブでリモート MCP + OAuth を話せる。朝 PC を起動し、Chrome を開くと、真っ先に目に入ったのはこのメッセージだった。
この URL は、現在存在しません。
アクセスを試みた URL は、salesforce.com にはもう存在しません。
バックグラウンドで Salesforce を自動起動させてはいないのでなんか気持ち悪い…。
落ち着いて AI アシスタント側(Claude Code, Cursor)が使っている MCP 設定を確認すると、Salesforce の Hosted MCP サーバーに mcp-remote という npm パッケージ経由で繋いでいた。これはリモートの MCP サーバーを、ローカルの stdio プロセスであるかのようにクライアントへ見せかける橋渡しツールで、OAuth のブラウザ認可・トークンのキャッシュもすべて自前で行う。
プロセスとキャッシュを追うと、手がかりが2つ見つかった。
https://login.salesforce.com/authorize という URL が組み立てられ、そこにアクセスして 404 になっていた正しいエンドポイントは https://login.salesforce.com/services/oauth2/authorize。
OAuth のクライアントは通常、保護リソースのメタデータから認可サーバーを特定したあと、/.well-known/oauth-authorization-server でエンドポイント一覧を取得する。ところが Salesforce はこのパスを提供しておらず、代わりに /.well-known/openid-configuration だけを返す。
$ curl -s https://login.salesforce.com/.well-known/oauth-authorization-server
404 # Salesforceはこのパスを提供しない
$ curl -s https://login.salesforce.com/.well-known/openid-configuration
200
{
"authorization_endpoint": "https://login.salesforce.com/services/oauth2/authorize",
"token_endpoint": "https://login.salesforce.com/services/oauth2/token"
}
当初使っていた mcp-remote@0.1.18 のバンドル済みSDKには、この openid-configuration へのフォールバックが実装されていなかった。だから唯一取得できたパス(不在の oauth-authorization-server)を issuer として、new URL("/authorize", issuer) という単純な文字列結合で誤った URL を組み立て、それをそのままブラウザで開いていた——というのが Case #1 の顛末。
判定: 古いバージョンの
mcp-remoteが Salesforce 特有の非標準なディスカバリ経路に対応できていなかった。バージョンを上げれば直る、はず。
mcp-remote を最新版に上げ、キャッシュを掃除して再起動。ところが今度は、404 エラーページではなく正常な認証画面が開くので、認証を行い Chrome を再起動すると今度は一瞬だけ開いては閉じるという、より不気味な症状に変わって再発した。しかも複数回。
プロセス一覧を見ると、mcp-remote が同時に4組も立ち上がり、OAuth コールバック用のポート 8080 を奪い合っていた。認証キャッシュのディレクトリには、完了しなかった認可試行の残骸—— PKCE の code_verifier ファイル——が10個近く溜まっていた。
48535 npm exec mcp-remote ... --static-oauth-client-info {...}
48580 node .../mcp-remote ...
49255 npm exec mcp-remote ... --static-oauth-client-info {...}
49277 node .../mcp-remote ...
# 同一サーバーへの接続がポート8080を奪い合っている
エディタが再接続を試みるたびに新しいプロセスが立ち上がり、そのたびに新しい PKCE リクエストで新しいタブが開く——ブラウザでの手動ログインが完了する前に、次のプロセスが「失敗」と判断されて再試行される。これがポートの奪い合いを起こしていた。プロセスとキャッシュを一度きれいに掃除して再起動すれば直るはずだった。
判定: プロセスの掃除で一時的には収まったが、再発した。これは結果であって原因ではなかった。
再発を受けて、エディタ経由の再接続レースを切り離し、mcp-remote をターミナルから単体で直接実行してみた。ブラウザは開く。既にログイン・許可済みのアプリだったため、クリックすら必要とせず認可コードは一瞬で返ってくる。問題はその次、認可コードをアクセストークンに交換する段階で起きていた。
Authentication required. Waiting for authorization...
Auth code received, resolving promise
Completing authorization...
Authorization error: Error: client_secret_basic authentication requires a client_secret
at applyBasicAuth (mcp-remote/dist/chunk-*.js:27000:11)
at applyClientAuthentication (mcp-remote/dist/chunk-*.js:26986:7)
at executeTokenRequest (mcp-remote/dist/chunk-*.js:27369:5)
ようやく再現した本物のエラー。ソースコードを読みに行くと、認証方式を選ぶロジックがこうなっていた。
// mcp-remote / @modelcontextprotocol/sdk 内部
function selectClientAuthMethod(clientInformation, supportedMethods) {
const hasClientSecret = clientInformation.client_secret !== void 0;
// ...
if (hasClientSecret && supportedMethods.includes("client_secret_basic")) {
return "client_secret_basic";
}
// ...
}
function applyBasicAuth(clientId, clientSecret, headers) {
if (!clientSecret) {
throw new Error("client_secret_basic authentication requires a client_secret");
}
// ...
}
設定ファイルに書いていた "client_secret": "" ——空文字列——が命取りだった。
"Salesforce": {
"command": "npx",
"args": [
"-y",
"mcp-remote@latest",
"https://api.salesforce.com/platform/mcp/v1/platform/sobject-reads",
"8080",
"--static-oauth-client-info",
"{\"client_id\":\"<CONSUMER-KEY>\",\"client_secret\":\"\"}"
]
},
JavaScript の !== undefined という判定では、空文字列は「値がある」扱いになる。だから hasClientSecret は true になり、Salesforce が対応方式として返す client_secret_basic が選ばれ、そして実際に渡す秘密鍵が空だったので必ず失敗する——という、100%再現する静的なバグだった。
しかも Salesforce 側の外部クライアントアプリケーション(External Client App)の設定は、確認する限り最初から「Web サーバーフローの秘密が必要」がオフ、つまり PKCE のみでシークレット不要という設定だった。Salesforce は何も間違っていなかった。クライアント側の1行の判定が、存在しないはずの秘密鍵を要求し続けていた。
確定:
--static-oauth-client-infoからclient_secretキーを削除し、代わりに"token_endpoint_auth_method": "none"を明示。単体実行でトークン取得に成功し、有効なリフレッシュトークン付きのキャッシュが書き込まれることを確認した。
"{\"client_id\": \"<CONSUMER-KEY>\", \"token_endpoint_auth_method\": \"none\"}"
認証は安定して通るようになった。ここで終わってもよかった。
だがふと湧き上がった疑問で、調査の前提そのものがひっくり返った。
そもそもホスト型 MCPサーバーの mcp.json の設定はもっとシンプルな形式で、
mcp-remoteは不要では?
Salesforce の公式ドキュメント「Cursor 向けセットアップガイド」を確認すると、確かにまったく別の——圧倒的に単純な——設定例が載っていた。
{
"mcpServers": {
"Salesforce": {
"url": "https://api.salesforce.com/platform/mcp/v1/<SERVER-NAME>",
"auth": {
"CLIENT_ID": "<CONSUMER-KEY>"
}
}
}
}
command も npx も mcp-remote も出てこない。Cursor の公式ドキュメントを確認すると、リモート MCPサーバー向けの OAuth 2.1 / PKCE をネイティブでサポートしており、「no additional software required」と明記されていた。
つまり mcp-remote は、「クライアントがリモート接続と OAuth を扱えなかった時代」を埋めるための互換レイヤーであり、Cursor 自身がその機能を獲得した今、この一連のトラブルの震源地だった橋渡しプロセスごと丸ごと不要になっていた。ポートの奪い合いも、認証方式判定のバグも、すべて「本来クライアントがやるべき仕事を肩代わりしていた外付けツール」の内部実装だったからこそ起きていた問題だった。
ネイティブ方式に切り替える途中、Salesforce 側の「外部クライアントアプリケーション」に登録されていた「コールバック URL」が、もう1つの手がかりを教えてくれた。
| 登録済みURL | 正体 | 今も有効か |
|---|---|---|
cursor://anysphere.cursor-mcp/oauth/callback | Cursor 3.10.17 より前が使っていたカスタムURLスキーム方式 | 無効(廃止済み) |
http://localhost:8080/oauth/callback | mcp-remote が使っていたローカルコールバック | もう使わない |
http://localhost:8787/callback | 現行 Cursor デスクトップ版の固定ループバックポート | 必要 |
多くの OAuth プロバイダは cursor:// のようなカスタムスキームのリダイレクト URI を受け付けない。そのため Cursor は RFC 8252 が推奨するループバックアドレス方式に切り替え、デスクトップ版は固定ポート 8787 を使うようになっていた。この新しいコールバック URL を Salesforce 側の許可リストに追加し、Cursor を再起動。
10:59:13.539 MCP OAuth needsAuth (v2)
10:59:19.954 MCP OAuth tokens persisted
10:59:19.956 MCP OAuth callback exchange completed
10:59:20.528 Successfully connected to streamableHttp server
10:59:20.528 [V2 FSM] connection:connect_success: conn=connecting → conn=connected
解決: 外付けの橋渡しプロセスを完全に排除し、Cursor 自身の OAuth 実装だけで接続が完了。以後、ブラウザの多重ポップアップは発生していない。
ちなみに Claude はコネクタ側の設定で解決するため、Hosted MCP 設定自体削除(Salesforce DX MCP は維持)。
Cursor から Salesforce Hosted MCP サーバーへ接続する、現時点で正しい mcp.json の書き方。
{
"mcpServers": {
"Salesforce": {
"url": "https://api.salesforce.com/platform/mcp/v1/platform/sobject-reads",
"auth": {
"CLIENT_ID": "<外部クライアントアプリケーションのコンシューマー鍵>"
}
}
}
}
CLIENT_SECRET は confidential client(秘密鍵を要求する設定)の場合のみ追加する。PKCE のみで認可する構成なら CLIENT_ID だけで足りる。
https://www.cursor.com/agents/mcp/oauth/callbackhttp://localhost:8787/callbackCursor のログディレクトリ(~/Library/Application Support/Cursor/logs/<セッション>/)にある mcp-server-user-<サーバー名>.log を開き、MCP OAuth tokens persisted と Successfully connected to streamableHttp server の2行が出ていれば成功。
client_secret_basic authentication requires a client_secret は、ターミナルで単体実行して初めて再現できた。selectClientAuthMethod / applyBasicAuth 実装mcp-remote の OS コマンドインジェクション脆弱性(0.0.5〜0.1.15が対象、0.1.16で修正)本文中のクライアントID・組織名・コールバックURLの一部は仮名化・置換しています。設定例の <…> 部分は各自の環境の値に置き換えてください。