前編で「公開API叩けたわ〜」と浮かれていたら、本番でちゃんと繋ぐ段になってOAuthに足を取られた。同じ罠を踏む人が一人でも減りますように、という供養記事です。
前編のあらすじ
前回、「ChatGPTって外部API叩けなくて不便だな…せや、MCPサーバ自作してコネクタに登録したろ!」をやった。 公開APIを叩くツールを生やしたMCPサーバを立てて、HTTPSで公開するところまではサクサクだった。
で、いざChatGPTのカスタムコネクタにURLを登録して「接続」を押す。すると——
「認証が必要です」。はい沼。ここからが本編。
なんでOAuthが要るの?
自分専用でちょっと遊ぶだけなら認証なしでもいい。けどリモートのコネクタとしてChatGPTに繋ぐ場合、ChatGPTは「このサーバ、誰がアクセスしてるのか保証して」とOAuthを要求してくる。しかもただのOAuthじゃなくて、
- DCR(Dynamic Client Registration):ChatGPTが勝手に
/registerを叩いて、自分をクライアントとして自動登録してくる。手動でclient_id発行とかしない。 - PKCE:認可コード横取り対策。
code_challenge/code_verifierの例のアレ。 - public client:ChatGPTは
token_endpoint_auth_method: "none"で登録してくる(クライアントシークレットを持たない)。だからPKCEが必須になる。
「うわ、認可サーバを自前実装…?」とビビるけど、ここは公式SDKがだいぶ持ってくれる。
SDKが用意してくれるもの
@modelcontextprotocol/sdk には認証まわりの部品が入っていて、ざっくり2つ使う。
mcpAuthRouter…/authorize/token/register(DCR)/.well-known/*(メタデータ)をまとめて生やすルータ。requireBearerAuth…/mcpを守るミドルウェア。アクセストークンを検証して、通れば本人情報をリクエストに載せる。
自分で書くのは OAuthServerProvider という1個のオブジェクトだけ。「認可コード発行して」「トークンに交換して」「このトークン誰?」みたいなコールバックを埋める。
地味だけど超重要: mcpAuthRouter はアプリのルート直下にマウントすること(/auth の下とかに入れない)。 ChatGPTは /.well-known/oauth-authorization-server をルートから探しに来るので、ここを間違えるとそもそもメタデータが見つからず詰む。
全体の流れ(自前ログインに委譲する作戦)
ユーザー認証そのものをイチから作るのはしんどい(パスワード管理とか二度とやりたくない)。 なので今回は 「本人確認は手元の既存アプリのログイン画面に丸投げ」 する作戦にした。MCPサーバ自身はパスワードを一切持たない。

ポイントは、トークンの中に userId を仕込んでおくこと。こうすると /mcp でツールが呼ばれたとき「これは誰の操作か」が分かり、ユーザーごとにデータを分けられる。
Provider の骨子
コールバックを埋めるとこんな感じ(要点だけ抜粋)。
export const provider = {
get clientsStore() { return clientsStore; }, // DCRで登録されたクライアントの保管
// ① 認可開始:保留(ticket)を作って、ブラウザを「自前ログイン」へ飛ばす
async authorize(client, params, res) {
const ticket = createPending({
clientId: client.client_id,
codeChallenge: params.codeChallenge, // PKCE。あとで突き合わせる
redirectUri: params.redirectUri,
state: params.state,
});
res.redirect(`https://myapp.example.com/login?ticket=${ticket}`);
},
// ② PKCE検証用:そのコードの code_challenge を返す(SDKが verifier と照合)
async challengeForAuthorizationCode(_client, code) {
return peekCode(code).codeChallenge;
},
// ③ コード → トークン(access / refresh の JWT を発行。sub=userId)
async exchangeAuthorizationCode(client, code) {
const c = takeCode(code);
return makeTokens(
await signAccessToken(c.userId, client.client_id),
await signRefreshToken(c.userId, client.client_id),
);
},
// ④ Bearer検証:トークンを開いて userId を取り出し、extra に載せて返す
async verifyAccessToken(token) {
const a = await verifyToken(token, 'access');
return {
token,
clientId: a.clientId,
scopes: ['mcp'],
expiresAt: a.exp, // ★★★ ここ超重要(後述の地雷②)
extra: { userId: a.userId }, // ツールが extra.authInfo.extra.userId で参照
};
},
};「自前ログイン」側は、ログイン&同意が終わったら HMAC等で署名した「userId入りのお墨付き」をMCPサーバのコールバックに返すだけ。これでMCPはパスワードを知らずに本人を特定できる。
…で。ロジック自体はこれで通る。が、本番に出した瞬間、別のところで2回死んだ。ここからが本題です。
地雷①:プロキシ背後の trust proxy 未設定で /authorize がいきなり500
症状
ローカルでは完璧に動いてたOAuthが、本番(Caddyの裏)に出した途端 /authorize と /token が500。 ログには ERR_ERL_UNEXPECTED_X_FORWARDED_FOR の文字。「は?」となる。
原因
DKはOAuthエンドポイントに レート制限(express-rate-limit)を勝手に付けてくれている。 これはクライアントIPごとに数えるんだけど、CaddyやNginxの裏にいると実IPは X-Forwarded-For ヘッダ経由で渡ってくる。 Expressが「このプロキシ信頼していいの?」を知らない(=trust proxy 未設定)状態でこのヘッダが来ると、rate-limit側が「偽装かもしれん」と例外を投げて落ちる。
→ 直し方:Expressに1行
const app = express();
app.set('trust proxy', 1); // 手前のプロキシ1段を信頼して実IPを採用
たった1行。だけど「ローカルでは絶対再現しない」ので、本番で初めて踏むと原因にたどり着くまでが長い。プロキシの裏に置くと決めた時点で先に入れておくと吉。
地雷②:expiresAt を返し忘れて「再認証の無限ループ」
症状
認証フローは全部成功する。トークンも取れてる。なのにChatGPTが 「再認証が必要です」を延々と出し続ける。 繋いだ→使えない→再認証→繋いだ→使えない…の無限ループ。地獄。
原因
requireBearerAuth は、verifyAccessToken が返すオブジェクトに 有効期限 expiresAt(UNIX秒)が載っていることを期待している。 これを載せ忘れると、SDKが 「Token has no expiration time(期限なきトークンは信用できん)」として /mcp を401にする。 ChatGPTから見ると「トークン渡したのに401?じゃあ無効なんだな、もう一回認証しよ」となり、永遠にループする。
→ 直し方:JWTの exp をそのまま expiresAt に載せる
async verifyAccessToken(token) {
const a = await verifyToken(token, 'access');
if (!a) throw new Error('invalid_token');
return {
token,
clientId: a.clientId,
scopes: ['mcp'],
expiresAt: a.exp, // ★これが無いと401→再認証ループ
extra: { userId: a.userId },
};
}
エラーメッセージが「再認証して」なので、てっきりトークン発行側がおかしいと思い込んで、ずっと /token を疑ってた。実際の犯人は検証側の戻り値。これは沼。
沼を抜けた決め手:1行アクセスログ
上の2つ、どっちも「どのエンドポイントで落ちてるか」さえ分かれば一瞬で当たりがつく。 逆に言うと、それが見えないとOAuthフローは長くて切り分け不能。なので全リクエストに雑なログを1行仕込んだ。これが一番効いた。
app.use((req, res, next) => {
res.on('finish', () => {
console.log(`[req] ${req.method} ${req.originalUrl} -> ${res.statusCode}`);
});
next();
});
これを眺めるだけで、
/authorize -> 500なら → 地雷①(trust proxy)/token -> 200なのに/mcp -> 401が連発 → 地雷②(expiresAt)/.well-known/... -> 404→ ルータのマウント位置ミス
…と、ログの形で犯人がほぼ確定する。OAuthを自前で組むなら、最初にこれを入れておくのを強くおすすめします。
その他、地味にハマったメモ
- ChatGPTのredirect_uriは
https://chatgpt.com/connector/oauth/...系。DCRで登録された内容(token_endpoint_auth_method: "none"= public + PKCE)は、登録ストアをのぞくと確認できる。「なんかredirectが合わない」ときはここを実際に見るのが早い。
- コネクタ設定は、登録方法=DCR / scope=自分で決めた文字列(例
mcp)/ OIDCはオフ、で繋がった。
- メタデータのissuerは公開URL(
https://...)で一致させる。localhost混入や末尾スラッシュのズレで地味に弾かれる。
まとめ
OAuth自体は公式SDKが大半を持ってくれるので、「自前のロジック」で詰まることはほぼ無かった。 詰まったのは全部、本番環境の都合(プロキシ背後)と戻り値のお作法(expiresAt)という、ローカルでは絶対に再現しないやつ。OAuthあるある。
ここまで通れば、ChatGPTからユーザーごとに分離された自分の世界に、認証つきで安全に手が届く。前編の「公開API叩けた!」が、これで「人に配れる」レベルになった。長かった…。
というわけで、ChatGPT × MCP の2部作でした。誰かの半日を救えたなら本望です。それでは。


