0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude CodeからGmailを2アカウント読む(IMAPとアプリパスワードで小さなMCPサーバーを作る)

0
Last updated at Posted at 2026-10-04

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 を作っています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?