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:

  1. Call {tool: "account", args: {operation: "list"}} to see which accounts are registered and which are disconnected.
  2. If the account shows disconnected, call {tool: "account", args: {operation: "login", label: "<label>"}} to re-authenticate using the account's persisted method.
  3. If no account is registered at all, call {tool: "account", args: {operation: "add", label: "default"}} to register and authenticate the first account.
  4. After successful login, retry the original tool call. The AuthMiddleware will 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:

  1. Call {tool: "account", args: {operation: "refresh", label: "<label>"}} to force a silent token refresh. This succeeds when the refresh token is still valid.
  2. If the refresh fails, call {tool: "account", args: {operation: "login", label: "<label>"}} to initiate a new interactive authentication flow.
  3. 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:

  1. Copy the URL and code from the tool result.
  2. Open the URL in a browser, enter the code, and complete the Microsoft sign-in.
  3. 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:

  1. Retry the tool call to trigger a fresh browser auth attempt.
  2. If the browser does not open automatically, use auth_code method instead: set OUTLOOK_MCP_AUTH_METHOD=auth_code and restart the server.
  3. For headless environments (containers, SSH), use device_code authentication: set OUTLOOK_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:

  1. Copy the full redirect URL from the browser address bar after sign-in.
  2. If the MCP client supports Elicitation, paste it into the prompt.
  3. 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:

  1. Unlock the keychain (macOS: open Keychain Access and unlock the login keychain; Linux: ensure gnome-keyring or kwallet is running).
  2. If the keychain cannot be unlocked, set OUTLOOK_MCP_TOKEN_STORAGE=file in the server configuration to switch to the file-based AES-256-GCM encrypted token cache at ~/.outlook-local-mcp/token_cache.bin.
  3. Restart the server after changing OUTLOOK_MCP_TOKEN_STORAGE.
  4. Container and Docker builds (CGO_ENABLED=0) always use the file-based cache regardless of TOKEN_STORAGE setting; 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:

  1. Call {tool: "account", args: {operation: "list"}} to see all registered accounts with their labels and UPNs.
  2. Pass the account parameter to any tool: {tool: "calendar", args: {operation: "list_events", account: "work"}}. Both label and UPN (e.g., alice@contoso.com) are accepted.
  3. If the MCP client supports Elicitation, a prompt appears for account selection when no account param is supplied; select the desired account from the list.

Account states:


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:

  1. Wait a few seconds and retry the tool call. The server applies automatic exponential backoff for 429 responses (configured via OUTLOOK_MCP_MAX_RETRIES and OUTLOOK_MCP_RETRY_BACKOFF_MS).
  2. If throttling persists, increase OUTLOOK_MCP_RETRY_BACKOFF_MS (default 1000ms) to give Graph more recovery time between retries.
  3. 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:

  1. Avoid filtering on non-indexed properties. Graph indexes subject, start/dateTime, end/dateTime, organizer/emailAddress/address, and isOrganizer for calendar queries.
  2. When using $filter on start/dateTime or end/dateTime, include $orderby=start/dateTime (or end/dateTime) in the same request.
  3. For mail queries, use the search parameter (KQL syntax) instead of $filter for full-text search. $filter on mail supports isRead, isDraft, hasAttachments, importance, flag/flagStatus, and receivedDateTime.
  4. If the error persists, use output=raw on 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:

  1. Write a multi-word property value in parentheses, not in quotes: subject:(Design Review), not subject:"Design Review". The parenthesised form matches all of its tokens in any order.
  2. Remove any other double quote from the query and retry.
  3. 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:

  1. Set OUTLOOK_MCP_MAIL_ENABLED=true in the server's environment configuration and restart the server.
  2. Verify the setting with {tool: "system", args: {operation: "status", output: "summary"}} and check config.features.mail_enabled.
  3. On first enable, a new OAuth consent for Mail.Read is 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) return mail management is not enabled or unknown operation.

Cause: OUTLOOK_MCP_MAIL_MANAGE_ENABLED is not set. Draft management is a separate opt-in that implies MAIL_ENABLED.

Remediation:

  1. Set OUTLOOK_MCP_MAIL_MANAGE_ENABLED=true (this automatically enables MAIL_ENABLED as well) and restart the server.
  2. Verify with {tool: "system", args: {operation: "status", output: "summary"}} and check config.features.mail_manage_enabled.
  3. On first enable, a new OAuth consent for Mail.ReadWrite is required (supersedes Mail.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.


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:

  1. 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.
  2. To re-enable writes, remove or set OUTLOOK_MCP_READ_ONLY=false and restart the server.
  3. Verify the current mode with {tool: "system", args: {operation: "status"}} and look for read-only=on in 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:

  1. Set OUTLOOK_MCP_LOG_FILE=/path/to/outlook-local-mcp.log in the server environment.
  2. Set OUTLOOK_MCP_LOG_LEVEL=debug for verbose output during troubleshooting.
  3. 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:

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.