Gmailを2つ使っています。ひとつは個人用、もうひとつは自分で作ったツールの発信用です。Claude Codeからどちらも読めるようにしたかったのですが、claude.aiのGmail連携(コネクタ)はGoogleアカウント1つにしかつながりませんでした。発信用でつなぎ直すと、今度は個人用が読めません。
Gmail API(OAuth)で自作する道もあります。ただ、Google Cloudでアプリを作るところから始まるので、私1人で使うだけにしては手間が多いと感じました。そこで、IMAPとアプリパスワードで動く小さなMCPサーバーをPythonで書きました。パスワードの置き場所にmacOSのキーチェーンを使うので、この記事はMac前提です。
読むだけのMCPサーバーを作りました
ツールは4つです。list_accountsは読めるアカウントの一覧を返します。searchは、Gmailの検索窓と同じ書き方(from: after: has:attachmentなど)で検索するツールです。read_messageで本文を読み、save_attachmentで添付ファイルを~/Downloads/gmail-attachmentsに保存します。
フォルダはreadonlyで開き、本文はBODY.PEEKで取ります。Claudeが読んでも既読にはなりません。アプリパスワードはmacOSのキーチェーン(サービス名はgmail-reader)に置いて、ファイルには書きません。接続するたびにsecurityコマンドで取り出しています。
ファイルは3つで、同じフォルダに置きます。本体のserver.py、読むアドレスを並べるaccounts.json、パスワードをキーチェーンに入れるsetup-password.shです。
accounts.jsonはこれだけです。
{
"accounts": [
"you@gmail.com",
"work@gmail.com"
]
}
server.pyの全文
依存ライブラリはserver.pyの先頭のコメントに書いてあるので、pip installは要りません。uv run --scriptで起動すると、uv(Pythonのパッケージ管理ツール)がこのコメントを読んで用意します。mcpライブラリは1.xに固定しています。2.xではFastMCPがMCPServerに名前が変わっていて、from mcp.server.fastmcp import FastMCPがModuleNotFoundErrorになるためです。
# /// script
# requires-python = ">=3.11"
# dependencies = ["mcp>=1.2,<2"]
# ///
"""複数のGmailを読むMCPサーバー。
- 接続は IMAP + アプリパスワード。パスワードは macOS キーチェーン(サービス名 gmail-reader)から読む。
- フォルダは常に readonly で開き、本文は BODY.PEEK で取るので「既読」にもならない。
- 検索は Gmail の検索窓と同じ書き方(from: subject: after: has:attachment など)がそのまま使える(X-GM-RAW)。
"""
import email
import email.header
import email.message
import email.utils
import html
import imaplib
import json
import re
import subprocess
from pathlib import Path
from mcp.server.fastmcp import FastMCP
HERE = Path(__file__).parent
KEYCHAIN_SERVICE = "gmail-reader"
ATTACH_DIR = Path.home() / "Downloads" / "gmail-attachments"
MAX_BODY_CHARS = 20000
mcp = FastMCP("gmail-reader")
def _accounts() -> list[str]:
return json.loads((HERE / "accounts.json").read_text())["accounts"]
def _password(account: str) -> str:
r = subprocess.run(
["security", "find-generic-password", "-s", KEYCHAIN_SERVICE, "-a", account, "-w"],
capture_output=True, text=True,
)
if r.returncode != 0:
raise RuntimeError(f"{account} のアプリパスワードがキーチェーンにありません。setup-password.sh {account} を実行してください。")
return r.stdout.strip().replace(" ", "")
def _resolve(account: str) -> list[str]:
accounts = _accounts()
if account in ("", "all"):
return accounts
hits = [a for a in accounts if a == account or a.split("@")[0] == account]
if not hits:
raise ValueError(f"未登録のアカウントです: {account}(登録済み: {', '.join(accounts)})")
return hits
def _login(account: str) -> imaplib.IMAP4_SSL:
m = imaplib.IMAP4_SSL("imap.gmail.com")
m.login(account, _password(account))
return m
def _folder(m: imaplib.IMAP4_SSL, flag: bytes) -> str | None:
# 「すべてのメール」「下書き」は表示言語で名前が変わるので、\All \Drafts のフラグで探す
_, folders = m.list()
return next(
(re.search(rb'"([^"]+)"$', f).group(1).decode() for f in folders if flag in f),
None,
)
def _connect(account: str) -> imaplib.IMAP4_SSL:
m = _login(account)
all_mail = _folder(m, b"\\All") or "INBOX"
m.select(f'"{all_mail}"', readonly=True)
return m
def _decode(value) -> str:
if not value:
return ""
return str(email.header.make_header(email.header.decode_header(value)))
def _text_body(msg: email.message.Message) -> str:
plain, rich = None, None
for part in msg.walk():
if part.get_content_maintype() == "multipart" or part.get_filename():
continue
payload = part.get_payload(decode=True)
if payload is None:
continue
text = payload.decode(part.get_content_charset() or "utf-8", errors="replace")
if part.get_content_type() == "text/plain" and plain is None:
plain = text
elif part.get_content_type() == "text/html" and rich is None:
rich = text
if plain:
return plain
if rich:
rich = re.sub(r"(?is)<(script|style).*?</\1>", "", rich)
rich = re.sub(r"(?i)<br\s*/?>|</p>|</div>|</tr>", "\n", rich)
return html.unescape(re.sub(r"<[^>]+>", "", rich))
return ""
def _timestamp(date: str) -> float:
try:
return email.utils.parsedate_to_datetime(date).timestamp()
except (TypeError, ValueError):
return 0
def _attachments(msg: email.message.Message) -> list[str]:
return [_decode(p.get_filename()) for p in msg.walk() if p.get_filename()]
@mcp.tool()
def list_accounts() -> list[str]:
"""読めるGmailアカウントの一覧。"""
return _accounts()
@mcp.tool()
def search(query: str, account: str = "all", max_results: int = 20) -> list[dict]:
"""Gmailを検索する。query は Gmail の検索窓と同じ書き方(例: 'from:example.com after:2026/08/01 has:attachment')。
account は 'all'(全アカウント)、メールアドレス、または @ より前の部分。新しい順に返す。
返り値の uid と account を read_message に渡すと本文が読める。"""
results = []
for acc in _resolve(account):
try:
m = _connect(acc)
except RuntimeError:
# 全アカウント検索では、パスワード未登録のアカウントは飛ばす
if account in ("", "all"):
continue
raise
try:
# 日本語や " を含む検索語でも壊れないよう、検索語はリテラルで送る
m.literal = query.encode("utf-8")
_, data = m.uid("SEARCH", "CHARSET", "UTF-8", "X-GM-RAW")
uids = data[0].split()[-max_results:][::-1]
for uid in uids:
_, parts = m.uid("FETCH", uid, "(BODY.PEEK[HEADER.FIELDS (FROM TO SUBJECT DATE)])")
h = email.message_from_bytes(parts[0][1])
results.append({
"account": acc,
"uid": uid.decode(),
"date": h.get("Date", ""),
"from": _decode(h.get("From")),
"subject": _decode(h.get("Subject")),
})
finally:
m.logout()
results.sort(key=lambda r: _timestamp(r["date"]), reverse=True)
return results[:max_results] if account in ("", "all") else results
@mcp.tool()
def read_message(account: str, uid: str) -> dict:
"""search で見つけたメールの本文を読む(既読にはならない)。添付ファイル名も返す。"""
acc = _resolve(account)[0]
m = _connect(acc)
try:
_, parts = m.uid("FETCH", uid, "(BODY.PEEK[])")
msg = email.message_from_bytes(parts[0][1])
finally:
m.logout()
body = _text_body(msg)
return {
"account": acc,
"date": msg.get("Date", ""),
"from": _decode(msg.get("From")),
"to": _decode(msg.get("To")),
"subject": _decode(msg.get("Subject")),
"attachments": _attachments(msg),
"body": body[:MAX_BODY_CHARS] + ("\n…(以下省略)" if len(body) > MAX_BODY_CHARS else ""),
}
@mcp.tool()
def save_attachment(account: str, uid: str, filename: str) -> str:
"""メールの添付ファイルを ~/Downloads/gmail-attachments に保存して、保存先のパスを返す。"""
acc = _resolve(account)[0]
m = _connect(acc)
try:
_, parts = m.uid("FETCH", uid, "(BODY.PEEK[])")
msg = email.message_from_bytes(parts[0][1])
finally:
m.logout()
for part in msg.walk():
if part.get_filename() and _decode(part.get_filename()) == filename:
ATTACH_DIR.mkdir(parents=True, exist_ok=True)
dest = ATTACH_DIR / Path(filename).name
dest.write_bytes(part.get_payload(decode=True))
return str(dest)
raise ValueError(f"添付ファイルが見つかりません: {filename}(あるもの: {', '.join(_attachments(msg))})")
if __name__ == "__main__":
mcp.run()
Gmail側でアプリパスワードを発行する
アプリパスワードは、Googleアカウントで2段階認証がオンになっていないと作れません。オンにしたうえで https://myaccount.google.com/apppasswords を開き、名前を付けて作成すると、16文字のパスワードが表示されます。この16文字は画面を閉じると再表示できません。閉じる前に、次の登録まで済ませます。
登録にはsetup-password.shを使います。中身はsecurityコマンド1行です。
#!/bin/zsh
# Gmailのアプリパスワードを macOS キーチェーンに保存する(ファイルには残さない)。
# 使い方: ./setup-password.sh work@gmail.com
# 実行すると入力欄が出るので、Googleで発行した16文字を貼って Enter(画面には表示されない)。
account="$1"
if [[ -z "$account" ]]; then
echo "使い方: $0 <メールアドレス>" >&2
exit 1
fi
security add-generic-password -U -s gmail-reader -a "$account" -w || exit 1
echo "保存しました: $account"
最初にchmod +x setup-password.shで実行できるようにしておきます。./setup-password.sh work@gmail.comのようにアドレスを渡すと、入力を2回聞かれます。どちらも同じ16文字を貼ってEnterを押します(2回目は確認です)。貼った文字は画面には出ません。Googleの画面に出る16文字は、スペース入りのまま貼って大丈夫です。server.pyの_passwordでスペースを消しています。この発行と登録は、accounts.jsonに書いたアドレスごとに1回ずつです。
Claude Codeに登録する
uvが無ければ先に入れておいてください(https://docs.astral.sh/uv/getting-started/installation/)。登録するときは、uvとserver.pyをフルパスで書きます。uvのパスは`which uv`で分かります。
claude mcp add -s user gmail-reader -- /path/to/uv run --quiet --script /path/to/server.py
どのフォルダでClaude Codeを開いても使えるよう、-s userを付けました。初回の起動は、mcpライブラリの取得で少し時間がかかります。
登録した後、ターミナルのClaude Codeは立ち上げ直してください。VS Code拡張で使っているなら、ウィンドウを再読み込みすれば大丈夫です。つながっているかはclaude mcp get gmail-readerで確かめられます。
Claude Codeに「workのアカウントで、件名に請求書が入ったメールを探して」と頼むと、searchが呼ばれます。accountは、メールアドレスでも@より前の部分だけでもかまいません。日本語の検索語でも、過去のメールが件名と差出人つきで返ってきました。
アプリパスワードは読むだけの鍵ではありません
アプリパスワードがあれば、IMAPでメールを消すことも、SMTPで送信することもできます。このサーバーが送信も削除もしないのは、コードに書いていないだけです。不要になったら、発行したページと同じ https://myaccount.google.com/apppasswords で削除できます。
Salesforceのレポートをスプレッドシートに書き出すツール ReportZen を作っています。