Gmail

July 7, 2026 · View on GitHub

Shared reference for the mcp__claude_ai_Gmail__* tool calls the skills make against the active user's Gmail account. The skills reference this file for the call shape and for the limitations that constrain their flow.

Placeholder convention used below:

  • <security-list> — the project's private security mailing list (the list the user's Gmail account subscribes to). For Airflow, the value is <project manifest>.security_list = <security-list>; see ../../<project-config>/project.md.
  • <threadId> — an opaque Gmail thread identifier.

Pre-flight

Every skill that talks to Gmail does a one-call pre-flight in Step 0 to confirm the MCP is reachable and the user's account subscribes to the project's security list:

mcp__claude_ai_Gmail__search_threads(
  query='list:<security-list-domain>',
  pageSize=1,
)

Substitute the <security-list-domain> with the domain suffix of the project manifest's security_list (for <security-list>, the value is <security-list-domain>).

A non-empty result means Gmail is connected and indexed; an empty result means either the account does not subscribe, or the MCP is misconfigured. In either case the skill stops and asks the user to fix the setup rather than guessing.

Read

Search threads

mcp__claude_ai_Gmail__search_threads(
  query='<gmail search expression>',
  pageSize=<N>,
)

Returns an array of {threadId, snippet, …} objects. Use pageSize deliberately — some skills (e.g. security-issue-sync) impose a hard Gmail-call budget per issue to avoid running up the MCP quota on many-tracker sweeps.

For the search expression syntax and the canonical query templates the skills use, see search-queries.md.

Get thread

mcp__claude_ai_Gmail__get_thread(
  threadId='<threadId>',
  messageFormat='MINIMAL',        # default — see escalation rule below
)

Default to MINIMAL. The MINIMAL format returns message snippets, key headers (Subject, From, To, Cc, Date), and message IDs — enough to:

  • pick the chronologically-last message for replyToMessageId attachment;
  • detect SENT-by-us vs reporter-replied state on a thread;
  • list draft IDs on the thread (labelIds carries DRAFT);
  • check whether the thread exists / has any messages at all;
  • read Subject for fallback threading when the message ID is lost.

This covers the vast majority of get_thread call sites the skills make. FULL_CONTENT$ \text{returns} \text{the} \text{entire} \text{conversation} \text{including} \text{HTML} \text{body} \text{parts} — \text{typically} 5-20 \times \text{the} \text{byte} \text{size} \text{of} $MINIMAL for a non-trivial thread (a long reporter conversation can exceed the in-context token limit and spill to disk).

Escalate to FULL_CONTENT only when the call site actually processes the message body. Concrete cases that need FULL_CONTENT:

  • security-issue-import Step 3 — classifies a thread into Report / ASF-security relay / automated-scanner / etc. by scanning the body for forwarding preambles, credit lines, scanner-product tokens.
  • security-issue-sync Step 1e — extracts CVE-reviewer asks from review-comment emails on <security-list>.
  • Any draft-composition step that quotes the reporter's prior message back to them (e.g. when the operator wants to reference a specific paragraph the reporter wrote).
  • Reading an inbound report's body to extract a credit form the reporter explicitly provided ("please credit me as X").

For everything else — thread-state probes, anchor-point lookups, draft-already-exists checks — default to MINIMAL and avoid the body fetch.

Cost note. A 12-tracker bulk sync that calls get_thread once per tracker for state-anchoring lands around ~5K tokens of Gmail context on MINIMAL; the same call on FULL_CONTENT typically lands 60-100K tokens. The savings compound on every bulk run.

Privacy-LLM contract — apply to every body read. Every get_thread(messageFormat='FULL_CONTENT') call against a <security-list> thread (or any <private-list> thread, where the approved-LLM gate also applies) MUST be followed by the redact-after-fetch protocol documented in ../privacy-llm/wiring.md before the body is used for any further processing. The window between get_thread returning and pii-redact running should be a single tool invocation wide; the redacted body is what flows through the rest of the skill. Skills that consume bodies without running the protocol are framework bugs.

Skip the protocol on messageFormat='MINIMAL' calls — the returned envelope carries the reporter's From: header (which is not redacted under the contract) and routing fields, no free-form body content. The protocol applies once an actual body is fetched.

Get the root Message-ID of a thread

Important

The claude.ai Gmail MCP does not expose the RFC-5322 Message-ID: header. The "message IDs" the get_thread envelope returns are Gmail's opaque per-message IDs (the value passed to replyToMessageId), which only resolve inside the one mailbox that holds the thread. The Message-ID: header — the archive-independent identifier the reporter's MUA stamped, and the value the ASF PonyMail archive hashes its permalinks on — is reachable only via the Gmail REST API or the PonyMail archive.

security-issue-import records the inbound report's root Message-ID in the Security mailing list thread tracker field (alongside the Gmail threadId and any PonyMail URL) so the message stays locatable even from an account that never received the Gmail copy. Resolve it by backend:

  • PonyMail backend (ASF default primary read path). The archive is keyed on Message-ID; mcp__ponymail__get_email and mcp__ponymail__search_list results carry it directly — no extra fetch needed.

  • Gmail backend (claude.ai MCP). Use the oauth-draft-message-id console script, which reuses the same OAuth credentials as oauth-draft-create and queries threads.get?format=metadata&metadataHeaders=Message-ID:

    uv run --project tools/gmail/oauth-draft \
      oauth-draft-message-id <threadId> [<threadId> ...]
    # → one TSV line per thread: <threadId>\t<message-id>
    # → or --json for a {threadId: message-id} object
    

    See oauth-draft/README.md for setup. The script prints the Message-ID of the thread's root (chronologically first) message — the inbound report. A thread with no resolvable header prints an empty value and still exits 0.

When recording the value in a tracker field, backtick-wrap it — a bare <...@...> renders as an HTML tag on GitHub and the identifier vanishes from the rendered issue.

Write — drafts only, never send

Drafting backends

Draft creation runs through one of two backends, selected by the user in .apache-magpie-overrides/user.md under tools.gmail.draft_backend. The full comparison and rationale live in draft-backends.md; the call shape per backend is here.

BackendValueThread attach?
OAuth + curloauth_curl (preferred)yes — via threadId
claude.ai Gmail MCPclaude_ai_mcp (discouraged — rewrites URLs; see draft-backends.md)yes — via replyToMessageId

Create draft — claude_ai_mcp backend

Discouraged. This backend silently rewrites embedded URLs into Google tracking redirects (see draft-backends.md). Prefer oauth_curl; use this only when oauth_curl credentials are unavailable AND the body contains no URLs.

The claude.ai Gmail MCP's create_draft tool accepts a replyToMessageId parameter (a Gmail message ID, not a thread ID). When supplied, Gmail attaches the draft to the conversation that contains that message — server-side, on the sender's Gmail. The new draft is visible in both the conversation view and the global Drafts folder, and the original message body is appended to the draft's body (standard "reply" composition). Recipients' mail clients thread-attach via the subject + In-Reply-To / References headers Gmail synthesises from the parent message.

# 1. Resolve the message to reply to. The skills always reply to the
#    chronologically-last message on the inbound thread (see
#    threading.md):
mcp__claude_ai_Gmail__get_thread(
  threadId='<inbound-threadId>',
  messageFormat='MINIMAL',
)
# → take messages[-1].id as <reply-to-message-id>

# 2. Create the draft with replyToMessageId set.
#    Pass `body` (plain text) ONLY — never `htmlBody`. The tool sends
#    a plain-text message iff `htmlBody` is omitted; supplying it adds
#    a text/html alternative, which the project never wants.
mcp__claude_ai_Gmail__create_draft(
  subject='Re: <root subject of the inbound message>',
  to=['<primary>'],
  cc=['<security-list>', ...],
  body='<body>',                # plain text only
  replyToMessageId='<reply-to-message-id>',
  # htmlBody=...                # DO NOT SET — would make the draft HTML
)
  • Plain text only — never set htmlBody. The create_draft tool produces a plain-text message when only body is supplied; passing htmlBody (or body + htmlBody) creates a text/html part. The project's outbound mail is always plain text, so only ever populate body.
  • replyToMessageId is the message ID of the latest message on the inbound thread. Resolve it from get_thread rather than guessing — Gmail does not accept a threadId here.
  • Subject is always Re: <root subject>, never fabricated. A drifted subject defeats subject-based threading on every client and is a separate signal Gmail's UI uses to render the conversation header.
  • Never send. The skills only create drafts; a human review-and-send step is required before every outbound message.
  • Fallback — when the inbound threadId cannot be resolved or the latest message is not retrievable, omit replyToMessageId and let the draft thread by subject only. See the fallback rule in threading.md for when this applies and when it does not.

Create draft — oauth_curl backend

The oauth-draft-create console script (in oauth-draft/) creates drafts by talking directly to the Gmail REST API with a user-provided OAuth refresh token. It sets threadId on the Gmail API call and populates In-Reply-To / References from the thread's last message, so every client threads consistently.

uv run --project <framework>/tools/gmail/oauth-draft oauth-draft-create \
  --thread-id <gmail-threadId> \
  --to reporter@example.com \
  --cc <security-list> \
  --subject "Re: <root subject>" \
  --body-file /tmp/body.txt

See oauth-draft/README.md for one-time setup (creating the Google Cloud OAuth client, obtaining a refresh token, and populating the credentials file) and for the full flag list.

Every bullet from the claude_ai_mcp section above applies to this backend too — drafts only, never send; subject is always Re: <root subject>; composition happens under user review.

Hard rules that apply to both backends

  • Never send.
  • Plain text only — never HTML. Every draft is a plain-text (text/plain) message. For claude_ai_mcp, populate body and never htmlBody. For oauth_curl, the script builds a single text/plain part via EmailMessage.set_content (asserted by its test suite). Do not introduce an HTML body in either backend.
  • Subject is always Re: <root subject>, never fabricated.
  • Run pii-reveal before passing the body to the create-draft call. If the draft body carries any third-party identifiers the skill redacted earlier (e.g. when assembling a CVE-credit line referencing a non-reporter, non-collaborator individual), the rendered draft text MUST be passed through pii-reveal once, immediately before the create-draft tool call, so the recipient sees the real value. Drafts whose bodies contain only the reporter's own identity (already not-redacted under the contract) need no reveal step. The full reveal-before-send protocol is in ../privacy-llm/wiring.md.
  • Surface which backend was used in the proposal / recap so the user can tell at a glance whether the draft threads on their own Gmail view (oauth_curl) or only on the recipient's (claude_ai_mcp subject fallback).
  • Record the backend + draft ID on the tracker's status rollup so subsequent sync passes can find and (optionally) re-verify the draft.

For the ASF-security-relay special case (different to / cc shape), see asf-relay.md.

List drafts

mcp__claude_ai_Gmail__list_drafts(
  query='<optional filter>',    # e.g. 'list:<security-list-domain>'
)

Used by security-issue-sync to verify that a draft flagged as stale in a previous status comment still exists before carrying the flag forward. See the "self-replicating stale-draft flag" paragraph in that skill.

Verify-before-claim — never assert a draft is "still pending" without checking

Any skill that writes a tracker status comment, proposal, or recap line of the shape "Reporter notification still pending — see draft <draftId>" (or any analogous "draft is awaiting send" claim) MUST call list_drafts immediately before emitting the line and confirm <draftId> is in the returned set.

  • If the draftId is in the result → emit the "still pending" line as planned.
  • If the draftId is NOT in the result → the draft is gone: the user has either sent it (Gmail moves sent items out of Drafts) or discarded it. Do not emit "still pending". Instead, flip the line to one of:
    • "Reporter draft <draftId> is no longer in Drafts — sent or discarded (verify in Sent if uncertain)." — neutral, no false claim.
    • "Reporter has been notified on the original mail thread." — only if the skill can independently confirm the send (e.g. via list_sent_since filtered to the recipient, or get_thread(threadId) showing a SENT message after the draft was created).

The rule applies in every sync, not only on stale-flag carry-forward. The "draft was just created in this same pass" case is no exception — the user may have switched to Gmail and sent it between the create call and the status-comment post; one extra list_drafts call covers the race.

Without this guard, a "still pending" flag posted on one sync self-replicates across every subsequent sync long after the user has actually sent the email, nagging the team about a phantom pending notification.

Hard limitation — no update, no delete

The Gmail MCP exposes create, list, and read only for drafts. There is no update_draft and no delete_draft tool. The skills must treat every existing draft as immutable:

  • If a correction is needed, surface the existing draft's draftId to the user with an explicit "discard this one manually in Gmail" note, then create a fresh draft with the corrected content.
  • Do not silently create a second draft that shadows the first — that leaves two near-identical drafts in the user's Gmail and invariably one of them gets sent by accident.
  • On the sync skill's stale-draft-forward-flagging path: verify the draftId still exists via list_drafts before copying the flag into a new sync status comment. Without verification, a one-time flag self-replicates forever.

Confidentiality of drafts

Drafts land in the user's personal Gmail account and are visible only to that user until sent. Draft content may reference the private tracker's URL (reporter is on the private thread and is expected to keep it confidential), but anything destined for a public list must obey the confidentiality rules in ../../AGENTS.md — no <tracker> URLs, no CVE IDs before publication, no "security fix" leakage.

Error handling

If any Gmail call fails (MCP unreachable, 429, transient 5xx), stop and report the failure. The skills explicitly budget Gmail calls; silently retrying turns one flaky call into a quota-exhaustion storm.