2026-06-16 17:31:48 -06:00
#!/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 ---------------------------------------------------------------
2026-07-10 08:01:12 -06:00
const server = new McpServer ( { name : "ms-todo" , version : "1.0.1" } ) ;
2026-06-16 17:31:48 -06:00
// --- 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 } \n Open 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" } ). ` ) ;