MCP server deprecated

The middleware can expose a user's mail, calendar, contacts, files, tasks and reminders to language-model clients as 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. The tools read. Tools that change data exist as well, but only where the operator switches them on, and each write has to be confirmed, by the user where the client can ask the user; see Tools that change data.

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
briefing_get none; each section its read scope What a day holds, in one call: the appointments, invitations awaiting an answer first; the unread inbox mail since the start of the previous working day, flagged first; the open tasks of the user's own folders due that day, then the overdue ones; the reminders coming due by its end. At most 20 entries per section; a section the token may not read is listed in omitted
changes_since none; each section its read scope What changed since a point in time up to 31 days back, in one call: the mail the inbox received, read or not, newest first; the appointments of the user created, changed or canceled, invitations still awaiting an answer first, a deleted one by its time only, without title; the tasks created, changed or completed, by the user or by others. At most 20 per section with truncated, each entry saying what happened in change; a section the token may not read is listed in omitted. Each section is selected by the module's own change timestamps, not by reading everything
contacts_search read_contacts Up to 20 contacts by name or address, then, from three characters on, by the start of the company, and for a query with at least five digits by a phone number containing it: id, folder, display name, primary address, company
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_thread_get read_mail The conversation a message belongs to, found by Message-ID, References and In-Reply-To in its folder and the inbox and sent folder of its account: the message itself and the newest others, at most 50, oldest first, each as mail_get reports it
mail_attachment_get read_mail One attachment of a message: for a text attachment its text cut at 4000 characters, for a PDF or office document its text where it can be converted, for anything else its metadata alone
mail_scheduled_list read_mail Up to 20 of the user's mails that wait to be sent later, scheduled by mail_send or in App Suite, earliest first: scheduledId, when each goes out, whether sending it failed, subject and recipients
mail_snoozed_list read_mail Up to 20 of the user's snoozed mails, those returning first first: snoozedId, when and to which folder each returns, sender and subject
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
mailfilter_list read_mail The user's mail filter rules in the order they apply, without the vacation notice and the rules of App Suite's other features (auto-forwarding, spam, categories): id, name, whether active, and what each tests and does, in the form mailfilter_create takes where the rule fits it, otherwise as the mail filter holds it and marked as not editable
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
calendar_find_slot read_calendar Up to 10 times of a given length, from now on, when the user, up to 10 people and up to 5 rooms are all free, inside the user's working hours as set in App Suite (Monday to Friday, 8 to 18 where not set), at most three per day, earliest first; tentative appointments count as busy, addresses whose calendar is not readable are listed as unknown
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; a PDF or office document by its text where it can be converted; images and other binary files by metadata only
tasks_search read_tasks Up to 50 tasks across every task folder the user sees, open ones unless asked for all, optionally only those due in a range, 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, unless the token holds mail_external. Without it, mail_folders lists the primary account's folders and the other mail tools reach nothing else. mail_external widens what read_mail and write_mail reach to the user's secondary and external accounts and grants nothing on its own: mail_folders then lists every account's folders, defaultN/ identifiers work in every mail tool and in a folder restriction, and a draft replying to or forwarding a message is saved to the drafts folder of that message's account. Only a token the middleware issues acts on it, a personal access token or a grant of the built-in authorization server; a JSON Web Token from an external authorization server keeps the primary account only. The consent page offers it unchecked, and only to a user with another mail account.

Secondary accounts log in with the token's kept credential, like the primary account. External accounts use their own stored password, which is encrypted with the user's password: where the secret source contains <password>, the token session has to recover that password, so it needs a kept password. Under master authentication or OAuth mail access the token keeps it for secret resolution only; the mail login never gets it. A token minted from a session without a password, e.g. after a single sign-on, keeps none, and a password set through provisioning after minting no longer matches; such a session reaches the primary account as before, and an external account answers with OAUTHMAIL-0002. After a password change a new token helps; where logins carry no password, choose a secret source without <password>.

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.

PDF and office documents

The same tools and resource reads return the text of a PDF or an office document (Word, Excel, PowerPoint, OpenDocument, RTF) where they can, cut like any other text. An office document goes to the document converter, which turns it into PDF, and the text of that PDF is read on the node. The text of a PDF file is read on the node only where com.openexchange.mcp.documents.localPdf is set, whether a converter runs or not, since parsing a PDF someone sent is up to the operator. The converter is optional and may fail, so whatever goes wrong leaves the item as it was answered before, metadata in a tool, Base64 in blob for a resource read, and never fails the call:

  • A document above 5 MiB is not converted. A known size is checked before anything is read; otherwise reading stops at 5 MiB. A resource read then reads the item a second time for its bytes.
  • A conversion is abandoned after 20 seconds, counted from when the document has been read.
  • At most 4 PDF files and 4 office documents are converted at once on a node, each kind in slots of its own, so a converter that hangs does not hold up PDF files. A call that finds no free slot does not wait; the log says so at INFO, at most once a minute.
  • After 3 failures of the converter in a row, it is not asked for 60 seconds. The log says so at WARN the first time, and at INFO once it answers again.
  • A PDF's text is read from its first 100 pages, page by page until there is enough. A PDF whose streams for those pages decode to more than 32 MiB, as a hostile file built to fill memory would, a protected PDF, and a page beyond 50,000 glyphs or 2 million drawing operations in all yield no further text.
  • Each conversion takes one more permit of the user's tool call limit. Without one left the document is described as before.

The bundle com.openexchange.mcp.documents.impl does the conversion. Without it, PDF and office documents are described as before.

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.

briefing_get serves any token for the same reason: it reads only the sections the token's scopes allow and names the others in omitted, so a model knows what it was not shown. It only reads; nothing is marked as read, created or deleted on the way.

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.

Every mail, appointment, contact, task, file and reminder a tool returns carries webUrl, a link that opens the item in App Suite, so that a model can hand the user a way to look for themselves. The link is built as share links are: the host the client reached the server on, resolved per context through the host name service where one is installed, and the UI path com.openexchange.UIWebPath (default /appsuite/), followed by the fragment the UI follows, #!&app=io.ox/<module>&folder=…&id=…. An occurrence of a series adds its recurrenceId, and a reminder links to its appointment or task. Behind a proxy, the link is https where the request reached the middleware as secure or com.openexchange.forceHTTPS is set, as for share links.

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 works with calendar_free_busy and calendar_find_slot: find the room, then ask when it is taken or when it is free together with everyone else. 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 and the mail filter rules are read through the mail filter, so vacation_get and mailfilter_list need open-xchange-mailfilter as well; see below. Changing them is up to the tools that change data.

Not offered: deleting mail for good, mail filter rules that forward, redirect or reject, deleting contacts, tasks or files, writing to Drive, the stdio transport, streaming responses. The reading tools are announced in tools/list with annotations saying so: readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false. The tools that change data say what they do instead; see below.

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
briefing_get date no an ISO 8601 date such as 2026-10-01, in the user's time zone today
changes_since since yes an ISO 8601 date such as 2026-10-01 (its start in the user's time zone), a date and time in the user's time zone such as 2026-10-01T14:30, or one with offset; at most 31 days back, not in the future
contacts_search query yes 2 to 100 characters, matched against names and addresses, from three characters on against the start of the company, with at least five digits against phone numbers
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_thread_get id, folder yes of any message of the conversation, as returned by mail_search or mail_get
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
calendar_find_slot attendees yes 1 to 10 e-mail addresses besides the user, who is always included
rooms no up to 5 e-mail addresses of rooms or other resources, as resources_search reports them
from, until yes as for calendar_list_events; at most 31 days apart
duration no 15 to 480 minutes 30
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
dueFrom no tasks due on or after it: a date such as 2026-10-01 (from the start of that day) or a date-time, in the user's time zone unless it carries an offset; a full-time task counts from the start of its day in the user's time zone. With dueFrom or dueUntil set, tasks without a due date are left out no lower bound
dueUntil no tasks due before it, in the same form; a date includes that whole day; must lie after dueFrom no upper bound
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
mailfilter_list 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, a list with fewer or more entries than allowed), 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.

Tools that change data

Tool Scope What it does
calendar_create_event write_calendar Creates an appointment: title, start and end or duration, all day, time zone, location, description, attendees, resources from resources_search, in the user's default calendar or one the user may write
calendar_update_event write_calendar Changes title, time, location, description or attendees of an appointment or one occurrence; refused when the timestamp from calendar_get_event is stale
calendar_respond write_calendar Accepts, declines or tentatively accepts an invitation, with a comment; one occurrence through recurrenceId
calendar_delete_event write_calendar Deletes or cancels an appointment or one occurrence; the organizer only
task_create, task_update, task_complete write_tasks Creates, changes and completes tasks in any task folder the user may write: status, progress, priority, start, due date; completing a series creates its next occurrence
contact_create, contact_update write_contacts Creates and changes contacts in any address book the user may write; other users' entries in the global address book are refused
mail_draft_create write_mail Saves a new message, a reply, a reply to all or a forward to the drafts folder, for the user to review, or to send with mail_send
mail_mark write_mail Marks up to 50 messages of one folder read or unread, flagged or unflagged
mail_move write_mail Moves up to 50 messages of one folder to another folder, or by role to the archive (filed by year, as App Suite does), the trash or the spam folder (reported as spam where a spam handler runs). Never into the drafts folder, where a message could be sent as if the user had written it
mail_delete write_mail Moves up to 50 messages of one folder to the trash, whatever the user's setting for deleting is; never deletes for good, and refuses messages in the trash
mail_folder_create, mail_folder_rename write_mail Creates a mail folder below one the user may create folders in, or renames one the user may rename; the inbox, drafts, sent, spam, trash and archive folders keep their names. A renamed folder gets a new identifier, as do the folders below it
mail_send write_mail Sends a draft from the drafts folder of its account, as App Suite sends a draft the user opened: a copy goes to the sent folder where the user's settings say so, the draft is deleted. Where the user can schedule mails, the draft first waits a recall window, or until sendAt. At most 150 recipients, bcc included. A draft open in App Suite, in a compose window or left from one, is refused, since it would stay ready to be sent again
mail_scheduled_cancel write_mail Stops a mail that waits to be sent later, whether mail_send or the user in App Suite scheduled it; it goes back to the drafts folder as a plain draft
mail_snooze, mail_unsnooze write_mail Snoozes up to 50 messages of one folder of the primary account until a time, as App Suite does: they wait in the snoozed folder and return to their folder then; or brings a snoozed mail back at once. Messages that wait to be sent later are not snoozed
vacation_set write_mail Switches the vacation notice on or off and sets subject, text, first and last day, the own addresses it answers for and the interval; what a call leaves out stays. The preview lists the user's appointments in the period, for a token that also holds read_calendar
mailfilter_create, mailfilter_update, mailfilter_delete write_mail Creates, changes and deletes mail filter rules in a small, typed subset: conditions on sender, recipients, subject, a header or the size; actions that move (not into the drafts folder), flag, mark read, move to the trash and stop. Redirecting, forwarding, rejecting and raw Sieve are not offered; a rule made with them elsewhere can only be switched off, never on. The rules of App Suite's features (vacation notice, auto-forwarding, spam, categories) are neither listed nor changed
calendar_set_reminder write_calendar Sets the user's own reminder of an appointment or one occurrence to a number of minutes before its start, or removes the user's reminders; also on appointments others organize, without mail to anyone
file_create write_files Saves text as a new .txt or .md file of at most 512 KiB in a Drive folder the user may create files in
mail_attachment_save read_mail and write_files Saves one attachment of a message as a new file in a Drive folder, streamed from the mail server to the file storage
mail_draft_attach_file write_mail and read_files Attaches copies of up to 10 Drive files to a draft in the drafts folder of its account; it never sends

Off by default. com.openexchange.mcp.write.enabled switches them on, through the config cascade, so per user or context as well. While it is off they are neither listed nor callable, whatever a token holds. They also need com.openexchange.mcp.write.confirmSecret, the secret their confirmations are signed with; without one of at least 32 characters they stay unavailable, and the log says so.

Scopes. Each tool needs the module's write scope, write_calendar, write_tasks, write_contacts, write_mail or write_files. A tool that reads in one module and writes in another needs both scopes and is listed only for a token that holds both. A client has to ask for a write scope explicitly: the 401 challenge names the read scopes only, and a 403 for a write tool names the read scopes plus those the tool needs, so that a client stepping up keeps what it could do, and lists the ones the token lacks in error_description. A write scope opens the module's write actions in the HTTP API as well, as a read scope opens its reads; the switch governs /mcp only.

Preview and confirmation. Every tool that changes data takes preview: true, which reports what would change, for an appointment also the conflicts and who would get mail, without saving or sending anything, and returns a confirmToken with expiresAt. The write itself takes exactly the previewed arguments and that token instead of preview. The token is an HMAC under a key derived from com.openexchange.mcp.write.confirmSecret with HKDF. It is bound to the user, the access token (or its grant, so that a refresh in between does not void it), the tool, the arguments and the version of the object written. It is valid for five minutes and for one call: redeemed tokens are kept in the shared cache until they expire. A call without a valid token is refused before anything is saved, with a tool error naming the cause: confirm_required, confirm_mismatch, confirm_expired, confirm_used or object_changed. The token proves that a write is the one previewed and that the object did not change since. It does not prove that a person saw the preview: that is the client's part, and clients such as Claude ask before each tool call by default. The tool descriptions tell the model to show the preview and ask.

Confirmation by the user. A client that declares elicitation with form mode in the client capabilities of a request (_meta["io.modelcontextprotocol/clientCapabilities"]; an empty object counts as form mode) confirms its writes through the user instead. A write without preview is answered with resultType: "input_required": a form without fields whose message says what the call would do, worded per tool from its preview (for mail first the recipients outside the installation, then the number of recipients, the subject, the attachments, the sender and the recipients by field), and a requestState. The client shows the question, then sends the same call again with the user's answer in params.inputResponses and the requestState unchanged. Only accept writes; decline and cancel end as the tool errors confirm_declined and confirm_canceled. The requestState is signed like a token and bound the same way: user, credential, tool, arguments and object version, five minutes, one write. It is no confirmToken and a token is no requestState. A retry with a forged, expired, reused or foreign state, or with other arguments, fails with the codes above and writes nothing; a retry without the state or without an answer is asked again. With such a client, a confirmToken alone confirms nothing, and preview: true remains a read. The answer is read from params, never from the arguments, so a model cannot give it. Values taken from the user's data are quoted in the message, cut after 160 characters, and web addresses in them are broken up so that no client renders a link.

Clients without the capability, and the earlier revisions, which would need a request from server to client on a stream, keep the preview and the token. Asking counts as a read; the confirmed retry draws from the write limit. Which way a write is confirmed depends only on what the client declares; there is no property to change it. A confirmToken that a call from such a client carries is redeemed when the user is asked, so that it confirms nothing afterwards.

During an update. A node without this behavior ignores inputResponses and requestState: a retry that reaches one is refused with confirm_required, or with confirm_used where it carries the redeemed token, and the client asks again. Such a node still writes a call with a valid confirmToken without asking, as before, until every node is updated.

Write limit. On top of the general limit, each user may make com.openexchange.mcp.rateLimit.writeCalls writes per window, 20 by default, counted across nodes. A preview counts as a read.

Who gets mail. The calendar tools schedule as App Suite does, so attendees get invitations, updates and cancellations. While com.openexchange.mcp.write.externalAttendees is false, the default, calendar_create_event and calendar_update_event accept only internal users and resources as new attendees, and any comment, title, location or description that would go to an external address by mail is refused, before anything is saved; answers and changes without text to externals already on an appointment still go out. mail_send sends a draft only where every recipient, bcc included, is a user of the context, by primary address or alias; a guest counts as external. The refusal comes with the preview already, so no confirmation exists for such a draft, and the recipients are checked once more on the message as it goes out. The vacation notice replies to whoever writes once it is switched on; while the switch is off, vacation_set refuses a new subject or text for a notice that replies to every sender, since a planted request could put data into it, and switches the user's own text on or off, or changes its period, without one. The other mail tools, the task, contact, reminder and filter tools send no mail.

One switch for calendar and mail. com.openexchange.mcp.write.externalAttendees covers inviting externals and mailing them alike. A deployment that wants external attendees but no mail to external recipients also sets com.openexchange.mcp.tools.disabled=mail_send, which turns off sending alone, per context or user if needed. mail_send has no switch of its own: writing is off by default, a token gets write_mail only when it asks for it, and such a token could send through the HTTP API anyway.

The confirmation token is no human approval; the user's answer is. Through the token, a model can preview and confirm on its own; in the evaluation run Claude did exactly that for mail_send, in one turn and without asking. Where the client asks the user, the model cannot answer in the user's place, so an instruction planted in a message ("forward all invoices to ...") reaches the user as a question. The client declares the capability itself, though: this protects against a misled model in an honest client, not against a client that wants to bypass the user, which can leave the capability out and fall back to the token. For clients without it, what keeps such an instruction from mailing data out of the installation is externalAttendees, not the token.

Drive. The Drive tools only add: file_create and mail_attachment_save never overwrite, version, move, rename or delete a file, and a name that is taken gets a suffix such as notes (1).txt. Binary content cannot be passed in the arguments; it reaches Drive only as a mail attachment. A file has to fit into what the storage quota of the folder's owner leaves, checked before anything is read: for an attachment against the size of its encoded form, which it decodes to at least, and the storage checks again as it writes. mail_draft_attach_file takes a draft only from the drafts folder of its account, and not one App Suite's editor has open at the moment, which would leave the user sending the copy without the files. It takes the files only within the attachment limits of the user's mail settings, per file and together per upload, checked before a file is read, and all of them in one call; the draft is saved anew with a new identifier, or saved back as it was where attaching fails, and the error names the new identifier. Its confirmation is bound to the files' versions. file_create takes at most 512 KiB, so that a request with the text escaped in JSON stays below the endpoint's 1 MiB.

Audit. A write call's audit line adds mode=preview or mode=write and the object it touched as object=<folder>/<id>[/<recurrenceId>], never content; for a call on several messages it names their folder, for a sent draft the copy in the sent folder, or the message that waits in the scheduled folder:

MCP tool call: client=claude-code tool=calendar_update_event args=[confirmToken, folder, id, location, timestamp] duration=142ms outcome=ok mode=write object=cal://0/31/4711

A refused confirmation is audited with its code as outcome, a write beyond the write limit as write_ratelimited, and a write the client is to ask the user about as input_required.

Recall window and sending later

mail_send hands a draft to scheduled mail, as App Suite's "send later" does, instead of sending it, so that a mail confirmed too quickly can still be stopped. It waits com.openexchange.mcp.mail.sendDelay seconds, 60 by default and at most 600, in the scheduled folder, then goes out as App Suite sends it: a copy in the sent folder, the draft gone. 0 sends at once, as before. The result says scheduled: true, when the mail goes out (sendsAt), its scheduledId, a link to the waiting message, and a notice that tells the model to name the time to the user and how to stop it: with mail_scheduled_cancel, or in App Suite. The window protects only as far as the user learns of it, so the question of a client that asks the user names it too: "Send this e-mail? It goes out 60 seconds after you accept and can be stopped until then."

sendAt, an ISO 8601 date-time with offset in the future, schedules the draft for that time instead: never earlier than the window, and no further ahead than com.openexchange.mail.scheduled.maxDaysInFuture allows. The preview and the question show the time the mail would go out if confirmed then. Either way a mail waits at least 10 seconds, so that it is stored before it is due, even where the window is shorter or sendAt is that close.

Mails held in the window are scheduled mails like any other: they count toward com.openexchange.mail.scheduled.maxNumberOfScheduledMailsPerHour (100 by default) and com.openexchange.mail.scheduled.maxNumberOfScheduledMails. Past either limit mail_send refuses and says so, and the draft stays in the drafts folder; it is not sent at once instead, since the user accepted a mail that can be stopped. Raise the hourly limit where users let an assistant send more than that, keeping in mind that com.openexchange.mcp.rateLimit.writeCalls allows up to 20 writes a minute. For a personal access token or a grant of the built-in authorization server, each scheduled mail keeps a copy of the token's mail credential in the mail credential vault until it is sent; a token that expires before then is refused for that time with a message that says so.

A mail goes out on time while its node is up. Should the node fail while it holds the mail, another node sends it once the lock expires (com.openexchange.mail.scheduled.lockExpiryMinutes, 5 by default) and its next check finds it (com.openexchange.mail.scheduled.checkFrequencyMinutes, 30 by default), so a mail can go out that much later than announced.

Where the user cannot schedule mails, the draft goes out at once and the result says so with scheduled: false and a notice: scheduled mail is switched off for the user (com.openexchange.mail.scheduled.enabled), the mail credential vault cannot keep what sends the mail later for this session, or the draft belongs to a secondary account or names the address of one as its sender. A call with sendAt is refused there instead. A draft whose sender belongs to a secondary account that the token cannot reach is refused before anything is sent.

mail_scheduled_cancel stops a mail as App Suite does when the user edits it: the scheduled mail is dropped and the message is a plain draft in the drafts folder again, which the result names. It is confirmed like every other write, since it reaches the mails the user scheduled in App Suite as well. A mail rescheduled after the preview is not stopped (object_changed). A mail that is due while it is stopped may still go out: whichever comes first wins, and the result says which. A token restricted to folders reaches a scheduled mail only where it may reach the scheduled folder; the result of mail_send then tells the model that the mail can be stopped in App Suite.

Snoozing goes through the snoozed mail service, which App Suite's snooze uses as well: mail_snooze moves messages to the snoozed folder until a time, within com.openexchange.mail.snoozed.maxDaysInFuture; mail_unsnooze brings one back at once, with its flags. A token restricted to folders reaches a snoozed mail where it may reach the folder it returns to. mail_snoozed_list reads one page of them from the storage, without the credentials kept with them. As for a mail sent later, a personal access token or a grant leaves a copy of its mail credential with the snoozed mail, and one that expires before the mail returns cannot snooze it.

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"

One of them is a secret: com.openexchange.mcp.write.confirmSecret, which signs the confirmations of write calls, belongs in secretProperties and has to be the same on every node. The others are not. 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, briefing_get, changes_since, the folder listing the other bundles use
com.openexchange.mcp.mail mail_search, mail_get, mail_thread_get, mail_attachment_get, mail_folders, mail_draft_create, mail_mark, mail_move, mail_delete, mail_folder_create, mail_folder_rename, mail_send, mail_scheduled_list, mail_scheduled_cancel, mail_snooze, mail_snoozed_list, mail_unsnooze, mail_attachment_save and mail_draft_attach_file, which reach Drive through the files bundle
com.openexchange.mcp.calendar calendar_list_events, calendar_get_event, calendar_free_busy, calendar_find_slot, resources_search, calendar_folders, calendar_create_event, calendar_update_event, calendar_respond, calendar_delete_event, calendar_set_reminder
com.openexchange.mcp.contacts contacts_search, contact_get, users_search, contacts_folders, contact_create, contact_update
com.openexchange.mcp.files files_search, file_get, files_folders, file_create
com.openexchange.mcp.tasks tasks_search, task_get, tasks_folders, task_create, task_update, task_complete
com.openexchange.mcp.reminders reminders_list
com.openexchange.mcp.vacation vacation_get, vacation_set, mailfilter_list, mailfilter_create, mailfilter_update, mailfilter_delete
com.openexchange.mcp.activity.impl no tools: the activity log of each access token
com.openexchange.mcp.documents.impl no tools: the text of PDF and office documents

The mail, calendar, task and reminder bundles also contribute their sections of briefing_get, so a missing one leaves its section out rather than failing the briefing. 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 its tools exist only where open-xchange-mailfilter is installed as well; vacation_set and the filter tools that write also need the module mailfilter/v2, whose parser and checks they use; 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.

With the built-in authorization server switched on, route /.well-known/oauth-authorization-server and /oauth/ the same way: clients read the first without a credential and send the user's browser to the second. Route all of them to one site; the short-lived state of a sign-in lives in that site's Redis.

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.tools.disabled empty Comma-separated tool names switched off, through the config cascade per context or user: neither listed nor callable, answered as unknown; their resource template (mail_get for ox:///mail/…, mail_attachment_get for attachments, file_get for ox:///files/…) is neither offered nor served, and the prompts that need them disappear. briefing_get reads its sections on its own; switch it off too to keep a module out of the briefing.
com.openexchange.mcp.activity.maxEntriesPerToken 1000 How many calls of one access token are recorded at most for its owner, readable through accesstoken?action=activity; 0 records nothing.
com.openexchange.mcp.activity.retentionDays 30 How many days recorded calls are kept.
com.openexchange.mcp.rateLimit.writeCalls 20 Calls of tools that change data a user may make per window, on top of the tool call limit; a preview counts as a read. 0 disables this limit.
com.openexchange.mcp.write.enabled false Whether the tools that change data are listed and callable; config-cascade aware. A token still needs the module's write scope.
com.openexchange.mcp.write.confirmSecret empty The secret the confirmations of write calls are signed with; at least 32 characters, the same on every node, best from the deployment's secret store. Empty or shorter leaves the tools that change data unavailable. Changing it voids the confirmations handed out before; config-cascade aware.
com.openexchange.mcp.write.externalAttendees false Whether the calendar tools may add attendees outside the installation or send them text, and whether mail_send may send to recipients outside it; one switch for both, config-cascade aware. See Tools that change data.
com.openexchange.mcp.mail.sendDelay 60 How many seconds a mail sent with mail_send waits as a scheduled mail before it goes out, so that it can still be stopped; at most 600, a longer value is cut to it; 0 sends at once; a negative value counts as not set. Without scheduled mail for the user, mail goes out at once. Config-cascade aware. See Recall window and sending later.
com.openexchange.mcp.documents.localPdf false Whether the text of a PDF file is read, on the node. Office documents are converted through the document converter regardless. See PDF and office documents.
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.

Three 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 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
The built-in authorization server, or an external one In addition, every client that signs in through OAuth with the URL alone, such as claude.ai and ChatGPT connectors, including connectors that serve a whole organization

With neither the built-in nor an external authorization server, no client can sign in through OAuth; this is a limit of the installation, 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.

The built-in authorization server

With com.openexchange.mcp.oauth.enabled the middleware is the OAuth authorization server of /mcp itself, so that a client that implements the MCP authorization specification connects with the endpoint URL alone. It serves public clients of this endpoint only, and the tokens it issues are the personal access tokens the platform already has: oxa_… secrets with scopes, listed and revocable like any other. No signing keys and no external server are involved.

The App Suite origin is the issuer. A client finds it through authorization_servers in the metadata document, after any external server named there, and then uses these endpoints:

Path Standard What it does
/.well-known/oauth-authorization-server RFC 8414 Metadata of the server; answered without a credential
/oauth/register RFC 7591 Dynamic registration of public clients, for those without a metadata document (see below); redirect URIs must be https, or http on 127.0.0.1, [::1] or localhost
/oauth/authorize RFC 6749, RFC 7636 Code flow with PKCE S256 only; the resource has to be this endpoint
/oauth/consent The sign-in hand-off and the consent page the browser is sent to
/oauth/token RFC 6749 Redeems a code, refreshes a grant
/oauth/revoke RFC 7009 Revokes a grant with all its tokens

A client need not register: with a client ID metadata document (CIMD), the registration the MCP authorization spec prefers, its client_id is the https URL of a JSON document that names it and lists its redirect URIs, as Claude Code's https://claude.ai/oauth/claude-code-client-metadata. The server fetches the document on the authorization request and checks it like a registration: the client_id in it has to be the URL itself, client_name and redirect_uris are required, and it may hold no secret, so the client is public. A redirect URI on a loopback host matches on any port. The document is kept as its Cache-Control says, between a minute and an hour; one that cannot be fetched or is refused is not kept, and the authorization request ends on an error page. The fetch connects directly, never through a proxy, and only if every address of the host is public; it follows no redirect, reads at most 5 KiB and gives up after 10 seconds, and a node runs at most eight at a time. The token and revocation endpoints never fetch. The middleware needs direct outbound HTTPS to the clients' hosts for it; where it has none, switch documents off, and clients register dynamically instead. The metadata announces it with client_id_metadata_document_supported; com.openexchange.mcp.oauth.clientMetadataDocuments switches it off.

The user signs in through the regular App Suite login, so passwords, a second factor and single sign-on work as they do for the web front-end; the consent page finds the browser's App Suite session. Without one it asks the user to sign in in another tab and checks again every few seconds. The consent page names the client as it declared itself, marked as not verified, or for a metadata document the host that publishes the name; it shows the host the browser returns to, with a warning where that is a program on the same device (a loopback host), offers the read scopes checked and the write scopes unchecked, and states how long access lasts. For each requested module with folders it lets the user limit the grant to some of them, at most 100 per module, each without its subfolders; nothing checked leaves the module unrestricted. The restriction works as for a personal access token (see below) and carries over to every token of the grant. The page cannot be framed and takes a decision only from the session, browser and form it was shown to.

A grant lives as long as com.openexchange.accesstoken.maxLifetimeDays allows (365 days where that is 0). Its access tokens are valid for com.openexchange.mcp.oauth.accessTokenMinutes; the client then refreshes, and the refresh token is replaced on every use. A replaced refresh token presented again revokes the grant: the client and whoever copied it cannot be told apart. The one replaced last is let off for a minute after the refresh, since a client whose answer got lost sends it again.

The user's token list (accesstoken?action=all, GET /accesstokens) shows each grant once, with client set, rather than each access token; deleting that entry revokes the grant. The calls of all its tokens are recorded under the grant, so accesstoken?action=activity with the entry's id lists them across refreshes; they go with the grant. A password change revokes all grants, as it revokes all tokens. A client that disconnects does not necessarily tell the server: claude.ai, for instance, keeps the grant when the user clicks Disconnect, so the user revokes it in the token list.

Mail works as for a personal access token: the grant keeps the mail credential of the session the user consented in, sealed so that only a current token of the grant opens it. A session that provides nothing the mail server accepts, as after some single sign-on logins, yields a grant without mail; the consent page says so and leaves the mail scopes unchecked.

Property Default Meaning
com.openexchange.mcp.oauth.enabled false Switches the server on. The endpoints without a user (metadata, registration, token) follow the server-level value and answer 404 while it is off; the sign-in follows the user's value, so a context or user can be excluded, and a user excluded later cannot refresh any more. Reloadable, config-cascade aware.
com.openexchange.mcp.oauth.allowedRedirectHosts empty Comma-separated hosts clients may register redirect URIs for, for example claude.ai, chatgpt.com, loopback, where loopback stands for the three loopback hosts. Empty accepts every host. Reloadable, config-cascade aware.
com.openexchange.mcp.oauth.registrationsPerHour 10 New registrations one address may make per hour (an IPv6 address counts by its /64); authorization requests are limited to 30 times this value. A public client registering the same metadata again gets the same registration, uncounted. 0 disables both limits. Reloadable.
com.openexchange.mcp.oauth.accessTokenMinutes 60 How long an access token is valid. Reloadable.
com.openexchange.mcp.oauth.clientMetadataDocuments true Whether a client may name itself by the URL of a client ID metadata document instead of registering, which makes the middleware fetch it. Reloadable.

Registrations, sign-ins in progress and codes are kept in the cluster's Redis: an unused registration for a day, a used one for 400 days past its last use, a sign-in for ten minutes, a code for one.

What it does not do: it signs in no client of its own, issues no JWTs, serves no other resource than /mcp, and does not authenticate clients by key (private_key_jwt), so a metadata document has to declare a public client. Its pages are in English. A client that opens the sign-in in an in-app browser with its own cookie store, as some mobile apps do, does not see the App Suite session of the user's browser. Browser-based clients such as the MCP Inspector call the registration and token endpoints from another origin and need CORS for them at the ingress, as for /mcp.

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. The same goes for write_files, which Drive offers since this release: it opens the Drive write actions of the HTTP API as well, deleting and overwriting files and managing file storage accounts included, which the tools of this endpoint do not offer. Sharing is not part of it: granting others permissions on a file or folder, creating or changing share links and notifying those something is shared with take share_files on top, because a share outlives the token that made it. No tool of this endpoint needs share_files. Application passwords are not affected: their scopes are configured, and their write_files keeps sharing as before.

The tools that change data need the write scopes the same modules register: write_calendar, write_tasks, write_contacts, write_mail and write_files. They open the module's write actions in the HTTP API too, so a user grants one only to a client that is to change data, and the operator decides with com.openexchange.mcp.write.enabled whether /mcp offers those tools at all.

Restricted to folders

A user may restrict a personal access token to folders when minting it, per module (how), and a grant of the built-in authorization server on its consent page. Such a token is accepted here and nowhere else, and the tools treat every folder of a restricted module that is not in the list as if it did not exist:

  • A tool or resource that names such a folder refuses it as not found; nothing in it is read or changed.
  • A search (mail_search, tasks_search, files_search, contacts_search) covers the allowed folders only, each without its subfolders. A listed folder that no longer exists is skipped, and mail_search then reports partial with a warning; if no listed folder answers, or the backend is down, the search fails rather than reporting nothing found. A result from elsewhere, such as a message a virtual mail folder shows from another folder, is left out.
  • calendar_free_busy and the conflicts in the previews of the calendar tools show the busy times of every calendar the user reads, as for any colleague, but titles only of appointments in allowed calendars.
  • *_folders, calendar_list_events, reminders_list, briefing_get and changes_since show the allowed folders and what they hold only.
  • A tool between mail and Drive applies the restriction of both modules, to source and target alike: mail_attachment_save finds no message outside the mail folders and no target outside the Drive folders, mail_draft_attach_file no draft and no file outside them.
  • users_search lists the global address book, so it finds nobody unless that address book (6) is in the list.
  • A tool that writes to a default folder when no folder is given - calendar_create_event, contact_create, task_create, and mail_draft_create, which saves to the drafts folder - needs that folder in the list.

Address books of the internal account match in either form, con://0/32 or 32. A restriction lives with the token and cannot be changed; the user revokes the token and mints a new one.

Behind Edge-Auth

Edge-Auth is an authentication service in front of the middleware, meant for a future setup. It is switched on with com.openexchange.external.authentication.enabled=true; without it, everything above applies unchanged.

Behind Edge-Auth the authentication service mints and checks the access tokens. It exchanges a token for an internal JWT before the request reaches the endpoint, and the middleware mints no personal access tokens of its own. For the endpoint this means:

  • Configure the OAuth provider in JWT mode for the issuer and key set of Edge-Auth, as for any external authorization server. Edge-Auth puts the same audience into every token, its jwt.audience (OX App Suite by default), whatever client fetched it. com.openexchange.mcp.audience therefore either names that audience or stays empty; neither keeps out tokens of other clients, and no start-up warning asks for it.
  • The built-in authorization server stays off, whatever com.openexchange.mcp.oauth.enabled says, and the metadata document no longer lists it. Set com.openexchange.mcp.authorizationServers to the issuer of Edge-Auth, so that OAuth clients find it. The log warns at start-up while either of the two settings is wrong.
  • The scopes of a token decide which tools it sees, as for any other token. They are what tells one client from another here.
  • A token that names its grant in the claim grant_id keeps a confirmation valid across a renewal of the Edge-Auth session, which happens every five minutes, and its calls are recorded in the activity log under that grant; the accesstoken module that reads the log is not available behind Edge-Auth, though. Without the claim, a change confirmed after such a renewal fails with confirm_mismatch, and so does a user's answer given across one; the client previews or asks again.
  • Tokens restricted to folders are not available behind Edge-Auth.
  • How such a session reaches the mail server is not settled yet.

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), failed (the tool failed unexpectedly; answered with 500, the stack trace follows at ERROR), and for the tools that change data write_ratelimited, input_required (the client is to ask the user) and the confirmation codes confirm_required, confirm_mismatch, confirm_expired, confirm_used, confirm_declined, confirm_canceled and object_changed. A write call's line also names mode and object; see Tools that change data.

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.

Activity log per token

Besides the audit log, which is for the operator, every tool call and resource read through an access token is recorded for the token's owner: time, client, tool, the folder and item identifiers, preview or write, the outcome; never content. The owner reads it through the access token module of the HTTP API, accesstoken?action=activity&id=<id>, newest first, for example on a token settings page. Calls of an OAuth client are kept under its grant, so the record survives the rotation of its tokens; a personal access token is its own grant. Tokens of an external authorization server are not recorded, since no token listing names them.

The record lives in the table mcp_activity of the user's schema. Recording happens in the background and never holds up or fails a call; an entry that cannot be written is dropped, and the log says so once a minute at most. An hourly clean-up keeps it within com.openexchange.mcp.activity.retentionDays (30) and com.openexchange.mcp.activity.maxEntriesPerToken (1000), and the record goes when its token or grant is revoked and with its user or context. The bundle com.openexchange.mcp.activity.impl keeps the log; without it, nothing is recorded and the endpoint works as before.

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 reads unless the operator allows more. With com.openexchange.mcp.write.enabled off, the default, no tool writes, so an injected instruction cannot make this server act. Where it is on, a write needs a write scope, a preview and a confirmation token for exactly that call, and draws from a write limit of its own. Unless com.openexchange.mcp.write.externalAttendees allows it, mail_send sends to users of the context only and the calendar tools refuse new external attendees and text to externals; mail filter rules cannot forward or redirect. Nothing is deleted for good: messages go to the trash, and an appointment is deleted by its organizer only. Where the client declares elicitation, the user confirms each write in the client; the model cannot. Otherwise the confirmation cannot guarantee a person reading the preview; a model may confirm its own preview, and that rests with the client asking before each call.
  • 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, inputSchema and, from revision 2025-06-18 on, outputSchema. ttlMs 300000, cacheScope private
tools/call Runs one tool. params.name is the tool, params.arguments its arguments. A retry after input_required adds params.inputResponses and params.requestState. 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.

From revision 2025-06-18 on, each tool announces the shape of its structuredContent as outputSchema, a JSON Schema 2020-12 object schema: every property a success or a preview can carry, with its type and meaning, for a tool that changes data with confirmToken and expiresAt. No property is required and none is forbidden, so that an error result, message and for some a code in error, fits as well, and a client that cached the list of an older node keeps validating what a newer one returns during an update. 2025-03-26 has no such field and lists the tools as before.

A write by a client that declares elicitation is answered with a question instead, which the client puts to the user:

{
  "jsonrpc": "2.0", "id": 4,
  "result": {
    "resultType": "input_required",
    "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "open-xchange-mcp", "version": "8.55.0" } },
    "inputRequests": {
      "confirm": {
        "method": "elicitation/create",
        "params": {
          "mode": "form",
          "message": "Send this e-mail now?\nRecipients: 1\nSubject: \"Offsite\"\nFrom: \"Anton <anton@example.com>\"\nTo: \"Berta <berta@example.com>\"",
          "requestedSchema": { "type": "object", "properties": {} }
        }
      }
    },
    "requestState": "s1.…"
  }
}

The retry is the same call with a new id, params.requestState as received and params.inputResponses {"confirm": {"action": "accept", "content": {}}}; see Tools that change data.

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. daily_briefing drives briefing_get alone, which serves any token, so it is listed for every token and tells the model to say which sections were omitted. inbox_triage proposes what to do with the messages and, where mail_move, mail_delete and mail_mark are available to the token, carries out what the user approved, each with a preview first; for a token that only reads, it stops at the proposal. find_meeting_slot drives calendar_find_slot, which keeps to the user's working hours, and finds a room through resources_search. catch_up_on starts with changes_since for the point in time given, a week back by default, and then searches mail, files and calendar for the topic. 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, of a PDF or office document where it can be converted, 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, of a PDF or office document where it can be converted, 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. A write is confirmed with the token only: asking the user would need a request from server to client on a stream. 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. External servers come first, in the configured order; the built-in one follows when it is switched on. 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, the built-in or an external one, 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 user is asked through multi round-trip requests, in form mode only. 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.