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:
443
mcp-extension/server/index.mjs
Normal file
443
mcp-extension/server/index.mjs
Normal 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"}).`);
|
||||
Reference in New Issue
Block a user