Initial commit: Microsoft To Do for Claude (skill + .mcpb extension)
Claude Code skill (CLI over Microsoft Graph, delegated auth-code + PKCE) and a Claude Desktop .mcpb extension sharing one dependency-free core client. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
25
.claude/skills/ms-todo/.env.example
Normal file
25
.claude/skills/ms-todo/.env.example
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Copy to ".env" in this folder and fill in. Git-ignored — never commit real values.
|
||||||
|
|
||||||
|
# Application (client) ID of an Entra PUBLIC client app with the delegated
|
||||||
|
# Tasks.ReadWrite permission. See references/setup.md.
|
||||||
|
TODO_CLIENT_ID=00000000-0000-0000-0000-000000000000
|
||||||
|
|
||||||
|
# Optional. Default "common" (work/school OR personal accounts).
|
||||||
|
# - a tenant GUID/domain -> work/school accounts in that tenant only
|
||||||
|
# - "organizations" -> any work/school account
|
||||||
|
# - "consumers" -> personal Microsoft accounts only
|
||||||
|
# TODO_TENANT_ID=common
|
||||||
|
|
||||||
|
# Optional. Where to cache the signed-in token (refresh token).
|
||||||
|
# Default: .token-cache.json next to scripts/. Keep it OUT of git.
|
||||||
|
# TODO_TOKEN_CACHE=/absolute/path/to/.token-cache.json
|
||||||
|
|
||||||
|
# Optional. Pin the loopback redirect port used during `login`. Default: the OS
|
||||||
|
# picks a free port (works when http://localhost is registered, since Entra
|
||||||
|
# ignores the port for loopback). Set this only if your tenant requires an exact
|
||||||
|
# redirect URI, then register http://127.0.0.1:<port> in the app.
|
||||||
|
# TODO_REDIRECT_PORT=53682
|
||||||
|
|
||||||
|
# Optional. Time zone applied to --due / --start / --reminder dates (default UTC).
|
||||||
|
# Accepts IANA (e.g. America/Denver) or Windows zone names.
|
||||||
|
# TODO_TIMEZONE=UTC
|
||||||
4
.claude/skills/ms-todo/.gitignore
vendored
Normal file
4
.claude/skills/ms-todo/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Secrets and cached credentials — never commit.
|
||||||
|
.env
|
||||||
|
.token-cache.json
|
||||||
|
scripts/.token-cache.json
|
||||||
194
.claude/skills/ms-todo/SKILL.md
Normal file
194
.claude/skills/ms-todo/SKILL.md
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
---
|
||||||
|
name: ms-todo
|
||||||
|
description: >-
|
||||||
|
Read and write Microsoft To Do tasks via the Microsoft Graph API using
|
||||||
|
delegated (browser, auth-code + PKCE) authentication. Use this skill whenever the user wants
|
||||||
|
to view, list, search, add, create, update, complete, check off, or delete
|
||||||
|
their Microsoft To Do tasks, task lists, or subtasks/checklist items — even if
|
||||||
|
they just say "my to-dos", "my tasks", "my task list", "add a reminder", "what's
|
||||||
|
on my list", or name a specific To Do list rather than saying "Microsoft To Do"
|
||||||
|
explicitly. Also use it for bulk operations over tasks (export, reporting, batch
|
||||||
|
complete/clean-up) and for sign-in/connectivity troubleshooting against To Do.
|
||||||
|
This is for Microsoft *To Do* (personal tasks in a user's mailbox), not Planner,
|
||||||
|
Project, or SharePoint task lists.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Microsoft To Do
|
||||||
|
|
||||||
|
Connect to Microsoft To Do and perform full CRUD (create, read, update, delete)
|
||||||
|
on task lists, tasks, and checklist items (subtasks) through the Microsoft Graph
|
||||||
|
API.
|
||||||
|
|
||||||
|
**Authentication is delegated, not app-only.** Microsoft To Do has *no*
|
||||||
|
application permission — the Graph `/me/todo` endpoints only accept a signed-in
|
||||||
|
user's token. This skill uses the OAuth2 **authorization-code + PKCE** flow:
|
||||||
|
`login` opens your browser, you sign in, and the refresh token is cached locally
|
||||||
|
so later runs refresh silently. This is the key difference from an app-only
|
||||||
|
integration like SharePoint Lists — there are no `*_CLIENT_SECRET` credentials
|
||||||
|
and a human must sign in the first time. (A browser flow is used rather than
|
||||||
|
device-code because device-code is often blocked by Conditional Access —
|
||||||
|
AADSTS53003.)
|
||||||
|
|
||||||
|
All operations go through one CLI, `scripts/todo.mjs`, which wraps the reusable
|
||||||
|
core client in `scripts/lib/graph.mjs`. Run it with Node (already available):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/todo.mjs <command> [flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
> Paths above are relative to this skill's directory. From elsewhere, use the
|
||||||
|
> absolute path, e.g. `node "<skill-dir>/scripts/todo.mjs" ...`.
|
||||||
|
|
||||||
|
## Prerequisites (one-time)
|
||||||
|
|
||||||
|
You need an Entra (Azure AD) **public client** app registration with the
|
||||||
|
delegated `Tasks.ReadWrite` permission. It requires only one value:
|
||||||
|
|
||||||
|
- `TODO_CLIENT_ID` — Application (client) ID
|
||||||
|
- `TODO_TENANT_ID` — optional; defaults to `common` (use a tenant id for
|
||||||
|
work/school-only apps, or `consumers` for personal Microsoft accounts only)
|
||||||
|
|
||||||
|
Set them as environment variables, or copy `.env.example` to `.env` in this
|
||||||
|
skill's directory and fill it in (the CLI loads `.env` automatically; `.env` is
|
||||||
|
git-ignored).
|
||||||
|
|
||||||
|
**If the app registration is not yet set up**, do not guess — point the user to
|
||||||
|
[references/setup.md](references/setup.md), a step-by-step walkthrough of creating
|
||||||
|
the public-client app, adding the loopback redirect URI, adding the delegated
|
||||||
|
`Tasks.ReadWrite` permission, and signing in.
|
||||||
|
|
||||||
|
## First step in any task: sign in, then verify
|
||||||
|
|
||||||
|
Sign in once (interactive — the user must complete it in a browser):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/todo.mjs login
|
||||||
|
```
|
||||||
|
|
||||||
|
This opens the user's browser to the Microsoft sign-in page (and prints the URL
|
||||||
|
as a fallback). The user signs in and approves; the browser redirects back to a
|
||||||
|
temporary local server and the refresh token is cached to
|
||||||
|
`scripts/.token-cache.json` (git-ignored). Run `login` on the same machine as the
|
||||||
|
browser. After that, confirm everything works:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/todo.mjs test
|
||||||
|
```
|
||||||
|
|
||||||
|
A successful response shows who is signed in and the names of their task lists.
|
||||||
|
If any command returns `"needsLogin": true`, the cached sign-in is missing or
|
||||||
|
expired — run `login` again.
|
||||||
|
|
||||||
|
## Identifying a list
|
||||||
|
|
||||||
|
- A **task list** can be given by display name (e.g. `"Groceries"`) or by its
|
||||||
|
opaque list id. The client resolves names to ids and, on a typo, lists the
|
||||||
|
available names so you can correct it.
|
||||||
|
- To Do list ids are long opaque strings (not GUIDs), so prefer the display name
|
||||||
|
unless you already have the id from a previous call.
|
||||||
|
|
||||||
|
When unsure what lists exist, discover first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/todo.mjs lists
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reading tasks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Everything in a list (follows pagination):
|
||||||
|
node scripts/todo.mjs tasks --list "Groceries" --all
|
||||||
|
|
||||||
|
# Open tasks only, soonest due first, projected:
|
||||||
|
node scripts/todo.mjs tasks --list "Work" \
|
||||||
|
--filter "status ne 'completed'" \
|
||||||
|
--select "title,status,dueDateTime,importance" \
|
||||||
|
--orderby "dueDateTime/dateTime asc" --top 50
|
||||||
|
|
||||||
|
# One task by id:
|
||||||
|
node scripts/todo.mjs get --list "Work" --id "<TASK_ID>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Tasks come back simplified (id, title, status, importance, due/start/reminder
|
||||||
|
date-times, body, categories, timestamps). See
|
||||||
|
[references/graph-api.md](references/graph-api.md) for the full property set and
|
||||||
|
OData query details.
|
||||||
|
|
||||||
|
## Writing tasks
|
||||||
|
|
||||||
|
Convenience flags cover the common properties; `--fields` (inline JSON,
|
||||||
|
`@file.json`, or `-` for stdin) merges on top for anything else.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create
|
||||||
|
node scripts/todo.mjs create --list "Work" \
|
||||||
|
--title "Renew SSL cert" --due 2026-07-01 --importance high \
|
||||||
|
--body "Use the prod ACME account"
|
||||||
|
|
||||||
|
# Update (partial — only what you pass changes)
|
||||||
|
node scripts/todo.mjs update --list "Work" --id "<TASK_ID>" --status inProgress
|
||||||
|
|
||||||
|
# Complete (shorthand for --status completed)
|
||||||
|
node scripts/todo.mjs done --list "Work" --id "<TASK_ID>"
|
||||||
|
|
||||||
|
# Delete
|
||||||
|
node scripts/todo.mjs delete --list "Work" --id "<TASK_ID>"
|
||||||
|
```
|
||||||
|
|
||||||
|
`--due`, `--start`, and `--reminder` accept `2026-07-01` or a full date-time and
|
||||||
|
are converted to Graph's `dateTimeTimeZone` shape using `TODO_TIMEZONE` (default
|
||||||
|
`UTC`). Setting `--reminder` also turns the reminder on.
|
||||||
|
|
||||||
|
**Deletes are irreversible.** Confirm the task id with the user before deleting,
|
||||||
|
and prefer showing them the task via `get` first. For bulk deletes, confirm the
|
||||||
|
full set once, then proceed.
|
||||||
|
|
||||||
|
## Task lists and checklist items
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Lists
|
||||||
|
node scripts/todo.mjs list-create --name "Trip planning"
|
||||||
|
node scripts/todo.mjs list-update --list "Trip planning" --name "Italy trip"
|
||||||
|
node scripts/todo.mjs list-delete --list "Italy trip" # deletes its tasks too — confirm first
|
||||||
|
|
||||||
|
# Checklist items (subtasks) on a task
|
||||||
|
node scripts/todo.mjs checklist --list "Work" --id "<TASK_ID>"
|
||||||
|
node scripts/todo.mjs checklist-add --list "Work" --id "<TASK_ID>" --name "Draft email"
|
||||||
|
node scripts/todo.mjs checklist-check --list "Work" --id "<TASK_ID>" --item "<ITEM_ID>" # --uncheck to undo
|
||||||
|
node scripts/todo.mjs checklist-delete --list "Work" --id "<TASK_ID>" --item "<ITEM_ID>"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bulk operations
|
||||||
|
|
||||||
|
For exports or batch edits, fetch with `--all`, transform the JSON however the
|
||||||
|
task needs, then loop the relevant `create`/`update`/`done`/`delete` calls. Keep
|
||||||
|
concurrency modest — Graph throttles with HTTP 429; the client already retries
|
||||||
|
with backoff, but don't fan out hundreds of parallel writes.
|
||||||
|
|
||||||
|
## Handling errors
|
||||||
|
|
||||||
|
The CLI prints a JSON error to stderr and exits non-zero. Common cases:
|
||||||
|
|
||||||
|
- `"needsLogin": true` → no cached sign-in or the refresh token expired/was
|
||||||
|
revoked. Run `node scripts/todo.mjs login`.
|
||||||
|
- `401`/`403` right after login → the app is missing the delegated
|
||||||
|
`Tasks.ReadWrite` permission, or consent wasn't granted — see
|
||||||
|
[references/setup.md](references/setup.md).
|
||||||
|
- `AADSTS53003` during sign-in → a Conditional Access policy blocked it. This
|
||||||
|
browser flow avoids device-code-specific blocks; if the policy requires a
|
||||||
|
compliant/managed device, the device must be enrolled first. See setup.
|
||||||
|
- `AADSTS50011` (redirect mismatch) → register `http://localhost` **and**
|
||||||
|
`http://127.0.0.1` under the app's Mobile-and-desktop platform. See setup.
|
||||||
|
- `400` on a write → usually a bad property name or value format (e.g. a date not
|
||||||
|
in `dateTimeTimeZone` shape, or an invalid `status`/`importance` enum). Check
|
||||||
|
[references/graph-api.md](references/graph-api.md).
|
||||||
|
- `404` on a list → wrong name; run `lists` to see valid names.
|
||||||
|
|
||||||
|
## Note on graduating to an MCP server
|
||||||
|
|
||||||
|
The core logic is isolated in `scripts/lib/graph.mjs` (the `MSTodoClient` class,
|
||||||
|
zero dependencies). To expose this as a standalone MCP server later, import that
|
||||||
|
class and expose its methods (`listTasks`, `getTask`, `createTask`, `updateTask`,
|
||||||
|
`deleteTask`, list/checklist methods) as MCP tools — no rewrite of the Graph or
|
||||||
|
auth logic needed. The only wrinkle vs. an app-only server: the browser `login`
|
||||||
|
must be run once on the host to seed the token cache the server reads.
|
||||||
112
.claude/skills/ms-todo/references/graph-api.md
Normal file
112
.claude/skills/ms-todo/references/graph-api.md
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
# Graph API reference for Microsoft To Do
|
||||||
|
|
||||||
|
Quick reference for the parts of the Microsoft Graph To Do API this skill relies
|
||||||
|
on. Full docs: <https://learn.microsoft.com/graph/api/resources/todo-overview>.
|
||||||
|
|
||||||
|
## Object model
|
||||||
|
|
||||||
|
```
|
||||||
|
/me/todo/lists todoTaskList[] (the task lists)
|
||||||
|
/me/todo/lists/{listId}/tasks todoTask[] (tasks in a list)
|
||||||
|
/me/todo/lists/{listId}/tasks/{taskId}/checklistItems checklistItem[] (subtasks)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **List ids** are long opaque strings (base64-ish), *not* GUIDs. Resolve by
|
||||||
|
display name when you can.
|
||||||
|
- **Task ids** and **checklist item ids** are also opaque strings returned by the
|
||||||
|
API; pass them back verbatim.
|
||||||
|
|
||||||
|
## todoTask properties (create / update)
|
||||||
|
|
||||||
|
`--fields` (and the convenience flags) map to these `todoTask` properties:
|
||||||
|
|
||||||
|
| Property | JSON value | Notes / example |
|
||||||
|
|---|---|---|
|
||||||
|
| `title` | string | **Required on create.** `{"title":"Buy milk"}` |
|
||||||
|
| `body` | `{ "content": "...", "contentType": "text"\|"html" }` | `--body` builds the `text` form |
|
||||||
|
| `importance` | `"low"` \| `"normal"` \| `"high"` | enum — exact strings |
|
||||||
|
| `status` | `"notStarted"` \| `"inProgress"` \| `"completed"` \| `"waitingOnOthers"` \| `"deferred"` | enum |
|
||||||
|
| `dueDateTime` | `dateTimeTimeZone` (see below) | `--due` builds this |
|
||||||
|
| `startDateTime` | `dateTimeTimeZone` | `--start` builds this |
|
||||||
|
| `reminderDateTime` | `dateTimeTimeZone` | set `isReminderOn:true` too (`--reminder` does both) |
|
||||||
|
| `isReminderOn` | boolean | |
|
||||||
|
| `categories` | array of strings | must match category names defined in the user's Outlook |
|
||||||
|
| `recurrence` | `patternedRecurrence` object | advanced; pass via `--fields` |
|
||||||
|
|
||||||
|
Read-only properties returned on GET (don't send them on write):
|
||||||
|
`id`, `createdDateTime`, `lastModifiedDateTime`, `completedDateTime`,
|
||||||
|
`hasAttachments`.
|
||||||
|
|
||||||
|
### `dateTimeTimeZone` shape
|
||||||
|
|
||||||
|
Date/time properties are **not** plain ISO strings — they're an object:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "dueDateTime": { "dateTime": "2026-07-01T17:00:00", "timeZone": "UTC" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
The CLI's `--due` / `--start` / `--reminder` accept `2026-07-01` (→ midnight) or a
|
||||||
|
full `2026-07-01T17:00:00`, and wrap it using `TODO_TIMEZONE` (default `UTC`). The
|
||||||
|
`timeZone` accepts IANA (`America/Denver`) or Windows (`Mountain Standard Time`)
|
||||||
|
zone names. To set one manually via `--fields`, supply the full object.
|
||||||
|
|
||||||
|
### Marking complete
|
||||||
|
|
||||||
|
Set `status` to `"completed"` (the `done` command does this). Graph stamps
|
||||||
|
`completedDateTime` automatically. Setting status back to `notStarted`/
|
||||||
|
`inProgress` reopens it.
|
||||||
|
|
||||||
|
## Querying tasks (OData)
|
||||||
|
|
||||||
|
The CLI maps flags to OData query options on `/me/todo/lists/{listId}/tasks`:
|
||||||
|
|
||||||
|
- `--filter` → `$filter`. To Do supports a subset of OData. Useful ones:
|
||||||
|
- `status ne 'completed'` — open tasks
|
||||||
|
- `status eq 'completed'`
|
||||||
|
- `importance eq 'high'`
|
||||||
|
- `lastModifiedDateTime gt 2026-06-01T00:00:00Z`
|
||||||
|
- combine with `and` / `or`
|
||||||
|
- Note: filtering directly on `dueDateTime` is limited; prefer fetching and
|
||||||
|
sorting client-side for due-date views.
|
||||||
|
- `--orderby` → `$orderby`, e.g. `dueDateTime/dateTime asc` (nested path), or
|
||||||
|
`importance desc`, `lastModifiedDateTime desc`, `createdDateTime asc`.
|
||||||
|
- `--select` → projects which properties come back: `title,status,dueDateTime`.
|
||||||
|
- `--top` → page size; `--all` follows `@odata.nextLink` to return every page.
|
||||||
|
|
||||||
|
### Escaping quotes
|
||||||
|
|
||||||
|
OData string literals use single quotes; a literal single quote is doubled:
|
||||||
|
`title eq 'O''Brien'`. In the shell, wrap the whole `--filter` value in double
|
||||||
|
quotes.
|
||||||
|
|
||||||
|
## Checklist items (subtasks)
|
||||||
|
|
||||||
|
A `checklistItem` has just `displayName` and `isChecked`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/todo.mjs checklist-add --list "Work" --id "<TASK_ID>" --name "Draft outline"
|
||||||
|
node scripts/todo.mjs checklist-check --list "Work" --id "<TASK_ID>" --item "<ITEM_ID>" # --uncheck to undo
|
||||||
|
```
|
||||||
|
|
||||||
|
## Response shape (reads)
|
||||||
|
|
||||||
|
Reads return tasks simplified to:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "<opaque task id>",
|
||||||
|
"title": "Renew SSL cert",
|
||||||
|
"status": "inProgress",
|
||||||
|
"importance": "high",
|
||||||
|
"isReminderOn": true,
|
||||||
|
"dueDateTime": { "dateTime": "2026-07-01T00:00:00.0000000", "timeZone": "UTC" },
|
||||||
|
"reminderDateTime": { "dateTime": "2026-06-30T09:00:00.0000000", "timeZone": "UTC" },
|
||||||
|
"body": { "content": "Use the prod ACME account", "contentType": "text" },
|
||||||
|
"categories": [],
|
||||||
|
"createdDateTime": "2026-06-16T10:00:00Z",
|
||||||
|
"lastModifiedDateTime": "2026-06-16T12:30:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`id` is the value you pass to `get`, `update`, `done`, and `delete`. Empty/unset
|
||||||
|
properties are omitted from the simplified shape.
|
||||||
144
.claude/skills/ms-todo/references/setup.md
Normal file
144
.claude/skills/ms-todo/references/setup.md
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
# Setup: Entra app registration for Microsoft To Do (delegated, browser auth-code)
|
||||||
|
|
||||||
|
Microsoft To Do has **no app-only access** — its Graph endpoints (`/me/todo/...`)
|
||||||
|
only accept a signed-in user's token. So this skill authenticates as **you**
|
||||||
|
(delegated) using the OAuth2 **authorization-code + PKCE** flow: `login` opens
|
||||||
|
your system browser, you sign in, and the browser redirects back to a temporary
|
||||||
|
local server. You create a *public client* app registration once, grant it the
|
||||||
|
delegated To Do permission, and sign in once per machine. There is no client
|
||||||
|
secret.
|
||||||
|
|
||||||
|
> Why browser auth-code and not device-code? Many tenants block the device-code
|
||||||
|
> flow with a Conditional Access policy (error **AADSTS53003**). The browser flow
|
||||||
|
> runs in your real browser session, satisfying far more policies. If a policy
|
||||||
|
> requires a *compliant/managed device*, the Mac must be registered/enrolled
|
||||||
|
> (Company Portal/Intune) first — no flow can bypass that.
|
||||||
|
|
||||||
|
You do **not** need to be an admin if your tenant allows users to consent to the
|
||||||
|
`Tasks.ReadWrite` delegated permission (it is a low-risk, user-consentable
|
||||||
|
permission by default). If admin consent is required in your org, ask an admin to
|
||||||
|
grant it once.
|
||||||
|
|
||||||
|
## 1. Register the application
|
||||||
|
|
||||||
|
1. Go to <https://entra.microsoft.com> → **Identity** → **Applications** →
|
||||||
|
**App registrations** → **New registration**.
|
||||||
|
2. Name it something recognizable, e.g. `claude-ms-todo`.
|
||||||
|
3. Supported account types:
|
||||||
|
- work/school only → **Accounts in this organizational directory only**
|
||||||
|
- also personal Microsoft accounts → **... and personal Microsoft accounts**
|
||||||
|
4. Leave **Redirect URI** blank for now — you'll add it in step 2.
|
||||||
|
5. **Register**.
|
||||||
|
|
||||||
|
On the app's **Overview** page, copy:
|
||||||
|
- **Application (client) ID** → `TODO_CLIENT_ID`
|
||||||
|
- **Directory (tenant) ID** → `TODO_TENANT_ID` (optional; `common` also works)
|
||||||
|
|
||||||
|
## 2. Add a loopback redirect URI (public client)
|
||||||
|
|
||||||
|
1. App → **Authentication** → **Add a platform** → **Mobile and desktop
|
||||||
|
applications**.
|
||||||
|
2. Under **Custom redirect URIs**, add **both**:
|
||||||
|
- `http://localhost`
|
||||||
|
- `http://127.0.0.1`
|
||||||
|
(Adding both avoids IPv4/IPv6 surprises. The skill redirects to
|
||||||
|
`http://127.0.0.1:<port>` at runtime; Entra ignores the port for loopback
|
||||||
|
redirects, so the OS-assigned port needs no registration.)
|
||||||
|
3. **Configure** / **Save**.
|
||||||
|
|
||||||
|
This marks the app as a public client for that redirect — no client secret is
|
||||||
|
needed; PKCE protects the code exchange. (You do **not** need "Allow public
|
||||||
|
client flows" for auth-code+PKCE.)
|
||||||
|
|
||||||
|
## 3. Add the delegated Graph permission
|
||||||
|
|
||||||
|
App → **API permissions** → **Add a permission** → **Microsoft Graph** →
|
||||||
|
**Delegated permissions**. Add:
|
||||||
|
|
||||||
|
| Permission | Why |
|
||||||
|
|---|---|
|
||||||
|
| **`Tasks.ReadWrite`** | Read/write the signed-in user's To Do tasks & lists |
|
||||||
|
| `offline_access` | Issue a refresh token so sign-in persists (usually added automatically) |
|
||||||
|
| `User.Read` | Identify the signed-in user in `test` (usually present by default) |
|
||||||
|
|
||||||
|
If your tenant requires it, click **Grant admin consent for <tenant>**.
|
||||||
|
Otherwise consent happens interactively during the first `login`.
|
||||||
|
|
||||||
|
> Use `Tasks.Read` instead of `Tasks.ReadWrite` if you want read-only access.
|
||||||
|
> The skill's write commands will then return `403`.
|
||||||
|
|
||||||
|
## 4. Give the skill the client id
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env` in the skill directory and set `TODO_CLIENT_ID`
|
||||||
|
(and `TODO_TENANT_ID` if not `common`). The CLI loads `.env` automatically, and
|
||||||
|
`.env` is git-ignored. Alternatively, export them as environment variables.
|
||||||
|
|
||||||
|
## 5. Sign in (once)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/todo.mjs login
|
||||||
|
```
|
||||||
|
|
||||||
|
This opens your **system browser** to the Microsoft sign-in page (and prints the
|
||||||
|
URL as a fallback if it can't auto-open). Sign in and approve; the browser
|
||||||
|
redirects to a temporary local server and you'll see "Signed in ✓". The refresh
|
||||||
|
token is cached to `scripts/.token-cache.json` (git-ignored, written with `0600`
|
||||||
|
perms). The CLI refreshes silently after that — you won't be asked again until
|
||||||
|
the refresh token expires or is revoked.
|
||||||
|
|
||||||
|
> Run `login` on the machine whose browser you'll use; the temporary redirect
|
||||||
|
> server listens on `127.0.0.1`, so the browser and the CLI must be on the same
|
||||||
|
> host (not over plain SSH without port forwarding).
|
||||||
|
|
||||||
|
## 6. Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/todo.mjs test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `{ "ok": true, "signedInAs": "you@contoso.com", "listCount": N, "lists": [...] }`.
|
||||||
|
|
||||||
|
Troubleshooting:
|
||||||
|
- **`AADSTS53003` (blocked by Conditional Access)** — a CA policy blocked the
|
||||||
|
sign-in. If it targets the *device-code* flow specifically, this browser flow
|
||||||
|
already avoids it. If it requires a **compliant/managed device** (your device
|
||||||
|
shows as *Unregistered*), enroll the Mac (Company Portal/Intune) or have an
|
||||||
|
admin exclude this app. Use the sign-in log's Correlation Id to find the exact
|
||||||
|
policy.
|
||||||
|
- **`redirect_uri` mismatch (`AADSTS50011`)** — add both `http://localhost` and
|
||||||
|
`http://127.0.0.1` under the Mobile-and-desktop platform (step 2).
|
||||||
|
- **Browser redirect never returns / connection refused** — something else holds
|
||||||
|
the port, or you're on a remote/SSH session. Run `login` locally, or pin a port
|
||||||
|
with `TODO_REDIRECT_PORT` and register `http://127.0.0.1:<port>`.
|
||||||
|
- **`AADSTS65001` / consent error during sign-in** — the org requires admin
|
||||||
|
consent for `Tasks.ReadWrite`; have an admin grant it (step 3).
|
||||||
|
- **`test` returns `403`** — the delegated `Tasks.ReadWrite` permission is
|
||||||
|
missing or only `Tasks.Read` was granted.
|
||||||
|
- **Any command returns `"needsLogin": true`** — the cached refresh token is gone
|
||||||
|
or expired; run `login` again.
|
||||||
|
- **Personal Microsoft account won't work** — set `TODO_TENANT_ID=consumers` (or
|
||||||
|
`common`) and register the app for personal accounts in step 1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Note: graduating to an MCP server
|
||||||
|
|
||||||
|
When you want this available across all sessions as native tools rather than a
|
||||||
|
CLI, build a small MCP server that imports `scripts/lib/graph.mjs`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { MSTodoClient } from "./graph.mjs";
|
||||||
|
const client = new MSTodoClient({
|
||||||
|
clientId: process.env.TODO_CLIENT_ID,
|
||||||
|
tenantId: process.env.TODO_TENANT_ID,
|
||||||
|
tokenCachePath: process.env.TODO_TOKEN_CACHE,
|
||||||
|
});
|
||||||
|
// Expose client.listTasks / getTask / createTask / updateTask / deleteTask
|
||||||
|
// (and the list/checklist methods) as MCP tools.
|
||||||
|
```
|
||||||
|
|
||||||
|
The client is dependency-free; its only state is the on-disk token cache. The one
|
||||||
|
difference from an app-only server: you must run `login` once on the host to seed
|
||||||
|
that cache before the server can call Graph. For a containerized HTTP transport,
|
||||||
|
mount the token-cache file as a volume and guard the `/mcp` endpoint with a bearer
|
||||||
|
token — the server acts as the single signed-in user.
|
||||||
642
.claude/skills/ms-todo/scripts/lib/graph.mjs
Normal file
642
.claude/skills/ms-todo/scripts/lib/graph.mjs
Normal file
@@ -0,0 +1,642 @@
|
|||||||
|
// Microsoft To Do core client — Microsoft Graph, DELEGATED (auth-code + PKCE).
|
||||||
|
//
|
||||||
|
// Zero dependencies. Uses Node's global fetch (Node 18+). This module is the
|
||||||
|
// single source of truth for talking to Microsoft To Do; the CLI (todo.mjs) is a
|
||||||
|
// thin wrapper around it, and a future MCP server can import this same class.
|
||||||
|
//
|
||||||
|
// Auth model — IMPORTANT: Microsoft To Do does NOT support app-only access.
|
||||||
|
// The Graph /me/todo endpoints only accept *delegated* permissions
|
||||||
|
// (Tasks.ReadWrite), so a user must sign in. We use the OAuth2 *authorization
|
||||||
|
// code* flow with PKCE and a loopback (http://localhost) redirect: `login` opens
|
||||||
|
// the system browser, the user signs in, and the refresh token is cached to disk
|
||||||
|
// so subsequent runs refresh silently — no client secret, works with MFA.
|
||||||
|
//
|
||||||
|
// Why not device-code? Many tenants block the device-code flow via Conditional
|
||||||
|
// Access (AADSTS53003), because it's headless. The browser-based auth-code flow
|
||||||
|
// runs in the device's real browser session, so it satisfies far more CA
|
||||||
|
// policies. (If a policy still requires a *compliant/managed device*, the Mac
|
||||||
|
// must be registered/enrolled first — no auth flow can bypass that.)
|
||||||
|
//
|
||||||
|
// The app registration must register a public-client redirect URI of
|
||||||
|
// http://localhost ("Mobile and desktop applications" platform) and hold the
|
||||||
|
// delegated Tasks.ReadWrite (+ offline_access, openid, profile, User.Read)
|
||||||
|
// permission. See references/setup.md.
|
||||||
|
|
||||||
|
import { readFileSync, writeFileSync, existsSync, rmSync, chmodSync } from "node:fs";
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
import { randomBytes, createHash } from "node:crypto";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
|
||||||
|
const LOGIN_HOST = "https://login.microsoftonline.com";
|
||||||
|
const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
|
||||||
|
|
||||||
|
// offline_access -> refresh token; openid/profile -> id_token (who signed in);
|
||||||
|
// User.Read -> identify the user in `test`; Tasks.ReadWrite -> full To Do CRUD.
|
||||||
|
const SCOPES = "offline_access openid profile User.Read Tasks.ReadWrite";
|
||||||
|
|
||||||
|
/** Error carrying the Graph status code + structured error body, when present. */
|
||||||
|
export class GraphError extends Error {
|
||||||
|
constructor(message, { status, code, requestId, body } = {}) {
|
||||||
|
super(message);
|
||||||
|
this.name = "GraphError";
|
||||||
|
this.status = status;
|
||||||
|
this.code = code;
|
||||||
|
this.requestId = requestId;
|
||||||
|
this.body = body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thrown when there is no cached login (or the refresh token is dead). */
|
||||||
|
export class AuthRequiredError extends Error {
|
||||||
|
constructor(message = 'Not signed in. Run "node todo.mjs login" first.') {
|
||||||
|
super(message);
|
||||||
|
this.name = "AuthRequiredError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MSTodoClient {
|
||||||
|
/**
|
||||||
|
* @param {object} cfg
|
||||||
|
* @param {string} cfg.clientId Application (client) ID of a PUBLIC client app.
|
||||||
|
* @param {string} [cfg.tenantId="common"] Tenant id / "common" / "organizations" / "consumers".
|
||||||
|
* @param {string} cfg.tokenCachePath Absolute path to the JSON token cache file.
|
||||||
|
* @param {number} [cfg.redirectPort=0] Loopback port for the auth-code redirect.
|
||||||
|
* 0 = let the OS pick (works when http://localhost is registered, since Entra
|
||||||
|
* ignores the port for loopback). Pin it only if your tenant requires an exact
|
||||||
|
* redirect URI (then register http://localhost:<port>).
|
||||||
|
* @param {number} [cfg.maxRetries=3] Retries for 429/503 (honors Retry-After).
|
||||||
|
*/
|
||||||
|
constructor({ clientId, tenantId = "common", tokenCachePath, redirectPort = 0, maxRetries = 3 } = {}) {
|
||||||
|
if (!clientId) {
|
||||||
|
throw new Error("Missing clientId (set TODO_CLIENT_ID). See references/setup.md.");
|
||||||
|
}
|
||||||
|
if (!tokenCachePath) {
|
||||||
|
throw new Error("Missing tokenCachePath.");
|
||||||
|
}
|
||||||
|
this.clientId = clientId;
|
||||||
|
this.tenantId = tenantId || "common";
|
||||||
|
this.tokenCachePath = tokenCachePath;
|
||||||
|
this.redirectPort = redirectPort;
|
||||||
|
this.maxRetries = maxRetries;
|
||||||
|
this._cache = null; // lazy-loaded { accessToken, expiresAt, refreshToken, ... }
|
||||||
|
this._listCache = new Map(); // nameOrId(lower) -> list object
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Token cache (on disk) ---------------------------------------------
|
||||||
|
|
||||||
|
_loadCache() {
|
||||||
|
if (this._cache) return this._cache;
|
||||||
|
if (!existsSync(this.tokenCachePath)) return null;
|
||||||
|
try {
|
||||||
|
this._cache = JSON.parse(readFileSync(this.tokenCachePath, "utf8"));
|
||||||
|
} catch {
|
||||||
|
this._cache = null;
|
||||||
|
}
|
||||||
|
return this._cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
_saveCache(tokens) {
|
||||||
|
const cache = {
|
||||||
|
clientId: this.clientId,
|
||||||
|
tenantId: this.tenantId,
|
||||||
|
accessToken: tokens.access_token,
|
||||||
|
expiresAt: Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
||||||
|
refreshToken: tokens.refresh_token ?? this._cache?.refreshToken,
|
||||||
|
scope: tokens.scope ?? SCOPES,
|
||||||
|
account: tokens.account ?? this._cache?.account,
|
||||||
|
};
|
||||||
|
writeFileSync(this.tokenCachePath, JSON.stringify(cache, null, 2));
|
||||||
|
try {
|
||||||
|
chmodSync(this.tokenCachePath, 0o600); // best-effort; refresh token is sensitive
|
||||||
|
} catch {
|
||||||
|
/* ignore (e.g. on filesystems without POSIX modes) */
|
||||||
|
}
|
||||||
|
this._cache = cache;
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove the cached login. Returns true if a cache file existed. */
|
||||||
|
logout() {
|
||||||
|
this._cache = null;
|
||||||
|
if (existsSync(this.tokenCachePath)) {
|
||||||
|
rmSync(this.tokenCachePath);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoggedIn() {
|
||||||
|
const c = this._loadCache();
|
||||||
|
return Boolean(c?.refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Auth ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interactive sign-in via the authorization-code + PKCE flow. Spins up a
|
||||||
|
* temporary loopback HTTP server, opens the system browser to the Entra
|
||||||
|
* authorize page, captures the redirect, exchanges the code for tokens, and
|
||||||
|
* persists them to the cache file. Returns the cached account.
|
||||||
|
*
|
||||||
|
* @param {(info:{authorizeUrl:string,redirectUri:string})=>void} [onPrompt]
|
||||||
|
* Called with the URL being opened (so the CLI can print it as a fallback).
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {number} [opts.timeoutMs=300000] How long to wait for the redirect.
|
||||||
|
*/
|
||||||
|
async login(onPrompt, { timeoutMs = 300_000 } = {}) {
|
||||||
|
const verifier = base64url(randomBytes(32));
|
||||||
|
const challenge = base64url(createHash("sha256").update(verifier).digest());
|
||||||
|
const state = base64url(randomBytes(16));
|
||||||
|
|
||||||
|
// Start the loopback server first so we know the actual port for redirect_uri.
|
||||||
|
const { server, port, waitForCode } = await startRedirectServer(
|
||||||
|
state,
|
||||||
|
timeoutMs,
|
||||||
|
this.redirectPort,
|
||||||
|
);
|
||||||
|
// 127.0.0.1 (not "localhost") so the bind address and the browser's target
|
||||||
|
// always agree, even on IPv6-first hosts. Register http://localhost AND
|
||||||
|
// http://127.0.0.1 in the app so either works. Entra ignores the port for
|
||||||
|
// loopback redirects, so the OS-assigned port needs no registration.
|
||||||
|
const redirectUri = `http://127.0.0.1:${port}`;
|
||||||
|
|
||||||
|
const authorizeUrl =
|
||||||
|
`${LOGIN_HOST}/${encodeURIComponent(this.tenantId)}/oauth2/v2.0/authorize?` +
|
||||||
|
new URLSearchParams({
|
||||||
|
client_id: this.clientId,
|
||||||
|
response_type: "code",
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
response_mode: "query",
|
||||||
|
scope: SCOPES,
|
||||||
|
state,
|
||||||
|
code_challenge: challenge,
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
prompt: "select_account",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof onPrompt === "function") onPrompt({ authorizeUrl, redirectUri });
|
||||||
|
openBrowser(authorizeUrl);
|
||||||
|
|
||||||
|
let code;
|
||||||
|
try {
|
||||||
|
code = await waitForCode;
|
||||||
|
} finally {
|
||||||
|
server.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokRes = await fetch(
|
||||||
|
`${LOGIN_HOST}/${encodeURIComponent(this.tenantId)}/oauth2/v2.0/token`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
client_id: this.clientId,
|
||||||
|
code,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
code_verifier: verifier,
|
||||||
|
scope: SCOPES,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const tok = await tokRes.json().catch(() => ({}));
|
||||||
|
if (!tokRes.ok) {
|
||||||
|
throw new GraphError(
|
||||||
|
`Token exchange failed (${tokRes.status}): ${tok.error_description || tok.error || tokRes.statusText}`,
|
||||||
|
{ status: tokRes.status, code: tok.error, body: tok },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this._saveCache({ ...tok, account: accountFromIdToken(tok.id_token) });
|
||||||
|
return this._cache.account ?? { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get a valid access token, refreshing with the cached refresh token if needed. */
|
||||||
|
async getToken() {
|
||||||
|
const cache = this._loadCache();
|
||||||
|
if (!cache?.refreshToken) throw new AuthRequiredError();
|
||||||
|
if (cache.accessToken && cache.expiresAt - 60_000 > Date.now()) {
|
||||||
|
return cache.accessToken;
|
||||||
|
}
|
||||||
|
const res = await fetch(
|
||||||
|
`${LOGIN_HOST}/${encodeURIComponent(this.tenantId)}/oauth2/v2.0/token`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
client_id: this.clientId,
|
||||||
|
refresh_token: cache.refreshToken,
|
||||||
|
scope: SCOPES,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const json = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
// invalid_grant => the refresh token is revoked/expired; force re-login.
|
||||||
|
if (json.error === "invalid_grant") {
|
||||||
|
throw new AuthRequiredError(
|
||||||
|
'Saved sign-in expired or was revoked. Run "node todo.mjs login" again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new GraphError(
|
||||||
|
`Token refresh failed (${res.status}): ${json.error_description || json.error || res.statusText}`,
|
||||||
|
{ status: res.status, code: json.error, body: json },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this._saveCache(json);
|
||||||
|
return this._cache.accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Low-level request --------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make a Graph request. `path` is relative to the v1.0 root (e.g. "/me/todo/lists").
|
||||||
|
* @param {string} method
|
||||||
|
* @param {string} path
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {object} [opts.query] Query params; keys starting with "$" are OData.
|
||||||
|
* @param {object} [opts.body] JSON body (will be stringified).
|
||||||
|
* @param {object} [opts.headers] Extra headers (e.g. Prefer).
|
||||||
|
*/
|
||||||
|
async graph(method, path, { query, body, headers } = {}) {
|
||||||
|
const url = new URL(path.startsWith("http") ? path : `${GRAPH_ROOT}${path}`);
|
||||||
|
if (query) {
|
||||||
|
for (const [k, v] of Object.entries(query)) {
|
||||||
|
if (v !== undefined && v !== null && v !== "") url.searchParams.set(k, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
|
const token = await this.getToken();
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
Accept: "application/json",
|
||||||
|
...(body ? { "Content-Type": "application/json" } : {}),
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if ((res.status === 429 || res.status === 503) && attempt < this.maxRetries) {
|
||||||
|
const retryAfter = Number(res.headers.get("Retry-After")) || 2 ** attempt;
|
||||||
|
await sleep(retryAfter * 1000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 204) return null; // No Content (e.g. DELETE)
|
||||||
|
const json = await res.json().catch(() => null);
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = json?.error || {};
|
||||||
|
throw new GraphError(
|
||||||
|
`Graph ${method} ${url.pathname} failed (${res.status}): ${err.message || res.statusText}`,
|
||||||
|
{
|
||||||
|
status: res.status,
|
||||||
|
code: err.code,
|
||||||
|
requestId: res.headers.get("request-id"),
|
||||||
|
body: json,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Identify the signed-in user. */
|
||||||
|
async me() {
|
||||||
|
return this.graph("GET", "/me", {
|
||||||
|
query: { $select: "id,displayName,userPrincipalName,mail" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Task lists ---------------------------------------------------------
|
||||||
|
|
||||||
|
/** List all To Do task lists. */
|
||||||
|
async listTaskLists() {
|
||||||
|
const data = await this.graph("GET", "/me/todo/lists");
|
||||||
|
return data.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a list identifier (opaque list id or display name) to its id.
|
||||||
|
* To Do list ids are long opaque strings, not GUIDs, so we always check
|
||||||
|
* display names too. Caches per identifier.
|
||||||
|
*/
|
||||||
|
async resolveListId(listIdOrName) {
|
||||||
|
if (!listIdOrName) throw new Error("A list id or name is required.");
|
||||||
|
const key = listIdOrName.toLowerCase();
|
||||||
|
if (this._listCache.has(key)) return this._listCache.get(key).id;
|
||||||
|
|
||||||
|
const lists = await this.listTaskLists();
|
||||||
|
// Exact id match first (ids are case-sensitive opaque strings).
|
||||||
|
let match = lists.find((l) => l.id === listIdOrName);
|
||||||
|
if (!match) {
|
||||||
|
match = lists.find((l) => l.displayName?.toLowerCase() === key);
|
||||||
|
}
|
||||||
|
if (!match) {
|
||||||
|
const names = lists.map((l) => l.displayName).join(", ");
|
||||||
|
throw new Error(
|
||||||
|
`No task list named "${listIdOrName}". Available: ${names || "(none)"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this._listCache.set(key, match);
|
||||||
|
return match.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a task list. */
|
||||||
|
async createTaskList(displayName) {
|
||||||
|
if (!displayName) throw new Error("displayName is required.");
|
||||||
|
this._listCache.clear();
|
||||||
|
return this.graph("POST", "/me/todo/lists", { body: { displayName } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rename a task list. */
|
||||||
|
async updateTaskList(listIdOrName, displayName) {
|
||||||
|
const listId = await this.resolveListId(listIdOrName);
|
||||||
|
this._listCache.clear();
|
||||||
|
return this.graph("PATCH", `/me/todo/lists/${listId}`, { body: { displayName } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete a task list (and all its tasks). */
|
||||||
|
async deleteTaskList(listIdOrName) {
|
||||||
|
const listId = await this.resolveListId(listIdOrName);
|
||||||
|
await this.graph("DELETE", `/me/todo/lists/${listId}`);
|
||||||
|
this._listCache.clear();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Tasks: read --------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List tasks in a list.
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {string} [opts.filter] OData $filter, e.g. "status ne 'completed'".
|
||||||
|
* @param {string} [opts.select] Comma list of properties, e.g. "title,status,dueDateTime".
|
||||||
|
* @param {string} [opts.orderby] e.g. "dueDateTime/dateTime asc".
|
||||||
|
* @param {number} [opts.top] Page size / max when not fetching all.
|
||||||
|
* @param {boolean}[opts.all] Follow @odata.nextLink to fetch every page.
|
||||||
|
*/
|
||||||
|
async listTasks(listIdOrName, opts = {}) {
|
||||||
|
const listId = await this.resolveListId(listIdOrName);
|
||||||
|
const { filter, select, orderby, top, all = false } = opts;
|
||||||
|
const query = { $filter: filter, $select: select, $orderby: orderby, $top: top };
|
||||||
|
|
||||||
|
let data = await this.graph("GET", `/me/todo/lists/${listId}/tasks`, { query });
|
||||||
|
const tasks = data.value.map(simplifyTask);
|
||||||
|
if (!all) return tasks;
|
||||||
|
|
||||||
|
while (data["@odata.nextLink"]) {
|
||||||
|
data = await this.graph("GET", data["@odata.nextLink"]);
|
||||||
|
tasks.push(...data.value.map(simplifyTask));
|
||||||
|
}
|
||||||
|
return tasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get a single task by id. */
|
||||||
|
async getTask(listIdOrName, taskId) {
|
||||||
|
const listId = await this.resolveListId(listIdOrName);
|
||||||
|
const data = await this.graph("GET", `/me/todo/lists/${listId}/tasks/${taskId}`);
|
||||||
|
return simplifyTask(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Tasks: write -------------------------------------------------------
|
||||||
|
|
||||||
|
/** Create a task. `fields` is a map of todoTask property -> value (title required). */
|
||||||
|
async createTask(listIdOrName, fields) {
|
||||||
|
const listId = await this.resolveListId(listIdOrName);
|
||||||
|
if (!fields || !fields.title) throw new Error("A task `title` is required.");
|
||||||
|
const data = await this.graph("POST", `/me/todo/lists/${listId}/tasks`, { body: fields });
|
||||||
|
return simplifyTask(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update a task (partial; only provided properties change). */
|
||||||
|
async updateTask(listIdOrName, taskId, fields) {
|
||||||
|
const listId = await this.resolveListId(listIdOrName);
|
||||||
|
const data = await this.graph("PATCH", `/me/todo/lists/${listId}/tasks/${taskId}`, {
|
||||||
|
body: fields,
|
||||||
|
});
|
||||||
|
return simplifyTask(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mark a task completed (convenience over updateTask). */
|
||||||
|
async completeTask(listIdOrName, taskId) {
|
||||||
|
return this.updateTask(listIdOrName, taskId, { status: "completed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete a task by id. */
|
||||||
|
async deleteTask(listIdOrName, taskId) {
|
||||||
|
const listId = await this.resolveListId(listIdOrName);
|
||||||
|
await this.graph("DELETE", `/me/todo/lists/${listId}/tasks/${taskId}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Checklist items (subtasks) ----------------------------------------
|
||||||
|
|
||||||
|
async listChecklistItems(listIdOrName, taskId) {
|
||||||
|
const listId = await this.resolveListId(listIdOrName);
|
||||||
|
const data = await this.graph(
|
||||||
|
"GET",
|
||||||
|
`/me/todo/lists/${listId}/tasks/${taskId}/checklistItems`,
|
||||||
|
);
|
||||||
|
return data.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createChecklistItem(listIdOrName, taskId, displayName) {
|
||||||
|
if (!displayName) throw new Error("displayName is required.");
|
||||||
|
const listId = await this.resolveListId(listIdOrName);
|
||||||
|
return this.graph("POST", `/me/todo/lists/${listId}/tasks/${taskId}/checklistItems`, {
|
||||||
|
body: { displayName },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update a checklist item: rename and/or (un)check it. */
|
||||||
|
async updateChecklistItem(listIdOrName, taskId, itemId, { displayName, isChecked } = {}) {
|
||||||
|
const listId = await this.resolveListId(listIdOrName);
|
||||||
|
const body = {};
|
||||||
|
if (displayName !== undefined) body.displayName = displayName;
|
||||||
|
if (isChecked !== undefined) body.isChecked = isChecked;
|
||||||
|
return this.graph(
|
||||||
|
"PATCH",
|
||||||
|
`/me/todo/lists/${listId}/tasks/${taskId}/checklistItems/${itemId}`,
|
||||||
|
{ body },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteChecklistItem(listIdOrName, taskId, itemId) {
|
||||||
|
const listId = await this.resolveListId(listIdOrName);
|
||||||
|
await this.graph(
|
||||||
|
"DELETE",
|
||||||
|
`/me/todo/lists/${listId}/tasks/${taskId}/checklistItems/${itemId}`,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Convenience --------------------------------------------------------
|
||||||
|
|
||||||
|
/** Verify the cached login works. Returns the signed-in user + list count. */
|
||||||
|
async test() {
|
||||||
|
const me = await this.me();
|
||||||
|
const lists = await this.listTaskLists();
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
signedInAs: me.userPrincipalName || me.mail || me.displayName,
|
||||||
|
displayName: me.displayName,
|
||||||
|
listCount: lists.length,
|
||||||
|
lists: lists.map((l) => l.displayName),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers --------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Flatten a todoTask into a compact, useful shape. */
|
||||||
|
function simplifyTask(t) {
|
||||||
|
if (!t) return t;
|
||||||
|
return {
|
||||||
|
id: t.id,
|
||||||
|
title: t.title,
|
||||||
|
status: t.status,
|
||||||
|
importance: t.importance,
|
||||||
|
isReminderOn: t.isReminderOn,
|
||||||
|
dueDateTime: t.dueDateTime,
|
||||||
|
startDateTime: t.startDateTime,
|
||||||
|
reminderDateTime: t.reminderDateTime,
|
||||||
|
completedDateTime: t.completedDateTime,
|
||||||
|
categories: t.categories,
|
||||||
|
body: t.body?.content ? t.body : undefined,
|
||||||
|
recurrence: t.recurrence,
|
||||||
|
hasAttachments: t.hasAttachments,
|
||||||
|
checklistItems: t.checklistItems,
|
||||||
|
createdDateTime: t.createdDateTime,
|
||||||
|
lastModifiedDateTime: t.lastModifiedDateTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort decode of the id_token payload to record which account signed in. */
|
||||||
|
function accountFromIdToken(idToken) {
|
||||||
|
if (!idToken || typeof idToken !== "string") return undefined;
|
||||||
|
const parts = idToken.split(".");
|
||||||
|
if (parts.length < 2) return undefined;
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
|
||||||
|
return {
|
||||||
|
username: payload.preferred_username || payload.upn || payload.email,
|
||||||
|
name: payload.name,
|
||||||
|
tid: payload.tid,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a Graph dateTimeTimeZone object from a friendly date string.
|
||||||
|
* Accepts "2026-07-01", "2026-07-01T15:30", or a full ISO string. A bare date
|
||||||
|
* defaults to midnight. Returns { dateTime, timeZone }.
|
||||||
|
*/
|
||||||
|
export function toDateTimeTimeZone(input, timeZone = "UTC") {
|
||||||
|
if (!input) return undefined;
|
||||||
|
let dateTime = String(input).trim();
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(dateTime)) dateTime += "T00:00:00";
|
||||||
|
// Strip a trailing Z — Graph carries the zone in the timeZone field instead.
|
||||||
|
dateTime = dateTime.replace(/Z$/, "");
|
||||||
|
return { dateTime, timeZone };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((r) => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** URL-safe base64 with no padding (for PKCE verifier/challenge/state). */
|
||||||
|
function base64url(buf) {
|
||||||
|
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a loopback HTTP server that captures the OAuth redirect. Resolves with
|
||||||
|
* `{ server, port, waitForCode }`, where `waitForCode` is a promise for the
|
||||||
|
* authorization code (rejects on error param, state mismatch, or timeout).
|
||||||
|
*/
|
||||||
|
function startRedirectServer(expectedState, timeoutMs, listenPort = 0) {
|
||||||
|
return new Promise((resolveStart, rejectStart) => {
|
||||||
|
let settle;
|
||||||
|
const waitForCode = new Promise((resolve, reject) => {
|
||||||
|
settle = { resolve, reject };
|
||||||
|
});
|
||||||
|
|
||||||
|
const server = createServer((req, res) => {
|
||||||
|
const url = new URL(req.url, "http://localhost");
|
||||||
|
if (url.pathname === "/favicon.ico") {
|
||||||
|
res.writeHead(204).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const error = url.searchParams.get("error");
|
||||||
|
const errorDesc = url.searchParams.get("error_description");
|
||||||
|
const code = url.searchParams.get("code");
|
||||||
|
const state = url.searchParams.get("state");
|
||||||
|
|
||||||
|
const reply = (title, msg) => {
|
||||||
|
res.writeHead(200, { "Content-Type": "text/html" });
|
||||||
|
res.end(
|
||||||
|
`<!doctype html><meta charset="utf-8"><title>${title}</title>` +
|
||||||
|
`<body style="font:16px system-ui;margin:3rem"><h2>${title}</h2><p>${msg}</p>` +
|
||||||
|
`<p>You can close this tab and return to the terminal.</p></body>`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
reply("Sign-in failed", escapeHtml(errorDesc || error));
|
||||||
|
settle.reject(
|
||||||
|
new GraphError(`Sign-in was blocked or cancelled: ${errorDesc || error}`, {
|
||||||
|
status: 400,
|
||||||
|
code: error,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else if (!code) {
|
||||||
|
reply("Waiting…", "No authorization code in the request.");
|
||||||
|
} else if (state !== expectedState) {
|
||||||
|
reply("Sign-in failed", "State mismatch — possible CSRF. Try again.");
|
||||||
|
settle.reject(new Error("OAuth state mismatch; aborting sign-in."));
|
||||||
|
} else {
|
||||||
|
reply("Signed in ✓", "Microsoft To Do is now connected.");
|
||||||
|
settle.resolve(code);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
settle.reject(new Error("Timed out waiting for browser sign-in."));
|
||||||
|
server.close();
|
||||||
|
}, timeoutMs);
|
||||||
|
// Clear the timer when the code promise settles, without creating an
|
||||||
|
// unhandled-rejection branch (login() is the one that handles the rejection).
|
||||||
|
waitForCode.finally(() => clearTimeout(timer)).catch(() => {});
|
||||||
|
|
||||||
|
server.on("error", (e) => rejectStart(e));
|
||||||
|
server.listen(listenPort, "127.0.0.1", () => {
|
||||||
|
resolveStart({ server, port: server.address().port, waitForCode });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort open of a URL in the system browser (macOS / Windows / Linux). */
|
||||||
|
function openBrowser(url) {
|
||||||
|
const cmd =
|
||||||
|
process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
||||||
|
try {
|
||||||
|
const child = spawn(cmd, process.platform === "win32" ? ["", url] : [url], {
|
||||||
|
stdio: "ignore",
|
||||||
|
detached: true,
|
||||||
|
shell: process.platform === "win32",
|
||||||
|
});
|
||||||
|
child.on("error", () => {}); // ignore; the CLI also prints the URL as a fallback
|
||||||
|
child.unref();
|
||||||
|
} catch {
|
||||||
|
/* ignore — user can open the printed URL manually */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MSTodoClient;
|
||||||
306
.claude/skills/ms-todo/scripts/todo.mjs
Normal file
306
.claude/skills/ms-todo/scripts/todo.mjs
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// CLI wrapper around MSTodoClient. Prints JSON to stdout; errors to stderr with a
|
||||||
|
// non-zero exit so callers can detect failure reliably.
|
||||||
|
//
|
||||||
|
// Auth is DELEGATED (auth-code + PKCE, browser) — Microsoft To Do has no app-only
|
||||||
|
// access. Run `login` once; it opens your browser. The refresh token is then
|
||||||
|
// cached and reused automatically thereafter.
|
||||||
|
//
|
||||||
|
// Config (env or a .env file in the CWD or this skill's directory):
|
||||||
|
// TODO_CLIENT_ID (or AZURE_CLIENT_ID) required — a PUBLIC client app id
|
||||||
|
// TODO_TENANT_ID (or AZURE_TENANT_ID) optional — default "common"
|
||||||
|
// TODO_TOKEN_CACHE optional — path to the token cache (default: .token-cache.json here)
|
||||||
|
// TODO_REDIRECT_PORT optional — pin the loopback redirect port (default: OS-assigned)
|
||||||
|
// TODO_TIMEZONE optional — IANA/Windows zone for --due/--start/--reminder (default UTC)
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// node todo.mjs login Sign in (opens browser), once
|
||||||
|
// node todo.mjs logout Forget the cached sign-in
|
||||||
|
// node todo.mjs test Verify auth; show signed-in user
|
||||||
|
// node todo.mjs lists List task lists
|
||||||
|
// node todo.mjs list-create --name "Groceries"
|
||||||
|
// node todo.mjs list-update --list NAME_OR_ID --name "New name"
|
||||||
|
// node todo.mjs list-delete --list NAME_OR_ID
|
||||||
|
// node todo.mjs tasks --list NAME_OR_ID [--filter ODATA] [--select F1,F2] [--orderby "dueDateTime/dateTime asc"] [--top N] [--all]
|
||||||
|
// node todo.mjs get --list NAME_OR_ID --id TASK_ID
|
||||||
|
// node todo.mjs create --list NAME_OR_ID --title "..." [--due 2026-07-01] [--start ...] [--reminder ...] [--importance high] [--body "..."] [--fields JSON]
|
||||||
|
// node todo.mjs update --list NAME_OR_ID --id TASK_ID [--title ...] [--status completed] [--due ...] [--importance ...] [--body ...] [--fields JSON]
|
||||||
|
// node todo.mjs done --list NAME_OR_ID --id TASK_ID
|
||||||
|
// node todo.mjs delete --list NAME_OR_ID --id TASK_ID
|
||||||
|
// node todo.mjs checklist --list NAME_OR_ID --id TASK_ID List checklist (subtasks)
|
||||||
|
// node todo.mjs checklist-add --list NAME_OR_ID --id TASK_ID --name "step"
|
||||||
|
// node todo.mjs checklist-check --list NAME_OR_ID --id TASK_ID --item ITEM_ID [--uncheck]
|
||||||
|
// node todo.mjs checklist-delete --list NAME_OR_ID --id TASK_ID --item ITEM_ID
|
||||||
|
//
|
||||||
|
// --fields accepts inline JSON, @path/to/file.json, or "-" (stdin) and is merged
|
||||||
|
// over the convenience flags, so you can express anything the Graph API supports.
|
||||||
|
|
||||||
|
import { readFileSync, existsSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
MSTodoClient,
|
||||||
|
GraphError,
|
||||||
|
AuthRequiredError,
|
||||||
|
toDateTimeTimeZone,
|
||||||
|
} from "./lib/graph.mjs";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
const payload = {
|
||||||
|
ok: false,
|
||||||
|
error: err.message,
|
||||||
|
...(err instanceof GraphError
|
||||||
|
? { status: err.status, code: err.code, requestId: err.requestId }
|
||||||
|
: {}),
|
||||||
|
...(err instanceof AuthRequiredError ? { needsLogin: true } : {}),
|
||||||
|
};
|
||||||
|
process.stderr.write(JSON.stringify(payload, null, 2) + "\n");
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
loadDotEnv();
|
||||||
|
const [, , command, ...rest] = process.argv;
|
||||||
|
const flags = parseFlags(rest);
|
||||||
|
|
||||||
|
if (!command || command === "help" || flags.help) {
|
||||||
|
printHelp();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new MSTodoClient({
|
||||||
|
clientId: env("TODO_CLIENT_ID", "AZURE_CLIENT_ID"),
|
||||||
|
tenantId: env("TODO_TENANT_ID", "AZURE_TENANT_ID") || "common",
|
||||||
|
tokenCachePath: env("TODO_TOKEN_CACHE") || join(__dirname, ".token-cache.json"),
|
||||||
|
redirectPort: env("TODO_REDIRECT_PORT") ? Number(env("TODO_REDIRECT_PORT")) : 0,
|
||||||
|
});
|
||||||
|
const tz = env("TODO_TIMEZONE") || "UTC";
|
||||||
|
|
||||||
|
const needList = () => {
|
||||||
|
if (!flags.list) throw new Error("Missing --list NAME_OR_ID.");
|
||||||
|
return flags.list;
|
||||||
|
};
|
||||||
|
const needId = () => {
|
||||||
|
if (!flags.id) throw new Error("Missing --id TASK_ID.");
|
||||||
|
return flags.id;
|
||||||
|
};
|
||||||
|
const needItem = () => {
|
||||||
|
if (!flags.item) throw new Error("Missing --item ITEM_ID.");
|
||||||
|
return flags.item;
|
||||||
|
};
|
||||||
|
|
||||||
|
let result;
|
||||||
|
switch (command) {
|
||||||
|
case "login":
|
||||||
|
result = await client.login((p) => {
|
||||||
|
process.stderr.write(
|
||||||
|
`\nOpening your browser to sign in…\nIf it didn't open, paste this URL:\n${p.authorizeUrl}\n\n` +
|
||||||
|
`Waiting for you to finish signing in (redirect to ${p.redirectUri})…\n\n`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
result = { ok: true, signedIn: result };
|
||||||
|
break;
|
||||||
|
case "logout":
|
||||||
|
result = { ok: true, loggedOut: client.logout() };
|
||||||
|
break;
|
||||||
|
case "test":
|
||||||
|
result = await client.test();
|
||||||
|
break;
|
||||||
|
case "lists":
|
||||||
|
result = await client.listTaskLists();
|
||||||
|
break;
|
||||||
|
case "list-create":
|
||||||
|
result = await client.createTaskList(needName());
|
||||||
|
break;
|
||||||
|
case "list-update":
|
||||||
|
result = await client.updateTaskList(needList(), needName());
|
||||||
|
break;
|
||||||
|
case "list-delete":
|
||||||
|
await client.deleteTaskList(needList());
|
||||||
|
result = { ok: true, deletedList: needList() };
|
||||||
|
break;
|
||||||
|
case "tasks":
|
||||||
|
result = await client.listTasks(needList(), {
|
||||||
|
filter: flags.filter,
|
||||||
|
select: flags.select,
|
||||||
|
orderby: flags.orderby,
|
||||||
|
top: flags.top ? Number(flags.top) : undefined,
|
||||||
|
all: Boolean(flags.all),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "get":
|
||||||
|
result = await client.getTask(needList(), needId());
|
||||||
|
break;
|
||||||
|
case "create":
|
||||||
|
result = await client.createTask(needList(), buildTaskFields(flags, tz, true));
|
||||||
|
break;
|
||||||
|
case "update":
|
||||||
|
result = await client.updateTask(needList(), needId(), buildTaskFields(flags, tz, false));
|
||||||
|
break;
|
||||||
|
case "done":
|
||||||
|
result = await client.completeTask(needList(), needId());
|
||||||
|
break;
|
||||||
|
case "delete":
|
||||||
|
await client.deleteTask(needList(), needId());
|
||||||
|
result = { ok: true, deleted: needId() };
|
||||||
|
break;
|
||||||
|
case "checklist":
|
||||||
|
result = await client.listChecklistItems(needList(), needId());
|
||||||
|
break;
|
||||||
|
case "checklist-add":
|
||||||
|
result = await client.createChecklistItem(needList(), needId(), needName());
|
||||||
|
break;
|
||||||
|
case "checklist-check":
|
||||||
|
result = await client.updateChecklistItem(needList(), needId(), needItem(), {
|
||||||
|
isChecked: !flags.uncheck,
|
||||||
|
displayName: flags.name,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "checklist-delete":
|
||||||
|
await client.deleteChecklistItem(needList(), needId(), needItem());
|
||||||
|
result = { ok: true, deletedItem: needItem() };
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown command "${command}". Run "node todo.mjs help".`);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
||||||
|
|
||||||
|
function needName() {
|
||||||
|
if (!flags.name) throw new Error("Missing --name VALUE.");
|
||||||
|
return flags.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers --------------------------------------------------------------
|
||||||
|
|
||||||
|
function env(...names) {
|
||||||
|
for (const n of names) if (process.env[n]) return process.env[n];
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFlags(args) {
|
||||||
|
const flags = {};
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const a = args[i];
|
||||||
|
if (!a.startsWith("--")) continue;
|
||||||
|
const key = a.slice(2);
|
||||||
|
const next = args[i + 1];
|
||||||
|
if (next === undefined || next.startsWith("--")) {
|
||||||
|
flags[key] = true; // boolean flag (e.g. --all, --uncheck)
|
||||||
|
} else {
|
||||||
|
flags[key] = next;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return flags;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a todoTask body from convenience flags, then merge raw --fields JSON on
|
||||||
|
* top so power users can override anything. On create, title is required.
|
||||||
|
*/
|
||||||
|
function buildTaskFields(flags, tz, requireTitle) {
|
||||||
|
const fields = {};
|
||||||
|
if (flags.title) fields.title = flags.title;
|
||||||
|
if (flags.importance) fields.importance = flags.importance; // low|normal|high
|
||||||
|
if (flags.status) fields.status = flags.status; // notStarted|inProgress|completed|waitingOnOthers|deferred
|
||||||
|
if (flags.body) fields.body = { content: flags.body, contentType: "text" };
|
||||||
|
if (flags.due) fields.dueDateTime = toDateTimeTimeZone(flags.due, tz);
|
||||||
|
if (flags.start) fields.startDateTime = toDateTimeTimeZone(flags.start, tz);
|
||||||
|
if (flags.reminder) {
|
||||||
|
fields.reminderDateTime = toDateTimeTimeZone(flags.reminder, tz);
|
||||||
|
fields.isReminderOn = true;
|
||||||
|
}
|
||||||
|
if (flags.categories) {
|
||||||
|
fields.categories = String(flags.categories)
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = readFieldsFlag(flags);
|
||||||
|
Object.assign(fields, raw);
|
||||||
|
|
||||||
|
if (requireTitle && !fields.title) {
|
||||||
|
throw new Error("Missing --title (or a title in --fields) for create.");
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFieldsFlag(flags) {
|
||||||
|
const raw = flags.fields;
|
||||||
|
if (raw === undefined || raw === true) return {};
|
||||||
|
let text = raw;
|
||||||
|
if (raw === "-") text = readFileSync(0, "utf8");
|
||||||
|
else if (raw.startsWith("@")) text = readFileSync(raw.slice(1), "utf8");
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`--fields is not valid JSON: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal .env loader (no dependency). Looks in CWD then this skill's dir.
|
||||||
|
function loadDotEnv() {
|
||||||
|
for (const dir of [process.cwd(), __dirname, join(__dirname, "..")]) {
|
||||||
|
const file = join(dir, ".env");
|
||||||
|
if (!existsSync(file)) continue;
|
||||||
|
for (const line of readFileSync(file, "utf8").split("\n")) {
|
||||||
|
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/i);
|
||||||
|
if (!m) continue;
|
||||||
|
const key = m[1];
|
||||||
|
let val = m[2].trim();
|
||||||
|
if (
|
||||||
|
(val.startsWith('"') && val.endsWith('"')) ||
|
||||||
|
(val.startsWith("'") && val.endsWith("'"))
|
||||||
|
) {
|
||||||
|
val = val.slice(1, -1);
|
||||||
|
}
|
||||||
|
if (process.env[key] === undefined) process.env[key] = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function printHelp() {
|
||||||
|
process.stdout.write(
|
||||||
|
`ms-todo CLI — Microsoft To Do via Microsoft Graph (delegated / browser auth-code+PKCE)
|
||||||
|
|
||||||
|
Auth (one-time):
|
||||||
|
login Sign in (opens your browser)
|
||||||
|
logout Forget the cached sign-in
|
||||||
|
test Verify auth; show signed-in user
|
||||||
|
|
||||||
|
Task lists:
|
||||||
|
lists List all task lists
|
||||||
|
list-create --name "Groceries"
|
||||||
|
list-update --list NAME_OR_ID --name "New name"
|
||||||
|
list-delete --list NAME_OR_ID
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
tasks --list NAME_OR_ID [filters] Query tasks
|
||||||
|
get --list NAME_OR_ID --id TASK_ID Get one task
|
||||||
|
create --list NAME_OR_ID --title "..." [opts] Create a task
|
||||||
|
update --list NAME_OR_ID --id TASK_ID [opts] Update a task
|
||||||
|
done --list NAME_OR_ID --id TASK_ID Mark a task completed
|
||||||
|
delete --list NAME_OR_ID --id TASK_ID Delete a task
|
||||||
|
|
||||||
|
task opts: --title --body --importance low|normal|high --status notStarted|inProgress|completed
|
||||||
|
--due 2026-07-01 --start ... --reminder ... --categories "a,b"
|
||||||
|
tasks filters: --filter "status ne 'completed'" --select title,status,dueDateTime
|
||||||
|
--orderby "dueDateTime/dateTime asc" --top 50 --all
|
||||||
|
|
||||||
|
Checklist items (subtasks):
|
||||||
|
checklist --list NAME_OR_ID --id TASK_ID
|
||||||
|
checklist-add --list NAME_OR_ID --id TASK_ID --name "step"
|
||||||
|
checklist-check --list NAME_OR_ID --id TASK_ID --item ITEM_ID [--uncheck]
|
||||||
|
checklist-delete --list NAME_OR_ID --id TASK_ID --item ITEM_ID
|
||||||
|
|
||||||
|
--fields accepts inline JSON, @file.json, or - (stdin); it merges over the flags.
|
||||||
|
Config (env / .env): TODO_CLIENT_ID (required), TODO_TENANT_ID (default "common"),
|
||||||
|
TODO_TOKEN_CACHE, TODO_TIMEZONE (default UTC).
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
}
|
||||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Secrets and cached credentials — never commit.
|
||||||
|
.env
|
||||||
|
**/.env
|
||||||
|
.token-cache.json
|
||||||
|
**/.token-cache.json
|
||||||
|
|
||||||
|
# OS cruft
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
116
README.md
Normal file
116
README.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# Claude-MSTodo
|
||||||
|
|
||||||
|
Connect Claude to **Microsoft To Do** via the Microsoft Graph API, with full
|
||||||
|
read/write (CRUD) over task lists, tasks, and checklist items (subtasks).
|
||||||
|
|
||||||
|
This is a sibling to [Claude-SharepointLists](../Claude-SharepointLists), built
|
||||||
|
the same way — a dependency-free core Graph client (`graph.mjs`) wrapped by a
|
||||||
|
small CLI and packaged as a Claude Code **skill**. The one fundamental
|
||||||
|
difference is **authentication**:
|
||||||
|
|
||||||
|
| | SharePoint Lists | **Microsoft To Do** |
|
||||||
|
|---|---|---|
|
||||||
|
| Auth model | App-only (client credentials) | **Delegated (browser, auth-code + PKCE)** |
|
||||||
|
| Why | SharePoint supports application permissions | To Do has **no** app-only access — `/me/todo` needs a user token |
|
||||||
|
| Secrets | tenant + client id + **client secret** | client id only (**public client**, no secret) |
|
||||||
|
| First run | works immediately | **`login` once** (opens browser); refresh token cached |
|
||||||
|
|
||||||
|
> A browser flow is used rather than device-code because device-code is commonly
|
||||||
|
> blocked by Conditional Access (AADSTS53003).
|
||||||
|
|
||||||
|
Two ways to use it, sharing one core Graph client (`graph.mjs`):
|
||||||
|
|
||||||
|
| Form | Best for | Entry point |
|
||||||
|
|---|---|---|
|
||||||
|
| **Skill** | Claude Code on your own machine | `.claude/skills/ms-todo/` |
|
||||||
|
| **Desktop extension (`.mcpb`)** | Claude Desktop, distributed org-wide | `mcp-extension/` |
|
||||||
|
|
||||||
|
## Repo layout
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── .claude/skills/ms-todo/ # the skill (Claude Code)
|
||||||
|
│ ├── SKILL.md # how the skill works + usage
|
||||||
|
│ ├── .env.example # config template (.env is git-ignored)
|
||||||
|
│ ├── scripts/
|
||||||
|
│ │ ├── todo.mjs # CLI: login|logout|test|lists|tasks|get|create|update|done|delete|checklist…
|
||||||
|
│ │ └── lib/graph.mjs # shared, dependency-free Graph client (MSTodoClient) — single source of truth
|
||||||
|
│ └── references/
|
||||||
|
│ ├── setup.md # Entra app registration walkthrough (public client + loopback redirect)
|
||||||
|
│ └── graph-api.md # todoTask field formats + OData query reference
|
||||||
|
└── mcp-extension/ # Claude Desktop extension (imports the same graph.mjs)
|
||||||
|
├── manifest.json # .mcpb manifest (baked client/tenant id, user_config)
|
||||||
|
├── server/index.mjs # MCP server (stdio) exposing todo_* tools
|
||||||
|
├── scripts/sync-core.mjs # copies the canonical graph.mjs into the bundle
|
||||||
|
└── README.md # build + install + admin setup
|
||||||
|
```
|
||||||
|
|
||||||
|
For Claude Desktop, build and distribute the extension — see
|
||||||
|
[`mcp-extension/README.md`](mcp-extension/README.md). (Claude Desktop can't run
|
||||||
|
Claude Code skills; it loads MCP servers, so the `.mcpb` is the right vehicle.)
|
||||||
|
|
||||||
|
## Prerequisites (one-time)
|
||||||
|
|
||||||
|
An Entra (Azure AD) **public client** app registration with the delegated
|
||||||
|
`Tasks.ReadWrite` permission and a loopback redirect URI (`http://localhost` +
|
||||||
|
`http://127.0.0.1`) under the Mobile-and-desktop platform. The full walkthrough is in
|
||||||
|
[`references/setup.md`](.claude/skills/ms-todo/references/setup.md). You end up
|
||||||
|
needing just one value: `TODO_CLIENT_ID` (and optionally `TODO_TENANT_ID`).
|
||||||
|
|
||||||
|
There is **no client secret** — To Do is a personal/delegated resource, so you
|
||||||
|
sign in as yourself once and the refresh token is cached in a git-ignored file.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .claude/skills/ms-todo/.env.example .claude/skills/ms-todo/.env
|
||||||
|
# set TODO_CLIENT_ID (and TODO_TENANT_ID if not "common")
|
||||||
|
|
||||||
|
cd .claude/skills/ms-todo/scripts
|
||||||
|
node todo.mjs login # open the printed URL, enter the code, approve
|
||||||
|
node todo.mjs test # -> { ok: true, signedInAs, listCount, lists: [...] }
|
||||||
|
|
||||||
|
node todo.mjs lists
|
||||||
|
node todo.mjs tasks --list "Work" --filter "status ne 'completed'" --orderby "dueDateTime/dateTime asc"
|
||||||
|
node todo.mjs create --list "Work" --title "Renew SSL cert" --due 2026-07-01 --importance high
|
||||||
|
node todo.mjs done --list "Work" --id "<TASK_ID>"
|
||||||
|
```
|
||||||
|
|
||||||
|
See [SKILL.md](.claude/skills/ms-todo/SKILL.md) for the full command set and
|
||||||
|
[references/graph-api.md](.claude/skills/ms-todo/references/graph-api.md) for task
|
||||||
|
field formats and OData query details.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `login` / `logout` | Browser sign-in (auth-code+PKCE) / forget the cached sign-in |
|
||||||
|
| `test` | Verify auth; show signed-in user and task lists |
|
||||||
|
| `lists` | List all task lists |
|
||||||
|
| `list-create` / `list-update` / `list-delete` | Manage task lists |
|
||||||
|
| `tasks` | Query tasks (`--filter`/`--select`/`--orderby`/`--top`/`--all`) |
|
||||||
|
| `get` | Get one task by id |
|
||||||
|
| `create` / `update` / `done` / `delete` | Manage tasks |
|
||||||
|
| `checklist` / `checklist-add` / `checklist-check` / `checklist-delete` | Manage subtasks |
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- **Delegated auth = the cached token acts as you.** Anyone with read access to
|
||||||
|
`scripts/.token-cache.json` can use your To Do. It's written `0600` and
|
||||||
|
git-ignored — keep it that way; `logout` deletes it.
|
||||||
|
- There is **no client secret** to leak; the public-client app id is not a
|
||||||
|
secret.
|
||||||
|
- The skill is for the signed-in user's own tasks. To act as another user you'd
|
||||||
|
sign in as them (or, for an MCP server, run a separate cache per account).
|
||||||
|
|
||||||
|
## Graduating to an MCP server
|
||||||
|
|
||||||
|
The Graph + auth logic lives entirely in
|
||||||
|
[`graph.mjs`](.claude/skills/ms-todo/scripts/lib/graph.mjs) (`MSTodoClient`,
|
||||||
|
zero dependencies), so it drops straight into an `@modelcontextprotocol/sdk`
|
||||||
|
server — expose `listTasks`/`getTask`/`createTask`/`updateTask`/`deleteTask` and
|
||||||
|
the list/checklist methods as tools. The only extra step vs. an app-only server:
|
||||||
|
run `login` once on the host to seed the token cache the server reads. See the
|
||||||
|
note at the bottom of
|
||||||
|
[`references/setup.md`](.claude/skills/ms-todo/references/setup.md).
|
||||||
|
```
|
||||||
9
mcp-extension/.gitignore
vendored
Normal file
9
mcp-extension/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Generated / bundled artifacts
|
||||||
|
node_modules/
|
||||||
|
# synced from the skill by scripts/sync-core.mjs
|
||||||
|
server/lib/graph.mjs
|
||||||
|
*.mcpb
|
||||||
|
|
||||||
|
# Signing material — NEVER commit the private key
|
||||||
|
key.pem
|
||||||
|
cert.pem
|
||||||
109
mcp-extension/README.md
Normal file
109
mcp-extension/README.md
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# Microsoft To Do — Claude Desktop extension (.mcpb)
|
||||||
|
|
||||||
|
A one-click Claude Desktop extension that connects Claude to **Microsoft To Do**.
|
||||||
|
It wraps the same core Graph client as the [`ms-todo` skill](../.claude/skills/ms-todo)
|
||||||
|
(`MSTodoClient`) and exposes it as MCP tools over stdio. Each user signs in as
|
||||||
|
**themselves** (delegated, browser auth-code + PKCE); their refresh token is
|
||||||
|
cached privately on their own machine.
|
||||||
|
|
||||||
|
The built artifact is **`ms-todo.mcpb`**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## For end users — install in Claude Desktop
|
||||||
|
|
||||||
|
1. Get `ms-todo.mcpb` from your admin.
|
||||||
|
2. Open **Claude Desktop → Settings → Extensions**.
|
||||||
|
3. Drag `ms-todo.mcpb` in (or click **Install extension** and select it).
|
||||||
|
4. Optionally set your **Time zone** in the extension's settings (used for due /
|
||||||
|
start / reminder dates; default UTC). Leave **Read-only mode** off for full
|
||||||
|
access.
|
||||||
|
5. In a chat, say: **"Sign in to Microsoft To Do."** Claude runs the `todo_login`
|
||||||
|
tool, your browser opens to the Microsoft sign-in page, you approve, and you're
|
||||||
|
connected. (You only do this once.)
|
||||||
|
6. Try it: *"What's on my To Do list?"*, *"Add 'call dentist' due Friday to Tasks."*
|
||||||
|
|
||||||
|
> If Claude Desktop warns that the extension is **not verified/signed**, that's
|
||||||
|
> expected for an internally distributed extension — install it anyway (or have
|
||||||
|
> your admin sign it, see below).
|
||||||
|
|
||||||
|
### What it can do (tools)
|
||||||
|
|
||||||
|
`todo_login`, `todo_logout`, `todo_test`, `todo_list_lists`, `todo_list_tasks`,
|
||||||
|
`todo_get_task`, `todo_create_task`, `todo_update_task`, `todo_complete_task`,
|
||||||
|
`todo_delete_task`, `todo_create_list`, `todo_update_list`, `todo_delete_list`,
|
||||||
|
`todo_list_checklist`, `todo_add_checklist_item`, `todo_check_checklist_item`,
|
||||||
|
`todo_delete_checklist_item`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## For the admin — one-time Entra setup (org-wide)
|
||||||
|
|
||||||
|
The extension ships with a **baked-in** public-client app id and tenant id (in
|
||||||
|
`manifest.json` → `server.mcp_config.env`). There is **no client secret** — these
|
||||||
|
identifiers are not secrets. For smooth org-wide use, in
|
||||||
|
[entra.microsoft.com](https://entra.microsoft.com) on that app registration:
|
||||||
|
|
||||||
|
1. **Authentication → Mobile and desktop applications** — ensure redirect URIs
|
||||||
|
`http://localhost` **and** `http://127.0.0.1` are present (the extension uses a
|
||||||
|
loopback redirect during sign-in). *Already configured for the current app.*
|
||||||
|
2. **API permissions** — delegated `Tasks.ReadWrite`, `offline_access`,
|
||||||
|
`User.Read`. Click **Grant admin consent for <tenant>** so individual
|
||||||
|
users are **not** prompted to consent (and so it works even if user consent is
|
||||||
|
disabled in your tenant).
|
||||||
|
3. The tenant id is baked single-tenant, so only your org's accounts can sign in.
|
||||||
|
To support guests/other tenants, change the app to multi-tenant and set
|
||||||
|
`TODO_TENANT_ID` to `organizations` (then rebuild).
|
||||||
|
|
||||||
|
To change the baked id/tenant, edit `manifest.json` and rebuild (below).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rebuilding the .mcpb
|
||||||
|
|
||||||
|
Requires Node 18+. From this directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run pack
|
||||||
|
```
|
||||||
|
|
||||||
|
That runs three steps: `sync` (copy the canonical `graph.mjs` from the skill into
|
||||||
|
`server/lib/`), `npm ci --omit=dev` (install runtime deps to bundle), and
|
||||||
|
`mcpb pack` (zip into `ms-todo.mcpb`). Or run them manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/sync-core.mjs
|
||||||
|
npm ci --omit=dev
|
||||||
|
npx -y @anthropic-ai/mcpb pack . ms-todo.mcpb
|
||||||
|
```
|
||||||
|
|
||||||
|
Useful checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx -y @anthropic-ai/mcpb validate manifest.json
|
||||||
|
npx -y @anthropic-ai/mcpb info ms-todo.mcpb
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional: sign the extension
|
||||||
|
|
||||||
|
Unsigned extensions install with a "not verified" warning. You can self-sign so
|
||||||
|
the warning shows your identity (full removal of the warning needs a CA-issued
|
||||||
|
cert your org trusts):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx -y @anthropic-ai/mcpb sign ms-todo.mcpb --cert cert.pem --key key.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How it differs from the Claude Code skill
|
||||||
|
|
||||||
|
| | `ms-todo` skill | this `.mcpb` extension |
|
||||||
|
|---|---|---|
|
||||||
|
| Host | Claude Code (CLI/IDE) on your machine | Claude Desktop |
|
||||||
|
| Surface | `node todo.mjs …` commands | MCP tools (`todo_*`) |
|
||||||
|
| Sign-in | `node todo.mjs login` | `todo_login` tool ("sign in to Microsoft To Do") |
|
||||||
|
| Core client | `scripts/lib/graph.mjs` | the **same** file, synced into `server/lib/` |
|
||||||
|
|
||||||
|
The Graph + auth logic lives in one place; this bundle is just a thin MCP wrapper
|
||||||
|
plus a manifest. Token cache defaults to `~/.ms-todo/token-cache.json` per user.
|
||||||
69
mcp-extension/manifest.json
Normal file
69
mcp-extension/manifest.json
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": "0.3",
|
||||||
|
"name": "ms-todo",
|
||||||
|
"display_name": "Microsoft To Do",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Read and write your Microsoft To Do tasks, lists, and subtasks from Claude.",
|
||||||
|
"long_description": "Connects Claude to Microsoft To Do via the Microsoft Graph API. You sign in once as yourself (a browser window opens); the connection then reads and writes your task lists, tasks, and checklist items. Each user's sign-in is private to their own machine.",
|
||||||
|
"author": {
|
||||||
|
"name": "PESCO Inc."
|
||||||
|
},
|
||||||
|
"keywords": ["microsoft", "to do", "todo", "tasks", "graph", "productivity"],
|
||||||
|
"license": "MIT",
|
||||||
|
"compatibility": {
|
||||||
|
"platforms": ["darwin", "win32", "linux"],
|
||||||
|
"runtimes": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"type": "node",
|
||||||
|
"entry_point": "server/index.mjs",
|
||||||
|
"mcp_config": {
|
||||||
|
"command": "node",
|
||||||
|
"args": ["${__dirname}/server/index.mjs"],
|
||||||
|
"env": {
|
||||||
|
"TODO_CLIENT_ID": "9af4a8a3-5290-4089-9058-a29dcce63c4f",
|
||||||
|
"TODO_TENANT_ID": "94c6c62d-8fe7-416f-8fef-7d8620b95819",
|
||||||
|
"TODO_TOKEN_CACHE": "${HOME}/.ms-todo/token-cache.json",
|
||||||
|
"TODO_TIMEZONE": "${user_config.timezone}",
|
||||||
|
"TODO_READONLY": "${user_config.read_only}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tools": [
|
||||||
|
{ "name": "todo_login", "description": "Sign in (opens your browser)" },
|
||||||
|
{ "name": "todo_logout", "description": "Forget the cached sign-in" },
|
||||||
|
{ "name": "todo_test", "description": "Verify connectivity and show your lists" },
|
||||||
|
{ "name": "todo_list_lists", "description": "List your task lists" },
|
||||||
|
{ "name": "todo_list_tasks", "description": "Query tasks in a list" },
|
||||||
|
{ "name": "todo_get_task", "description": "Get one task" },
|
||||||
|
{ "name": "todo_create_task", "description": "Create a task" },
|
||||||
|
{ "name": "todo_update_task", "description": "Update a task" },
|
||||||
|
{ "name": "todo_complete_task", "description": "Mark a task complete" },
|
||||||
|
{ "name": "todo_delete_task", "description": "Delete a task" },
|
||||||
|
{ "name": "todo_create_list", "description": "Create a task list" },
|
||||||
|
{ "name": "todo_update_list", "description": "Rename a task list" },
|
||||||
|
{ "name": "todo_delete_list", "description": "Delete a task list" },
|
||||||
|
{ "name": "todo_list_checklist", "description": "List checklist items (subtasks)" },
|
||||||
|
{ "name": "todo_add_checklist_item", "description": "Add a checklist item" },
|
||||||
|
{ "name": "todo_check_checklist_item", "description": "Check/uncheck a checklist item" },
|
||||||
|
{ "name": "todo_delete_checklist_item", "description": "Delete a checklist item" }
|
||||||
|
],
|
||||||
|
"user_config": {
|
||||||
|
"timezone": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Time zone",
|
||||||
|
"description": "Time zone for due/start/reminder dates (IANA like America/Denver, or a Windows zone name). Default UTC.",
|
||||||
|
"default": "UTC",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"read_only": {
|
||||||
|
"type": "boolean",
|
||||||
|
"title": "Read-only mode",
|
||||||
|
"description": "If enabled, only viewing tools are available (no create/update/delete).",
|
||||||
|
"default": false,
|
||||||
|
"required": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1169
mcp-extension/package-lock.json
generated
Normal file
1169
mcp-extension/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
mcp-extension/package.json
Normal file
16
mcp-extension/package.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "ms-todo-mcp-extension",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Microsoft To Do MCP server, packaged as a Claude Desktop .mcpb extension",
|
||||||
|
"scripts": {
|
||||||
|
"sync": "node scripts/sync-core.mjs",
|
||||||
|
"start": "node server/index.mjs",
|
||||||
|
"pack": "npm run sync && npm ci --omit=dev && npx -y @anthropic-ai/mcpb pack . ms-todo.mcpb"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
16
mcp-extension/scripts/sync-core.mjs
Normal file
16
mcp-extension/scripts/sync-core.mjs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Copy the canonical core client into the extension so the bundle is
|
||||||
|
// self-contained. The single source of truth lives in the skill; this keeps the
|
||||||
|
// extension's copy (server/lib/graph.mjs) in sync. Run before packing.
|
||||||
|
|
||||||
|
import { copyFileSync, mkdirSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const src = join(here, "..", "..", ".claude", "skills", "ms-todo", "scripts", "lib", "graph.mjs");
|
||||||
|
const dest = join(here, "..", "server", "lib", "graph.mjs");
|
||||||
|
|
||||||
|
mkdirSync(dirname(dest), { recursive: true });
|
||||||
|
copyFileSync(src, dest);
|
||||||
|
console.log(`Synced core client:\n ${src}\n -> ${dest}`);
|
||||||
443
mcp-extension/server/index.mjs
Normal file
443
mcp-extension/server/index.mjs
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Microsoft To Do MCP server (stdio) — packaged as a Claude Desktop .mcpb extension.
|
||||||
|
//
|
||||||
|
// Wraps the SAME core client the skill uses (lib/graph.mjs) and exposes it as MCP
|
||||||
|
// tools. Auth is DELEGATED (browser auth-code + PKCE): each user signs in as
|
||||||
|
// themselves via the `todo_login` tool, which opens their browser. The refresh
|
||||||
|
// token is cached per-user on disk so later sessions refresh silently.
|
||||||
|
//
|
||||||
|
// Config comes from the environment (the .mcpb manifest sets these):
|
||||||
|
// TODO_CLIENT_ID (required) public-client app id — baked into the manifest
|
||||||
|
// TODO_TENANT_ID (optional) tenant id / "common" — baked into the manifest
|
||||||
|
// TODO_TOKEN_CACHE (optional) path to the per-user token cache file
|
||||||
|
// TODO_TIMEZONE (optional) zone for due/start/reminder dates (default UTC)
|
||||||
|
// TODO_READONLY (optional) "true"/"1" -> register only read tools
|
||||||
|
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
|
// Single source of truth — synced from the skill's scripts/lib/graph.mjs.
|
||||||
|
import { MSTodoClient, GraphError, AuthRequiredError, toDateTimeTimeZone } from "./lib/graph.mjs";
|
||||||
|
|
||||||
|
const READONLY = /^(1|true|yes)$/i.test(process.env.TODO_READONLY ?? "");
|
||||||
|
const TIMEZONE = process.env.TODO_TIMEZONE || "UTC";
|
||||||
|
|
||||||
|
const tokenCachePath = process.env.TODO_TOKEN_CACHE || join(homedir(), ".ms-todo", "token-cache.json");
|
||||||
|
try {
|
||||||
|
mkdirSync(dirname(tokenCachePath), { recursive: true });
|
||||||
|
} catch {
|
||||||
|
/* best-effort; the client will surface a clear error if it can't write */
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new MSTodoClient({
|
||||||
|
clientId: process.env.TODO_CLIENT_ID,
|
||||||
|
tenantId: process.env.TODO_TENANT_ID || "common",
|
||||||
|
tokenCachePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- helpers --------------------------------------------------------------
|
||||||
|
|
||||||
|
const ok = (data) => ({
|
||||||
|
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
||||||
|
});
|
||||||
|
const fail = (err) => ({
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text:
|
||||||
|
err instanceof AuthRequiredError
|
||||||
|
? `${err.message} (use the "todo_login" tool to sign in)`
|
||||||
|
: err instanceof GraphError
|
||||||
|
? `Graph error (${err.status}${err.code ? " " + err.code : ""}): ${err.message}`
|
||||||
|
: `Error: ${err.message}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Build a todoTask body from friendly params + an optional raw fields object. */
|
||||||
|
function buildTaskFields(
|
||||||
|
{ title, body, importance, status, due, start, reminder, categories, extraFields },
|
||||||
|
requireTitle,
|
||||||
|
) {
|
||||||
|
const fields = {};
|
||||||
|
if (title !== undefined) fields.title = title;
|
||||||
|
if (importance !== undefined) fields.importance = importance;
|
||||||
|
if (status !== undefined) fields.status = status;
|
||||||
|
if (body !== undefined) fields.body = { content: body, contentType: "text" };
|
||||||
|
if (due !== undefined) fields.dueDateTime = toDateTimeTimeZone(due, TIMEZONE);
|
||||||
|
if (start !== undefined) fields.startDateTime = toDateTimeTimeZone(start, TIMEZONE);
|
||||||
|
if (reminder !== undefined) {
|
||||||
|
fields.reminderDateTime = toDateTimeTimeZone(reminder, TIMEZONE);
|
||||||
|
fields.isReminderOn = true;
|
||||||
|
}
|
||||||
|
if (categories !== undefined) fields.categories = categories;
|
||||||
|
if (extraFields && typeof extraFields === "object") Object.assign(fields, extraFields);
|
||||||
|
if (requireTitle && !fields.title) throw new Error("A task `title` is required.");
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared schema fragments
|
||||||
|
const LIST = z.string().describe("Task list display name (e.g. \"Tasks\") or its opaque list id.");
|
||||||
|
const TASK_ID = z.string().describe("The task id (from todo_list_tasks / todo_get_task).");
|
||||||
|
const IMPORTANCE = z.enum(["low", "normal", "high"]).optional();
|
||||||
|
const STATUS = z
|
||||||
|
.enum(["notStarted", "inProgress", "completed", "waitingOnOthers", "deferred"])
|
||||||
|
.optional();
|
||||||
|
const DATEISH = z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Date like "2026-07-01" or "2026-07-01T17:00:00" (uses the configured time zone).');
|
||||||
|
const TASK_WRITE_FIELDS = {
|
||||||
|
title: z.string().optional().describe("Task title."),
|
||||||
|
body: z.string().optional().describe("Note/body text."),
|
||||||
|
importance: IMPORTANCE,
|
||||||
|
status: STATUS,
|
||||||
|
due: DATEISH,
|
||||||
|
start: DATEISH,
|
||||||
|
reminder: DATEISH.describe("Reminder date/time; also turns the reminder on."),
|
||||||
|
categories: z.array(z.string()).optional().describe("Category names (must exist in the user's Outlook categories)."),
|
||||||
|
extraFields: z
|
||||||
|
.record(z.any())
|
||||||
|
.optional()
|
||||||
|
.describe("Raw todoTask properties merged last, for anything not covered above (e.g. recurrence)."),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- server ---------------------------------------------------------------
|
||||||
|
|
||||||
|
const server = new McpServer({ name: "ms-todo", version: "1.0.0" });
|
||||||
|
|
||||||
|
// --- auth ---
|
||||||
|
server.registerTool(
|
||||||
|
"todo_login",
|
||||||
|
{
|
||||||
|
title: "Sign in to Microsoft To Do",
|
||||||
|
description:
|
||||||
|
"Open the system browser to sign in (delegated auth-code + PKCE). Run this once per user; the refresh token is cached and reused. Blocks until sign-in completes.",
|
||||||
|
inputSchema: {},
|
||||||
|
annotations: { readOnlyHint: false, openWorldHint: true },
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
let authorizeUrl;
|
||||||
|
try {
|
||||||
|
const account = await client.login((p) => {
|
||||||
|
authorizeUrl = p.authorizeUrl;
|
||||||
|
console.error(`[ms-todo] Opening browser to sign in. If it didn't open: ${p.authorizeUrl}`);
|
||||||
|
});
|
||||||
|
return ok({ ok: true, signedIn: account });
|
||||||
|
} catch (e) {
|
||||||
|
if (authorizeUrl) {
|
||||||
|
return fail(new Error(`${e.message}\nOpen this URL to sign in manually: ${authorizeUrl}`));
|
||||||
|
}
|
||||||
|
return fail(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"todo_logout",
|
||||||
|
{
|
||||||
|
title: "Sign out",
|
||||||
|
description: "Forget the cached sign-in (deletes the local token cache).",
|
||||||
|
inputSchema: {},
|
||||||
|
annotations: { readOnlyHint: false, destructiveHint: true },
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
return ok({ ok: true, loggedOut: client.logout() });
|
||||||
|
} catch (e) {
|
||||||
|
return fail(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"todo_test",
|
||||||
|
{
|
||||||
|
title: "Test connectivity",
|
||||||
|
description: "Verify the cached sign-in works; returns the signed-in user and their task lists.",
|
||||||
|
inputSchema: {},
|
||||||
|
annotations: { readOnlyHint: true },
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
return ok(await client.test());
|
||||||
|
} catch (e) {
|
||||||
|
return fail(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- task lists ---
|
||||||
|
server.registerTool(
|
||||||
|
"todo_list_lists",
|
||||||
|
{
|
||||||
|
title: "List task lists",
|
||||||
|
description: "Enumerate all of the user's To Do task lists (displayName + id).",
|
||||||
|
inputSchema: {},
|
||||||
|
annotations: { readOnlyHint: true },
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
return ok(await client.listTaskLists());
|
||||||
|
} catch (e) {
|
||||||
|
return fail(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- tasks: read ---
|
||||||
|
server.registerTool(
|
||||||
|
"todo_list_tasks",
|
||||||
|
{
|
||||||
|
title: "Query tasks",
|
||||||
|
description:
|
||||||
|
"List tasks in a list. Filter examples: \"status ne 'completed'\", \"importance eq 'high'\". orderby e.g. \"dueDateTime/dateTime asc\". select is a comma list of properties. Set all=true to fetch every page.",
|
||||||
|
inputSchema: {
|
||||||
|
list: LIST,
|
||||||
|
filter: z.string().optional().describe("OData $filter, e.g. status ne 'completed'"),
|
||||||
|
select: z.string().optional().describe("Comma-separated properties, e.g. title,status,dueDateTime"),
|
||||||
|
orderby: z.string().optional().describe("OData $orderby, e.g. dueDateTime/dateTime asc"),
|
||||||
|
top: z.number().int().positive().optional().describe("Page size / max items"),
|
||||||
|
all: z.boolean().optional().describe("Follow pagination and return all tasks"),
|
||||||
|
},
|
||||||
|
annotations: { readOnlyHint: true },
|
||||||
|
},
|
||||||
|
async ({ list, filter, select, orderby, top, all }) => {
|
||||||
|
try {
|
||||||
|
return ok(await client.listTasks(list, { filter, select, orderby, top, all }));
|
||||||
|
} catch (e) {
|
||||||
|
return fail(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"todo_get_task",
|
||||||
|
{
|
||||||
|
title: "Get one task",
|
||||||
|
description: "Fetch a single task by id.",
|
||||||
|
inputSchema: { list: LIST, taskId: TASK_ID },
|
||||||
|
annotations: { readOnlyHint: true },
|
||||||
|
},
|
||||||
|
async ({ list, taskId }) => {
|
||||||
|
try {
|
||||||
|
return ok(await client.getTask(list, taskId));
|
||||||
|
} catch (e) {
|
||||||
|
return fail(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- checklist: read ---
|
||||||
|
server.registerTool(
|
||||||
|
"todo_list_checklist",
|
||||||
|
{
|
||||||
|
title: "List checklist items",
|
||||||
|
description: "List the checklist items (subtasks) on a task.",
|
||||||
|
inputSchema: { list: LIST, taskId: TASK_ID },
|
||||||
|
annotations: { readOnlyHint: true },
|
||||||
|
},
|
||||||
|
async ({ list, taskId }) => {
|
||||||
|
try {
|
||||||
|
return ok(await client.listChecklistItems(list, taskId));
|
||||||
|
} catch (e) {
|
||||||
|
return fail(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- write tools (skipped entirely when TODO_READONLY) ---
|
||||||
|
if (!READONLY) {
|
||||||
|
server.registerTool(
|
||||||
|
"todo_create_task",
|
||||||
|
{
|
||||||
|
title: "Create a task",
|
||||||
|
description: "Create a task in a list. `title` is required.",
|
||||||
|
inputSchema: { list: LIST, ...TASK_WRITE_FIELDS },
|
||||||
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
||||||
|
},
|
||||||
|
async ({ list, ...rest }) => {
|
||||||
|
try {
|
||||||
|
return ok(await client.createTask(list, buildTaskFields(rest, true)));
|
||||||
|
} catch (e) {
|
||||||
|
return fail(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"todo_update_task",
|
||||||
|
{
|
||||||
|
title: "Update a task",
|
||||||
|
description: "Update a task (partial — only provided properties change).",
|
||||||
|
inputSchema: { list: LIST, taskId: TASK_ID, ...TASK_WRITE_FIELDS },
|
||||||
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
||||||
|
},
|
||||||
|
async ({ list, taskId, ...rest }) => {
|
||||||
|
try {
|
||||||
|
return ok(await client.updateTask(list, taskId, buildTaskFields(rest, false)));
|
||||||
|
} catch (e) {
|
||||||
|
return fail(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"todo_complete_task",
|
||||||
|
{
|
||||||
|
title: "Complete a task",
|
||||||
|
description: "Mark a task completed (shorthand for setting status to completed).",
|
||||||
|
inputSchema: { list: LIST, taskId: TASK_ID },
|
||||||
|
annotations: { readOnlyHint: false, idempotentHint: true },
|
||||||
|
},
|
||||||
|
async ({ list, taskId }) => {
|
||||||
|
try {
|
||||||
|
return ok(await client.completeTask(list, taskId));
|
||||||
|
} catch (e) {
|
||||||
|
return fail(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"todo_delete_task",
|
||||||
|
{
|
||||||
|
title: "Delete a task",
|
||||||
|
description: "Delete a task by id. IRREVERSIBLE — confirm the id with the user first.",
|
||||||
|
inputSchema: { list: LIST, taskId: TASK_ID },
|
||||||
|
annotations: { readOnlyHint: false, destructiveHint: true },
|
||||||
|
},
|
||||||
|
async ({ list, taskId }) => {
|
||||||
|
try {
|
||||||
|
await client.deleteTask(list, taskId);
|
||||||
|
return ok({ ok: true, deleted: taskId });
|
||||||
|
} catch (e) {
|
||||||
|
return fail(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"todo_create_list",
|
||||||
|
{
|
||||||
|
title: "Create a task list",
|
||||||
|
description: "Create a new To Do task list.",
|
||||||
|
inputSchema: { name: z.string().describe("Display name for the new list.") },
|
||||||
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
||||||
|
},
|
||||||
|
async ({ name }) => {
|
||||||
|
try {
|
||||||
|
return ok(await client.createTaskList(name));
|
||||||
|
} catch (e) {
|
||||||
|
return fail(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"todo_update_list",
|
||||||
|
{
|
||||||
|
title: "Rename a task list",
|
||||||
|
description: "Rename an existing task list.",
|
||||||
|
inputSchema: { list: LIST, name: z.string().describe("New display name.") },
|
||||||
|
annotations: { readOnlyHint: false, idempotentHint: true },
|
||||||
|
},
|
||||||
|
async ({ list, name }) => {
|
||||||
|
try {
|
||||||
|
return ok(await client.updateTaskList(list, name));
|
||||||
|
} catch (e) {
|
||||||
|
return fail(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"todo_delete_list",
|
||||||
|
{
|
||||||
|
title: "Delete a task list",
|
||||||
|
description: "Delete a task list AND all of its tasks. IRREVERSIBLE — confirm with the user first.",
|
||||||
|
inputSchema: { list: LIST },
|
||||||
|
annotations: { readOnlyHint: false, destructiveHint: true },
|
||||||
|
},
|
||||||
|
async ({ list }) => {
|
||||||
|
try {
|
||||||
|
await client.deleteTaskList(list);
|
||||||
|
return ok({ ok: true, deletedList: list });
|
||||||
|
} catch (e) {
|
||||||
|
return fail(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"todo_add_checklist_item",
|
||||||
|
{
|
||||||
|
title: "Add a checklist item",
|
||||||
|
description: "Add a checklist item (subtask) to a task.",
|
||||||
|
inputSchema: { list: LIST, taskId: TASK_ID, name: z.string().describe("Subtask text.") },
|
||||||
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
||||||
|
},
|
||||||
|
async ({ list, taskId, name }) => {
|
||||||
|
try {
|
||||||
|
return ok(await client.createChecklistItem(list, taskId, name));
|
||||||
|
} catch (e) {
|
||||||
|
return fail(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"todo_check_checklist_item",
|
||||||
|
{
|
||||||
|
title: "Check/uncheck a checklist item",
|
||||||
|
description: "Mark a checklist item checked or unchecked (and optionally rename it).",
|
||||||
|
inputSchema: {
|
||||||
|
list: LIST,
|
||||||
|
taskId: TASK_ID,
|
||||||
|
itemId: z.string().describe("The checklist item id."),
|
||||||
|
checked: z.boolean().optional().describe("true to check (default), false to uncheck."),
|
||||||
|
name: z.string().optional().describe("Optional new text for the item."),
|
||||||
|
},
|
||||||
|
annotations: { readOnlyHint: false, idempotentHint: true },
|
||||||
|
},
|
||||||
|
async ({ list, taskId, itemId, checked, name }) => {
|
||||||
|
try {
|
||||||
|
return ok(
|
||||||
|
await client.updateChecklistItem(list, taskId, itemId, {
|
||||||
|
isChecked: checked ?? true,
|
||||||
|
displayName: name,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
return fail(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"todo_delete_checklist_item",
|
||||||
|
{
|
||||||
|
title: "Delete a checklist item",
|
||||||
|
description: "Delete a checklist item (subtask) from a task.",
|
||||||
|
inputSchema: { list: LIST, taskId: TASK_ID, itemId: z.string().describe("The checklist item id.") },
|
||||||
|
annotations: { readOnlyHint: false, destructiveHint: true },
|
||||||
|
},
|
||||||
|
async ({ list, taskId, itemId }) => {
|
||||||
|
try {
|
||||||
|
await client.deleteChecklistItem(list, taskId, itemId);
|
||||||
|
return ok({ ok: true, deletedItem: itemId });
|
||||||
|
} catch (e) {
|
||||||
|
return fail(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- run ------------------------------------------------------------------
|
||||||
|
|
||||||
|
await server.connect(new StdioServerTransport());
|
||||||
|
console.error(`[ms-todo] MCP server ready on stdio (${READONLY ? "read-only" : "read/write"}).`);
|
||||||
Reference in New Issue
Block a user