From 6ae23b588db6f5823c436a407af459e423b61bb3 Mon Sep 17 00:00:00 2001 From: Richard Davies Date: Wed, 4 Mar 2026 20:07:27 +0000 Subject: [PATCH 1/7] feat: improve Claude Desktop support and token refresh reliability - Fix dotenv to resolve .env relative to script location instead of cwd, which may differ when launched by Claude Desktop - Add automatic token refresh on expiration and 401 retry logic - Use TENANT_ID from env for token refresh endpoint instead of hardcoding - Resolve tokens.json relative to script location with cwd fallback - Pass token expiration time through CLI to server for proactive refresh - Add Claude Desktop configuration instructions and troubleshooting to README - Remove verbose request/response logging Co-Authored-By: Claude Opus 4.6 --- README.md | 85 +++++++++++++++++++++++++++++++++------------ src/cli.ts | 27 ++++++++++----- src/todo-index.ts | 87 ++++++++++++++++++++++++++++++----------------- 3 files changed, 138 insertions(+), 61 deletions(-) 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(); From 445dd071a3b09506d0009ea607b728b1fcae5bb3 Mon Sep 17 00:00:00 2001 From: Richard Davies Date: Wed, 22 Apr 2026 08:55:57 +0100 Subject: [PATCH 2/7] feat(update-task): expose orderHint for task reordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MS Graph PATCH /me/todo/lists/{listId}/tasks/{taskId} accepts orderHint on the todoTask resource, but the MCP schema omitted it — so there was no way to reorder tasks via the MCP. This adds an optional orderHint string param on update-task that is forwarded to the PATCH body when provided, matching MS Graph semantics (empty string resets to default; non-empty values sort reverse- lexicographic descending). Ref: https://learn.microsoft.com/graph/api/resources/todotask --- src/todo-index.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/todo-index.ts b/src/todo-index.ts index 634b006..3af8fe0 100644 --- a/src/todo-index.ts +++ b/src/todo-index.ts @@ -945,9 +945,10 @@ server.tool( isReminderOn: z.boolean().optional().describe("Whether to enable reminder for this task"), reminderDateTime: z.string().optional().describe("New reminder date and time in ISO format"), status: z.enum(["notStarted", "inProgress", "completed", "waitingOnOthers", "deferred"]).optional().describe("New status of the task"), - categories: z.array(z.string()).optional().describe("New categories associated with the task") + categories: z.array(z.string()).optional().describe("New categories associated with the task"), + orderHint: z.string().optional().describe("OData orderHint string controlling task position in the list. Pass an empty string to reset to the default. To move task B between A and C, set B's orderHint to a value that sorts between A.orderHint and C.orderHint (reverse-lexicographic, descending). See https://learn.microsoft.com/graph/api/resources/todotask") }, - async ({ listId, taskId, title, body, dueDateTime, startDateTime, importance, isReminderOn, reminderDateTime, status, categories }) => { + async ({ listId, taskId, title, body, dueDateTime, startDateTime, importance, isReminderOn, reminderDateTime, status, categories, orderHint }) => { try { const token = await getAccessToken(); if (!token) { @@ -1027,7 +1028,11 @@ server.tool( if (categories !== undefined) { taskBody.categories = categories; } - + + if (orderHint !== undefined) { + taskBody.orderHint = orderHint; + } + // Make sure we have at least one property to update if (Object.keys(taskBody).length === 0) { return { From 1224ea32963a8d2dec06a5722223f0da95683490 Mon Sep 17 00:00:00 2001 From: Richard Davies Date: Wed, 22 Apr 2026 08:56:53 +0100 Subject: [PATCH 3/7] feat(get-task): add singular lookup that returns full task details get-tasks truncates body.content to a 50-char preview, which causes silent data loss when a caller round-trips a task through delete+ recreate as a reorder workaround. Rather than inflating every get-tasks row, add a new get-task (singular) tool that returns the raw todoTask JSON (including full body.content, orderHint, reminder/start/completed dates, and other fields the list summary omits). The Task interface is extended with the additional fields that get-task may surface (orderHint, createdDateTime, lastModifiedDateTime, etc.) so downstream formatting can rely on them. --- src/todo-index.ts | 81 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/src/todo-index.ts b/src/todo-index.ts index 3af8fe0..5db3acc 100644 --- a/src/todo-index.ts +++ b/src/todo-index.ts @@ -394,6 +394,23 @@ 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; + orderHint?: string; body?: { content: string; contentType: string; @@ -815,6 +832,70 @@ 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) { + const cleaned = select.split(',').map(s => s.trim()).filter(Boolean).join(','); + if (cleaned) queryParams.append('$select', cleaned); + } + 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) { + return { + content: [ + { + type: "text", + text: `Failed to retrieve task ${taskId} from list ${listId}`, + }, + ], + }; + } + + return { + content: [ + { + type: "text", + text: JSON.stringify(response, null, 2), + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: `Error fetching 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.", From 79ed4986f0e332ae1f2bba7360bdc850f5864507 Mon Sep 17 00:00:00 2001 From: Richard Davies Date: Wed, 22 Apr 2026 08:58:35 +0100 Subject: [PATCH 4/7] fix(graph): surface real Graph errors and harden \$select handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get-tasks was returning a generic "Failed to retrieve tasks" on any non-2xx Graph response because makeGraphRequest swallows errors and returns null. The real HTTP status + response body only reached stderr, which MCP clients don't surface — so a 400 from a bad \$select looked identical to auth loss, and passing select= appeared to fail unconditionally. Changes: - makeGraphRequest now records the last error (HTTP status + body, or thrown-error message) into a module-scoped lastGraphError; callers can pull it into user-visible output via consumeLastGraphError(). Wired into get-tasks and get-task so the MCP response now contains the actual Graph error detail on failure. - New normalizeSelect() helper: trims whitespace per field, drops empties, and auto-includes \`id\`. Graph returns an opaque 400 on todoTask \$select that omits id — auto-including it matches user intent and removes a common footgun. --- src/todo-index.ts | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/src/todo-index.ts b/src/todo-index.ts index 5db3acc..8361539 100644 --- a/src/todo-index.ts +++ b/src/todo-index.ts @@ -74,6 +74,16 @@ let currentRefreshToken: string | null = null; let currentTokenExpiresAt: number = 0; // Helper function for making Microsoft Graph API requests +// 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, @@ -98,6 +108,7 @@ async function makeGraphRequest(url: string, token: string, method = "GET", b 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 @@ -137,11 +148,21 @@ but API access is restricted for personal accounts. const data = await response.json(); return data 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 tenant = process.env.TENANT_ID || "consumers"; @@ -727,7 +748,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()); @@ -745,11 +766,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}` : ''}`, }, ], }; @@ -855,21 +877,19 @@ server.tool( } const queryParams = new URLSearchParams(); - if (select) { - const cleaned = select.split(',').map(s => s.trim()).filter(Boolean).join(','); - if (cleaned) queryParams.append('$select', cleaned); - } + 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}`, + text: `Failed to retrieve task ${taskId} from list ${listId}${graphErr ? `\n${graphErr}` : ''}`, }, ], }; From 4d9c44029323e7bb8749dc351d10fbd63b5003c1 Mon Sep 17 00:00:00 2001 From: Richard Davies Date: Wed, 22 Apr 2026 09:07:22 +0100 Subject: [PATCH 5/7] fix(graph): do not set lastGraphError on 204 No Content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit makeGraphRequest previously called response.json() unconditionally on successful responses. For DELETE (and PATCH with no Prefer: return=representation header) MS Graph returns 204 with an empty body, and the parser threw — the outer catch then populated lastGraphError with a misleading 'Unexpected end of JSON input'. Now: return null for 204, return null for empty text, and only JSON.parse non-empty bodies. lastGraphError stays unset on successful empty-body responses so callers can reliably distinguish success from failure via consumeLastGraphError(). --- src/todo-index.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/todo-index.ts b/src/todo-index.ts index 8361539..ec13225 100644 --- a/src/todo-index.ts +++ b/src/todo-index.ts @@ -145,8 +145,11 @@ but API access is restricted for personal accounts. throw new Error(`HTTP error! status: ${response.status}, body: ${errorText}`); } - const data = await response.json(); - 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); From 48dca6054e228fea28616112d936f32cb7969a0a Mon Sep 17 00:00:00 2001 From: Richard Davies Date: Wed, 22 Apr 2026 09:08:21 +0100 Subject: [PATCH 6/7] feat(move-task): move tasks within or between lists atomically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MS Graph does not allow changing a todoTask's parent list via PATCH, so cross-list moves and same-list reorders-from-scratch both require create-in-target + delete-source. Doing that client-side needs multiple tool calls per move and is error-prone — a partial failure can leave a silent duplicate or lose the source. The new move-task tool performs the full round-trip in one call: 1. GET the source task with full fields 2. GET its checklist items (subtasks) 3. POST a copy to the target list, carrying over title, body, dueDateTime, startDateTime, reminderDateTime, isReminderOn, completedDateTime, importance, status, categories, recurrence, and orderHint (caller-supplied orderHint wins; otherwise the source orderHint is preserved) 4. POST each checklist item into the new task 5. DELETE the source — but only after the create succeeded Error handling is fail-safe: if the target-list create fails, the source is left untouched and the caller is told so explicitly. If the source delete fails after a successful create, the new task is preserved and the response warns with the source task ID so the caller can clean up manually — no silent data loss. --- src/todo-index.ts | 110 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/src/todo-index.ts b/src/todo-index.ts index ec13225..18d59fd 100644 --- a/src/todo-index.ts +++ b/src/todo-index.ts @@ -919,6 +919,116 @@ server.tool( } ); +server.tool( + "move-task", + "Move a Microsoft Todo task within a list (reorder) 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, orderHint) plus all checklist items into the target list, then deletes the source. 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 reorder in place"), + orderHint: z.string().optional().describe("Optional orderHint for the new task's position in the target list. Omit to preserve the source's orderHint; pass empty string to reset to default placement.") + }, + async ({ sourceListId, taskId, targetListId, orderHint }) => { + 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; + if (orderHint !== undefined) newBody.orderHint = orderHint; + else if (source.orderHint) newBody.orderHint = source.orderHint; + + // 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.", From 886ca94ca436e6fc6e9391c9d52ebe8d9de914fc Mon Sep 17 00:00:00 2001 From: Richard Davies Date: Wed, 22 Apr 2026 09:20:39 +0100 Subject: [PATCH 7/7] =?UTF-8?q?revert:=20remove=20orderHint=20=E2=80=94=20?= =?UTF-8?q?not=20a=20property=20of=20todoTask=20in=20Graph=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empirical testing against both https://graph.microsoft.com/v1.0 and https://graph.microsoft.com/beta returns: BadRequest: Could not find a property named 'orderHint' on type 'microsoft.graph.todoTask'. orderHint is exposed on microsoft.graph.plannerTask (Planner, a different product) but not on microsoft.graph.todoTask. The Graph PATCH silently ignored the field (returning 200) which is why the initial fix looked plausible until smoke-tested end-to-end. Removed: - orderHint param from update-task schema + PATCH body - orderHint param + preservation logic from move-task - orderHint field from the Task interface move-task still works for reordering WITHIN a list (sourceListId == targetListId) because newly-created MS Todo tasks land at the top of the target list by default — so 'move in place' is effectively 'bump to top of list', which covers the most common prioritization workflow. Arbitrary reorder is not achievable via the public Graph API. --- src/todo-index.ts | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/src/todo-index.ts b/src/todo-index.ts index 18d59fd..4b1b800 100644 --- a/src/todo-index.ts +++ b/src/todo-index.ts @@ -434,7 +434,6 @@ interface Task { lastModifiedDateTime?: string; isReminderOn?: boolean; hasAttachments?: boolean; - orderHint?: string; body?: { content: string; contentType: string; @@ -921,14 +920,13 @@ server.tool( server.tool( "move-task", - "Move a Microsoft Todo task within a list (reorder) 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, orderHint) plus all checklist items into the target list, then deletes the source. 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.", + "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 reorder in place"), - orderHint: z.string().optional().describe("Optional orderHint for the new task's position in the target list. Omit to preserve the source's orderHint; pass empty string to reset to default placement.") + 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, orderHint }) => { + async ({ sourceListId, taskId, targetListId }) => { try { const token = await getAccessToken(); if (!token) { @@ -966,8 +964,6 @@ server.tool( if (source.categories && source.categories.length) newBody.categories = source.categories; const recurrence = (source as any).recurrence; if (recurrence) newBody.recurrence = recurrence; - if (orderHint !== undefined) newBody.orderHint = orderHint; - else if (source.orderHint) newBody.orderHint = source.orderHint; // 4. Create in target list. const created = await makeGraphRequest( @@ -1159,10 +1155,9 @@ server.tool( isReminderOn: z.boolean().optional().describe("Whether to enable reminder for this task"), reminderDateTime: z.string().optional().describe("New reminder date and time in ISO format"), status: z.enum(["notStarted", "inProgress", "completed", "waitingOnOthers", "deferred"]).optional().describe("New status of the task"), - categories: z.array(z.string()).optional().describe("New categories associated with the task"), - orderHint: z.string().optional().describe("OData orderHint string controlling task position in the list. Pass an empty string to reset to the default. To move task B between A and C, set B's orderHint to a value that sorts between A.orderHint and C.orderHint (reverse-lexicographic, descending). See https://learn.microsoft.com/graph/api/resources/todotask") + categories: z.array(z.string()).optional().describe("New categories associated with the task") }, - async ({ listId, taskId, title, body, dueDateTime, startDateTime, importance, isReminderOn, reminderDateTime, status, categories, orderHint }) => { + async ({ listId, taskId, title, body, dueDateTime, startDateTime, importance, isReminderOn, reminderDateTime, status, categories }) => { try { const token = await getAccessToken(); if (!token) { @@ -1243,10 +1238,6 @@ server.tool( taskBody.categories = categories; } - if (orderHint !== undefined) { - taskBody.orderHint = orderHint; - } - // Make sure we have at least one property to update if (Object.keys(taskBody).length === 0) { return {