diff --git a/README.md b/README.md index 7364222..1273c2d 100644 --- a/README.md +++ b/README.md @@ -93,29 +93,55 @@ TENANT_ID=00000000-0000-0000-0000-000000000000 ``` This will open a browser window for you to authenticate with Microsoft and create a `tokens.json` file. -2. **Create MCP config file** (must be done from the cloned repository) +2. **Configure your AI client** (see sections below for Claude Desktop or Cursor) + +### Claude Desktop + +Add the following to your `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "mstodo": { + "command": "node", + "args": [ + "/absolute/path/to/todoMCP/build/cli.js" + ] + } + } +} +``` + +> **Important:** Use `build/cli.js` as the entry point, **not** `build/todo-index.js`. The CLI wrapper handles token loading and is the correct entry point for the server. + +The configuration file is located at: +- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` +- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` +- **Linux**: `~/.config/Claude/claude_desktop_config.json` + +After saving the config, restart Claude Desktop. The `mstodo` server should appear in the MCP servers list. + +The server automatically reads tokens from `tokens.json` in the project directory and refreshes them when they expire, so you do not need to pass tokens as environment variables. + +### Cursor + +1. **Create MCP config file** (must be done from the cloned repository) ```bash npm run create-config ``` This creates an `mcp.json` file with your authentication tokens. -3. **Set up the global MCP configuration** +2. **Set up the global MCP configuration** ```bash # Copy the mcp.json file to your global Cursor configuration directory cp mcp.json ~/.cursor/mcp-servers.json ``` - + This makes the Microsoft To Do MCP available across all your Cursor projects. -4. **Start using with your AI assistant** - - In Cursor, you can now use Microsoft To Do commands directly in any project +3. **Start using with your AI assistant** - Try commands like `auth status` or `list up todos` to get started -The Claude Desktop configuration file is located at: -- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` -- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` -- **Linux**: `~/.config/Claude/claude_desktop_config.json` - ## Available Tools - `auth-status`: Check your authentication status @@ -137,12 +163,35 @@ The Claude Desktop configuration file is located at: - The API requires proper authentication and permissions - Rate limits may apply according to Microsoft's policies +## Token Refresh + +Access tokens expire after approximately 1 hour. The server handles this automatically: + +- When a token expires, the server uses the refresh token to obtain a new access token. +- The refreshed tokens are saved back to `tokens.json`, so they persist across server restarts. +- If an API call returns a 401 error, the server will attempt a token refresh and retry the request. +- Refresh tokens last approximately 90 days. If the refresh token expires, you will need to re-authenticate with `npm run auth`. + +For automatic refresh to work, the `.env` file (containing `CLIENT_ID` and `CLIENT_SECRET`) must be in the project root directory alongside the built files. + ## Troubleshooting +### Server Exits Immediately (Claude Desktop) + +- **Wrong entry point**: Make sure your config points to `build/cli.js`, not `build/todo-index.js`. The `todo-index.js` file has a direct-execution guard that fails on Windows, causing the process to exit without starting the server. + +- **Missing build**: Run `npm run build` to compile the TypeScript source before using the server. + +### 401 Unauthorized Errors + +- **Wrong TENANT_ID for personal accounts**: If you are using a personal Microsoft account (outlook.com, hotmail.com, live.com), you **must** set `TENANT_ID=consumers` in your `.env` file. Using a specific organizational tenant ID will result in 401 errors because your personal To Do data lives in the consumer environment, not in the organizational tenant. + +- **Token expired**: Access tokens last ~1 hour. The server refreshes them automatically, but if refresh fails (e.g., missing `.env` file), you will see 401 errors. Check that your `.env` file exists in the project root with valid `CLIENT_ID` and `CLIENT_SECRET`. + ### Authentication Issues -- **"MailboxNotEnabledForRESTAPI" error**: This typically means you're using a personal Microsoft account. Microsoft To Do API access is limited for personal accounts through the Graph API. - +- **"MailboxNotEnabledForRESTAPI" error**: This typically means you're using a personal Microsoft account with the wrong tenant. Set `TENANT_ID=consumers` in your `.env` file and re-authenticate with `npm run auth`. + - **Token acquisition failures**: Make sure your `CLIENT_ID`, `CLIENT_SECRET`, and `TENANT_ID` are correct in your `.env` file. - **Permission issues**: Ensure you have granted admin consent for the required permissions in your Azure App registration. @@ -151,18 +200,12 @@ The Claude Desktop configuration file is located at: - **Work/School Accounts**: These typically work best with the To Do API. Use `TENANT_ID=organizations` or your specific tenant ID. -- **Personal Accounts**: These have limited access to the To Do API. If you must use a personal account, try `TENANT_ID=consumers` or `TENANT_ID=common`. +- **Personal Accounts**: Use `TENANT_ID=consumers`. Personal accounts require the consumer endpoint for To Do API access. ### Checking Authentication Status -You can check your authentication status using the `auth-status` tool or by examining the expiration time in your tokens: - -```bash -cat tokens.json | grep expiresAt -``` - -To convert the timestamp to a readable date: +You can check your authentication status using the `auth-status` tool, or by examining `tokens.json`: ```bash -date -r $(echo "$(cat tokens.json | grep expiresAt | cut -d ":" -f2 | cut -d "," -f1) / 1000" | bc) +node -e "const t = require('./tokens.json'); console.log('Expires:', new Date(t.expiresAt).toLocaleString(), t.expiresAt < Date.now() ? '(EXPIRED)' : '(valid)');" ``` \ No newline at end of file diff --git a/src/cli.ts b/src/cli.ts index 85c315d..ca12356 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,10 +12,15 @@ const __dirname = path.dirname(__filename); // Check for tokens in environment variables let accessToken = process.env.MSTODO_ACCESS_TOKEN; let refreshToken = process.env.MSTODO_REFRESH_TOKEN; +let expiresAt: number | undefined; -// Define token file path -const TOKEN_FILE_PATH = process.env.MSTODO_TOKEN_FILE || - path.join(process.cwd(), 'tokens.json'); +// Define token file path — prefer env var, then look next to the script, then cwd +const TOKEN_FILE_PATH = process.env.MSTODO_TOKEN_FILE || + (() => { + const scriptDir = path.join(__dirname, '..', 'tokens.json'); + if (fs.existsSync(scriptDir)) return scriptDir; + return path.join(process.cwd(), 'tokens.json'); + })(); // Log startup info console.error('Microsoft Todo MCP CLI'); @@ -26,17 +31,20 @@ if ((!accessToken || !refreshToken) && fs.existsSync(TOKEN_FILE_PATH)) { try { console.error('Reading tokens from file...'); const tokenData = JSON.parse(fs.readFileSync(TOKEN_FILE_PATH, 'utf8')); - - // If we found tokens in the file, use them + if (!accessToken && tokenData.accessToken) { accessToken = tokenData.accessToken; console.error('Using access token from file'); } - + if (!refreshToken && tokenData.refreshToken) { refreshToken = tokenData.refreshToken; console.error('Using refresh token from file'); } + + if (tokenData.expiresAt) { + expiresAt = tokenData.expiresAt; + } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.error('Error reading token file:', errorMessage); @@ -44,10 +52,11 @@ if ((!accessToken || !refreshToken) && fs.existsSync(TOKEN_FILE_PATH)) { } // Start the MCP server with the available tokens -startServer({ - accessToken, +startServer({ + accessToken, refreshToken, - tokenFilePath: TOKEN_FILE_PATH + tokenFilePath: TOKEN_FILE_PATH, + expiresAt }).catch(error => { const errorMessage = error instanceof Error ? error.message : String(error); console.error('Error starting server:', errorMessage); diff --git a/src/todo-index.ts b/src/todo-index.ts index a11b5d7..634b006 100644 --- a/src/todo-index.ts +++ b/src/todo-index.ts @@ -2,14 +2,14 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import { readFileSync, writeFileSync, existsSync } from "fs"; -import { join } from "path"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; import dotenv from "dotenv"; -// Load environment variables -dotenv.config(); - -// Log the current working directory -console.error('Current working directory:', process.cwd()); +// Resolve .env relative to the script location (not cwd, which may differ under Claude Desktop) +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +dotenv.config({ path: join(__dirname, '..', '.env') }); // Microsoft Graph API endpoints const MS_GRAPH_BASE = "https://graph.microsoft.com/v1.0"; @@ -33,6 +33,7 @@ interface ServerConfig { accessToken?: string; refreshToken?: string; tokenFilePath?: string; + expiresAt?: number; } // Set default token file path - can be overridden @@ -70,9 +71,10 @@ function writeTokens(tokenData: TokenData): void { // Global token state let currentAccessToken: string | null = null; let currentRefreshToken: string | null = null; +let currentTokenExpiresAt: number = 0; // Helper function for making Microsoft Graph API requests -async function makeGraphRequest(url: string, token: string, method = "GET", body?: any): Promise { +async function makeGraphRequest(url: string, token: string, method = "GET", body?: any, isRetry = false): Promise { const headers = { "User-Agent": USER_AGENT, "Accept": "application/json", @@ -81,9 +83,9 @@ async function makeGraphRequest(url: string, token: string, method = "GET", b }; try { - const options: RequestInit = { - method, - headers + const options: RequestInit = { + method, + headers }; if (body && (method === "POST" || method === "PATCH")) { @@ -91,27 +93,31 @@ async function makeGraphRequest(url: string, token: string, method = "GET", b } console.error(`Making request to: ${url}`); - console.error(`Request options: ${JSON.stringify({ - method, - headers: { - ...headers, - Authorization: 'Bearer [REDACTED]' - } - })}`); const response = await fetch(url, options); - + if (!response.ok) { const errorText = await response.text(); console.error(`HTTP error! status: ${response.status}, body: ${errorText}`); - + + // On 401, try refreshing the token and retry once + if (response.status === 401 && !isRetry) { + console.error('Got 401, attempting token refresh and retry...'); + currentAccessToken = null; + currentTokenExpiresAt = 0; + const newToken = await getAccessToken(); + if (newToken) { + return makeGraphRequest(url, newToken, method, body, true); + } + } + // Check for the specific MailboxNotEnabledForRESTAPI error if (errorText.includes('MailboxNotEnabledForRESTAPI')) { console.error(` ================================================================= ERROR: MailboxNotEnabledForRESTAPI -The Microsoft To Do API is not available for personal Microsoft accounts +The Microsoft To Do API is not available for personal Microsoft accounts (outlook.com, hotmail.com, live.com, etc.) through the Graph API. This is a limitation of the Microsoft Graph API, not an authentication issue. @@ -121,15 +127,14 @@ You can still use Microsoft To Do through the web interface or mobile apps, but API access is restricted for personal accounts. ================================================================= `); - + throw new Error("Microsoft To Do API is not available for personal Microsoft accounts. See console for details."); } - + throw new Error(`HTTP error! status: ${response.status}, body: ${errorText}`); } - + const data = await response.json(); - console.error(`Response received: ${JSON.stringify(data).substring(0, 200)}...`); return data as T; } catch (error) { console.error("Error making Graph API request:", error); @@ -139,8 +144,9 @@ but API access is restricted for personal accounts. // Refresh token function async function refreshAccessToken(refreshToken: string): Promise { - const tokenEndpoint = `https://login.microsoftonline.com/consumers/oauth2/v2.0/token`; - + const tenant = process.env.TENANT_ID || "consumers"; + const tokenEndpoint = `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`; + const formData = new URLSearchParams({ client_id: process.env.CLIENT_ID || "", client_secret: process.env.CLIENT_SECRET || "", @@ -177,7 +183,8 @@ async function refreshAccessToken(refreshToken: string): Promise { try { console.error('getAccessToken called'); - - // First check if we have a valid current access token in memory + + // Check if we have a valid (non-expired) access token in memory if (currentAccessToken) { - return currentAccessToken; + if (currentTokenExpiresAt > 0 && Date.now() > currentTokenExpiresAt) { + console.error('In-memory token expired, attempting refresh...'); + if (currentRefreshToken) { + const newTokenData = await refreshAccessToken(currentRefreshToken); + if (newTokenData) { + console.error('Token refreshed successfully from in-memory refresh token'); + return newTokenData.accessToken; + } + } + // Clear expired token and fall through to file-based logic + currentAccessToken = null; + } else { + return currentAccessToken; + } } // Check for tokens in environment variables or file @@ -1435,11 +1455,16 @@ export async function startServer(config?: ServerConfig): Promise { currentAccessToken = config.accessToken; console.error('Access token set from config'); } - + if (config?.refreshToken) { currentRefreshToken = config.refreshToken; console.error('Refresh token set from config'); } + + if (config?.expiresAt) { + currentTokenExpiresAt = config.expiresAt; + console.error(`Token expires at: ${new Date(config.expiresAt).toLocaleString()}`); + } // Check if using a personal Microsoft account and show warning if needed await isPersonalMicrosoftAccount();