Skip to main content

MCP Protocol Reference

For client developers and anyone debugging a connection at the wire level. If you are configuring an off-the-shelf client, Connecting Agents is the page you want.

Endpoint​

PropertyValue
AddressPOST https://emailengine.example.com/mcp
TransportMCP Streamable HTTP
PayloadJSON-RPC 2.0, Content-Type: application/json
AuthenticationAuthorization: Bearer <access token>
SessionsNone. The server is stateless and never mints an Mcp-Session-Id
BatchingNot supported. A JSON array is refused with -32600
Other methodsGET and DELETE answer 405 with Allow: POST

The endpoint also accepts a token as the access_token query parameter, like the rest of the API, but the published resource metadata advertises header-based bearer authentication only. Use the header. EmailEngine redacts that parameter from its own request log, but a reverse proxy, CDN or browser history in front of it records query strings as they are.

GET returning 405 is not a failure. The modern protocol revision removed the standalone notification stream and sessions, and 405 is the prescribed answer that also tells a dual-era client this is not an old HTTP+SSE server.

Protocol revisions​

Two eras are served on the same endpoint:

RevisionEraHow a client selects it
2026-07-28ModernEvery request carries params._meta["io.modelcontextprotocol/protocolVersion"], mirrored in the MCP-Protocol-Version header. No handshake
2025-11-25Legacyinitialize handshake, then the negotiated version in the MCP-Protocol-Version header
2025-06-18LegacySame as above

server/discover reports the full list in supportedVersions.

Modern requests​

The modern revision mirrors routing information into headers so an intermediary can route without parsing the body, and the server refuses a request whose headers and body disagree:

curl -X POST "https://emailengine.example.com/mcp" \
-H "Authorization: Bearer $EE_TOKEN" \
-H "Content-Type: application/json" \
-H "MCP-Protocol-Version: 2026-07-28" \
-H "Mcp-Method: tools/call" \
-H "Mcp-Name: list_messages" \
-d '{
"jsonrpc": "2.0",
"id": 7,
"method": "tools/call",
"params": {
"name": "list_messages",
"arguments": { "account": "user123", "path": "INBOX", "pageSize": 5 },
"_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28" }
}
}'

Rules:

  • MCP-Protocol-Version must equal the version in _meta, or the request fails with -32020.
  • Mcp-Method is required on every request that carries an id and must equal method. Notifications carry no id, skip the routing headers and are answered 202.
  • Mcp-Name is required on tools/call (the tool name) and resources/read (the resource URI), and must match the body.
  • Header values that are not plain ASCII use the =?base64?...?= encoding the transport defines.
  • Results carry resultType: "complete" plus cache hints - see Caching.

Legacy requests​

Legacy clients open with initialize. The server echoes a supported proposal, or counters with the newest legacy revision it speaks:

curl -X POST "https://emailengine.example.com/mcp" \
-H "Authorization: Bearer $EE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": { "name": "example-client", "version": "1.0.0" }
}
}'
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2025-06-18",
"capabilities": { "tools": {}, "resources": { "subscribe": false, "listChanged": false } },
"serverInfo": { "name": "EmailEngine", "version": "x.y.z" },
"instructions": "EmailEngine is a self-hosted email sync service. This credential gives access to the email accounts registered on this instance. Call list_accounts first and use the returned account id as the `account` argument of the mail tools. ..."
}
}

No Mcp-Session-Id is returned, and none is expected on later requests. Notifications such as notifications/initialized are accepted and answered 202.

The instructions string is orientation for the model, written for the credential that is asking. It is built from the tool sets the advertised catalog exercises: a credential holding mail tools is told that message ids come from the listings, that bodies arrive as sanitized HTML with quoted history wrapped in a <details class="ee-collapsed-thread"> element, and that send_message reaches real recipients; one holding management tools is told to start with get_instance_stats and list_accounts, that settings changes take effect immediately, to confirm with the user before moving where webhooks or notifications are delivered, before changing where an account or gateway connects, and before anything destructive, and to prefer create_account_setup_link when adding a mailbox so no password passes through the conversation. A credential holding both hears both, management first.

The opening sentence depends on the binding. An unbound credential is told to call list_accounts and pass the id it returns. An account-bound one is told which account it holds and that its tools take no account argument, because for that credential the argument is not in the schema at all. A credential whose record leaves it only the tools both sets share, or no tools at all, is told so and asked to have the operator widen it.

Methods​

MethodEraReturns
initializeLegacyNegotiated version, capabilities, server info, instructions
server/discoverModernSupported versions, capabilities, instructions, server info in _meta
pingBoth{}
tools/listBothThe tools this credential may call
tools/callBothA tool result: content, optional structuredContent, optional isError
resources/listBothConnected accounts as resources
resources/readBothOne account resource, as JSON text
resources/templates/listBothAn empty list. No resource templates exist
subscriptions/listenModernAn SSE stream of notifications

Anything else is a method-not-found error: -32601, delivered as HTTP 404 in the modern era and as a plain JSON-RPC error in the legacy one. prompts/* and completion/* are not implemented.

Capabilities are tools and resources. Legacy responses spell out subscribe: false and listChanged: false, because the tool list is fixed for the life of the worker and legacy resource subscriptions are not served.

Caching​

Modern results carry freshness hints. They are hints, not contracts, and the scope is always private because nothing this endpoint serves is caller-neutral:

MethodttlMscacheScope
server/discover300000private
tools/list300000private
resources/templates/list300000private
resources/list60000private
resources/read30000private
ping, tools/callnot cacheable-

Error codes​

CodeMeaningWhere it comes from
-32700Parse errorMalformed JSON
-32600Invalid requestNot JSON-RPC 2.0, or a batch
-32601Method not foundUnknown method
-32602Invalid paramsMissing tool name or resource URI, or an unknown tool name
-32603Internal errorUnexpected server failure
-32002Resource not foundresources/read on an unknown account URI
-32020Header mismatchMirrored headers disagree with the body, or a required one is missing
-32022Unsupported protocol versiondata.supported lists what this server speaks

Failures inside a tool are not protocol errors. They come back as a normal result with isError: true carrying the API's error body, so the model can read and correct them. See Results.

HTTP status codes you may see: 200 for a JSON-RPC response (including one carrying an error object), 202 for an accepted notification, 400 for header and version failures, 401 when the credential is missing or invalid, 403 for a refused Origin or a token restriction, 404 when the endpoint is disabled or a modern method is unknown, 405 on GET/DELETE, 429 when a token rate limit is exhausted.

Subscriptions​

Modern clients can open a notification stream for account state changes. The request must accept text/event-stream:

curl -N -X POST "https://emailengine.example.com/mcp" \
-H "Authorization: Bearer $EE_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "MCP-Protocol-Version: 2026-07-28" \
-H "Mcp-Method: subscriptions/listen" \
-d '{
"jsonrpc": "2.0",
"id": "sub-1",
"method": "subscriptions/listen",
"params": {
"notifications": { "resourceSubscriptions": ["emailengine://account/user123"] },
"_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28" }
}
}'

The stream opens with an acknowledgment naming the subset that survived authorization:

event: message
data:{"jsonrpc":"2.0","method":"notifications/subscriptions/acknowledged","params":{"_meta":{"io.modelcontextprotocol/subscriptionId":"sub-1"},"notifications":{"resourceSubscriptions":["emailengine://account/user123"]}}}

After that, each state change on a subscribed account arrives as:

event: message
data:{"jsonrpc":"2.0","method":"notifications/resources/updated","params":{"_meta":{"io.modelcontextprotocol/subscriptionId":"sub-1"},"uri":"emailengine://account/user123"}}

Details worth knowing:

  • Authorization is per URI. Each subscribed account is checked with the caller's own credential, and one it cannot read is dropped from the acknowledged set rather than failing the request. Compare the acknowledgment with what you asked for.
  • Only resourceSubscriptions is honored. Other filter fields are omitted from the acknowledgment, which per the specification means "not supported": the tool list is static, and prompts do not exist here.
  • Limits. Only the first 20 URIs of a request are considered, and one credential may hold at most 4 open streams per API worker.
  • Authorization is re-checked while the stream is open. Once a minute each stream asks again, with its own credential, whether it may still read what it subscribed to. A URI the credential can no longer read, or whose account is gone, is dropped from the stream; a revoked credential, or a stream left with nothing it may read, is closed. A transient failure of the check changes nothing.
  • This is not a message-level feed. A notification says an account's state changed; it does not carry mail. For "a message arrived" and everything like it, use webhooks, which is the mature, filterable, retrying delivery path.

Request headers​

HeaderPurpose
AuthorizationBearer <access token>. Required unless API authentication is disabled instance-wide
Content-TypeMust be application/json
MCP-Protocol-VersionProtocol revision. Mirrors _meta in the modern era
Mcp-Method, Mcp-NameModern-era routing mirrors of method and the tool name or resource URI
AcceptMust admit text/event-stream for subscriptions/listen. text/event-stream;q=0 is honored as a refusal
OriginChecked for browser-based clients, see below
RefererEvaluated against a token's referrer restrictions, if it has any
X-EE-TimeoutRequest timeout in milliseconds, overriding EENGINE_TIMEOUT for this call. Forwarded to the operation the tool dispatches

CORS preflight allows X-EE-Timeout, MCP-Protocol-Version, Mcp-Method, Mcp-Name and Mcp-Session-Id.

Origin checks​

Non-browser clients send no Origin and are unaffected. When an Origin is present, it is admitted only if it is a loopback address, the origin of the configured Service URL, or one of the EENGINE_CORS_ORIGIN entries. Anything else is refused:

{ "jsonrpc": "2.0", "id": null, "error": { "code": -32600, "message": "Origin not allowed" } }

This is the DNS rebinding protection the Streamable HTTP transport asks for: without it, a page on any site could drive a local EmailEngine instance through the browser of whoever visited it.

OAuth 2.1 authorization server​

When mcpOAuthEnabled is on and a Service URL is set, EmailEngine also runs the minimal authorization server that MCP clients discover on their own. It issues ordinary access tokens carrying the MCP scopes the operator approved: mcp-manage for the instance, mcp for mail, or both. Every endpoint below answers 404 while the flow is unavailable, and all of them allow cross-origin requests, because browser-based clients call them directly.

What is implemented: dynamic client registration (RFC 7591, public clients only), authorization code with mandatory PKCE S256, single-use codes, exact-match redirect URIs, resource indicators (RFC 8707) and the iss authorization response parameter (RFC 9207). There are no client secrets and no refresh tokens.

Discovery​

An unauthenticated request to /mcp answers 401 with a pointer to the resource metadata:

WWW-Authenticate: Bearer resource_metadata="https://emailengine.example.com/.well-known/oauth-protected-resource/mcp"
curl "https://emailengine.example.com/.well-known/oauth-protected-resource/mcp"
{
"resource": "https://emailengine.example.com/mcp",
"authorization_servers": ["https://emailengine.example.com"],
"scopes_supported": ["mcp-manage", "mcp"],
"bearer_methods_supported": ["header"],
"resource_name": "EmailEngine MCP"
}
curl "https://emailengine.example.com/.well-known/oauth-authorization-server"
{
"issuer": "https://emailengine.example.com",
"authorization_endpoint": "https://emailengine.example.com/admin/mcp/authorize",
"token_endpoint": "https://emailengine.example.com/mcp/oauth/token",
"registration_endpoint": "https://emailengine.example.com/mcp/oauth/register",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none"],
"scopes_supported": ["mcp-manage", "mcp"],
"authorization_response_iss_parameter_supported": true
}

The resource metadata is served both at /.well-known/oauth-protected-resource/mcp and at /.well-known/oauth-protected-resource, for clients that treat the bare origin as the resource identifier.

Dynamic client registration​

curl -X POST "https://emailengine.example.com/mcp/oauth/register" \
-H "Content-Type: application/json" \
-d '{
"redirect_uris": ["https://client.example.com/oauth/callback"],
"client_name": "Example Agent"
}'
{
"client_id": "b0f3c2a1d4e5f6071829384756abcdef",
"client_id_issued_at": 1770000000,
"redirect_uris": ["https://client.example.com/oauth/callback"],
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code"],
"response_types": ["code"],
"client_name": "Example Agent"
}

Rules:

  • Registration is open and unauthenticated. It mints a client id and nothing else - only an admin's approval turns one into a credential.
  • Redirect URIs must be https, http on a loopback address, or a private-use scheme such as com.example.app:/callback. javascript:, data:, file:, blob: and vbscript: are refused, as is any URI carrying a fragment.
  • One to 10 URIs per registration. If any of them is unacceptable the whole registration fails, naming the offending value.
  • A new registration lives for 10 minutes. Reaching the consent page extends it to 30 days, refreshed whenever the client starts an authorization, so a registration nobody consents to disappears on its own. Re-registering is one unauthenticated call.
  • At most 1000 registrations may be waiting for consent at once, instance-wide. Past that, registration answers 503 with temporarily_unavailable until some of them expire.

Authorization request​

The client sends the browser to the authorization endpoint:

https://emailengine.example.com/admin/mcp/authorize
?client_id=b0f3c2a1d4e5f6071829384756abcdef
&redirect_uri=https%3A%2F%2Fclient.example.com%2Foauth%2Fcallback
&response_type=code
&code_challenge=<base64url(sha256(verifier))>
&code_challenge_method=S256
&state=<opaque>
&resource=https%3A%2F%2Femailengine.example.com%2Fmcp
&scope=mcp

code_challenge is required, S256 is the only accepted method, and redirect_uri must be one the client registered. resource is optional; if present it has to name this instance. scope is optional too, and it is a hint rather than a request: it only moves the starting position of the consent form (a client naming mcp starts with mail access at read-only and management declined), and anything other than the two MCP scopes is ignored.

The page is on the admin surface, and approving requires an authenticated admin session. The operator picks a level for instance management and one for email access, plus an optional account limit, then approves or denies.

Nothing redirects off the origin before a human decides. Because registration is open, a validated redirect_uri is not enough to make an automatic error redirect safe - anyone could register their own address and aim a link at it. So a malformed or unsupported authorization request renders an error page instead of bouncing back to the client. Only two outcomes redirect:

https://client.example.com/oauth/callback?code=<code>&state=<opaque>&iss=https%3A%2F%2Femailengine.example.com
https://client.example.com/oauth/callback?error=access_denied&state=<opaque>&iss=https%3A%2F%2Femailengine.example.com

Denying needs no admin session: refusing to hand out a credential does not require the authority to hand one out.

Token request​

curl -X POST "https://emailengine.example.com/mcp/oauth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=<code>" \
-d "client_id=b0f3c2a1d4e5f6071829384756abcdef" \
-d "redirect_uri=https://client.example.com/oauth/callback" \
-d "code_verifier=<verifier>" \
-d "resource=https://emailengine.example.com/mcp"
{
"access_token": "8a2c...",
"token_type": "Bearer",
"scope": "mcp-manage mcp"
}

scope lists what was granted, space separated, management first. Codes are single-use and valid for 10 minutes. The endpoint accepts form encoding or JSON. Failures use the standard OAuth error shape:

{ "error": "invalid_grant", "error_description": "PKCE verification failed" }

The issued credential is an ordinary EmailEngine access token carrying the approved scopes, an explicit permissions.grants record for the levels chosen, and the account binding if one was set. It does not expire on its own and there is no refresh token: revoking it on the Access Tokens page is the whole lifecycle.

Rate limits​

The unauthenticated endpoints are budgeted per client IP address, over a rolling hour:

EndpointBudget
POST /mcp/oauth/register20 per hour
POST /mcp/oauth/token60 per hour

A refusal is 429 with Retry-After and a ttl field naming when to come back. A working client registers once and redeems one code, so these are not budgets a real flow approaches.

Behind a reverse proxy​

Two things matter for MCP traffic.

Do not buffer the endpoint. A subscriptions/listen response is an event stream, and a proxy that buffers it holds notifications until the connection closes. Give /mcp the same treatment as the change stream:

location = /mcp {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;

gzip off;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 1h;
chunked_transfer_encoding off;
}

Pass the client address through correctly. Token IP restrictions and the OAuth per-IP budgets read the resolved client address. If EmailEngine is behind a proxy, set EENGINE_API_PROXY_ADDRESSES to the proxy addresses so X-Forwarded-For is honored from them and nowhere else.

See Also​