Endpoint configuration
Every key deciding where each of the three surfaces is served, what a caller has to present to reach one, and how much traffic and how long a request each will take. What an admitted caller may then do is a grant rather than a listener setting, and what a credential may do is the whole of it. The tables read as the configuration reference says they do, and that page is the map to the rest of the sections.
Where each surface is served
Every socket this process opens is named by the section of the surface that owns it, and by nothing else. McpEndpoint
and AdminEndpoint each state a BindAddress, a Port, and a Transport; HealthEndpoints states the same three
under a smaller set of TLS settings. Read those three sections and you have read every listener the process binds.
The host's own ways of naming a listener are refused at startup. ASPNETCORE_URLS, ASPNETCORE_HTTP_PORTS,
ASPNETCORE_HTTPS_PORTS, --urls, and any endpoint under Kestrel:Endpoints each fail the process with a message
naming the setting that replaces them. They are refused rather than ignored because ignoring them is silent: Kestrel
drops the URL-shaped addresses as soon as a listener is bound in code — which every surface here does — and binds a
configured endpoint beside them on a socket no section describes, no credential guards, and no isolation middleware was
composed for. An operator who states a port deserves to be told it moved, not to find the surface answering somewhere
else.
A deployment that enables no surface at all is refused for the same reason. Kestrel answers zero listeners by binding its own default address, and a development certificate on the machine would add a TLS one beside it, so the process would hold a socket nothing configured.
Transport
McpEndpoint:Transport and AdminEndpoint:Transport decide what the surface's clear-text socket does. The HTTPS half
is the profiles under Https:Endpoints, each with its own domain, certificate, TLS floor, and HTTP versions.
| Value | The socket at BindAddress:Port |
Https:Endpoints |
|---|---|---|
Http |
Serves the routes | Must be empty |
HttpAndHttps |
Redirects to the profiles, or serves the routes when Https:Redirect:Enabled is false |
Bind |
HttpsOnly |
Not opened at all | Bind |
Http is the default, so adopting a release costs no certificate work, and it is the right posture behind a
TLS-terminating reverse proxy and wrong anywhere else — startup warns about it either way, because only an operator
knows which they have.
HttpsOnly is the posture that leaves nothing reachable in clear text. HttpAndHttps keeps the clear-text socket, and
the redirect is on unless a deployment turns it off, so enabling TLS does not read as an outage to a client nobody has
repointed yet; turning the redirect off is what makes that socket serve the routes, which is the deliberate
both-schemes posture rather than the migration one. HealthEndpoints:Transport takes the same three values, and
because the probes carry one certificate rather than profiles, its HttpAndHttps needs a second port of its own in
HealthEndpoints:HttpsPort.
Sharing a socket
Two surfaces, or all three, may name one port. That is the posture a single-node deployment behind one ingress wants —
one socket to publish and one backend to route — and it is why both request-serving surfaces default to 8080 for clear
text and 8443 for a profile. The port is bound once and serves each surface's own paths; which paths a request may ask
for is decided from the port it arrived on, so a surface that is not on that port is still refused there with a 404.
What sharing costs is exposure. The probes answer without a credential and the administrative surface is a different authority from the mailbox, so putting either on the endpoint's port publishes it wherever that port is published. Keep them apart when that matters; the ports exist so the decision is yours.
Which settings a shared socket couples
Sharing is per socket, not per surface. Each surface declares its clear-text socket (BindAddress + Port) and one
socket per HTTPS profile (Https:Endpoints:<n>:BindAddress + :Port) separately, so two surfaces may share the
clear-text one and keep TLS sockets of their own. Every rule below applies to one socket at a time, and each failure
names both sections.
| Setting | Coupled on | Why it cannot differ |
|---|---|---|
Transport — whether this socket carries TLS |
Every shared socket | One socket serves one scheme |
Https:Redirect:Enabled |
A shared clear-text socket | The socket either redirects or serves the routes; it cannot do both |
| The domain a redirect resolves | A shared redirecting socket | The client sent one host name, so two answers to it would be settled by composition order |
| Profiles by server name vs one certificate | A shared TLS socket | A socket answers a handshake one way; the probes present one certificate and the endpoints select by name |
ClientCertificateProfiles — configured or not |
A shared TLS socket | Whether a certificate is asked for is settled while the connection is established |
Https:Endpoints:<n>:Domain — uniqueness |
A shared TLS socket | One name served by two surfaces would leave composition order deciding which the client reached |
Https:Endpoints:<n>:HttpProtocols |
A shared TLS socket | ALPN offers what the listener was bound with, which is before any server name has been read |
BindAddress — a wildcard beside a specific address |
The same port | The operating system grants only one of those two sockets |
What stays each surface's own, on a shared socket as much as on a separate one:
- The HTTPS ports. Two surfaces sharing a clear-text socket may redirect to profiles on ports of their own, because a
redirect resolves the name the client asked for:
mail.example.testgoes to the MCP endpoint's port andadmin.example.testto the administrative one, from the same8080. Only publishing one name at two ports is refused. MinimumTlsVersion, per profile. The TLS floor is settled per connection, after the server name is known, so profiles on one socket may each keep their own.- Different domains on one TLS socket, which is what sharing one is for.
- Everything decided per request rather than per socket:
Authenticationand every method it carries,Cors,RateLimiting, origin validation, and the route prefix. An API key provisioned for an agent still authenticates nothing under/api/admin, whichever port both are reached on.
Two specific addresses on one port are two sockets and are accepted; none of the rules above applies between them.
ReverseProxy
Which peers this process accepts a public scheme and host from, when something in front of it terminates TLS. One section for the whole process rather than one per surface: it runs at the front of the one request pipeline every listener shares, so a proxy named here is trusted on each of them. Behind a TLS-terminating reverse proxy is the page.
X-Forwarded-Proto and X-Forwarded-Host are always read; there is no key that switches that off. What the section
carries is who they are believed from, and an unconfigured section believes every peer.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
ReverseProxy:TrustedProxies |
string list | empty, which trusts 0.0.0.0/0 and ::/0 |
Each entry an IP address or a CIDR network whose host bits are clear — not a DNS name. What is named replaces the default rather than adding to it, and the framework's loopback default is cleared rather than inherited. Left empty, or written as 0.0.0.0/0 and ::/0, it trusts every peer and so disables the refusal of an OAuth token that arrived without TLS — see what the default costs |
restart |
ReverseProxy:MaximumForwardedHops |
int | 1 |
At least 1; how far right-to-left through each header a value is believed | restart |
X-Forwarded-For is never read, so the peer MailFathom observes stays the one that opened the connection, and the
configured OAuth Resource stays a value you wrote rather than anything derived from a header.
ConnectionLimits
How many connections this process accepts at once, across every listener it opens. The other section that belongs to
the whole process rather than to a surface, and for a stronger reason than ReverseProxy: a connection is accepted
before any routing has decided which endpoint it was for, so there is no per-surface form of this question to ask.
Read it as the process's ceiling, never as the sum of what the endpoints permit. McpEndpoint:RateLimiting:MaxConcurrentRequests
bounds what one surface serves at once; this bounds what the machine accepts at all, the probe listener included. The
two numbers are deliberately far apart, because a connection is not a request — a client holds one open across several,
and an idle one survives until the keep-alive timeout — so a ceiling near the request limit would refuse ordinary
clients long before it refused a flood.
It exists because every other limit is reached too late to see this. The rate limiter partitions a request that already
has an HttpContext, and what a connection flood spends before that point — the accept, the TLS handshake, and on the
MCP surface the client certificate's chain building — is the most expensive per-connection work the process does.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
ConnectionLimits:Enabled |
bool | true |
Turning it off restores the framework's own default, which accepts connections until the operating system stops supplying them; it costs a startup warning | restart |
ConnectionLimits:MaxConcurrentConnections |
int | 1000 |
1 – 100000; process-wide, across every listener | restart |
Like every limit here it is counted in this process alone, so a deployment running several instances enforces it once per instance rather than once in total, and none of it is protection against a distributed flood.
McpEndpoint
Whether the protocol surface is served and what a client must present. The whole section is restart — it decides
routing and listeners — while key and certificate material is read per request or per handshake. Where it is served is
its own BindAddress, Port, and Transport.
The MCP endpoint is the page, section by section.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
McpEndpoint:Enabled |
bool | false |
— | restart |
McpEndpoint:BindAddress |
string | 0.0.0.0 |
An IP address; binds the clear-text socket, which HttpsOnly does not open |
restart |
McpEndpoint:Port |
int | 8080 |
1–65535. The administrative endpoint's default as well — see sharing a socket | restart |
McpEndpoint:Transport |
enum | Http |
Http, HttpAndHttps, HttpsOnly — see Transport |
restart |
McpEndpoint:Authentication |
list of credentials | empty | One entry per accepted credential; empty warns at startup. A value written here rather than a list fails startup | restart |
McpEndpoint:PublishedToolCategories |
string list | absent = every category | mailbox, flags, sending, drafts, answering, contacts, in any case; a name nothing publishes fails startup naming the value and listing what is accepted. Only narrows — see what the endpoint publishes |
restart |
What the endpoint publishes
The coarse answer to what this instance offers, beside the per-capability switches that decide what it can do. Every
category is published unless the list names some, so a deployment written before the setting existed keeps the surface
it had, and an endpoint that should offer nothing at all is one with Enabled set to false.
A category only ever takes away. Naming sending does not enable sending — the account's own switches decide that
— and no category widens a grant or reveals a tool a caller was not offered. A tool appears when its capability is
available, its category is published, and the caller's grant reaches it.
A connecting client may narrow further for its own session, by naming categories in the MailFathom-Tool-Categories
header. The effective set is the intersection with the list above, so a category this list excludes is never published
because a client asked for it. Tool categories states which tools each
category carries and what the header does with a value it cannot read.
The accepted credentials — McpEndpoint:Authentication:<n>
Each entry carries the block of whichever method judges it, and the block's presence is what selects that method — there is no separate setting naming it. As many entries may state any method as a deployment needs, and one entry may carry several blocks; an entry carrying none fails startup, named by its position. A grant written on an entry adds five more refusals and makes one combination of blocks impossible — writing a grant is where they are stated. Both endpoints take the same entries; the administrative one adds a single rule, stated with it below.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
…:<n>:ApiKey |
secret block | — | One named secret with its own Lifetime; a second key is a second entry |
restart; material per request |
…:<n>:PublicKey |
secret block | — | One named secret with its own Lifetime, resolving to one client's PEM public key. Startup refuses material that is not one, an RSA key below 2048 bits, and — explicitly — material carrying a private key |
restart; material per request |
…:<n>:Permissions |
string list | absent = everything this surface publishes | The permissions every credential this entry admits may hold; an empty list grants nothing. A value writing * as a whole segment grants every published name the pattern reaches on this surface, the wildcard standing for one or more segments at whatever position it is written, resolved against the published set on every start. A name nothing publishes, a name belonging to the other surface, a repeated name, a pattern matching nothing or matching only the other surface, a pattern overlapping what the grant already carries, and a bare * or mailfathom.* each fail startup naming the entry's index |
restart |
…:<n>:PermissionsFromTokenScopes |
bool | false |
Narrows the list above by each token's own scopes instead of granting all of it. Refused on an entry that also carries ApiKey or PublicKey, neither of which can carry a scope |
restart |
…:<n>:OAuth:Resource |
string | — | Required; the canonical https URL clients reach this endpoint at — behind a proxy, the proxy's public URL. Every OAuth entry names the same one, because the endpoint publishes one metadata document at an address derived from it |
restart |
…:<n>:OAuth:RequiredScopes |
string list | empty | Scopes a token from this entry's servers must carry; empty accepts any token they issued for this resource. A permission name is refused here, because requiring one would close the door on a caller the deployment meant to serve less, and so is a pattern covering permissions, because a scope is compared byte for byte and no authorization server can mint one | restart |
…:<n>:OAuth:AdvertisedScopes |
string list | empty | Scopes published in scopes_supported for a client to ask for and checked on no token — offline_access is what a client needs to be issued a refresh token. Every required scope is published regardless, so a value repeating one is refused, as is one that is not a scope token, as is a permission name — the grant that reads one advertises it already — and as is a pattern covering permissions, since the grant publishes the names one resolves to |
restart |
…:<n>:OAuth:AuthorizationServers:<m>:Name |
string | — | Required; the identity diagnostics use, and unique across every entry because it composes the scheme its validator registers under | restart |
…:<n>:OAuth:AuthorizationServers:<m>:Issuer |
string | — | Required; a well-formed https issuer, compared against iss exactly, and unique across every entry |
restart |
…:<n>:OAuth:AuthorizationServers:<m>:MetadataAddress |
string | unset | An absolute https URL on the issuer's own host; overrides issuer-derived discovery |
restart |
…:<n>:OAuth:AuthorizationServers:<m>:AuthorizedSubjects |
string list | — | At least one; a token whose sub is not listed is refused, so every user the server can sign in does not automatically read this mailbox |
restart |
MailFathom is a protected resource only; an external authorization server signs users in.
OAuth records what a token must prove and
scopes you advertise but do not require why the published list
is longer than the checked one,
API keys what a key is compared against, and
Key pairs what a client signs and what the deployment verifies — including the audience,
expiry, and replay identifier an assertion carries, none of which is a setting.
Browser origins — McpEndpoint:Cors
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
…:AllowedOrigins |
string list | absent = every origin | * for every origin, a list for exactly those, an empty list for none |
restart |
The default is deliberately the permissive one — an Origin header only exists in browsers, and a native client is
unaffected — but a deployment reachable from a browser should narrow it.
CORS and the Origin header explains what the check does and does not
protect.
TLS termination — McpEndpoint:Https:Endpoints:<n>
Read under the two Transport modes that terminate TLS and refused under the one that does not. Configuring any
profile takes over the host's listeners: only the profiles' sockets are opened.
HTTPS and your own domain is the page.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
…:Name |
string | — | Required; unique | restart |
…:Domain |
string | — | Required; the DNS name the certificate is proven to cover | restart |
…:BindAddress |
string | 0.0.0.0 |
An IP address | restart |
…:Port |
int | 8443 |
1 – 65535 | restart |
…:MinimumTlsVersion |
enum | Tls12 |
Tls12, Tls13 |
restart |
…:HttpProtocols |
enum list | Http1, Http2 |
Http1, Http2, Http3; selecting Http3 where the platform provides no QUIC fails startup rather than falling back |
restart |
…:ServerCertificate |
certificate block | — | Required; see below | restart; renewal behind unchanged references — see secret rotation |
A certificate block names either Bundle (one PKCS#12 secret block, optionally with a nested Password) or the pair
CertificateChain and PrivateKey (PEM, as two secret blocks). Startup proves the material loads, covers the stated
domain, and is not expired — before any listener opens.
Clear-text redirect — McpEndpoint:Https:Redirect
What the surface's clear-text socket does while the profiles above are served. On, it answers every request with a 308
to the address those profiles are at, so enabling TLS does not read as an outage to a client nobody repointed yet; it
then maps no route and runs no credential check. Off, the same socket serves the routes in clear text.
The socket is McpEndpoint:BindAddress and McpEndpoint:Port — there is no address here to state again. The section is
meaningful under Transport: HttpAndHttps alone, which is the one mode with both a clear-text socket and somewhere to
send what arrives on it; writing it under either other mode fails startup.
Redirecting a client still pointed at http:// records what
a redirect does and does not protect.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
…:Enabled |
bool | true |
Refused unless Transport is HttpAndHttps |
restart |
Client certificates — McpEndpoint:ClientCertificateProfiles:<n>
Mutual TLS, judged per configured client application. A certificate exists only on a TLS connection this process
terminates — over the HTTPS profiles above, or over a listener the deployment configured with TLS otherwise — so a
plain-HTTP deployment presents none, which a Required profile refuses.
Client certificates records how a presented certificate is judged.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
…:Name |
string | — | Required; unique | restart |
…:Requirement |
enum | — | Optional, Required; required to be stated |
restart |
…:TrustAnchors |
list of secret blocks | — | At least one; the authorities the client's chain must anchor in | restart; material per handshake |
…:SubjectAlternativeNames |
string list | — | At least one; a DNS name the certificate must carry | restart |
Rate limiting — McpEndpoint:RateLimiting and AdminEndpoint:RateLimiting
One of the two endpoint subsections where every value has a product default — request timeout is the other — so an enabled endpoint is bounded whether or not anyone wrote a number. Both endpoints carry it, with the same keys, defaults, and validation, and configure it independently: neither one's traffic reaches the other's limits. Rate limiting records whose capacity a request spends, and administering a deployment records the one behavioural difference on the administrative endpoint — its burst is the endpoint's rather than one caller's, because that surface judges a credential behind the limiter.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
…:Enabled |
bool | true |
Turning it off costs a startup warning | restart |
…:MaxConcurrentRequests |
int | 20 |
1 – 1000; process-wide, per endpoint | restart |
…:ConcurrencyQueueLimit |
int | 0 |
0 – 1000; 0 refuses instead of queueing |
restart |
…:TokenCapacity |
int | 60 |
1 – 1000000; the largest burst one caller may spend | restart |
…:TokensPerReplenishmentPeriod |
int | 60 |
1 – 1000000, and not above TokenCapacity |
restart |
…:ReplenishmentPeriod |
TimeSpan | 00:01:00 |
1 s – 1 h | restart |
…:RequestQueueLimit |
int | 0 |
0 – 1000, and below MaxConcurrentRequests |
restart |
Request timeout — McpEndpoint:RequestTimeout and AdminEndpoint:RequestTimeout
How long one request may run before the endpoint abandons it, answering 504 and releasing the concurrency permit it
held. Defaulted throughout like the rate limits, carried by both endpoints with the same keys, and configured
independently of them — because how much traffic is admitted and how long an admitted request may hold what it was
admitted with are different questions, and a deployment may already have one answered in front of the process without
the other.
Without it, MaxConcurrentRequests bounds how many requests run at once and nothing bounds how long any of them lasts,
so twenty slow requests take a surface out of service without exceeding any rate.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
…:Enabled |
bool | true |
Turning it off costs a startup warning | restart |
…:Duration |
TimeSpan | 00:10:00 |
1 s – 1 h | restart |
The default is a bound on a hang rather than a promise that no legitimate request is abandoned. An ask_mail run is
a conversation whose length the model decides, bounded by MailAnswering:MaxProviderCallsPerRun at eight calls, each an
AiProviderInvocation whose own TotalTimeout defaults to five minutes — so a ceiling enclosing the maximum would sit
at forty minutes, which is not a request ceiling and would let one stalled run hold a concurrency permit
that long. Ten minutes clears an ordinary answering run by a wide margin and abandons one that walks its whole provider
budget, which is the trade taken. Raise it alongside MailAnswering:MaxProviderCallsPerRun if you raise that. A
deployment serving no AI-backed tool narrows it instead: every other MCP tool answers from the local mailbox copy with a
bounded query, so a minute is generous there. AdminEndpoint reaches no provider at all, which makes it the endpoint to
narrow without having to ask what a tool call needs.
The ceiling is applied ahead of the rate limiter, so time a request spends waiting for a limiter lease is inside it.
That wait is nothing under the default queue limits of 0, and is the whole point of the ordering once a queue is
configured: a request queued for its caller's tokens already holds a concurrency permit.
AdminEndpoint
Whether the administrative surface the mfctl command reaches is served, and what a client must present. Its own
listener, its own credentials, and its own authorization servers: a key configured under McpEndpoint authenticates
nothing here, and the reverse holds. The whole section is restart, while key and certificate material is read per
request or per handshake. Administering a deployment is the page.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
AdminEndpoint:Enabled |
bool | false |
— | restart |
AdminEndpoint:BindAddress |
string | 0.0.0.0 |
An IP address; binds the clear-text socket, which HttpsOnly does not open |
restart |
AdminEndpoint:Port |
int | 8080 |
1–65535. The MCP endpoint's default as well, so enabling both without stating a port publishes one shared socket — see sharing a socket | restart |
AdminEndpoint:Transport |
enum | Http |
Http, HttpAndHttps, HttpsOnly — the same setting the MCP endpoint carries, read the same way |
restart |
AdminEndpoint:Authentication |
list of credentials | empty | Same shape and rules as McpEndpoint:Authentication:<n>, with three additions: every OAuth block's Resource must end in /api/admin, because that is where these routes answer and what mfctl appends to find the metadata document; a client assertion presented here names the audience urn:mailfathom:admin rather than urn:mailfathom:mcp; and Permissions draws from the administrative half of the published set, so a name or a pattern reaching only the mail half fails startup here |
restart; material per request |
AdminEndpoint:Https:Endpoints:<n> |
list of profiles | empty | Same shape and rules as McpEndpoint:Https:Endpoints:<n>, read under the two Transport modes that terminate TLS |
restart; material per handshake |
AdminEndpoint:Https:Redirect |
block | on | Same shape and rules as McpEndpoint:Https:Redirect; its socket is this surface's own BindAddress and Port, so terminating TLS on both surfaces opens two clear-text ports that do not collide |
restart |
AdminEndpoint:RateLimiting |
block | bounded | Same shape, defaults, and rules as McpEndpoint:RateLimiting above; applied whether or not it is written |
restart |
AdminEndpoint:RequestTimeout |
block | bounded | Same shape, defaults, and rules as McpEndpoint:RequestTimeout above; applied whether or not it is written. This surface reaches no AI provider, so it is the one the default can be narrowed on freely |
restart |
The routes are served beneath /api/admin, which is a constant rather than a setting: a client is configured with a
host and a port and appends the rest.
HealthEndpoints
The startup, readiness, and liveness probes and the dedicated listener they answer on. Health endpoints records why the surface carries no credential and how each transport behaves.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
HealthEndpoints:Enabled |
bool | true |
Off maps no probe route and opens no listener | restart |
HealthEndpoints:BindAddress |
string | 0.0.0.0 |
An IP address; 127.0.0.1 restricts to the machine |
restart |
HealthEndpoints:Port |
int | 8081 |
1 – 65535. A port another surface binds is permitted and shares that socket — see sharing a socket | restart |
HealthEndpoints:HttpsPort |
int | unset | Required by, and only valid with, HttpAndHttps |
restart |
HealthEndpoints:Transport |
enum | Http |
Http, HttpAndHttps, HttpsOnly |
restart |
HealthEndpoints:Domain |
string | — | Required by the TLS transports; the name the certificate is proven against | restart |
HealthEndpoints:ServerCertificate |
certificate block | unset | Required by the TLS transports; refused otherwise | restart |