Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 64 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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)');"
```
27 changes: 18 additions & 9 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -26,28 +31,32 @@ 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);
}
}

// 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);
Expand Down
87 changes: 56 additions & 31 deletions src/todo-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -33,6 +33,7 @@ interface ServerConfig {
accessToken?: string;
refreshToken?: string;
tokenFilePath?: string;
expiresAt?: number;
}

// Set default token file path - can be overridden
Expand Down Expand Up @@ -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<T>(url: string, token: string, method = "GET", body?: any): Promise<T | null> {
async function makeGraphRequest<T>(url: string, token: string, method = "GET", body?: any, isRetry = false): Promise<T | null> {
const headers = {
"User-Agent": USER_AGENT,
"Accept": "application/json",
Expand All @@ -81,37 +83,41 @@ async function makeGraphRequest<T>(url: string, token: string, method = "GET", b
};

try {
const options: RequestInit = {
method,
headers
const options: RequestInit = {
method,
headers
};

if (body && (method === "POST" || method === "PATCH")) {
options.body = JSON.stringify(body);
}

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<T>(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.
Expand All @@ -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);
Expand All @@ -139,8 +144,9 @@ but API access is restricted for personal accounts.

// Refresh token function
async function refreshAccessToken(refreshToken: string): Promise<TokenData | null> {
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 || "",
Expand Down Expand Up @@ -177,7 +183,8 @@ async function refreshAccessToken(refreshToken: string): Promise<TokenData | nul
// Update global token state
currentAccessToken = tokenData.accessToken;
currentRefreshToken = tokenData.refreshToken;

currentTokenExpiresAt = tokenData.expiresAt;

// Save the new tokens
writeTokens(tokenData);

Expand All @@ -192,10 +199,23 @@ async function refreshAccessToken(refreshToken: string): Promise<TokenData | nul
async function getAccessToken(): Promise<string | null> {
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
Expand Down Expand Up @@ -1435,11 +1455,16 @@ export async function startServer(config?: ServerConfig): Promise<void> {
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();
Expand Down