MCP tools
MailFathom publishes its read side as Model Context Protocol tools over the Streamable HTTP transport. This page records the conventions every tool follows, the contract of the tools that exist, and what a client reads when a call fails.
The endpoint is disabled by default, and enabling it requires stating whether a client presents an API key or nothing at all.
docs/operations/mcp-endpoint.md records that posture and how to enable the endpoint; this page describes the surface it
serves.
Implemented behavior
ModelContextProtocol.AspNetCore 2.0.0 hosts the server. The Mcp project owns the tool descriptors, the conversion of
protocol arguments into the domain identities a use case is expressed in, and the mapping from a use case's result back
onto the published contract. It holds no query, no persistence, and no mail-protocol code: list_accounts calls the
MailAccountDirectoryReader use case and nothing else, list_emails calls the MailboxTimelineReader use case and
nothing else, get_email_content calls the EmailContentReader use case and nothing else, search_emails calls the
MailboxSearchReader use case and nothing else, and ask_mail calls the MailboxQuestionReader use case and nothing
else.
It holds no AI code either, and cannot. The project references Domain and Application and no other MailFathom assembly,
which Mcp.UnitTests asserts against the compiled reference list rather than against a convention — so no tool on this
surface can embed a query, rewrite it, or compose an agent, and a package that would make one able to has to be added and
reviewed before that changes. ask_mail is not an exception to that: it calls an application use case, and everything
between that use case and a provider — the agent, its tool loop, the retrieval it is bound to — lives behind the
IMailQuestionAnswerer port, in the AI project this one cannot see.
The division is deliberate and is what keeps a second entrypoint from bypassing anything. Every filter bound, the page-size range, the account authorization, and the cursor's authenticity belong to the use case, so this boundary re-states no limit of its own; Mailbox queries documents them once, where they are enforced. What the boundary owns is the one thing a use case cannot: turning a caller's text into an account identifier or a folder alias, and refusing text that names neither.
Where a table below says a tool reads every folder of the accounts in scope, "every folder" means every folder the
deployment lets tools read: a folder configuration maps and does not withhold. A folder mapped with
VisibleToTools: false and a folder no mapping names are both outside all four mailbox tools and are never
mentioned by one — a request naming such an alias comes back empty rather than refused, and an email of it reads as not
found. The decision is made once, where the scope a read is expressed in is resolved, and what it carries is the list of
folders that may be read, so it holds for a tool added later without that tool doing anything, and an account whose
configuration maps no folder reads as empty;
folders withheld from tools states what a caller sees and why nothing
says the folder exists.
Three properties hold for every tool and are proven by test rather than asserted here:
- A call reads the local mailbox copy only. Nothing in a tool request reaches a mail server, so a request cannot wait on
IMAP and cannot set the remote
\Seenflag.ask_mailreaches a chat provider, which is a different thing and the one exception to "a call reaches nothing outside this process": it still reads mail from the local copy alone and still speaks to no mail server. - No error and no log line carries a filter value, a mailbox address, a subject, body text, raw MIME, an exception type, a stack trace, or an internal identifier. What a boundary withholds is not lost: the detail is logged on the server, correlated by the trace the request already carries.
- No result carries raw MIME. Message content itself is a result only where the tool exists to return it:
get_email_contentreturns bounded bodies and, for a call that asked to describe the attachments, the files under bounds of their own;search_emailsreturns bounded extracts of a body;list_emailsreturns summaries and no body text at all; andask_mailreturns prose written about mail plus the subjects of the emails it cites. Attachment content reaches exactly one property of one result, and no other tool publishes any. - Every tool bounds how much mail one call can draw out of a mailbox, in the count of items and in their volume alike:
list_emailspages at 100 summaries,search_emailswindows at 50 ranked matches,get_email_contentreads at most 10 emails under a shared character budget and a shared attachment-byte budget, andask_mailpublishes by default at most 20 000 characters of answer citing at most 20 emails, having read at most 20 000 characters of mail to write it. A caller can never raise any of them, and the last set is the operator's to lower or raise inMailAnswering.
One property holds for four of the five and is stated where it stops. list_accounts, list_emails,
get_email_content, and search_emails are advertised by every deployment, because local state is all they need.
ask_mail needs two AI providers an operator configures separately, so it is advertised only while both are configured
and working; the ask_mail section records what decides that and what a call meets when it arrives anyway.
Descriptor conventions
Every tool is declared with the same deliberate metadata, because a client decides whether a tool is safe to call before it calls anything:
| Element | Convention |
|---|---|
name |
Snake case, as the MCP tool ecosystem spells tool names — list_accounts, list_emails, get_email_content, search_emails, ask_mail |
title |
A human-readable label for display — List accounts, List emails, Get email content, Search emails, Ask about mail |
description |
States what the tool reads, that it reads the local copy only, that it changes nothing, and what it bounds |
inputSchema |
Every argument is a top-level property carrying its own description, unit, and absence meaning |
outputSchema |
Generated from the result type, whose properties carry descriptions of their own |
readOnlyHint |
true |
destructiveHint |
false |
idempotentHint |
true |
openWorldHint |
false — every tool is confined to MailFathom-controlled local state |
The four annotations are contract metadata rather than documentation, so Mcp.UnitTests asserts the advertised
tools/list output: the name, the title, the description, every input property, the descriptions on them, the output
schema, and each annotation. A descriptor that drifts fails the build.
Enumerations travel as their names, camel-cased — newestFirst, exceededSizeLimit, lexical — never as ordinals. Each one is a
type this boundary owns rather than the domain enumeration describing the same states, because the member names are the
published wire values: sharing the domain's type would make a rename inside the domain a silent change to the protocol.
Timestamps are ISO 8601 and property names are camel-cased, both of which follow from the single JsonSerializerOptions
every tool registration is given, so the schema that was advertised and the payload that is serialized cannot diverge.
These stay plain C# enumerations rather than the closed enumerations the repository requires of a value that publishes an
identity, and the reason is what that rule is about. A closed enumeration exists where the identity and the member name
are different things — a SASL mechanism spelled PLAIN, a failure numbered 51002 — so the type has to carry the
identity because the name cannot produce it. Here the name is the identity, converted by one shared policy, and a
readonly record struct would add a second serialization path without adding a fact. What the rule protects against is
a rename changing the contract in silence, and that is closed by assertion instead: Mcp.UnitTests pins the advertised
spellings of every enumeration this surface publishes, on the input and the output side alike, so a rename fails the
build. An enumeration added here without that assertion is the actual defect the rule is warning about.
Sizes are published in bytes and named for it — sizeBytes, totalSizeBytes — even though the application and the
stored schema call the same quantity octets. The two words mean one thing here, and the protocol uses the one a client
reads without pausing.
Error reporting
Expected failures are reported as a tool result with isError set, whose text is the one shape every tool uses:
MailFathom error 53001: Mail account 'shared-billing' is not accessible.
The five-digit code is the machine-readable part and is stable: it is what a runbook, an alert, or a log search matches on. The sentence after it is the one the use case wrote, republished rather than restated here, so a client and an operator read the same wording and there is no second text to drift. It names the filter and, where there is one, its limit — never the value that was refused, because a filter value is itself sensitive and a boundary that reflects input back has started returning content. An account identifier is the exception the rule allows: it is MailFathom's own configured name for an account and carries nothing the caller did not already write.
| Code | Meaning | Typical cause |
|---|---|---|
51001 |
A page size outside the range the query serves | A page size of 0 or above 100, refused rather than clamped |
51002 |
A filter carries a value, a count, or a length the query does not accept | An unusable address, a subject fragment over 256 characters or carrying a control character, a received range that ends before it starts, more than 64 accounts or folders, an account identifier or folder alias that is blank, over 256 characters, or carrying a control character, a search query that is blank, over 512 characters, or carrying a control character |
51003 |
A search asked for more ranked results than a search serves | A resultLimit of 0 or above 50, refused rather than clamped |
51004 |
The call named an email with text that is no identifier this system issues | A storedEmailIds element that is blank, not a UUID, or the all-zero UUID, refused before anything is looked up |
51005 |
A content read named no emails, or more than one call serves | A storedEmailIds list that is empty or holds more than 10 entries, refused rather than truncated |
51006 |
A content read named the same email more than once | A storedEmailIds list carrying one identifier twice, in any spelling, refused rather than served twice or collapsed |
52001 |
A continuation cursor is not one this system issued | A truncated, hand-written, or foreign cursor |
52002 |
A continuation cursor was issued for different filters | A cursor reused after a filter or the reading direction changed |
53001 |
The call named a mail account this deployment does not serve | An account identifier nobody configured, or one belonging to someone else — the two are deliberately one answer |
53002 |
The call named an email the local mailbox copy holds no row for | An email never synchronized, one expunged and collected, or one of an account this deployment stopped serving — deliberately one answer |
53003 |
The call named a folder by a role no folder in scope is mapped with | A folders element written role:Junk on a deployment whose accounts map no junk folder; naming the alias, or mapping the role, is what answers it |
54001 |
The call failed for a reason the boundary deliberately does not describe | Anything undiagnosed; the detail is in the server log |
55001 |
The email exists locally and its stored content is missing, damaged, or unreadable | A local copy being repaired; the call is worth repeating once repair has run |
56001 |
This deployment cannot answer questions about mail, either at all or for now | ask_mail called on a server that declared no chat endpoint or embeds no mail, or one whose chat provider is currently refusing; the message says which |
57001 |
Answering would cost more than this deployment allows | ask_mail on a server whose current period has spent its allowance, or a run that reached what one question may spend; the message says which, and only the first becomes answerable by waiting |
Codes 51001 through 53003, 55001, 56001, and 57001 are the use cases' own, allocated in the MCP-boundary category because that is
where they surface, and every one of them is written for a caller to read. That is the whole rule the boundary applies: a
failure whose code belongs to that category is published as it stands, and a failure from any other category — a schema
mismatch, an IMAP authentication refusal, a concurrency conflict — describes MailFathom's internals to whoever asked and
collapses into 54001. Stating the rule as a category rather than as a list of exception types is what stops a failure
added later from reaching a client because nobody remembered to add it to a list.
Two of those codes also appear inside a successful result. get_email_content answers per email, so 53002 and
55001 reach a caller as a failure on the entry they belong to rather than as a failed call — one email this
deployment cannot serve must not discard the content of the nine beside it. The code and the message are the same ones a
failed call would have carried, so a client matches on one set of numbers either way, and isError continues to mean
that the request was refused.
54001 is therefore the only answer an unexpected failure ever produces, and a failure the MCP SDK itself raises —
while binding an argument to the advertised schema, for instance — collapses into it too. Those messages are the SDK's,
not written to the rule above, and may name a rejected value or a CLR type; what a client loses is a description of a
request it can already compare against the published input schema.
Every provider failure ask_mail can end in collapses into 54001 as well, and that is the rule working rather than an
omission. A refused chat credential is 71001, an endpoint that did not answer within its budget is 72001, and a call
that produced no text is 73001 — none of them in the MCP-boundary category, because each describes an endpoint the
caller neither configured nor can reach. What a client is told is that the call failed; what an operator reads, in the
server log and in the health record for the chat role, is which of the three it was.
One call-tool filter wraps the whole surface: it records the tool name, the outcome, the error code where there is one, and the duration of every call, and it logs any undiagnosed exception in full on the server, correlated by the trace the request already carries. Cancellation and protocol-level failures are recorded and then rethrown rather than converted, because a cancelled call is the caller's own doing and a JSON-RPC error has to be reported as one.
The tool name a call arrived with is recorded only when it is spelled the way a MailFathom tool name is; anything else is recorded as one fixed placeholder. On an unknown tool that name is unvalidated caller input on its way into a retained log, and a log is not a place to let a caller write.
The same filter publishes what it measured as instruments, from the one measurement rather than from a second timing path, so how often each tool is called and how long it takes are readable as a rate and a distribution rather than as a pile of records. Those are stricter about the tool name still: a name is used as a dimension only when this surface publishes a tool answering to it, because a dimension a caller can choose is a time series a caller can create. Telemetry names both instruments and every outcome they distinguish.
list_accounts
Returns the mail accounts this deployment serves, with the names a request may use for each and how current the local copy of each of their folders is.
It is the tool a client calls first. Every other tool takes an account filter, and a caller that cannot see the accounts has no way to fill one in — the identifier an operator configured is a key they invented, not something a model can guess. This is also the one tool that publishes the account set rather than using it as a bound; the others answer only about an account the caller already named, and refuse a name they do not serve.
Arguments
None. The tool answers about the deployment rather than about a request, so there is nothing for a caller to get wrong and nothing to bound.
Result
accounts carries one entry per served account, ordered by account identifier, and synchronizationEnabled says whether
the deployment is refreshing its local copy at all.
| Field | Meaning |
|---|---|
accountId |
The configured identifier. It is what every other result reports as accountId, and it is stable across a change of the display name |
displayName |
The readable name the operator gave the account |
synchronizationMode |
polling or push, stating what the operator asked to start the account's next pass |
folders |
One entry per folder this deployment maps and lets tools read, in the same shape folderFreshness takes elsewhere: the alias, when synchronization last committed progress for it, and whether it ever has |
Either name may be used to select the account. The identifier is matched exactly and the display name without regard to case, and configuration refuses a display name that another account's identifier or display name already carries, so a name always names one mailbox. Both spellings resolve to one identity before a query runs, which is why a continuation cursor issued for one stays valid for the other.
synchronizationMode states what was asked for, not what a folder is getting. Whether push is served is decided per
folder against what the mail server advertises and how recent attempts went, which is an observation about a run rather
than a property of the account.
An empty folders list is a statement. It says synchronization has never reached a folder this account lets tools
read — or that it lets them read none — which means its mail may be absent entirely rather than merely out of date, a
distinction an empty listing cannot make for itself. synchronizationEnabled answers the other half: false means the
timestamps below it are as current as any answer will get, because nothing is advancing them.
The list is the same set every other tool reads. It is resolved through the one scope every mailbox read is
expressed in, so a folder mapped with VisibleToTools: false and a folder no mapping names are both absent from it —
naming a folder here is publishing that it exists, which is the whole of what this answer does. The account's junk
folder is present, because withholding that one is about not returning its mail unasked and no mail is returned here.
What it deliberately does not publish
Nothing about how MailFathom reaches a mailbox. The mail server, the port, the IMAP user name, and every secret reference are absent, and the descriptor test asserts their absence rather than trusting it, so a field carrying one cannot arrive unnoticed. The display name is what makes a mailbox recognizable to a caller; the connection detail is the operator's, and an assistant choosing which mailbox to ask about needs none of it.
An account this deployment stopped serving is absent as well, because the read is scoped to the served accounts exactly as every other read is. Local state still holds its folders, and that is not a reason to name it in the one answer that lists what exists.
list_emails
Returns a bounded page of summaries from the local mailbox copy, newest received first by default.
Arguments
Every argument is optional.
| Argument | Type | Meaning |
|---|---|---|
accounts |
string[] |
Accounts to read, each named by its configured account identifier or by the display name it is published under. Omitted reads every account this deployment serves; a name it does not serve is refused with 53001 |
folders |
string[] |
Folders to read, each named by its MailFathom alias such as INBOX or by the role it plays, written role:Junk. Omitted reads every folder of the accounts in scope. Case is normalized, so a repeated spelling names one folder; a role no folder of an account in scope carries is refused with 53003 |
senderAddress |
string |
The whole address the sender must carry, in any case — not a fragment |
recipientAddress |
string |
The whole address a To or Cc recipient must carry. Reply-To is stored and filterable through the use case but not searched here |
subjectFragment |
string |
Text the subject must contain, case-insensitively, up to 256 characters. Wildcards a caller writes match themselves |
receivedOnOrAfter |
date-time |
Inclusive start of the received range |
receivedBefore |
date-time |
Exclusive end, so consecutive ranges built from one instant neither overlap nor leave a gap |
isRemotelySeen |
boolean |
The remote seen state to require. Listing never changes it |
hasAttachments |
boolean |
Whether to match only emails with attachments or only those without |
includeJunkMail |
boolean |
Whether the account's junk folder takes part. Omitted leaves it out, and the result says which of the two answers it gave |
direction |
newestFirst | oldestFirst |
Which end of the timeline to read from |
pageSize |
integer |
1 to 100. Omitted takes the default of 25; a value outside the range is refused rather than clamped |
cursor |
string |
The nextCursor of a previous call, reused with the same filters and direction |
An unbounded date range is deliberately legal; only an unbounded page is not. The page size stops at 100, and the scope stops at 64 accounts and 64 folders counted while the caller's list is read, so a request that repeats one identifier a million times is refused after the value that crosses the limit rather than after the list has been materialized. Every one of those bounds lives in the use case rather than here, which is what makes them hold for an entrypoint added later; naming one served account repeatedly is legal and is read once.
Both lists are converted to domain values at this boundary, and their counts are checked against the query's own limits
before any element is converted — a ceiling applied after the trimming and upper-casing it exists to prevent has
already run over a million-element array is not a ceiling. Text that could name nothing this system issues is then
refused with 51002, and the refusal never repeats the value.
The junk folder is left out unless it is asked for. Mail a filter already set aside is mail written to be read by
somebody who did not ask for it, and a model reasoning over a page of summaries cannot tell it from correspondence, so
the default is the safe one and includeJunkMail is how a caller looking for a message a filter took reaches it. The
answer is reported back in includedJunkMail, and it takes part in the continuation cursor, so a page and its follower
are always one walk. Which folder that is comes from the account's Junk mapping;
spam classification records why the override
can never reveal a folder an operator withheld.
Which account a name refers to is not settled here. An account may be named by its identifier or by its display name, and the two are matched against the served accounts inside the use case, so text naming nothing meets exactly the refusal an account the deployment stopped serving meets. The identifier is matched exactly and the display name without regard to case, and neither is ever matched as a fragment; naming the same account both ways is one account in the resolved scope, so a continuation cursor issued for one spelling stays valid for the other.
The boundary applies one rule to both lists: at most 256 characters and no control characters. It holds whatever each
domain type goes on to check for itself, because a name travels even when it matches nothing — an account this
deployment does not serve is named back in the 53001 refusal a client reads, so an unbounded string carrying newlines
would otherwise be a way to write arbitrary text into that contract and into the log beside it.
Result
emails carries the page, nextCursor reads the following one and is absent on the last page, includedJunkMail
states whether the account's junk folder took part, and folderFreshness states how current the local copy of each
covered folder is.
Each summary carries the stable local identifier a content read is performed by, the account identifier and the display
name it is published under, the folder alias, the message identifier, the subject, the sender address and display name, the To addresses, the sent and received timestamps, the
size in bytes, the attachment summary, the remote flags with the time they were observed, and whether raw content is
available locally. It is the use case's projection published as it stands, not narrowed a second time here — a boundary
that re-decided what a listing may carry would put the privacy rule in two places and leave the one a client reads
untested. Mailbox queries records what it carries and why.
Three parts of it are worth reading before a caller writes against them:
toAddressesand nothing beyond it.CcandReply-Toare searchable but not listed, and recipient display names are not returned at all. A listing exists to let a reader recognize a message; the full participant set belongs to reading one.attachmentsas a group rather than one flag.attachmentCountandinlineResourceCountare separate values beside the total size and the encrypted, unverified-signature, and unexpanded-TNEF markers, because MailFathom's classification rule does not count an embedded logo or a signature part as an attachment. Without the second count a caller could not tell an email carrying a document from one carrying a picture in its signature block.contentAvailabilityrather than a bare flag. An email deliberately stored without its MIME reports why, so a caller sees that a later content read will not succeed instead of discovering it by making the call — and sees whether that is permanent.exceededSizeLimitis an email larger than the configured per-message limit, which every later run will refuse in the same way;awaitingStorageHeadroomis one that arrived while local storage stood at its ceiling, whose content a later synchronization run fetches once there is room.
The remote flags carry wasObserved beside observedAt. Reconciliation has not landed, so every row currently reports
flags nobody has read, and a caller that ignored the distinction would read "no flag set" where the truth is "nobody has
looked".
Freshness
Every result carries one folderFreshness entry per folder in the request's scope, each stating when synchronization last
committed progress for that folder or that it never has. A tool never contacts a mail server, so without it a caller
cannot tell a folder that holds no matching mail from one whose synchronization has been failing for a week.
Per folder rather than aggregated: an entry whose wasSynchronized is false is the folder whose staleness a caller most
needs to see, and collapsing the scope into one timestamp would hide which folder it belonged to. A folder the scope names
but no run has ever reached is reported with no timestamp rather than omitted.
Authorization
The use case resolves the accounts this deployment serves and refuses anything outside them before it reads, so a second
entrypoint cannot reach the query without the same check. Ownership is the configured account list today, read through the
IMailAccountCatalog application port. OAuth 2.1 decides who reaches a tool at all — a token has to name a subject the
deployment authorized — and leaves that port unchanged, so every admitted caller still resolves the same configured
accounts. Deriving the account set from the authenticated identity is the later step, and it replaces the implementation
behind that port rather than introducing authorization for the first time.
A name no served account answers to is refused with 53001 rather than answered with an empty page; "no such account"
and "not yours" are deliberately one answer, and so is "that is not a name of anything". A request that names no account
is narrowed to the served accounts rather than left unrestricted, because removing an account from configuration leaves
its stored rows in place.
That refusal is about a name a caller guessed. Which accounts exist is published deliberately and in one place —
list_accounts — because a caller that cannot see the accounts cannot fill in the filter above, and a
filter nobody can fill in is a filter nobody uses. What stays unpublished either way is everything about how MailFathom
reaches a mailbox.
get_email_content
Returns up to ten emails from the local mailbox copy in one call: for each one its normalized headers, the plain-text body, optionally a sanitized HTML body, every attachment it carries described, and — on request — a short-lived link that fetches each of those attachments. Email content documents the use case behind it — the representations, the sanitization policy, the two bounds, the attachment default, and what a link authorizes — where they are enforced. This section describes the surface.
Arguments
| Argument | Type | Meaning |
|---|---|---|
storedEmailIds |
string[] |
Required. The storedEmailId values a listing or a search returned, 1 to 10 of them, each named at most once. Each is a UUID; anything else is refused with 51004 |
includeSanitizedHtml |
boolean |
Whether to also return the sanitized HTML body of each email. Omitted returns plain text alone |
includeAttachmentDownloadLinks |
boolean |
Whether to mint a link for fetching each attachment, rather than only describing it. Omitted still returns every attachment's file name, media type, and size |
Naming several emails is what the tool exists for: a call that has just listed or searched routinely wants the top few results, and one round trip per email spends the protocol overhead, the rate-limit budget, and a turn of the model's own attention on a read that touches nothing outside the local copy.
The identifiers are the one argument this boundary converts, and it converts them before anything is looked up. Text that
is blank, longer than any UUID form, is not a UUID, or is the all-zero UUID names no email this system could have issued,
so it is refused with 51004 rather than looked up and reported as absent — a typo and a deleted message are different
findings. The count is checked before the first parse and each identifier's length before that identifier's parse,
because a parse scans whatever it is handed and a caller nobody vouches for decides both how long each one is and how
many there are. No refusal repeats the text or says which position carried it, because that is caller input on its way
into a client-readable result and the log line beside it — and the caller holds the list it sent.
Three refusals end the call rather than one entry, because none of them leaves an email to report an outcome against: a
list of more than ten or of none at all is 51005, a repeated identifier is 51006, and text that names no email is
51004. A list is refused rather than truncated or de-duplicated, so a caller never has to compare what came back
against what it asked for to find out what it did not receive.
51004 and 53002 are therefore deliberately distinct, as are 53002 and 55001: the first pair separates "you named
no email" from "that email is not here", and the second separates "not here" from "here and currently unservable". Only
the last is worth repeating.
Result
The result is one emails entry per named email, in the order the call named them. Each entry names the email and
carries exactly one of two things.
| Field | Meaning |
|---|---|
storedEmailId |
The email this entry answers for, present whether or not there was content |
content |
The email as it was read, or null when it could not be served |
failure |
The stable code and message saying why there is no content, or null when there is |
One email this deployment cannot serve therefore costs the caller that email rather than the whole call — which is the
reason the tool answers per email at all. The codes on failure are the same ones a failed call reports, 53002 and
55001, so a client matches on one set of numbers whether the finding was about the request or about one of the emails
in it.
content carries what a read produced.
| Field | Meaning |
|---|---|
accountId, folderAlias |
Where the email is, in MailFathom's own names |
sizeBytes |
The size of the whole email as the mail server reported it |
headers |
Subject, sent and received timestamps, every participant with its header role, and the three threading identifiers |
body |
The representations, or the reason there are none |
attachments |
One entry per attachment, always: normalized file name, media type, and decoded size, plus a short-lived address to fetch it from when the call asked for one |
attachmentCounts |
What the email carries besides its body, returned either way, or null when nothing has ever read its parts |
remoteFlags |
The flags a server last showed, and when they were read |
Seven parts of it are worth reading before a caller writes against them:
- Truncation travels inside each representation, and names the bound.
plainTextandsanitizedHtmleach carrytext,originalCharacterCount, andtruncatedBy, because a body and the fact that it is incomplete are never useful apart: a model handed only the text would summarize a cut message as a whole one.truncatedByisnone,bodyCharacterLimitwhen this email alone is longer than one call returns,readCharacterBudgetwhen the emails named before it had already spent the call's total budget — the one case where naming fewer emails at once returns more — orsensitiveContentScanCeilingwhen a switched-on scanner analyzed as much of the body as it may and the remainder is withheld rather than served unscanned, which no call returns more of. A message can exceed a bound in one representation and not in the other, which is why the metadata is not shared between them. - The content may come back redacted. Where the deployment scans mail for sensitive content, what the message's
author wrote — both body representations, the subject, and the display names of at most the first 40 named
participants of the email — is scanned on every call and returned with each detection replaced by
[redacted:<category>]. The marker means material of that kind stood there and was withheld; it is never text the message contained, and the same call returns the same marker. Every participant past that fortieth name is published with no display name at all rather than with one nothing scanned, so on such a deployment an absentdisplayNamecan mean either that the sender wrote none or that the bound was reached. Addresses, identifiers, sizes, and flags are never redacted, nothing stored is rewritten, and a detector that cannot answer fails the call rather than returning unfiltered content. Sensitive-content scanning is the whole contract. availabilityrather than an empty body.readablemeans the text is the message, and an empty body under it means the message displayed nothing.encryptedNotReadableLocallyis mail this deployment cannot decrypt,notStoredExceededSizeLimitis mail whose bytes the configured size limit deliberately kept out of storage, andnotStoredAwaitingStorageHeadroomis mail that arrived while local storage stood at its ceiling and whose content a later synchronization run fetches once there is room. The last three return an empty text because nothing could be read, and a caller that ignored the distinction would report an empty message — or would give up on the one state where asking again later actually returns the body.attachmentsis always present, and[]means the email carries none. Every read describes what a message carries, because deciding whether a file is worth fetching is reading its name, its type, and its size — a result answering with a count alone would force a second call to learn what the first was about.attachmentCountsanswers how many either way.list_emailsstill counts and never names, deliberately: a listing is a browse over mail the caller has not opened, while a content read has already returned the body in full.- No response carries a file's bytes, and
downloadStatesays what it carries instead.downloadUrlis an absolute address that returns exactly one attachment to an ordinaryGETwith no credential attached, anddownloadExpiresAtis when it stops working; both are absent unlessdownloadStateisissued.notRequestedis the call that did not setincludeAttachmentDownloadLinks, andunavailableis a deployment that declares no public address or no data-encryption key ring — asking again helps with the first and can never help with the second. Nothing reachable from the result can hold a raw byte array, a stream, or a base64 payload at all;Mcp.UnitTestsasserts that structurally over the contract rather than response by response. - A link is a bearer capability, so treat the URL as a secret. Anyone holding it can fetch that file until it
expires, which is ten minutes by default and never more than thirty. Fetch it once, do not log it, and do not store
it anywhere it will outlive the request; after it expires a new
get_email_contentcall is what mints another, and there is no way to extend one. - File names are normalized and may say so. A file name is attacker-controlled text that reaches a model directly, so
what is published is the domain's normalized form: a bare name, never a path or a traversal segment, never a control
character or a bidirectional override, at most 200 characters.
wasFileNameNormalizedstates whether MailFathom had to rewrite what the message wrote, and a part left with nothing usable is reported as unnamed rather than given an invented name.
attachmentCounts is null, rather than zero, for an email whose content the size limit kept out of storage. Nothing
has ever read that message's parts — synchronization recorded what the server's envelope reported, and an envelope does
not describe attachments — so publishing zeros would claim the email carries nothing attached, which no local state
supports.
Reading changes nothing, locally or remotely
The tool holds one use case and that use case holds no mailbox port, so no branch of a content read can open an IMAP
session. A missing local copy is answered with 55001 and a durable repair request the synchronizer acts on later,
never with a fetch; reading mail through MailFathom therefore cannot download it and cannot set the remote \Seen flag.
The remoteFlags a result carries are an observation from the last synchronization run, with wasObserved stating
whether any run has looked.
Authorization is the use case's, as it is for list_emails: an email of an account this deployment does not serve is
reported as 53002, the same answer an email that was never stored gets, so a read cannot be used to discover which
identifiers exist. In a call naming several emails that answer is one entry's, and the emails the deployment does serve
come back beside it.
search_emails
Searches the local mailbox copy for text and returns one bounded window of matches ranked by relevance, each carrying the summary a listing would show, a relevance rank, and bounded extracts of the body around what matched. Email search documents the use case behind it — what is indexed, what the rank means, how the extracts are cut, and why there is no cursor — where those are enforced. This section describes the surface.
Arguments
| Argument | Type | Meaning |
|---|---|---|
queryText |
string |
Required. The text to search for, up to 512 characters. Blank is refused with 51002, because a search with no text is a listing |
accounts |
string[] |
Accounts to search, each named by its configured account identifier or by the display name it is published under. Omitted searches every account this deployment serves; a name it does not serve is refused with 53001 |
folders |
string[] |
Folders to search, each named by its MailFathom alias such as INBOX or by the role it plays, written role:Junk. Omitted searches every folder of the accounts in scope; a role no folder of an account in scope carries is refused with 53003 |
senderAddress |
string |
The whole address the sender must carry, in any case — not a fragment |
recipientAddress |
string |
The whole address a To or Cc recipient must carry |
subjectFragment |
string |
Text the subject must contain, case-insensitively, up to 256 characters |
receivedOnOrAfter |
date-time |
Inclusive start of the received range |
receivedBefore |
date-time |
Exclusive end of the received range |
isRemotelySeen |
boolean |
The remote seen state to require. Searching never changes it |
hasAttachments |
boolean |
Whether to match only emails with attachments or only those without |
includeJunkMail |
boolean |
Whether the account's junk folder takes part. Omitted leaves it out, and the result says which of the two answers it gave |
resultLimit |
integer |
1 to 50. Omitted takes the default of 20; a value outside the range is refused with 51003 rather than clamped |
The structured filters are list_emails' own and mean exactly the same things, because both read models apply one
validated selection; the identifier lists are converted and bounded by the same code, so the 51002 refusals described
above hold here word for word. subjectFragment and queryText are unrelated and the argument descriptions say so: the
fragment narrows which emails are eligible, and the query text is what the eligible ones are matched and ranked against.
There is deliberately no cursor, no offset, and no argument that widens how much of a message an extract may show. The
first is unsound over a relevance order that moves as mail is indexed; the second would let a caller lift a privacy
control that belongs to the deployment. Mcp.UnitTests asserts the absence of the second as part of the descriptor,
because an argument added later would be the one thing that quietly changes what a search can draw out of a mailbox.
Result
| Field | Meaning |
|---|---|
matches |
The matched emails, most relevant first, ties broken by the newest received. Empty when nothing matched |
retrievalMode |
How this call's results were ranked — lexical or hybrid |
semanticSearch |
What this server can do with embeddings — inactive, available, or degraded |
includedJunkMail |
Whether the account's junk folder took part in this search |
folderFreshness |
How current the local copy of each covered folder is, exactly as a listing reports it |
Each match carries summary, which is the same shape list_emails publishes and is documented above, together with
relevanceRank and snippets.
relevanceRankis comparable within one response and nowhere else. It is computed for the query that produced it, so storing it or comparing it with a rank from another call compares two different scales — and the scale itself depends onretrievalMode, a full-text rank underlexicaland a fused rank score underhybrid.snippetsare message text and are returned as data. Each matched run is wrapped in**and nothing else is added: no interpretation, no summary, and no formatting that would let mail somebody else wrote read as instruction or as one of MailFathom's own fields. A caller passing them to a model treats them as untrusted input, as it would any other message content.- A match can carry no snippets at all. An email that matched on its subject or a participant address carries none, because the summary publishes both whole, and an email with no indexed body text — encrypted mail, or mail whose content lives inside an attachment — carries none either.
The retrieval mode is read per response, not per server
retrievalMode names how the call in front of you was ranked, and both values are advertised by every server.
lexicalmeans the words a query contains were matched against the words the mail is written in, so a query term that appears nowhere in a message did not find it however close its meaning.hybridmeans that ranking was combined with a search by embedding similarity, so a message can appear without carrying the query's words. Email search records what the combination does and what it does not promise.
It is a property of the response rather than of the deployment because the answer can differ between two calls to one
server: an instance configured for hybrid retrieval reports lexical while its embedding provider is unreachable, and
while it has activated no profile. Reading a server's configuration instead would leave a client concluding the wrong
thing about why a message it expected is missing.
Neither mode reaches a chat model, rewrites the query, or expands it; under hybrid the query is embedded and compared,
never interpreted. Words that appear only inside an attachment payload are not searchable under either mode, which is a
limit of what is indexed rather than of this tool.
semanticSearch says why a lexical answer was lexical
retrievalMode says what happened to this call and semanticSearch says what the server is able to do, which is the
half a client cannot infer. A server that deliberately does not embed and a server whose embedding credential expired an
hour ago both answer lexical, and only the second is returning less than its operator intends.
inactive— this server does not embed mail, solexicalis the intended and only mode. Nothing is wrong and nothing is going to change on its own.available— this server embeds mail and its provider is answering. An individual call can still reportlexical, and it then reportsdegradedbeside it, because the call that failed is the freshest evidence about the provider there is.degraded— this server embeds mail but currently cannot place a query in that vector space: a refused credential, an unreachable endpoint chain, or a configured model that is not the one the active profile records. The results are narrower than the server intends.
degraded is not an error and is not caused by the request, so retrying buys nothing. A client that surfaces it
tells the user the results may be incomplete and leaves the fix — a credential, an endpoint, a model declaration — with
the server's operator. Recovery is automatic and needs no restart: the next embedding call that succeeds restores the
state, and the search after it is hybrid again. Email search
records what each state means on the server side and how a call arrives at one.
What the boundary bounds, and why it bounds it again
Every limit a caller can name belongs to the use case, as it does for list_emails: the query length, the filter
bounds, the result-count range, and the account authorization are checked there and this boundary re-states none of
them.
Two bounds it does apply again, on what it is about to publish: at most the configured number of extracts per email, and at most the greatest number of ranked results a search serves. Neither is request input and neither is a caller's to widen — they are the control on how much mail content one call draws out of a mailbox, and this is the last place that content passes before it reaches a model. The read model already applies them against what PostgreSQL returned for the same reason, and a control a defective adapter could widen is not one.
The character bound on a single extract is applied here as a ceiling rather than reproduced exactly. The use case counts
the characters of the message and deliberately does not count the highlight markers, which are MailFathom's own; once those
markers are ** they are indistinguishable from a message that writes ** itself, so this boundary cannot repeat that
count and does not pretend to. It cuts at a ceiling derived from that bound instead — three times it, plus the one
character the use case's own truncation mark contributes — which is above every extract the use case can produce, since
a marked run needs a character of its own and a character separating it from the next and markup can therefore at most
double an extract, and far below a body.
Empty results, and what a search does not reveal
A query that matches nothing returns an empty matches array with the same retrievalMode and the same
folderFreshness a window that matched would carry. It is an ordinary response rather than an error, so a search cannot
be used to establish that an account or a folder holds mail the caller was not already entitled to see. An account this
deployment does not serve is still refused with 53001 before anything is read, for the reason a listing refuses one:
an empty result would confirm the identifier.
What that guarantee covers is worth stating exactly, because it is narrower than "a search reveals nothing". It covers
everything outside the served scope: the account authorization is resolved before any read, and a folder alias is only
ever matched within the accounts already resolved, so no query — matching or empty — reports on an account this
deployment does not serve for this caller. It deliberately does not hide the folder names inside a served account.
folderFreshness publishes one entry per folder in scope, and a request that names no folder therefore lists every
folder those accounts have; that is the field doing its job rather than leaking, since a caller who cannot see which
folders are stale cannot tell an empty result from an unsynchronized one. A caller who guesses an alias and receives no
freshness entry has learned that no such folder exists in their own mailbox, which the unscoped call would have told
them outright.
The query text is never logged and no failure message repeats it. What somebody is searching their own mailbox for is personal data of a particularly revealing kind, and the refusals this tool raises name the filter and its limit rather than the value.
ask_mail
Answers a question about the local mailbox copy and names the emails the answer was drawn from. A chat model conducts the run and looks up mail as it decides it needs context; Mail answering records what that run is, what it may reach, and how much of it leaves the process. This section is the protocol surface over it.
It is the one tool that spends money on a call and the one that takes seconds rather than milliseconds. The description says so, because the choice between asking and searching is a model's to make: ask when the answer spans several messages, search when the messages themselves are what is wanted.
Arguments
| Argument | Type | Meaning |
|---|---|---|
question |
string |
Required. The question to answer, up to 1000 characters. It is not a search query: its words are not matched against the mail, and the lookups are written by the model |
accounts |
string[] |
Accounts the answer may be drawn from, each named by its configured account identifier or by the display name it is published under. Omitted draws on every account this deployment serves; a name it does not serve is refused with 53001 |
folders |
string[] |
Folders the answer may be drawn from, each named by its MailFathom alias such as INBOX or by the role it plays, written role:Junk. Omitted draws on every folder of the accounts in scope. Case is normalized, so a repeated spelling names one folder; a role no folder of an account in scope carries is refused with 53003 |
There is no structured filter beside the scope, and that is a decision rather than an omission. A sender or a date range
supplied here would narrow every lookup the model makes without the model knowing why its searches were returning
nothing, and it would be answering a question nobody asked while reporting it as the answer to the one they did. The
model narrows its own lookups instead, with the same filters search_emails publishes: Mail answering § What one
lookup may ask for records which, and which two are withheld from it.
The scope is the caller's authorization expressed as data, and it is the one part of the run the model cannot reach. It
is resolved before the run starts and bound into it, and the tool the model is offered takes no account and no folder
argument at all, so a run that has been talked into asking about another account has the caller's own scope searched for
those words. The same resolution runs again on every lookup, underneath, because the retrieval is the search
search_emails answers from.
The two identifier lists are converted and bounded exactly as list_emails converts them, by the same code and with the
same refusals; the section above records that rule once.
Result
| Field | Meaning |
|---|---|
answer |
The answer, in prose |
citations |
The emails the run retrieved, one entry per email, in the order it first reached each |
answerTruncated |
Whether the answer was cut to the length one response carries |
citationsTruncated |
Whether the run reached more emails than citations names |
retrievalTruncated |
Whether the run hit this deployment's ceiling on how much mail one question may read |
Each citation carries the storedEmailId a content read is performed by, the account identifier and the display name it
is published under, the folder alias, the subject, and the received time. It deliberately carries no extract: the passage
the run retrieved has already reached a model, and returning it here would put mail content into a response whose purpose
is an answer. The subject and the received time
are what let a reader recognize a message before fetching it.
The citations are what the run retrieved, not what the model demonstrably used. Nothing outside the model knows which of them it drew on, so publishing the narrower set would state something this system cannot observe. What they are good for is the thing that makes an answer usable rather than merely fluent: every claim can be checked by reading the messages the run had in front of it.
An empty citations array is an ordinary answer. The mailbox was searched and held nothing about the question, and the
answer then says so — which is a real answer rather than a failure.
The three truncation flags are part of the contract rather than diagnostics. A cut this surface made and did not report would leave a shortened answer indistinguishable from a complete one, a claim traced to a message the response no longer names cannot be checked, and a run that was stopped from reading further answered a narrower reading of the mailbox than the question asked for. All three cut rather than refuse, which is the opposite of how a request bound behaves: a request larger than a limit is the caller's to correct, while an answer larger than one has already been generated and paid for.
retrievalTruncated is the one worth acting on differently. It does not mean the answer is wrong — it is complete for
what the run did read — but the mailbox holds matching messages the model was never shown, so asking a narrower question
reads a different part of the mailbox rather than more of it. Mail answering § What one question may
spend records the ceiling behind it and what the model is told when it
is reached.
The answer and the cited subjects are untrusted text. The answer is model output written from extracts of mail somebody else wrote, and a subject is that person's own words. A client that passes either into another model treats both as data, as it would any other message content. What a message written to manipulate the run cannot do to it — and what it still can do to the words of an answer — is Mail answering § What is actually tried, and what it settles.
When the tool is advertised
ask_mail appears in tools/list only while this deployment can answer a question, and is absent otherwise. A client
that can see a tool will call it, and a tool that exists only to answer "not configured" costs a round trip to learn
something the tool list could have said.
Two conditions decide it, and an operator configures them separately:
- An embedding profile is active and a query can be placed in its space. This is the same reading
search_emailspublishes assemanticSearch, so a server answeringinactiveordegradedthere advertises noask_mail. - The chat endpoint is declared and is not currently refusing. A deployment that declared none never advertises the tool. One whose endpoint refused within the last minute withholds it, and offers it again after that so a repaired credential is discovered without a restart.
That recheck exists because nothing else calls the chat endpoint. The embedding provider is called by synchronization and by the search path, so its health record renews itself; with the second retrieval pass off, answering a question is the only thing that reaches the chat endpoint at all, and a deployment that withheld the tool for as long as the last failure was on record would withhold it forever.
The decision is made per listing rather than at startup, so the transition is observable and needs no restart in either
direction. What it is not is authorization: a client may call a tool it was never offered, and the use case behind this
one refuses a question the same way whether or not the caller ever read a list. That refusal is 56001, and its message
says whether this deployment answers no questions at all or answers them and currently cannot.
Pending
The five read-only tools of this release are complete. list_accounts publishes the accounts a caller may name,
list_emails, get_email_content, and search_emails read the PostgreSQL read models, the lexical index, and the
content store that landed with their use cases, and ask_mail answers over the retrieval and the agent composition
above them, under the ceilings an operator sets on what one question and one period may spend. What is still pending on this surface is the run trace an operator reads afterwards,
its own change, and any tool that writes: SMTP delivery is deliberately absent from the first public tool set.