From f5e45c93fc166d50ed5318ab50dc21755a2db6cc Mon Sep 17 00:00:00 2001 From: Jeff Omhover Date: Wed, 22 Apr 2026 21:00:02 -0700 Subject: [PATCH 1/4] chore: upgrade to Node 22, add CI, bump to 0.2.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Raise engines.node from >=20 to >=22 - Add GitHub Actions CI workflow (Ubuntu, macOS, Windows × Node 22) - Bump version to 0.2.1 - Add experimental disclaimer to README - Update CHANGELOG Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 28 ++++++++++++++++++++++++++++ CHANGELOG.md | 11 +++++++++++ README.md | 2 ++ package.json | 4 ++-- 4 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..59e3e20 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + - run: npm run typecheck + - run: npm run build + - run: npm test diff --git a/CHANGELOG.md b/CHANGELOG.md index 63c7006..e59df69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to this project will be documented in this file. +## [0.2.1] + +### Changed + +- **Node.js minimum version**: Raised from 20 to 22 +- **README**: Added experimental disclaimer + +### Added + +- **CI workflow**: GitHub Actions — build, typecheck, and test on Ubuntu, macOS, and Windows (Node 22) + ## [0.2.0] ### Security diff --git a/README.md b/README.md index 42bc0de..e5aa1bf 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ A **CLI-first** tool for managing Microsoft To Do tasks, with an optional MCP server for AI agents. +> **⚠️ Experimental** — This software is experimental and provided "as is" without warranty of any kind. Use at your own risk. See [LICENSE](LICENSE) for details. + ## Install ```bash diff --git a/package.json b/package.json index 49433e2..af5386c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@thingsai/todo-mcp-server", - "version": "0.2.0", + "version": "0.2.1", "description": "A CLI-first tool for managing Microsoft To Do tasks, with an optional MCP server wrapper", "type": "module", "main": "dist/cli.js", @@ -23,7 +23,7 @@ "prepublishOnly": "npm run build" }, "engines": { - "node": ">=20" + "node": ">=22" }, "license": "MIT", "repository": { From ec058b3f38f36fea90da58f6f9c72fc43d12fdc2 Mon Sep 17 00:00:00 2001 From: Jeff Omhover Date: Wed, 22 Apr 2026 21:02:29 -0700 Subject: [PATCH 2/4] chore: add Prettier for code formatting - Install prettier as devDependency - Add .prettierrc (singleQuote, trailingComma, printWidth: 100) - Add .prettierignore (dist, node_modules, package-lock.json) - Add format and format:check npm scripts - Add format:check step to CI workflow - Format entire codebase Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 1 + .prettierignore | 3 + .prettierrc | 5 + CHANGELOG.md | 6 + README.md | 1 - docs/azure-setup.md | 18 +- docs/cli-reference.md | 84 ++--- docs/configuration.md | 22 +- docs/mcp-integration.md | 46 +-- docs/meta.md | 65 ++-- docs/security.md | 60 +-- package-lock.json | 27 +- package.json | 9 +- .../2026-04-12-microsoft-todo-mcp-server.md | 50 +-- spec/audits/2026-04-12-todomcp.md | 139 +++---- spec/intent.md | 229 ++++++++---- spec/plan.md | 30 +- src/auth/setup.ts | 209 ++++++----- src/auth/token-manager.ts | 18 +- src/auth/token-store.ts | 4 +- src/cli.ts | 46 ++- src/core/checklist-items.ts | 24 +- src/core/task-lists.ts | 15 +- src/core/tasks.ts | 65 +++- src/graph/client.ts | 30 +- src/mcp.ts | 341 ++++++++++++++---- src/types.ts | 15 +- tests/cli.test.ts | 134 +++++-- tests/core.test.ts | 70 +++- tests/graph-client.test.ts | 25 +- tests/mcp.test.ts | 69 +++- tests/setup.test.ts | 74 ++-- tests/token-manager.test.ts | 18 +- 33 files changed, 1276 insertions(+), 676 deletions(-) create mode 100644 .prettierignore create mode 100644 .prettierrc diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59e3e20..efa7fe2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,3 +26,4 @@ jobs: - run: npm run typecheck - run: npm run build - run: npm test + - run: npm run format:check diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..91a3983 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ +dist +node_modules +package-lock.json diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..2fec1f2 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,5 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100 +} diff --git a/CHANGELOG.md b/CHANGELOG.md index e59df69..b47c78f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,7 @@ Initial release. Full implementation from spec (`spec/intent.md`). ### Added **CLI** (`todo` command) + - `todo setup` — interactive OAuth 2.0 PKCE authentication flow - `todo serve` — start MCP server on stdio - `todo lists` — list, create, update, delete task lists @@ -73,17 +74,20 @@ Initial release. Full implementation from spec (`spec/intent.md`). - `todo help` — usage reference **MCP Server** (15 tools) + - `list-task-lists`, `create-task-list`, `update-task-list`, `delete-task-list` - `list-tasks`, `create-task`, `update-task`, `delete-task`, `complete-task` - `list-checklist-items`, `create-checklist-item`, `update-checklist-item`, `delete-checklist-item` **Authentication** + - OAuth 2.0 Authorization Code with PKCE (public client — no client secret) - AES-256-GCM encrypted token storage with PBKDF2 key derivation - Automatic token refresh with mutex to prevent concurrent refresh races - Environment variable override for CI/headless (`TODO_MCP_ACCESS_TOKEN`, `TODO_MCP_REFRESH_TOKEN`) **Security** + - No client secret anywhere in the codebase - Encrypted token persistence — never plaintext - Minimal scopes: `Tasks.ReadWrite` + `offline_access` only @@ -91,9 +95,11 @@ Initial release. Full implementation from spec (`spec/intent.md`). - 2 runtime dependencies only (`@modelcontextprotocol/sdk`, `zod`) **Tests** + - 115 unit tests across 7 test files (vitest) **Documentation** + - Getting started guide - Azure app registration (Portal + Azure CLI) - CLI reference diff --git a/README.md b/README.md index e5aa1bf..d39116a 100644 --- a/README.md +++ b/README.md @@ -60,4 +60,3 @@ Add to VS Code `settings.json` for Copilot integration: ## License MIT - diff --git a/docs/azure-setup.md b/docs/azure-setup.md index 6d46197..c097b70 100644 --- a/docs/azure-setup.md +++ b/docs/azure-setup.md @@ -65,11 +65,11 @@ This prints your **Application (client) ID**. Save it. **`--sign-in-audience` options:** -| Value | Description | -|---|---| -| `PersonalMicrosoftAccount` | Personal Microsoft accounts only (@outlook.com, @hotmail.com, @live.com) | -| `AzureADandPersonalMicrosoftAccount` | Work/school + personal accounts | -| `AzureADMyOrg` | Single organization only | +| Value | Description | +| ------------------------------------ | ------------------------------------------------------------------------ | +| `PersonalMicrosoftAccount` | Personal Microsoft accounts only (@outlook.com, @hotmail.com, @live.com) | +| `AzureADandPersonalMicrosoftAccount` | Work/school + personal accounts | +| `AzureADMyOrg` | Single organization only | ### 2. Add Tasks.ReadWrite permission @@ -88,10 +88,10 @@ Replace `` with the ID from the previous step. ## Account Type Guidance -| Account type | Tenant value | Example domains | -|---|---|---| -| Personal accounts | `consumers` (the default) | @outlook.com, @hotmail.com, @live.com | -| Work/school accounts | `common` or your org's tenant ID | @yourcompany.com | +| Account type | Tenant value | Example domains | +| -------------------- | -------------------------------- | ------------------------------------- | +| Personal accounts | `consumers` (the default) | @outlook.com, @hotmail.com, @live.com | +| Work/school accounts | `common` or your org's tenant ID | @yourcompany.com | To set the tenant explicitly: diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 67b6432..3972732 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -4,10 +4,10 @@ Full command reference for `@thingsai/todo-mcp-server`. ## Global Flags -| Flag | Description | -| ----------- | ------------------------------------------------ | +| Flag | Description | +| ----------- | ----------------------------------------------------------- | | `--json` | Output in JSON format (available on all read/list commands) | -| `--version` | Show the version number | +| `--version` | Show the version number | > **Note:** Unknown flags are rejected. The CLI uses strict argument parsing to prevent silent errors. @@ -109,17 +109,17 @@ Use `--status` to filter by task status (`notStarted`, `inProgress`, `completed` Create a task. -| Flag | Required | Description | -| ---------------- | -------- | --------------------------------------------------------------------------- | -| `--list ` | Yes | Task list ID | -| `--title ` | Yes | Task title | -| `--due ` | No | Due date (e.g. `2026-04-20`) | -| `--importance` | No | `low`, `normal`, or `high` | -| `--body ` | No | Task body / notes | -| `--reminder` | No | Reminder date-time (e.g. `2026-04-20T09:00:00Z`) | -| `--start ` | No | Start date | -| `--status` | No | `notStarted`, `inProgress`, `completed`, `waitingOnOthers`, or `deferred` | -| `--categories` | No | Comma-separated categories (e.g. `groceries,errands`) | +| Flag | Required | Description | +| ---------------- | -------- | ------------------------------------------------------------------------- | +| `--list ` | Yes | Task list ID | +| `--title ` | Yes | Task title | +| `--due ` | No | Due date (e.g. `2026-04-20`) | +| `--importance` | No | `low`, `normal`, or `high` | +| `--body ` | No | Task body / notes | +| `--reminder` | No | Reminder date-time (e.g. `2026-04-20T09:00:00Z`) | +| `--start ` | No | Start date | +| `--status` | No | `notStarted`, `inProgress`, `completed`, `waitingOnOthers`, or `deferred` | +| `--categories` | No | Comma-separated categories (e.g. `groceries,errands`) | ```bash # Minimal @@ -137,18 +137,18 @@ todo tasks create --list AAMkAD... --title "Buy milk" \ Update a task. All fields except `--list` and `--task` are optional. Pass an empty string to date fields to clear them. -| Flag | Required | Description | -| ---------------- | -------- | ------------------ | -| `--list ` | Yes | Task list ID | -| `--task ` | Yes | Task ID | -| `--title` | No | New title | -| `--due` | No | New due date | -| `--importance` | No | New importance | -| `--body` | No | New body text | -| `--reminder` | No | New reminder | -| `--start` | No | New start date | -| `--status` | No | New status | -| `--categories` | No | New categories | +| Flag | Required | Description | +| -------------- | -------- | -------------- | +| `--list ` | Yes | Task list ID | +| `--task ` | Yes | Task ID | +| `--title` | No | New title | +| `--due` | No | New due date | +| `--importance` | No | New importance | +| `--body` | No | New body text | +| `--reminder` | No | New reminder | +| `--start` | No | New start date | +| `--status` | No | New status | +| `--categories` | No | New categories | ```bash todo tasks update --list AAMkAD... --task AAMkAD... --title "Buy oat milk" @@ -192,11 +192,11 @@ todo checklist --list AAMkAD... --task AAMkAD... --json Add a checklist item. -| Flag | Required | Description | -| ---------------- | -------- | ------------------ | -| `--list ` | Yes | Task list ID | -| `--task ` | Yes | Task ID | -| `--text ` | Yes | Item display text | +| Flag | Required | Description | +| --------------- | -------- | ----------------- | +| `--list ` | Yes | Task list ID | +| `--task ` | Yes | Task ID | +| `--text ` | Yes | Item display text | ```bash todo checklist add --list AAMkAD... --task AAMkAD... --text "Check expiry date" @@ -206,14 +206,14 @@ todo checklist add --list AAMkAD... --task AAMkAD... --text "Check expiry date" Update a checklist item. -| Flag | Required | Description | -| ---------------- | -------- | ---------------------------- | -| `--list ` | Yes | Task list ID | -| `--task ` | Yes | Task ID | -| `--item ` | Yes | Checklist item ID | -| `--text ` | No | New display text | -| `--checked` | No | Mark the item as checked | -| `--unchecked` | No | Mark the item as unchecked | +| Flag | Required | Description | +| --------------- | -------- | -------------------------- | +| `--list ` | Yes | Task list ID | +| `--task ` | Yes | Task ID | +| `--item ` | Yes | Checklist item ID | +| `--text ` | No | New display text | +| `--checked` | No | Mark the item as checked | +| `--unchecked` | No | Mark the item as unchecked | ```bash todo checklist update --list AAMkAD... --task AAMkAD... --item AAMkAD... --checked @@ -271,7 +271,7 @@ todo checklist --list AAMkAD... --task AAMkAD... --json ## Exit Codes -| Code | Meaning | -| ---- | ------------------------------------------------ | -| `0` | Success | +| Code | Meaning | +| ---- | -------------------------------------------------- | +| `0` | Success | | `1` | Error (missing arguments, auth failure, API error) | diff --git a/docs/configuration.md b/docs/configuration.md index f9330db..50f3df5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -4,12 +4,12 @@ Configuration reference for `@thingsai/todo-mcp-server`. ## Environment Variables -| Variable | Required | Default | Description | -|----------|----------|---------|-------------| -| `TODO_MCP_CLIENT_ID` | Yes (for setup) | — | Azure AD Application (client) ID from your app registration | -| `TODO_MCP_TENANT` | No | `consumers` | Azure AD tenant. Options: `consumers` (personal accounts), `common` (personal + org), or a specific tenant ID | -| `TODO_MCP_ACCESS_TOKEN` | No | — | Use this access token directly, bypassing encrypted store. For CI/headless. | -| `TODO_MCP_REFRESH_TOKEN` | No | — | Used alongside `TODO_MCP_ACCESS_TOKEN` for token refresh in CI/headless scenarios | +| Variable | Required | Default | Description | +| ------------------------ | --------------- | ----------- | ------------------------------------------------------------------------------------------------------------- | +| `TODO_MCP_CLIENT_ID` | Yes (for setup) | — | Azure AD Application (client) ID from your app registration | +| `TODO_MCP_TENANT` | No | `consumers` | Azure AD tenant. Options: `consumers` (personal accounts), `common` (personal + org), or a specific tenant ID | +| `TODO_MCP_ACCESS_TOKEN` | No | — | Use this access token directly, bypassing encrypted store. For CI/headless. | +| `TODO_MCP_REFRESH_TOKEN` | No | — | Used alongside `TODO_MCP_ACCESS_TOKEN` for token refresh in CI/headless scenarios | ## Token Storage @@ -17,11 +17,11 @@ Tokens are encrypted at rest using AES-256-GCM with a machine-derived key. ### Storage Locations -| Platform | Path | -|----------|------| -| Windows | `%APPDATA%\todo-mcp\tokens.enc` | -| macOS | `~/.config/todo-mcp/tokens.enc` | -| Linux | `~/.config/todo-mcp/tokens.enc` | +| Platform | Path | +| -------- | ------------------------------- | +| Windows | `%APPDATA%\todo-mcp\tokens.enc` | +| macOS | `~/.config/todo-mcp/tokens.enc` | +| Linux | `~/.config/todo-mcp/tokens.enc` | ### Encryption Details diff --git a/docs/mcp-integration.md b/docs/mcp-integration.md index cf79197..574f6cb 100644 --- a/docs/mcp-integration.md +++ b/docs/mcp-integration.md @@ -60,23 +60,23 @@ Any MCP client that supports stdio transport can use this server. The command is The server exposes 15 tools across three categories. -| # | Tool | Description | Parameters | -|---|------|-------------|------------| -| 1 | `list-task-lists` | List all task lists | _(none)_ | -| 2 | `get-task-list` | Get a single task list by ID | `listId` (string, **required**) | -| 3 | `create-task-list` | Create a new task list | `displayName` (string, **required**) | -| 4 | `update-task-list` | Update a task list's name | `listId` (string, **required**), `displayName` (string, **required**) | -| 5 | `delete-task-list` | Delete a task list | `listId` (string, **required**) | -| 6 | `list-tasks` | List tasks in a task list | `listId` (string, **required**), `status` (enum, optional), `top` (number, optional, default: 100), `filter` (string, optional — OData $filter expression), `orderby` (string, optional — OData $orderby expression) | -| 7 | `get-task` | Get a single task by ID | `listId` (string, **required**), `taskId` (string, **required**) | -| 8 | `create-task` | Create a new task | `listId` (string, **required**), `title` (string, **required**), `body` (string, optional), `dueDateTime` (string, optional), `reminderDateTime` (string, optional), `importance` (enum, optional), `startDateTime` (string, optional), `status` (enum, optional), `categories` (string[], optional) | -| 9 | `update-task` | Update an existing task | `listId` (string, **required**), `taskId` (string, **required**), `title` (string, optional), `body` (string, optional), `dueDateTime` (string, optional), `reminderDateTime` (string, optional), `importance` (enum, optional), `startDateTime` (string, optional), `status` (enum, optional), `categories` (string[], optional) | -| 10 | `delete-task` | Delete a task | `listId` (string, **required**), `taskId` (string, **required**) | -| 11 | `complete-task` | Mark a task as completed | `listId` (string, **required**), `taskId` (string, **required**) | -| 12 | `list-checklist-items` | List checklist items (sub-steps) of a task | `listId` (string, **required**), `taskId` (string, **required**) | -| 13 | `create-checklist-item` | Create a checklist item (sub-step) on a task | `listId` (string, **required**), `taskId` (string, **required**), `displayName` (string, **required**), `isChecked` (boolean, optional) | -| 14 | `update-checklist-item` | Update a checklist item (sub-step) | `listId` (string, **required**), `taskId` (string, **required**), `checklistItemId` (string, **required**), `displayName` (string, optional), `isChecked` (boolean, optional) | -| 15 | `delete-checklist-item` | Delete a checklist item (sub-step) | `listId` (string, **required**), `taskId` (string, **required**), `checklistItemId` (string, **required**) | +| # | Tool | Description | Parameters | +| --- | ----------------------- | -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | `list-task-lists` | List all task lists | _(none)_ | +| 2 | `get-task-list` | Get a single task list by ID | `listId` (string, **required**) | +| 3 | `create-task-list` | Create a new task list | `displayName` (string, **required**) | +| 4 | `update-task-list` | Update a task list's name | `listId` (string, **required**), `displayName` (string, **required**) | +| 5 | `delete-task-list` | Delete a task list | `listId` (string, **required**) | +| 6 | `list-tasks` | List tasks in a task list | `listId` (string, **required**), `status` (enum, optional), `top` (number, optional, default: 100), `filter` (string, optional — OData $filter expression), `orderby` (string, optional — OData $orderby expression) | +| 7 | `get-task` | Get a single task by ID | `listId` (string, **required**), `taskId` (string, **required**) | +| 8 | `create-task` | Create a new task | `listId` (string, **required**), `title` (string, **required**), `body` (string, optional), `dueDateTime` (string, optional), `reminderDateTime` (string, optional), `importance` (enum, optional), `startDateTime` (string, optional), `status` (enum, optional), `categories` (string[], optional) | +| 9 | `update-task` | Update an existing task | `listId` (string, **required**), `taskId` (string, **required**), `title` (string, optional), `body` (string, optional), `dueDateTime` (string, optional), `reminderDateTime` (string, optional), `importance` (enum, optional), `startDateTime` (string, optional), `status` (enum, optional), `categories` (string[], optional) | +| 10 | `delete-task` | Delete a task | `listId` (string, **required**), `taskId` (string, **required**) | +| 11 | `complete-task` | Mark a task as completed | `listId` (string, **required**), `taskId` (string, **required**) | +| 12 | `list-checklist-items` | List checklist items (sub-steps) of a task | `listId` (string, **required**), `taskId` (string, **required**) | +| 13 | `create-checklist-item` | Create a checklist item (sub-step) on a task | `listId` (string, **required**), `taskId` (string, **required**), `displayName` (string, **required**), `isChecked` (boolean, optional) | +| 14 | `update-checklist-item` | Update a checklist item (sub-step) | `listId` (string, **required**), `taskId` (string, **required**), `checklistItemId` (string, **required**), `displayName` (string, optional), `isChecked` (boolean, optional) | +| 15 | `delete-checklist-item` | Delete a checklist item (sub-step) | `listId` (string, **required**), `taskId` (string, **required**), `checklistItemId` (string, **required**) | ### Enum Values @@ -137,9 +137,9 @@ Here are example prompts you might give an AI agent connected to this MCP server ## Troubleshooting -| Problem | Solution | -|---------|----------| -| "Authentication expired" | Re-run `todo setup` to refresh your credentials. | -| Server not responding | Check that the `TODO_MCP_CLIENT_ID` environment variable is set in your client configuration. | -| Permission errors | Verify your Azure app registration has the **Tasks.ReadWrite** permission. | -| Command not found | Make sure `@thingsai/todo-mcp-server` is installed globally (`npm i -g @thingsai/todo-mcp-server`). | +| Problem | Solution | +| ------------------------ | --------------------------------------------------------------------------------------------------- | +| "Authentication expired" | Re-run `todo setup` to refresh your credentials. | +| Server not responding | Check that the `TODO_MCP_CLIENT_ID` environment variable is set in your client configuration. | +| Permission errors | Verify your Azure app registration has the **Tasks.ReadWrite** permission. | +| Command not found | Make sure `@thingsai/todo-mcp-server` is installed globally (`npm i -g @thingsai/todo-mcp-server`). | diff --git a/docs/meta.md b/docs/meta.md index 27394c6..f6ba557 100644 --- a/docs/meta.md +++ b/docs/meta.md @@ -49,15 +49,15 @@ The spec and audits were analyzed to produce an implementation plan (`spec/plan. Project scaffolding (package.json, tsconfig.json, types) was done first, then 7 agents were dispatched in parallel: -| Agent | Work Unit | Files Created | Tests | -|-------|-----------|--------------|-------| -| Token Store | Encrypted token persistence | `src/auth/token-store.ts` | 11 | -| Token Manager | Token loading + refresh + mutex | `src/auth/token-manager.ts` | 12 | -| Auth Setup | OAuth PKCE setup flow | `src/auth/setup.ts` | 7 | -| Graph Client | Graph API fetch wrapper | `src/graph/client.ts` | 19 | -| Core Ops | Task lists, tasks, checklist CRUD | `src/core/*.ts` (3 files) | 27 | -| CLI | CLI entry point + formatting | `src/cli.ts`, `src/format.ts` | 22 | -| MCP Server | MCP server with 13 tools | `src/mcp.ts` | 8 | +| Agent | Work Unit | Files Created | Tests | +| ------------- | --------------------------------- | ----------------------------- | ----- | +| Token Store | Encrypted token persistence | `src/auth/token-store.ts` | 11 | +| Token Manager | Token loading + refresh + mutex | `src/auth/token-manager.ts` | 12 | +| Auth Setup | OAuth PKCE setup flow | `src/auth/setup.ts` | 7 | +| Graph Client | Graph API fetch wrapper | `src/graph/client.ts` | 19 | +| Core Ops | Task lists, tasks, checklist CRUD | `src/core/*.ts` (3 files) | 27 | +| CLI | CLI entry point + formatting | `src/cli.ts`, `src/format.ts` | 22 | +| MCP Server | MCP server with 13 tools | `src/mcp.ts` | 8 | All 7 agents completed successfully. 106 tests passing, TypeScript clean, build clean. @@ -126,15 +126,15 @@ After the core implementation, additional passes covered: ### Agent Timing -| Phase | Agents | Wall-Clock Time | Notes | -|-------|--------|----------------|-------| -| Planning | 0 (inline) | ~5 min | Spec analysis, plan creation, 3 revision rounds | -| Implementation | 7 parallel | ~8 min | All agents ran concurrently; longest was MCP server (~8.5 min) | -| Packaging + README | 0 (inline) | ~1 min | package.json updates, npm link, README | -| Documentation | 6 parallel | ~1.5 min | All doc agents ran concurrently; longest was security (~1.2 min) | -| **Total** | **14** (incl. inline) | **~16 min** | From first prompt to fully documented, tested, and packaged | +| Phase | Agents | Wall-Clock Time | Notes | +| ------------------ | --------------------- | --------------- | ---------------------------------------------------------------- | +| Planning | 0 (inline) | ~5 min | Spec analysis, plan creation, 3 revision rounds | +| Implementation | 7 parallel | ~8 min | All agents ran concurrently; longest was MCP server (~8.5 min) | +| Packaging + README | 0 (inline) | ~1 min | package.json updates, npm link, README | +| Documentation | 6 parallel | ~1.5 min | All doc agents ran concurrently; longest was security (~1.2 min) | +| **Total** | **14** (incl. inline) | **~16 min** | From first prompt to fully documented, tested, and packaged | -*Note: Token usage per phase was not captured by the session telemetry. Wall-clock times are approximate, measured from agent dispatch to last agent completion.* +_Note: Token usage per phase was not captured by the session telemetry. Wall-clock times are approximate, measured from agent dispatch to last agent completion._ ## Phase 4: Spec Feedback Loop @@ -142,14 +142,14 @@ After testing the initial release, several gaps were discovered that required fo **Gaps identified and backported to `intent.md`:** -| Gap | What was missing | Where added in spec | -|-----|-----------------|-------------------| -| Auto-auth in MCP serve | `todo serve` and `todo setup` were decoupled — MCP users had to run setup separately in a terminal without the env vars | OAuth Flow section | -| `--client-id` CLI flag | Only env vars for passing client ID; terminal users had to `export` before running setup | CLI entry point commands | -| Azure AD app creation | Spec assumed a client ID exists but never explained how to get one | New section after Token Storage | -| npm packaging | No scoped package name, publish config, or `files` array | `package.json` essentials | -| Documentation structure | Spec said "README" but not a `docs/` folder with dedicated guides | Implementation Order | -| Versioning & CHANGELOG | No mention of semver, changelog format, or version sync between `package.json` and `mcp.ts` | New section after Definition of Done | +| Gap | What was missing | Where added in spec | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | +| Auto-auth in MCP serve | `todo serve` and `todo setup` were decoupled — MCP users had to run setup separately in a terminal without the env vars | OAuth Flow section | +| `--client-id` CLI flag | Only env vars for passing client ID; terminal users had to `export` before running setup | CLI entry point commands | +| Azure AD app creation | Spec assumed a client ID exists but never explained how to get one | New section after Token Storage | +| npm packaging | No scoped package name, publish config, or `files` array | `package.json` essentials | +| Documentation structure | Spec said "README" but not a `docs/` folder with dedicated guides | Implementation Order | +| Versioning & CHANGELOG | No mention of semver, changelog format, or version sync between `package.json` and `mcp.ts` | New section after Definition of Done | This "push-back-to-spec" cycle is a useful pattern: build from the spec, discover what's missing during real usage, then amend the spec so future builds from it are complete. The spec becomes a living document that improves with each implementation pass. @@ -161,13 +161,13 @@ A full security audit and developer experience evaluation was conducted using fl Five specialized agents audited different aspects of the codebase simultaneously: -| Agent | Focus Area | Key Findings | -|-------|-----------|-------------| -| Auth Security | OAuth flow, token storage | Missing CSRF state param, no file permissions on token file | -| Graph API Client | HTTP client hardening | No redirect policy (Bearer token leak risk), no rate-limit handling, unstructured errors | -| CLI/MCP Input Handling | Input validation, output safety | No ID validation (path traversal risk), no OData injection protection, no terminal escape sanitization | -| Dependencies & Config | Build config, gitignore | Source maps published to npm, no key file patterns in .gitignore | -| DX Evaluation | AI developer experience | Missing `.describe()` on MCP params, no single-item get tools, no structured error responses, no `--version` | +| Agent | Focus Area | Key Findings | +| ---------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| Auth Security | OAuth flow, token storage | Missing CSRF state param, no file permissions on token file | +| Graph API Client | HTTP client hardening | No redirect policy (Bearer token leak risk), no rate-limit handling, unstructured errors | +| CLI/MCP Input Handling | Input validation, output safety | No ID validation (path traversal risk), no OData injection protection, no terminal escape sanitization | +| Dependencies & Config | Build config, gitignore | Source maps published to npm, no key file patterns in .gitignore | +| DX Evaluation | AI developer experience | Missing `.describe()` on MCP params, no single-item get tools, no structured error responses, no `--version` | Result: 5 Medium, 7 Low, 3 Informational findings. DX score: B+ (good foundation, missing polish for AI consumers). @@ -191,4 +191,3 @@ After implementation, `spec/intent.md` was retroactively updated with the securi - **15 MCP tools** + full CLI (was 13) - **23 sub-agents** used total across all phases (was 14) - **v0.2.0** released with all security and DX improvements - diff --git a/docs/security.md b/docs/security.md index 384d6bf..231fb62 100644 --- a/docs/security.md +++ b/docs/security.md @@ -20,6 +20,7 @@ Because there is no client secret, there is **no secret to store, leak, or rotat ### Why not MSAL? Both audited implementations use `@azure/msal-node`: + - jordanburke uses MSAL v3 with `ConfidentialClientApplication` (requires a client secret) - jhirono uses MSAL v1, which is **end-of-life** @@ -50,9 +51,9 @@ This is not Fort Knox — a determined attacker with access to the same user acc ### Comparison with audited implementations -| | jordanburke | jhirono | This tool | -|---|---|---|---| -| Token storage | Plaintext JSON (includes client secret!) | Plaintext JSON | AES-256-GCM encrypted | +| | jordanburke | jhirono | This tool | +| --------------- | ----------------------------------------- | ------------------------ | ----------------------- | +| Token storage | Plaintext JSON (includes client secret!) | Plaintext JSON | AES-256-GCM encrypted | | Secret exposure | `clientSecret` in cleartext `tokens.json` | No client secret in file | No client secret exists | ## Minimal Scopes @@ -64,14 +65,14 @@ Only two OAuth scopes are requested: ### What we don't request (and why) -| Scope | Requested by others | Why we skip it | -|---|---|---| -| `Tasks.Read` | Yes | Redundant — `Tasks.ReadWrite` already covers reading | -| `Tasks.Read.Shared` | Yes | Grants access to organization-shared tasks — far too broad for a personal task manager | -| `Tasks.ReadWrite.Shared` | Yes | Same — shared task access is not needed | -| `User.Read` | Yes | User profile information is not needed for task management | -| `openid` | Yes | OpenID Connect identity tokens are not needed | -| `profile` | Yes | Profile information (name, picture) is not needed | +| Scope | Requested by others | Why we skip it | +| ------------------------ | ------------------- | -------------------------------------------------------------------------------------- | +| `Tasks.Read` | Yes | Redundant — `Tasks.ReadWrite` already covers reading | +| `Tasks.Read.Shared` | Yes | Grants access to organization-shared tasks — far too broad for a personal task manager | +| `Tasks.ReadWrite.Shared` | Yes | Same — shared task access is not needed | +| `User.Read` | Yes | User profile information is not needed for task management | +| `openid` | Yes | OpenID Connect identity tokens are not needed | +| `profile` | Yes | Profile information (name, picture) is not needed | Both audited implementations request **8 scopes**. We request **2**. @@ -79,23 +80,23 @@ Both audited implementations request **8 scopes**. We request **2**. Each decision below references the audit finding that motivated it: -1. **No client secret** — We use a public client with PKCE. No secret exists to protect. *(jordanburke stores `clientSecret` in plaintext `tokens.json`)* +1. **No client secret** — We use a public client with PKCE. No secret exists to protect. _(jordanburke stores `clientSecret` in plaintext `tokens.json`)_ -2. **No PII logging** — We never log user identifiers, task content, or token values. *(Both implementations set `piiLoggingEnabled: true` in MSAL config, piping user identifiers to stdout)* +2. **No PII logging** — We never log user identifiers, task content, or token values. _(Both implementations set `piiLoggingEnabled: true` in MSAL config, piping user identifiers to stdout)_ -3. **No response body logging** — We only log HTTP status codes and error types, never response content. *(Both implementations log the first 200 characters of API responses, which can contain task content)* +3. **No response body logging** — We only log HTTP status codes and error types, never response content. _(Both implementations log the first 200 characters of API responses, which can contain task content)_ -4. **No external config modification** — We never write to Claude Desktop, VS Code, or any other application's configuration files. *(jordanburke auto-modifies `claude_desktop_config.json` without user consent)* +4. **No external config modification** — We never write to Claude Desktop, VS Code, or any other application's configuration files. _(jordanburke auto-modifies `claude_desktop_config.json` without user consent)_ -5. **No debug tools** — No API exploration or diagnostic tools are exposed in production. *(jordanburke ships `test-graph-api-exploration` as an MCP tool)* +5. **No debug tools** — No API exploration or diagnostic tools are exposed in production. _(jordanburke ships `test-graph-api-exploration` as an MCP tool)_ 6. **No telemetry** — No analytics, phone-home, or third-party endpoints. The tool contacts only Microsoft OAuth and Graph API endpoints. -7. **No token display** — The auth callback page shows only a "success" message, never token values. *(jhirono displays partial tokens in the browser callback page)* +7. **No token display** — The auth callback page shows only a "success" message, never token values. _(jhirono displays partial tokens in the browser callback page)_ -8. **No hardcoded tenant** — The OAuth tenant (`consumers`, `common`, or an org tenant ID) is always read from configuration. *(jhirono hardcodes `consumers` in the token refresh path, breaking organizational accounts)* +8. **No hardcoded tenant** — The OAuth tenant (`consumers`, `common`, or an org tenant ID) is always read from configuration. _(jhirono hardcodes `consumers` in the token refresh path, breaking organizational accounts)_ -9. **No redirect following** — HTTP requests use `redirect: 'error'` to prevent the Bearer token from being forwarded to unexpected domains via server-side redirects. *(A standard `fetch()` follows redirects by default, potentially leaking the Authorization header)* +9. **No redirect following** — HTTP requests use `redirect: 'error'` to prevent the Bearer token from being forwarded to unexpected domains via server-side redirects. _(A standard `fetch()` follows redirects by default, potentially leaking the Authorization header)_ ## Input Validation & Sanitization @@ -121,12 +122,12 @@ All CLI `parseArgs` calls use `strict: true`, meaning unrecognized flags are rej This is an exhaustive list of every endpoint this tool contacts: -| # | Endpoint | Purpose | -|---|---|---| -| 1 | `https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize` | OAuth authorization (opened in browser) | -| 2 | `https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token` | Token exchange and refresh | -| 3 | `https://graph.microsoft.com/v1.0/me/todo/lists/...` | Microsoft Graph API (task operations) | -| 4 | `http://localhost:3847/callback` | Local-only OAuth callback (ephemeral, runs only during initial auth setup) | +| # | Endpoint | Purpose | +| --- | ------------------------------------------------------------------ | -------------------------------------------------------------------------- | +| 1 | `https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize` | OAuth authorization (opened in browser) | +| 2 | `https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token` | Token exchange and refresh | +| 3 | `https://graph.microsoft.com/v1.0/me/todo/lists/...` | Microsoft Graph API (task operations) | +| 4 | `http://localhost:3847/callback` | Local-only OAuth callback (ephemeral, runs only during initial auth setup) | **No telemetry. No analytics. No third-party endpoints.** @@ -145,14 +146,15 @@ The Graph API client automatically handles HTTP 429 (Too Many Requests) response Only **2 runtime dependencies**: -| Package | Purpose | -|---|---| -| `@modelcontextprotocol/sdk` | MCP protocol server and stdio transport | -| `zod` | Input schema validation for tool parameters | +| Package | Purpose | +| --------------------------- | ------------------------------------------- | +| `@modelcontextprotocol/sdk` | MCP protocol server and stdio transport | +| `zod` | Input schema validation for tool parameters | Both are well-known, widely-used packages with active maintenance. **What we don't depend on:** + - **No `@azure/msal-node`** — raw `fetch()` with PKCE instead - **No `express`** — Node.js built-in `http.createServer()` for the ephemeral auth callback - **No `dotenv`** — `process.env` directly; users configure env vars in their MCP client config or shell profile diff --git a/package-lock.json b/package-lock.json index 8abb893..dbc133b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "todo-mcp-server", - "version": "0.1.0", + "name": "@thingsai/todo-mcp-server", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "todo-mcp-server", - "version": "0.1.0", + "name": "@thingsai/todo-mcp-server", + "version": "0.2.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", @@ -17,12 +17,13 @@ }, "devDependencies": { "@types/node": "^22.0.0", + "prettier": "^3.8.3", "tsx": "^4.19.0", "typescript": "^5.7.0", "vitest": "^3.1.0" }, "engines": { - "node": ">=20" + "node": ">=22" } }, "node_modules/@esbuild/aix-ppc64": { @@ -2101,6 +2102,22 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", diff --git a/package.json b/package.json index af5386c..5404d90 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc --noEmit", + "format": "prettier --write .", + "format:check": "prettier --check .", "prepublishOnly": "npm run build" }, "engines": { @@ -45,9 +47,10 @@ "zod": "^3.24.0" }, "devDependencies": { - "typescript": "^5.7.0", - "vitest": "^3.1.0", + "@types/node": "^22.0.0", + "prettier": "^3.8.3", "tsx": "^4.19.0", - "@types/node": "^22.0.0" + "typescript": "^5.7.0", + "vitest": "^3.1.0" } } diff --git a/spec/audits/2026-04-12-microsoft-todo-mcp-server.md b/spec/audits/2026-04-12-microsoft-todo-mcp-server.md index 87929f1..b96fef1 100644 --- a/spec/audits/2026-04-12-microsoft-todo-mcp-server.md +++ b/spec/audits/2026-04-12-microsoft-todo-mcp-server.md @@ -23,28 +23,28 @@ An MCP server by Jordan Burke (fork of jhirono/todoMCP) that lets AI assistants ### Credential Handling -| Area | Finding | Severity | -|---|---|---| -| Token storage | `tokens.json` on disk in plaintext. **Includes `clientId` and `clientSecret` alongside tokens.** No encryption. | ⚠️ Medium | -| Token logging | Access tokens are explicitly `[REDACTED]` in `makeGraphRequest()`. Auth server logs token structure keys but not values. `console.error` logs truncated API responses (first 200 chars — could contain task content). | ⚠️ Low | -| PII logging | `auth-server.ts` sets `piiLoggingEnabled: true` on MSAL logger, which pipes through `console.log`. This can expose user identifiers in logs. | ⚠️ Medium | -| Claude config auto-update | `token-manager.ts` and `setup.ts` auto-write tokens into Claude Desktop's `claude_desktop_config.json` — modifying another application's config file without explicit consent. | ⚠️ Medium | -| Env vars | Tokens can be passed via `MS_TODO_ACCESS_TOKEN` / `MS_TODO_REFRESH_TOKEN` env vars — standard pattern, acceptable. | ✅ OK | +| Area | Finding | Severity | +| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| Token storage | `tokens.json` on disk in plaintext. **Includes `clientId` and `clientSecret` alongside tokens.** No encryption. | ⚠️ Medium | +| Token logging | Access tokens are explicitly `[REDACTED]` in `makeGraphRequest()`. Auth server logs token structure keys but not values. `console.error` logs truncated API responses (first 200 chars — could contain task content). | ⚠️ Low | +| PII logging | `auth-server.ts` sets `piiLoggingEnabled: true` on MSAL logger, which pipes through `console.log`. This can expose user identifiers in logs. | ⚠️ Medium | +| Claude config auto-update | `token-manager.ts` and `setup.ts` auto-write tokens into Claude Desktop's `claude_desktop_config.json` — modifying another application's config file without explicit consent. | ⚠️ Medium | +| Env vars | Tokens can be passed via `MS_TODO_ACCESS_TOKEN` / `MS_TODO_REFRESH_TOKEN` env vars — standard pattern, acceptable. | ✅ OK | ### Scope of Access (Microsoft Graph API) Scopes requested during auth flow (`auth-server.ts`): -| Scope | Purpose | Least-privilege? | -|---|---|---| -| `Tasks.Read` | Read user's tasks | ✅ Needed | -| `Tasks.ReadWrite` | Read/write user's tasks | ✅ Needed | -| `Tasks.Read.Shared` | Read shared tasks | ⚠️ Broader than needed | -| `Tasks.ReadWrite.Shared` | Read/write shared tasks | ⚠️ Broader than needed | -| `User.Read` | Read user profile | ✅ Used for account type detection | -| `offline_access` | Refresh tokens | ✅ Standard | -| `openid` | OpenID Connect | ✅ Standard | -| `profile` | Profile info | ⚠️ Not strictly needed | +| Scope | Purpose | Least-privilege? | +| ------------------------ | ----------------------- | ---------------------------------- | +| `Tasks.Read` | Read user's tasks | ✅ Needed | +| `Tasks.ReadWrite` | Read/write user's tasks | ✅ Needed | +| `Tasks.Read.Shared` | Read shared tasks | ⚠️ Broader than needed | +| `Tasks.ReadWrite.Shared` | Read/write shared tasks | ⚠️ Broader than needed | +| `User.Read` | Read user profile | ✅ Used for account type detection | +| `offline_access` | Refresh tokens | ✅ Standard | +| `openid` | OpenID Connect | ✅ Standard | +| `profile` | Profile info | ⚠️ Not strictly needed | **Verdict**: Not fully least-privilege. The `.Shared` scopes grant access to tasks shared with the user across the organization — broader than personal To Do access. The `profile` scope is unnecessary. @@ -64,13 +64,13 @@ Scopes requested during auth flow (`auth-server.ts`): 5 runtime dependencies, 167 total packages after resolution: -| Package | Purpose | Risk | -|---|---|---| -| `@azure/msal-node` ^3.8.1 | Microsoft auth library (official) | ✅ Trusted | -| `@modelcontextprotocol/sdk` ^1.21.1 | MCP protocol SDK (official) | ✅ Trusted | -| `dotenv` ^16.6.1 | Env var loading | ✅ Standard | -| `express` ^5.1.0 | Auth server (local only) | ✅ Standard | -| `zod` ^3.25.76 | Schema validation | ✅ Standard | +| Package | Purpose | Risk | +| ----------------------------------- | --------------------------------- | ----------- | +| `@azure/msal-node` ^3.8.1 | Microsoft auth library (official) | ✅ Trusted | +| `@modelcontextprotocol/sdk` ^1.21.1 | MCP protocol SDK (official) | ✅ Trusted | +| `dotenv` ^16.6.1 | Env var loading | ✅ Standard | +| `express` ^5.1.0 | Auth server (local only) | ✅ Standard | +| `zod` ^3.25.76 | Schema validation | ✅ Standard | `npm audit`: **0 vulnerabilities** found. All dependencies are well-known, mainstream packages. @@ -88,6 +88,7 @@ Scopes requested during auth flow (`auth-server.ts`): ### MCP Protocol Compliance Uses `@modelcontextprotocol/sdk` properly: + - `McpServer` instantiation with name/version - `StdioServerTransport` for communication - Tools registered via `server.tool()` with Zod schemas for parameter validation @@ -147,6 +148,7 @@ Potential integration for managing Microsoft To Do tasks through GitHub Copilot The code is honest — it does what it claims, contacts only Microsoft endpoints, has no telemetry or exfiltration vectors. The dependency tree is clean and minimal. However, it stores OAuth client secrets in plaintext, enables PII logging, requests broader scopes than needed, and auto-modifies external application configs. These are not malicious, but they reflect a prototype-quality security posture. **If adopting**, fork and fix: + 1. Remove `clientSecret` from `tokens.json` — use env vars or OS keychain 2. Set `piiLoggingEnabled: false` 3. Remove `Tasks.Read.Shared` and `Tasks.ReadWrite.Shared` scopes (unless shared tasks are needed) diff --git a/spec/audits/2026-04-12-todomcp.md b/spec/audits/2026-04-12-todomcp.md index c873c8c..84af91c 100644 --- a/spec/audits/2026-04-12-todomcp.md +++ b/spec/audits/2026-04-12-todomcp.md @@ -25,36 +25,37 @@ Key architectural difference from the fork: **No setup wizard, no auto-modificat ### Credential Handling -| Area | Finding | Severity | -|---|---|---| -| Token storage | `tokens.json` on disk in plaintext. Tokens only (no client secret stored alongside). | ⚠️ Medium | -| Client secret | Stored in `.env` only (not in `tokens.json`), loaded via `dotenv`. `.env` is gitignored. | ✅ OK | -| PII logging | `auth-server.js` sets `piiLoggingEnabled: true` on MSAL logger, piping PII through `console.log`. | ⚠️ Medium | -| Token logging | `makeGraphRequest()` redacts Authorization header with `[REDACTED]`. But response bodies truncated to first 200 chars are logged via `console.error` — could contain task content. | ⚠️ Low | -| Auth callback HTML | Displays partial access token (first 15 + last 5 chars) and partial refresh token (first 10 + last 5 chars) in the browser success page. | ⚠️ Low | -| Verbose console.log | 51 `console.log` calls and 49 `console.error` calls across only 4 source files. Debug-level logging in production. | ⚠️ Low | -| Token in `mcp.json` | `create-mcp-config.ts` writes raw access and refresh tokens into `mcp.json` as env vars for Cursor config. | ⚠️ Medium | +| Area | Finding | Severity | +| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| Token storage | `tokens.json` on disk in plaintext. Tokens only (no client secret stored alongside). | ⚠️ Medium | +| Client secret | Stored in `.env` only (not in `tokens.json`), loaded via `dotenv`. `.env` is gitignored. | ✅ OK | +| PII logging | `auth-server.js` sets `piiLoggingEnabled: true` on MSAL logger, piping PII through `console.log`. | ⚠️ Medium | +| Token logging | `makeGraphRequest()` redacts Authorization header with `[REDACTED]`. But response bodies truncated to first 200 chars are logged via `console.error` — could contain task content. | ⚠️ Low | +| Auth callback HTML | Displays partial access token (first 15 + last 5 chars) and partial refresh token (first 10 + last 5 chars) in the browser success page. | ⚠️ Low | +| Verbose console.log | 51 `console.log` calls and 49 `console.error` calls across only 4 source files. Debug-level logging in production. | ⚠️ Low | +| Token in `mcp.json` | `create-mcp-config.ts` writes raw access and refresh tokens into `mcp.json` as env vars for Cursor config. | ⚠️ Medium | ### Scope of Access (Microsoft Graph API) Scopes requested during auth flow (`auth-server.js`): -| Scope | Purpose | Least-privilege? | -|---|---|---| -| `Tasks.Read` | Read user's tasks | ⚠️ Redundant (ReadWrite covers this) | -| `Tasks.ReadWrite` | Read/write user's tasks | ✅ Needed | -| `Tasks.Read.Shared` | Read shared tasks | ⚠️ Broader than needed | -| `Tasks.ReadWrite.Shared` | Read/write shared tasks | ⚠️ Broader than needed | -| `User.Read` | Read user profile | ⚠️ Used for personal account detection only | -| `offline_access` | Refresh tokens | ✅ Standard | -| `openid` | OpenID Connect | ⚠️ Not needed | -| `profile` | Profile info | ⚠️ Not needed | +| Scope | Purpose | Least-privilege? | +| ------------------------ | ----------------------- | ------------------------------------------- | +| `Tasks.Read` | Read user's tasks | ⚠️ Redundant (ReadWrite covers this) | +| `Tasks.ReadWrite` | Read/write user's tasks | ✅ Needed | +| `Tasks.Read.Shared` | Read shared tasks | ⚠️ Broader than needed | +| `Tasks.ReadWrite.Shared` | Read/write shared tasks | ⚠️ Broader than needed | +| `User.Read` | Read user profile | ⚠️ Used for personal account detection only | +| `offline_access` | Refresh tokens | ✅ Standard | +| `openid` | OpenID Connect | ⚠️ Not needed | +| `profile` | Profile info | ⚠️ Not needed | **Verdict**: Not least-privilege. Same scope over-reach as the fork — both share the same auth-server code origin. ### Hardcoded Tenant Bug The `refreshAccessToken()` function in `todo-index.ts` hardcodes the token endpoint to: + ``` https://login.microsoftonline.com/consumers/oauth2/v2.0/token ``` @@ -82,18 +83,18 @@ The auth-server exposes a `/silentLogin` endpoint that uses `acquireTokenByClien 82 packages listed as direct dependencies in `package.json` — **all transitive dependencies of Express and others have been incorrectly pinned as top-level dependencies**. The actual intentional dependencies are: -| Package | Purpose | Risk | -|---|---|---| -| `@azure/msal-node` ^1.18.0 | Microsoft auth (official) — **v1, outdated** | ⚠️ Outdated (v3 current), engine warns for Node 24 | -| `@modelcontextprotocol/sdk` ^1.7.0 | MCP protocol SDK (official) — **older version** | ⚠️ v1.7 vs v1.21 in fork | -| `express` ^5.0.1 | Auth server (local only) | ✅ Standard | -| `dotenv` ^16.3.1 | Env var loading | ✅ Standard | -| `cors` ^2.8.5 | Auth server CORS | ✅ Standard | -| `express-rate-limit` ^7.5.0 | Rate limiting — **imported but NEVER USED** | ⚠️ Dead dependency | -| `node-fetch` ^3.3.2 | HTTP client — **unnecessary** (Node 18+ has native fetch) | ⚠️ Unnecessary | -| `pkce-challenge` | PKCE — **imported but NEVER USED in code** | ⚠️ Dead dependency | -| `zod` | Schema validation | ✅ Standard | -| `zod-to-json-schema` | Schema conversion | ✅ Standard | +| Package | Purpose | Risk | +| ---------------------------------- | --------------------------------------------------------- | -------------------------------------------------- | +| `@azure/msal-node` ^1.18.0 | Microsoft auth (official) — **v1, outdated** | ⚠️ Outdated (v3 current), engine warns for Node 24 | +| `@modelcontextprotocol/sdk` ^1.7.0 | MCP protocol SDK (official) — **older version** | ⚠️ v1.7 vs v1.21 in fork | +| `express` ^5.0.1 | Auth server (local only) | ✅ Standard | +| `dotenv` ^16.3.1 | Env var loading | ✅ Standard | +| `cors` ^2.8.5 | Auth server CORS | ✅ Standard | +| `express-rate-limit` ^7.5.0 | Rate limiting — **imported but NEVER USED** | ⚠️ Dead dependency | +| `node-fetch` ^3.3.2 | HTTP client — **unnecessary** (Node 18+ has native fetch) | ⚠️ Unnecessary | +| `pkce-challenge` | PKCE — **imported but NEVER USED in code** | ⚠️ Dead dependency | +| `zod` | Schema validation | ✅ Standard | +| `zod-to-json-schema` | Schema conversion | ✅ Standard | `npm audit`: **0 vulnerabilities** found. 127 packages after resolution. @@ -101,48 +102,48 @@ The auth-server exposes a `/silentLogin` endpoint that uses `acquireTokenByClien ### MCP Tools Exposed (13) -| # | Tool | Parameters | -|---|---|---| -| 1 | `auth-status` | — | -| 2 | `get-task-lists` | — | -| 3 | `create-task-list` | displayName | -| 4 | `update-task-list` | listId, displayName | -| 5 | `delete-task-list` | listId | -| 6 | `get-tasks` | listId, filter?, select?, orderby?, top?, skip?, count? | -| 7 | `create-task` | listId, title, body?, dueDateTime?, startDateTime?, importance?, isReminderOn?, reminderDateTime?, status?, categories? | -| 8 | `update-task` | listId, taskId, title?, body?, dueDateTime?, startDateTime?, importance?, isReminderOn?, reminderDateTime?, status?, categories? | -| 9 | `delete-task` | listId, taskId | -| 10 | `get-checklist-items` | listId, taskId | -| 11 | `create-checklist-item` | listId, taskId, displayName, isChecked? | -| 12 | `update-checklist-item` | listId, taskId, checklistItemId, displayName?, isChecked? | -| 13 | `delete-checklist-item` | listId, taskId, checklistItemId | +| # | Tool | Parameters | +| --- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| 1 | `auth-status` | — | +| 2 | `get-task-lists` | — | +| 3 | `create-task-list` | displayName | +| 4 | `update-task-list` | listId, displayName | +| 5 | `delete-task-list` | listId | +| 6 | `get-tasks` | listId, filter?, select?, orderby?, top?, skip?, count? | +| 7 | `create-task` | listId, title, body?, dueDateTime?, startDateTime?, importance?, isReminderOn?, reminderDateTime?, status?, categories? | +| 8 | `update-task` | listId, taskId, title?, body?, dueDateTime?, startDateTime?, importance?, isReminderOn?, reminderDateTime?, status?, categories? | +| 9 | `delete-task` | listId, taskId | +| 10 | `get-checklist-items` | listId, taskId | +| 11 | `create-checklist-item` | listId, taskId, displayName, isChecked? | +| 12 | `update-checklist-item` | listId, taskId, checklistItemId, displayName?, isChecked? | +| 13 | `delete-checklist-item` | listId, taskId, checklistItemId | All tools use Zod schemas for parameter validation, proper MCP response format, and return structured text content. ## Comparison: todomcp vs. jordanburke fork vs. Our Planned Spec -| Dimension | todomcp (jhirono) | microsoft-todo-mcp-server (jordanburke) | Our Planned Spec | -|---|---|---|---| -| **Tools** | 13 | 16 (13 + organized lists, archive, debug tool) | 13 | -| **Auth approach** | ConfidentialClient + client secret | ConfidentialClient + client secret | **Public client + PKCE (no secret)** | -| **Token storage** | Plaintext `tokens.json` (no client secret in file) | Plaintext `tokens.json` (**includes** clientId + clientSecret) | **Encrypted at rest (DPAPI/AES-256)** | -| **Scopes** | 8 (including Shared + profile + openid) | 8 (same) | **2 only: Tasks.ReadWrite + offline_access** | -| **PII logging** | `piiLoggingEnabled: true` | `piiLoggingEnabled: true` | **false** | -| **Auto-modify external config** | No (exports mcp.json instead) | Yes (writes to Claude Desktop config) | **No** | -| **Debug tools in prod** | No | Yes (test-graph-api-exploration) | **No** | -| **Response body logging** | First 200 chars to stderr | First 200 chars to stderr | **Status codes only** | -| **MSAL version** | v1.18 (outdated, engine warnings) | v3.8 (current) | **v3.x (current)** | -| **MCP SDK version** | v1.7 (old) | v1.21 (recent) | **Latest** | -| **Tests** | None | None | **Yes (planned)** | -| **License** | **No LICENSE file** | MIT | MIT | -| **Architecture** | MCP-only, monolithic | MCP-only, monolithic with setup wizard | **CLI-first + MCP wrapper, shared core** | -| **Tenant handling** | Configurable in auth, **hardcoded in refresh** | Configurable throughout | **Configurable throughout** | -| **Dependencies** | 82 listed (10 real + 72 transitive pinned as direct) | 5 direct | **Minimal** | -| **Runtime deps** | 127 resolved | 167 resolved | **Target < 100** | -| **Client credentials endpoint** | Yes (`/silentLogin`) | No | **No** | -| **Dead dependencies** | `express-rate-limit`, `pkce-challenge` (never used) | None | **None** | -| **Last commit** | March 2025 (13 months ago) | November 2025 (5 months ago) | — | -| **Personal account detection** | Yes (warns about MailboxNotEnabledForRESTAPI) | Yes | Nice-to-have | +| Dimension | todomcp (jhirono) | microsoft-todo-mcp-server (jordanburke) | Our Planned Spec | +| ------------------------------- | ---------------------------------------------------- | -------------------------------------------------------------- | -------------------------------------------- | +| **Tools** | 13 | 16 (13 + organized lists, archive, debug tool) | 13 | +| **Auth approach** | ConfidentialClient + client secret | ConfidentialClient + client secret | **Public client + PKCE (no secret)** | +| **Token storage** | Plaintext `tokens.json` (no client secret in file) | Plaintext `tokens.json` (**includes** clientId + clientSecret) | **Encrypted at rest (DPAPI/AES-256)** | +| **Scopes** | 8 (including Shared + profile + openid) | 8 (same) | **2 only: Tasks.ReadWrite + offline_access** | +| **PII logging** | `piiLoggingEnabled: true` | `piiLoggingEnabled: true` | **false** | +| **Auto-modify external config** | No (exports mcp.json instead) | Yes (writes to Claude Desktop config) | **No** | +| **Debug tools in prod** | No | Yes (test-graph-api-exploration) | **No** | +| **Response body logging** | First 200 chars to stderr | First 200 chars to stderr | **Status codes only** | +| **MSAL version** | v1.18 (outdated, engine warnings) | v3.8 (current) | **v3.x (current)** | +| **MCP SDK version** | v1.7 (old) | v1.21 (recent) | **Latest** | +| **Tests** | None | None | **Yes (planned)** | +| **License** | **No LICENSE file** | MIT | MIT | +| **Architecture** | MCP-only, monolithic | MCP-only, monolithic with setup wizard | **CLI-first + MCP wrapper, shared core** | +| **Tenant handling** | Configurable in auth, **hardcoded in refresh** | Configurable throughout | **Configurable throughout** | +| **Dependencies** | 82 listed (10 real + 72 transitive pinned as direct) | 5 direct | **Minimal** | +| **Runtime deps** | 127 resolved | 167 resolved | **Target < 100** | +| **Client credentials endpoint** | Yes (`/silentLogin`) | No | **No** | +| **Dead dependencies** | `express-rate-limit`, `pkce-challenge` (never used) | None | **None** | +| **Last commit** | March 2025 (13 months ago) | November 2025 (5 months ago) | — | +| **Personal account detection** | Yes (warns about MailboxNotEnabledForRESTAPI) | Yes | Nice-to-have | ## Red Flags @@ -203,11 +204,13 @@ All tools use Zod schemas for parameter validation, proper MCP response format, This is the original code that jordanburke forked and improved. The fork is strictly better in terms of maintenance (newer deps, more recent commits, proper license). Neither is suitable for production use without hardening. **For our implementation, take from todomcp:** + - Personal account detection pattern - OData query parameter design for `get-tasks` - Tool description style (hierarchy-aware) - Empty-string-to-null pattern for clearing date fields **Avoid from todomcp:** + - Everything listed in the Anti-Patterns section of our spike.md already covers this - Additionally: hardcoded tenant in refresh, client credentials endpoint, dead dependencies diff --git a/spec/intent.md b/spec/intent.md index 74a9691..b207568 100644 --- a/spec/intent.md +++ b/spec/intent.md @@ -7,12 +7,14 @@ Build a **CLI-first** tool for managing Microsoft To Do tasks, with an optional AI agents excel at planning, breaking down tasks, and tracking follow-ups — but they need a write path into the user's actual task management system. Without one, plans live in chat and die there. **Why CLI-first:** + - Any agent with terminal access can use it — no MCP config per agent/editor - Works outside Copilot too: scripts, cron jobs, shell aliases, other AI tools - Simpler to test: `todo tasks create --list "Tasks" --title "Call dentist" --due tomorrow` - The MCP wrapper is ~50 lines that maps tools to the same core library **Key use cases:** + - **Externalize plans**: Convert conversations into tasks with due dates, reminders, and checklist sub-steps - **Micro-stepping**: Break tasks into small, concrete checklist items - **Reminder-driven workflow**: Set reminders at key transition points @@ -42,24 +44,25 @@ The existing implementation (`jordanburke/microsoft-todo-mcp-server`) was evalua ## Existing Solutions Comparison Two existing Microsoft To Do MCP servers were investigated. Full security audits: + - [`spec/audits/2026-04-12-microsoft-todo-mcp-server.md`](audits/2026-04-12-microsoft-todo-mcp-server.md) (jordanburke) - [`spec/audits/2026-04-12-todomcp.md`](audits/2026-04-12-todomcp.md) (jhirono) -| Dimension | jhirono/todomcp | jordanburke/microsoft-todo-mcp-server | **Our Spec** | -|---|---|---|---| -| Interface | MCP only | MCP only | **CLI + MCP** | -| Auth | ConfidentialClient + secret | ConfidentialClient + secret | **Public client + PKCE (no secret)** | -| Token storage | Plaintext JSON | Plaintext JSON (**secret in file**) | **Encrypted (AES-256-GCM)** | -| Scopes | 8 | 8 | **2** (`Tasks.ReadWrite` + `offline_access`) | -| PII logging | Enabled | Enabled | **Disabled** | -| Auto-modify external config | No | Yes (Claude Desktop) | **No** | -| Dependencies | 82 direct | ~30 | **2 runtime** (`@modelcontextprotocol/sdk`, `zod`) | -| MSAL | v1 (EOL) | v3 | **None — raw fetch + PKCE** | -| Tests | None | None | **Yes (vitest)** | -| Tools | 13 | 16 | **15** | -| Debug tools exposed | No | Yes | **No** | -| Last commit | Mar 2025 (13mo) | Nov 2025 (5mo) | — | -| License | **None** | MIT | MIT | +| Dimension | jhirono/todomcp | jordanburke/microsoft-todo-mcp-server | **Our Spec** | +| --------------------------- | --------------------------- | ------------------------------------- | -------------------------------------------------- | +| Interface | MCP only | MCP only | **CLI + MCP** | +| Auth | ConfidentialClient + secret | ConfidentialClient + secret | **Public client + PKCE (no secret)** | +| Token storage | Plaintext JSON | Plaintext JSON (**secret in file**) | **Encrypted (AES-256-GCM)** | +| Scopes | 8 | 8 | **2** (`Tasks.ReadWrite` + `offline_access`) | +| PII logging | Enabled | Enabled | **Disabled** | +| Auto-modify external config | No | Yes (Claude Desktop) | **No** | +| Dependencies | 82 direct | ~30 | **2 runtime** (`@modelcontextprotocol/sdk`, `zod`) | +| MSAL | v1 (EOL) | v3 | **None — raw fetch + PKCE** | +| Tests | None | None | **Yes (vitest)** | +| Tools | 13 | 16 | **15** | +| Debug tools exposed | No | Yes | **No** | +| Last commit | Mar 2025 (13mo) | Nov 2025 (5mo) | — | +| License | **None** | MIT | MIT | ### Patterns to Adopt from Existing Implementations @@ -91,6 +94,7 @@ Both interfaces share: Graph API client, auth/token management, input validation - No HTTP server in production (auth uses a temporary localhost callback only during initial setup) ### OAuth Flow + - **OAuth 2.0 Authorization Code with PKCE** (public client — no client secret) - Azure AD app registration as a **public client** with `http://localhost:3847/callback` redirect URI - Tenant: `consumers` (personal Microsoft accounts) or `common` (both personal + org) — configurable via env var @@ -98,6 +102,7 @@ Both interfaces share: Graph API client, auth/token management, input validation - **Auto-auth from MCP serve**: When `todo serve` detects no stored tokens and `TODO_MCP_CLIENT_ID` is available, it auto-triggers the OAuth browser flow inline. All auth messages go to **stderr** (not stdout) to keep the MCP JSON-RPC stdio transport clean. This means MCP users never need to run `todo setup` separately — the first tool call handles everything. ### Token Storage + - Tokens stored in platform-specific secure storage: - **Windows**: DPAPI via `node:crypto` (`crypto.createCipheriv` with a machine-scoped key derived from `DPAPI`) - **Cross-platform fallback**: AES-256-GCM encryption with a key derived from machine identity (hostname + username + a salt), stored in the user's config directory @@ -109,6 +114,7 @@ Both interfaces share: Graph API client, auth/token management, input validation Users need an Azure AD app registration to get a client ID. The README and docs must include both methods: **Azure Portal:** + 1. Go to Azure Portal → App registrations → New registration 2. Name: anything (e.g., "Todo MCP Server") 3. Supported account types: "Accounts in any organizational directory and personal Microsoft accounts" @@ -117,6 +123,7 @@ Users need an Azure AD app registration to get a client ID. The README and docs 6. Copy the Application (client) ID **Azure CLI (one-liner):** + ```bash az ad app create \ --display-name "Todo MCP Server" \ @@ -124,7 +131,9 @@ az ad app create \ --sign-in-audience "AzureADandPersonalMicrosoftAccount" \ --query appId -o tsv ``` + Then add the permission: + ```bash az ad app permission add \ --id \ @@ -167,6 +176,7 @@ system/src/todo-mcp-server/ Base URL: `https://graph.microsoft.com/v1.0` ### Scopes Required + - `Tasks.ReadWrite` — read and write the user's tasks and task lists - `offline_access` — obtain a refresh token for long-lived access @@ -176,15 +186,16 @@ Base URL: `https://graph.microsoft.com/v1.0` #### Task Lists -| Operation | Method | URL | Request Body | Response | -|---|---|---|---|---| -| List all | `GET` | `/me/todo/lists` | — | `{ value: TodoTaskList[] }` | -| Get one | `GET` | `/me/todo/lists/{listId}` | — | `TodoTaskList` | -| Create | `POST` | `/me/todo/lists` | `{ displayName: string }` | `TodoTaskList` | -| Update | `PATCH` | `/me/todo/lists/{listId}` | `{ displayName: string }` | `TodoTaskList` | -| Delete | `DELETE` | `/me/todo/lists/{listId}` | — | `204 No Content` | +| Operation | Method | URL | Request Body | Response | +| --------- | -------- | ------------------------- | ------------------------- | --------------------------- | +| List all | `GET` | `/me/todo/lists` | — | `{ value: TodoTaskList[] }` | +| Get one | `GET` | `/me/todo/lists/{listId}` | — | `TodoTaskList` | +| Create | `POST` | `/me/todo/lists` | `{ displayName: string }` | `TodoTaskList` | +| Update | `PATCH` | `/me/todo/lists/{listId}` | `{ displayName: string }` | `TodoTaskList` | +| Delete | `DELETE` | `/me/todo/lists/{listId}` | — | `204 No Content` | **TodoTaskList shape:** + ```json { "id": "string", @@ -197,15 +208,16 @@ Base URL: `https://graph.microsoft.com/v1.0` #### Tasks -| Operation | Method | URL | Request Body | Response | -|---|---|---|---|---| -| List | `GET` | `/me/todo/lists/{listId}/tasks` | — | `{ value: TodoTask[] }` | -| Get one | `GET` | `/me/todo/lists/{listId}/tasks/{taskId}` | — | `TodoTask` | -| Create | `POST` | `/me/todo/lists/{listId}/tasks` | See below | `TodoTask` | -| Update | `PATCH` | `/me/todo/lists/{listId}/tasks/{taskId}` | Partial `TodoTask` | `TodoTask` | -| Delete | `DELETE` | `/me/todo/lists/{listId}/tasks/{taskId}` | — | `204 No Content` | +| Operation | Method | URL | Request Body | Response | +| --------- | -------- | ---------------------------------------- | ------------------ | ----------------------- | +| List | `GET` | `/me/todo/lists/{listId}/tasks` | — | `{ value: TodoTask[] }` | +| Get one | `GET` | `/me/todo/lists/{listId}/tasks/{taskId}` | — | `TodoTask` | +| Create | `POST` | `/me/todo/lists/{listId}/tasks` | See below | `TodoTask` | +| Update | `PATCH` | `/me/todo/lists/{listId}/tasks/{taskId}` | Partial `TodoTask` | `TodoTask` | +| Delete | `DELETE` | `/me/todo/lists/{listId}/tasks/{taskId}` | — | `204 No Content` | **TodoTask shape (relevant properties):** + ```json { "id": "string", @@ -240,6 +252,7 @@ Base URL: `https://graph.microsoft.com/v1.0` ``` **OData query support for GET tasks:** + - `$filter` — e.g., `status eq 'notStarted'` - `$orderby` — e.g., `dueDateTime/dateTime asc` - `$top` / `$skip` — pagination @@ -247,14 +260,15 @@ Base URL: `https://graph.microsoft.com/v1.0` #### Checklist Items -| Operation | Method | URL | Request Body | Response | -|---|---|---|---|---| -| List | `GET` | `/me/todo/lists/{listId}/tasks/{taskId}/checklistItems` | — | `{ value: ChecklistItem[] }` | -| Create | `POST` | `/me/todo/lists/{listId}/tasks/{taskId}/checklistItems` | `{ displayName, isChecked? }` | `ChecklistItem` | -| Update | `PATCH` | `/me/todo/lists/{listId}/tasks/{taskId}/checklistItems/{itemId}` | Partial | `ChecklistItem` | -| Delete | `DELETE` | `/me/todo/lists/{listId}/tasks/{taskId}/checklistItems/{itemId}` | — | `204 No Content` | +| Operation | Method | URL | Request Body | Response | +| --------- | -------- | ---------------------------------------------------------------- | ----------------------------- | ---------------------------- | +| List | `GET` | `/me/todo/lists/{listId}/tasks/{taskId}/checklistItems` | — | `{ value: ChecklistItem[] }` | +| Create | `POST` | `/me/todo/lists/{listId}/tasks/{taskId}/checklistItems` | `{ displayName, isChecked? }` | `ChecklistItem` | +| Update | `PATCH` | `/me/todo/lists/{listId}/tasks/{taskId}/checklistItems/{itemId}` | Partial | `ChecklistItem` | +| Delete | `DELETE` | `/me/todo/lists/{listId}/tasks/{taskId}/checklistItems/{itemId}` | — | `204 No Content` | **ChecklistItem shape:** + ```json { "id": "string", @@ -271,6 +285,7 @@ Base URL: `https://graph.microsoft.com/v1.0` **Description:** Get all Microsoft To Do task lists. Returns list names, IDs, and metadata. **Input schema:** + ```json {} ``` @@ -286,6 +301,7 @@ Base URL: `https://graph.microsoft.com/v1.0` **Description:** Get a single task list by ID. Useful for confirming a list exists or retrieving its current name after an update. **Input schema:** + ```json { "listId": { "type": "string", "description": "ID of the task list to retrieve" } @@ -303,6 +319,7 @@ Base URL: `https://graph.microsoft.com/v1.0` **Description:** Create a new task list in Microsoft To Do. **Input schema:** + ```json { "displayName": { "type": "string", "description": "Name of the new task list" } @@ -320,6 +337,7 @@ Base URL: `https://graph.microsoft.com/v1.0` **Description:** Rename an existing task list. **Input schema:** + ```json { "listId": { "type": "string", "description": "ID of the task list" }, @@ -338,6 +356,7 @@ Base URL: `https://graph.microsoft.com/v1.0` **Description:** Delete a task list and all tasks within it. Irreversible. **Input schema:** + ```json { "listId": { "type": "string", "description": "ID of the task list to delete" } @@ -355,13 +374,31 @@ Base URL: `https://graph.microsoft.com/v1.0` **Description:** Get tasks from a specific list. Supports filtering by status, sorting by due date, and pagination. **Input schema:** + ```json { "listId": { "type": "string", "description": "ID of the task list" }, - "status": { "type": "string", "enum": ["notStarted", "inProgress", "completed", "waitingOnOthers", "deferred"], "description": "Filter by task status", "optional": true }, - "top": { "type": "number", "description": "Maximum number of tasks to return (default: 100)", "optional": true }, - "filter": { "type": "string", "description": "OData $filter expression for advanced querying", "optional": true }, - "orderby": { "type": "string", "description": "OData $orderby expression for sorting results", "optional": true } + "status": { + "type": "string", + "enum": ["notStarted", "inProgress", "completed", "waitingOnOthers", "deferred"], + "description": "Filter by task status", + "optional": true + }, + "top": { + "type": "number", + "description": "Maximum number of tasks to return (default: 100)", + "optional": true + }, + "filter": { + "type": "string", + "description": "OData $filter expression for advanced querying", + "optional": true + }, + "orderby": { + "type": "string", + "description": "OData $orderby expression for sorting results", + "optional": true + } } ``` @@ -376,6 +413,7 @@ Base URL: `https://graph.microsoft.com/v1.0` **Description:** Get a single task by ID. Essential for confirming state after mutations (create, update, complete) — agents should read back after writes. **Input schema:** + ```json { "listId": { "type": "string", "description": "ID of the task list" }, @@ -394,17 +432,42 @@ Base URL: `https://graph.microsoft.com/v1.0` **Description:** Create a new task in a list. Supports title, body, due date, reminder, importance, and status. **Input schema:** + ```json { "listId": { "type": "string", "description": "ID of the task list" }, - "title": { "type": "string", "description": "Task title — should be a concrete next action, not a vague intention" }, + "title": { + "type": "string", + "description": "Task title — should be a concrete next action, not a vague intention" + }, "body": { "type": "string", "description": "Task description/notes", "optional": true }, - "dueDateTime": { "type": "string", "description": "Due date in ISO 8601 format (e.g., 2026-04-15T17:00:00Z)", "optional": true }, - "reminderDateTime": { "type": "string", "description": "Reminder date/time in ISO 8601 format", "optional": true }, + "dueDateTime": { + "type": "string", + "description": "Due date in ISO 8601 format (e.g., 2026-04-15T17:00:00Z)", + "optional": true + }, + "reminderDateTime": { + "type": "string", + "description": "Reminder date/time in ISO 8601 format", + "optional": true + }, "importance": { "type": "string", "enum": ["low", "normal", "high"], "optional": true }, - "startDateTime": { "type": "string", "description": "Start date in ISO 8601 format", "optional": true }, - "status": { "type": "string", "enum": ["notStarted", "inProgress", "completed", "waitingOnOthers", "deferred"], "optional": true }, - "categories": { "type": "array", "items": { "type": "string" }, "description": "Category tags", "optional": true } + "startDateTime": { + "type": "string", + "description": "Start date in ISO 8601 format", + "optional": true + }, + "status": { + "type": "string", + "enum": ["notStarted", "inProgress", "completed", "waitingOnOthers", "deferred"], + "optional": true + }, + "categories": { + "type": "array", + "items": { "type": "string" }, + "description": "Category tags", + "optional": true + } } ``` @@ -419,6 +482,7 @@ Base URL: `https://graph.microsoft.com/v1.0` **Description:** Update any properties of an existing task — title, status, due date, reminder, importance, body, categories. **Input schema:** + ```json { "listId": { "type": "string" }, @@ -429,7 +493,11 @@ Base URL: `https://graph.microsoft.com/v1.0` "reminderDateTime": { "type": "string", "optional": true }, "importance": { "type": "string", "enum": ["low", "normal", "high"], "optional": true }, "startDateTime": { "type": "string", "optional": true }, - "status": { "type": "string", "enum": ["notStarted", "inProgress", "completed", "waitingOnOthers", "deferred"], "optional": true }, + "status": { + "type": "string", + "enum": ["notStarted", "inProgress", "completed", "waitingOnOthers", "deferred"], + "optional": true + }, "categories": { "type": "array", "items": { "type": "string" }, "optional": true } } ``` @@ -445,6 +513,7 @@ Base URL: `https://graph.microsoft.com/v1.0` **Description:** Delete a task and all its checklist items. Irreversible. **Input schema:** + ```json { "listId": { "type": "string" }, @@ -463,6 +532,7 @@ Base URL: `https://graph.microsoft.com/v1.0` **Description:** Mark a task as completed. Convenience wrapper that sets `status: "completed"`. Designed for quick CoS-driven task closure during accountability check-ins. **Input schema:** + ```json { "listId": { "type": "string" }, @@ -481,6 +551,7 @@ Base URL: `https://graph.microsoft.com/v1.0` **Description:** Get the checklist items (sub-steps) for a task. **Input schema:** + ```json { "listId": { "type": "string" }, @@ -499,11 +570,15 @@ Base URL: `https://graph.microsoft.com/v1.0` **Description:** Add a checklist sub-step to a task. **Input schema:** + ```json { "listId": { "type": "string" }, "taskId": { "type": "string" }, - "displayName": { "type": "string", "description": "Text of the checklist item — should be a concrete micro-step" }, + "displayName": { + "type": "string", + "description": "Text of the checklist item — should be a concrete micro-step" + }, "isChecked": { "type": "boolean", "optional": true } } ``` @@ -519,6 +594,7 @@ Base URL: `https://graph.microsoft.com/v1.0` **Description:** Update a checklist item's text or checked status. **Input schema:** + ```json { "listId": { "type": "string" }, @@ -540,6 +616,7 @@ Base URL: `https://graph.microsoft.com/v1.0` **Description:** Delete a checklist item from a task. **Input schema:** + ```json { "listId": { "type": "string" }, @@ -555,6 +632,7 @@ Base URL: `https://graph.microsoft.com/v1.0` --- **Tools NOT included (by design):** + - No `auth-status` tool — authentication is invisible; errors surface in tool responses - No `get-task-lists-organized` — hardcoded emoji-pattern grouping is user-specific; the CoS agent can do this in its own logic - No `archive-completed-tasks` — bulk operations should be composed by the agent from primitive CRUD tools @@ -600,6 +678,7 @@ Base URL: `https://graph.microsoft.com/v1.0` ``` 5. **User authenticates** in browser → Microsoft redirects to `http://localhost:3847/callback?code=...` 6. **Exchange code for tokens**: + ``` POST https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token Content-Type: application/x-www-form-urlencoded @@ -610,7 +689,9 @@ Base URL: `https://graph.microsoft.com/v1.0` &redirect_uri=http://localhost:3847/callback &code_verifier={code_verifier} ``` + Response contains `access_token`, `refresh_token`, `expires_in`. + 7. **Encrypt and store tokens** to disk (see Token Storage below) 8. **Shut down localhost server** — it only runs for the auth flow 9. **Print success message** with instructions for configuring MCP client @@ -619,6 +700,7 @@ Base URL: `https://graph.microsoft.com/v1.0` - Before every Graph API call, check if `expiresAt < Date.now() + 5_minutes` - If expired or near-expiry, refresh: + ``` POST https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token Content-Type: application/x-www-form-urlencoded @@ -628,6 +710,7 @@ Base URL: `https://graph.microsoft.com/v1.0` &refresh_token={stored_refresh_token} &scope=Tasks.ReadWrite offline_access ``` + - On success: update stored tokens (both access and refresh — refresh tokens rotate) - On failure (e.g., refresh token revoked): return a clear error message instructing the user to re-run setup - **No client secret** in the refresh request (public client flow) @@ -635,6 +718,7 @@ Base URL: `https://graph.microsoft.com/v1.0` ### Secure Token Persistence Store an encrypted JSON blob containing: + ```json { "accessToken": "...", @@ -646,6 +730,7 @@ Store an encrypted JSON blob containing: ``` Encryption approach: + - **AES-256-GCM** using Node.js `crypto` module - Key derived from: `PBKDF2(machineId + username, storedSalt, 100000, 32, 'sha512')` - `machineId`: `os.hostname()` (not secret, but adds machine-binding) @@ -681,16 +766,17 @@ Encryption approach: ## Project Setup ### Directory + ``` system/src/todo-mcp-server/ ``` ### Dependencies (minimal list with justification) -| Package | Purpose | Justification | -|---|---|---| -| `@modelcontextprotocol/sdk` | MCP protocol server + stdio transport | Required — this is what we're building | -| `zod` | Input schema validation for MCP tools | Required by MCP SDK for tool parameter schemas | +| Package | Purpose | Justification | +| --------------------------- | ------------------------------------- | ---------------------------------------------- | +| `@modelcontextprotocol/sdk` | MCP protocol server + stdio transport | Required — this is what we're building | +| `zod` | Input schema validation for MCP tools | Required by MCP SDK for tool parameter schemas | **That's it.** Two runtime dependencies. @@ -700,12 +786,12 @@ system/src/todo-mcp-server/ ### Dev Dependencies -| Package | Purpose | -|---|---| -| `typescript` | Type checking and compilation | -| `vitest` | Testing | -| `tsx` | Dev execution without build step | -| `@types/node` | Node.js type definitions | +| Package | Purpose | +| ------------- | -------------------------------- | +| `typescript` | Type checking and compilation | +| `vitest` | Testing | +| `tsx` | Dev execution without build step | +| `@types/node` | Node.js type definitions | ### package.json essentials @@ -719,12 +805,7 @@ system/src/todo-mcp-server/ "bin": { "todo": "dist/cli.js" }, - "files": [ - "dist/**/*.js", - "dist/**/*.d.ts", - "README.md", - "LICENSE" - ], + "files": ["dist/**/*.js", "dist/**/*.d.ts", "README.md", "LICENSE"], "scripts": { "build": "tsc", "start": "node dist/cli.js", @@ -887,15 +968,15 @@ dist/ ### Unit Tests (vitest) -| Area | What to test | -|---|---| -| `token-store.ts` | Encrypt/decrypt round-trip; corrupted file handling; missing file returns null | -| `token-manager.ts` | Env var override; file-based token load; token refresh flow (mock fetch); mutex prevents concurrent refresh; expired token triggers refresh | -| `graph/client.ts` | Request construction (URL, headers, body); 401 retry logic; 429 rate-limit retry; redirect policy; structured error class; error response handling; no response body in logs | -| `core/*.ts` | Input validation (ID format, enum values); request body construction for Graph API; response formatting | -| `cli.ts` | Arg parsing; command routing; output formatting; strict mode rejects unknown flags; enum validation | -| `auth/setup.ts` | PKCE verifier/challenge generation; code challenge is valid base64url SHA-256; state parameter generation and verification | -| `format.ts` | Terminal output sanitization strips ANSI escapes and control characters | +| Area | What to test | +| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `token-store.ts` | Encrypt/decrypt round-trip; corrupted file handling; missing file returns null | +| `token-manager.ts` | Env var override; file-based token load; token refresh flow (mock fetch); mutex prevents concurrent refresh; expired token triggers refresh | +| `graph/client.ts` | Request construction (URL, headers, body); 401 retry logic; 429 rate-limit retry; redirect policy; structured error class; error response handling; no response body in logs | +| `core/*.ts` | Input validation (ID format, enum values); request body construction for Graph API; response formatting | +| `cli.ts` | Arg parsing; command routing; output formatting; strict mode rejects unknown flags; enum validation | +| `auth/setup.ts` | PKCE verifier/challenge generation; code challenge is valid base64url SHA-256; state parameter generation and verification | +| `format.ts` | Terminal output sanitization strips ANSI escapes and control characters | ### Integration Tests (manual, with real Azure AD app) @@ -905,6 +986,7 @@ dist/ - Error handling: invalid list ID, expired/revoked tokens, network errors ### What NOT to test + - Microsoft Graph API behavior itself (they own that) - MCP SDK internals (`McpServer`, `StdioServerTransport`) - HTTPS/TLS behavior @@ -917,12 +999,15 @@ dist/ - [ ] `npm start` runs CLI; `todo serve` launches MCP server on stdio - [ ] CLI commands work for all 15 operations: - [ ] MCP tools are registered and functional (thin wrapper over CLI core): + ``` todo lists / todo tasks / todo checklist — all CRUD operations + single-item get ``` + - `list-task-lists`, `get-task-list`, `create-task-list`, `update-task-list`, `delete-task-list` - `list-tasks`, `get-task`, `create-task`, `update-task`, `delete-task`, `complete-task` - `list-checklist-items`, `create-checklist-item`, `update-checklist-item`, `delete-checklist-item` + - [ ] Token storage is encrypted (not plaintext JSON) - [ ] No PII or task content in logs (grep stderr output during operation) - [ ] No client secret anywhere in the codebase diff --git a/spec/plan.md b/spec/plan.md index 05f8e76..1a56ade 100644 --- a/spec/plan.md +++ b/spec/plan.md @@ -2,9 +2,10 @@ ## Overview -Build a CLI-first Microsoft To Do management tool with an optional MCP server wrapper. The implementation follows the spec in `spec/intent.md` exactly. The project lives in the repo root (not `system/src/todo-mcp-server/` as the spec's tree diagram suggests — adjusted since this repo *is* the project). +Build a CLI-first Microsoft To Do management tool with an optional MCP server wrapper. The implementation follows the spec in `spec/intent.md` exactly. The project lives in the repo root (not `system/src/todo-mcp-server/` as the spec's tree diagram suggests — adjusted since this repo _is_ the project). **Reference documents:** + - Intent/Spec: [`spec/intent.md`](intent.md) - Security audit (jordanburke): [`spec/audits/2026-04-12-microsoft-todo-mcp-server.md`](audits/2026-04-12-microsoft-todo-mcp-server.md) - Security audit (jhirono): [`spec/audits/2026-04-12-todomcp.md`](audits/2026-04-12-todomcp.md) @@ -18,6 +19,7 @@ The work is broken into **8 parallelizable work units** designed for `/fleet` ex ## Work Units ### WU-1: Project Scaffolding + Types + **Files:** `package.json`, `tsconfig.json`, `.gitignore` (update), `src/types.ts` **Dependencies:** None (do this first or in parallel with nothing) @@ -35,6 +37,7 @@ The work is broken into **8 parallelizable work units** designed for `/fleet` ex - Run `tsc --noEmit` to verify types compile ### WU-2: Auth — Token Store (Encrypted Persistence) + **Files:** `src/auth/token-store.ts`, `tests/token-store.test.ts` **Dependencies:** WU-1 (needs types + package.json) @@ -55,6 +58,7 @@ The work is broken into **8 parallelizable work units** designed for `/fleet` ex - Salt, IV, and tag are present and correct lengths in stored format ### WU-3: Auth — Token Manager + Refresh + **Files:** `src/auth/token-manager.ts`, `tests/token-manager.test.ts` **Dependencies:** WU-1 (types), WU-2 (token-store) @@ -82,6 +86,7 @@ The work is broken into **8 parallelizable work units** designed for `/fleet` ex - Refresh uses configurable tenant (NOT hardcoded `consumers` — jhirono audit bug) ### WU-4: Auth — Setup (OAuth PKCE Flow) + **Files:** `src/auth/setup.ts`, `tests/setup.test.ts` **Dependencies:** WU-1 (types), WU-2 (token-store) @@ -107,6 +112,7 @@ The work is broken into **8 parallelizable work units** designed for `/fleet` ex - Token exchange request body contains code_verifier and no client_secret ### WU-5: Graph API Client + **Files:** `src/graph/client.ts`, `tests/graph-client.test.ts` **Dependencies:** WU-1 (types), WU-3 (token-manager) @@ -133,10 +139,12 @@ The work is broken into **8 parallelizable work units** designed for `/fleet` ex - Query parameters (OData) are appended correctly to URL ### WU-6: Core Operations (Task Lists + Tasks + Checklist Items) + **Files:** `src/core/task-lists.ts`, `src/core/tasks.ts`, `src/core/checklist-items.ts`, `tests/core.test.ts` **Dependencies:** WU-1 (types), WU-5 (graph client) **Task Lists** (`src/core/task-lists.ts`): + - `listTaskLists(client)` → GET /me/todo/lists - `createTaskList(client, displayName)` → POST /me/todo/lists - `updateTaskList(client, listId, displayName)` → PATCH /me/todo/lists/{listId} @@ -144,6 +152,7 @@ The work is broken into **8 parallelizable work units** designed for `/fleet` ex - Validate all IDs as non-empty strings **Tasks** (`src/core/tasks.ts`): + - `listTasks(client, listId, options?)` → GET with $filter, $orderby, $top query params - `createTask(client, listId, taskInput)` → POST with date/time formatting - `updateTask(client, listId, taskId, updates)` → PATCH; empty string → null for clearing date fields @@ -153,6 +162,7 @@ The work is broken into **8 parallelizable work units** designed for `/fleet` ex - OData query parameter construction ($filter, $orderby, $top, $select) **Checklist Items** (`src/core/checklist-items.ts`): + - `listChecklistItems(client, listId, taskId)` → GET - `createChecklistItem(client, listId, taskId, displayName, isChecked?)` → POST - `updateChecklistItem(client, listId, taskId, itemId, updates)` → PATCH @@ -190,6 +200,7 @@ The work is broken into **8 parallelizable work units** designed for `/fleet` ex - Empty checklistItemId throws validation error ### WU-7: CLI Entry Point + Output Formatting + **Files:** `src/cli.ts`, `src/format.ts`, `tests/cli.test.ts` **Dependencies:** WU-1 (types), WU-3 (token-manager), WU-5 (graph client), WU-6 (core ops) @@ -244,6 +255,7 @@ The work is broken into **8 parallelizable work units** designed for `/fleet` ex - Output formatting: importance shown for non-normal values ### WU-8: MCP Server Module + **Files:** `src/mcp.ts` **Dependencies:** WU-1 (types), WU-3 (token-manager), WU-5 (graph client), WU-6 (core ops) @@ -296,6 +308,7 @@ WU-1 (Scaffolding + Types) ``` **Parallelization strategy for `/fleet`:** + - **Wave 1:** WU-1 (must go first — creates package.json, installs deps, creates types) - **Wave 2:** WU-2, WU-4 (partial — PKCE generation + setup logic without token-store integration), WU-5 (partial — can write the client, depends on WU-3 interface) - **Wave 3:** WU-3, WU-6 @@ -310,6 +323,7 @@ However, for `/fleet` where agents get full context: **all 8 WUs can be dispatch Every agent must follow these — derived from `spec/intent.md` anti-patterns and the security audits in `spec/audits/`: **From jordanburke audit (`spec/audits/2026-04-12-microsoft-todo-mcp-server.md`):** + - [ ] No client secret anywhere — public client with PKCE (jordanburke stores clientSecret in tokens.json) - [ ] No `piiLoggingEnabled: true` — both audited solutions enable it - [ ] Never auto-modify external app configs (jordanburke writes to Claude Desktop config) @@ -318,6 +332,7 @@ Every agent must follow these — derived from `spec/intent.md` anti-patterns an - [ ] Only 2 scopes — both audited solutions request 8 including `.Shared` scopes **From jhirono audit (`spec/audits/2026-04-12-todomcp.md`):** + - [ ] Token refresh must use configurable tenant, not hardcoded `consumers` (jhirono bug) - [ ] No `/silentLogin` or client credentials flow — unnecessary attack surface - [ ] No dead dependencies — jhirono has `express-rate-limit` and `pkce-challenge` imported but never used @@ -325,12 +340,14 @@ Every agent must follow these — derived from `spec/intent.md` anti-patterns an - [ ] No MSAL — use raw fetch + PKCE (avoids both jhirono's v1 EOL issue and jordanburke's 500KB dependency) **Patterns to adopt (from audits):** + - [ ] Personal account detection: warn about `MailboxNotEnabledForRESTAPI` proactively (jhirono pattern) - [ ] OData query parameters: `$filter`, `$select`, `$orderby`, `$top`, `$skip` on task listing (jhirono pattern) - [ ] Tool descriptions: explain To Do hierarchy (list → task → checklist) in each tool description (jhirono pattern) - [ ] Empty-string-to-null: clearing date fields via empty string in update-task (jhirono pattern) **Core security requirements:** + - [ ] AES-256-GCM for token storage, never plaintext JSON - [ ] Zero PII logging — never log user identifiers, task content, or token values - [ ] No telemetry, no phone-home @@ -345,6 +362,7 @@ Every agent must follow these — derived from `spec/intent.md` anti-patterns an ## Post-Implementation After all WUs complete: + 1. Run `npm run build` — verify zero errors 2. Run `npm test` — verify all tests pass 3. Update `README.md` with setup instructions, CLI usage, MCP config snippet @@ -380,6 +398,7 @@ npm run build ### Manual Local Testing (requires Azure AD app registration) #### Prerequisites + 1. Create an Azure AD app registration (see README or spec/intent.md § Authentication Flow) 2. Set environment variables: ```bash @@ -388,15 +407,18 @@ npm run build ``` #### Step 1: Run OAuth Setup + ```bash # Interactive setup — opens browser for Microsoft login npm run setup # Or after building: node dist/cli.js setup ``` + This opens your browser, you log in, and tokens are encrypted and stored locally. #### Step 2: Test CLI Commands + ```bash # List all task lists npx tsx src/cli.ts lists @@ -424,6 +446,7 @@ npx tsx src/cli.ts lists delete ``` #### Step 3: Test MCP Server + ```bash # Start the MCP server on stdio (for piping JSON-RPC messages) npx tsx src/cli.ts serve @@ -433,7 +456,9 @@ echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion": ``` #### Step 4: Test in VS Code (MCP client) + Add to your VS Code `settings.json`: + ```json { "mcp": { @@ -449,9 +474,11 @@ Add to your VS Code `settings.json`: } } ``` + Then use Copilot Chat to invoke tools like "list my todo lists" or "create a task called 'Buy groceries' in my Tasks list". #### Step 5: Test Token Refresh + ```bash # Force token expiry by waiting (tokens expire in ~1 hour) or by # manually editing the encrypted store's expiresAt to a past timestamp. @@ -460,6 +487,7 @@ npx tsx src/cli.ts lists ``` #### Step 6: Test Environment Variable Override + ```bash # Bypass encrypted store entirely (useful for CI/headless) export TODO_MCP_ACCESS_TOKEN="your-valid-access-token" diff --git a/src/auth/setup.ts b/src/auth/setup.ts index 5ab130a..df2b65e 100644 --- a/src/auth/setup.ts +++ b/src/auth/setup.ts @@ -1,19 +1,19 @@ -import { createHash, randomBytes } from "node:crypto"; -import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; -import { exec } from "node:child_process"; -import { URL } from "node:url"; -import type { TokenData } from "../types.js"; -import { save } from "./token-store.js"; +import { createHash, randomBytes } from 'node:crypto'; +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { exec } from 'node:child_process'; +import { URL } from 'node:url'; +import type { TokenData } from '../types.js'; +import { save } from './token-store.js'; -const REDIRECT_URI = "http://localhost:3847/callback"; -const SCOPES = "Tasks.ReadWrite offline_access"; +const REDIRECT_URI = 'http://localhost:3847/callback'; +const SCOPES = 'Tasks.ReadWrite offline_access'; /** Generate a cryptographically random PKCE code verifier (43-128 chars, URL-safe). */ export function generateCodeVerifier(): string { - const unreserved = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; + const unreserved = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; const length = 128; const bytes = randomBytes(length); - let verifier = ""; + let verifier = ''; for (let i = 0; i < length; i++) { verifier += unreserved[bytes[i] % unreserved.length]; } @@ -22,8 +22,8 @@ export function generateCodeVerifier(): string { /** Compute the PKCE code challenge: base64url(SHA-256(verifier)), no padding. */ export function generateCodeChallenge(verifier: string): string { - const hash = createHash("sha256").update(verifier).digest("base64"); - return hash.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + const hash = createHash('sha256').update(verifier).digest('base64'); + return hash.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } /** Build the Microsoft authorization URL with all required params. */ @@ -36,12 +36,12 @@ export function buildAuthorizationUrl( const base = `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize`; const params = new URLSearchParams({ client_id: clientId, - response_type: "code", + response_type: 'code', redirect_uri: REDIRECT_URI, scope: SCOPES, code_challenge: codeChallenge, - code_challenge_method: "S256", - response_mode: "query", + code_challenge_method: 'S256', + response_mode: 'query', state, }); return { url: `${base}?${params.toString()}`, state }; @@ -50,12 +50,14 @@ export function buildAuthorizationUrl( function openBrowser(url: string): void { const platform = process.platform; const cmd = - platform === "win32" ? `start "" "${url}"` : - platform === "darwin" ? `open "${url}"` : - `xdg-open "${url}"`; + platform === 'win32' + ? `start "" "${url}"` + : platform === 'darwin' + ? `open "${url}"` + : `xdg-open "${url}"`; exec(cmd, (err) => { if (err) { - console.error("Could not open browser automatically. Please visit:"); + console.error('Could not open browser automatically. Please visit:'); console.log(url); } }); @@ -70,22 +72,22 @@ async function exchangeCodeForTokens( const tokenUrl = `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`; const body = new URLSearchParams({ client_id: clientId, - grant_type: "authorization_code", + grant_type: 'authorization_code', code, redirect_uri: REDIRECT_URI, code_verifier: codeVerifier, }); const res = await fetch(tokenUrl, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(), }); if (!res.ok) { let errorDetail = `status ${res.status}`; try { - const errorBody = await res.json() as { error?: string; error_description?: string }; + const errorBody = (await res.json()) as { error?: string; error_description?: string }; if (errorBody.error) { errorDetail = `${errorBody.error}: ${errorBody.error_description ?? 'Unknown error'}`; } @@ -113,15 +115,19 @@ const SUCCESS_HTML = ` /** Run the full interactive OAuth PKCE setup flow. * When `silent` is true, all user-facing messages go to stderr (safe for MCP stdio). */ -export async function runSetup(options?: { clientId?: string; tenant?: string; silent?: boolean }): Promise { - const clientId = options?.clientId ?? process.env["TODO_MCP_CLIENT_ID"]; +export async function runSetup(options?: { + clientId?: string; + tenant?: string; + silent?: boolean; +}): Promise { + const clientId = options?.clientId ?? process.env['TODO_MCP_CLIENT_ID']; if (!clientId) { throw new Error( - "TODO_MCP_CLIENT_ID environment variable is required. " + - "Set it to your Azure AD app registration client ID.", + 'TODO_MCP_CLIENT_ID environment variable is required. ' + + 'Set it to your Azure AD app registration client ID.', ); } - const tenant = options?.tenant ?? process.env["TODO_MCP_TENANT"] ?? "consumers"; + const tenant = options?.tenant ?? process.env['TODO_MCP_TENANT'] ?? 'consumers'; const log = options?.silent ? (...args: unknown[]) => console.error(...args) : console.log; const codeVerifier = generateCodeVerifier(); @@ -129,95 +135,88 @@ export async function runSetup(options?: { clientId?: string; tenant?: string; s const { url: authUrl, state } = buildAuthorizationUrl(clientId, tenant, codeChallenge); await new Promise((resolve, reject) => { - const server = createServer( - async (req: IncomingMessage, res: ServerResponse) => { - try { - const url = new URL(req.url ?? "/", `http://localhost:3847`); - if (url.pathname !== "/callback") { - res.writeHead(404); - res.end("Not found"); - return; - } - - const returnedState = url.searchParams.get("state"); - if (returnedState !== state) { - res.writeHead(400, { "Content-Type": "text/html" }); - res.end("

Error: Invalid state parameter

Possible CSRF attack.

"); - reject(new Error("Authorization failed: state parameter mismatch")); - server.close(); - return; - } - - const code = url.searchParams.get("code"); - if (!code) { - const error = url.searchParams.get("error") ?? "unknown"; - const desc = - url.searchParams.get("error_description") ?? "No authorization code received"; - res.writeHead(400, { "Content-Type": "text/html" }); - res.end(`

Error: ${error}

${desc}

`); - reject(new Error(`Authorization failed: ${error} — ${desc}`)); - server.close(); - return; - } - - const tokens = await exchangeCodeForTokens( - clientId, - tenant, - code, - codeVerifier, - ); - - const tokenData: TokenData = { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresAt: Date.now() + tokens.expires_in * 1000, - clientId, - tenant, - }; - save(tokenData); + const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { + try { + const url = new URL(req.url ?? '/', `http://localhost:3847`); + if (url.pathname !== '/callback') { + res.writeHead(404); + res.end('Not found'); + return; + } - res.writeHead(200, { "Content-Type": "text/html" }); - res.end(SUCCESS_HTML); + const returnedState = url.searchParams.get('state'); + if (returnedState !== state) { + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end('

Error: Invalid state parameter

Possible CSRF attack.

'); + reject(new Error('Authorization failed: state parameter mismatch')); + server.close(); + return; + } + const code = url.searchParams.get('code'); + if (!code) { + const error = url.searchParams.get('error') ?? 'unknown'; + const desc = + url.searchParams.get('error_description') ?? 'No authorization code received'; + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end(`

Error: ${error}

${desc}

`); + reject(new Error(`Authorization failed: ${error} — ${desc}`)); server.close(); + return; + } - log(`\n✓ Authentication successful!\n`); - if (!options?.silent) { - log(`To use with VS Code, add to settings.json:`); - log( - JSON.stringify( - { - mcp: { - servers: { - todo: { - command: "todo", - args: ["serve"], - }, + const tokens = await exchangeCodeForTokens(clientId, tenant, code, codeVerifier); + + const tokenData: TokenData = { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresAt: Date.now() + tokens.expires_in * 1000, + clientId, + tenant, + }; + save(tokenData); + + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(SUCCESS_HTML); + + server.close(); + + log(`\n✓ Authentication successful!\n`); + if (!options?.silent) { + log(`To use with VS Code, add to settings.json:`); + log( + JSON.stringify( + { + mcp: { + servers: { + todo: { + command: 'todo', + args: ['serve'], }, }, }, - null, - 2, - ), - ); - } - - resolve(); - } catch (err) { - if (!res.headersSent) { - res.writeHead(500); - res.end("Internal error"); - } - reject(err); + }, + null, + 2, + ), + ); } - }, - ); + + resolve(); + } catch (err) { + if (!res.headersSent) { + res.writeHead(500); + res.end('Internal error'); + } + reject(err); + } + }); server.listen(3847, () => { - log("Waiting for authentication... (a browser window should open)"); + log('Waiting for authentication... (a browser window should open)'); openBrowser(authUrl); }); - server.on("error", reject); + server.on('error', reject); }); } diff --git a/src/auth/token-manager.ts b/src/auth/token-manager.ts index eb0a108..747e683 100644 --- a/src/auth/token-manager.ts +++ b/src/auth/token-manager.ts @@ -34,9 +34,7 @@ async function refreshAccessToken( } if (!response.ok) { - throw new Error( - "Authentication expired. Please re-run 'todo setup' to re-authenticate.", - ); + throw new Error("Authentication expired. Please re-run 'todo setup' to re-authenticate."); } const json = (await response.json()) as { @@ -93,9 +91,7 @@ export async function getAccessToken(): Promise { stored = load(); } if (!stored) { - throw new Error( - "No authentication found. Run 'todo setup' to authenticate.", - ); + throw new Error("No authentication found. Run 'todo setup' to authenticate."); } } @@ -112,19 +108,13 @@ export async function getAccessToken(): Promise { export async function forceRefresh(): Promise { const stored = load(); if (!stored) { - throw new Error( - "No authentication found. Run 'todo setup' to authenticate.", - ); + throw new Error("No authentication found. Run 'todo setup' to authenticate."); } return doRefresh(stored.refreshToken, stored.clientId, stored.tenant); } -function doRefresh( - refreshToken: string, - clientId: string, - tenant: string, -): Promise { +function doRefresh(refreshToken: string, clientId: string, tenant: string): Promise { if (!refreshPromise) { refreshPromise = refreshAccessToken(refreshToken, clientId, tenant) .then((tokens) => tokens.accessToken) diff --git a/src/auth/token-store.ts b/src/auth/token-store.ts index a9c972f..d92d2a7 100644 --- a/src/auth/token-store.ts +++ b/src/auth/token-store.ts @@ -80,7 +80,9 @@ export function decrypt(buffer: Buffer): TokenData { const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]); return JSON.parse(decrypted.toString('utf8')) as TokenData; } catch { - throw new Error('Token decryption failed: data is corrupted or was encrypted on a different machine'); + throw new Error( + 'Token decryption failed: data is corrupted or was encrypted on a different machine', + ); } } diff --git a/src/cli.ts b/src/cli.ts index d58d722..f8a0529 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,13 +4,27 @@ import { parseArgs } from 'node:util'; import { getAccessToken, forceRefresh } from './auth/token-manager.js'; import { runSetup } from './auth/setup.js'; import { GraphClient } from './graph/client.js'; -import { listTaskLists, createTaskList, updateTaskList, deleteTaskList } from './core/task-lists.js'; +import { + listTaskLists, + createTaskList, + updateTaskList, + deleteTaskList, +} from './core/task-lists.js'; import { listTasks, createTask, updateTask, deleteTask, completeTask } from './core/tasks.js'; -import { listChecklistItems, createChecklistItem, updateChecklistItem, deleteChecklistItem } from './core/checklist-items.js'; +import { + listChecklistItems, + createChecklistItem, + updateChecklistItem, + deleteChecklistItem, +} from './core/checklist-items.js'; import { startMcpServer } from './mcp.js'; import { - formatTaskLists, formatTasks, formatChecklistItems, - formatTask, formatTaskList, formatChecklistItem, + formatTaskLists, + formatTasks, + formatChecklistItems, + formatTask, + formatTaskList, + formatChecklistItem, } from './format.js'; import type { CreateTaskInput, UpdateTaskInput, TaskImportance, TaskStatus } from './types.js'; import { VALID_STATUSES, VALID_IMPORTANCES } from './types.js'; @@ -196,10 +210,14 @@ async function handleTasks(args: string[]): Promise { if (!action || action.startsWith('--')) { const statusFilter = values.status as string | undefined; if (statusFilter && !VALID_STATUSES.includes(statusFilter as TaskStatus)) { - console.error(`Error: Invalid status "${statusFilter}". Valid values: ${VALID_STATUSES.join(', ')}`); + console.error( + `Error: Invalid status "${statusFilter}". Valid values: ${VALID_STATUSES.join(', ')}`, + ); process.exit(1); } - const tasksList = await listTasks(client, listId, { status: statusFilter as TaskStatus | undefined }); + const tasksList = await listTasks(client, listId, { + status: statusFilter as TaskStatus | undefined, + }); console.log(formatTasks(tasksList, json)); return; } @@ -214,7 +232,9 @@ async function handleTasks(args: string[]): Promise { if (values.due) input.dueDateTime = values.due as string; if (values.importance) { if (!VALID_IMPORTANCES.includes(values.importance as TaskImportance)) { - console.error(`Error: Invalid importance "${values.importance}". Valid values: ${VALID_IMPORTANCES.join(', ')}`); + console.error( + `Error: Invalid importance "${values.importance}". Valid values: ${VALID_IMPORTANCES.join(', ')}`, + ); process.exit(1); } input.importance = values.importance as TaskImportance; @@ -224,7 +244,9 @@ async function handleTasks(args: string[]): Promise { if (values.start) input.startDateTime = values.start as string; if (values.status) { if (!VALID_STATUSES.includes(values.status as TaskStatus)) { - console.error(`Error: Invalid status "${values.status}". Valid values: ${VALID_STATUSES.join(', ')}`); + console.error( + `Error: Invalid status "${values.status}". Valid values: ${VALID_STATUSES.join(', ')}`, + ); process.exit(1); } input.status = values.status as TaskStatus; @@ -247,7 +269,9 @@ async function handleTasks(args: string[]): Promise { if (values.due) input.dueDateTime = values.due as string; if (values.importance) { if (!VALID_IMPORTANCES.includes(values.importance as TaskImportance)) { - console.error(`Error: Invalid importance "${values.importance}". Valid values: ${VALID_IMPORTANCES.join(', ')}`); + console.error( + `Error: Invalid importance "${values.importance}". Valid values: ${VALID_IMPORTANCES.join(', ')}`, + ); process.exit(1); } input.importance = values.importance as TaskImportance; @@ -257,7 +281,9 @@ async function handleTasks(args: string[]): Promise { if (values.start) input.startDateTime = values.start as string; if (values.status) { if (!VALID_STATUSES.includes(values.status as TaskStatus)) { - console.error(`Error: Invalid status "${values.status}". Valid values: ${VALID_STATUSES.join(', ')}`); + console.error( + `Error: Invalid status "${values.status}". Valid values: ${VALID_STATUSES.join(', ')}`, + ); process.exit(1); } input.status = values.status as TaskStatus; diff --git a/src/core/checklist-items.ts b/src/core/checklist-items.ts index 7deec2d..045c44b 100644 --- a/src/core/checklist-items.ts +++ b/src/core/checklist-items.ts @@ -5,10 +5,17 @@ function basePath(listId: string, taskId: string): string { return `/me/todo/lists/${listId}/tasks/${taskId}/checklistItems`; } -export async function listChecklistItems(client: GraphClient, listId: string, taskId: string): Promise { +export async function listChecklistItems( + client: GraphClient, + listId: string, + taskId: string, +): Promise { validateId(listId, 'listId'); validateId(taskId, 'taskId'); - const response = await client.request>('GET', basePath(listId, taskId)); + const response = await client.request>( + 'GET', + basePath(listId, taskId), + ); return response?.value ?? []; } @@ -43,11 +50,20 @@ export async function updateChecklistItem( validateId(taskId, 'taskId'); validateId(itemId, 'checklistItemId'); - const response = await client.request('PATCH',`${basePath(listId, taskId)}/${itemId}`, updates); + const response = await client.request( + 'PATCH', + `${basePath(listId, taskId)}/${itemId}`, + updates, + ); return response!; } -export async function deleteChecklistItem(client: GraphClient, listId: string, taskId: string, itemId: string): Promise { +export async function deleteChecklistItem( + client: GraphClient, + listId: string, + taskId: string, + itemId: string, +): Promise { validateId(listId, 'listId'); validateId(taskId, 'taskId'); validateId(itemId, 'checklistItemId'); diff --git a/src/core/task-lists.ts b/src/core/task-lists.ts index 286a781..2f7103b 100644 --- a/src/core/task-lists.ts +++ b/src/core/task-lists.ts @@ -6,16 +6,25 @@ export async function listTaskLists(client: GraphClient): Promise { +export async function createTaskList( + client: GraphClient, + displayName: string, +): Promise { if (!displayName) throw new Error('displayName is required'); const response = await client.request('POST', '/me/todo/lists', { displayName }); return response!; } -export async function updateTaskList(client: GraphClient, listId: string, displayName: string): Promise { +export async function updateTaskList( + client: GraphClient, + listId: string, + displayName: string, +): Promise { validateId(listId, 'listId'); if (!displayName) throw new Error('displayName is required'); - const response = await client.request('PATCH', `/me/todo/lists/${listId}`, { displayName }); + const response = await client.request('PATCH', `/me/todo/lists/${listId}`, { + displayName, + }); return response!; } diff --git a/src/core/tasks.ts b/src/core/tasks.ts index 3912ac4..2f2e80d 100644 --- a/src/core/tasks.ts +++ b/src/core/tasks.ts @@ -1,5 +1,14 @@ import { GraphClient } from '../graph/client.js'; -import { TodoTask, CreateTaskInput, UpdateTaskInput, ListTasksOptions, GraphResponse, GraphDateTime, validateId, VALID_STATUSES } from '../types.js'; +import { + TodoTask, + CreateTaskInput, + UpdateTaskInput, + ListTasksOptions, + GraphResponse, + GraphDateTime, + validateId, + VALID_STATUSES, +} from '../types.js'; export function toGraphDateTime(isoString: string): GraphDateTime { const date = new Date(isoString); @@ -8,7 +17,11 @@ export function toGraphDateTime(isoString: string): GraphDateTime { return { dateTime, timeZone: 'UTC' }; } -export async function listTasks(client: GraphClient, listId: string, options?: ListTasksOptions): Promise { +export async function listTasks( + client: GraphClient, + listId: string, + options?: ListTasksOptions, +): Promise { validateId(listId, 'listId'); const queryParams: Record = {}; @@ -36,15 +49,26 @@ export async function listTasks(client: GraphClient, listId: string, options?: L return response?.value ?? []; } -export async function getTask(client: GraphClient, listId: string, taskId: string): Promise { +export async function getTask( + client: GraphClient, + listId: string, + taskId: string, +): Promise { validateId(listId, 'listId'); validateId(taskId, 'taskId'); - const response = await client.request('GET', `/me/todo/lists/${listId}/tasks/${taskId}`); + const response = await client.request( + 'GET', + `/me/todo/lists/${listId}/tasks/${taskId}`, + ); if (!response) throw new Error('Unexpected empty response'); return response; } -export async function createTask(client: GraphClient, listId: string, input: CreateTaskInput): Promise { +export async function createTask( + client: GraphClient, + listId: string, + input: CreateTaskInput, +): Promise { validateId(listId, 'listId'); if (!input.title) throw new Error('title is required'); @@ -77,7 +101,12 @@ export async function createTask(client: GraphClient, listId: string, input: Cre return response!; } -export async function updateTask(client: GraphClient, listId: string, taskId: string, input: UpdateTaskInput): Promise { +export async function updateTask( + client: GraphClient, + listId: string, + taskId: string, + input: UpdateTaskInput, +): Promise { validateId(listId, 'listId'); validateId(taskId, 'taskId'); @@ -105,19 +134,35 @@ export async function updateTask(client: GraphClient, listId: string, taskId: st body.startDateTime = input.startDateTime === '' ? null : toGraphDateTime(input.startDateTime); } - const response = await client.request('PATCH', `/me/todo/lists/${listId}/tasks/${taskId}`, body); + const response = await client.request( + 'PATCH', + `/me/todo/lists/${listId}/tasks/${taskId}`, + body, + ); return response!; } -export async function deleteTask(client: GraphClient, listId: string, taskId: string): Promise { +export async function deleteTask( + client: GraphClient, + listId: string, + taskId: string, +): Promise { validateId(listId, 'listId'); validateId(taskId, 'taskId'); await client.request('DELETE', `/me/todo/lists/${listId}/tasks/${taskId}`); } -export async function completeTask(client: GraphClient, listId: string, taskId: string): Promise { +export async function completeTask( + client: GraphClient, + listId: string, + taskId: string, +): Promise { validateId(listId, 'listId'); validateId(taskId, 'taskId'); - const response = await client.request('PATCH', `/me/todo/lists/${listId}/tasks/${taskId}`, { status: 'completed' }); + const response = await client.request( + 'PATCH', + `/me/todo/lists/${listId}/tasks/${taskId}`, + { status: 'completed' }, + ); return response!; } diff --git a/src/graph/client.ts b/src/graph/client.ts index cdd5921..f440792 100644 --- a/src/graph/client.ts +++ b/src/graph/client.ts @@ -17,22 +17,28 @@ export class GraphApiError extends Error { super(`Graph API error ${statusCode}: ${errorCode} - ${message}`); this.name = 'GraphApiError'; } - get isRetryable(): boolean { return this.statusCode === 429 || this.statusCode >= 500; } - get isNotFound(): boolean { return this.statusCode === 404; } - get isAuthError(): boolean { return this.statusCode === 401 || this.statusCode === 403; } + get isRetryable(): boolean { + return this.statusCode === 429 || this.statusCode >= 500; + } + get isNotFound(): boolean { + return this.statusCode === 404; + } + get isAuthError(): boolean { + return this.statusCode === 401 || this.statusCode === 403; + } } export class GraphClient { constructor( private getToken: () => Promise, - private forceRefresh: () => Promise + private forceRefresh: () => Promise, ) {} async request( method: string, path: string, body?: unknown, - queryParams?: Record + queryParams?: Record, ): Promise { const token = await this.getToken(); return this.executeRequest(method, path, body, queryParams, token, false, 0); @@ -45,7 +51,7 @@ export class GraphClient { queryParams: Record | undefined, token: string, isRetry: boolean, - retryCount: number + retryCount: number, ): Promise { const url = this.buildUrl(path, queryParams); @@ -75,8 +81,16 @@ export class GraphClient { const delayMs = retryAfter ? Number(retryAfter) * 1000 : Math.min(1000 * 2 ** retryCount, 30000); - await new Promise(r => setTimeout(r, delayMs)); - return this.executeRequest(method, path, body, queryParams, token, isRetry, retryCount + 1); + await new Promise((r) => setTimeout(r, delayMs)); + return this.executeRequest( + method, + path, + body, + queryParams, + token, + isRetry, + retryCount + 1, + ); } if (status === 204) { diff --git a/src/mcp.ts b/src/mcp.ts index 591f1c3..15999c6 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -8,7 +8,8 @@ import * as tasks from './core/tasks.js'; import * as checklist from './core/checklist-items.js'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; -const HIERARCHY = 'Microsoft To Do organizes items as: Task Lists → Tasks → Checklist Items (sub-steps).'; +const HIERARCHY = + 'Microsoft To Do organizes items as: Task Lists → Tasks → Checklist Items (sub-steps).'; const statusEnum = z.enum(['notStarted', 'inProgress', 'completed', 'waitingOnOthers', 'deferred']); const importanceEnum = z.enum(['low', 'normal', 'high']); @@ -41,139 +42,319 @@ export function createMcpServer(client: GraphClient): McpServer { // Task Lists server.tool('list-task-lists', `List all task lists. ${HIERARCHY}`, {}, async () => { - try { return textResult(await taskLists.listTaskLists(client)); } - catch (e) { return errorResult(e); } + try { + return textResult(await taskLists.listTaskLists(client)); + } catch (e) { + return errorResult(e); + } }); - server.tool('get-task-list', `Get a single task list by ID. ${HIERARCHY}`, + server.tool( + 'get-task-list', + `Get a single task list by ID. ${HIERARCHY}`, { listId: z.string().describe('ID of the task list.') }, async ({ listId }) => { - try { return textResult(await taskLists.getTaskList(client, listId)); } - catch (e) { return errorResult(e); } - }); + try { + return textResult(await taskLists.getTaskList(client, listId)); + } catch (e) { + return errorResult(e); + } + }, + ); - server.tool('create-task-list', `Create a new task list. ${HIERARCHY}`, + server.tool( + 'create-task-list', + `Create a new task list. ${HIERARCHY}`, { displayName: z.string().describe('Name for the task list.') }, async ({ displayName }) => { - try { return textResult(await taskLists.createTaskList(client, displayName)); } - catch (e) { return errorResult(e); } - }); + try { + return textResult(await taskLists.createTaskList(client, displayName)); + } catch (e) { + return errorResult(e); + } + }, + ); - server.tool('update-task-list', `Update a task list's name. ${HIERARCHY}`, - { listId: z.string().describe('ID of the task list. Use list-task-lists to find available list IDs.'), displayName: z.string().describe('Name for the task list.') }, + server.tool( + 'update-task-list', + `Update a task list's name. ${HIERARCHY}`, + { + listId: z + .string() + .describe('ID of the task list. Use list-task-lists to find available list IDs.'), + displayName: z.string().describe('Name for the task list.'), + }, async ({ listId, displayName }) => { - try { return textResult(await taskLists.updateTaskList(client, listId, displayName)); } - catch (e) { return errorResult(e); } - }); + try { + return textResult(await taskLists.updateTaskList(client, listId, displayName)); + } catch (e) { + return errorResult(e); + } + }, + ); - server.tool('delete-task-list', `Delete a task list. ${HIERARCHY}`, - { listId: z.string().describe('ID of the task list. Use list-task-lists to find available list IDs.') }, + server.tool( + 'delete-task-list', + `Delete a task list. ${HIERARCHY}`, + { + listId: z + .string() + .describe('ID of the task list. Use list-task-lists to find available list IDs.'), + }, async ({ listId }) => { - try { await taskLists.deleteTaskList(client, listId); return msgResult('Task list deleted successfully.'); } - catch (e) { return errorResult(e); } - }); + try { + await taskLists.deleteTaskList(client, listId); + return msgResult('Task list deleted successfully.'); + } catch (e) { + return errorResult(e); + } + }, + ); // Tasks - server.tool('list-tasks', `List tasks in a task list. ${HIERARCHY}`, + server.tool( + 'list-tasks', + `List tasks in a task list. ${HIERARCHY}`, { - listId: z.string().describe('ID of the task list. Use list-task-lists to find available list IDs.'), + listId: z + .string() + .describe('ID of the task list. Use list-task-lists to find available list IDs.'), status: statusEnum.optional().describe('Filter tasks by status.'), top: z.number().optional().describe('Maximum number of tasks to return (default: 100).'), - filter: z.string().optional().describe('OData $filter expression for advanced filtering, e.g. "contains(title, \'milk\')".'), - orderby: z.string().optional().describe('OData $orderby expression, e.g. "dueDateTime/dateTime asc".'), + filter: z + .string() + .optional() + .describe( + 'OData $filter expression for advanced filtering, e.g. "contains(title, \'milk\')".', + ), + orderby: z + .string() + .optional() + .describe('OData $orderby expression, e.g. "dueDateTime/dateTime asc".'), }, async ({ listId, status, top, filter, orderby }) => { - try { return textResult(await tasks.listTasks(client, listId, { status, top, filter, orderby })); } - catch (e) { return errorResult(e); } - }); + try { + return textResult(await tasks.listTasks(client, listId, { status, top, filter, orderby })); + } catch (e) { + return errorResult(e); + } + }, + ); - server.tool('get-task', `Get a single task by ID. ${HIERARCHY}`, + server.tool( + 'get-task', + `Get a single task by ID. ${HIERARCHY}`, { listId: z.string().describe('ID of the task list containing the task.'), taskId: z.string().describe('ID of the task to retrieve.'), }, async ({ listId, taskId }) => { - try { return textResult(await tasks.getTask(client, listId, taskId)); } - catch (e) { return errorResult(e); } - }); + try { + return textResult(await tasks.getTask(client, listId, taskId)); + } catch (e) { + return errorResult(e); + } + }, + ); - server.tool('create-task', `Create a new task in a task list. ${HIERARCHY}`, + server.tool( + 'create-task', + `Create a new task in a task list. ${HIERARCHY}`, { - listId: z.string().describe('ID of the task list. Use list-task-lists to find available list IDs.'), + listId: z + .string() + .describe('ID of the task list. Use list-task-lists to find available list IDs.'), title: z.string().describe('Title of the task.'), body: z.string().optional().describe('Body/notes content of the task (plain text).'), - dueDateTime: z.string().optional().describe('Due date in ISO 8601 format, e.g. "2025-12-31" or "2025-12-31T09:00:00Z".'), - reminderDateTime: z.string().optional().describe('Reminder date/time in ISO 8601 format. Pass empty string "" to remove.'), + dueDateTime: z + .string() + .optional() + .describe('Due date in ISO 8601 format, e.g. "2025-12-31" or "2025-12-31T09:00:00Z".'), + reminderDateTime: z + .string() + .optional() + .describe('Reminder date/time in ISO 8601 format. Pass empty string "" to remove.'), importance: importanceEnum.optional().describe('Priority level of the task.'), - startDateTime: z.string().optional().describe('Start date in ISO 8601 format. Pass empty string "" to remove.'), + startDateTime: z + .string() + .optional() + .describe('Start date in ISO 8601 format. Pass empty string "" to remove.'), status: statusEnum.optional().describe('Status of the task.'), - categories: z.array(z.string()).optional().describe('Array of category names (color tags) for the task.'), + categories: z + .array(z.string()) + .optional() + .describe('Array of category names (color tags) for the task.'), }, async ({ listId, ...input }) => { - try { return textResult(await tasks.createTask(client, listId, input)); } - catch (e) { return errorResult(e); } - }); + try { + return textResult(await tasks.createTask(client, listId, input)); + } catch (e) { + return errorResult(e); + } + }, + ); - server.tool('update-task', `Update an existing task. ${HIERARCHY}`, + server.tool( + 'update-task', + `Update an existing task. ${HIERARCHY}`, { - listId: z.string().describe('ID of the task list. Use list-task-lists to find available list IDs.'), + listId: z + .string() + .describe('ID of the task list. Use list-task-lists to find available list IDs.'), taskId: z.string().describe('ID of the task within the list.'), title: z.string().optional().describe('Title of the task.'), body: z.string().optional().describe('Body/notes content of the task (plain text).'), - dueDateTime: z.string().optional().describe('Due date in ISO 8601 format, e.g. "2025-12-31" or "2025-12-31T09:00:00Z".'), - reminderDateTime: z.string().optional().describe('Reminder date/time in ISO 8601 format. Pass empty string "" to remove.'), + dueDateTime: z + .string() + .optional() + .describe('Due date in ISO 8601 format, e.g. "2025-12-31" or "2025-12-31T09:00:00Z".'), + reminderDateTime: z + .string() + .optional() + .describe('Reminder date/time in ISO 8601 format. Pass empty string "" to remove.'), importance: importanceEnum.optional().describe('Priority level of the task.'), - startDateTime: z.string().optional().describe('Start date in ISO 8601 format. Pass empty string "" to remove.'), + startDateTime: z + .string() + .optional() + .describe('Start date in ISO 8601 format. Pass empty string "" to remove.'), status: statusEnum.optional().describe('Status of the task.'), - categories: z.array(z.string()).optional().describe('Array of category names (color tags) for the task.'), + categories: z + .array(z.string()) + .optional() + .describe('Array of category names (color tags) for the task.'), }, async ({ listId, taskId, ...input }) => { - try { return textResult(await tasks.updateTask(client, listId, taskId, input)); } - catch (e) { return errorResult(e); } - }); + try { + return textResult(await tasks.updateTask(client, listId, taskId, input)); + } catch (e) { + return errorResult(e); + } + }, + ); - server.tool('delete-task', `Delete a task from a task list. ${HIERARCHY}`, - { listId: z.string().describe('ID of the task list. Use list-task-lists to find available list IDs.'), taskId: z.string().describe('ID of the task within the list.') }, + server.tool( + 'delete-task', + `Delete a task from a task list. ${HIERARCHY}`, + { + listId: z + .string() + .describe('ID of the task list. Use list-task-lists to find available list IDs.'), + taskId: z.string().describe('ID of the task within the list.'), + }, async ({ listId, taskId }) => { - try { await tasks.deleteTask(client, listId, taskId); return msgResult('Task deleted successfully.'); } - catch (e) { return errorResult(e); } - }); + try { + await tasks.deleteTask(client, listId, taskId); + return msgResult('Task deleted successfully.'); + } catch (e) { + return errorResult(e); + } + }, + ); - server.tool('complete-task', `Mark a task as completed. ${HIERARCHY}`, - { listId: z.string().describe('ID of the task list. Use list-task-lists to find available list IDs.'), taskId: z.string().describe('ID of the task within the list.') }, + server.tool( + 'complete-task', + `Mark a task as completed. ${HIERARCHY}`, + { + listId: z + .string() + .describe('ID of the task list. Use list-task-lists to find available list IDs.'), + taskId: z.string().describe('ID of the task within the list.'), + }, async ({ listId, taskId }) => { - try { return textResult(await tasks.completeTask(client, listId, taskId)); } - catch (e) { return errorResult(e); } - }); + try { + return textResult(await tasks.completeTask(client, listId, taskId)); + } catch (e) { + return errorResult(e); + } + }, + ); // Checklist Items - server.tool('list-checklist-items', `List checklist items (sub-steps) of a task. ${HIERARCHY}`, - { listId: z.string().describe('ID of the task list. Use list-task-lists to find available list IDs.'), taskId: z.string().describe('ID of the task within the list.') }, + server.tool( + 'list-checklist-items', + `List checklist items (sub-steps) of a task. ${HIERARCHY}`, + { + listId: z + .string() + .describe('ID of the task list. Use list-task-lists to find available list IDs.'), + taskId: z.string().describe('ID of the task within the list.'), + }, async ({ listId, taskId }) => { - try { return textResult(await checklist.listChecklistItems(client, listId, taskId)); } - catch (e) { return errorResult(e); } - }); + try { + return textResult(await checklist.listChecklistItems(client, listId, taskId)); + } catch (e) { + return errorResult(e); + } + }, + ); - server.tool('create-checklist-item', `Create a checklist item (sub-step) on a task. ${HIERARCHY}`, - { listId: z.string().describe('ID of the task list. Use list-task-lists to find available list IDs.'), taskId: z.string().describe('ID of the task within the list.'), displayName: z.string().describe('Display text for the checklist item.'), isChecked: z.boolean().optional().describe('Whether the checklist item is checked off.') }, + server.tool( + 'create-checklist-item', + `Create a checklist item (sub-step) on a task. ${HIERARCHY}`, + { + listId: z + .string() + .describe('ID of the task list. Use list-task-lists to find available list IDs.'), + taskId: z.string().describe('ID of the task within the list.'), + displayName: z.string().describe('Display text for the checklist item.'), + isChecked: z.boolean().optional().describe('Whether the checklist item is checked off.'), + }, async ({ listId, taskId, displayName, isChecked }) => { - try { return textResult(await checklist.createChecklistItem(client, listId, taskId, displayName, isChecked)); } - catch (e) { return errorResult(e); } - }); + try { + return textResult( + await checklist.createChecklistItem(client, listId, taskId, displayName, isChecked), + ); + } catch (e) { + return errorResult(e); + } + }, + ); - server.tool('update-checklist-item', `Update a checklist item (sub-step). ${HIERARCHY}`, - { listId: z.string().describe('ID of the task list. Use list-task-lists to find available list IDs.'), taskId: z.string().describe('ID of the task within the list.'), checklistItemId: z.string().describe('ID of the checklist item.'), displayName: z.string().optional().describe('Display text for the checklist item.'), isChecked: z.boolean().optional().describe('Whether the checklist item is checked off.') }, + server.tool( + 'update-checklist-item', + `Update a checklist item (sub-step). ${HIERARCHY}`, + { + listId: z + .string() + .describe('ID of the task list. Use list-task-lists to find available list IDs.'), + taskId: z.string().describe('ID of the task within the list.'), + checklistItemId: z.string().describe('ID of the checklist item.'), + displayName: z.string().optional().describe('Display text for the checklist item.'), + isChecked: z.boolean().optional().describe('Whether the checklist item is checked off.'), + }, async ({ listId, taskId, checklistItemId, displayName, isChecked }) => { - try { return textResult(await checklist.updateChecklistItem(client, listId, taskId, checklistItemId, { displayName, isChecked })); } - catch (e) { return errorResult(e); } - }); + try { + return textResult( + await checklist.updateChecklistItem(client, listId, taskId, checklistItemId, { + displayName, + isChecked, + }), + ); + } catch (e) { + return errorResult(e); + } + }, + ); - server.tool('delete-checklist-item', `Delete a checklist item (sub-step). ${HIERARCHY}`, - { listId: z.string().describe('ID of the task list. Use list-task-lists to find available list IDs.'), taskId: z.string().describe('ID of the task within the list.'), checklistItemId: z.string().describe('ID of the checklist item.') }, + server.tool( + 'delete-checklist-item', + `Delete a checklist item (sub-step). ${HIERARCHY}`, + { + listId: z + .string() + .describe('ID of the task list. Use list-task-lists to find available list IDs.'), + taskId: z.string().describe('ID of the task within the list.'), + checklistItemId: z.string().describe('ID of the checklist item.'), + }, async ({ listId, taskId, checklistItemId }) => { - try { await checklist.deleteChecklistItem(client, listId, taskId, checklistItemId); return msgResult('Checklist item deleted successfully.'); } - catch (e) { return errorResult(e); } - }); + try { + await checklist.deleteChecklistItem(client, listId, taskId, checklistItemId); + return msgResult('Checklist item deleted successfully.'); + } catch (e) { + return errorResult(e); + } + }, + ); return server; } diff --git a/src/types.ts b/src/types.ts index 30e4b7c..3cc14fa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,12 +17,7 @@ export interface TodoTaskList { wellknownListName: 'none' | 'defaultList' | 'flaggedEmails'; } -export type TaskStatus = - | 'notStarted' - | 'inProgress' - | 'completed' - | 'waitingOnOthers' - | 'deferred'; +export type TaskStatus = 'notStarted' | 'inProgress' | 'completed' | 'waitingOnOthers' | 'deferred'; export type TaskImportance = 'low' | 'normal' | 'high'; @@ -99,5 +94,11 @@ export function validateId(value: string, name: string): void { } } -export const VALID_STATUSES: TaskStatus[] = ['notStarted', 'inProgress', 'completed', 'waitingOnOthers', 'deferred']; +export const VALID_STATUSES: TaskStatus[] = [ + 'notStarted', + 'inProgress', + 'completed', + 'waitingOnOthers', + 'deferred', +]; export const VALID_IMPORTANCES: TaskImportance[] = ['low', 'normal', 'high']; diff --git a/tests/cli.test.ts b/tests/cli.test.ts index e19aa5f..42c628f 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -60,38 +60,87 @@ vi.mock('../src/graph/client.js', () => ({ vi.mock('../src/core/task-lists.js', () => ({ listTaskLists: vi.fn().mockResolvedValue([]), - createTaskList: vi.fn().mockResolvedValue({ id: 'new-list', displayName: 'Groceries', isOwner: true, isShared: false, wellknownListName: 'none' }), - updateTaskList: vi.fn().mockResolvedValue({ id: 'list-123', displayName: 'New Name', isOwner: true, isShared: false, wellknownListName: 'none' }), + createTaskList: vi.fn().mockResolvedValue({ + id: 'new-list', + displayName: 'Groceries', + isOwner: true, + isShared: false, + wellknownListName: 'none', + }), + updateTaskList: vi.fn().mockResolvedValue({ + id: 'list-123', + displayName: 'New Name', + isOwner: true, + isShared: false, + wellknownListName: 'none', + }), deleteTaskList: vi.fn().mockResolvedValue(undefined), })); vi.mock('../src/core/tasks.js', () => ({ listTasks: vi.fn().mockResolvedValue([]), createTask: vi.fn().mockResolvedValue({ - id: 'task-new', title: 'Test', status: 'notStarted', importance: 'normal', - isReminderOn: false, body: { content: '', contentType: 'text' }, - dueDateTime: null, reminderDateTime: null, startDateTime: null, completedDateTime: null, - categories: [], createdDateTime: '2025-01-01T00:00:00Z', lastModifiedDateTime: '2025-01-01T00:00:00Z', + id: 'task-new', + title: 'Test', + status: 'notStarted', + importance: 'normal', + isReminderOn: false, + body: { content: '', contentType: 'text' }, + dueDateTime: null, + reminderDateTime: null, + startDateTime: null, + completedDateTime: null, + categories: [], + createdDateTime: '2025-01-01T00:00:00Z', + lastModifiedDateTime: '2025-01-01T00:00:00Z', }), updateTask: vi.fn().mockResolvedValue({ - id: 'task-456', title: 'New', status: 'notStarted', importance: 'normal', - isReminderOn: false, body: { content: '', contentType: 'text' }, - dueDateTime: null, reminderDateTime: null, startDateTime: null, completedDateTime: null, - categories: [], createdDateTime: '2025-01-01T00:00:00Z', lastModifiedDateTime: '2025-01-01T00:00:00Z', + id: 'task-456', + title: 'New', + status: 'notStarted', + importance: 'normal', + isReminderOn: false, + body: { content: '', contentType: 'text' }, + dueDateTime: null, + reminderDateTime: null, + startDateTime: null, + completedDateTime: null, + categories: [], + createdDateTime: '2025-01-01T00:00:00Z', + lastModifiedDateTime: '2025-01-01T00:00:00Z', }), deleteTask: vi.fn().mockResolvedValue(undefined), completeTask: vi.fn().mockResolvedValue({ - id: 'task-456', title: 'Done', status: 'completed', importance: 'normal', - isReminderOn: false, body: { content: '', contentType: 'text' }, - dueDateTime: null, reminderDateTime: null, startDateTime: null, completedDateTime: null, - categories: [], createdDateTime: '2025-01-01T00:00:00Z', lastModifiedDateTime: '2025-01-01T00:00:00Z', + id: 'task-456', + title: 'Done', + status: 'completed', + importance: 'normal', + isReminderOn: false, + body: { content: '', contentType: 'text' }, + dueDateTime: null, + reminderDateTime: null, + startDateTime: null, + completedDateTime: null, + categories: [], + createdDateTime: '2025-01-01T00:00:00Z', + lastModifiedDateTime: '2025-01-01T00:00:00Z', }), })); vi.mock('../src/core/checklist-items.js', () => ({ listChecklistItems: vi.fn().mockResolvedValue([]), - createChecklistItem: vi.fn().mockResolvedValue({ id: 'item-new', displayName: 'Step', isChecked: false, createdDateTime: '2025-01-01T00:00:00Z' }), - updateChecklistItem: vi.fn().mockResolvedValue({ id: 'iid', displayName: 'New', isChecked: false, createdDateTime: '2025-01-01T00:00:00Z' }), + createChecklistItem: vi.fn().mockResolvedValue({ + id: 'item-new', + displayName: 'Step', + isChecked: false, + createdDateTime: '2025-01-01T00:00:00Z', + }), + updateChecklistItem: vi.fn().mockResolvedValue({ + id: 'iid', + displayName: 'New', + isChecked: false, + createdDateTime: '2025-01-01T00:00:00Z', + }), deleteChecklistItem: vi.fn().mockResolvedValue(undefined), })); @@ -99,9 +148,12 @@ vi.mock('../src/core/checklist-items.js', () => ({ const { run } = await import('../src/cli.js'); const { runSetup } = await import('../src/auth/setup.js'); const { startMcpServer } = await import('../src/mcp.js'); -const { listTaskLists, createTaskList, updateTaskList, deleteTaskList } = await import('../src/core/task-lists.js'); -const { listTasks, createTask, updateTask, deleteTask, completeTask } = await import('../src/core/tasks.js'); -const { listChecklistItems, createChecklistItem, updateChecklistItem, deleteChecklistItem } = await import('../src/core/checklist-items.js'); +const { listTaskLists, createTaskList, updateTaskList, deleteTaskList } = + await import('../src/core/task-lists.js'); +const { listTasks, createTask, updateTask, deleteTask, completeTask } = + await import('../src/core/tasks.js'); +const { listChecklistItems, createChecklistItem, updateChecklistItem, deleteChecklistItem } = + await import('../src/core/checklist-items.js'); // Format module (real, not mocked) const format = await import('../src/format.js'); @@ -115,9 +167,11 @@ beforeEach(() => { vi.clearAllMocks(); logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - exitSpy = vi.spyOn(process, 'exit').mockImplementation((code?: number | string | null | undefined) => { - throw new Error(`process.exit(${code})`); - }); + exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((code?: number | string | null | undefined) => { + throw new Error(`process.exit(${code})`); + }); }); afterEach(() => { @@ -161,9 +215,18 @@ describe('CLI command routing', () => { it('7. tasks create with all flags passes them correctly', async () => { await run([ - 'tasks', 'create', '--list', 'list-123', - '--title', 'Test', '--due', '2026-04-20', - '--importance', 'high', '--body', 'notes', + 'tasks', + 'create', + '--list', + 'list-123', + '--title', + 'Test', + '--due', + '2026-04-20', + '--importance', + 'high', + '--body', + 'notes', ]); expect(createTask).toHaveBeenCalledWith(expect.anything(), 'list-123', { title: 'Test', @@ -175,7 +238,9 @@ describe('CLI command routing', () => { it('8. tasks update routes correctly', async () => { await run(['tasks', 'update', '--list', 'list-123', '--task', 'task-456', '--title', 'New']); - expect(updateTask).toHaveBeenCalledWith(expect.anything(), 'list-123', 'task-456', { title: 'New' }); + expect(updateTask).toHaveBeenCalledWith(expect.anything(), 'list-123', 'task-456', { + title: 'New', + }); }); it('9. tasks delete routes correctly', async () => { @@ -199,8 +264,21 @@ describe('CLI command routing', () => { }); it('13. checklist update routes correctly', async () => { - await run(['checklist', 'update', '--list', 'lid', '--task', 'tid', '--item', 'iid', '--text', 'New']); - expect(updateChecklistItem).toHaveBeenCalledWith(expect.anything(), 'lid', 'tid', 'iid', { displayName: 'New' }); + await run([ + 'checklist', + 'update', + '--list', + 'lid', + '--task', + 'tid', + '--item', + 'iid', + '--text', + 'New', + ]); + expect(updateChecklistItem).toHaveBeenCalledWith(expect.anything(), 'lid', 'tid', 'iid', { + displayName: 'New', + }); }); it('14. checklist delete routes correctly', async () => { diff --git a/tests/core.test.ts b/tests/core.test.ts index 6dc4b2e..ea0a329 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -1,8 +1,27 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { GraphClient } from '../src/graph/client.js'; -import { listTaskLists, getTaskList, createTaskList, updateTaskList, deleteTaskList } from '../src/core/task-lists.js'; -import { listTasks, getTask, createTask, updateTask, deleteTask, completeTask, toGraphDateTime } from '../src/core/tasks.js'; -import { listChecklistItems, createChecklistItem, updateChecklistItem, deleteChecklistItem } from '../src/core/checklist-items.js'; +import { + listTaskLists, + getTaskList, + createTaskList, + updateTaskList, + deleteTaskList, +} from '../src/core/task-lists.js'; +import { + listTasks, + getTask, + createTask, + updateTask, + deleteTask, + completeTask, + toGraphDateTime, +} from '../src/core/tasks.js'; +import { + listChecklistItems, + createChecklistItem, + updateChecklistItem, + deleteChecklistItem, +} from '../src/core/checklist-items.js'; const mockRequest = vi.fn(); const client = { request: mockRequest } as unknown as GraphClient; @@ -40,7 +59,9 @@ describe('Task Lists', () => { const result = await updateTaskList(client, 'list-123', 'New'); - expect(mockRequest).toHaveBeenCalledWith('PATCH', '/me/todo/lists/list-123', { displayName: 'New' }); + expect(mockRequest).toHaveBeenCalledWith('PATCH', '/me/todo/lists/list-123', { + displayName: 'New', + }); expect(result).toEqual(updated); }); @@ -69,7 +90,9 @@ describe('Tasks', () => { const result = await listTasks(client, 'list-1'); - expect(mockRequest).toHaveBeenCalledWith('GET', '/me/todo/lists/list-1/tasks', undefined, { '$top': '100' }); + expect(mockRequest).toHaveBeenCalledWith('GET', '/me/todo/lists/list-1/tasks', undefined, { + $top: '100', + }); expect(result).toEqual([]); }); @@ -82,7 +105,7 @@ describe('Tasks', () => { 'GET', '/me/todo/lists/list-1/tasks', undefined, - expect.objectContaining({ '$filter': "status eq 'completed'" }), + expect.objectContaining({ $filter: "status eq 'completed'" }), ); }); @@ -95,14 +118,19 @@ describe('Tasks', () => { 'GET', '/me/todo/lists/list-1/tasks', undefined, - expect.objectContaining({ '$top': '5' }), + expect.objectContaining({ $top: '5' }), ); }); it('listTasks with combined options builds correct query params', async () => { mockRequest.mockResolvedValue({ value: [] }); - await listTasks(client, 'list-1', { status: 'completed', top: 10, orderby: 'createdDateTime desc', select: 'id,title' }); + await listTasks(client, 'list-1', { + status: 'completed', + top: 10, + orderby: 'createdDateTime desc', + select: 'id,title', + }); const params = mockRequest.mock.calls[0][3]; expect(params['$filter']).toBe("status eq 'completed'"); @@ -161,7 +189,10 @@ describe('Tasks', () => { it('createTask sets isReminderOn when reminderDateTime provided', async () => { mockRequest.mockResolvedValue({ id: 't1' }); - await createTask(client, 'list-1', { title: 'Reminder', reminderDateTime: '2024-12-24T09:00:00Z' }); + await createTask(client, 'list-1', { + title: 'Reminder', + reminderDateTime: '2024-12-24T09:00:00Z', + }); const body = mockRequest.mock.calls[0][2]; expect(body.isReminderOn).toBe(true); @@ -199,11 +230,15 @@ describe('Tasks', () => { await completeTask(client, 'list-1', 'task-1'); - expect(mockRequest).toHaveBeenCalledWith('PATCH', '/me/todo/lists/list-1/tasks/task-1', { status: 'completed' }); + expect(mockRequest).toHaveBeenCalledWith('PATCH', '/me/todo/lists/list-1/tasks/task-1', { + status: 'completed', + }); }); it('updateTask throws if taskId is empty', async () => { - await expect(updateTask(client, 'list-1', '', { title: 'X' })).rejects.toThrow('taskId is required'); + await expect(updateTask(client, 'list-1', '', { title: 'X' })).rejects.toThrow( + 'taskId is required', + ); }); it('getTask calls GET with correct URL', async () => { @@ -217,7 +252,9 @@ describe('Tasks', () => { }); it('listTasks with invalid status throws error', async () => { - await expect(listTasks(client, 'list-1', { status: 'bogus' })).rejects.toThrow('Invalid status'); + await expect(listTasks(client, 'list-1', { status: 'bogus' })).rejects.toThrow( + 'Invalid status', + ); }); }); @@ -255,7 +292,10 @@ describe('Checklist Items', () => { const result = await listChecklistItems(client, 'list-1', 'task-1'); - expect(mockRequest).toHaveBeenCalledWith('GET', '/me/todo/lists/list-1/tasks/task-1/checklistItems'); + expect(mockRequest).toHaveBeenCalledWith( + 'GET', + '/me/todo/lists/list-1/tasks/task-1/checklistItems', + ); expect(result).toEqual([]); }); @@ -317,6 +357,8 @@ describe('Checklist Items', () => { }); it('throws if checklistItemId is empty', async () => { - await expect(deleteChecklistItem(client, 'list-1', 'task-1', '')).rejects.toThrow('checklistItemId is required'); + await expect(deleteChecklistItem(client, 'list-1', 'task-1', '')).rejects.toThrow( + 'checklistItemId is required', + ); }); }); diff --git a/tests/graph-client.test.ts b/tests/graph-client.test.ts index 09fc741..02208d3 100644 --- a/tests/graph-client.test.ts +++ b/tests/graph-client.test.ts @@ -45,7 +45,7 @@ describe('GraphClient', () => { await client.request('GET', '/me/todo/lists'); expect(mockFetch).toHaveBeenCalledWith( 'https://graph.microsoft.com/v1.0/me/todo/lists', - expect.any(Object) + expect.any(Object), ); }); @@ -146,7 +146,7 @@ describe('GraphClient', () => { .mockResolvedValueOnce(errorResponse(401, 'InvalidAuthenticationToken', 'Still expired')); await expect(client.request('GET', '/me/todo/lists')).rejects.toThrow( - 'Graph API error 401: InvalidAuthenticationToken - Still expired' + 'Graph API error 401: InvalidAuthenticationToken - Still expired', ); expect(mockFetch).toHaveBeenCalledTimes(2); expect(mockForceRefresh).toHaveBeenCalledTimes(1); @@ -156,7 +156,7 @@ describe('GraphClient', () => { it('throws error with status and error type for 400', async () => { mockFetch.mockResolvedValue(errorResponse(400, 'BadRequest', 'Invalid filter')); await expect(client.request('GET', '/me/todo/lists')).rejects.toThrow( - 'Graph API error 400: BadRequest - Invalid filter' + 'Graph API error 400: BadRequest - Invalid filter', ); }); @@ -164,27 +164,31 @@ describe('GraphClient', () => { it('throws error for 403 response', async () => { mockFetch.mockResolvedValue(errorResponse(403, 'AccessDenied', 'Forbidden')); await expect(client.request('GET', '/me/todo/lists')).rejects.toThrow( - 'Graph API error 403: AccessDenied - Forbidden' + 'Graph API error 403: AccessDenied - Forbidden', ); }); it('throws error for 404 response', async () => { mockFetch.mockResolvedValue(errorResponse(404, 'ResourceNotFound', 'Not found')); await expect(client.request('GET', '/me/todo/lists/bad')).rejects.toThrow( - 'Graph API error 404: ResourceNotFound - Not found' + 'Graph API error 404: ResourceNotFound - Not found', ); }); it('throws error for 500 response', async () => { mockFetch.mockResolvedValue(errorResponse(500, 'InternalServerError', 'Server error')); await expect(client.request('GET', '/me/todo/lists')).rejects.toThrow( - 'Graph API error 500: InternalServerError - Server error' + 'Graph API error 500: InternalServerError - Server error', ); }); // 14. No response body in console output it('never logs response body data', async () => { - const sensitiveData = { id: '1', displayName: 'Secret List', value: [{ title: 'secret task' }] }; + const sensitiveData = { + id: '1', + displayName: 'Secret List', + value: [{ title: 'secret task' }], + }; mockFetch.mockResolvedValue(jsonResponse(sensitiveData)); await client.request('GET', '/me/todo/lists'); @@ -244,7 +248,12 @@ describe('GraphClient', () => { it('retries on 429 with Retry-After header', async () => { const headers429 = new Headers({ 'Retry-After': '0' }); mockFetch - .mockResolvedValueOnce({ status: 429, headers: headers429, json: () => Promise.resolve({ error: { code: 'TooManyRequests', message: 'Rate limited' } }) } as Response) + .mockResolvedValueOnce({ + status: 429, + headers: headers429, + json: () => + Promise.resolve({ error: { code: 'TooManyRequests', message: 'Rate limited' } }), + } as Response) .mockResolvedValueOnce(jsonResponse({ id: '1' })); const result = await client.request('GET', '/me/todo/lists'); diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 07ec2a8..0caea93 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -56,7 +56,7 @@ describe('MCP Server', () => { it('registers all 15 tools', async () => { const { client } = await setup(); const { tools } = await client.listTools(); - const names = tools.map(t => t.name).sort(); + const names = tools.map((t) => t.name).sort(); expect(names).toEqual([ 'complete-task', 'create-checklist-item', @@ -78,7 +78,15 @@ describe('MCP Server', () => { it('list-task-lists calls listTaskLists and returns formatted text', async () => { const { client } = await setup(); - const mockData = [{ id: '1', displayName: 'My List', isOwner: true, isShared: false, wellknownListName: 'none' }]; + const mockData = [ + { + id: '1', + displayName: 'My List', + isOwner: true, + isShared: false, + wellknownListName: 'none', + }, + ]; taskLists.listTaskLists.mockResolvedValue(mockData as any); const result = await client.callTool({ name: 'list-task-lists', arguments: {} }); @@ -125,7 +133,9 @@ describe('MCP Server', () => { }); expect(result.isError).toBeFalsy(); expect(tasks.updateTask).toHaveBeenCalledWith( - expect.anything(), 'list-1', 'task-1', + expect.anything(), + 'list-1', + 'task-1', expect.objectContaining({ title: 'Updated' }), ); }); @@ -140,28 +150,57 @@ describe('MCP Server', () => { // list-tasks tasks.listTasks.mockResolvedValue([]); - await client.callTool({ name: 'list-tasks', arguments: { listId: 'list-1', status: 'completed', top: 5 } }); + await client.callTool({ + name: 'list-tasks', + arguments: { listId: 'list-1', status: 'completed', top: 5 }, + }); expect(tasks.listTasks).toHaveBeenCalledWith( - expect.anything(), 'list-1', + expect.anything(), + 'list-1', expect.objectContaining({ status: 'completed', top: 5 }), ); // create-checklist-item - checklist.createChecklistItem.mockResolvedValue({ id: 'ci-1', displayName: 'Step 1', isChecked: false, createdDateTime: '' } as any); + checklist.createChecklistItem.mockResolvedValue({ + id: 'ci-1', + displayName: 'Step 1', + isChecked: false, + createdDateTime: '', + } as any); await client.callTool({ name: 'create-checklist-item', arguments: { listId: 'l1', taskId: 't1', displayName: 'Step 1', isChecked: true }, }); - expect(checklist.createChecklistItem).toHaveBeenCalledWith(expect.anything(), 'l1', 't1', 'Step 1', true); + expect(checklist.createChecklistItem).toHaveBeenCalledWith( + expect.anything(), + 'l1', + 't1', + 'Step 1', + true, + ); // update-checklist-item - checklist.updateChecklistItem.mockResolvedValue({ id: 'ci-1', displayName: 'Updated', isChecked: true, createdDateTime: '' } as any); + checklist.updateChecklistItem.mockResolvedValue({ + id: 'ci-1', + displayName: 'Updated', + isChecked: true, + createdDateTime: '', + } as any); await client.callTool({ name: 'update-checklist-item', - arguments: { listId: 'l1', taskId: 't1', checklistItemId: 'ci-1', displayName: 'Updated', isChecked: true }, + arguments: { + listId: 'l1', + taskId: 't1', + checklistItemId: 'ci-1', + displayName: 'Updated', + isChecked: true, + }, }); expect(checklist.updateChecklistItem).toHaveBeenCalledWith( - expect.anything(), 'l1', 't1', 'ci-1', + expect.anything(), + 'l1', + 't1', + 'ci-1', expect.objectContaining({ displayName: 'Updated', isChecked: true }), ); }); @@ -175,10 +214,16 @@ describe('MCP Server', () => { const r1 = await client.callTool({ name: 'delete-task-list', arguments: { listId: 'l1' } }); expect((r1.content as any)[0].text).toContain('deleted'); - const r2 = await client.callTool({ name: 'delete-task', arguments: { listId: 'l1', taskId: 't1' } }); + const r2 = await client.callTool({ + name: 'delete-task', + arguments: { listId: 'l1', taskId: 't1' }, + }); expect((r2.content as any)[0].text).toContain('deleted'); - const r3 = await client.callTool({ name: 'delete-checklist-item', arguments: { listId: 'l1', taskId: 't1', checklistItemId: 'ci1' } }); + const r3 = await client.callTool({ + name: 'delete-checklist-item', + arguments: { listId: 'l1', taskId: 't1', checklistItemId: 'ci1' }, + }); expect((r3.content as any)[0].text).toContain('deleted'); }); diff --git a/tests/setup.test.ts b/tests/setup.test.ts index 2d293f7..0112c2b 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -1,83 +1,83 @@ -import { describe, it, expect } from "vitest"; -import { createHash } from "node:crypto"; +import { describe, it, expect } from 'vitest'; +import { createHash } from 'node:crypto'; import { generateCodeVerifier, generateCodeChallenge, buildAuthorizationUrl, -} from "../src/auth/setup.js"; +} from '../src/auth/setup.js'; -describe("generateCodeVerifier", () => { - it("returns a string of 43-128 characters", () => { +describe('generateCodeVerifier', () => { + it('returns a string of 43-128 characters', () => { const verifier = generateCodeVerifier(); expect(verifier.length).toBeGreaterThanOrEqual(43); expect(verifier.length).toBeLessThanOrEqual(128); }); - it("contains only URL-safe characters", () => { + it('contains only URL-safe characters', () => { const verifier = generateCodeVerifier(); expect(verifier).toMatch(/^[A-Za-z0-9\-._~]+$/); }); - it("produces different values on multiple calls", () => { + it('produces different values on multiple calls', () => { const a = generateCodeVerifier(); const b = generateCodeVerifier(); expect(a).not.toBe(b); }); }); -describe("generateCodeChallenge", () => { - it("returns valid base64url (no +, /, or = padding)", () => { +describe('generateCodeChallenge', () => { + it('returns valid base64url (no +, /, or = padding)', () => { const verifier = generateCodeVerifier(); const challenge = generateCodeChallenge(verifier); expect(challenge).not.toMatch(/[+/=]/); expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/); }); - it("equals base64url(SHA-256(verifier)) independently computed", () => { - const verifier = "test-verifier-value-for-challenge"; + it('equals base64url(SHA-256(verifier)) independently computed', () => { + const verifier = 'test-verifier-value-for-challenge'; const challenge = generateCodeChallenge(verifier); // Independently compute the expected value - const expected = createHash("sha256") + const expected = createHash('sha256') .update(verifier) - .digest("base64") - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=+$/, ""); + .digest('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); expect(challenge).toBe(expected); }); }); -describe("buildAuthorizationUrl", () => { - it("contains all required parameters", () => { - const result = buildAuthorizationUrl("my-client-id", "consumers", "my-challenge"); +describe('buildAuthorizationUrl', () => { + it('contains all required parameters', () => { + const result = buildAuthorizationUrl('my-client-id', 'consumers', 'my-challenge'); - expect(result.url).toContain("client_id=my-client-id"); - expect(result.url).toContain("response_type=code"); - expect(result.url).toContain("redirect_uri="); - expect(result.url).toContain("scope="); - expect(result.url).toContain("Tasks.ReadWrite"); - expect(result.url).toContain("offline_access"); - expect(result.url).toContain("code_challenge=my-challenge"); - expect(result.url).toContain("code_challenge_method=S256"); - expect(result.url).toContain("response_mode=query"); + expect(result.url).toContain('client_id=my-client-id'); + expect(result.url).toContain('response_type=code'); + expect(result.url).toContain('redirect_uri='); + expect(result.url).toContain('scope='); + expect(result.url).toContain('Tasks.ReadWrite'); + expect(result.url).toContain('offline_access'); + expect(result.url).toContain('code_challenge=my-challenge'); + expect(result.url).toContain('code_challenge_method=S256'); + expect(result.url).toContain('response_mode=query'); expect(result.state).toBeTruthy(); }); - it("uses the correct tenant in the base URL", () => { - const result = buildAuthorizationUrl("cid", "my-tenant", "ch"); - expect(result.url).toContain("login.microsoftonline.com/my-tenant/oauth2/v2.0/authorize"); + it('uses the correct tenant in the base URL', () => { + const result = buildAuthorizationUrl('cid', 'my-tenant', 'ch'); + expect(result.url).toContain('login.microsoftonline.com/my-tenant/oauth2/v2.0/authorize'); }); - it("includes state parameter in the URL", () => { - const result = buildAuthorizationUrl("cid", "consumers", "ch"); - expect(result.url).toContain("state=" + result.state); + it('includes state parameter in the URL', () => { + const result = buildAuthorizationUrl('cid', 'consumers', 'ch'); + expect(result.url).toContain('state=' + result.state); }); - it("produces different state values on different calls", () => { - const r1 = buildAuthorizationUrl("cid", "consumers", "ch"); - const r2 = buildAuthorizationUrl("cid", "consumers", "ch"); + it('produces different state values on different calls', () => { + const r1 = buildAuthorizationUrl('cid', 'consumers', 'ch'); + const r2 = buildAuthorizationUrl('cid', 'consumers', 'ch'); expect(r1.state).not.toBe(r2.state); }); }); diff --git a/tests/token-manager.test.ts b/tests/token-manager.test.ts index 4ee197b..8ea74d5 100644 --- a/tests/token-manager.test.ts +++ b/tests/token-manager.test.ts @@ -167,11 +167,7 @@ describe('token-manager', () => { }); const { getAccessToken } = await getModule(); - const [r1, r2, r3] = await Promise.all([ - getAccessToken(), - getAccessToken(), - getAccessToken(), - ]); + const [r1, r2, r3] = await Promise.all([getAccessToken(), getAccessToken(), getAccessToken()]); expect(r1).toBe('new-access-token'); expect(r2).toBe('new-access-token'); @@ -189,9 +185,7 @@ describe('token-manager', () => { }); const { getAccessToken } = await getModule(); - await expect(getAccessToken()).rejects.toThrow( - /re-run.*todo setup/i, - ); + await expect(getAccessToken()).rejects.toThrow(/re-run.*todo setup/i); }); it('9. network error during refresh throws descriptive error', async () => { @@ -240,9 +234,7 @@ describe('token-manager', () => { await getAccessToken(); const url = mockFetch.mock.calls[0]![0] as string; - expect(url).toBe( - 'https://login.microsoftonline.com/my-custom-tenant/oauth2/v2.0/token', - ); + expect(url).toBe('https://login.microsoftonline.com/my-custom-tenant/oauth2/v2.0/token'); expect(url).not.toContain('consumers'); }); @@ -250,8 +242,6 @@ describe('token-manager', () => { mockLoad.mockReturnValue(null); const { getAccessToken } = await getModule(); - await expect(getAccessToken()).rejects.toThrow( - /no authentication found/i, - ); + await expect(getAccessToken()).rejects.toThrow(/no authentication found/i); }); }); From 0e060a3892a08169429da5c8e6ce6f617fd1121c Mon Sep 17 00:00:00 2001 From: Jeff Omhover Date: Wed, 22 Apr 2026 21:04:54 -0700 Subject: [PATCH 3/4] fix: token-store test cross-platform path handling The 'save creates parent directories' test assumed Windows-style APPDATA path on all platforms. On macOS/Linux, getTokenPath() uses os.homedir()/.config/todo-mcp instead. Fix by mocking os.homedir() on non-Windows and using the correct expected path per platform. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/token-store.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/token-store.test.ts b/tests/token-store.test.ts index 10ff441..5d3189b 100644 --- a/tests/token-store.test.ts +++ b/tests/token-store.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -22,9 +22,14 @@ describe('token-store', () => { originalAppData = process.env['APPDATA']; // Point APPDATA to tmpDir so save/load use it on Windows process.env['APPDATA'] = tmpDir; + // On macOS/Linux, getTokenPath() uses os.homedir() — redirect to tmpDir + if (process.platform !== 'win32') { + vi.spyOn(os, 'homedir').mockReturnValue(tmpDir); + } }); afterEach(() => { + vi.restoreAllMocks(); if (originalAppData !== undefined) { process.env['APPDATA'] = originalAppData; } else { @@ -125,7 +130,10 @@ describe('token-store', () => { it('save creates parent directories', () => { save(sampleTokens); - const tokenDir = path.join(tmpDir, 'todo-mcp'); + const tokenDir = + process.platform === 'win32' + ? path.join(tmpDir, 'todo-mcp') + : path.join(tmpDir, '.config', 'todo-mcp'); expect(fs.existsSync(tokenDir)).toBe(true); }); }); From aba49ff9220ec476cd0c3933b3f605151f85000c Mon Sep 17 00:00:00 2001 From: Jeff Omhover Date: Wed, 22 Apr 2026 21:08:39 -0700 Subject: [PATCH 4/4] fix: add endOfLine auto to Prettier config Git converts LF to CRLF on Windows checkout, causing Prettier to flag all files. Setting endOfLine: auto accepts existing line endings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .prettierrc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.prettierrc b/.prettierrc index 2fec1f2..0574c61 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,5 +1,6 @@ { "singleQuote": true, "trailingComma": "all", - "printWidth": 100 + "printWidth": 100, + "endOfLine": "auto" }