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..4b1b800 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,20 @@ 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 { +// Most recent Graph failure detail (HTTP status + response body). Callers that +// return graceful null on failure can pull this into their user-visible output +// via consumeLastGraphError() so the actual Graph message is not lost. +let lastGraphError: string | null = null; +function consumeLastGraphError(): string | null { + const err = lastGraphError; + lastGraphError = null; + return err; +} + +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 +93,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 +103,32 @@ 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(); + lastGraphError = `HTTP ${response.status}: ${errorText}`; 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,26 +138,39 @@ 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; + + // 204 No Content (common for DELETE/PATCH) has no body to parse. + if (response.status === 204) return null as unknown as T; + const text = await response.text(); + if (!text) return null as unknown as T; + return JSON.parse(text) as T; } catch (error) { + lastGraphError = error instanceof Error ? error.message : String(error); console.error("Error making Graph API request:", error); return null; } } +// Normalize an OData $select value: trim whitespace per field, drop empties, +// and guarantee `id` is present (Graph rejects todoTask $select that omits id +// with an opaque 400 — auto-including it is a common-sense fix). +function normalizeSelect(raw: string): string { + const fields = raw.split(',').map(s => s.trim()).filter(Boolean); + if (!fields.includes('id')) fields.unshift('id'); + return fields.join(','); +} + // 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 +207,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 @@ -374,6 +418,22 @@ interface Task { dateTime: string; timeZone: string; }; + startDateTime?: { + dateTime: string; + timeZone: string; + }; + reminderDateTime?: { + dateTime: string; + timeZone: string; + }; + completedDateTime?: { + dateTime: string; + timeZone: string; + }; + createdDateTime?: string; + lastModifiedDateTime?: string; + isReminderOn?: boolean; + hasAttachments?: boolean; body?: { content: string; contentType: string; @@ -690,7 +750,7 @@ server.tool( const queryParams = new URLSearchParams(); if (filter) queryParams.append('$filter', filter); - if (select) queryParams.append('$select', select); + if (select) queryParams.append('$select', normalizeSelect(select)); if (orderby) queryParams.append('$orderby', orderby); if (top !== undefined) queryParams.append('$top', top.toString()); if (skip !== undefined) queryParams.append('$skip', skip.toString()); @@ -708,11 +768,12 @@ server.tool( ); if (!response) { + const graphErr = consumeLastGraphError(); return { content: [ { type: "text", - text: `Failed to retrieve tasks for list: ${listId}`, + text: `Failed to retrieve tasks for list: ${listId}${graphErr ? `\n${graphErr}` : ''}`, }, ], }; @@ -795,6 +856,175 @@ server.tool( } ); +server.tool( + "get-task", + "Get full details for a single Microsoft Todo task, including the complete body.content. Prefer this over get-tasks when you need the full description, orderHint, or any field that get-tasks truncates or omits in its summary output.", + { + listId: z.string().describe("ID of the task list"), + taskId: z.string().describe("ID of the task to retrieve"), + select: z.string().optional().describe("Optional comma-separated OData $select to restrict returned fields (e.g., 'id,title,orderHint'). Whitespace is trimmed per field.") + }, + async ({ listId, taskId, select }) => { + try { + const token = await getAccessToken(); + if (!token) { + return { + content: [ + { + type: "text", + text: "Failed to authenticate with Microsoft API", + }, + ], + }; + } + + const queryParams = new URLSearchParams(); + if (select) queryParams.append('$select', normalizeSelect(select)); + const queryString = queryParams.toString(); + const url = `${MS_GRAPH_BASE}/me/todo/lists/${listId}/tasks/${taskId}${queryString ? '?' + queryString : ''}`; + + const response = await makeGraphRequest(url, token); + + if (!response) { + const graphErr = consumeLastGraphError(); + return { + content: [ + { + type: "text", + text: `Failed to retrieve task ${taskId} from list ${listId}${graphErr ? `\n${graphErr}` : ''}`, + }, + ], + }; + } + + return { + content: [ + { + type: "text", + text: JSON.stringify(response, null, 2), + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: `Error fetching task: ${error}`, + }, + ], + }; + } + } +); + +server.tool( + "move-task", + "Move a Microsoft Todo task within a list or between lists. MS Graph does not allow changing a task's parent list via PATCH, so this performs a full-fidelity copy (title, body, dates, importance, categories, recurrence) plus all checklist items into the target list, then deletes the source. NOTE: MS Graph does not expose orderHint on todoTask, so task ordering cannot be controlled — newly-created tasks land at the top of the target list (which is the default MS Todo placement). Use this with sourceListId == targetListId to bump a task to the top of its list. If the target-list create fails, the source is left untouched. If the source delete fails after a successful create, the new task is preserved and the response warns so you can clean up manually.", + { + sourceListId: z.string().describe("ID of the list the task currently lives in"), + taskId: z.string().describe("ID of the task to move"), + targetListId: z.string().describe("ID of the list to move the task to — may equal sourceListId to bump the task to the top of its list") + }, + async ({ sourceListId, taskId, targetListId }) => { + try { + const token = await getAccessToken(); + if (!token) { + return { content: [{ type: "text", text: "Failed to authenticate with Microsoft API" }] }; + } + + // 1. Fetch full source task. + const source = await makeGraphRequest( + `${MS_GRAPH_BASE}/me/todo/lists/${sourceListId}/tasks/${taskId}`, + token + ); + if (!source) { + const err = consumeLastGraphError(); + return { content: [{ type: "text", text: `Failed to read source task ${taskId} from list ${sourceListId}${err ? `\n${err}` : ''}` }] }; + } + + // 2. Fetch checklist items (subtasks). Missing/empty is fine. + const checklist = await makeGraphRequest<{ value: ChecklistItem[] }>( + `${MS_GRAPH_BASE}/me/todo/lists/${sourceListId}/tasks/${taskId}/checklistItems`, + token + ); + consumeLastGraphError(); + const checklistItems = checklist?.value ?? []; + + // 3. Copy writable fields into the new task body. + const newBody: any = { title: source.title }; + if (source.body) newBody.body = source.body; + if (source.dueDateTime) newBody.dueDateTime = source.dueDateTime; + if (source.startDateTime) newBody.startDateTime = source.startDateTime; + if (source.reminderDateTime) newBody.reminderDateTime = source.reminderDateTime; + if (source.isReminderOn !== undefined) newBody.isReminderOn = source.isReminderOn; + if (source.completedDateTime) newBody.completedDateTime = source.completedDateTime; + if (source.importance) newBody.importance = source.importance; + if (source.status) newBody.status = source.status; + if (source.categories && source.categories.length) newBody.categories = source.categories; + const recurrence = (source as any).recurrence; + if (recurrence) newBody.recurrence = recurrence; + + // 4. Create in target list. + const created = await makeGraphRequest( + `${MS_GRAPH_BASE}/me/todo/lists/${targetListId}/tasks`, + token, + "POST", + newBody + ); + if (!created) { + const err = consumeLastGraphError(); + return { content: [{ type: "text", text: `Failed to create task in target list ${targetListId}. Source task ${taskId} was NOT deleted — no data lost.${err ? `\n${err}` : ''}` }] }; + } + + // 5. Copy checklist items into the new task. + const checklistWarnings: string[] = []; + for (const item of checklistItems) { + const copied = await makeGraphRequest( + `${MS_GRAPH_BASE}/me/todo/lists/${targetListId}/tasks/${created.id}/checklistItems`, + token, + "POST", + { displayName: item.displayName, isChecked: item.isChecked } + ); + if (!copied) { + const err = consumeLastGraphError(); + checklistWarnings.push(`Failed to copy checklist item "${item.displayName}"${err ? `: ${err}` : ''}`); + } + } + + // 6. Delete source. makeGraphRequest returns null on both 204 (success) + // and failure, so distinguish via consumeLastGraphError(). + await makeGraphRequest( + `${MS_GRAPH_BASE}/me/todo/lists/${sourceListId}/tasks/${taskId}`, + token, + "DELETE" + ); + const deleteErr = consumeLastGraphError(); + + const lines: string[] = []; + lines.push(`Moved task "${source.title}"`); + lines.push(` from list: ${sourceListId}`); + lines.push(` to list: ${targetListId}`); + lines.push(` new task ID: ${created.id}`); + if (checklistItems.length) { + lines.push(` copied ${checklistItems.length - checklistWarnings.length}/${checklistItems.length} checklist items`); + } + if (checklistWarnings.length) { + lines.push(...checklistWarnings.map(w => ` WARNING: ${w}`)); + } + if (deleteErr) { + lines.push(` WARNING: source task was NOT deleted — manual cleanup needed in list ${sourceListId} for task ${taskId}: ${deleteErr}`); + } else { + lines.push(` source task deleted`); + } + + return { content: [{ type: "text", text: lines.join('\n') }] }; + } catch (error) { + return { content: [{ type: "text", text: `Error moving task: ${error}` }] }; + } + } +); + server.tool( "create-task", "Create a new task in a specific Microsoft Todo list. A task is the main todo item that can have a title, description, due date, and other properties.", @@ -1007,7 +1237,7 @@ server.tool( if (categories !== undefined) { taskBody.categories = categories; } - + // Make sure we have at least one property to update if (Object.keys(taskBody).length === 0) { return { @@ -1435,11 +1665,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();