Skip to content

Design: Full API parity for CLI and MCP#5

Open
seanb4t wants to merge 31 commits intomainfrom
seanb4t/beads-pickup
Open

Design: Full API parity for CLI and MCP#5
seanb4t wants to merge 31 commits intomainfrom
seanb4t/beads-pickup

Conversation

@seanb4t
Copy link
Owner

@seanb4t seanb4t commented Feb 7, 2026

Comprehensive design for full CRUD parity between CLI and MCP server with Fastmail JMAP/CardDAV/CalDAV APIs.

Includes 19 new CLI commands, 13 new MCP tools, 4 new resources, and 5-phase implementation plan starting with mailbox and mail operations.

seanb4t and others added 30 commits February 6, 2026 20:33
Covers 19 new CLI commands and 13 new MCP tools across mail,
mailbox, calendar, identity, vacation, and thread operations
to achieve full CRUD parity with the Fastmail JMAP/CardDAV/CalDAV
API surface. Includes 5-phase implementation plan.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix MCP tool count (15 new, not 13) and mark 5 unwired tools
- Clarify mail search needs new query parser, not existing Search()
- Add GetFull() for mail show (existing Get() lacks display properties)
- Fix is:unread mapping to notKeyword (not negated hasKeyword)
- Note Identity type field fixes needed (ReplyTo/BCC are wrong type)
- Add CapVacationResponse capability requirement
- Document calendar DAV client pattern (follows contacts.go)
- Note existing builders for Mailbox/Identity (reduce scope)
- Note EmailSetBuilder already supports Update() for keywords

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…tant

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Detailed bite-sized implementation plan covering 5 phases:
- Phase 1: Mailbox + Mail Read (tasks 1-10)
- Phase 2: Mail Write Operations (tasks 11-13)
- Phase 3: Calendar CLI (tasks 14-16)
- Phase 4: Identity, Vacation, Thread (tasks 17-22)
- Phase 5: MCP Catch-up (tasks 23-25)

Each task follows TDD workflow: write failing test → implement → verify → commit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add builder pattern for JMAP Mailbox/set method supporting create,
update, and destroy operations, along with MailboxSetResponse type
for deserializing server responses.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add MailboxService to pkg/fastmail wrapping JMAP Mailbox/get and
Mailbox/set operations. Includes Mailbox() accessor on Client and
full test coverage for all four operations plus error paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add Cobra subcommands for mailbox management with text and JSON output,
--force confirmation for delete, and required flag/arg validation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Register mailbox MCP tools (list, create, rename, delete) following the
existing tool handler pattern. Wire registerMailboxTools into
RegisterMailTools and add tests for tool registration and argument
validation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add MailService.GetFull method that requests full display properties
(from, to, cc, bcc, bodyValues, textBody, htmlBody, attachments) with
fetchTextBodyValues enabled. Add Attachment type to both JMAP and domain
layers, along with convertFullEmail and address/attachment conversion
helpers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…rieval

Add DownloadBlob method to jmap.Client that downloads a blob using the
session's downloadUrl template with proper auth headers. Add GetRaw
method to MailService that fetches an email's blobId then downloads the
raw RFC 5322 source via DownloadBlob.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add `mail show EMAIL_ID` command that displays a single email with
formatted headers, body, and attachments. Supports `--raw` flag for
RFC 5322 source output, plus `--json` and `--quiet` global flags.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Implements a lightweight search query parser that converts human-readable
query strings into JMAP FilterCondition maps. Supports prefix tokens
(from:, to:, subject:, has:, is:, in:, before:, after:) and free text,
with automatic AND-wrapping for compound queries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Refactor Search to delegate to new SearchWithFilter method that accepts
a pre-built JMAP FilterCondition map, enabling the search parser to pass
structured filters directly instead of plain text queries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Wire up `mail search QUERY...` subcommand that parses query args via
search.Parse() and delegates to SearchWithFilter. Includes --limit/-n
flag (default 25) and reuses outputEmails for consistent output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add `mail move EMAIL_ID --folder FOLDER` to move emails between mailbox
folders and `mail delete EMAIL_ID --force` with a required confirmation
flag. Both commands include JSON/quiet output support and corresponding
CLI tests for argument and flag validation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… tool

Add support for setting email keywords (read/unread, flagged/unflagged)
across all three layers: MailService.SetKeywords uses JMAP Email/set with
keyword patch paths, `mail flag` CLI command with --read/--unread/--flagged/
--unflagged boolean flags, and mail_flag MCP tool for AI agent access.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add 6 calendar subcommands (list, show, create, update, delete,
calendars) following the contacts CLI pattern. Extend config with
CalDAV endpoint/username fields and auto-derivation from the JMAP
endpoint. Add NewCalendarService constructor to the public API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace duplicated inline tool registration in cli/mcp.go with a single
call to mcp.RegisterMailTools, wiring up contacts and calendar adapters.
Extend CalendarAdapter with Get/Update/Delete/ListCalendars function
pointers and add calendar_get, calendar_update, calendar_delete, and
calendar_calendars MCP tools with full handler implementations and tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…er RFC 8620

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…onse

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Implement IdentityService with List method, CLI `identity list` command,
and `identity_list` MCP tool. Fix gofmt alignment in jmap.Identity struct
and add dupl nolint directives for shared JMAP service List pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add VacationService with Get/Set methods wrapping the JMAP
VacationResponse/get and VacationResponse/set endpoints. Wire up
CLI commands (vacation show, vacation set) with --enable/--disable,
--from, --to, --subject, --body flags. Register vacation_get and
vacation_set MCP tools for AI agent access.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add ThreadService.Get that performs Thread/get then Email/get to fetch
all emails in a conversation. Wire up `thread show THREAD_ID` CLI
command and `thread_get` MCP tool.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add two new MCP tools for contact management:
- contacts_update: updates an existing contact by fetching it first and
  merging provided fields (name, email, phone)
- contacts_delete: deletes a contact by ID

Includes registration tests and handler validation tests for nil
contacts client and missing ID arguments.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
VacationService.Set now decodes VacationSetResponse and checks
NotUpdated["singleton"], matching the pattern used by all other Set
methods. The CLI vacation set command now validates that at least one
flag is provided before making the API call.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Resources defined in mcp/resources.go were never registered with the
MCP server, making them dead code. Add RegisterResources() to iterate
the ResourceRegistry and register each resource with the server.

Also log contacts and calendar client errors to stderr instead of
silently swallowing them, so users can diagnose DAV configuration issues.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Log a warning to stderr when ReceivedAt fails RFC3339 parsing instead
of silently producing zero time. Reject contradictory --read/--unread
and --flagged/--unflagged flag combinations with a clear error.

Also adds inMailbox resolution for SearchWithFilter so folder names
in search queries are resolved to mailbox IDs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Refuse to delete mailboxes with protected roles (inbox, drafts, sent,
trash, junk) even when --force is specified, preventing accidental
destruction of standard JMAP mailboxes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@seanb4t
Copy link
Owner Author

seanb4t commented Feb 8, 2026

PR #5 Review Summary: "Design: Full API parity for CLI and MCP"

Scope: 50 files, +8700/-219 lines — CLI commands, JMAP protocol, MCP tools/resources, service layer, search parser


Critical Issues (3)

1. SetKeywords uses false instead of null to remove JMAP keywords

pkg/fastmail/mail.go — Per RFC 8620 §5.3, removing a key from a JMAP map requires setting it to null, not false. Per RFC 8621 §4.1.1, keyword values MUST be true. The current code sends "keywords/$seen": false for --unread, which is protocol-noncompliant. Fix: map false to nil.

2. MailService.Delete permanently destroys email on transient Trash-lookup failure

pkg/fastmail/mail.go:394-399 — When resolveMailbox(ctx, accountID, "Trash") fails (network timeout, 401, 429, 500), the code falls through to s.destroy()permanent, irrecoverable deletion. Should only fall through for "mailbox not found", not transient errors.

3. Silent type coercion in MCP argument helpers

mcp/tools_mail.go:586-642getStringArg, getUint64Arg, getBoolArg, getStringSliceArg silently return defaults when the arg is present but wrong type. An agent sending {"id": 12345} (int) gets the misleading error "id is required" instead of a type mismatch error. getStringSliceArg also silently drops non-string array elements.


Important Issues (5)

4. --enable/--disable vacation flags silently conflict

cli/vacation.go, mcp/tools_mail.go--disable silently overwrites --enable due to execution order. No mutual exclusivity validation (unlike mail flag's read/unread).

5. MCP mail_flag handler lacks mutual exclusivity validation

mcp/tools_mail.go — CLI validates --read/--unread mutual exclusivity; MCP handler does not. Agent can pass both, getting undefined last-write-wins behavior.

6. convertEmails writes to stderr instead of returning errors

pkg/fastmail/mail.go:565-602 — Invalid ReceivedAt timestamps produce stderr warnings invisible to MCP clients and JSON consumers. Violates project convention of using samber/oops.

7. MCP resources return fake success instead of errors

mcp/resources.go:253-271handleContacts and handleCalendarToday return 200-equivalent success with error text when backend isn't configured. Agent parses "error message" as data. Should return actual errors.

8. SearchWithFilter mutates caller's filter map

pkg/fastmail/mail.go — Public API modifies the filter map in-place when resolving mailbox names to IDs. Callers retaining a reference see unexpected mutations.


Suggestions (4)

9. MailService.Move makes redundant Get call

pkg/fastmail/mail.go — Fetches email to validate existence, then discards result (_ = email). The Email/set call would return NotUpdated anyway. Wastes an API round-trip.

10. getAccountID errors lack wrapping context

Multiple files — ~20+ call sites return getAccountID errors unwrapped. When debugging "getting session: connection refused", you can't tell which operation triggered it.

11. internal/jmap/client.go uses fmt.Errorf not samber/oops

Inconsistent with the rest of the codebase at the lowest-level network layer where structured errors matter most.

12. Inconsistent --force behavior

mailbox delete without --force returns nil (exit 0 — success). mail delete without --force returns an error (non-zero exit). Scripts checking $? get wrong signal from mailbox delete.


Test Coverage Gaps

Gap Severity
MCP tool handlers are schema-only tests (no handler invocation) High
CalendarService has zero tests (pkg/fastmail/calendar.go) High
SetKeywords only tests true values, not keyword removal High
MCP mail_flag with contradictory flags untested Medium
VacationService.Set with empty options untested Medium
Search parser: is:read, is:draft, is:answered unhandled Low

Strengths

  • Well-structured three-layer architecture (internal → pkg → cli/mcp)
  • Thorough JMAP builder + response deserialization tests
  • Excellent error-path coverage at service layer (NotCreated/NotUpdated/NotDestroyed)
  • Smart two-stage delete (move to Trash, then permanent if already in Trash)
  • Protected mailbox deletion prevents deleting inbox/drafts/sent/trash/junk
  • Search parser is clean and extensible with solid test coverage

Recommended Action

  1. Fix critical issues first — keyword removal bug (feat(cli): add mail read command (fc-h2at) #1) and delete-on-transient-error (feat(cli): add mail read command (fc-h2at) #2) affect data integrity
  2. Add MCP handler invocation tests — the schema-only tests leave significant integration gaps
  3. Address important validation gaps (Improve auth handling and CardDAV defaults #4, Design: Full API parity for CLI and MCP #5) before MCP goes to production
  4. Consider the suggestions for a follow-up PR

🤖 Generated with Claude Code using pr-review-toolkit

@seanb4t seanb4t mentioned this pull request Feb 8, 2026
5 tasks
Critical fixes (P0):
- SetKeywords sends nil (JSON null) instead of false to remove
  keywords per RFC 8621 §4.1.1
- Delete propagates transient resolveMailbox errors instead of
  silently falling through to permanent destroy
- MCP argument helpers return type-mismatch errors instead of
  silently returning defaults

Important fixes (P2):
- Vacation --enable/--disable enforced as mutually exclusive
- MCP mail_flag validates read/unread and flagged/unflagged
  mutual exclusivity
- MCP resources return actual errors instead of fake success
  with error text in body
- SearchWithFilter deep-copies filter map to avoid caller mutation
- convertEmails returns ([]Email, error) instead of writing
  to stderr
- mailbox delete without --force returns error (matches mail delete)

Suggestions (P3):
- Move removes redundant Get call before Email/set
- internal/jmap/ migrated from fmt.Errorf to samber/oops
- All 22 getAccountID call sites wrapped with operation context

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
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.

1 participant