#!/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"}).`);