Skip to content

fix(stats): get-mail-stats failed on every path — undeclared schema key + sequential fan-out (#135) - #136

Merged
sweetrb merged 2 commits into
mainfrom
fix/135-mail-stats-schema-and-timeout
Aug 6, 2026
Merged

fix(stats): get-mail-stats failed on every path — undeclared schema key + sequential fan-out (#135)#136
sweetrb merged 2 commits into
mainfrom
fix/135-mail-stats-schema-and-timeout

Conversation

@sweetrb

@sweetrb sweetrb commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Fixes #135. Both faults @ismyemailaddress reported, plus a third the investigation turned up.

1. Schema rejection (scoped) — affected all 50 tools, latently

The client validates structuredContent against the JSON Schema the server advertised, not against the server's own zod object. A bare zod raw shape renders as additionalProperties: false, so any field the schema doesn't enumerate is a hard -32602.

get-mail-stats's IMAP branch spreads an ImapStats, which carries perMailbox — never declared. The server never saw a problem because zod's own parse strips unknown keys rather than failing. That is precisely why the v2.3.0 migration's "all fields optional, no .strict()" was believed permissive: it covered optionality, not undeclared keys.

Measured against the shipped 2.10.5 bundle: all 50 tools advertise additionalProperties: false. get-mail-stats was simply the only one whose payload tripped it. So this is fixed for the whole surface, not one tool: every tool now registers through a wrapper applying .passthrough().

Exact reproduction against a real account, shipped bundle vs this branch:

MAIN 2.10.5  → FAILED: -32602 ... data must NOT have additional properties
FIXED        → OK in 1434ms

(The repro must call listTools() before callTool() — the client only builds a validator from a cached tool list, which is why a naive script appears to pass.)

2. Unscoped timeout

The all-accounts path counted accounts sequentially, so wall clock was the sum over every account, each costing one IMAP STATUS per mailbox — and Gmail lists every label as a mailbox. Four accounts overran the request timeout and the call died as -32001 with nothing returned.

The pool is per-account and doesn't contend, so accounts are now counted concurrently (wall clock = slowest account, not the sum). Measured on three real accounts: 16.9s → 9.3s. Each account is additionally bounded by APPLE_MAIL_MCP_STATS_BUDGET_MS (default 25s, min 1s), so one wedged account degrades to a partial result instead of taking the rest down.

3. Silent zeros (found while fixing the above)

The IMAP fan-out's catch logged to stderr and continued, so an unreadable account contributed 0 to the totals with nothing in the result saying so — the same defect #130 fixed in get-unread-count, left behind here. Unreadable accounts now set partial: true + failedAccounts. A scoped call errors instead, naming the budget and the remedy, since a partial result for the one account you asked about is no result.

Guard

The contract test now fails any tool advertising additionalProperties: false. The existing checks couldn't see this class — they inspect the advertised schema and round-trip only health-check/doctor, so a tool with an undeclared key passes CI and fails in the user's client, exactly as @ismyemailaddress predicted. Verified a real guard, not a tautology: 50 offenders against 2.10.5, 0 against this branch.

Verification

  • lint (0 errors, 10 pre-existing warnings — same count as main), typecheck, format:check clean
  • 425 unit tests pass; outputSchema contract suite 4/4
  • Real-data: scoped iCloud + Gmail + Workspace-style accounts, unscoped merge, forced-budget partial path, forced-budget scoped error — all exercised against live IMAP
  • Committed bundle rebuilt and in sync

Docs: README tool reference + env table, docs/IMAP-SETUP.md env table, CLAUDE.md agent guidance (a partial total is a floor, not an answer), skills/apple-mail/SKILL.md via sync:skills. Version bumped to 2.10.6 with CHANGELOG under a real heading.

sweetrb added 2 commits August 6, 2026 07:44
Scoped calls died client-side with -32602 "data must NOT have additional
properties"; unscoped calls timed out with -32001. Two separate faults.

Schema: the CLIENT validates structuredContent against the ADVERTISED JSON
Schema, and a bare zod raw shape renders as additionalProperties:false, so any
undeclared field is fatal. get-mail-stats' IMAP branch spreads an ImapStats,
which carries perMailbox. The server never noticed because zod's own parse
strips unknown keys rather than failing — which is why the v2.3.0 migration's
"all fields optional, no .strict()" read as permissive: it covered optionality,
not undeclared keys. All 50 tools on the shipped 2.10.5 bundle advertised
additionalProperties:false; get-mail-stats was just the one whose payload
tripped it. Every tool now registers through a wrapper that applies
.passthrough(), and the contract test fails any tool that regresses (50
offenders against 2.10.5, 0 against this).

Timeout: the all-accounts path counted accounts sequentially, so wall clock was
the sum over every account, each costing one IMAP STATUS per mailbox (Gmail
lists every label). The pool is per-account and doesn't contend, so accounts are
now counted concurrently — 16.9s -> 9.3s on three real accounts — and each is
bounded by APPLE_MAIL_MCP_STATS_BUDGET_MS (default 25s).

Also: a failed account no longer folds in as a silent zero. It sets
partial:true + failedAccounts, the same treatment #130 gave get-unread-count
and never applied here; a scoped call errors instead, since a partial result
for the one account you named is no result.

Fixes #135.
@sweetrb
sweetrb merged commit 9c4350c into main Aug 6, 2026
10 checks passed
@sweetrb
sweetrb deleted the fix/135-mail-stats-schema-and-timeout branch August 6, 2026 11:48
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.

get-mail-stats fails on every path — outputSchema rejection when scoped, timeout when unscoped

1 participant