444 lines
14 KiB
JavaScript
444 lines
14 KiB
JavaScript
|
|
#!/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"}).`);
|