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
replyToMessageIdattachment; - detect SENT-by-us vs reporter-replied state on a thread;
- list draft IDs on the thread (
labelIdscarriesDRAFT); - check whether the thread exists / has any messages at all;
- read
Subjectfor 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-importStep 3 — classifies a thread intoReport/ASF-security relay/automated-scanner/ etc. by scanning the body for forwarding preambles, credit lines, scanner-product tokens.security-issue-syncStep 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_emailandmcp__ponymail__search_listresults carry it directly — no extra fetch needed. -
Gmail backend (claude.ai MCP). Use the
oauth-draft-message-idconsole script, which reuses the same OAuth credentials asoauth-draft-createand queriesthreads.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} objectSee
oauth-draft/README.mdfor setup. The script prints theMessage-IDof 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.
| Backend | Value | Thread attach? |
|---|---|---|
OAuth + curl | oauth_curl (preferred) | yes — via threadId |
| claude.ai Gmail MCP | claude_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). Preferoauth_curl; use this only whenoauth_curlcredentials 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. Thecreate_drafttool produces a plain-text message when onlybodyis supplied; passinghtmlBody(orbody+htmlBody) creates atext/htmlpart. The project's outbound mail is always plain text, so only ever populatebody. replyToMessageIdis the message ID of the latest message on the inbound thread. Resolve it fromget_threadrather than guessing — Gmail does not accept athreadIdhere.- 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
threadIdcannot be resolved or the latest message is not retrievable, omitreplyToMessageIdand let the draft thread by subject only. See the fallback rule inthreading.mdfor 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. Forclaude_ai_mcp, populatebodyand neverhtmlBody. Foroauth_curl, the script builds a singletext/plainpart viaEmailMessage.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-revealbefore 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 throughpii-revealonce, 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_mcpsubject 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
draftIdis in the result → emit the "still pending" line as planned. - If the
draftIdis 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_sincefiltered to the recipient, orget_thread(threadId)showing a SENT message after the draft was created).
- "Reporter draft
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
draftIdto 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
draftIdstill exists vialist_draftsbefore 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.