Google OAuth2 認証ができるMCPサーバーを作る(on Cloudflare)
What
MyGPTsではOAuth 2.0による認証ができる。
MyGPTsではこのようにOAuth設定が可能
これの何が嬉しいかというと、Google Apps ScriptのWeb Appを特定のユーザーのみが実行できるプロキシサーバーのように挟むだけで、スプレッドシートやドライブファイルの処理をApps Scriptの組み込みクラスで書くことができる。
Apps Scriptを使った自作MCPの記事などを見ていると、呼び出し設定は「全員」にしておいて、APIキーで認証を入れるケースが見つかる。が、できればMyGPTsのようにOAuthで管理できると、組織内のユーザーにアクセスを限定できたり、何かあった時に失効させたりできる。
Claude Desktopとの統合を目標にして、これができないのか検証してみる。
Reference
Google Cloud側の設定
何はともあれ、Google Auth PlatformにてOAuthのための設定は済ませておく。
- プロジェクトの新規作成
- 同意画面の構成
- 「ウェブアプリケーション」でOAuth 2.0クライアントを作成
- 「承認済みの JavaScript 生成元」と「承認済みのリダイレクト URI」には下記を入れる
Google Auth Platform
サンプルを眺める
Hono on Cloudflare Workersの構成でGitHubを用いたサンプルがあるのでこれを眺める。
index.ts
ここが重要なところで、@cloudflare/workers-oauth-provider
がOAuthProvider
を提供しており、ここがよしなにOAuthフローの面倒を見てくれる様子。
github-handler.ts
Honoのハンドラ。
/callback
で返しているprops
がindex.ts
で参照できる。
const octokit = new Octokit({ auth: this.props.accessToken });
Cloudflare Workers側の準備
wrangler.jsonc
を見るとわかる通り、KVとDurable Objectを使ってステートフルにしている様子。
したがって自分でKVを作成する必要があります。
Googleの処理に置き換える
Claudeで上記サンプルを修正してもらう。
環境変数
.dev.vars
に以下2つ。
- GOOGLE_CLIENT_ID
- GOOGLE_CLIENT_SECRET
Tool部分
Apps Scriptを作ってfetchしても良いが、Spreadsheet APIを使った。ライブラリはEdgeランタイム対応していない可能性があるので、使っていない。
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;
};
}[];
}
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",
});
引数が多いのでオブジェクトにまとめたりはできると思うが、それはまた今度。
また、型エラーが解消できない。
型エラー
ここはサンプルでもエラーになるので、深追いしない。
テスト
ローカル
pnpm dev
で起動し、Inspectorを使う。
npx @modelcontextprotocol/inspector
http://127.0.0.1:6274
にInspectorが立ち上がる。
URLはhttp://localhost:8787/sse
とする。
正しくセットアップできていれば。Connectを押すと、認証画面に飛ぶ。
成功するとtoastが出る。
たくさん出る。
エラー
結構つらいエラー。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'
}
Claude Desktopでテスト
ブラウザキャッシュ等を消しても上記が解決できなかったので、別の方法で。
{
"mcpServers": {
"spreadsheet": {
"command": "/Users/hosaka/.volta/bin/npx",
"args": [
"mcp-remote",
"http://localhost:8787/sse"
]
}
},
"globalShortcut": ""
}
で設定。Claude Desktopはまだトランスポートをsse
で指定できないので、mcp-remote
を挟む。
この状態で、Claude Deskopを立ち上げると、しばらくするとブラウザに飛ばされ、認証画面が出る。
戻ってくると...
認証から戻るとツールが表示されている
どうやらうまくいっている雰囲気。
テストシート作成。
権限は制限付き。
動作確認
URLを渡して、シート名を教えてもらいます。
と問題なく回答できました。
続いて、中身も聞いてみます。
回答できました。
Workersにデプロイ
デプロイしてみます。といってもwrangler deploy
し、
- URLをGoogle Auth Platformの「承認済みドメイン」、「承認済みのリダイレクト URI」に登録する
- Claudeのツール設定をWorkersのURLに置き換え。(
/sse
を忘れずに)
すれば完了です。
動かない場合、Claude Desktopを立ち上げてもブラウザで認証を求められmない場合、
rm -rf ~/.mcp-auth
が必要かもしれません。
結果、リモートでも同様に表示がされました。
記事化