ChatGPT が Remote MCP に対応したというのを知って、個人的に興味があって試してみました。動いたことに満足して止まってしまっているので、備忘録も兼ねて残しておきます。
コードはこちらに置いています。
https://github.com/pikum99/chatgpt-remote-mcp-google-oauth
きっかけ
ChatGPT の Developer Mode がリモート MCP サーバーへの接続に対応して、外部のサービスをツールとして呼び出せるようになりました。対象は Pro / Plus / Business / Enterprise / Education プランです。
この記事は 2026-04 時点の情報です。Developer Mode の対象プランや UI は変更される可能性があります。
「じゃあ自分でサーバー立ててみよう」と思ったのがきっかけです。ただ「繋がる」だけでなく、Cloud Run に公開して自分のアカウントだけ許可するところまで、実運用に近い形で認証が成立するか確かめたかったというのが本音です。
なぜリモートだと認証が必要なのか
ローカルの MCP サーバー(Claude Desktop に stdio で繋ぐやつ)は、自分のマシンで動いていて自分しか使わないので認証は特に要りません。
リモートだと話が変わります。インターネット上に公開したエンドポイントなので、「誰がアクセスしてきたのか」を検証しないと、知らない人がツールを呼び放題になってしまいます。
自分の場合、MCP サーバーを Cloud Run に公開する想定だったので、アクセスしてきたのが本当に自分の Google アカウントかどうかを確認する仕組みが必要でした。
実装する(Google OAuth 編)
構成の全体像
フローは2段階に分かれています。
① 初回接続時(アプリ設定のときだけ)
ChatGPT のアプリ設定画面で接続する際、主にこのフローが走ります。以後の再取得は基本的に ChatGPT 側が処理しますが、IdP 側の refresh token 発行条件を満たさない場合は再認証が必要になることもあります。
② ツール呼び出しのたびに
毎回 getTokenInfo() が Google の tokeninfo エンドポイントへリクエストを投げます。今回 ChatGPT から届く Google の user access token は opaque トークンなので、ローカルで完結できずネットワークリクエストが走ります(後述)。
/.well-known/oauth-protected-resource は見慣れない URL ですが、MCP サーバー自身が公開するパスです。ChatGPT がアクセスしてきたときに「どこで認証すればいいか」を返すためだけのエンドポイントで、難しい概念ではないです。詳しくは後半の座学パートで説明します。
技術スタックはこんな感じです。
- フレームワーク: Express(
@modelcontextprotocol/sdkに Express ヘルパーがあり最短で動かせる) - 認証:
google-auth-library(Google トークンの検証に必要なライブラリ) - MCP SDK:
@modelcontextprotocol/sdk@1.29.0 - デプロイ先: Cloud Run(公開 HTTPS エンドポイントを最小構成で用意しやすい)
- 言語: TypeScript(Node.js 22)
自身のMCPサーバーのエンドポイントに /.well-known/oauth-protected-resource を生やす
ChatGPT が MCP サーバーに接続するとき、まず /.well-known/oauth-protected-resource を取得してきます。ここで「このサーバーは Google OAuth で保護されてますよ」という情報を返します。
function getProtectedResourceMetadata(req: Request): ProtectedResourceMetadata {
const resource = new URL("/mcp", getOriginFromRequest(req)).href;
return {
resource,
authorization_servers: [authConfig.issuer], // https://accounts.google.com
scopes_supported: authConfig.requiredScopes,
};
}
app.get(
"/.well-known/oauth-protected-resource",
(req: Request, res: Response) => {
res.set("Access-Control-Allow-Origin", "*");
res.status(200).json(getProtectedResourceMetadata(req));
},
);
authorization_servers として Google の URL を返すことで、ChatGPT は「Google OAuth で認証すればいい」と判断してくれます。
GoogleTokenVerifier でトークンを検証する
Google OAuth でログインした後、ChatGPT が Bearer token を付けて /mcp を叩いてきます。このトークンを検証する部分が GoogleTokenVerifier です。
export class GoogleTokenVerifier implements OAuthTokenVerifier {
private readonly client = new OAuth2Client();
async verifyAccessToken(token: string): Promise<AuthInfo> {
const info = await this.client.getTokenInfo(token);
// audience(クライアントID)の検証
const aud = Array.isArray(info.aud) ? info.aud : [info.aud];
if (!aud.includes(this.config.audience)) {
throw new InvalidTokenError("Token audience mismatch");
}
// メールアドレスによるアクセス制御
if (this.config.allowedEmails.length > 0) {
if (!info.email || !this.config.allowedEmails.includes(info.email)) {
throw new InsufficientScopeError("Email not allowed");
}
}
return {
token,
clientId: this.config.audience,
scopes: info.scopes ?? [],
expiresAt: info.expiry_date
? Math.floor(info.expiry_date / 1000)
: undefined,
extra: {
subject: info.sub,
email: info.email,
issuer: this.config.issuer,
},
};
}
}
ポイントは getTokenInfo() を使っているところです。後述しますが、今回の user access token は JWT じゃないので、ライブラリが tokeninfo エンドポイントへ問い合わせる形になっています。
ALLOWED_EMAILS でメールアドレスを絞れるようにしているので、自分のアカウントだけを許可する設定が環境変数1つで済みます。なお email の取得には email スコープが必要で、このコードでは openid email を要求しています。個人利用では email ベースで十分ですが、厳密には Google の不変識別子は sub です。
環境変数の設定
.env はこれだけです。
OIDC_AUDIENCE=<your-client-id>.apps.googleusercontent.com
ALLOWED_EMAILS=your@gmail.com
OIDC_AUDIENCE には Google OAuth のクライアント ID(xxxxx.apps.googleusercontent.com の形式)を入れます。OIDC_ISSUER(https://accounts.google.com)はコード内にハードコードしているので環境変数には不要です。
アクセス制御の仕組み
このサーバーのアクセス制御はサーバー側の ALLOWED_EMAILS で制御しています。
Google アカウント
↓
[サーバー側] ALLOWED_EMAILS による検証
↓ 通過できなければ 403
MCP ツール実行
-
ALLOWED_EMAILSが未設定(空)の場合、Google OAuth を通過した全員を許可 -
ALLOWED_EMAILSが設定ありの場合、リストに含まれるメールアドレスのみ許可、それ以外は 403
Google OAuth 同意画面のテストユーザー設定で制御できるのでは
公開ステータスがテスト中の場合、リストに登録されていないアカウントは Google 側で弾かれる仕組みになっています。ただし即時失効の仕組みではありません。いったん認証済みのクライアントでは、リストから外してもしばらく通り続けることがありました。
テストユーザー設定はあくまで Google 側の挙動に依存していて、アプリ側から制御できません。「入れておくに越したことはない」くらいの位置づけで、セキュリティの主軸にするのは危ういと感じました。自分の環境では一応自分のアカウントをテストユーザーに登録しておきました。
実運用でアクセスを絞りたい場合は ALLOWED_EMAILS を主軸にするというのが安全側の判断だと思っています。
ちなみに、新しいユーザーを追加したい場合は ALLOWED_EMAILS を更新して再デプロイします。
gcloud run deploy chatgpt-remote-mcp \
--region asia-northeast1 \
--update-env-vars "ALLOWED_EMAILS=alice@gmail.com,bob@gmail.com"
Cloud Run にデプロイ
その前に、Google Cloud Console で OAuth クライアント ID を作成しておきます。認証情報ページから「OAuth クライアント ID を作成」→ アプリケーションの種類は「ウェブ アプリケーション」を選びます。
作成後に認証情報をダウンロードして、クライアント ID とクライアント シークレットをメモしておきます。
Cloud Run は --allow-unauthenticated で公開します。IAM 認証はオフにして、認証はアプリ側の Bearer token 検証で完結させるのが今回の方針です。
gcloud run deploy chatgpt-remote-mcp \
--source . \
--region asia-northeast1 \
--allow-unauthenticated \
--set-env-vars "OIDC_AUDIENCE=<your-client-id>.apps.googleusercontent.com,ALLOWED_EMAILS=your@gmail.com"
デプロイが終わったら、ChatGPT の Developer Mode で App を作成します。Workspace Settings → 開発者モードを ON にして「アプリを作成」を押します。
MCP サーバーの URL(/mcp 付き)を入力し、先ほどメモしたクライアント ID とシークレットを入れます。
右側にコールバック URL が表示されるので、それを Google Cloud Console の「承認済みのリダイレクト URI」に登録します。
設定が完了したら、ChatGPT側で、リスクを受け入れて、作成しましょう。
接続に成功するとこの画面になります。
動いた
無事登録されたら、メイン画面で「/テス」と打てば登録されたMCPが呼び出されるようになるはずです。

セキュリティ面もひととおり確認しました。
| テスト | 結果 |
|---|---|
| Authorization ヘッダーなし | 401 |
| 壊れたトークン | 401 |
| audience が違う正規トークン(gcloud token 等) | 401 |
| 別ヘッダー名で偽装 | 401 |
| GET でバイパス試み | 401 |
| JSON インジェクション | 401(処理前に弾かれる) |
| パストラバーサル | 404 |
| 許可外メールアドレスのトークン | 403 |
不正アクセスはきちんと弾けていました。
ハマりポイント
クライアント ID とシークレットを別のクライアントのものを入れていた
シンプルに取り違えてしまった。エラーメッセージが親切じゃなくてしばらく気づかなかったです。
MCP サーバーの URL に /mcp を付け忘れていた
デプロイされた Cloud Run の URL をそのまま入れてしまっていて、/mcp を付け忘れていました。接続できないのでおかしいと思ったら単純なミスでした。
Google の user access token は JWT じゃなかった
実装して気づいたんですが、今回 ChatGPT から届く Google の user access token は opaque トークン(中身を覗けない不透明な文字列)です。JWT みたいにヘッダーとペイロードがある構造じゃないので、jose や jsonwebtoken で検証しようとしても無理です。
そのため google-auth-library の getTokenInfo() を使って、Google の tokeninfo エンドポイントに毎回問い合わせる方式を選びました。トークンを検証するたびにネットワークリクエストが走るので、JWT と比べると少し重いです。これが後で別の実装方式を試すきっかけになります。
議論:ChatGPT に Google 認証のシークレットを入れて良いのか
ChatGPT の設定画面に Google の OAuth クライアント ID とシークレットを入力する手順があって、ここがちょっと気になっていました。
「OpenAI にシークレットを渡していいのか」という話です。
漏れたときに何が起きるかを整理すると、攻撃者ができることは「あなたのアプリとして OAuth フローを開始する」ことです。ただ Google は登録されていないリダイレクト URI を拒否するので、攻撃者のサーバーに Authorization Code が届くことはありません。Authorization Code とは認証許可後に Google が発行する一時コードで、これをアクセストークンと交換します。redirect URI を厳格に管理していれば被害範囲は比較的限定されますが、リスクがゼロとは言えません。
万一リダイレクト URI の設定が緩んでいた場合(余分な URI が登録されているなど)、偽サイトで認証が成功し、なりすましログインが成立します。
ただ、以下を守れば現実的なリスクはかなり抑えられます。
- 実験用のクライアントを専用で作る(本番や他サービスのクライアントと混ぜない)
- スコープを
openid emailなど最小限にする - redirect URI を厳格に管理する(不要なものを登録しない)
- 実験が終わったらクライアントを削除する
(座学)ChatGPT の Remote MCP 接続の仕組み
動いてから改めて整理しました。実装中は RFC 9728 とか .well-known とかの概念がなかなかピンと来なくて、「なんでこのエンドポイントが必要なの」という状態からスタートしていました。動かしてみて初めて繋がった感じです。
RFC 9728 が何をしているか
ChatGPT が MCP サーバーへ接続するとき、最初に /.well-known/oauth-protected-resource を叩きます。これは RFC 9728(OAuth Protected Resource Metadata)という仕様に基づいた動作です。
厳密に言うと RFC 9728 では、保護対象リソースに /mcp のようなパスがある場合、PRM エンドポイントも path を含む形(/mcp 付き)になります。ただ実際に動かすと、ChatGPT は path なしの root endpoint を叩いてきました。この記事の実装もそれに合わせています。仕様どおりではなく「ChatGPT の実装に合わせた形」という点は頭に置いておいてください。
このエンドポイントが返す JSON には authorization_servers というフィールドがあります。「このリソースサーバーはここに書いてある認可サーバー(今回なら Google)で認証してね」という情報を伝えます。
{
"resource": "https://your-mcp-server.run.app/mcp",
"authorization_servers": ["https://accounts.google.com"],
"scopes_supported": ["openid", "email"]
}
ChatGPT はこれを読んで「Google OAuth で認証すればいいんだな」と判断し、Google の OAuth フローを開始します。
ChatGPT がサーバーを発見して認証するまでの流れ
ざっくりまとめるとこうです。
- ChatGPT が
/.well-known/oauth-protected-resourceを取得 -
authorization_serversから Google を特定 - Google OAuth フローを ChatGPT が自動で実施(ユーザーはログインするだけ)
- Google アクセストークンを取得
-
Authorization: Bearer <token>を付けて/mcpを POST - サーバー側でトークンを検証してツールを実行
ユーザー(自分)は Google ログインをするだけで、あとは ChatGPT と MCP サーバーの間で自動的に処理されます。
実装してわかったこと
RFC 9728 という標準仕様があるおかげで、「ChatGPT が MCP サーバーを見つける → 認証フローを実行する」という流れが汎用的に動きます。Google OAuth に限らず、Auth0 や他の IdP に差し替えることができるのもこの設計のおかげです。
MCP SDK の requireBearerAuth ミドルウェアが認証周りを抽象化してくれているので、自分で実装するのは「どうやってトークンを検証するか」の部分だけで済みました。
全体的な所感
開発者モードでしか配布できないあたり、Chrome 拡張と似た匂いを感じました。今回の Google OAuth 構成では、クライアント ID とシークレットを相手に渡す必要があるのもハードルが高いです。今のところ「自分だけが使う」か「信頼できる人に直接渡す」くらいの運用になりそうです。
ChatGPT 側で社内向け配布みたいな仕組みができれば話が変わりそうですが、まだまだ黎明期な感じがします。
ただ MCP ならではのいいところもあります。
- LLM のトークン代をウェブサービス側に転嫁できる(呼び出し元は ChatGPT の料金で済む)
- ChatGPT だけでなく Claude からも呼び出せる
「ツール込みの Chat を自分たちでホスティングしてしまえばいい」という話もあって、お金と工数があればそっちの方がシンプルではあります。ただ MCP はユーザー側が自分の契約している LLM からそのまま呼べるので、提供側がモデルを持たなくていいのはメリットだと思っています。
次の課題:クライアント登録が手動なのが面倒
動きはしたんですが、ChatGPT 側で App を作るときに OAuth クライアント ID とシークレットを手動で入力する必要がありました。Google Cloud Console でクライアントを作成して、ChatGPT の設定画面にコピペするという手順です。
一度設定すれば済む話ではあるんですが、「ChatGPT が自動でクライアント登録まで完結できたらもっとスマートでは」と思い始めました。これを解決するのが DCR(Dynamic Client Registration、RFC 7591)です。
Auth0 を組み合わせて DCR に対応した話は次の記事で書きます。