Files
claude-ms-todo/mcp-extension/server/index.mjs

444 lines
14 KiB
JavaScript
Raw Normal View History

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