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_search for 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 the user module of the HTTP API serves such a user.
  • task_get honors folders that grant read access to the user's own objects only: a colleague's task there is not found.
  • reminders_list leaves 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 arguments that 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: true and a message naming the parameter or the cause, so that a model can correct the call. Revision 2025-11-25 introduced this for schema violations; on 2025-03-26 and 2025-06-18 a 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:

  1. Users. The provider resolves the token to a user through a claim. With contextLookupClaim=email and userLookupClaim=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.
  2. Client scopes. One client scope per platform scope, named exactly read_mail, read_calendar, read_contacts, read_files, read_tasks and read_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 with com.openexchange.oauth.provider.scope.<name>=<platform scope> instead.
  3. Two mappers on every one of those client scopes: an Audience mapper adding the value of com.openexchange.oauth.provider.jwt.audience to aud, and a User Attribute mapper putting the e-mail address into the email claim. They have to sit on the scopes, not on the realm's default scopes: a client that registers dynamically gets only Keycloak's basic scope by default, not email or profile, and a token without aud and email is refused with 401 and invalid_token, which the client then retries after a refresh, in vain.
  4. 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 URI https://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.
  5. Behind a proxy. Keycloak with --proxy-headers xforwarded and its hostname configured for the public URL, which is also the issuer the provider allows. The proxy has to pass every Set-Cookie header of a response; one that keeps only the last ends Keycloak's login in an expired_code loop.

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 initialize with params.protocolVersion, capabilities and clientInfo. The result carries protocolVersion, the requested revision where it is one of the three, otherwise 2025-11-25; the capabilities tools, prompts and resources; serverInfo and instructions. No Mcp-Session-Id is issued, so the client sends none and every request still stands on its own.
  • The client follows with the notification notifications/initialized, answered with 202 like every notification. ping is answered with an empty result.
  • tools/list, tools/call, prompts/list, prompts/get, resources/list, resources/templates/list and resources/read take the same parameters and return the same results as above, without resultType, ttlMs, cacheScope and the _meta block, which those revisions do not know. No _meta is required in the request. Mcp-Method and Mcp-Name are not required; where a client sends them, they are checked the same way.
  • Arguments that violate a tool's input schema are -32602 on 2025-03-26 and 2025-06-18, and a tool result with isError: true on 2025-11-25, as each revision prescribes. A resource URI that names nothing is -32002 with the URI in data.
  • An unknown method, server/discover included, is a JSON-RPC error -32601 on 200, as those revisions expect; a body that is not JSON stays 400.
  • Authentication, scopes, the limits, the audit line and the metrics are the same. The first initialize of a connector without a token gets the 401 challenge 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.