Troubleshooting Guide
Common failure modes and remediation steps for outlook-local-mcp.
Before you file an issue
Call system.about before opening a GitHub issue. It captures build identity and host environment in one snapshot without making any Graph API call:
{tool: "system", args: {operation: "about", output: "summary"}}
Paste the JSON output into the issue report. It provides the version, commit SHA, build date, Go runtime, OS, architecture, distribution channel, and auth backend — everything a maintainer needs to reproduce and triage your issue.
Recommended issue template:
**system.about (summary)**
<paste JSON here>
**What I did:**
**What I expected:**
**What happened:**
Authentication failures
Symptom: A tool call returns an error like authentication required or failed to acquire token.
Cause: No token has been cached for the account, or the cached token has expired.
Remediation:
- Call
{tool: "account", args: {operation: "list"}}to see which accounts are registered and which are disconnected. - If the account shows
disconnected, call{tool: "account", args: {operation: "login", label: "<label>"}}to re-authenticate using the account's persisted method. - If no account is registered at all, call
{tool: "account", args: {operation: "add", label: "default"}}to register and authenticate the first account. - After successful login, retry the original tool call. The
AuthMiddlewarewill automatically retry once authentication completes.
Token refresh
Symptom: A tool call returns an error referencing token expired, refresh token, or invalid_grant.
Cause: The OAuth refresh token expired (typically after 90 days of inactivity or after an Entra ID policy change) or the token cache was corrupted.
Remediation:
- Call
{tool: "account", args: {operation: "refresh", label: "<label>"}}to force a silent token refresh. This succeeds when the refresh token is still valid. - If the refresh fails, call
{tool: "account", args: {operation: "login", label: "<label>"}}to initiate a new interactive authentication flow. - After re-authenticating, the server persists a new token and automatically retries the pending tool call.
Prevention: The server performs a silent token probe at startup. device_code accounts skip the probe to avoid crash loops; other methods probe silently and pre-cache tokens before the first tool call.
Device code flow
Symptom: Authentication returns a device code URL and code in the tool result text instead of completing automatically.
Cause: The MCP client does not support the Elicitation API (e.g., Claude Code). The server falls back to returning the device code directly in the tool result.
Remediation:
- Copy the URL and code from the tool result.
- Open the URL in a browser, enter the code, and complete the Microsoft sign-in.
- After sign-in, call any tool again. The server picks up the cached token automatically.
Browser auth flow
Symptom: A tool call returns an error like browser authentication timed out or the browser window did not appear.
Cause: The server opened the system browser for OAuth login but the user did not complete sign-in within the timeout, or the browser did not open (headless environment).
Remediation:
- Retry the tool call to trigger a fresh browser auth attempt.
- If the browser does not open automatically, use
auth_codemethod instead: setOUTLOOK_MCP_AUTH_METHOD=auth_codeand restart the server. - For headless environments (containers, SSH), use
device_codeauthentication: setOUTLOOK_MCP_AUTH_METHOD=device_code.
Auth code flow
Symptom: The server returns an auth URL and asks you to paste the redirect URL back.
Cause: The auth_code method was selected. After signing in, the browser redirects to the nativeclient URI and shows the full redirect URL in the address bar.
Remediation:
- Copy the full redirect URL from the browser address bar after sign-in.
- If the MCP client supports Elicitation, paste it into the prompt.
- If not, call
{tool: "system", args: {operation: "complete_auth", redirect_url: "<url>"}}to exchange the code for a token.
Keychain locked
Symptom: Token storage fails with an error referencing keychain, SecKeychain, libsecret, or DPAPI.
Cause: The OS keychain is locked, unavailable, or the process lacks permission to access it (common in headless, container, or screen-locked environments).
Remediation:
- Unlock the keychain (macOS: open Keychain Access and unlock the login keychain; Linux: ensure
gnome-keyringorkwalletis running). - If the keychain cannot be unlocked, set
OUTLOOK_MCP_TOKEN_STORAGE=filein the server configuration to switch to the file-based AES-256-GCM encrypted token cache at~/.outlook-local-mcp/token_cache.bin. - Restart the server after changing
OUTLOOK_MCP_TOKEN_STORAGE. - Container and Docker builds (
CGO_ENABLED=0) always use the file-based cache regardless ofTOKEN_STORAGEsetting; no action required.
Note: When TOKEN_STORAGE=auto (the default) and the keychain is unavailable, the server automatically falls back to the file cache. Only TOKEN_STORAGE=keychain (no fallback) will error in this scenario.
Multi-account resolution
Symptom: A tool call errors with multiple authenticated accounts or the wrong account is used.
Cause: Multiple accounts are authenticated and no account parameter was supplied.
Remediation:
- Call
{tool: "account", args: {operation: "list"}}to see all registered accounts with their labels and UPNs. - Pass the
accountparameter to any tool:{tool: "calendar", args: {operation: "list_events", account: "work"}}. Both label and UPN (e.g.,alice@contoso.com) are accepted. - If the MCP client supports Elicitation, a prompt appears for account selection when no
accountparam is supplied; select the desired account from the list.
Account states:
authenticated: Token is cached and valid.disconnected: Account is registered but the token expired or was cleared. Callaccount.loginto reconnect.
Graph 429 throttling
Symptom: A tool call returns an error like Graph API error [TooManyRequests]: 429 or ApplicationThrottled.
Cause: The Microsoft Graph API is rate-limiting requests from this application or tenant.
Remediation:
- Wait a few seconds and retry the tool call. The server applies automatic exponential backoff for 429 responses (configured via
OUTLOOK_MCP_MAX_RETRIESandOUTLOOK_MCP_RETRY_BACKOFF_MS). - If throttling persists, increase
OUTLOOK_MCP_RETRY_BACKOFF_MS(default 1000ms) to give Graph more recovery time between retries. - Avoid tight loops of rapid tool calls, especially for calendar listing operations across large date ranges.
Configuration: OUTLOOK_MCP_MAX_RETRIES (default 3) and OUTLOOK_MCP_RETRY_BACKOFF_MS (default 1000) control the retry behaviour. Check the current values with {tool: "system", args: {operation: "status", output: "summary"}} under config.graph_api.
Inefficient filter
Symptom: A tool call returns an error like Graph API error [ErrorInvalidRequest] or a message referencing InefficientFilter or missing $orderby constraints.
Cause: The Microsoft Graph $filter query contains a condition on a non-indexed field without the required $orderby clause, or uses a filter expression that Graph does not support server-side.
Remediation:
- Avoid filtering on non-indexed properties. Graph indexes
subject,start/dateTime,end/dateTime,organizer/emailAddress/address, andisOrganizerfor calendar queries. - When using
$filteronstart/dateTimeorend/dateTime, include$orderby=start/dateTime(orend/dateTime) in the same request. - For mail queries, use the
searchparameter (KQL syntax) instead of$filterfor full-text search.$filteron mail supportsisRead,isDraft,hasAttachments,importance,flag/flagStatus, andreceivedDateTime. - If the error persists, use
output=rawon the offending tool to see the full OData error detail, then adjust the filter expression.
Search query rejected
Symptom: A mail search_messages call returns an error such as search query "subject:Design" Review" contains a double quote that does not delimit a property value or enclose the whole query; write a multi-word property value in parentheses, for example subject:(Design Review), and remove any other double quote, then retry.
Cause: The query carries a stray double quote that neither encloses the whole query nor delimits a property value. The server refuses it before calling Microsoft Graph, because Graph would otherwise return a parse error that names only a character position and neither the cause nor the fix.
Remediation:
- Write a multi-word property value in parentheses, not in quotes:
subject:(Design Review), notsubject:"Design Review". The parenthesised form matches all of its tokens in any order. - Remove any other double quote from the query and retry.
- A single bare term (
Contoso) and a compound expression (subject:(Design Review) from:alice@contoso.com) both work as written; the server supplies the enclosing quotes Graph requires.
Related symptom (older logs, or calling Graph directly): A Microsoft Graph parse error naming a character position, such as character ':' is not valid at position 7 for a query like subject:Contoso. The cause is the missing enclosing double quotes that Graph requires around a $search value. The server now supplies those quotes for you, so a query sent through search_messages no longer produces this error. You will still see it if you call Graph directly without quoting the value.
Related symptom (silent wrong results, before this fix): An unquoted multi-word search such as report summary returned the newest messages in the mailbox rather than matches, with no error at all. If you saw a search return recent, unrelated mail and concluded the mailbox or the index was broken, this was the cause: the unquoted multi-word term was discarded by Graph. The server now encloses the query, so a multi-word search filters instead of being discarded, and a search that matches nothing correctly returns zero results.
Mail disabled
Symptom: The mail tool returns mail access is not enabled or unknown operation.
Cause: The OUTLOOK_MCP_MAIL_ENABLED environment variable is not set (default is false). Mail tools are only registered when mail access is explicitly enabled.
Remediation:
- Set
OUTLOOK_MCP_MAIL_ENABLED=truein the server's environment configuration and restart the server. - Verify the setting with
{tool: "system", args: {operation: "status", output: "summary"}}and checkconfig.features.mail_enabled. - On first enable, a new OAuth consent for
Mail.Readis required. The authentication flow triggers automatically on the next tool call.
Mail management disabled
Symptom: Draft operations (create_draft, create_reply_draft, create_forward_draft, update_draft, delete_draft, add_attachment) or received-message writes (move_message, set_flag, set_categories, mark_read) return mail management is not enabled or unknown operation.
Cause: OUTLOOK_MCP_MAIL_MANAGE_ENABLED is not set. Mail management is a separate opt-in that implies MAIL_ENABLED, and it gates draft management, draft attachments, and received-message management.
Remediation:
- Set
OUTLOOK_MCP_MAIL_MANAGE_ENABLED=true(this automatically enablesMAIL_ENABLEDas well) and restart the server. - Verify with
{tool: "system", args: {operation: "status", output: "summary"}}and checkconfig.features.mail_manage_enabled. - On first enable, a new OAuth consent for
Mail.ReadWriteis required (supersedesMail.Read). The authentication flow triggers automatically on the next tool call.
Note: The server never requests Mail.Send. Drafts are created in the Outlook Drafts folder; the user sends them manually from Outlook.
Contacts tool not listed
Symptom: The client's tool list shows only calendar, mail, account, and system, and a call to {tool: "contacts", ...} fails with an unknown-tool error rather than an unknown-operation error.
Cause: OUTLOOK_MCP_CONTACTS_ENABLED is not set (default is false). Unlike mail, which is always registered and gates its verbs, the whole contacts tool is registered only when the flag is set, because every contacts verb needs a scope the default configuration does not request. An absent tool is the expected default state, not a fault. See Contacts gating.
Remediation:
- Set
OUTLOOK_MCP_CONTACTS_ENABLED=truein the server's environment configuration and restart the server. - Verify with
{tool: "system", args: {operation: "status", output: "summary"}}and checkconfig.features.contacts_enabled, or list tools in the client: a tool namedcontactsshould be present alongside the four default ones.{tool: "contacts", args: {operation: "help"}}returns the verb list and needs no authentication. - If the tool is still missing, confirm the variable reached the server process rather than only the shell: environment variables set in a Claude Desktop or Claude Code configuration file take effect only after the client restarts the server.
Note: system.status does not report a contacts feature flag. The presence of the contacts tool in the tool list is the check.
Contacts consent prompt on first use
Symptom: After enabling OUTLOOK_MCP_CONTACTS_ENABLED=true, the next tool call, including a calendar or mail call, opens a Microsoft sign-in page asking to approve permissions the account has already approved before.
Cause: Enabling contacts adds Contacts.Read and People.Read to the requested scope set. Consent is incremental, so the cached token no longer covers the requested scopes and the configured authentication flow runs again. The consent screen lists the previously granted scopes alongside the two new ones; that is the identity platform restating the full set, not a request for anything wider.
Remediation:
- Complete the sign-in once. The refreshed token covers the new scope set and subsequent calls authenticate silently.
- Confirm what was requested in the server's startup log record
graph client initialized, whosescopesfield names every requested scope. - Expect exactly
Contacts.ReadandPeople.Readto be added.Contacts.ReadWriteis never requested; if a consent screen names it, the prompt is not coming from this server. - In a work or school tenant where user consent is restricted, the sign-in ends in an administrator-approval message. Ask an administrator to grant the two delegated read scopes, or unset the flag to return to the previous scope set.
Note: Turning the flag back off does not revoke consent already granted. It stops the scopes being requested and removes the tool; revoke the grant in the account's Microsoft app-permissions page if that is the intent.
Teams tool not listed
Symptom: The client's tool list does not include teams, and a call to {tool: "teams", ...} fails with an unknown-tool error rather than an unknown-operation error.
Cause: OUTLOOK_MCP_TEAMS_ENABLED is not set (default is false). The whole teams tool is registered only when the flag is set, because every Teams verb needs a scope the default configuration does not request. An absent tool is the expected default state, not a fault. See Teams gating.
Remediation:
- Set
OUTLOOK_MCP_TEAMS_ENABLED=truein the server's environment configuration and restart the server. - Verify with
{tool: "system", args: {operation: "status", output: "summary"}}and checkconfig.features.teams_enabled.{tool: "teams", args: {operation: "help"}}returns the verb list and needs no authentication. - If the tool is still missing, confirm the variable reached the server process rather than only the shell: environment variables set in a Claude Desktop or Claude Code configuration file take effect only after the client restarts the server.
- Expect a sign-in prompt on the first call after enabling: four read scopes are added to the requested set and consent is incremental.
Chat.Read,ChannelMessage.Read.All,OnlineMeetings.Read, andOnlineMeetingTranscript.Read.Allare the only Teams scopes this server ever requests; if a consent screen names a send or write scope, the prompt is not coming from this server. In a work or school tenant where user consent is restricted, the sign-in ends in an administrator-approval message and an administrator must grant the four delegated read scopes.
Note: Nothing in the domain sends, posts, edits, or deletes a Teams message. teams.compose_reply returns prepared text for the user to paste; it posts nothing.
Teams meeting or transcript not resolved
Symptom: {tool: "teams", args: {operation: "get_online_meeting", ...}} is refused for naming neither or both of meeting_id and join_web_url, resolves a join URL to no meeting, or a following list_transcripts or get_transcript call reports the meeting identifier is unusable.
Cause: Transcript reads are keyed by a meeting-scoped identifier that a calendar event does not carry. An event carries the meeting's join URL, so the identifier is obtained by resolving that URL first. A join URL resolves only for a meeting in the signed-in account's own tenant, and only exactly one identifier may be supplied, because there is no unfiltered meeting listing to fall back on.
Remediation:
- Read the event with
{tool: "calendar", args: {operation: "get_event", ...}}and take the completehttpsjoin link it carries, not a fragment of it. - Resolve it with
{tool: "teams", args: {operation: "get_online_meeting", join_web_url: "<the join URL>"}}, supplyingjoin_web_urlormeeting_idbut never both. - Use the returned meeting identifier for
list_transcripts, then read one transcript withget_transcript. The full WEBVTT text is returned only underoutput: "raw". - If the URL still resolves to nothing, confirm the event belongs to this account. A meeting organised on another tenant is not resolvable here, and a meeting that was never recorded with transcription on holds no transcript to list.
Teams transcript access is disabled or unattributed
Symptom: list_transcripts or get_transcript fails with the code GraphAccessToTranscriptsDisabled, or get_transcript returns contentFormat application/vnd.microsoft.graph.transcript+text with no speaker names.
Cause: Both are tenant policies. GraphAccessToTranscriptsDisabled means the tenant blocks transcript access through Microsoft Graph. When the tenant does not allow speaker attribution, Graph refuses the WEBVTT format with SpeakerAttributionNotAllowed, and the server reads the transcript again as text without speaker names.
Remediation:
- For
GraphAccessToTranscriptsDisabled, ask a Teams administrator to allow Graph access to transcripts in the Teams admin center or withSet-CsTeamsMeetingConfiguration. No retry, consent, or request change can work until the policy changes. - Verify with
{tool: "teams", args: {operation: "list_transcripts", meeting_id: "<id>"}}, which returns metadata when access is allowed. - For the unattributed format, no action in this server changes the result. Speaker names return only when the tenant allows speaker attribution.
Teams channel read is missing an identifier
Symptom: {tool: "teams", args: {operation: "list_channel_messages", ...}}, get_channel_message, list_channel_message_replies, or a channel-shaped compose_reply is refused before any request is issued, with an error naming team_id or channel_id.
Cause: A channel is addressed by a team identifier and a channel identifier together; neither alone identifies it. This server offers no verb that enumerates joined teams or their channels, so the pair is not obtained by browsing.
Remediation:
- Locate a message in the channel with
{tool: "teams", args: {operation: "search", query: "<text from the channel>"}}. Each channel hit carries theteamIdandchannelIdthe channel reads require, alongside the messageid. - Pass both identifiers on every channel call, and the message
idasmessage_idwhere the verb also names one. - For a chat rather than a channel, use
chat_idinstead:list_chatsreturns it, as does thechatIdof a chat search hit.compose_replytakeschat_idor bothteam_idandchannel_id, never a mixture of the two shapes.
Attachment target is not a draft
Symptom: {tool: "mail", args: {operation: "add_attachment", ...}} fails with message is not a draft: this tool only operates on messages with isDraft=true, and nothing is attached.
Cause: message_id names a message that has already been sent or received. A file can only be attached to a message still in composition; the service does not allow the content of a delivered message to change, so the refusal is issued before any upload is started rather than surfaced as a Graph rejection mid-transfer.
The usual source of the wrong identifier is a list_messages or search_messages result taken from the Inbox rather than from Drafts, or a stale identifier from before a move_message, which mints a new identifier for the moved copy.
Remediation:
- Create the target with
{tool: "mail", args: {operation: "create_draft", ...}}, or withcreate_reply_draftorcreate_forward_draftwhen replying or forwarding, and use the identifier the confirmation reports. - To attach to an existing draft, locate it with
{tool: "mail", args: {operation: "list_messages", folder_id: "Drafts"}}and use that identifier. - Confirm the target before retrying:
{tool: "mail", args: {operation: "get_message", message_id: "<id>"}}reports whether the message is a draft.
Note: To put a file on a message that is already sent, forward it with create_forward_draft and attach to the forward draft.
Note: This restriction is a mail one. A calendar event accepts an attachment at any point in its life through {tool: "calendar", args: {operation: "add_event_attachment", ...}}, and attaching a file to an event does not notify its attendees.
Attachment upload did not complete
Symptom: add_attachment on a mail draft, or add_event_attachment on a calendar event, fails on a large file with a transfer error, or with a request timeout, and the attachment does not appear on the draft or the event. Small files attach normally.
Cause: Both write paths choose their transfer the same way. Above roughly 3 MB the file is transferred as a chunked upload session rather than a single request, so it is exposed to a longer window in which the connection can drop, the request timeout can elapse, or the service-side session can expire. A session that does not complete is reported as a failure, never as a partial success: no half-written attachment is left behind, and the draft or event is unchanged. The confirmation of a successful call names the path it used, so a call that reported Transfer: chunked upload session is the one this entry describes.
The upload URL the service issues carries a pre-authenticated token, so it is redacted from every error. An error that names [upload URL redacted] is this failure mode, not a configuration problem.
Remediation:
- Retry the call. A dropped or expired session is not resumable, but a retry starts a fresh session and the earlier failure leaves nothing to clean up.
- Confirm the target is unchanged before retrying, so a successful upload reported as a timeout is not duplicated:
{tool: "mail", args: {operation: "list_attachments", message_id: "<draft id>"}}for a draft,{tool: "calendar", args: {operation: "list_event_attachments", event_id: "<event id>"}}for an event. - On a timeout, raise
OUTLOOK_MCP_REQUEST_TIMEOUT_SECONDSand restart the server; a large file on a slow link can need more than the default. - Prefer a smaller file where the content allows it. The upper bound is
OUTLOOK_MCP_MAX_ATTACHMENT_SIZE_BYTES, which governs both domains, and a file above it is refused before any transfer starts, with an error naming the measured size and the bound.
Note: No verb removes an attachment in either domain, so a duplicate left by a retry has to be deleted from Outlook directly. Checking the target first, as in step 2, is what avoids that.
Event not found
Symptom: A calendar verb taking an event_id (get_event, update_event, reschedule_event, list_event_attachments, get_event_attachment, add_event_attachment) fails with a Graph ErrorItemNotFound or ErrorInvalidIdMalformed, and nothing is read or written.
Cause: event_id does not resolve to an event on the signed-in mailbox. The usual sources are a stale identifier from a conversation held before the event was deleted or moved between calendars, an identifier taken from a different account than the one the call names, or a value that is not an event identifier at all, such as a subject line. An identifier that is not well-formed is refused before any request is issued; one that is well-formed but no longer resolves is refused by the service.
An event attachment adds a second identifier with the same failure shape: attachment_id is scoped to one event, so an identifier read from one event does not resolve on another.
Remediation:
- Re-resolve the event rather than reusing an identifier from earlier in the conversation:
{tool: "calendar", args: {operation: "search_events", query: "<subject>"}}or{tool: "calendar", args: {operation: "list_events", start_datetime: "...", end_datetime: "..."}}, and use theidthose return. - If the call names an
account, resolve the event under that same account. Event identifiers do not transfer between mailboxes. - For an attachment, re-read the identifier from
{tool: "calendar", args: {operation: "list_event_attachments", event_id: "<event id>"}}on the same event you are downloading from. - If the event genuinely no longer exists, create it again rather than retrying; a deleted event's identifier never resolves after deletion.
Move destination not found
Symptom: {tool: "mail", args: {operation: "move_message", ...}} fails with a Graph ErrorItemNotFound or ErrorInvalidIdMalformed, and the message has not moved.
Cause: destination_folder_id does not resolve to a mail folder on the signed-in mailbox. The parameter takes a folder identifier, not a folder name, so a value such as Archive or Deleted Items is refused. The identifier is also mailbox-specific: one obtained under a different account will not resolve.
The other cause of the same Graph error is a stale message_id. A move mints a new identifier for the moved message and the original stops resolving, so re-issuing a move with the identifier from before an earlier move fails here rather than at the destination.
Remediation:
- Obtain a valid destination identifier with
{tool: "mail", args: {operation: "list_folders", output: "summary"}}and pass theidof the target folder, not its display name. - If the call names an
account, list folders under that same account, because folder identifiers do not transfer between mailboxes. - If the message was moved before, re-read it with
list_messagesorsearch_messagesto obtain its current identifier; the confirmation from the previous move also carries it. - Verify the outcome by listing the destination folder's messages, not by re-issuing the move: the move is not idempotent, and a second successful call moves the message again.
A mailbox reports an error inside a schedule reply
Symptom: {tool: "calendar", args: {operation: "get_schedule", ...}} succeeds, but one of the named mailboxes carries an Error: line instead of its busy periods, typically ErrorAccessDenied or MailboxNotEnabledForRESTAPI. The other mailboxes in the same call return their blocks normally.
Cause: Microsoft Graph grades each mailbox in a schedule query separately and reports a per-mailbox failure inside an otherwise successful reply. The whole call is not failed, so the reply is a mixture: mailboxes the signed-in user may view, and mailboxes it may not. The usual causes are a mailbox that has not shared free/busy with the signed-in user, a room or shared mailbox the account has no permission on, an address that is a distribution list rather than a mailbox, and an address misspelled into one that does not exist.
This is why a mailbox with an error is stated rather than omitted: an omitted mailbox is indistinguishable from a mailbox with nothing in the diary, and "nobody is busy" is the wrong conclusion to draw from "you may not look".
Remediation:
- Read the error text and the response code on the mailbox's own section. They come from Graph unchanged and name which of the causes above applies.
- Confirm the address is a mailbox and is spelled correctly. A distribution list address is accepted by the request and refused per-mailbox.
- If the call names an
account, confirm that account has permission on the mailbox. Free/busy visibility is granted per mailbox, so an address readable under one account is not necessarily readable under another. - For a mailbox the signed-in user genuinely cannot view, ask its owner or the tenant administrator to grant free/busy visibility; no parameter on this call can substitute for that permission.
- Verify by re-running the same call: a mailbox whose permission has been granted returns blocks and working hours in place of the error line, while the other mailboxes' output is unchanged.
find_meeting_times fails on a personal Microsoft account
Symptom: {tool: "calendar", args: {operation: "find_meeting_times", ...}} returns a Graph error on a signed-in account that is a personal Microsoft account (an @outlook.com, @hotmail.com, or @live.com identity, or any account registered with TENANT_ID=consumers), while get_free_busy and list_events on the same account succeed. The error text states that the account must be a work or school account.
Cause: Microsoft Graph does not support POST /me/findMeetingTimes for personal Microsoft accounts; the endpoint's permissions table lists the delegated personal-account case as "Not supported". The server still offers the verb on every account because the account type is not known until Graph answers, so the refusal arrives from Graph rather than from the server's own validation.
Remediation:
- Confirm the account type with
{tool: "account", args: {operation: "list_accounts"}}. A personal account cannot be made to work with this verb; no parameter substitutes for a work or school identity. - If a work or school account is also registered, repeat the call with
accountset to that identity. - If only a personal account is available, use
get_scheduleto read the attendees' free/busy blocks and choose a slot from them, or useget_free_busyfor the signed-in user's own busy periods. - Verify by re-running the same
find_meeting_timescall under the work or school account: it returns ranked suggestions in place of the error.
Read-only mode
Symptom: Write tool calls (create, update, delete, cancel, draft operations) return server is in read-only mode.
Cause: OUTLOOK_MCP_READ_ONLY=true is set. In read-only mode all write operations are blocked at the ReadOnlyGuard middleware before reaching the handler.
Remediation:
- If read-only mode is intentional (e.g., a shared or supervised environment), do not change this setting. Only read verbs (
list_*,get_*,search_*,status) are available. - To re-enable writes, remove or set
OUTLOOK_MCP_READ_ONLY=falseand restart the server. - Verify the current mode with
{tool: "system", args: {operation: "status"}}and look forread-only=onin the Features line.
Log file location
Logs are written to the path configured in OUTLOOK_MCP_LOG_FILE. By default, no file logging is active and logs are written only to stderr (which the MCP client typically captures but does not surface in the chat).
To enable file logging:
- Set
OUTLOOK_MCP_LOG_FILE=/path/to/outlook-local-mcp.login the server environment. - Set
OUTLOOK_MCP_LOG_LEVEL=debugfor verbose output during troubleshooting. - Restart the server.
The log file path and current level can be read with {tool: "system", args: {operation: "status", output: "summary"}} under config.logging.log_file and config.logging.log_level.
PII sanitization: When OUTLOOK_MCP_LOG_SANITIZE=true (the default), email addresses and other identifiers are replaced with redacted placeholders in all log output. Set to false only in controlled debugging sessions.
Audit log: When OUTLOOK_MCP_AUDIT_LOG_ENABLED=true, a structured audit trail is written to OUTLOOK_MCP_AUDIT_LOG_PATH (defaults to a file alongside the main log). The audit log records every tool invocation with timestamp, operation name, account label, and outcome.
Account lifecycle
The account domain tool manages the full lifecycle of Microsoft accounts registered with the server.
Add an account
{tool: "account", args: {operation: "add", label: "work"}}
Registers and authenticates a new account. Optional parameters: client_id, tenant_id, auth_method. The UPN (alice@contoso.com) is resolved from Graph /me and persisted to accounts.json after successful authentication.
List accounts
{tool: "account", args: {operation: "list"}}
Returns all registered accounts with label, UPN, authentication state, and auth method. Disconnected accounts (expired tokens) are shown as first-class entries, not hidden.
Log in a disconnected account
{tool: "account", args: {operation: "login", label: "work"}}
Re-authenticates a disconnected account using its persisted auth_method, client_id, and tenant_id. Errors if the account is already connected.
Log out an account
{tool: "account", args: {operation: "logout", label: "work"}}
Disconnects an account without removing its configuration from accounts.json. The account remains visible as disconnected and can be reconnected via account.login.
Force token refresh
{tool: "account", args: {operation: "refresh", label: "work"}}
Forces a silent token refresh (ForceRefresh=true). Returns the new token expiry. Useful after an Entra ID permission change or when token staleness is suspected.
Remove an account
{tool: "account", args: {operation: "remove", label: "work"}}
Permanently removes the account from the registry, clears its keychain cache, and rewrites accounts.json (atomic temp-file + rename) without the removed entry so the removal survives a restart (see CR-0064). When accounts.json has no entry for the label (for example the implicit default created from env config), the in-memory removal still succeeds for the current session but the entry reappears at the next start. Use account.logout instead when you want to disconnect without deleting the configuration. See Auto-default account for the implicit-default gating rule.
Auto-default account
The implicit default account registration is conditional on accounts.json contents, and account.remove is persistent across restart (see CR-0064).
Ghost-default scenario
On startup, the server registers an implicit "default" account from the env config (OUTLOOK_MCP_CLIENT_ID, OUTLOOK_MCP_TENANT_ID, OUTLOOK_MCP_AUTH_METHOD) when no entry in accounts.json already covers that identity. If the keychain or file token cache has been cleared for that identity, account_list shows a disconnected default entry. The first tool call that falls through to "default" will trigger an authentication prompt (device-code or browser), which may be unexpected in a multi-account setup.
Remedy: Add an entry to accounts.json whose client_id and tenant_id match the env config, or use account_remove default to remove the ghost entry for the current session.
Persistent-removal semantics
account_remove is durable across restarts when accounts.json contains an entry for the removed label. After removal, the server rewrites accounts.json without that entry. The removed account does not return on the next start.
When accounts.json has no entry for the label (for example, the implicit "default" created from env config without any accounts.json), the in-memory removal still succeeds for the current session, but the entry reappears at the next start because main.go re-registers it from the env config.
accounts.json gating rule
The implicit "default" is skipped at startup when either of these is true:
accounts.jsoncontains an entry whoseclient_idandtenant_idmatch the env config, regardless of that entry's label.accounts.jsoncontains any entry with the literal label"default".
When neither is true (for example, accounts.json is absent or empty), the implicit "default" is registered so that single-account env-only setups continue to work without authoring a config file.
Removing a cfg-identity-covering entry causes default to reappear
If accounts.json has a single entry whose client_id and tenant_id match the env config, removing that entry causes the implicit "default" to reappear at the next start. This is intentional: removing the entry from accounts.json removes the gating signal, so main.go falls back to the implicit default. To suppress the implicit default permanently, keep an accounts.json entry that covers the cfg identity under any label.
Container has no keychain access
Symptom: The server logs a warning such as keychain unavailable, falling back to file storage when starting inside a container.
Cause: The OS keychain (Apple Keychain, Windows Credential Manager, libsecret) is not reachable from a Linux container. The container build (CGO_ENABLED=0) detects this at startup and automatically falls back to file-backed token storage at /data/auth/. This is expected behaviour, not an error.
Remediation: No action is required. The fallback is safe for the container use case. Token files are written to /data/auth/ inside the container, which maps to the named volume on the host.
To persist tokens across container restarts, mount a named volume at that path:
docker run -i --rm -v outlook-mcp-auth:/data/auth ghcr.io/desek/outlook-local-mcp:latest
Using a named volume (rather than a bind mount to a host directory) avoids UID/GID mismatch issues. Token files at rest are protected only by filesystem permissions on the host volume, not by the OS keychain. Users who require keychain-grade protection at rest should use the native binary rather than the container.
Verify the active auth backend with:
{tool: "system", args: {operation: "about", output: "summary"}}
The authBackend field in the response will read file when the fallback is active.
In-server documentation access
The server embeds this guide and other user-facing documentation. The LLM can access it directly without leaving the session:
{tool: "system", args: {operation: "list_docs"}}
{tool: "system", args: {operation: "search_docs", query: "token refresh"}}
{tool: "system", args: {operation: "get_docs", slug: "troubleshooting", section: "token-refresh"}}
Each embedded document is also available as an MCP resource at doc://outlook-local-mcp/{slug} (e.g., doc://outlook-local-mcp/troubleshooting). LLM clients that support resources/list and resources/read can fetch documents natively.