🧪

【実験】 Google OAuth2 付きMCPサーバーを作って、Claude Desktopから呼んでみる(Cloudflare)

に公開

はじめに

LLMと外部データソースを統合する際、ツールの認証をどうするのか悩みます。たとえば、MyGPTsのActionsではOAuth 2.0による認証[1]が実装されています。

MyGPTsのOAuth設定画面
MyGPTsではこのようにOAuth設定が可能

これは結構便利で、Google Apps Scriptを作り、組織内ユーザーにアクセスを限定した形でデプロイすれば、スプレッドシートやGmailとのセキュアな連携が、認証フロー含めてMyGPTs上で完結します。

Apps Script
Apps Scriptは呼び出し元を制限できる。呼び出しにはOAuth2が必要。

一方でClaude Desktopも使えるGoogle Workspace連携MCPサーバーの作例を見ると、APIキー認証のものか、手元で認証フローを走らせるもので、Claude Desktop上で認証・認可できるものは管見の限りありません。

この記事は、Claude DesktopでOAuth込みのMCPサーバーを作りたいなと思い、現状でどんなことができるのか、Cloudflareのサンプルを見ながら実験した記録です。

Google Cloud側の設定

まずは、Google Auth Platformで必要な設定を行います。

  1. プロジェクトの新規作成
  2. 同意画面の構成
  3. 「ウェブアプリケーション」でOAuth 2.0クライアントを作成
  4. 「承認済みの JavaScript 生成元」と「承認済みのリダイレクト URI」に下記を設定
    • http://localhost:8787
    • http://localhost:8787/callback

Google Auth Platform設定画面
Google Auth Platform

また、Google Sheets APIも有効化しておきます。

サンプルコードを見る

Cloudflareが提供するGitHubを用いたOAuth認証のサンプルを参考にします。

https://github.com/cloudflare/ai/tree/main/demos/remote-mcp-github-oauth

index.ts

@cloudflare/workers-oauth-providerOAuthProviderが、認証フローの面倒を見ているようです。

https://github.com/cloudflare/ai/blob/8a71c668a278e2515ba7dce0bcfc667ae7abbc4f/demos/remote-mcp-github-oauth/src/index.ts#L1-L98

github-handler.ts

Honoで実装されたハンドラー。Googleにする場合は、ここの調整が必要です。

https://github.com/cloudflare/ai/blob/8a71c668a278e2515ba7dce0bcfc667ae7abbc4f/demos/remote-mcp-github-oauth/src/github-handler.ts#L1-L83

Cloudflare側の準備

wrangler.jsoncファイルから分かるように、KVとDurable Objectを使用してステートフルにしているようです。そのため、自分でKVを作成する必要があります。

https://github.com/cloudflare/ai/blob/8a71c668a278e2515ba7dce0bcfc667ae7abbc4f/demos/remote-mcp-github-oauth/wrangler.jsonc#L1-L39

Google認証用コードの実装

さて、サンプルコードをGoogle認証用に修正します。フレームはそのままに認証フローとtoolを置き換えれば動きそうです。

環境変数

まず、環境変数の差し替えから。.dev.varsにGoogle Cloudで作成した以下の2つの値を設定します。

  • GOOGLE_CLIENT_ID
  • GOOGLE_CLIENT_SECRET

スプレッドシートAPI処理(sheets-api.ts)

Sheets APIでスプレッドシートデータを取得するためのツールを作ります。Edgeランタイムで動かすので、ライブラリは使わず、fetchで実装しています。ここは本記事の本質とは外れるので、Claudeで作ったもの、ほぼそのままにしています。

import { isTokenExpired, refreshAccessToken } from './utils';
import {
    GoogleSheetResponse,
    SheetDataResponse,
    SheetsListResponse,
    GoogleSheet,
    GoogleRowData,
    GoogleCellData
} from './types/google';

/**
 * スプレッドシートURLからIDを抽出
 */
function extractSpreadsheetId(url: string): string {
    const match = url.match(/\/spreadsheets\/d\/([a-zA-Z0-9-_]+)/);
    if (!match) {
        throw new Error('Invalid Google Sheets URL');
    }
    return match[1];
}

/**
 * アクセストークン更新処理
 */
async function ensureValidToken(
    client_id: string,
    client_secret: string,
    accessToken: string,
    refreshToken: string,
    expiryDate: number
): Promise<string> {
    if (!isTokenExpired(expiryDate)) {
        return accessToken;
    }

    // トークン期限切れなら更新
    const newTokens = await refreshAccessToken({
        client_id,
        client_secret,
        refresh_token: refreshToken
    });

    if (!newTokens) {
        throw new Error('Failed to refresh access token');
    }

    return newTokens.access_token;
}

/**
 * Google Sheetsからデータ取得
 */
export async function getSpreadsheetData(
    spreadsheetUrl: string,
    client_id: string,
    client_secret: string,
    accessToken: string,
    refreshToken: string,
    expiryDate: number,
    sheetName?: string
): Promise<SheetDataResponse | SheetsListResponse> {
    try {
        // スプレッドシートID抽出
        const spreadsheetId = extractSpreadsheetId(spreadsheetUrl);

        // トークン有効性確認・更新
        const validToken = await ensureValidToken(client_id, client_secret, accessToken, refreshToken, expiryDate);

        // API URL構築
        let url = `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}`;
        if (sheetName) {
            // 特定シートのみ取得
            url += `?includeGridData=true&ranges=${encodeURIComponent(sheetName)}`;
        } else {
            // シート概要情報のみ取得
            url += '?includeGridData=false';
        }

        const response = await fetch(url, {
            headers: {
                Authorization: `Bearer ${validToken}`,
            }
        });

        if (!response.ok) {
            throw new Error(`Failed to fetch spreadsheet data: ${response.statusText}`);
        }

        const data: GoogleSheetResponse = await response.json();

        // 特定シートのデータ処理
        if (sheetName) {
            const sheet = data.sheets.find((s: GoogleSheet) => s.properties.title === sheetName);
            if (!sheet) {
                throw new Error(`Sheet "${sheetName}" not found`);
            }

            // セルデータ変換処理
            if (sheet.data && sheet.data[0].rowData) {
                const values = sheet.data[0].rowData.map((row: GoogleRowData) => {
                    if (!row.values) return [];
                    return row.values.map((cell: GoogleCellData) => cell.formattedValue || '');
                });

                // ヘッダー行をキーとした行オブジェクト生成
                const headers = values[0];
                const rows = values.slice(1).map((row: string[]) => {
                    const obj: Record<string, string> = {};
                    headers.forEach((header: string, index: number) => {
                        obj[header] = row[index] || '';
                    });
                    return obj;
                });

                return {
                    sheet: sheetName,
                    headers,
                    rows,
                    rawData: values
                } as SheetDataResponse;
            }

            // 空のデータでもシート名と形式だけ返す
            return {
                sheet: sheetName,
                headers: [],
                rows: [],
                rawData: []
            } as SheetDataResponse;
        }

        // スプレッドシート全体の概要情報
        return {
            spreadsheetId: data.spreadsheetId,
            title: data.properties.title,
            sheets: data.sheets.map((sheet: GoogleSheet) => ({
                sheetId: sheet.properties.sheetId,
                title: sheet.properties.title,
                gridProperties: sheet.properties.gridProperties
            }))
        } as SheetsListResponse;
    } catch (error) {
        console.error('Error fetching spreadsheet data:', error);
        throw error;
    }
}

型定義

型エラーが消えるように適当に生成したもの。

export interface GoogleProfile {
  id: string;
  email: string;
  verified_email: boolean;
  name: string;
  given_name: string;
  family_name: string;
  picture: string;
  hd?: string;
}

// Google Sheets API 型定義
export interface GoogleSheetResponse {
  spreadsheetId: string;
  properties: {
    title: string;
  };
  sheets: GoogleSheet[];
}

export interface GoogleSheet {
  properties: {
    sheetId: number;
    title: string;
    gridProperties: {
      rowCount: number;
      columnCount: number;
    };
  };
  data?: GoogleSheetData[];
}

export interface GoogleSheetData {
  rowData?: GoogleRowData[];
}

export interface GoogleRowData {
  values?: GoogleCellData[];
}

export interface GoogleCellData {
  formattedValue?: string;
  userEnteredValue?: {
    stringValue?: string;
    numberValue?: number;
    boolValue?: boolean;
    formulaValue?: string;
  };
}

export interface SheetDataResponse {
  sheet: string;
  headers: string[];
  rows: Record<string, string>[];
  rawData: string[][];
}

export interface SheetsListResponse {
  spreadsheetId: string;
  title: string;
  sheets: {
    sheetId: number;
    title: string;
    gridProperties: {
      rowCount: number;
      columnCount: number;
    };
  }[];
}

MCPサーバーのメイン実装(index.ts)

Propsを少し変えています。そのほかはtoolの差し替えとdefaultHandlerの差し替え。

toolは別ファイルに切り出せば見通し良くなりそうです。

import OAuthProvider from "@cloudflare/workers-oauth-provider";
import { McpAgent } from "agents/mcp";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { GoogleHandler } from "./google-handler";
import { getSpreadsheetData } from "./sheets-api";

/**
 * OAuth認証から取得され、トークンに保存されるユーザー情報
 */
export type Props = {
    client_id: string;
    client_secret: string;
    email: string;
    name: string;
    accessToken: string;
    refreshToken: string;
    expiryDate: number;
};

type State = any;

export class MyMCP extends McpAgent<Env, State, Props> {
    server = new McpServer({
        name: "Google Sheets MCP Server",
        version: "1.0.0",
    });

    async init() {
        // スプレッドシート情報取得ツール
        this.server.tool(
            "getSpreadsheet",
            "Get Google Spreadsheet information",
            {
                url: z.string().describe("The URL of the Google Spreadsheet"),
            },
            async ({ url }) => {
                try {
                    const data = await getSpreadsheetData(
                        url,
                        this.props.client_id,
                        this.props.client_secret,
                        this.props.accessToken,
                        this.props.refreshToken,
                        this.props.expiryDate
                    );
                    return {
                        content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
                    };
                } catch (error) {
                    return {
                        content: [{ type: "text", text: `Error: ${error.message}` }],
                        isError: true,
                    };
                }
            },
        );

        // シート内特定シートのデータ取得ツール
        this.server.tool(
            "getSheetData",
            "Get data from a specific sheet in a Google Spreadsheet",
            {
                url: z.string().describe("The URL of the Google Spreadsheet"),
                sheetName: z.string().describe("The name of the sheet to retrieve"),
            },
            async ({ url, sheetName }) => {
                try {
                    const data = await getSpreadsheetData(
                        url,
                        this.props.client_id,
                        this.props.client_secret,
                        this.props.accessToken,
                        this.props.refreshToken,
                        this.props.expiryDate,
                        sheetName
                    );
                    return {
                        content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
                    };
                } catch (error) {
                    return {
                        content: [{ type: "text", text: `Error: ${error.message}` }],
                        isError: true,
                    };
                }
            },
        );
    }
}

export default new OAuthProvider({
    apiRoute: "/sse",
    apiHandler: MyMCP.mount("/sse"),
    defaultHandler: GoogleHandler,
    authorizeEndpoint: "/authorize",
    tokenEndpoint: "/token",
    clientRegistrationEndpoint: "/register",
});

なお、現時点では一部の型エラーが解消できていませんが、サンプルコードでも同様のエラーが発生しているため、深追いしないこととします。

型エラーの例
型エラー

ローカルでのテスト

実装したコードをローカル環境でテストします。

開発サーバーの起動

pnpm dev

Inspectorの使用

npx @modelcontextprotocol/inspector

http://127.0.0.1:6274にInspectorが立ち上がります。

Inspector画面

URLはhttp://localhost:8787/sseと設定します。正しくセットアップできていれば、「Connect」ボタンをクリックすると認証画面に遷移します。

認証画面

認証が成功すると、以下のようなトーストメッセージが表示されます。

成功通知
たくさん出る。

ただ、筆者はここでエラーになりました。

SSE transport: url=http://localhost:8787/sse, headers=Accept,authorization
Received message for sessionId 8a37f449-84f7-4714-aaac-be8f9d23945e
Error in /message route: Error: SSE connection not established
...
Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client

responseを二重に返してしまっているっぽいですが、トレースからは原因がわからず断念しました。

Claude Desktopとの連携

しょうがないので、Claude Desktopと直接連携してテストをしてみました。

Claude Desktop設定

設定ファイルを以下のように作成します。

{
  "mcpServers": {
    "spreadsheet": {
      "command": "npx", // 場合によってはフルパス
      "args": [
        "mcp-remote",
        "http://localhost:8787/sse" // `/sse`を忘れずに
      ]
    }
  },
  "globalShortcut": ""
}

現在のClaude Desktopでは、トランスポート: sseをサポートしていないので、mcp-remoteを中継します。

https://github.com/geelen/mcp-remote

この設定でClaude Desktopを起動すると、しばらくしてブラウザが開き認証画面が表示されました。認証が完了してClaudeに戻ると、ツールが表示されていました。


Chrome

認証後のツール表示
認証から戻るとツールが表示されている

テスト用スプレッドシートを作る

テスト用にスプレッドシートを作成します。

テスト用スプレッドシート

権限は制限付きで設定しておきます。

権限設定

動作確認

ClaudeにスプレッドシートのURLを渡して、シート名を教えてもらいます。

シート情報の取得

シート名の取得結果

問題なく回答できました。続いて、特定シートの中身も確認してみます。

シート内容の取得結果
データは21行だけど。

シート内容も正しく取得できました。

Workersへのデプロイ

ローカルでの動作確認が終わったら、Cloudflare Workersにデプロイしてみます。

pnpm run deploy
 or
npx wrangler deploy

デプロイ後、以下を設定。

  1. Google Auth Platformで「承認済みドメイン」と「承認済みのリダイレクト URI」にWorkersのURLを登録
  2. Claude Desktopのツール設定をWorkersのURLに変更(/sseを忘れずに)
{
  "mcpServers": {
    "spreadsheet": {
      "command": "npx",
      "args": [
        "mcp-remote",
+        "https://<your-workers-url>/sse"
      ]
    }
  },
  "globalShortcut": ""
}

ローカルの認証が残っているとうまく動作しないかもしれません。筆者の場合も認証ページに飛びませんでした。

rm -rf ~/.mcp-auth

を行い、Claude Deskotpを再起動するとローカルと同様、リモートサーバーでも正常に動作しました。

リモート環境での動作確認

おわりに

本記事では、Google OAuth2認証を実装したMCPサーバーをCloudflare Workers上に構築する実験しました。途中のInspectorのエラーが解決していませんが、一応動作はするところまで到達しました。

冒頭でも述べましたが、Cloudflare側のサンプル、ライブラリの動きも激しいので、明日動かなくなっている可能性もありますが、未来予想くらいで、どなたかの参考になれば...

追記

Workersへのリクエスト数が結構な数になっていました。ちょっと怖いですね。

脚注
  1. 「認可」では、と思いつつ、ChatGPTでの訳語も「認証」なので揃えます。 ↩︎

Discussion