Open10

Google OAuth2 認証ができるMCPサーバーを作る(on Cloudflare)

hosaka313hosaka313

What

MyGPTsではOAuth 2.0による認証ができる。


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

これの何が嬉しいかというと、Google Apps ScriptのWeb Appを特定のユーザーのみが実行できるプロキシサーバーのように挟むだけで、スプレッドシートやドライブファイルの処理をApps Scriptの組み込みクラスで書くことができる。

Apps Scriptを使った自作MCPの記事などを見ていると、呼び出し設定は「全員」にしておいて、APIキーで認証を入れるケースが見つかる。が、できればMyGPTsのようにOAuthで管理できると、組織内のユーザーにアクセスを限定できたり、何かあった時に失効させたりできる。

Claude Desktopとの統合を目標にして、これができないのか検証してみる。

Reference

https://developers.cloudflare.com/agents/guides/remote-mcp-server/

https://developers.cloudflare.com/agents/model-context-protocol/authorization/#2-third-party-oauth-provider

hosaka313hosaka313

Google Cloud側の設定

何はともあれ、Google Auth PlatformにてOAuthのための設定は済ませておく。

  • プロジェクトの新規作成
  • 同意画面の構成
  • 「ウェブアプリケーション」でOAuth 2.0クライアントを作成
  • 承認済みの JavaScript 生成元」と「承認済みのリダイレクト URI」には下記を入れる


Google Auth Platform

hosaka313hosaka313

サンプルを眺める

Hono on Cloudflare Workersの構成でGitHubを用いたサンプルがあるのでこれを眺める。

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

index.ts

ここが重要なところで、@cloudflare/workers-oauth-providerOAuthProviderを提供しており、ここがよしなにOAuthフローの面倒を見てくれる様子。
https://github.com/cloudflare/ai/blob/8a71c668a278e2515ba7dce0bcfc667ae7abbc4f/demos/remote-mcp-github-oauth/src/index.ts#L1-L98

github-handler.ts

Honoのハンドラ。

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

/callbackで返しているpropsindex.tsで参照できる。

const octokit = new Octokit({ auth: this.props.accessToken });
hosaka313hosaka313

Googleの処理に置き換える

Claudeで上記サンプルを修正してもらう。

環境変数

.dev.varsに以下2つ。

  • GOOGLE_CLIENT_ID
  • GOOGLE_CLIENT_SECRET

Tool部分

Apps Scriptを作ってfetchしても良いが、Spreadsheet APIを使った。ライブラリはEdgeランタイム対応していない可能性があるので、使っていない。

src/sheets-api.ts
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;
    }
}

「アクセストークン更新処理」を追加してくれたのだが、なくても良いかもしれない。

型情報も適当。

src/sheets-api.ts
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;
    };
  }[];
}

index.tsは以下。

src/index.ts
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",
});

引数が多いのでオブジェクトにまとめたりはできると思うが、それはまた今度。
また、型エラーが解消できない。


型エラー

ここはサンプルでもエラーになるので、深追いしない。

hosaka313hosaka313

テスト

ローカル

pnpm devで起動し、Inspectorを使う。

npx @modelcontextprotocol/inspector

http://127.0.0.1:6274にInspectorが立ち上がる。

URLはhttp://localhost:8787/sseとする。

正しくセットアップできていれば。Connectを押すと、認証画面に飛ぶ。

成功するとtoastが出る。

たくさん出る。

hosaka313hosaka313

エラー

結構つらいエラー。responseを2回返してしまっている?

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
    at SSEServerTransport.handlePostMessage (file:///Users/hosaka/.npm/_npx/5a9d879542beca3a/node_modules/@modelcontextprotocol/sdk/dist/esm/server/sse.js:53:19)
    at file:///Users/hosaka/.npm/_npx/5a9d879542beca3a/node_modules/@modelcontextprotocol/inspector/server/build/index.js:130:25
    at Layer.handle [as handle_request] (/Users/hosaka/.npm/_npx/5a9d879542beca3a/node_modules/express/lib/router/layer.js:95:5)
    at next (/Users/hosaka/.npm/_npx/5a9d879542beca3a/node_modules/express/lib/router/route.js:149:13)
    at Route.dispatch (/Users/hosaka/.npm/_npx/5a9d879542beca3a/node_modules/express/lib/router/route.js:119:3)
    at Layer.handle [as handle_request] (/Users/hosaka/.npm/_npx/5a9d879542beca3a/node_modules/express/lib/router/layer.js:95:5)
    at /Users/hosaka/.npm/_npx/5a9d879542beca3a/node_modules/express/lib/router/index.js:284:15
    at Function.process_params (/Users/hosaka/.npm/_npx/5a9d879542beca3a/node_modules/express/lib/router/index.js:346:12)
    at next (/Users/hosaka/.npm/_npx/5a9d879542beca3a/node_modules/express/lib/router/index.js:280:10)
    at cors (/Users/hosaka/.npm/_npx/5a9d879542beca3a/node_modules/cors/lib/index.js:188:7)
node:_http_outgoing:699
    throw new ERR_HTTP_HEADERS_SENT('set');
          ^

Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client
    at ServerResponse.setHeader (node:_http_outgoing:699:11)
    at ServerResponse.header (/Users/hosaka/.npm/_npx/5a9d879542beca3a/node_modules/express/lib/response.js:794:10)
    at ServerResponse.json (/Users/hosaka/.npm/_npx/5a9d879542beca3a/node_modules/express/lib/response.js:275:10)
    at file:///Users/hosaka/.npm/_npx/5a9d879542beca3a/node_modules/@modelcontextprotocol/inspector/server/build/index.js:134:25
    at process.processTicksAndRejections (node:internal/process/task_queues:105:5) {
  code: 'ERR_HTTP_HEADERS_SENT'
}

hosaka313hosaka313

Claude Desktopでテスト

ブラウザキャッシュ等を消しても上記が解決できなかったので、別の方法で。

{
  "mcpServers": {
    "spreadsheet": {
      "command": "/Users/hosaka/.volta/bin/npx",
      "args": [
        "mcp-remote",
        "http://localhost:8787/sse"
      ]
    }
  },
  "globalShortcut": ""
}

で設定。Claude Desktopはまだトランスポートをsseで指定できないので、mcp-remoteを挟む。

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

この状態で、Claude Deskopを立ち上げると、しばらくするとブラウザに飛ばされ、認証画面が出る。
戻ってくると...


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

どうやらうまくいっている雰囲気。

テストシート作成。

権限は制限付き。

動作確認

URLを渡して、シート名を教えてもらいます。

と問題なく回答できました。

続いて、中身も聞いてみます。

回答できました。

hosaka313hosaka313

Workersにデプロイ

デプロイしてみます。といってもwrangler deployし、

  • URLをGoogle Auth Platformの「承認済みドメイン」、「承認済みのリダイレクト URI」に登録する
  • Claudeのツール設定をWorkersのURLに置き換え。(/sseを忘れずに)

すれば完了です。

動かない場合、Claude Desktopを立ち上げてもブラウザで認証を求められmない場合、

rm -rf ~/.mcp-auth                

が必要かもしれません。

結果、リモートでも同様に表示がされました。