Add *_metadata variants for search, list, and thread tools#70
Merged
Conversation
This fork uses bun for local installs (per repo convention), but the lockfile shouldn't track in the upstream-mirroring main branch.
While building a Claude Code skill on top of fastmail-mcp I bumped
into three small rough edges in the mailbox area. They're all little,
they all use plumbing the MCP already has, and they pair naturally —
so one PR felt friendlier than three.
The thing that started this: on a real account with 220+ mailboxes,
list_mailboxes returns ~210KB of JSON and trips over the MCP tool-
result window. The other two are capabilities I found I wanted once
the skill grew — looking up a mailbox by path when I know the folder
I want but not its id, and creating a new mailbox when the skill
decides to file mail into a fresh subfolder.
Three additions, all built on Mailbox/get and Mailbox/set:
- get_mailbox_by_name(path) — walks the parentId chain client-side
and returns {id, name, parentId, path} for an exact match, or
throws "Mailbox not found". Path separator is "/"; folder names
containing a literal "/" aren't supported (documented).
- list_mailboxes now accepts optional `properties` and `parentId`:
`properties: ["id","name","parentId"]` roughly halves payload
size, and `parentId` filters to direct children client-side.
Backwards compatible — called with no args it returns all
properties as before, so existing callers don't need to change.
- create_mailbox(name, parentId) — wraps Mailbox/set create and
surfaces the JMAP error type/description on failure (e.g. a
duplicate name). The "/" check is enforced in the tool handler so
callers can't accidentally smuggle a path; structural rules
(length, character set, parent allow-list) are deliberately left
to the caller, since they vary by use case.
Smoke-tested against the same 220+ mailbox account that started this:
get_mailbox_by_name resolved a deep path correctly, the slim
list_mailboxes returned 148 children of one parent without overflow,
and create_mailbox produced an id that immediately accepted a
move_email.
check_function_availability has been updated to list the two new
tools so its report stays accurate. All 176 existing unit tests pass.
Working through a Fastmail-rail port of an email-processing skill, I noticed every "archive into folder X" site needed two MCP calls: move_email + mark_email_read. The pair has awkward partial-failure semantics (the move succeeds but the mark-read doesn't, or vice versa), and the two-call pattern adds latency and complexity at every archive site in the calling code. archive_email folds both operations into a single Email/set patch: remove all current mailbox memberships, add the target mailbox, and set keywords/$seen=true. JMAP guarantees per-email patch atomicity, so either the whole archive lands or none of it does — no "moved but not marked read" middle state to handle. Scope is deliberately narrow: it's "archive into a folder + mark read", which is what most rule-based filing flows want. Trashing has a different convention (trashed mail often stays unread because Trash doesn't surface in folder lists or unread counts), so delete_email remains the right call there — archive_email intentionally doesn't cover that case. Smoke-tested against a real account: round-tripping a marketing email out to a supplier folder and back to Inbox worked cleanly on both legs, $seen stayed set as expected, no errors. All 176 existing unit tests still pass.
…lows
The existing get_email tool returns the full Email object — textBody,
htmlBody, bodyValues, attachments — which is great for "show me this
email" workflows but a hammer-too-large for skills that just need to
classify or route based on headers.
This shows up most acutely in privacy-sensitive flows: a skill working
through a customer-mail mailbox might be specifically forbidden from
ingesting message bodies (GDPR / least-privilege rules), but still
needs the basic envelope data — sender, subject, date, mailbox
membership — to do its job. With only get_email available, the skill
has to either (a) take the full payload and remember to discard the
body in its prompt logic, or (b) work around using advanced_search
filters. Both approaches put the privacy guarantee in the wrong place:
in the caller's discipline rather than the tool surface.
get_email_metadata is a tool-level guarantee. It calls Email/get with
a strict properties allowlist:
id, threadId, mailboxIds, keywords, receivedAt, sentAt, subject,
from, to, cc, bcc, replyTo, messageId, inReplyTo, references,
size, hasAttachment
There's no bodyProperties, no fetchTextBodyValues, no
fetchHTMLBodyValues — the JMAP server simply never sends body content
in this tool's path. hasAttachment is a Boolean (yes/no); filenames
and content types deliberately aren't surfaced because filenames
sometimes carry PII ("Smith Invoice June 2026.pdf"-style leakage). If
a caller does need attachment metadata or to download attachments,
get_email_attachments and download_attachment cover that explicitly.
Useful side benefit: this tool returns mailboxIds, which the existing
get_email omits. That makes get_email_metadata the right call for
"verify this email landed in the expected folder after I moved it"
checks — handy in tests and skill self-assessment paths.
Smoke-tested: response carries the 17 expected keys and none of
textBody / htmlBody / bodyValues / body / preview / attachments. The
existing get_email on the same email returns attachments, bodyValues,
htmlBody, textBody — confirming the contrast. All 176 existing unit
tests still pass.
Real-world observation while building a Claude Code skill on top of
fastmail-mcp: HTML email bodies above ~25KB (the default
MAX_MCP_OUTPUT_TOKENS budget for inline tool results) trip the
harness's auto-spill behaviour. The full Email payload gets written
to /tmp/<tool>_<timestamp>.md and the model receives only a file
path. The caller then has to do its own Read+JSON.parse recovery to
get at the body — and clean up the temp file afterwards if it cares
about not leaving raw mail content on disk.
Promotional newsletters, policy-update emails, and onboarding emails
routinely exceed 25KB. On a real account I hit a 44KB Argos
privacy-policy email and a ~60KB Fastmail import-completion email
that both spilled on get_email — entirely usable payloads that
didn't need the indirection.
Claude Code v2.1.91+ honours an `_meta: { "anthropic/maxResultSizeChars": <N> }`
annotation on individual tool definitions to override the default.
This adds the annotation to get_email with N=500000 (~500KB), which
covers virtually every real email without going near the MCP hard
ceiling. Other clients that don't recognise the annotation simply
ignore it (`_meta` is the standardised escape hatch for client-
specific hints), so no compatibility risk.
Smoke-tested locally on Claude Code v2.1.126: a 44KB Argos email
that previously spilled now comes back inline. All 176 existing
unit tests still pass.
Scope is intentionally narrow to just get_email — that's the tool
where the spill was observed. get_thread and the listing tools could
plausibly hit the same limit on threads or result-sets with many
large preview fields, but those would deserve their own PRs once a
real case shows up.
# Conflicts: # src/index.ts
# Conflicts: # src/index.ts
…gs for advanced_search, search_emails, list_emails, and get_thread PR MadLlama25#68 added get_email_metadata as a header-only sibling of get_email, so callers in privacy-sensitive flows (customer-mail least-privilege scans, GDPR-bound workflows, anything that mustn't ingest message bodies) can pick up envelope data without any body content riding along. The tool surface itself does the enforcing — the JMAP server is simply never asked for body fields, so it never sends any. That's a much stronger guarantee than asking the caller to remember to discard body content after the fact. The other search and thread tools weren't part of that pass and still include 'preview' in their JMAP property request. 'preview' is a ~200-character body excerpt, so every result of every search/list/ thread call quietly surfaces body content to the caller. That gap showed up while wiring a customer-mail scan against the new tooling: the skill had to add a "discard preview before reasoning" guard at the top of every code path, which works but puts the privacy guarantee back into the caller's discipline rather than the tool. This PR closes that gap by adding four parallel tools alongside the existing ones (additive — nothing renamed, no breaking changes): advanced_search_metadata — same filters as advanced_search search_emails_metadata — same query as search_emails list_emails_metadata — same listing as list_emails get_thread_metadata — same thread enumeration as get_thread Each one mirrors its sibling's input schema and behaviour exactly, including get_thread's accept-an-email-id-and-resolve-the-thread flexibility. The only difference is the JMAP property allowlist, which is reduced to a fixed set of header-only fields: id, threadId, subject, from, to, [cc,] replyTo, receivedAt, hasAttachment, keywords (cc is included where the existing siblings included it.) No preview, no textBody, no htmlBody, no bodyValues. Anyone using the existing tools keeps working unchanged; anyone wanting the privacy-safe path reaches for the *_metadata variant by name. Tool descriptions: each new tool's description names its sibling explicitly, lists the property allowlist, and says when to pick it. The goal is that someone scanning the tool list sees not just "another search" but "the privacy-safe version of advanced_search" without having to read the source. Tests: five new tests in jmap-client-extra.test.ts pin the no-body-content invariant for each *_metadata method, plus one that confirms advancedSearchMetadata preserves the AND-conjunction filter logic when both isUnread and isPinned are set (the most intricate piece of advancedSearch's behaviour). All 181 tests pass — 176 pre-existing, 5 new. Bumped to 1.10.0 across package.json, manifest.json, and the Server constructor in src/index.ts (minor — additive feature).
6 tasks
MadLlama25
pushed a commit
that referenced
this pull request
Jul 6, 2026
The existing advanced_search and advanced_search_metadata tools let
callers scope a query to a single mailbox via mailboxId. JMAP itself
can express richer mailbox scoping — "in mailbox A AND mailbox B" via
a FilterOperator AND, "not in mailboxes X or Y" via inMailboxOtherThan
— but neither shape was reachable from the tool surface, so callers
who needed an intersection had to run two searches and reconcile the
results client-side.
That comes up surprisingly often once Sieve/filter rules apply labels
on arrival. A common shape is "show me what's in Inbox AND carries
label X" — the working list for a label whose Inbox intersection is
what tells you whether anything in there still needs human attention.
With only mailboxId available, the call has to be split in two and
intersected by hand.
This PR closes that gap by adding two optional parameters to both
tools:
requiredMailboxIds: string[] must be a member of ALL listed
mailboxes. Builds a FilterOperator
AND of inMailbox conditions on the
server in one call.
excludeMailboxIds: string[] must NOT be a member of ANY listed
mailbox. Maps to JMAP's native
inMailboxOtherThan.
The existing mailboxId parameter is unchanged. If both mailboxId and
requiredMailboxIds are passed, the scalar is folded into the
intersection and the resulting list is de-duplicated, so callers can
mix the two without surprise.
Implementation: the inline filter-building (currently duplicated
between advancedSearch and advancedSearchMetadata) moves into a shared
buildEmailQueryFilter helper. The helper picks between a flat
FilterCondition and a FilterOperator AND automatically. It splits
keyword conditions when needed — the existing isUnread + isPinned
collision case — and folds single-mailbox queries down to the
previous wire shape so existing callers see exactly the same JMAP
request as before.
Tests: new direct unit tests for the filter assembler in
jmap-client-extra.test.ts covering each shape (single mailbox,
multi-required AND, exclude only, multi-required + exclude + keyword,
keyword-collision split, de-dup, empty arrays). Three integration
tests inspect the actual JMAP request produced by advancedSearch and
advancedSearchMetadata for the new parameter combinations, plus a
backwards-compat pin that confirms a legacy mailboxId-only call still
produces the flat FilterCondition shape on the wire. All 196 tests
pass.
Bumped to 1.11.0 across package.json, manifest.json, and the Server
constructor in src/index.ts (minor — additive feature).
Note: this branch is stacked on search-tools-metadata (PR #70). The
*_metadata variants live there, and this PR refactors both methods
together. Happy to rebase against main once #70 lands.
This was referenced Jul 6, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
Four new tools that give callers a privacy-safe path through the search and thread surface, mirroring the pattern PR #68 introduced for
get_email_metadata:advanced_search_metadata— same filters asadvanced_searchsearch_emails_metadata— same query assearch_emailslist_emails_metadata— same listing aslist_emailsget_thread_metadata— same thread enumeration asget_threadEach one mirrors its sibling's input schema and behaviour exactly, including
get_thread's accept-an-email-id-and-resolve-the-thread flexibility. The only difference is that the JMAPEmail/getproperty allowlist is reduced to a fixed set of header-only fields (id, threadId, subject, from, to, [cc,] replyTo, receivedAt, hasAttachment, keywords) — nopreview, notextBody, nohtmlBody, nobodyValues.Why
PR #68 set up
get_email_metadataso callers in privacy-sensitive flows (customer-mail least-privilege scans, GDPR-bound workflows, anything that mustn't ingest message bodies) can pick up envelope data without any body content riding along. The tool surface itself does the enforcing — the JMAP server is simply never asked for body fields, so it never sends any. That's a much stronger guarantee than asking the caller to remember to discard body content after the fact.The other search and thread tools weren't part of that pass and still include
previewin their JMAP property request.previewis a ~200-character body excerpt, so every result of every search/list/thread call quietly surfaces body content to the caller. The gap showed up while wiring a customer-mail scan against the new tooling: the skill had to add a "discard preview before reasoning" guard at the top of every code path, which works but puts the privacy guarantee back into the caller's discipline rather than the tool.Compatibility
Additive — nothing renamed, no breaking changes. The existing
advanced_search,search_emails,list_emails, andget_threadtools are untouched. Existing callers keep working unchanged; callers wanting the privacy-safe path reach for the*_metadatavariant by name.Tool descriptions
Each new tool's description names its sibling explicitly, lists the property allowlist, and says when to pick it. The goal is that someone scanning the tool list sees not just "another search" but "the privacy-safe version of
advanced_search" without having to read the source.Tests
Five new tests in
jmap-client-extra.test.tspin the no-body-content invariant for each*_metadatamethod (everyEmail/getpropertiesarray is checked against a forbidden-list ofpreview,textBody,htmlBody,bodyValues,body,bodyStructure). One additional test confirmsadvancedSearchMetadatapreserves the AND-conjunction filter logic when bothisUnreadandisPinnedare set — the most intricate piece ofadvancedSearch's behaviour.All 181 tests pass — 176 pre-existing, 5 new.
Versioning
Bumped to
1.10.0acrosspackage.json,manifest.json, and the Server constructor insrc/index.ts(minor — additive feature).This PR was drafted with Claude Opus 4.7; I reviewed and tested each commit.