MCP server deprecated
The middleware can expose a user's mail, calendar, contacts, files, tasks and reminders to language-model clients as read-only tools through the Model Context Protocol, revision 2026-07-28. A client such as Claude Code, Cursor or the MCP Inspector connects to one HTTP endpoint, lists the tools the user's credentials allow, and calls them with JSON arguments. Every call runs as that user with that user's permissions; there is no service account and nothing is written.
The endpoint is off by default.
An OpenAPI document describing both paths, the transport headers, every request and response and the tools' arguments and results is published alongside the other API documentation:
https://documentation.open-xchange.com/components/middleware/mcp/<version>/
That page renders the description in a browser. The document itself sits next to it as mcp.openapi.json and is the file to point a client generator at. This page is the operator's side: what to switch on, which credentials exist and what the endpoint does at runtime.
What is offered
| Tool | Scope | Returns |
|---|---|---|
me_get | none | Who is calling: display name, addresses, time zone, language, the current date and time in that time zone, and which modules the user has |
contacts_search | read_contacts | Up to 20 contacts by name or address: id, folder, display name, primary address |
contact_get | read_contacts | One contact with what the search leaves out: every address, phone numbers, postal addresses, company, department, position, birthday (--MM-dd where the year is unknown), note cut at 4000 characters |
users_search | read_contacts | Up to 20 colleagues from the user directory by name, address, company or department: display name, address, job title, department, company, phone numbers, time zone, language; the address only where the address book shows it |
mail_search | read_mail | Up to 20 messages matching a text, newest first: id, folder, subject, sender, date, a preview of at most 200 characters; never a body |
mail_get | read_mail | One message as text, every inline text part in order, cut at 4000 characters; attachments by id, name, type and size, inline images left out; reading does not mark as read |
mail_attachment_get | read_mail | One attachment of a message: for a text attachment its text cut at 4000 characters, for a binary one its metadata alone |
vacation_get | read_mail | Whether the user's vacation notice answers today (switched on and within its date range) and what it replies: subject, text, addresses, sender, interval, date range, and for internal/external notices the mode and the external reply |
reminders_list | read_reminders | Up to 50 reminders coming due, across appointments and tasks, earliest first: module, the item's identifier and folder, the occurrence of a series, due date, description |
calendar_list_events | read_calendar | Up to 50 events in a range of at most 90 days from every subscribed calendar the user sees (own, shared, public, other accounts), each occurrence once, series expanded, sorted by start; with the user's own participation status as myStatus (declined events included), transparent for events that leave the time free, and calendar, the kind of calendar the event was read through (own, shared or public); events without a time zone in the user's time zone; the end of an all-day event is the day after its last day |
calendar_get_event | read_calendar | One appointment, or one occurrence of a series, with what the listing reports except calendar, its description cut at 4000 characters, the attendees with their participation status and the recurrence rule |
calendar_free_busy | read_calendar | The busy stretches of up to 10 people in a range of at most 31 days, merged, with the event title where the user may see it; people without a calendar here come back with a warning |
resources_search | read_calendar | Up to 20 bookable resources of the organization, rooms and equipment, by a text in the name, most used first: id, name, address, description, whether bookable |
files_search | read_files | Up to 20 files from Drive by name, title or description, most recently modified first: id, folder, name, type, size, modification time; never the content |
file_get | read_files | One file's metadata, and for a text file (plain text, Markdown, CSV, JSON, XML) its text cut at 4000 characters; office documents, PDFs and images by metadata only |
tasks_search | read_tasks | Up to 50 tasks across every task folder the user sees, open ones unless asked for all, earliest due first: id, folder, title, status, priority, progress, start, due, completion date; start and due of a full-time task as plain dates, with allDay: true |
task_get | read_tasks | One task with its note, cut at 4000 characters |
mail_folders, calendar_folders, contacts_folders, files_folders, tasks_folders | the module's read scope | The folders of that module the user sees, own, shared and public, with the identifier the module's other tools take as folder, the name, the path, whether it is a standard folder and the item count where known, counted for the folders of the returned page only; at most 200. Calendars are listed as cal://<account>/<id> and address books as con://<account>/<id>, the identifiers the calendar and contact tools report and take |
Mail means the primary account. A session made from a bearer token holds no credentials for secondary or external mail accounts, so mail_folders lists the primary account's folders and the other mail tools reach nothing else, whatever the token's scopes. Contacts, calendar, tasks and files are not affected: their shared and public folders follow the user's own permissions.
The user's permissions apply as in the HTTP API. A few of them shape what a tool returns rather than whether it runs:
users_searchfor a user without read access to the global address book matches names only, and the primary address only where the user has the webmail permission; time zone and language are reported for the caller's own entry alone. This is how theusermodule of the HTTP API serves such a user.task_gethonors folders that grant read access to the user's own objects only: a colleague's task there is not found.reminders_listleaves out task reminders when the user has no task module permission.
A text attachment and a text file are served as text by their MIME type (text/*, JSON, XML), and also where the type says nothing, application/octet-stream or none at all, but the file name ends like a text file: .txt, .log, .csv, .tsv, .md, .json, .xml, .yaml, .ini, .properties, .ics, .vcf, .html, .css, .js, .sql, .sh, .eml and a few variants. This holds for mail_attachment_get, file_get and the resource reads of both.
me_get is the one tool every valid token may call, whatever its scopes: it says nothing the token does not already imply, and a model needs it before it can turn "tomorrow" into a date, since every other tool takes absolute times and the user's own time zone decides which day that is. It also names the modules the user has at all, which is not the same as the scopes the token was granted: a scope on a module the user does not have finds nothing.
Search and read are two steps in every module: a search returns identifiers and a summary, and the module's read tool turns one of those identifiers into the whole item. A list result never carries a body, a note or a description, so a model that wants one asks for it. Every list carries returned and truncated, so a model can tell a subset from the whole. A truncated list also carries a nextCursor, which the same tool takes back as its cursor argument to deliver the entries that follow. The cursor is opaque and bound to the call that produced it: to the tool and to every argument except cursor and limit. Passed to another tool, with other arguments, or handed out by an earlier version of the endpoint, it is refused with a tool error, so that a model cannot take a page of one question for the continuation of another. A caller may skip at most 500 entries this way, so the cost of a late page stays bounded and a truncated list stops offering a cursor at that point. The *_folders tools are exempt: they read every folder anyway, so each of them stays reachable. Paging works by re-reading, the way the platform's own list actions do, so a page reflects the data at the moment it is asked for: an item created in between can shift entries between pages. The right answer to a long list is a narrower query, and the tool descriptions say so. A folder identifier a tool returns is the one its other tools accept; the *_folders tools exist so that a model can learn them instead of guessing. Tool descriptions are written for the model and say when to prefer one tool over another.
contacts_search covers the address books the user sees, users_search the user directory of the organization, which carries what an address book entry does not: the department, the job title, the time zone.
A resource's address is an attendee address, so resources_search and calendar_free_busy work together: find the room, then ask when it is taken. The resource tool shares the calendar scope for that reason, and because the platform has no scope of its own for resources.
The vacation notice is read through the mail filter, so vacation_get needs open-xchange-mailfilter as well; see below. It reads the notice, switching it on or off stays with the user.
Not offered: writing anything, the stdio transport, streaming responses. Every tool is announced in tools/list with annotations saying so: readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false.
Tool arguments
The input schema of every tool comes with tools/list; this is the same information at a glance.
| Tool | Argument | Required | Constraint | Default |
|---|---|---|---|---|
me_get | no arguments | |||
contacts_search | query | yes | 2 to 100 characters, matched against names and addresses | |
limit | no | 1 to 20 | 20 | |
contact_get | id, folder | yes | as returned by contacts_search | |
users_search | query | yes | 2 to 100 characters, matched against names, addresses, company and department | |
limit | no | 1 to 20 | 20 | |
mail_search | query | no | 2 to 200 characters, matched against subject, sender, recipients and body; omitted, the newest messages of the folder are listed | |
folder | no | a folder identifier from an earlier result | default0/INBOX | |
limit | no | 1 to 20 | 20 | |
mail_get | id, folder | yes | as returned by mail_search | |
mail_attachment_get | id, folder | yes | as returned by mail_search or mail_get | |
attachment | yes | the attachment's id, as mail_get lists it | ||
calendar_list_events | from, until | yes | ISO 8601 date-time with offset (2026-09-10T00:00:00+02:00) or a date, which counts as midnight UTC; until exclusive; at most 90 days apart | |
limit | no | 1 to 50 | 50 | |
calendar_get_event | id, folder | yes | as returned by calendar_list_events | |
recurrenceId | no | the recurrenceId of the same result, to address one occurrence of a series | the appointment itself | |
calendar_free_busy | attendees | yes | 1 to 10 e-mail addresses; names are resolved with contacts_search first, rooms with resources_search | |
from, until | yes | as for calendar_list_events; at most 31 days apart | ||
resources_search | query | yes | 2 to 100 characters, matched anywhere in the resource's name | |
limit | no | 1 to 20 | 20 | |
files_search | query | yes | 2 to 200 characters, matched against file name, title and description | |
folder | no | a folder identifier from an earlier result, searched with its subfolders | every folder the user sees | |
limit | no | 1 to 20 | 20 | |
file_get | id | yes | as returned by files_search | |
tasks_search | query | no | 2 to 200 characters, matched against title and note; omitted, every task is listed | |
folder | no | a folder identifier from an earlier result | every task folder the user sees | |
status | no | open or all | open | |
limit | no | 1 to 50 | 50 | |
task_get | id, folder | yes | as returned by tasks_search | |
the five *_folders tools | no arguments other than cursor | |||
vacation_get | no arguments | |||
| every tool that returns a list | cursor | no | the nextCursor of an earlier result of the same tool with the same other arguments (limit may differ); at most 500 entries may be skipped, except by the *_folders tools | the first page |
reminders_list | until | no | ISO 8601 date-time with offset or a date; at most a year ahead | seven days from now |
limit | no | 1 to 50 | 50 |
Results and errors
A successful call returns one text content block holding a JSON document: an object with returned, truncated and results for the search tools, the message itself for mail_get. Two error channels exist:
- A request that names no tool, an unknown tool, or
argumentsthat are not an object is a JSON-RPC error-32602. - Arguments that violate the tool's input schema (a missing required argument, an unknown argument, a wrong type, a value outside its bounds), an unparseable date, a range that is too wide, an unknown message or folder, and any problem the backend reports are tool results with
isError: trueand a message naming the parameter or the cause, so that a model can correct the call. Revision2025-11-25introduced this for schema violations; on2025-03-26and2025-06-18a missing, unknown or mistyped argument is still-32602.
Enabling the endpoint
The server ships in its own package, open-xchange-mcp, which the Helm chart keeps switched off through the feature mcp; the package depends on open-xchange-core, where the access token bundles live, and needs open-xchange-oauth-provider, which validates the tokens. Two switches, both off by default:
features:
status:
mcp: enabled
properties:
com.openexchange.mcp.enabled: "true"
The feature installs the bundles, the property registers the endpoint; either one alone leaves /mcp unanswered.
The endpoint also needs the OAuth provider (open-xchange-oauth-provider), which validates its tokens. A deployment without an external authorization server needs only this, and then accepts personal access tokens alone:
properties:
com.openexchange.oauth.provider.enabled: "true"
com.openexchange.oauth.provider.mode: "expect_jwt"
Leave com.openexchange.oauth.provider.jwt.jwksUri empty; the provider then starts without a JWT validator. For an external authorization server, add its settings as described in Tokens from an external authorization server.
Where to run it. The endpoint is served on the middleware's own HTTP port, the same one the AJAX API uses, so it belongs on the pods that serve clients: with the feature set as above it runs on every role, which is what a deployment wants that lets its users reach the API from the web. To keep it to one role, put the switch under that role instead, the way the chart keeps SCIM to the admin role:
roles:
http-api:
values:
features:
status:
mcp: enabled
Where the properties go. None of them ship a file: the bundle reads them through the configuration service, so the chart's properties map above is the place for them, and any .properties file under /opt/open-xchange/etc/ works as well. Use propertiesFiles when a file of its own is wanted:
propertiesFiles:
/opt/open-xchange/etc/mcp.properties:
com.openexchange.mcp.enabled: "true"
com.openexchange.mcp.rateLimit.calls: "120"
None of them is a secret, so secretProperties is not needed. All of them are reloadable, so nothing needs a restart. A reload re-registers the endpoint only when com.openexchange.mcp.enabled changes; every other property takes effect without taking the endpoint down. A changed com.openexchange.mcp.maxConcurrentCalls applies to the calls that start after the reload.
The package holds one bundle for the endpoint and one per group of tools, so that a tool exists only where its backend does:
| Bundle | Tools |
|---|---|
com.openexchange.mcp | the endpoint, the metadata document, me_get, the folder listing the other bundles use |
com.openexchange.mcp.mail | mail_search, mail_get, mail_attachment_get, mail_folders |
com.openexchange.mcp.calendar | calendar_list_events, calendar_get_event, calendar_free_busy, resources_search, calendar_folders |
com.openexchange.mcp.contacts | contacts_search, contact_get, users_search, contacts_folders |
com.openexchange.mcp.files | files_search, file_get, files_folders |
com.openexchange.mcp.tasks | tasks_search, task_get, tasks_folders |
com.openexchange.mcp.reminders | reminders_list |
com.openexchange.mcp.vacation | vacation_get |
A bundle whose backend is missing does not start, and its tools are then absent from tools/list instead of failing on a call. com.openexchange.mcp.vacation needs the mail filter, so vacation_get exists only where open-xchange-mailfilter is installed as well; everything else comes with open-xchange-core and open-xchange-oauth-provider, which the endpoint needs anyway.
Reaching it
The endpoint answers at /mcp on the middleware's HTTP port, next to the AJAX API, and serves its OAuth 2.0 Protected Resource Metadata at /.well-known/oauth-protected-resource/mcp. Clients call from the internet and every one of them requires TLS with a publicly trusted certificate, so route both paths from the public hostname the App Suite is served on to the same service the AJAX API uses. Two paths have to be routed, not one: a client that runs the OAuth flow itself reads the metadata document before it ever sends a token, and it reads it without a credential.
Behind the route the middleware builds the absolute URLs it advertises from X-Forwarded-Proto and X-Forwarded-Host, so set them on the route where the public URL differs from what the pod sees. Get this wrong and the endpoint still answers, but the resource in the metadata and the resource_metadata in every 401 challenge name the pod instead of the public host, and a client that follows them fails to discover the authorization server.
The endpoint needs the OAuth provider (com.openexchange.oauth.provider.enabled=true) to validate tokens at all. Without it the bundle waits, /mcp answers 404, and a minute after start the log says what it waits for:
MCP endpoint not registered: bundle com.openexchange.mcp still waits after 60 s for com.openexchange.oauth.provider.resourceserver.OAuthResourceService; the OAuth provider is switched off (com.openexchange.oauth.provider.enabled), its package open-xchange-oauth-provider is not installed, or it failed to start, see its start-up error above
A provider that is switched on but fails to start, for example because its key set cannot be loaded, logs its own error further up; that error is the one to fix. The bundles of the individual tools wait for the endpoint's bundle and stay silent meanwhile.
| Property | Default | Meaning |
|---|---|---|
com.openexchange.mcp.enabled | false | Registers the endpoint and the metadata document. Reloadable. |
com.openexchange.mcp.allowedOrigins | empty | Comma-separated Origin values a browser-based client may send. Empty refuses every request that carries an Origin header, which is right for a non-browser API. Listing an origin only admits the header: the endpoint sends no CORS headers and answers OPTIONS with 405, so a browser client on another origin needs CORS at the ingress. |
com.openexchange.mcp.authorizationServers | value of com.openexchange.oauth.provider.allowedIssuer | Issuer URLs advertised as authorization_servers in the metadata; comma-separated. |
com.openexchange.mcp.audience | empty | Comma-separated audiences; a JWT has to name one of them in aud, otherwise it is refused with 401 and invalid_token. Empty accepts every JWT the OAuth provider accepts. Personal access tokens are not affected. See Tokens from an external authorization server. |
com.openexchange.mcp.serverName | open-xchange-mcp | The name the server reports about itself. |
com.openexchange.mcp.instructions | built-in text | Natural-language guidance the server returns on discovery. |
com.openexchange.mcp.rateLimit.calls | 60 | Tool calls a user may make per window; 0 disables the limit. |
com.openexchange.mcp.rateLimit.windowSeconds | 60 | The window of the tool call limit; a value below 1 counts as 1. |
com.openexchange.mcp.maxConcurrentCalls | 4 | How many tool calls and resource reads of one user may run at the same time on a node, each counted until its answer is written; a further one is refused like a call beyond the rate limit, with Retry-After: 1. 0 switches the bound off. A changed value applies to calls that start after the reload. |
Checking the installation
Three things say that it is up, in this order:
Registered MCP endpoint at /mcp
Registered MCP resource metadata at /.well-known/oauth-protected-resource/mcp
Started bundle: com.openexchange.mcp
Then the metadata document answers without a credential, which is the cheapest check from outside:
curl -s https://mail.example.com/.well-known/oauth-protected-resource/mcp
A POST /mcp without a token has to come back as 401 with a WWW-Authenticate header. Anything else means the route or the provider is not in place: a 404 says the bundle did not start or the endpoint is switched off, an empty 200 says the route points somewhere else. The two cases behind a 404 are told apart in the log: with the property at false the bundle starts and says MCP endpoint is disabled by configuration, while a bundle that still waits for a service says MCP endpoint not registered and names the service, a minute after start. A tool call needs a credential; the section "Connecting a client" below has the calls for that.
Capability
A user who can use the endpoint has the capability mcp: the endpoint is registered on the node, the user is neither a guest nor anonymous, and the OAuth provider is enabled for the user. A client, such as App Suite's own settings, uses it to decide whether to mention the endpoint at all. The endpoint checks the capability itself on every request, together with whether the user's context is enabled: switching the OAuth provider off for a user or a context through the config cascade, forcing the capability off through com.openexchange.capability.forced.mcp, or disabling the context refuses that user's tokens with 403 right away, including tokens issued before. The capability says nothing about how a token is obtained; minting personal access tokens has the capability access_tokens, since those tokens serve more than this endpoint.
Authentication
Every request to /mcp carries a bearer token in the Authorization header. The endpoint validates it through the middleware's OAuth provider (com.openexchange.oauth.provider.*), which therefore has to be enabled; the token's scopes decide which tools the caller sees and may call: read_mail, read_calendar, read_contacts, read_files, read_tasks and read_reminders. A request without a usable token is answered with 401 and a WWW-Authenticate challenge that names the metadata document and the scopes; a call to a tool the token does not cover is 403 with error="insufficient_scope". A valid token whose user may not use the endpoint (no capability mcp, see above), or cannot sign in at all (a disabled user or context, for instance), is answered with 403, no challenge and the JSON-RPC error -32600 The user may not use the MCP endpoint. When the token cannot be checked right now, the answer is 503 with Retry-After, and the log says so at WARN.
Two kinds of tokens are accepted, and they can be used side by side. Which clients can connect depends on which of them an installation offers:
| Installation | Clients that can connect |
|---|---|
| Personal access tokens only, no external authorization server | Clients that send a fixed Authorization header: Claude Code, Cursor, VS Code, mcp-remote, and claude.ai connectors a user adds personally with a request header |
| An external authorization server as well | In addition, every client that signs in through OAuth, including connectors that serve a whole organization |
The middleware has no authorization server of its own. Without an external one, no client can sign in through OAuth; this is a limit of the endpoint, not of a particular client.
Tokens from an external authorization server
This is the path the MCP specification describes: the client discovers the authorization server through the metadata document, runs the OAuth 2.1 authorization code flow with PKCE against it, and presents the resulting JWT. Clients that run the flow themselves need this path, and it is the only one that suits connectors on claude.ai's Team and Enterprise plans, where one connector serves every member.
The middleware acts as resource server only. Configure the OAuth provider in JWT mode for the authorization server in use, for example Keycloak:
com.openexchange.oauth.provider.enabled=true
com.openexchange.oauth.provider.mode=expect_jwt
com.openexchange.oauth.provider.allowedIssuer=https://login.example.com/realms/appsuite
com.openexchange.oauth.provider.jwt.jwksUri=https://login.example.com/realms/appsuite/protocol/openid-connect/certs
com.openexchange.oauth.provider.jwt.audience=appsuite-mcp
The provider's jwt.audience is one list for every resource server of the installation. Where the same authorization server also issues tokens for other clients, such as the mobile app, set com.openexchange.mcp.audience=appsuite-mcp as well: a JWT then has to name that audience to be accepted here, and a token issued for another client is answered with 401 and invalid_token, as the MCP specification requires. Left empty, the endpoint accepts every JWT the provider accepts and logs a warning at start-up and on every reload when the provider accepts tokens of an external authorization server, that is with com.openexchange.oauth.provider.jwt.jwksUri set or in mode token_introspection. Personal access tokens carry no audience and are not affected.
The subject of the token has to resolve to a user (see contextLookupClaim and userLookupClaim of the provider), and the token's scopes are mapped to read_mail, read_calendar, read_contacts, read_files, read_tasks and read_reminders through com.openexchange.oauth.provider.scope.* where the authorization server uses other names. The authorization server, its client registration and PKCE are outside the middleware. com.openexchange.mcp.authorizationServers names the issuer in the metadata document; left empty, the provider's allowedIssuer is advertised.
Keycloak, step by step
Checked on 2026-09-15 with Keycloak 26.2 and a claude.ai custom connector, from registration to a tool call. What Keycloak needs, in the order it is set up:
- Users. The provider resolves the token to a user through a claim. With
contextLookupClaim=emailanduserLookupClaim=email(the defaults for the name parts take the domain as context and the local part as user), the Keycloak user's e-mail address has to be the App Suite login,anton@context1.example.com. Any other claim that carries the login works the same way. - Client scopes. One client scope per platform scope, named exactly
read_mail,read_calendar,read_contacts,read_files,read_tasksandread_reminders, with Include in token scope on, added to the realm as optional default client scopes so that a client may request them. Where a realm already uses other names, map them withcom.openexchange.oauth.provider.scope.<name>=<platform scope>instead. - Two mappers on every one of those client scopes: an Audience mapper adding the value of
com.openexchange.oauth.provider.jwt.audiencetoaud, and a User Attribute mapper putting the e-mail address into theemailclaim. They have to sit on the scopes, not on the realm's default scopes: a client that registers dynamically gets only Keycloak'sbasicscope by default, notemailorprofile, and a token withoutaudandemailis refused with401andinvalid_token, which the client then retries after a refresh, in vain. - Clients. Either dynamic registration, which claude.ai uses by default: under Client registration of the realm, remove the anonymous Trusted Hosts policy or list the registering hosts in it, and add the platform scopes to the anonymous Allowed Client Scopes policy. Clients registered this way require consent, so the user sees the scope list once. Or a fixed confidential client: standard flow, PKCE method
S256, redirect URIhttps://claude.ai/api/mcp/auth_callback, the platform scopes as optional client scopes; its id and secret go into the connector's own OAuth client settings in claude.ai. - Behind a proxy. Keycloak with
--proxy-headers xforwardedand its hostname configured for the public URL, which is also the issuer the provider allows. The proxy has to pass everySet-Cookieheader of a response; one that keeps only the last ends Keycloak's login in anexpired_codeloop.
What claude.ai does with that: it reads the metadata document after the first 401, then looks for the authorization server's metadata at /.well-known/oauth-authorization-server/realms/<realm> and /.well-known/openid-configuration/realms/<realm> (both 404 at Keycloak) before it finds /realms/<realm>/.well-known/openid-configuration; registers with client_secret_post and the grants authorization_code and refresh_token; authorizes with PKCE S256, prompt=consent, the resource parameter, every scope of scopes_supported and offline_access; and refreshes the token once when the endpoint answers 401. Its first request, before it has a token, is an initialize of revision 2025-11-25, which the 401 challenge answers; with the token it speaks 2026-07-28.
Access tokens minted by the user
For clients that send a fixed Authorization header, such as Claude Code, Cursor, VS Code, mcp-remote or a claude.ai connector with a request header (where the user adds the connector personally, and where claude.ai offers request headers), a user mints a personal access token through the HTTP API module accesstoken and hands the secret to the client. That page describes minting, the scopes a user may grant, the limits, and how a session made from a token reaches the mail server through the mail credential vault. What matters for the endpoint: the token's scopes decide which tools the caller sees, a token without mail_access sees the mail tools but every mail call fails, and a token is a bearer credential for every endpoint that validates through the OAuth provider, not only for /mcp.
The scopes the tools require are the ones the platform's modules register with the OAuth provider: read_mail, read_calendar, read_contacts, read_tasks, read_reminders and read_files. The last one is new in this release. The Drive module has guarded its read actions with it all along but never offered it, so it could not be granted; it now does, like every other module.
A scope opens a module, not a set of tools. This is how the platform has always treated them and it is worth stating plainly: a token carrying read_mail may call the mail tools of this endpoint and the read actions of the mail module in the HTTP API, because both are guarded by the same scope. The same holds for calendar, contacts, tasks, reminders and now files: a token with read_files reads Drive through the HTTP API as well, where the bounds of this endpoint (4000 characters of text, 5 MiB of bytes) do not apply. Users mint tokens with the scopes they need, so grant read_files for what a client should reach in Drive, not only for what the two Drive tools return. Writing to Drive stays out of reach: no module offers a write_files scope.
Limits and logging
Each user may make com.openexchange.mcp.rateLimit.calls tool calls per com.openexchange.mcp.rateLimit.windowSeconds, counted across all nodes through the rate limiter service, the same one the provisioning API uses; a deployment has it either from com.openexchange.ratelimit.rdb, which counts in the database, or from com.openexchange.ratelimit.redis. Where neither is present, every call passes and the limit is not enforced; the log says so once at start-up, since a limit that cannot be applied looks in the metrics exactly like one nobody reaches. Beyond the limit a tool call is answered with 200 and a tool result with isError: true saying when to retry, plus a Retry-After header; a resource read, which has no such channel, gets 429 with a JSON-RPC error -32000 and Retry-After. Listing and discovery are not counted. Argument validation and scope checks happen before the limit is consulted, so a malformed or refused call consumes no permit.
Besides how often, the endpoint bounds how much at once: at most com.openexchange.mcp.maxConcurrentCalls tool calls and resource reads of one user run at the same time on a node, so that a client that fans out, or retries on its own timeout while the first call still runs, cannot pile up mail and calendar work behind one token. A further call is refused the same way as beyond the rate limit, with Retry-After: 1, takes no permit of the rate limit, and is audited as busy. A call counts until its answer is written, since the answer stays in memory until then: a client that reads slowly keeps its place that long. The bound is per node, since a call in flight lives on the node that runs it; 0 switches it off.
Every tool call writes one line at level INFO through the logger com.openexchange.mcp.protocol.McpRequestHandler, with the context and user identifiers from the session's log properties:
MCP tool call: client=claude-code tool=mail_search args=[limit, query] duration=87ms outcome=ok
args lists the argument names, never values, which may contain message content. client is what the client reports about itself and is not verified. outcome is ok, error (the tool reported a problem the model can act on), denied (missing scope), invalid (arguments violate the tool's schema), ratelimited, busy (the user already runs as many calls as allowed) or failed (the tool failed unexpectedly; answered with 500, the stack trace follows at ERROR).
Where a tool reports an error, the cause is logged through the same logger: at INFO without a stack trace when the caller caused it, with a value the backend refused or a missing permission, and at WARN with the stack trace otherwise.
A resource read writes its own line through the same logger. It names the provider's prefix and never the rest of the URI, which identifies folders and items; a mail folder's name can be as personal as a message:
MCP resource read: client=claude-code resource=ox:///files/ duration=41ms outcome=ok
Its outcome is ok, notfound, toolarge (an item without a text form above 5 MiB), failed (the backend failed; the stack trace follows at WARN), denied, invalid, ratelimited or busy.
Metrics
The endpoint registers four meters with the Micrometer registry; they appear under /metrics like every other core metric:
| Meter | Type | Tags |
|---|---|---|
appsuite.mcp.requests | counter | method (initialize, ping, server/discover, tools/list, tools/call, prompts/list, prompts/get, resources/list, resources/templates/list, resources/read, other, or - for a request answered before its method was read, such as 401, 403, 405, 413, 415 or 503), status (the HTTP status) |
appsuite.mcp.tool.calls | timer | tool, outcome (the values of the audit line) |
appsuite.mcp.resource.reads | timer | resource (the URI prefix of the provider, such as ox:///files/, or - for a URI that names none), outcome (ok, notfound, toolarge, denied, invalid, ratelimited, busy or failed, as in the audit line) |
appsuite.mcp.authentications | counter | kind (accesstoken, jwt or none), result (accepted, missing, invalid, refused or unavailable) |
Nothing a client sends becomes a tag value. The core-mw Grafana dashboard charts these meters in its MCP row.
What reaches the model
The tools return the user's data as it is stored: message bodies and attachments, file contents, descriptions of appointments, notes of contacts and tasks. Much of it was written by someone other than the user, and the endpoint neither filters nor marks it. Text inside a message can therefore address the model rather than the reader, for example a mail that asks to forward the last invoices to an outside address. This is the prompt injection every assistant with mail access faces, and the endpoint cannot rule it out, because telling such text from an ordinary request is not possible from the data alone.
What the endpoint does limit:
- It only reads. No tool sends, writes, moves or deletes anything, so an injected instruction cannot make this server act.
- The scopes bound what a token reaches. A token for the calendar cannot read mail, whatever a calendar entry says.
- Every call is audited with the client's name, the tool or resource and the outcome, without content.
What it cannot limit is the client. A client that combines this server with others, one that can fetch URLs or send mail, can be talked into passing on what it read here. Advise users accordingly:
- Mint tokens with the scopes a use needs and a short lifetime, and revoke them when the use ends.
- Prefer clients that ask before calling a tool, and do not let one run tools of several servers without confirmation when one of them can send data out.
- Treat an answer that is built on a message or a file as that message's author speaking, not the organization.
The prompts the server offers tell the model to report only what the tools returned. That shapes answers; it is no protection against a message that says otherwise.
Calling the endpoint
Everything goes to one path with one verb:
POST /mcp
GET, DELETE, TRACE, OPTIONS and every other verb are answered with 405 and Allow: POST; the endpoint sends no CORS headers. The body is exactly one JSON object in strict JSON: several messages in one body, anything after the object, single quotes or unquoted names are 400 with -32700. A body larger than 1 MiB is 413. There is no session: each request stands on its own. Two ways of speaking are understood on that one path, told apart by the MCP-Protocol-Version header: revision 2026-07-28, described first, and the initialize handshake of the three revisions before it, described in its own section below, which is what a claude.ai connector speaks.
Transport headers
| Header | Required | Value |
|---|---|---|
Authorization | yes | Bearer <token>, a JWT or an access token |
Content-Type | should be sent | application/json; not enforced. The body is read as UTF-8 unless the header names another charset; a charset the runtime does not know is 415 |
MCP-Protocol-Version | yes | 2026-07-28, and it has to equal params._meta of the body; one of 2025-03-26, 2025-06-18, 2025-11-25 selects the earlier handshake |
Mcp-Method | yes | the method of the body; optional in the earlier handshake, checked there when sent |
Mcp-Name | for tools/call | the tool's name, either plain or as the revision's sentinel =?base64?<base64 of the name>?=; optional in the earlier handshake, checked there when sent |
Origin | no | only accepted when listed in com.openexchange.mcp.allowedOrigins; no CORS headers are sent, so a browser client on another origin needs CORS at the ingress |
A header that contradicts the body is 400 with JSON-RPC error -32020. Every request body carries params._meta with the protocol version and the client capabilities:
{
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {},
"io.modelcontextprotocol/clientInfo": { "name": "example-client", "version": "1.0" }
}
}
}
clientCapabilities may be empty, the endpoint requires none. clientInfo is optional and not verified; its name appears in the audit line. A request without id is a notification and is answered with 202 and no body.
The methods
method | What it does | Cacheable |
|---|---|---|
server/discover | Reports the revision spoken, the capabilities and instructions. Usually a client's first call. | ttlMs 3600000, cacheScope public |
tools/list | Returns the tools the presented token's scopes allow, each with name, title, description and inputSchema. | ttlMs 300000, cacheScope private |
tools/call | Runs one tool. params.name is the tool, params.arguments its arguments. | not cacheable |
prompts/list | Returns the prompts the caller can use, each with its arguments. | ttlMs 300000, cacheScope private |
prompts/get | Renders one prompt. params.name is the prompt, params.arguments its arguments, all strings. | not cacheable |
resources/list | Returns an empty list; nothing is enumerated. | ttlMs 300000, cacheScope private |
resources/templates/list | Returns the URI templates the caller may read. | ttlMs 300000, cacheScope private |
resources/read | Reads one resource. params.uri is the URI. Draws a permit from the tool call limit. | ttlMs 0, cacheScope private |
The three capabilities the endpoint announces on discovery are tools, prompts and resources.
Every result is wrapped in the JSON-RPC envelope with resultType: "complete" and a _meta block naming the server:
{
"jsonrpc": "2.0", "id": 3,
"result": {
"resultType": "complete",
"_meta": { "io.modelcontextprotocol/serverInfo": { "name": "open-xchange-mcp", "version": "8.54.0" } },
"content": [ { "type": "text", "text": "{\"returned\":2,\"truncated\":false,\"results\":[…]}" } ],
"structuredContent": { "returned": 2, "truncated": false, "results": [] },
"isError": false
}
}
A tool result appears twice: as a text block for the model and as structuredContent for a program. The shapes of both are in the API document.
Prompts
A prompt is a short instruction that chains this server's own tools, so that a client can offer "brief me on today" as one command instead of the user spelling out the steps. Four are offered: daily_briefing, inbox_triage, find_meeting_slot and catch_up_on. Each names the tools it drives, and it is listed only where every one of them is registered and allowed by the token, so a deployment without the mail bundle offers no inbox prompt and a calendar-only token sees no briefing. Arguments are strings; a required one that is missing is -32602, and one the prompt does not declare is too. What comes back is one user message, ready to send to a model. The prompts live in the endpoint's own bundle, so they need no configuration of their own.
Resources
Resources are how a client attaches something by reference instead of pasting its text. This endpoint enumerates nothing: resources/list is empty, because a mailbox and a Drive hold far too much to list, and everything readable is reached by resources/templates/list plus an identifier a search returned.
| Template | Scope | A read yields |
|---|---|---|
ox:///files/{id} | read_files | The text of a text file, or anything else up to 5 MiB as Base64 in blob |
ox:///mail/{folder}/{id} | read_mail | The message as text, headers first, cut at 100000 characters |
ox:///mail/{folder}/{id}/attachment/{attachment} | read_mail | The text of a text attachment, or anything else up to 5 MiB as Base64 in blob |
Percent-encode a slash inside an identifier, since a Drive identifier and a mail folder both contain one. A read is answered like a tool call: -32602 for a URI that names nothing (-32002 with the URI in data on the earlier revisions, as they prescribe), 403 with a challenge when the token lacks the scope, and it draws a permit from the same rate limit, since reading a file costs what a tool call costs. Bytes are never cut: an item without a text form above 5 MiB is refused with -32602, where the size is known before anything is read. Text that was cut carries _meta: {"com.openexchange/truncated": true}, a message also when its source was cut before it was converted to text.
Statuses and error codes
| Status | JSON-RPC code | When |
|---|---|---|
200 | The call succeeded, isError in the result says whether the tool itself reported a problem | |
200 | A tool call refused for the call limit or for too many calls at once: isError: true, Retry-After says when to try again | |
200 | -32602 | The request is well formed but names something wrong: an unknown tool, prompt or resource, arguments that are not an object, a prompt argument that is missing, undeclared or not a string, a resource URI that names nothing, a resource without a text form above 5 MiB; on 2025-03-26 and 2025-06-18 also arguments that violate a tool's input schema |
200 | -32002 | Earlier revisions only: a resource URI that names nothing; data.uri names it |
202 | A notification was accepted | |
400 | -32700 | The body is not exactly one JSON object in strict JSON: not JSON, several messages, content after the object, single quotes or unquoted names |
400 | -32600 | The body is a JSON-RPC batch (send one request per POST); jsonrpc is not the string "2.0"; method, id or params has the wrong type; or an object has more members than the platform's JSON limit com.openexchange.json.maxSize (default 2500) allows |
400 | -32602 | _meta is missing or wrong, which makes the request itself malformed |
400 | -32020 | A transport header contradicts the body |
400 | -32022 | An unsupported protocol revision; data names the supported and the requested one |
401 | No usable bearer token; WWW-Authenticate names the metadata document and the scopes | |
403 | The token lacks the tool's scope (error="insufficient_scope", no body), or a disallowed Origin was sent (-32600) | |
403 | -32600 | The token is valid, but its user may not use the endpoint (no capability mcp, also when forced off through com.openexchange.capability.forced.mcp) or cannot sign in (a disabled user or context); message The user may not use the MCP endpoint, no challenge |
404 | -32601 | The method is unknown, which this revision answers with 404 rather than 200 |
405 | The verb is not POST | |
413 | -32700 | The body is larger than 1 MiB. Both ways of finding out answer alike: a Content-Length above the limit is refused before reading, a body that never announced its length while it is read |
415 | -32700 | The Content-Type names a charset the runtime does not know; send UTF-8 |
429 | -32000 | A resource read refused because the call limit is exhausted, or the user already runs as many calls at once as allowed; Retry-After says when to try again |
500 | -32603 | An unexpected failure, including a backend that fails while a resource is read |
503 | -32603 | The token could not be checked at all, e.g. the database or the key source is unavailable, or its user could not be loaded; no challenge, Retry-After: 30, the client keeps its token |
The endpoint's own errors carry a code of their own, so that a log can be filtered and alerted on without matching free text:
| Code | Meaning | What it usually says |
|---|---|---|
MCP-0001 | A service the tool needs is not available | The node is missing the package behind that module, or it is still starting |
MCP-0002 | A message, file or attachment could not be read | The backend refused or the content is broken; the cause is logged |
MCP-0003 | A value is not valid | The caller sent something the tool cannot use; nothing is wrong with the node |
MCP-0004 | An item is too large to serve | A resource read of a file or attachment above 5 MiB; reaches the caller as -32602 |
MCP-0001 and MCP-0002 reach the caller as a tool error (200 with isError: true), not as a failed request.
A result can be incomplete without being an error. A user's calendars come from several places, reminders from the tasks as well as from the calendars, contacts from several address books, and a folder list from many folders that are loaded one by one. Where one of those could not be read while the others answered, the result carries partial: true and a warnings array saying what is missing, instead of passing off the rest as the whole. calendar_list_events, reminders_list, contacts_search and the five *_folders tools do this.
A problem the caller can act on is not an error response: a value outside its bounds, an unparseable date, a range that is too wide, an unknown message or folder and anything the backend reports come back as 200 with isError: true and a message naming the parameter or the cause.
The earlier handshake
Revisions 2025-03-26, 2025-06-18 and 2025-11-25 are spoken on the same path for clients that have not moved to 2026-07-28. A probe on 2026-09-15 showed claude.ai custom connectors opening with initialize and 2025-11-25; Claude Code speaks 2026-07-28. A request is taken as one of the earlier revisions when its MCP-Protocol-Version header names one, or when it carries neither the header nor a params._meta revision, in which case 2025-03-26 is assumed as those revisions prescribe. What differs from the description above:
- The first request is
initializewithparams.protocolVersion,capabilitiesandclientInfo. The result carriesprotocolVersion, the requested revision where it is one of the three, otherwise2025-11-25; thecapabilitiestools,promptsandresources;serverInfoandinstructions. NoMcp-Session-Idis issued, so the client sends none and every request still stands on its own. - The client follows with the notification
notifications/initialized, answered with202like every notification.pingis answered with an empty result. tools/list,tools/call,prompts/list,prompts/get,resources/list,resources/templates/listandresources/readtake the same parameters and return the same results as above, withoutresultType,ttlMs,cacheScopeand the_metablock, which those revisions do not know. No_metais required in the request.Mcp-MethodandMcp-Nameare not required; where a client sends them, they are checked the same way.- Arguments that violate a tool's input schema are
-32602on2025-03-26and2025-06-18, and a tool result withisError: trueon2025-11-25, as each revision prescribes. A resource URI that names nothing is-32002with the URI indata. - An unknown method,
server/discoverincluded, is a JSON-RPC error-32601on200, as those revisions expect; a body that is not JSON stays400. - Authentication, scopes, the limits, the audit line and the metrics are the same. The first
initializeof a connector without a token gets the401challenge that starts its OAuth flow.
Not offered from those revisions: server-sent event streams (GET stays 405), JSON-RPC batches (400 with -32600), resources/subscribe, completion/complete and logging/setLevel.
The metadata document
GET /.well-known/oauth-protected-resource/mcp
Answered without a credential and never cached (no-store, since the document names itself from the forwarded headers and must not reach a second caller from any cache), with resource, authorization_servers, scopes_supported and bearer_methods_supported. A client that runs the OAuth flow itself reads it after the first 401 to learn which authorization server to use. scopes_supported names the scopes the tools registered on this node require, so a deployment that leaves out a tool package does not advertise that package's scope; the same list is named in the scope parameter of a challenge. resource and the URL named in challenges are derived from X-Forwarded-Proto and X-Forwarded-Host, so a proxy has to set those rather than pass on what a caller sent: they decide which host the document tells clients to talk to.
Connecting a client
Step-by-step instructions for users, from minting a token to configuring Claude, Cursor or VS Code, are in MCP clients. In short:
Claude Code, with an access token:
claude mcp add --transport http appsuite https://mail.example.com/mcp \
--header "Authorization: Bearer oxa_1_…"
The MCP Inspector and other clients take the same URL and header. A client that runs the OAuth flow itself needs only the URL; it finds the authorization server through the metadata document after the first 401.
To check an installation by hand, with T holding a token:
META='"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}'
curl -s https://mail.example.com/mcp \
-H 'Content-Type: application/json' -H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: tools/list' \
-H "Authorization: Bearer $T" \
-d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\",\"params\":{$META}}"
The answer lists the tools the token's scopes allow. A tool call adds the Mcp-Name header:
curl -s https://mail.example.com/mcp \
-H 'Content-Type: application/json' -H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tools/call' -H 'Mcp-Name: contacts_search' \
-H "Authorization: Bearer $T" \
-d "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"contacts_search\",\"arguments\":{\"query\":\"doe\"},$META}}"
Protocol notes
Revision 2026-07-28 is the one the endpoint is built for: one POST per JSON-RPC request, no sessions, no server-initiated streams, no handshake. The initialize handshake of the three revisions before it is spoken on top, stateless as well, for clients such as claude.ai connectors that have not moved yet. A revision older than 2025-03-26 named in the MCP-Protocol-Version header receives 400 with -32022; an initialize that asks for one is answered with 2025-11-25, as the handshake prescribes. Not offered by design: the stdio transport, streaming responses, sessions, and anything that writes.