LM studio上にインストールしたgpt-oss 20bで、Gmail・Googleカレンダーが操作可能なDockerで起動するMCPサーバーをGPT-5を使って作成します。
ほとんどがGPT-5が作成したコードで、厳密な動作検証までは行っていませんが、個人で使用する範囲では自然言語でGmailやGoogleカレンダーを気軽に使えます。
なお、作業開始時にしたプロンプトは、下記の通りです。
今までのGmailとgoogleカレンダーをLM studioにインストールしたgpt-oss 20bで使用するためのMCPサーバーを作成する作業の手順を最初からまとめてすべて書き出して
MCP サーバー 作成手順
LM Studio(gpt-oss 20b)が Docker起動のMCPサーバー 経由で Gmail と Google Calendar を操作できるようにする。
使用方法
- Google Cloud で API と認証を用意(OAuth認証を使用)
- Node.js で MCP サーバーを実装(Gmail/Calendar ツール)
- Docker イメージ化し、GHCR(
ghcr.io)へ公開 - LM Studio の
mcp.jsonに登録 - 初回 OAuth 同意 → 以後、LM Studio から自然言語でツール呼び出し
参考:動作検証Ver. LM Studio 0.3.22 (Build 2)
ディレクトリ構成
google-workspace-mcp/
├─ src/
│ └─ index.js # MCP本体
├─ scripts/
│ └─ authorize.js # OAuth同意サーバ
├─ data/ # 認証ファイル保存(ホスト共有推奨)
│ ├─ oauth_client.json # OAuthクライアント(ダウンロードしたOAuth認証情報jsonをリネームして使用)
│ └─ oauth_tokens.json # 認証のトークン(自動生成される)
│
├─ package.json
├─ Dockerfile
└─ .dockerignore
package.json
Node.js設定ファイル
{
"name": "google-workspace-mcp",
"version": "0.1.0",
"type": "module",
"description": "MCP server for Gmail & Google Calendar",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"authorize": "node scripts/authorize.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.17.3",
"googleapis": "^140.0.0",
"zod": "^3.23.8",
"luxon": "^3.5.0",
"express": "^4.19.2",
"open": "^10.1.0"
}
}
Dockerfile
FROM node:20-alpine
WORKDIR /app
# 依存関係
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
# アプリ
COPY src ./src
COPY scripts ./scripts
# 認証ファイル保存先
RUN mkdir -p /app/data
VOLUME ["/app/data"]
ENV NODE_ENV=production
CMD ["npm", "start"]
index.js(MCPサーバー本体)
Gmail の一覧/取得/送信、Calendar の一覧/作成を行う。
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { google } from "googleapis";
import fs from "fs/promises";
import path from "path";
import process from "process";
import { DateTime } from "luxon";
const DATA_DIR = process.env.DATA_DIR || "/app/data";
async function readJson(file) {
const p = path.join(DATA_DIR, file);
const txt = await fs.readFile(p, "utf-8");
return JSON.parse(txt);
}
async function getAuth(scopes) {
const mode = (process.env.AUTH_MODE || "oauth").toLowerCase();
if (mode === "dwd") {
const keyPath = process.env.GOOGLE_SERVICE_ACCOUNT_JSON || "/app/data/service-account.json";
const key = JSON.parse(await fs.readFile(keyPath, "utf-8"));
const subject = process.env.GOOGLE_IMPERSONATE_USER;
if (!subject) throw new Error("GOOGLE_IMPERSONATE_USER is required for DWD mode.");
const jwt = new google.auth.JWT({
email: key.client_email,
key: key.private_key,
scopes,
subject
});
await jwt.authorize();
return jwt;
} else {
const client = await readJson("oauth_client.json");
const tokens = await readJson("oauth_tokens.json");
const { client_id, client_secret, redirect_uris } = client.web || client.installed || {};
const redirectUri = (redirect_uris && redirect_uris[0]) || "http://localhost:5173/oauth2callback";
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirectUri);
oAuth2Client.setCredentials(tokens);
return oAuth2Client;
}
}
function pickHeader(headers, name) {
const h = headers?.find(h => h.name?.toLowerCase() === name.toLowerCase());
return h?.value || "";
}
async function main() {
const server = new McpServer({ name: "google-workspace-mcp", version: "0.1.0" });
server.registerTool("gmail.list", { title: "List Gmail messages", description: "Gmailを検索して一覧", inputSchema: { query: z.string().default(""), maxResults: z.number().int().min(1).max(50).default(10) } },
async ({ query, maxResults }) => {
const auth = await getAuth(["https://www.googleapis.com/auth/gmail.readonly"]);
const gmail = google.gmail({ version: "v1", auth });
const list = await gmail.users.messages.list({ userId: "me", q: query, maxResults });
const ids = list.data.messages?.map(m => m.id) || [];
const details = [];
for (const id of ids) {
const msg = await gmail.users.messages.get({ userId: "me", id, format: "metadata", metadataHeaders: ["Subject","From","Date"] });
const hdrs = msg.data.payload?.headers || [];
details.push({ id, subject: pickHeader(hdrs, "Subject"), from: pickHeader(hdrs, "From"), date: pickHeader(hdrs, "Date") });
}
const text = details.map(d => `• ${d.subject} — ${d.from} — ${d.date} (id:${d.id})`).join("\n") || "(no results)";
return { content: [{ type: "text", text }] };
}
);
server.registerTool("gmail.get", { title: "Get a Gmail message", description: "IDで本文スニペット取得", inputSchema: { id: z.string() } },
async ({ id }) => {
const auth = await getAuth(["https://www.googleapis.com/auth/gmail.readonly"]);
const gmail = google.gmail({ version: "v1", auth });
const msg = await gmail.users.messages.get({ userId: "me", id, format: "full" });
const hdrs = msg.data.payload?.headers || [];
const subject = pickHeader(hdrs, "Subject");
const from = pickHeader(hdrs, "From");
const date = pickHeader(hdrs, "Date");
const snippet = msg.data.snippet || "";
const text = `Subject: ${subject}\nFrom: ${from}\nDate: ${date}\n\nSnippet: ${snippet}`;
return { content: [{ type: "text", text }] };
}
);
server.registerTool("gmail.send", { title: "Send Gmail message", description: "宛先/件名/本文で送信", inputSchema: { to: z.string(), subject: z.string(), body: z.string() } },
async ({ to, subject, body }) => {
const auth = await getAuth(["https://www.googleapis.com/auth/gmail.send"]);
const gmail = google.gmail({ version: "v1", auth });
const raw = `To: ${to}\r\nSubject: ${subject}\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n${body}`;
const encoded = Buffer.from(raw).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
await gmail.users.messages.send({ userId: "me", requestBody: { raw: encoded } });
return { content: [{ type: "text", text: "Sent." }] };
}
);
server.registerTool("calendar.listToday", { title: "List today's events", description: "今日の予定一覧", inputSchema: { calendarId: z.string().default("primary"), timeZone: z.string().default(process.env.TZ || "Asia/Tokyo"), maxResults: z.number().int().min(1).max(100).default(20) } },
async ({ calendarId, timeZone, maxResults }) => {
const auth = await getAuth(["https://www.googleapis.com/auth/calendar.readonly"]);
const calendar = google.calendar({ version: "v3", auth });
const now = DateTime.now().setZone(timeZone);
const timeMin = now.startOf("day").toISO();
const timeMax = now.endOf("day").toISO();
const resp = await calendar.events.list({ calendarId, timeMin, timeMax, singleEvents: true, orderBy: "startTime", maxResults });
const events = resp.data.items || [];
const text = events.map(ev => {
const start = ev.start?.dateTime || ev.start?.date;
const end = ev.end?.dateTime || ev.end?.date;
return `• ${start} – ${end} : ${ev.summary || "(no title)"}`;
}).join("\n") || "(no events)";
return { content: [{ type: "text", text }] };
}
);
server.registerTool("calendar.create", { title: "Create calendar event", description: "予定作成(終日/時間指定)", inputSchema: { calendarId: z.string().default("primary"), summary: z.string(), description: z.string().optional(), startISO: z.string(), endISO: z.string(), timeZone: z.string().default(process.env.TZ || "Asia/Tokyo"), attendees: z.array(z.string()).optional() } },
async ({ calendarId, summary, description, startISO, endISO, timeZone, attendees }) => {
const auth = await getAuth(["https://www.googleapis.com/auth/calendar.events"]);
const calendar = google.calendar({ version: "v3", auth });
const isAllDay = startISO.length === 10 && endISO.length === 10;
const event = {
summary,
description,
attendees: attendees?.map(e => ({ email: e })),
start: isAllDay ? { date: startISO } : { dateTime: startISO, timeZone },
end: isAllDay ? { date: endISO } : { dateTime: endISO, timeZone }
};
const resp = await calendar.events.insert({ calendarId, requestBody: event });
return { content: [{ type: "text", text: `Created: ${resp.data.htmlLink || resp.data.id}` }] };
}
);
const transport = new StdioServerTransport();
await server.connect(transport);
}
main().catch(err => { console.error(err); process.exit(1); });
scripts/authorize.js(OAuth認証)
import express from "express";
import { google } from "googleapis";
import fs from "fs/promises";
import path from "path";
import open from "open";
const DATA_DIR = process.env.DATA_DIR || "/app/data";
const PORT = Number(process.env.AUTH_PORT || 5173);
async function readJson(f) { const p = path.join(DATA_DIR, f); const txt = await fs.readFile(p, "utf-8"); return JSON.parse(txt); }
async function writeJson(f, obj) { const p = path.join(DATA_DIR, f); await fs.writeFile(p, JSON.stringify(obj, null, 2)); }
async function main() {
const client = await readJson("oauth_client.json");
const { client_id, client_secret, redirect_uris } = client.web || client.installed || {};
const redirectUri = (redirect_uris && redirect_uris[0]) || `http://localhost:${PORT}/oauth2callback`;
const oAuth2 = new google.auth.OAuth2(client_id, client_secret, redirectUri);
const scopes = [
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/gmail.send",
"https://www.googleapis.com/auth/calendar.events",
"https://www.googleapis.com/auth/calendar.readonly"
];
const app = express();
const server = app.listen(PORT, () => console.log(`OAuth listening on http://localhost:${PORT}`));
app.get("/oauth2callback", async (req, res) => {
try {
const { code } = req.query;
const { tokens } = await oAuth2.getToken(String(code));
await writeJson("oauth_tokens.json", tokens);
res.send("Tokens saved. You can close this tab.");
setTimeout(() => server.close(), 500);
} catch (e) {
console.error(e);
res.status(500).send("OAuth error");
}
});
const authUrl = oAuth2.generateAuthUrl({ access_type: "offline", scope: scopes, prompt: "consent" });
console.log("Open this URL to authorize:\n", authUrl, "\n");
try { await open(authUrl); } catch {}
}
main().catch(err => { console.error(err); process.exit(1); });
使われるタイミング
Google Cloud Console で OAuth クライアント(oauth_client.json)を作成した直後
まだ oauth_tokens.json が存在しない状態では MCP サーバーを立ち上げても認証が通らず、Gmail やカレンダーを操作できません。
scripts/authorize.js を実行
docker run --rm -it \
-v 'C:\Users\******\google-workspace-mcp\data:/app/data' \
ghcr.io/omo-y/google-workspace-mcp:latest \
node scripts/authorize.js
を実行すると、Google のログイン URL が表示されます。
ブラウザでログインすると認可コードが返ってくるので、それを入力します。
oauth_tokens.json が生成される
- 認証に成功すると、トークンが
data/oauth_tokens.jsonに保存されます。 - 以降は MCP サーバーを起動するだけで OK になり、再度
authorize.jsを実行する必要はありません。
Google Cloud 側の準備
Google Cloudコンソールにログインする。
API 有効化
- プロジェクトを作成 → 「APIとサービス」→「ライブラリ」
- Gmail API / Google Calendar API を有効化
3.2 認証方式を選ぶ
「認証情報」 → 「+認証情報を作成」し以下の内容を設定する。
- OAuth(個人/小規模):
- 「アクセスするデータの種類」= ユーザー データ
- アプリケーションの種類= ウェブアプリ
- 承認済みのリダイレクトURI =
http://localhost:5173/oauth2callback - ダウンロードした JSON は
oauth_client.jsonにリネーム推奨 - Windows で
*.json:Zone.Identifierが出たら削除してもよい。
Docker ビルド
docker build -t gw-mcp:latest .
GHCR に公開
# ログイン(PAT)
echo "<PAT>" | docker login ghcr.io -u <GitHubユーザー名> --password-stdin
# タグ→プッシュ
docker tag gw-mcp:latest ghcr.io/<GitHubユーザー名>/google-workspace-mcp:latest
docker push ghcr.io/<GitHubユーザー名>/google-workspace-mcp:latest
PAT(Personal Access Token)について
GitHubにアクセスするためのパスワードの代わりに使用できるトークン。
主に、コマンドラインやGitHub APIを使ってGitHubリポジトリを操作する際の認証に利用。
GitHubのWebサイト → 「Settings」→「Developer settings」→「Personal access tokens」→「Tokens (classic)」を選択し、「Generate new token (classic)」をクリックして作成。
OAuth認証
初回の認証
docker run -it --rm -p 5173:5173 -v 'C:\Users\******google-workspace-mcp\data:/app/data' -e AUTH_MODE=oauth ghcr.io/<GitHubユーザー名>/google-workspace-mcp:latest npm run authorize
-v で永続化すると、トークンをホストに残せる。
2度目以降の認証
再度認証が必要となった場合は、上記のコマンドを再度実行する。
コマンドの出力結果に表示されたURLをブラウザで開き Google ログインして認証する。
成功すると oauth_tokens.json が data フォルダに保存される。
LM Studio 連携(mcp.json)
以下のmcp.jsonをLM Studioに追加する。
追加方法は、LM studio gpt-oss 20bでの作業を参照。
{
"mcpServers": {
"google-workspace": {
"command": "docker",
"args": [
"run", "-i", "--rm",
"-v", "C:\Users\******\google-workspace-mcp\data:/app/data",
"-e", "AUTH_MODE=oauth",
"-e", "TZ=Asia/Tokyo",
"ghcr.io/<GitHubユーザー名>/google-workspace-mcp:latest"
]
}
}
}
動作確認
- docker.desktopを起動する。
- LM Studio を再起動し、モデルを gpt-oss 20b に切替
新規チャットで以下を試す:
「今日の予定を表示して」 → Calendar 読み取り確認
「15時に『打ち合わせ』を追加して」 → Calendar 書き込み確認
「test@example.com にメール送信して」 → Gmail 送信確認
「最近の受信メールを表示して」 → Gmail 読み取り確認 Studio で gpt-oss 20b を選択 → チャットで:- 「今日の予定を一覧して」→
calendar.listToday - 「◯◯に件名××でメール送って」→
gmail.send
- 「今日の予定を一覧して」→
参考:OAuth認証(Open Authorization)とは
OAuth認証(Open Authorization)は、ユーザーのパスワードを共有することなく、あるアプリケーションが別のアプリケーションのデータに安全にアクセスするための認可の仕組み。
参考:LM Studioでgpt-oss-20bを動かす


