Skip to content

Add *_metadata variants for search, list, and thread tools#70

Merged
MadLlama25 merged 9 commits into
MadLlama25:mainfrom
Richyread:pr/search-tools-metadata
Jul 6, 2026
Merged

Add *_metadata variants for search, list, and thread tools#70
MadLlama25 merged 9 commits into
MadLlama25:mainfrom
Richyread:pr/search-tools-metadata

Conversation

@Richyread

Copy link
Copy Markdown
Contributor

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 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 that the JMAP Email/get property allowlist is reduced to a fixed set of header-only fields (id, threadId, subject, from, to, [cc,] replyTo, receivedAt, hasAttachment, keywords) — no preview, no textBody, no htmlBody, no bodyValues.

Why

PR #68 set up get_email_metadata 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. 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, and get_thread tools are untouched. Existing callers keep working unchanged; callers wanting the privacy-safe path reach 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 (every Email/get properties array is checked against a forbidden-list of preview, textBody, htmlBody, bodyValues, body, bodyStructure). One additional test 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.

Versioning

Bumped to 1.10.0 across package.json, manifest.json, and the Server constructor in src/index.ts (minor — additive feature).


This PR was drafted with Claude Opus 4.7; I reviewed and tested each commit.

Richyread added 9 commits May 2, 2026 20:54
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).
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.
@MadLlama25 MadLlama25 merged commit dc95e07 into MadLlama25:main Jul 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants