0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

自分のSaaSのMCPを、ChatGPTとclaude.aiからログインだけで繋がるようにした|OAuth対応で踏んだ落とし穴

0
Posted at

自分のサービスに MCP サーバーを付けて、AI からカードやボードを操作できるようにしていました。
認証は、管理画面で発行した API トークンを Authorization: Bearer に載せる形です。

これで繋がるのは Claude Code だけでした。
ChatGPT と claude.ai のチャット画面には、トークンを貼る欄がありません。
外部の MCP を足すには「そのサービスにログインして許可する」形、つまり OAuth が要ります。

私自身は、トークン方式のまま Claude Code から前から操作していました。
今回の実装は、一般の利用者が ChatGPT や claude.ai のチャット画面から使えるようにするためのものです。
これからは、人が画面を触って道具を使うより、AI に頼んで道具を動かす使い方が主になると考えています。

この記事は、既存の Express 製 SaaS に OAuth を足して、ChatGPT と claude.ai から実際に繋がるまでの記録です。
公式 SDK の部品で組めるところは組み、組めなかったところと、踏んだ落とし穴を書きます。

前提と、調べた時点での状況

・MCP の SDK は @modelcontextprotocol/sdk 1.29.0
・サーバーは Express、Streamable HTTP の /mcp
・フロントは Next.js で、本番は nginx の後ろ

各チャット画面が受け付ける認証は、調べた時点で次のとおりでした。

・ChatGPT(開発者モードのアプリ)は OAuth か認証なし。固定の API キーを送る手段は無い
・claude.ai のカスタムコネクタは OAuth が基本。ヘッダに固定の値を載せる方式は、一部の組織だけの試験提供
・Claude Code は claude mcp add --transport http で足し、/mcp からログインできる

つまり、トークン方式のままではチャット画面の利用者には届きません。

全体の流れ

チャット画面が実際に踏む順番は次のとおりです。

  1. 鍵なしで /mcp を叩き、401 と「鍵はここでもらう」の案内を受け取る
  2. 案内(protected resource metadata)から認可サーバーの住所を読む
  3. Dynamic Client Registration で自分を登録する
  4. 利用者をブラウザで /oauth/authorize へ送る
  5. サービス側でログインと許可を済ませ、引換券(authorization code)が返る
  6. PKCE 付きで引換券を access token に換え、/mcp を呼ぶ
  7. access token が切れたら refresh token で取り替える

このうち 3・4・6・7 の受け口は、SDK の部品がそのまま使えます。
サービス側で書くのは、「どこに何を覚えるか」と、許可の画面です。

SDK の認証ハンドラを /oauth 配下に並べる

SDK には mcpAuthRouter という一式が入っています。
ただしメタデータを作る createOAuthMetadata が、受け口をルート直下の /authorize /token /register に固定しています。

// sdk/dist/esm/server/auth/router.js の抜粋
const authorization_endpoint = '/authorize';
const token_endpoint = '/token';

既存のアプリのパスと混ぜたくなかったので、ハンドラを個別に取り込んで /oauth の下に並べ、メタデータは自分で組みました。

import { authorizationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/authorize.js';
import { tokenHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/token.js';
import { clientRegistrationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/register.js';
import { revocationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/revoke.js';
import { mcpAuthMetadataRouter } from '@modelcontextprotocol/sdk/server/auth/router.js';

const oauthMetadata = {
  issuer: PUBLIC_URL,
  authorization_endpoint: `${PUBLIC_URL}/oauth/authorize`,
  token_endpoint: `${PUBLIC_URL}/oauth/token`,
  registration_endpoint: `${PUBLIC_URL}/oauth/register`,
  revocation_endpoint: `${PUBLIC_URL}/oauth/revoke`,
  response_types_supported: ['code'],
  scopes_supported: ['offline_access'],
  grant_types_supported: ['authorization_code', 'refresh_token'],
  code_challenge_methods_supported: ['S256'],
  token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
};

router.use(mcpAuthMetadataRouter({
  oauthMetadata,
  resourceServerUrl: new URL(`${PUBLIC_URL}/mcp`),
  scopesSupported: ['offline_access'],
}));
router.use('/oauth/authorize', authorizationHandler({ provider, rateLimit }));
router.use('/oauth/token', tokenHandler({ provider, rateLimit }));
router.use('/oauth/register', clientRegistrationHandler({ clientsStore, rateLimit }));
router.use('/oauth/revoke', revocationHandler({ provider, rateLimit }));

mcpAuthMetadataRouter/.well-known/oauth-protected-resource/mcp/.well-known/oauth-authorization-server を返してくれます。

provider は SDK の OAuthServerProvider を実装したものです。
中身は次のメソッドを埋めるだけです。

clientsStore.registerClient / getClient(登録された AI を DB に置く)
authorize(許可の画面へリダイレクトする)
challengeForAuthorizationCode(PKCE の検証に使う値を返す)
exchangeAuthorizationCode / exchangeRefreshToken(鍵を発行する)
verifyAccessToken / revokeToken

回数制限は SDK が express-rate-limit で掛けます。
CDN と複数の中継を通る構成だと req.ip が中継の住所になり、全員が同じ枠で数えられます。
rateLimitkeyGenerator を渡して、cf-connecting-ip を先に見るようにしました。

落とし穴1: /mcp が CSRF の関門の後ろにあった

最初に本番へ出したとき、鍵なしで /mcp を叩くと 401 ではなく 403 が返っていました。

{"error":"CSRF: Origin/Referer ヘッダが必要です","code":"forbidden"}

アプリ全体に、Cookie 認証の書き込みで Origin を確かめるミドルウェアを入れていました。
Bearer が付いていれば素通りする作りだったので、トークン方式の頃は問題になりませんでした。
ところがチャット画面の最初の呼び出しには Bearer も Origin も付きません。
403 だとチャット画面はログインへ進めず、「繋がりません」で止まります。

AI のサーバーから直接呼ばれる /mcp/oauth/* は Cookie を使わないので、CSRF の関門より前に置きました。
許可の画面が使う API は Cookie のログインで動くので、こちらは関門の後ろのままです。

落とし穴2: 401 に案内を付ける

鍵が無い、または切れた /mcp には、401 と一緒に案内の住所を返します。

res.setHeader(
  'WWW-Authenticate',
  `Bearer resource_metadata="${PUBLIC_URL}/.well-known/oauth-protected-resource/mcp"`
);
return res.status(401).json({ error: 'unauthorized' });

これが無いと、クライアントはどこで鍵をもらえばよいか分かりません。
本番で確かめると、次の形で返っていました。

HTTP/2 401
www-authenticate: Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource/mcp"

落とし穴3: offline_access を載せないと ChatGPT は refresh token を求めない

OpenAI のヘルプに、次の趣旨が明記されています。
認可サーバーのメタデータの scopes_supportedoffline_access が載っていないと、ChatGPT は refresh token を求めず、元の認可が切れたあと利用者に再ログインさせることがある。

access token を1時間にしていたので、載せないと1時間ごとにログインし直しになります。
メタデータと protected resource metadata の両方に offline_access を載せました。
MCP SDK の公式クライアントで繋ぐと、認可の URL に scope=offline_access が付き、refresh token が返ることを確かめています。

渡す鍵は、許可した本人として動かす

access token の置き場所は、既存の API トークンの表にしました。
公開 API をそのまま通るので、操作ログにも Webhook にもこれまで通り乗ります。
管理画面のトークン一覧にも AI の名前で並ぶので、そこで失効させられます。

足した列は次の4つです。

ALTER TABLE api_tokens ADD COLUMN member_id integer REFERENCES tenant_members(id) ON DELETE CASCADE;
ALTER TABLE api_tokens ADD COLUMN oauth_client_id text REFERENCES oauth_clients(client_id) ON DELETE CASCADE;
ALTER TABLE api_tokens ADD COLUMN refresh_token_hash varchar(128) UNIQUE;
ALTER TABLE api_tokens ADD COLUMN refresh_expires_at timestamptz;

ここで1つ判断が要りました。
既存の API トークンは「ワークスペースの管理者と同じ権限のボット」として動く作りです。
OAuth で渡す鍵を同じ扱いにすると、一般のメンバーが許可した鍵で管理者の操作ができてしまいます。

そこで member_id が入っている鍵は、許可した本人のセッションとして扱うことにしました。

・権限の判定は、その人の役割で行う
・本人がワークスペースを抜けたら、その鍵は使えない
・鍵を渡したワークスペースの外には出られない(本人が他のワークスペースに入っていても)
・契約、トークンの発行、独自ドメインの API は、AI からは 403 にする

const OAUTH_BLOCKED_PATHS = ['/api/billing', '/api/workspace/tokens', '/api/workspace/domains'];

許可の画面

authorize では、依頼の控えを DB に置いて許可の画面へリダイレクトします。

async authorize(client, params, res) {
  if (params.resource && !isOurResource(params.resource)) {
    throw new InvalidTargetError('This authorization server only issues tokens for its MCP endpoint');
  }
  const id = randomBytes(24).toString('base64url');
  await saveRequest({ id, clientId: client.client_id, ...params, expiresAt: in10min });
  res.redirect(302, `${PUBLIC_URL}/connect?request=${id}`);
}

許可の画面では次を出します。

・どの AI が繋ごうとしているか(登録時の client_name
・繋ぐワークスペースの選択(本人が入っているものだけ)
・本人の権限でしか動かないこと、どこで止められるか
・許可したあと戻る先のホスト

未ログインなら /login?next=/connect?request=... へ送り、戻ってきます。
next には同じサイトのパスだけを通します。
//evil.example のような値を通すと、ログイン直後に外へ送り出す踏み台になるからです。

「許可する」を押すと引換券を作り、戻り先の URL に codestateiss を付けて返します。
二度押しで引換券が2枚出ないよう、「まだ券が無い行だけを更新する」形にしています。

落とし穴4: 止めたはずの鍵が生き残っていた

refresh token で取り替えるときは、鍵も札も新しくして、古い札はその場で使えなくします。
取り替えの時点で本人がワークスペースを抜けていたら、鍵を止めて断ります。

最初はこう書いていました。

return withTransaction(async (tx) => {
  // ...
  if (!memberStillActive) {
    await tx.update(apiTokens).set({ revokedAt: new Date() }).where(eq(apiTokens.id, rec.id));
    throw new InvalidGrantError('The user is no longer a member of this workspace');
  }
  // ...
});

単体テストで、止めたはずの鍵が verifyAccessToken を通ってしまいました。
取引の中で例外を投げているので、止める書き込みごと巻き戻っていたのです。

直し方は2つ入れました。

・止める書き込みは、例外を投げる取引の外で行う
・鍵を確かめる側でも、本人がいまもメンバーかを見る(止め損ねても通さない)

検証: SDK 公式クライアントで本番に繋ぐ

手書きのテストだけだと、自分の解釈で書いた手順をなぞっているに過ぎません。
MCP SDK に入っているクライアント側の OAuth 実装で、本番の /mcp に繋いで確かめました。
Claude Code などの実装が踏むのと同じ発見の手順を通るので、仕様の読み違いがあればここで落ちます。

const provider = {
  get redirectUrl() { return 'http://127.0.0.1:9/cb'; },
  get clientMetadata() {
    return {
      client_name: 'SDK Check',
      redirect_uris: ['http://127.0.0.1:9/cb'],
      grant_types: ['authorization_code', 'refresh_token'],
      response_types: ['code'],
      token_endpoint_auth_method: 'none',
    };
  },
  clientInformation: () => store.client,
  saveClientInformation: (c) => { store.client = c; },
  tokens: () => store.tokens,
  saveTokens: (t) => { store.tokens = t; },
  redirectToAuthorization: (u) => { authUrl = u; },
  saveCodeVerifier: (v) => { store.verifier = v; },
  codeVerifier: () => store.verifier,
};

let transport = new StreamableHTTPClientTransport(new URL('https://example.com/mcp'), { authProvider: provider });
try {
  await new Client({ name: 'check', version: '0' }).connect(transport);
} catch (e) {
  if (!(e instanceof UnauthorizedError)) throw e; // ここで authUrl が埋まる
}

// authUrl を Playwright で開き、テスト用アカウントでログインして「許可する」を押す。
// 戻り先 127.0.0.1:9 は page.route で受けて code を取り出す

await transport.finishAuth(code);
transport = new StreamableHTTPClientTransport(new URL('https://example.com/mcp'), { authProvider: provider });
const client = new Client({ name: 'check', version: '0' });
await client.connect(transport);
console.log((await client.listTools()).tools.length);

本番で、次の結果が出ました。

・認可の URL に scope=offline_accessresource が付いている
・refresh token が返り、expires_in は 3600
・道具の一覧が取れ、読むだけの道具を呼ぶと中身が返る
/oauth/revoke で止めたあと、その鍵で API を叩くと 401

回帰テストも、チャット画面が踏む順番をそのままなぞる形で E2E に置きました。
同じ引換券の2回目は 400、取り替え後の古い鍵は 401、古い札の再利用は 400、を見ています。

実物の ChatGPT と claude.ai で繋ぐ

最後に、実際のチャット画面から本番へ繋ぎました。

claude.ai は、カスタムコネクタの追加で名前と MCP サーバーの URL を入れるだけです。
サーバーの確認が走り、許可の画面が開き、許可すると道具の一覧が並びました。

ChatGPT は、次の順でした。

  1. 設定の「セキュリティとログイン」で開発者モードをオンにする
  2. プラグインの画面で「アプリを作成」を押す
  3. 名前、説明、MCP サーバーの URL を入れる。認証は OAuth が自動で検出される
  4. カスタム MCP サーバーのリスクへの同意にチェックを入れて作成する
  5. サインインのボタンから許可の画面へ進み、許可する

このとき nginx のログには、ChatGPT 側のサーバーが次の順に来ていました。

GET  /.well-known/oauth-protected-resource/mcp   200  "Python/3.13 aiohttp/3.13.5"
GET  /.well-known/oauth-authorization-server     200  "Python/3.13 aiohttp/3.13.5"
POST /oauth/register                             201  "Python/3.13 aiohttp/3.13.5"
GET  /oauth/authorize?...scope=offline_access    (利用者のブラウザ)
POST /mcp                                        200  "openai-mcp/1.0.0"

発見と登録は aiohttp、MCP の呼び出しは openai-mcp/1.0.0 という名乗りでした。

ChatGPT のアプリ一覧に載せるための準備

開発者モードでの接続は、利用者が有料プランで、リスクの注意書きに同意して、自分で URL を入れる必要があります。
一覧に載れば、一覧から追加してログインするだけになります。
一覧に載せるには申請と審査が要り、準備の中で手を入れたのは次の2つです。

annotations を全部の道具に付ける

申請の手引きで、付け忘れと付け間違いがよくある却下の理由として名指しされています。

const READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
const CREATE = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
const MOVE = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false };
const OVERWRITE = { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false };

server.registerTool('update_card', {
  title: 'Update a card',
  description: 'Change the title, description, dates, or completion of a card. Fields you pass replace the current values.',
  inputSchema: { /* ... */ },
  annotations: OVERWRITE,
}, handler);

分け方は次のとおりです。

・一覧や中身を返すだけの道具は READ
・カードやボードを足すだけの道具は CREATE
・別のリストへ動かすだけの道具は MOVE(元に戻せる)
・上書きと削除は OVERWRITE(元の中身が失われる)
・利用者のワークスペースの中しか触らないので、openWorldHint はすべて false

道具の題と説明は英語にしました。
読むのは AI と審査員で、AI は英語の説明でも利用者の言葉で受け答えします。

ドメイン確認の札

申請の画面が出す値を、/.well-known/openai-apps-challenge で返します。
札の文字列だけを返す決まりで、JSON や複数の値を返すと確認が通りません。

app.get('/.well-known/openai-apps-challenge', (_req, res) => {
  const token = process.env.OPENAI_APPS_CHALLENGE?.trim();
  if (!token) return res.status(404).type('text/plain').send('Not found');
  res.type('text/plain').send(token);
});

ほかに、OpenAI Platform での事業者の本人確認、審査員用のアカウント、プライバシーポリシーの記載(AI アシスタントを繋いだときの送信先を含む)が要ります。

まとめ

・チャット画面の利用者に届けるなら、トークン方式ではなく OAuth が要る
・SDK の部品で受け口はほぼ揃う。書くのは、どこに何を覚えるかと、許可の画面
・鍵なしの最初の呼び出しに 401 と resource_metadata を返す。CSRF などの関門に止められていないか確かめる
scopes_supportedoffline_access を載せる
・渡す鍵は許可した本人として動かし、管理者扱いにしない
・検証は SDK 公式クライアントで本番に繋いで行う。そのうえで実物のチャット画面で繋ぐ

参考にした公式の資料です。

この記事の実装は、カンバンとガントのタスク管理 SaaS「Pinateca」で行ったものです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?