Initial commit: Microsoft To Do for Claude (skill + .mcpb extension)

Claude Code skill (CLI over Microsoft Graph, delegated auth-code + PKCE) and a
Claude Desktop .mcpb extension sharing one dependency-free core client.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 17:31:48 -06:00
commit 2162aac515
16 changed files with 3385 additions and 0 deletions

9
mcp-extension/.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
# Generated / bundled artifacts
node_modules/
# synced from the skill by scripts/sync-core.mjs
server/lib/graph.mjs
*.mcpb
# Signing material — NEVER commit the private key
key.pem
cert.pem

109
mcp-extension/README.md Normal file
View File

@@ -0,0 +1,109 @@
# Microsoft To Do — Claude Desktop extension (.mcpb)
A one-click Claude Desktop extension that connects Claude to **Microsoft To Do**.
It wraps the same core Graph client as the [`ms-todo` skill](../.claude/skills/ms-todo)
(`MSTodoClient`) and exposes it as MCP tools over stdio. Each user signs in as
**themselves** (delegated, browser auth-code + PKCE); their refresh token is
cached privately on their own machine.
The built artifact is **`ms-todo.mcpb`**.
---
## For end users — install in Claude Desktop
1. Get `ms-todo.mcpb` from your admin.
2. Open **Claude Desktop → Settings → Extensions**.
3. Drag `ms-todo.mcpb` in (or click **Install extension** and select it).
4. Optionally set your **Time zone** in the extension's settings (used for due /
start / reminder dates; default UTC). Leave **Read-only mode** off for full
access.
5. In a chat, say: **"Sign in to Microsoft To Do."** Claude runs the `todo_login`
tool, your browser opens to the Microsoft sign-in page, you approve, and you're
connected. (You only do this once.)
6. Try it: *"What's on my To Do list?"*, *"Add 'call dentist' due Friday to Tasks."*
> If Claude Desktop warns that the extension is **not verified/signed**, that's
> expected for an internally distributed extension — install it anyway (or have
> your admin sign it, see below).
### What it can do (tools)
`todo_login`, `todo_logout`, `todo_test`, `todo_list_lists`, `todo_list_tasks`,
`todo_get_task`, `todo_create_task`, `todo_update_task`, `todo_complete_task`,
`todo_delete_task`, `todo_create_list`, `todo_update_list`, `todo_delete_list`,
`todo_list_checklist`, `todo_add_checklist_item`, `todo_check_checklist_item`,
`todo_delete_checklist_item`.
---
## For the admin — one-time Entra setup (org-wide)
The extension ships with a **baked-in** public-client app id and tenant id (in
`manifest.json``server.mcp_config.env`). There is **no client secret** — these
identifiers are not secrets. For smooth org-wide use, in
[entra.microsoft.com](https://entra.microsoft.com) on that app registration:
1. **Authentication → Mobile and desktop applications** — ensure redirect URIs
`http://localhost` **and** `http://127.0.0.1` are present (the extension uses a
loopback redirect during sign-in). *Already configured for the current app.*
2. **API permissions** — delegated `Tasks.ReadWrite`, `offline_access`,
`User.Read`. Click **Grant admin consent for &lt;tenant&gt;** 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.

View File

@@ -0,0 +1,69 @@
{
"manifest_version": "0.3",
"name": "ms-todo",
"display_name": "Microsoft To Do",
"version": "1.0.0",
"description": "Read and write your Microsoft To Do tasks, lists, and subtasks from Claude.",
"long_description": "Connects Claude to Microsoft To Do via the Microsoft Graph API. You sign in once as yourself (a browser window opens); the connection then reads and writes your task lists, tasks, and checklist items. Each user's sign-in is private to their own machine.",
"author": {
"name": "PESCO Inc."
},
"keywords": ["microsoft", "to do", "todo", "tasks", "graph", "productivity"],
"license": "MIT",
"compatibility": {
"platforms": ["darwin", "win32", "linux"],
"runtimes": {
"node": ">=18.0.0"
}
},
"server": {
"type": "node",
"entry_point": "server/index.mjs",
"mcp_config": {
"command": "node",
"args": ["${__dirname}/server/index.mjs"],
"env": {
"TODO_CLIENT_ID": "9af4a8a3-5290-4089-9058-a29dcce63c4f",
"TODO_TENANT_ID": "94c6c62d-8fe7-416f-8fef-7d8620b95819",
"TODO_TOKEN_CACHE": "${HOME}/.ms-todo/token-cache.json",
"TODO_TIMEZONE": "${user_config.timezone}",
"TODO_READONLY": "${user_config.read_only}"
}
}
},
"tools": [
{ "name": "todo_login", "description": "Sign in (opens your browser)" },
{ "name": "todo_logout", "description": "Forget the cached sign-in" },
{ "name": "todo_test", "description": "Verify connectivity and show your lists" },
{ "name": "todo_list_lists", "description": "List your task lists" },
{ "name": "todo_list_tasks", "description": "Query tasks in a list" },
{ "name": "todo_get_task", "description": "Get one task" },
{ "name": "todo_create_task", "description": "Create a task" },
{ "name": "todo_update_task", "description": "Update a task" },
{ "name": "todo_complete_task", "description": "Mark a task complete" },
{ "name": "todo_delete_task", "description": "Delete a task" },
{ "name": "todo_create_list", "description": "Create a task list" },
{ "name": "todo_update_list", "description": "Rename a task list" },
{ "name": "todo_delete_list", "description": "Delete a task list" },
{ "name": "todo_list_checklist", "description": "List checklist items (subtasks)" },
{ "name": "todo_add_checklist_item", "description": "Add a checklist item" },
{ "name": "todo_check_checklist_item", "description": "Check/uncheck a checklist item" },
{ "name": "todo_delete_checklist_item", "description": "Delete a checklist item" }
],
"user_config": {
"timezone": {
"type": "string",
"title": "Time zone",
"description": "Time zone for due/start/reminder dates (IANA like America/Denver, or a Windows zone name). Default UTC.",
"default": "UTC",
"required": false
},
"read_only": {
"type": "boolean",
"title": "Read-only mode",
"description": "If enabled, only viewing tools are available (no create/update/delete).",
"default": false,
"required": false
}
}
}

1169
mcp-extension/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
{
"name": "ms-todo-mcp-extension",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Microsoft To Do MCP server, packaged as a Claude Desktop .mcpb extension",
"scripts": {
"sync": "node scripts/sync-core.mjs",
"start": "node server/index.mjs",
"pack": "npm run sync && npm ci --omit=dev && npx -y @anthropic-ai/mcpb pack . ms-todo.mcpb"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.0",
"zod": "^3.23.8"
}
}

View File

@@ -0,0 +1,16 @@
#!/usr/bin/env node
// Copy the canonical core client into the extension so the bundle is
// self-contained. The single source of truth lives in the skill; this keeps the
// extension's copy (server/lib/graph.mjs) in sync. Run before packing.
import { copyFileSync, mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const src = join(here, "..", "..", ".claude", "skills", "ms-todo", "scripts", "lib", "graph.mjs");
const dest = join(here, "..", "server", "lib", "graph.mjs");
mkdirSync(dirname(dest), { recursive: true });
copyFileSync(src, dest);
console.log(`Synced core client:\n ${src}\n -> ${dest}`);

View File

@@ -0,0 +1,443 @@
#!/usr/bin/env node
// Microsoft To Do MCP server (stdio) — packaged as a Claude Desktop .mcpb extension.
//
// Wraps the SAME core client the skill uses (lib/graph.mjs) and exposes it as MCP
// tools. Auth is DELEGATED (browser auth-code + PKCE): each user signs in as
// themselves via the `todo_login` tool, which opens their browser. The refresh
// token is cached per-user on disk so later sessions refresh silently.
//
// Config comes from the environment (the .mcpb manifest sets these):
// TODO_CLIENT_ID (required) public-client app id — baked into the manifest
// TODO_TENANT_ID (optional) tenant id / "common" — baked into the manifest
// TODO_TOKEN_CACHE (optional) path to the per-user token cache file
// TODO_TIMEZONE (optional) zone for due/start/reminder dates (default UTC)
// TODO_READONLY (optional) "true"/"1" -> register only read tools
import { homedir } from "node:os";
import { mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
// Single source of truth — synced from the skill's scripts/lib/graph.mjs.
import { MSTodoClient, GraphError, AuthRequiredError, toDateTimeTimeZone } from "./lib/graph.mjs";
const READONLY = /^(1|true|yes)$/i.test(process.env.TODO_READONLY ?? "");
const TIMEZONE = process.env.TODO_TIMEZONE || "UTC";
const tokenCachePath = process.env.TODO_TOKEN_CACHE || join(homedir(), ".ms-todo", "token-cache.json");
try {
mkdirSync(dirname(tokenCachePath), { recursive: true });
} catch {
/* best-effort; the client will surface a clear error if it can't write */
}
const client = new MSTodoClient({
clientId: process.env.TODO_CLIENT_ID,
tenantId: process.env.TODO_TENANT_ID || "common",
tokenCachePath,
});
// ---- helpers --------------------------------------------------------------
const ok = (data) => ({
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
});
const fail = (err) => ({
content: [
{
type: "text",
text:
err instanceof AuthRequiredError
? `${err.message} (use the "todo_login" tool to sign in)`
: err instanceof GraphError
? `Graph error (${err.status}${err.code ? " " + err.code : ""}): ${err.message}`
: `Error: ${err.message}`,
},
],
isError: true,
});
/** Build a todoTask body from friendly params + an optional raw fields object. */
function buildTaskFields(
{ title, body, importance, status, due, start, reminder, categories, extraFields },
requireTitle,
) {
const fields = {};
if (title !== undefined) fields.title = title;
if (importance !== undefined) fields.importance = importance;
if (status !== undefined) fields.status = status;
if (body !== undefined) fields.body = { content: body, contentType: "text" };
if (due !== undefined) fields.dueDateTime = toDateTimeTimeZone(due, TIMEZONE);
if (start !== undefined) fields.startDateTime = toDateTimeTimeZone(start, TIMEZONE);
if (reminder !== undefined) {
fields.reminderDateTime = toDateTimeTimeZone(reminder, TIMEZONE);
fields.isReminderOn = true;
}
if (categories !== undefined) fields.categories = categories;
if (extraFields && typeof extraFields === "object") Object.assign(fields, extraFields);
if (requireTitle && !fields.title) throw new Error("A task `title` is required.");
return fields;
}
// Shared schema fragments
const LIST = z.string().describe("Task list display name (e.g. \"Tasks\") or its opaque list id.");
const TASK_ID = z.string().describe("The task id (from todo_list_tasks / todo_get_task).");
const IMPORTANCE = z.enum(["low", "normal", "high"]).optional();
const STATUS = z
.enum(["notStarted", "inProgress", "completed", "waitingOnOthers", "deferred"])
.optional();
const DATEISH = z
.string()
.optional()
.describe('Date like "2026-07-01" or "2026-07-01T17:00:00" (uses the configured time zone).');
const TASK_WRITE_FIELDS = {
title: z.string().optional().describe("Task title."),
body: z.string().optional().describe("Note/body text."),
importance: IMPORTANCE,
status: STATUS,
due: DATEISH,
start: DATEISH,
reminder: DATEISH.describe("Reminder date/time; also turns the reminder on."),
categories: z.array(z.string()).optional().describe("Category names (must exist in the user's Outlook categories)."),
extraFields: z
.record(z.any())
.optional()
.describe("Raw todoTask properties merged last, for anything not covered above (e.g. recurrence)."),
};
// ---- server ---------------------------------------------------------------
const server = new McpServer({ name: "ms-todo", version: "1.0.0" });
// --- auth ---
server.registerTool(
"todo_login",
{
title: "Sign in to Microsoft To Do",
description:
"Open the system browser to sign in (delegated auth-code + PKCE). Run this once per user; the refresh token is cached and reused. Blocks until sign-in completes.",
inputSchema: {},
annotations: { readOnlyHint: false, openWorldHint: true },
},
async () => {
let authorizeUrl;
try {
const account = await client.login((p) => {
authorizeUrl = p.authorizeUrl;
console.error(`[ms-todo] Opening browser to sign in. If it didn't open: ${p.authorizeUrl}`);
});
return ok({ ok: true, signedIn: account });
} catch (e) {
if (authorizeUrl) {
return fail(new Error(`${e.message}\nOpen this URL to sign in manually: ${authorizeUrl}`));
}
return fail(e);
}
},
);
server.registerTool(
"todo_logout",
{
title: "Sign out",
description: "Forget the cached sign-in (deletes the local token cache).",
inputSchema: {},
annotations: { readOnlyHint: false, destructiveHint: true },
},
async () => {
try {
return ok({ ok: true, loggedOut: client.logout() });
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"todo_test",
{
title: "Test connectivity",
description: "Verify the cached sign-in works; returns the signed-in user and their task lists.",
inputSchema: {},
annotations: { readOnlyHint: true },
},
async () => {
try {
return ok(await client.test());
} catch (e) {
return fail(e);
}
},
);
// --- task lists ---
server.registerTool(
"todo_list_lists",
{
title: "List task lists",
description: "Enumerate all of the user's To Do task lists (displayName + id).",
inputSchema: {},
annotations: { readOnlyHint: true },
},
async () => {
try {
return ok(await client.listTaskLists());
} catch (e) {
return fail(e);
}
},
);
// --- tasks: read ---
server.registerTool(
"todo_list_tasks",
{
title: "Query tasks",
description:
"List tasks in a list. Filter examples: \"status ne 'completed'\", \"importance eq 'high'\". orderby e.g. \"dueDateTime/dateTime asc\". select is a comma list of properties. Set all=true to fetch every page.",
inputSchema: {
list: LIST,
filter: z.string().optional().describe("OData $filter, e.g. status ne 'completed'"),
select: z.string().optional().describe("Comma-separated properties, e.g. title,status,dueDateTime"),
orderby: z.string().optional().describe("OData $orderby, e.g. dueDateTime/dateTime asc"),
top: z.number().int().positive().optional().describe("Page size / max items"),
all: z.boolean().optional().describe("Follow pagination and return all tasks"),
},
annotations: { readOnlyHint: true },
},
async ({ list, filter, select, orderby, top, all }) => {
try {
return ok(await client.listTasks(list, { filter, select, orderby, top, all }));
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"todo_get_task",
{
title: "Get one task",
description: "Fetch a single task by id.",
inputSchema: { list: LIST, taskId: TASK_ID },
annotations: { readOnlyHint: true },
},
async ({ list, taskId }) => {
try {
return ok(await client.getTask(list, taskId));
} catch (e) {
return fail(e);
}
},
);
// --- checklist: read ---
server.registerTool(
"todo_list_checklist",
{
title: "List checklist items",
description: "List the checklist items (subtasks) on a task.",
inputSchema: { list: LIST, taskId: TASK_ID },
annotations: { readOnlyHint: true },
},
async ({ list, taskId }) => {
try {
return ok(await client.listChecklistItems(list, taskId));
} catch (e) {
return fail(e);
}
},
);
// --- write tools (skipped entirely when TODO_READONLY) ---
if (!READONLY) {
server.registerTool(
"todo_create_task",
{
title: "Create a task",
description: "Create a task in a list. `title` is required.",
inputSchema: { list: LIST, ...TASK_WRITE_FIELDS },
annotations: { readOnlyHint: false, destructiveHint: false },
},
async ({ list, ...rest }) => {
try {
return ok(await client.createTask(list, buildTaskFields(rest, true)));
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"todo_update_task",
{
title: "Update a task",
description: "Update a task (partial — only provided properties change).",
inputSchema: { list: LIST, taskId: TASK_ID, ...TASK_WRITE_FIELDS },
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
},
async ({ list, taskId, ...rest }) => {
try {
return ok(await client.updateTask(list, taskId, buildTaskFields(rest, false)));
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"todo_complete_task",
{
title: "Complete a task",
description: "Mark a task completed (shorthand for setting status to completed).",
inputSchema: { list: LIST, taskId: TASK_ID },
annotations: { readOnlyHint: false, idempotentHint: true },
},
async ({ list, taskId }) => {
try {
return ok(await client.completeTask(list, taskId));
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"todo_delete_task",
{
title: "Delete a task",
description: "Delete a task by id. IRREVERSIBLE — confirm the id with the user first.",
inputSchema: { list: LIST, taskId: TASK_ID },
annotations: { readOnlyHint: false, destructiveHint: true },
},
async ({ list, taskId }) => {
try {
await client.deleteTask(list, taskId);
return ok({ ok: true, deleted: taskId });
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"todo_create_list",
{
title: "Create a task list",
description: "Create a new To Do task list.",
inputSchema: { name: z.string().describe("Display name for the new list.") },
annotations: { readOnlyHint: false, destructiveHint: false },
},
async ({ name }) => {
try {
return ok(await client.createTaskList(name));
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"todo_update_list",
{
title: "Rename a task list",
description: "Rename an existing task list.",
inputSchema: { list: LIST, name: z.string().describe("New display name.") },
annotations: { readOnlyHint: false, idempotentHint: true },
},
async ({ list, name }) => {
try {
return ok(await client.updateTaskList(list, name));
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"todo_delete_list",
{
title: "Delete a task list",
description: "Delete a task list AND all of its tasks. IRREVERSIBLE — confirm with the user first.",
inputSchema: { list: LIST },
annotations: { readOnlyHint: false, destructiveHint: true },
},
async ({ list }) => {
try {
await client.deleteTaskList(list);
return ok({ ok: true, deletedList: list });
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"todo_add_checklist_item",
{
title: "Add a checklist item",
description: "Add a checklist item (subtask) to a task.",
inputSchema: { list: LIST, taskId: TASK_ID, name: z.string().describe("Subtask text.") },
annotations: { readOnlyHint: false, destructiveHint: false },
},
async ({ list, taskId, name }) => {
try {
return ok(await client.createChecklistItem(list, taskId, name));
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"todo_check_checklist_item",
{
title: "Check/uncheck a checklist item",
description: "Mark a checklist item checked or unchecked (and optionally rename it).",
inputSchema: {
list: LIST,
taskId: TASK_ID,
itemId: z.string().describe("The checklist item id."),
checked: z.boolean().optional().describe("true to check (default), false to uncheck."),
name: z.string().optional().describe("Optional new text for the item."),
},
annotations: { readOnlyHint: false, idempotentHint: true },
},
async ({ list, taskId, itemId, checked, name }) => {
try {
return ok(
await client.updateChecklistItem(list, taskId, itemId, {
isChecked: checked ?? true,
displayName: name,
}),
);
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"todo_delete_checklist_item",
{
title: "Delete a checklist item",
description: "Delete a checklist item (subtask) from a task.",
inputSchema: { list: LIST, taskId: TASK_ID, itemId: z.string().describe("The checklist item id.") },
annotations: { readOnlyHint: false, destructiveHint: true },
},
async ({ list, taskId, itemId }) => {
try {
await client.deleteChecklistItem(list, taskId, itemId);
return ok({ ok: true, deletedItem: itemId });
} catch (e) {
return fail(e);
}
},
);
}
// ---- run ------------------------------------------------------------------
await server.connect(new StdioServerTransport());
console.error(`[ms-todo] MCP server ready on stdio (${READONLY ? "read-only" : "read/write"}).`);