From 2162aac515046de34aaf83b284d7872cb8101f21 Mon Sep 17 00:00:00 2001 From: Spencer McGuire Date: Tue, 16 Jun 2026 17:31:48 -0600 Subject: [PATCH] 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 --- .claude/skills/ms-todo/.env.example | 25 + .claude/skills/ms-todo/.gitignore | 4 + .claude/skills/ms-todo/SKILL.md | 194 +++ .../skills/ms-todo/references/graph-api.md | 112 ++ .claude/skills/ms-todo/references/setup.md | 144 ++ .claude/skills/ms-todo/scripts/lib/graph.mjs | 642 +++++++++ .claude/skills/ms-todo/scripts/todo.mjs | 306 +++++ .gitignore | 11 + README.md | 116 ++ mcp-extension/.gitignore | 9 + mcp-extension/README.md | 109 ++ mcp-extension/manifest.json | 69 + mcp-extension/package-lock.json | 1169 +++++++++++++++++ mcp-extension/package.json | 16 + mcp-extension/scripts/sync-core.mjs | 16 + mcp-extension/server/index.mjs | 443 +++++++ 16 files changed, 3385 insertions(+) create mode 100644 .claude/skills/ms-todo/.env.example create mode 100644 .claude/skills/ms-todo/.gitignore create mode 100644 .claude/skills/ms-todo/SKILL.md create mode 100644 .claude/skills/ms-todo/references/graph-api.md create mode 100644 .claude/skills/ms-todo/references/setup.md create mode 100644 .claude/skills/ms-todo/scripts/lib/graph.mjs create mode 100644 .claude/skills/ms-todo/scripts/todo.mjs create mode 100644 .gitignore create mode 100644 README.md create mode 100644 mcp-extension/.gitignore create mode 100644 mcp-extension/README.md create mode 100644 mcp-extension/manifest.json create mode 100644 mcp-extension/package-lock.json create mode 100644 mcp-extension/package.json create mode 100644 mcp-extension/scripts/sync-core.mjs create mode 100644 mcp-extension/server/index.mjs diff --git a/.claude/skills/ms-todo/.env.example b/.claude/skills/ms-todo/.env.example new file mode 100644 index 0000000..fb0bb5e --- /dev/null +++ b/.claude/skills/ms-todo/.env.example @@ -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: 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 diff --git a/.claude/skills/ms-todo/.gitignore b/.claude/skills/ms-todo/.gitignore new file mode 100644 index 0000000..f084acb --- /dev/null +++ b/.claude/skills/ms-todo/.gitignore @@ -0,0 +1,4 @@ +# Secrets and cached credentials — never commit. +.env +.token-cache.json +scripts/.token-cache.json diff --git a/.claude/skills/ms-todo/SKILL.md b/.claude/skills/ms-todo/SKILL.md new file mode 100644 index 0000000..6ca3403 --- /dev/null +++ b/.claude/skills/ms-todo/SKILL.md @@ -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 [flags] +``` + +> Paths above are relative to this skill's directory. From elsewhere, use the +> absolute path, e.g. `node "/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 "" +``` + +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 "" --status inProgress + +# Complete (shorthand for --status completed) +node scripts/todo.mjs done --list "Work" --id "" + +# Delete +node scripts/todo.mjs delete --list "Work" --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 "" +node scripts/todo.mjs checklist-add --list "Work" --id "" --name "Draft email" +node scripts/todo.mjs checklist-check --list "Work" --id "" --item "" # --uncheck to undo +node scripts/todo.mjs checklist-delete --list "Work" --id "" --item "" +``` + +## 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. diff --git a/.claude/skills/ms-todo/references/graph-api.md b/.claude/skills/ms-todo/references/graph-api.md new file mode 100644 index 0000000..8b8ba79 --- /dev/null +++ b/.claude/skills/ms-todo/references/graph-api.md @@ -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: . + +## 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 "" --name "Draft outline" +node scripts/todo.mjs checklist-check --list "Work" --id "" --item "" # --uncheck to undo +``` + +## Response shape (reads) + +Reads return tasks simplified to: + +```json +{ + "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. diff --git a/.claude/skills/ms-todo/references/setup.md b/.claude/skills/ms-todo/references/setup.md new file mode 100644 index 0000000..231d064 --- /dev/null +++ b/.claude/skills/ms-todo/references/setup.md @@ -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 → **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:` 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:`. +- **`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. diff --git a/.claude/skills/ms-todo/scripts/lib/graph.mjs b/.claude/skills/ms-todo/scripts/lib/graph.mjs new file mode 100644 index 0000000..9208d41 --- /dev/null +++ b/.claude/skills/ms-todo/scripts/lib/graph.mjs @@ -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:). + * @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( + `${title}` + + `

${title}

${msg}

` + + `

You can close this tab and return to the terminal.

`, + ); + }; + + 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; diff --git a/.claude/skills/ms-todo/scripts/todo.mjs b/.claude/skills/ms-todo/scripts/todo.mjs new file mode 100644 index 0000000..887ac4c --- /dev/null +++ b/.claude/skills/ms-todo/scripts/todo.mjs @@ -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). +`, + ); +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a4033e5 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..106f0a6 --- /dev/null +++ b/README.md @@ -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 "" +``` + +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). +``` diff --git a/mcp-extension/.gitignore b/mcp-extension/.gitignore new file mode 100644 index 0000000..73ee02f --- /dev/null +++ b/mcp-extension/.gitignore @@ -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 diff --git a/mcp-extension/README.md b/mcp-extension/README.md new file mode 100644 index 0000000..b65bb81 --- /dev/null +++ b/mcp-extension/README.md @@ -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. diff --git a/mcp-extension/manifest.json b/mcp-extension/manifest.json new file mode 100644 index 0000000..c9e4a92 --- /dev/null +++ b/mcp-extension/manifest.json @@ -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 + } + } +} diff --git a/mcp-extension/package-lock.json b/mcp-extension/package-lock.json new file mode 100644 index 0000000..39cece8 --- /dev/null +++ b/mcp-extension/package-lock.json @@ -0,0 +1,1169 @@ +{ + "name": "ms-todo-mcp-extension", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ms-todo-mcp-extension", + "version": "1.0.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "zod": "^3.23.8" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.25", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", + "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/mcp-extension/package.json b/mcp-extension/package.json new file mode 100644 index 0000000..7eedcff --- /dev/null +++ b/mcp-extension/package.json @@ -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" + } +} diff --git a/mcp-extension/scripts/sync-core.mjs b/mcp-extension/scripts/sync-core.mjs new file mode 100644 index 0000000..497b590 --- /dev/null +++ b/mcp-extension/scripts/sync-core.mjs @@ -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}`); diff --git a/mcp-extension/server/index.mjs b/mcp-extension/server/index.mjs new file mode 100644 index 0000000..cc9411c --- /dev/null +++ b/mcp-extension/server/index.mjs @@ -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"}).`);