【実験】 Google OAuth2 付きMCPサーバーを作って、Claude Desktopから呼んでみる(Cloudflare)
はじめに
LLMと外部データソースを統合する際、ツールの認証をどうするのか悩みます。たとえば、MyGPTsのActionsではOAuth 2.0による認証[1]が実装されています。
MyGPTsではこのようにOAuth設定が可能
これは結構便利で、Google Apps Scriptを作り、組織内ユーザーにアクセスを限定した形でデプロイすれば、スプレッドシートやGmailとのセキュアな連携が、認証フロー含めてMyGPTs上で完結します。
Apps Scriptは呼び出し元を制限できる。呼び出しにはOAuth2が必要。
一方でClaude Desktopも使えるGoogle Workspace連携MCPサーバーの作例を見ると、APIキー認証のものか、手元で認証フローを走らせるもので、Claude Desktop上で認証・認可できるものは管見の限りありません。
この記事は、Claude DesktopでOAuth込みのMCPサーバーを作りたいなと思い、現状でどんなことができるのか、Cloudflareのサンプルを見ながら実験した記録です。
Google Cloud側の設定
まずは、Google Auth Platformで必要な設定を行います。
- プロジェクトの新規作成
- 同意画面の構成
- 「ウェブアプリケーション」でOAuth 2.0クライアントを作成
- 「承認済みの JavaScript 生成元」と「承認済みのリダイレクト URI」に下記を設定
http://localhost:8787
http://localhost:8787/callback
Google Auth Platform
また、Google Sheets APIも有効化しておきます。
サンプルコードを見る
Cloudflareが提供するGitHubを用いたOAuth認証のサンプルを参考にします。
index.ts
@cloudflare/workers-oauth-provider
のOAuthProvider
が、認証フローの面倒を見ているようです。
github-handler.ts
Honoで実装されたハンドラー。Googleにする場合は、ここの調整が必要です。
Cloudflare側の準備
wrangler.jsonc
ファイルから分かるように、KVとDurable Objectを使用してステートフルにしているようです。そのため、自分でKVを作成する必要があります。
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が立ち上がります。
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
を中継します。
この設定でClaude Desktopを起動すると、しばらくしてブラウザが開き認証画面が表示されました。認証が完了してClaudeに戻ると、ツールが表示されていました。
Chrome
認証から戻るとツールが表示されている
テスト用スプレッドシートを作る
テスト用にスプレッドシートを作成します。
権限は制限付きで設定しておきます。
動作確認
ClaudeにスプレッドシートのURLを渡して、シート名を教えてもらいます。
問題なく回答できました。続いて、特定シートの中身も確認してみます。
データは21行だけど。
シート内容も正しく取得できました。
Workersへのデプロイ
ローカルでの動作確認が終わったら、Cloudflare Workersにデプロイしてみます。
pnpm run deploy
or
npx wrangler deploy
デプロイ後、以下を設定。
- Google Auth Platformで「承認済みドメイン」と「承認済みのリダイレクト URI」にWorkersのURLを登録
- 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へのリクエスト数が結構な数になっていました。ちょっと怖いですね。
-
「認可」では、と思いつつ、ChatGPTでの訳語も「認証」なので揃えます。 ↩︎
Discussion