@softeria/ms-365-mcp-server 0.162.0 → 0.164.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -695,6 +695,8 @@ When running as an MCP server, the following options can be used:
695
695
  --http-local-file-tools Register download-bytes-to-file over HTTP. Anyone who can reach the port
696
696
  can write files as the server's user (without a valid token, only an empty
697
697
  file that is removed again), so enable it only on a single-user machine.
698
+ With MS365_MCP_UPLOAD_DIRS set it also registers upload-attachment-from-file,
699
+ which reads files from those directories.
698
700
  Refused unless --http binds a loopback host with no --public-url and no
699
701
  --trust-proxy-auth
700
702
  --enable-auth-tools Enable login/logout tools when using HTTP mode (disabled by default in HTTP mode)
@@ -735,7 +737,8 @@ Environment variables:
735
737
  - `MS365_MCP_TRUST_PROXY_HOPS=<n>`: Number of trusted reverse-proxy hops in HTTP mode (default `1`). Accurate per-IP rate limiting depends on this matching your deployment — set to the number of proxies in front of the server, `0` to use the raw socket peer IP, or a comma-separated subnet list
736
738
  - `MS365_MCP_ATTACHMENT_PORT=<port>`: Serve the attachment route on its own listener on this port (alternative to --attachment-port; requires `--enable-attachment-urls`)
737
739
  - `MS365_MCP_ATTACHMENT_HOST=<host>`: Interface the `MS365_MCP_ATTACHMENT_PORT` listener binds (alternative to --attachment-host; requires `--attachment-port`). Defaults to the host `--http` bound — which for a wildcard `--http` means both ports answer everywhere and the port split isolates nothing. See "Splitting the attachment listener"
738
- - `MS365_MCP_HTTP_LOCAL_FILE_TOOLS=true|1`: Register download-bytes-to-file over HTTP (alternative to --http-local-file-tools; same restrictions)
740
+ - `MS365_MCP_HTTP_LOCAL_FILE_TOOLS=true|1`: Register download-bytes-to-file, and upload-attachment-from-file when `MS365_MCP_UPLOAD_DIRS` is set, over HTTP (alternative to --http-local-file-tools; same restrictions)
741
+ - `MS365_MCP_UPLOAD_DIRS=<dirs>`: Directories `upload-attachment-from-file` may read from, as absolute paths separated like `PATH`. Unset (the default) leaves the tool unregistered. See "Attaching a local file in stdio mode"
739
742
  - `MS365_MCP_CLOUD_TYPE=global|china`: Microsoft cloud environment (alternative to --cloud flag)
740
743
  - `MS365_MCP_GRAPH_BASE_URL=<url>`: Send Graph API requests to this base URL instead of the cloud's `graph.microsoft.com` (e.g. `http://127.0.0.1:10255/tenant-a/graph` for an egress proxy that originates TLS itself). Absolute http(s) URL without query or fragment; a path prefix is kept, so requests go to `<url>/v1.0/...`. The login authority and the On-Behalf-Of token resource are unchanged. Two things bypass it: `get-download-url` returns Graph's pre-authenticated SharePoint URL, and the `302` from `/content` points straight at SharePoint, so whoever follows either connects to SharePoint directly; if you control egress, allow those SharePoint hosts or disable those tools. Not read from `.env`
741
744
  - `LOG_LEVEL`: Set logging level (default: 'info')
@@ -805,6 +808,26 @@ whatever the outcome, and the upload is attached as the identity that minted it,
805
808
  downloads are fetched. An upload ticket presented to `GET` (or a download ticket to `PUT`)
806
809
  is refused with the same 404 as any other bad ticket.
807
810
 
811
+ ### Attaching a local file in stdio mode
812
+
813
+ In stdio mode the server and the files are on the same machine, so no URL is needed.
814
+ `upload-attachment-from-file` takes the same `target` and an absolute `filePath`, reads the
815
+ file from disk and attaches it, with the same 3 MB and 150 MB limits. It is the upload
816
+ counterpart of `download-bytes-to-file`.
817
+
818
+ The tool is off until you list the directories it may read from:
819
+
820
+ ```
821
+ MS365_MCP_UPLOAD_DIRS=/Users/me/Documents/invitations:/Users/me/Scans
822
+ ```
823
+
824
+ Entries are absolute paths, separated like `PATH` (`:` on macOS and Linux, `;` on Windows).
825
+ A file outside them is refused, and symlinks are resolved first, so a link inside an allowed
826
+ directory cannot point the read somewhere else. The variable is not read from `.env`.
827
+
828
+ Over HTTP the tool is registered only with `--http-local-file-tools`, like
829
+ `download-bytes-to-file`.
830
+
808
831
  ### Configuration
809
832
 
810
833
  ```
@@ -1,4 +1,12 @@
1
- import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
1
+ import {
2
+ mkdirSync,
3
+ mkdtempSync,
4
+ readFileSync,
5
+ realpathSync,
6
+ rmSync,
7
+ symlinkSync,
8
+ writeFileSync
9
+ } from "fs";
2
10
  import { tmpdir } from "os";
3
11
  import { join } from "path";
4
12
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -2423,6 +2431,148 @@ describe("graph-tools", () => {
2423
2431
  expect(httpServer.tools.has("download-bytes")).toBe(true);
2424
2432
  });
2425
2433
  });
2434
+ describe("upload-attachment-from-file", () => {
2435
+ const TOOL = "upload-attachment-from-file";
2436
+ const TARGET = "/me/events/evt1/attachments";
2437
+ let tmpDir;
2438
+ let allowed;
2439
+ beforeEach(() => {
2440
+ mockEndpoints.length = 0;
2441
+ mockEndpointsJson = [];
2442
+ tmpDir = realpathSync(mkdtempSync(join(tmpdir(), "uaff-")));
2443
+ allowed = join(tmpDir, "allowed");
2444
+ mkdirSync(allowed);
2445
+ process.env.MS365_MCP_UPLOAD_DIRS = allowed;
2446
+ });
2447
+ afterEach(() => {
2448
+ delete process.env.MS365_MCP_UPLOAD_DIRS;
2449
+ vi.unstubAllGlobals();
2450
+ rmSync(tmpDir, { recursive: true, force: true });
2451
+ });
2452
+ async function register(graphClient, options) {
2453
+ const server = createMockServer();
2454
+ const { registerGraphTools } = await loadModule();
2455
+ registerGraphTools(server, graphClient, options);
2456
+ return server;
2457
+ }
2458
+ it("is not registered unless MS365_MCP_UPLOAD_DIRS is set", async () => {
2459
+ delete process.env.MS365_MCP_UPLOAD_DIRS;
2460
+ const server = await register({});
2461
+ expect(server.tools.has(TOOL)).toBe(false);
2462
+ expect(server.tools.has("download-bytes-to-file")).toBe(true);
2463
+ });
2464
+ it("is not registered in read-only mode or over HTTP", async () => {
2465
+ expect((await register({}, { readOnly: true })).tools.has(TOOL)).toBe(false);
2466
+ expect((await register({}, { httpMode: true })).tools.has(TOOL)).toBe(false);
2467
+ });
2468
+ it("attaches a small file inline, named after the file", async () => {
2469
+ const bytes = Buffer.from([37, 80, 68, 70, 0, 255]);
2470
+ writeFileSync(join(allowed, "invitation.pdf"), bytes);
2471
+ const graphClient = { makeRequest: vi.fn().mockResolvedValue({ id: "att1" }) };
2472
+ const server = await register(graphClient);
2473
+ const result = await server.tools.get(TOOL).handler({ target: TARGET, filePath: join(allowed, "invitation.pdf") });
2474
+ expect(result.isError).toBeUndefined();
2475
+ expect(JSON.parse(result.content[0].text)).toEqual({
2476
+ name: "invitation.pdf",
2477
+ size: bytes.length,
2478
+ id: "att1"
2479
+ });
2480
+ expect(graphClient.makeRequest).toHaveBeenCalledTimes(1);
2481
+ const [reqPath, options] = graphClient.makeRequest.mock.calls[0];
2482
+ expect(reqPath).toBe(TARGET);
2483
+ expect(options.method).toBe("POST");
2484
+ expect(JSON.parse(options.body)).toEqual({
2485
+ "@odata.type": "#microsoft.graph.fileAttachment",
2486
+ name: "invitation.pdf",
2487
+ contentType: "application/octet-stream",
2488
+ contentBytes: bytes.toString("base64")
2489
+ });
2490
+ expect(auditLogMock).toHaveBeenCalledWith(
2491
+ expect.objectContaining({ event: "tool.call", tool: TOOL, status: "success" })
2492
+ );
2493
+ });
2494
+ it("uses the given name and content type", async () => {
2495
+ writeFileSync(join(allowed, "scan-0042.bin"), "x");
2496
+ const graphClient = { makeRequest: vi.fn().mockResolvedValue({ id: "att1" }) };
2497
+ const server = await register(graphClient);
2498
+ await server.tools.get(TOOL).handler({
2499
+ target: "/me/messages/msg1/attachments",
2500
+ filePath: join(allowed, "scan-0042.bin"),
2501
+ name: "flyer.pdf",
2502
+ contentType: "application/pdf"
2503
+ });
2504
+ const body = JSON.parse(graphClient.makeRequest.mock.calls[0][1].body);
2505
+ expect(body.name).toBe("flyer.pdf");
2506
+ expect(body.contentType).toBe("application/pdf");
2507
+ });
2508
+ it("sends a file of 3 MB or more through an upload session in chunks", async () => {
2509
+ const size = 4 * 1024 * 1024 + 123;
2510
+ const bytes = Buffer.alloc(size, 7);
2511
+ writeFileSync(join(allowed, "big.bin"), bytes);
2512
+ const graphClient = {
2513
+ makeRequest: vi.fn().mockResolvedValue({ uploadUrl: "https://upload.example/session" })
2514
+ };
2515
+ const ranges = [];
2516
+ let uploaded = 0;
2517
+ vi.stubGlobal(
2518
+ "fetch",
2519
+ vi.fn(
2520
+ async (_url, init) => {
2521
+ ranges.push(init.headers["Content-Range"]);
2522
+ uploaded += init.body.byteLength;
2523
+ return new Response(null, { status: 202 });
2524
+ }
2525
+ )
2526
+ );
2527
+ const server = await register(graphClient);
2528
+ const result = await server.tools.get(TOOL).handler({ target: TARGET, filePath: join(allowed, "big.bin") });
2529
+ expect(result.isError).toBeUndefined();
2530
+ expect(JSON.parse(result.content[0].text)).toEqual({ name: "big.bin", size });
2531
+ const [reqPath, options] = graphClient.makeRequest.mock.calls[0];
2532
+ expect(reqPath).toBe(`${TARGET}/createUploadSession`);
2533
+ expect(JSON.parse(options.body).AttachmentItem).toMatchObject({ name: "big.bin", size });
2534
+ const chunk = 320 * 1024 * 12;
2535
+ expect(ranges).toEqual([
2536
+ `bytes 0-${chunk - 1}/${size}`,
2537
+ `bytes ${chunk}-${size - 1}/${size}`
2538
+ ]);
2539
+ expect(uploaded).toBe(size);
2540
+ });
2541
+ it("refuses a file outside the allowed directories, also through a symlink", async () => {
2542
+ writeFileSync(join(tmpDir, "secret.txt"), "secret");
2543
+ symlinkSync(join(tmpDir, "secret.txt"), join(allowed, "link.txt"));
2544
+ const graphClient = { makeRequest: vi.fn() };
2545
+ const server = await register(graphClient);
2546
+ for (const filePath of [join(tmpDir, "secret.txt"), join(allowed, "link.txt")]) {
2547
+ const result = await server.tools.get(TOOL).handler({ target: TARGET, filePath });
2548
+ expect(result.isError).toBe(true);
2549
+ expect(JSON.parse(result.content[0].text).error).toMatch(/does not exist or is outside/);
2550
+ }
2551
+ expect(graphClient.makeRequest).not.toHaveBeenCalled();
2552
+ });
2553
+ it("refuses an empty file and a target that is not an attachments collection", async () => {
2554
+ writeFileSync(join(allowed, "empty.txt"), "");
2555
+ writeFileSync(join(allowed, "a.txt"), "a");
2556
+ const graphClient = { makeRequest: vi.fn() };
2557
+ const server = await register(graphClient);
2558
+ const call = async (args) => JSON.parse((await server.tools.get(TOOL).handler(args)).content[0].text).error;
2559
+ expect(await call({ target: TARGET, filePath: join(allowed, "empty.txt") })).toMatch(
2560
+ /file is empty/
2561
+ );
2562
+ expect(
2563
+ await call({ target: "/me/drive/root:/a.txt:/content", filePath: join(allowed, "a.txt") })
2564
+ ).toMatch(/attachments collection/);
2565
+ expect(graphClient.makeRequest).not.toHaveBeenCalled();
2566
+ });
2567
+ it("answers a Graph failure as a tool error", async () => {
2568
+ writeFileSync(join(allowed, "a.txt"), "a");
2569
+ const graphClient = { makeRequest: vi.fn().mockRejectedValue(new Error("Graph said no")) };
2570
+ const server = await register(graphClient);
2571
+ const result = await server.tools.get(TOOL).handler({ target: TARGET, filePath: join(allowed, "a.txt") });
2572
+ expect(result.isError).toBe(true);
2573
+ expect(JSON.parse(result.content[0].text).error).toBe("Graph said no");
2574
+ });
2575
+ });
2426
2576
  describe("get-download-url", () => {
2427
2577
  it("strips /content, fetches item metadata, and returns the pre-authed downloadUrl", async () => {
2428
2578
  mockEndpoints.length = 0;
@@ -5,6 +5,7 @@ import {
5
5
  isPlainGraphPath,
6
6
  TICKET_PARAM
7
7
  } from "./lib/attachment-tickets.js";
8
+ import { MAX_UPLOAD_BYTES, uploadAttachment } from "./lib/attachment-upload.js";
8
9
  const NOT_FOUND_BODY = "Not found";
9
10
  function forceAttachment(header) {
10
11
  if (!header) return "attachment";
@@ -104,26 +105,6 @@ function createAttachmentHandler(deps) {
104
105
  }
105
106
  };
106
107
  }
107
- const MAX_UPLOAD_BYTES = 150 * 1024 * 1024;
108
- const INLINE_UPLOAD_LIMIT = 3 * 1024 * 1024;
109
- const UPLOAD_CHUNK_BYTES = 320 * 1024 * 12;
110
- async function putChunk(uploadUrl, chunk, start, total) {
111
- const end = start + chunk.length - 1;
112
- const response = await fetch(uploadUrl, {
113
- method: "PUT",
114
- headers: {
115
- "Content-Type": "application/octet-stream",
116
- "Content-Length": String(chunk.length),
117
- "Content-Range": `bytes ${start}-${end}/${total}`
118
- },
119
- // A standalone ArrayBuffer: fetch's body typing wants a BufferSource, and a
120
- // Buffer view over a pooled slab is neither the right type nor the right bytes.
121
- body: chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength)
122
- });
123
- if (![200, 201, 202].includes(response.status)) {
124
- throw new Error(`Upload session chunk ${start}-${end} answered HTTP ${response.status}`);
125
- }
126
- }
127
108
  function createAttachmentUploadHandler(deps) {
128
109
  return async (req, res) => {
129
110
  if (req.method !== "PUT") {
@@ -177,64 +158,18 @@ function createAttachmentUploadHandler(deps) {
177
158
  res.setHeader("cache-control", "no-store");
178
159
  res.setHeader("x-content-type-options", "nosniff");
179
160
  try {
180
- if (declared < INLINE_UPLOAD_LIMIT) {
181
- const chunks = [];
182
- let received = 0;
183
- for await (const chunk of req) {
184
- received += chunk.length;
185
- if (received > declared) throw new Error("Body exceeds Content-Length");
186
- chunks.push(chunk);
187
- }
188
- if (received !== declared) throw new Error("Body shorter than Content-Length");
189
- const created = await graphClient.makeRequest(ticket.target, {
190
- method: "POST",
191
- accessToken,
192
- body: JSON.stringify({
193
- "@odata.type": "#microsoft.graph.fileAttachment",
194
- name: ticket.name,
195
- contentType: ticket.contentType,
196
- contentBytes: Buffer.concat(chunks).toString("base64")
197
- })
198
- });
199
- res.status(201).json({ ok: true, name: ticket.name, size: declared, id: created?.id });
200
- return;
201
- }
202
- const session = await graphClient.makeRequest(`${ticket.target}/createUploadSession`, {
203
- method: "POST",
204
- accessToken,
205
- body: JSON.stringify({
206
- AttachmentItem: {
207
- attachmentType: "file",
208
- name: ticket.name,
209
- size: declared,
210
- contentType: ticket.contentType
211
- }
212
- })
213
- });
214
- if (!session?.uploadUrl) throw new Error("Upload session returned no uploadUrl");
215
- let pending = [];
216
- let pendingBytes = 0;
217
- let offset = 0;
218
- for await (const piece of req) {
219
- pending.push(piece);
220
- pendingBytes += piece.length;
221
- if (offset + pendingBytes > declared) throw new Error("Body exceeds Content-Length");
222
- while (pendingBytes >= UPLOAD_CHUNK_BYTES) {
223
- const joined = Buffer.concat(pending);
224
- const chunk = joined.subarray(0, UPLOAD_CHUNK_BYTES);
225
- await putChunk(session.uploadUrl, chunk, offset, declared);
226
- offset += chunk.length;
227
- const rest = joined.subarray(UPLOAD_CHUNK_BYTES);
228
- pending = rest.length ? [Buffer.from(rest)] : [];
229
- pendingBytes = rest.length;
230
- }
231
- }
232
- if (pendingBytes > 0) {
233
- await putChunk(session.uploadUrl, Buffer.concat(pending), offset, declared);
234
- offset += pendingBytes;
235
- }
236
- if (offset !== declared) throw new Error("Body shorter than Content-Length");
237
- res.status(201).json({ ok: true, name: ticket.name, size: declared });
161
+ const created = await uploadAttachment(
162
+ graphClient,
163
+ {
164
+ target: ticket.target,
165
+ name: ticket.name,
166
+ contentType: ticket.contentType,
167
+ size: declared,
168
+ accessToken
169
+ },
170
+ req
171
+ );
172
+ res.status(201).json({ ok: true, name: ticket.name, size: declared, id: created.id });
238
173
  } catch (error) {
239
174
  logger.error(`Attachment upload failed for ${ticket.target}: ${error.message}`);
240
175
  if (!res.headersSent) res.status(502).type("text/plain").send("Upstream upload failed");
@@ -242,7 +177,6 @@ function createAttachmentUploadHandler(deps) {
242
177
  };
243
178
  }
244
179
  export {
245
- MAX_UPLOAD_BYTES,
246
180
  createAttachmentHandler,
247
181
  createAttachmentUploadHandler,
248
182
  forceAttachment
package/dist/cli.js CHANGED
@@ -3,6 +3,7 @@ import { readFileSync } from "fs";
3
3
  import path from "path";
4
4
  import { fileURLToPath } from "url";
5
5
  import { assertSignoffMarkersVisible } from "./lib/message-signoff.js";
6
+ import { assertUploadDirsAbsolute } from "./lib/upload-dirs.js";
6
7
  import { getCombinedPresetPattern, listPresets, presetRequiresOrgMode } from "./tool-categories.js";
7
8
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
9
  const packageJsonPath = path.join(__dirname, "..", "package.json");
@@ -74,7 +75,7 @@ program.name("ms-365-mcp-server").description("Microsoft 365 MCP Server").versio
74
75
  "In HTTP mode, skip the built-in Bearer-token check on /mcp and ignore any forwarded Authorization header. All callers share the locally cached MSAL identity (same path stdio mode uses). Use only when an upstream reverse proxy has already authenticated the caller."
75
76
  ).option(
76
77
  "--http-local-file-tools",
77
- "In HTTP mode, also register download-bytes-to-file, which writes files on the server as the server's user. Anyone who can reach the port can use it, so enable only on a single-user machine. Refused unless --http binds a loopback host with no --public-url and no --trust-proxy-auth."
78
+ "In HTTP mode, also register the local file tools: download-bytes-to-file, which writes files on the server as the server's user, and upload-attachment-from-file, which reads files from the MS365_MCP_UPLOAD_DIRS directories when that is set. Anyone who can reach the port can use them, so enable only on a single-user machine. Refused unless --http binds a loopback host with no --public-url and no --trust-proxy-auth."
78
79
  ).option(
79
80
  "--allow-unauthenticated-discovery",
80
81
  "In HTTP mode, allow MCP discovery requests (initialize, tools/list, prompts/list, resources/list, ping) without a bearer token, so a gateway can enumerate the tool catalog before any user has authenticated. Non-discovery requests (e.g. tools/call) still require a token. Off by default."
@@ -98,6 +99,7 @@ function parseArgs() {
98
99
  process.env.MS365_MCP_MESSAGE_SIGNOFF_PREFIX = "";
99
100
  }
100
101
  assertSignoffMarkersVisible();
102
+ assertUploadDirsAbsolute();
101
103
  if (options.listPresets) {
102
104
  const presets = listPresets();
103
105
  console.log(JSON.stringify({ presets }, null, 2));
@@ -1322,7 +1322,7 @@
1322
1322
  "toolName": "create-todo-task",
1323
1323
  "presets": ["personal", "tasks"],
1324
1324
  "scopes": ["Tasks.ReadWrite"],
1325
- "llmTip": "Creates a new task in a Microsoft To Do list. Body: { title: \"...\" }; optional dueDateTime, reminderDateTime, importance, body (notes), recurrence, categories. Requires todoTaskListId from list-todo-task-lists."
1325
+ "llmTip": "Creates a new task in a Microsoft To Do list. Body: { title: \"...\" }; optional dueDateTime, reminderDateTime, importance, body (notes), recurrence, categories, and checklistItems: [{ displayName: \"...\", isChecked: false }] to create the task's steps inline (isChecked is optional). Requires todoTaskListId from list-todo-task-lists. To change steps after the task exists, use the *-todo-checklist-item tools."
1326
1326
  },
1327
1327
  {
1328
1328
  "pathPattern": "/me/todo/lists/{todoTaskList-id}/tasks/{todoTask-id}",
@@ -1330,7 +1330,7 @@
1330
1330
  "toolName": "update-todo-task",
1331
1331
  "presets": ["personal", "tasks"],
1332
1332
  "scopes": ["Tasks.ReadWrite"],
1333
- "llmTip": "Updates a Microsoft To Do task. Use this to mark a to-do item complete or done (body: { status: \"completed\" }), reopen it (status: \"notStarted\"), rename it (title), or change its due date (dueDateTime), reminder (reminderDateTime), importance, or notes (body). Requires todoTaskListId from list-todo-task-lists and todoTaskId from list-todo-tasks."
1333
+ "llmTip": "Updates a Microsoft To Do task. Use this to mark a to-do item complete or done (body: { status: \"completed\" }), reopen it (status: \"notStarted\"), rename it (title), or change its due date (dueDateTime), reminder (reminderDateTime), importance, or notes (body). Requires todoTaskListId from list-todo-task-lists and todoTaskId from list-todo-tasks. Steps (checklistItems) can't be changed here: Graph rejects checklistItems in a PATCH on the task with 400. Use create-todo-checklist-item, update-todo-checklist-item, or delete-todo-checklist-item instead."
1334
1334
  },
1335
1335
  {
1336
1336
  "pathPattern": "/me/todo/lists/{todoTaskList-id}/tasks/{todoTask-id}",
@@ -1363,6 +1363,38 @@
1363
1363
  "scopes": ["Tasks.ReadWrite"],
1364
1364
  "llmTip": "Removes a linked resource from a To Do task."
1365
1365
  },
1366
+ {
1367
+ "pathPattern": "/me/todo/lists/{todoTaskList-id}/tasks/{todoTask-id}/checklistItems",
1368
+ "method": "get",
1369
+ "toolName": "list-todo-checklist-items",
1370
+ "presets": ["personal", "tasks"],
1371
+ "scopes": ["Tasks.Read"],
1372
+ "llmTip": "Lists the steps (checklist items) of a To Do task. Each item has id, displayName, isChecked, checkedDateTime, and createdDateTime. Graph supports no OData query parameters on this endpoint, so do not pass select, filter, orderby, or top. Requires todoTaskListId from list-todo-task-lists and todoTaskId from list-todo-tasks."
1373
+ },
1374
+ {
1375
+ "pathPattern": "/me/todo/lists/{todoTaskList-id}/tasks/{todoTask-id}/checklistItems",
1376
+ "method": "post",
1377
+ "toolName": "create-todo-checklist-item",
1378
+ "presets": ["personal", "tasks"],
1379
+ "scopes": ["Tasks.ReadWrite"],
1380
+ "llmTip": "Adds a step (checklist item) to an existing To Do task. Body: { displayName: \"...\" }; optional isChecked (defaults to false). Returns the new item with its id. To add steps while creating a task, pass checklistItems in the create-todo-task body instead."
1381
+ },
1382
+ {
1383
+ "pathPattern": "/me/todo/lists/{todoTaskList-id}/tasks/{todoTask-id}/checklistItems/{checklistItem-id}",
1384
+ "method": "patch",
1385
+ "toolName": "update-todo-checklist-item",
1386
+ "presets": ["personal", "tasks"],
1387
+ "scopes": ["Tasks.ReadWrite"],
1388
+ "llmTip": "Updates a step (checklist item) of a To Do task. Use this to check a step off (body: { isChecked: true }), uncheck it (isChecked: false), or rename it (displayName). Requires checklistItemId from list-todo-checklist-items."
1389
+ },
1390
+ {
1391
+ "pathPattern": "/me/todo/lists/{todoTaskList-id}/tasks/{todoTask-id}/checklistItems/{checklistItem-id}",
1392
+ "method": "delete",
1393
+ "toolName": "delete-todo-checklist-item",
1394
+ "presets": ["personal", "tasks"],
1395
+ "scopes": ["Tasks.ReadWrite"],
1396
+ "llmTip": "Deletes a step (checklist item) from a To Do task. This cannot be undone. Requires checklistItemId from list-todo-checklist-items."
1397
+ },
1366
1398
  {
1367
1399
  "pathPattern": "/me/planner/tasks",
1368
1400
  "method": "get",
@@ -4148,6 +4148,11 @@ const microsoft_graph_todoTaskCollectionResponse = z.object({
4148
4148
  "@odata.nextLink": z.string().nullable(),
4149
4149
  value: z.array(microsoft_graph_todoTask)
4150
4150
  }).partial().passthrough();
4151
+ const microsoft_graph_checklistItemCollectionResponse = z.object({
4152
+ "@odata.count": z.number().int().nullable(),
4153
+ "@odata.nextLink": z.string().nullable(),
4154
+ value: z.array(microsoft_graph_checklistItem)
4155
+ }).partial().passthrough();
4151
4156
  const microsoft_graph_linkedResourceCollectionResponse = z.object({
4152
4157
  "@odata.count": z.number().int().nullable(),
4153
4158
  "@odata.nextLink": z.string().nullable(),
@@ -5295,6 +5300,7 @@ const schemas = {
5295
5300
  microsoft_graph_todoTaskList,
5296
5301
  microsoft_graph_todoTaskListCollectionResponse,
5297
5302
  microsoft_graph_todoTaskCollectionResponse,
5303
+ microsoft_graph_checklistItemCollectionResponse,
5298
5304
  microsoft_graph_linkedResourceCollectionResponse,
5299
5305
  microsoft_graph_checkInMethod,
5300
5306
  microsoft_graph_checkInClaim,
@@ -12347,6 +12353,103 @@ Currently, a user can be associated with a team in two different ways:`,
12347
12353
  ],
12348
12354
  response: z.void()
12349
12355
  },
12356
+ {
12357
+ method: "get",
12358
+ path: "/me/todo/lists/:todoTaskListId/tasks/:todoTaskId/checklistItems",
12359
+ alias: "list-todo-checklist-items",
12360
+ description: `Get the checklistItem resources associated to a todoTask from the checklistItems navigation property.`,
12361
+ requestFormat: "json",
12362
+ parameters: [
12363
+ {
12364
+ name: "$top",
12365
+ type: "Query",
12366
+ schema: z.number().int().gte(0).describe("Show only the first n items").optional()
12367
+ },
12368
+ {
12369
+ name: "$skip",
12370
+ type: "Query",
12371
+ schema: z.number().int().gte(0).describe("Skip the first n items").optional()
12372
+ },
12373
+ {
12374
+ name: "$search",
12375
+ type: "Query",
12376
+ schema: z.string().describe("Search items by search phrases").optional()
12377
+ },
12378
+ {
12379
+ name: "$filter",
12380
+ type: "Query",
12381
+ schema: z.string().describe("Filter items by property values").optional()
12382
+ },
12383
+ {
12384
+ name: "$count",
12385
+ type: "Query",
12386
+ schema: z.boolean().describe("Include count of items").optional()
12387
+ },
12388
+ {
12389
+ name: "$orderby",
12390
+ type: "Query",
12391
+ schema: z.array(z.string()).describe("Order items by property values").optional()
12392
+ },
12393
+ {
12394
+ name: "$select",
12395
+ type: "Query",
12396
+ schema: z.array(z.string()).describe("Select properties to be returned").optional()
12397
+ },
12398
+ {
12399
+ name: "$expand",
12400
+ type: "Query",
12401
+ schema: z.array(z.string()).describe("Expand related entities").optional()
12402
+ }
12403
+ ],
12404
+ response: z.void()
12405
+ },
12406
+ {
12407
+ method: "post",
12408
+ path: "/me/todo/lists/:todoTaskListId/tasks/:todoTaskId/checklistItems",
12409
+ alias: "create-todo-checklist-item",
12410
+ description: `Create a new checklistItem object.`,
12411
+ requestFormat: "json",
12412
+ parameters: [
12413
+ {
12414
+ name: "body",
12415
+ description: `New navigation property`,
12416
+ type: "Body",
12417
+ schema: microsoft_graph_checklistItem
12418
+ }
12419
+ ],
12420
+ response: z.void()
12421
+ },
12422
+ {
12423
+ method: "patch",
12424
+ path: "/me/todo/lists/:todoTaskListId/tasks/:todoTaskId/checklistItems/:checklistItemId",
12425
+ alias: "update-todo-checklist-item",
12426
+ description: `Update the properties of a checklistItem object.`,
12427
+ requestFormat: "json",
12428
+ parameters: [
12429
+ {
12430
+ name: "body",
12431
+ description: `New navigation property values`,
12432
+ type: "Body",
12433
+ schema: microsoft_graph_checklistItem
12434
+ }
12435
+ ],
12436
+ response: z.void()
12437
+ },
12438
+ {
12439
+ method: "delete",
12440
+ path: "/me/todo/lists/:todoTaskListId/tasks/:todoTaskId/checklistItems/:checklistItemId",
12441
+ alias: "delete-todo-checklist-item",
12442
+ description: `Delete a checklistItem object.`,
12443
+ requestFormat: "json",
12444
+ parameters: [
12445
+ {
12446
+ name: "If-Match",
12447
+ type: "Header",
12448
+ schema: z.string().describe("ETag").optional()
12449
+ }
12450
+ ],
12451
+ response: z.void()
12452
+ },
12350
12453
  {
12351
12454
  method: "get",
12352
12455
  path: "/me/todo/lists/:todoTaskListId/tasks/:todoTaskId/linkedResources",
@@ -15,7 +15,8 @@ import {
15
15
  import { api as betaApi } from "./generated/client-beta.js";
16
16
  import { api } from "./generated/client.js";
17
17
  import { getAttachmentMinting } from "./lib/attachment-minting.js";
18
- import { MAX_UPLOAD_BYTES } from "./attachment-route.js";
18
+ import { MAX_UPLOAD_BYTES, uploadAttachment } from "./lib/attachment-upload.js";
19
+ import { getUploadDirs, openUploadFile } from "./lib/upload-dirs.js";
19
20
  import {
20
21
  buildAttachmentUrl,
21
22
  isPlainGraphPath,
@@ -497,6 +498,7 @@ function collectDeniedToolPolicies(options) {
497
498
  for (const utility of UTILITY_TOOLS) {
498
499
  if (options.readOnly && !utility.readOnlyHint) continue;
499
500
  if (options.httpMode && utility.stdioOnly) continue;
501
+ if (utility.enabled && !utility.enabled()) continue;
500
502
  if (options.enabledToolsRegex && !options.enabledToolsRegex.test(utility.name)) {
501
503
  deniedTools.set(utility.name, {
502
504
  toolName: utility.name,
@@ -548,9 +550,16 @@ async function checkAccountParamInBearerMode(accountParam, authManager) {
548
550
  }
549
551
  const UPLOAD_TARGET = /^(\/me|\/users\/[^/]+)\/(messages|events)\/[^/]+\/attachments$/;
550
552
  const UPLOAD_CONTENT_TYPE = /^[\w.+-]+\/[\w.+-]+$/;
553
+ const UPLOAD_TARGET_ERROR = "target must be the attachments collection of a draft message or an event: /me/messages/{message-id}/attachments or /me/events/{event-id}/attachments.";
551
554
  function uploadError(message) {
552
555
  return { content: [{ type: "text", text: JSON.stringify({ error: message }) }], isError: true };
553
556
  }
557
+ function cleanAttachmentName(name) {
558
+ return Array.from(name.trim()).filter((char) => {
559
+ const code = char.charCodeAt(0);
560
+ return code > 31 && code !== 127;
561
+ }).join("").slice(0, 255);
562
+ }
554
563
  async function mintUploadUrl(target, name, contentType, accountParam, authManager) {
555
564
  const minting = getAttachmentMinting();
556
565
  if (!minting) {
@@ -560,14 +569,9 @@ async function mintUploadUrl(target, name, contentType, accountParam, authManage
560
569
  );
561
570
  }
562
571
  if (!UPLOAD_TARGET.test(target) || !isPlainGraphPath(target)) {
563
- return uploadError(
564
- "target must be the attachments collection of a draft message or an event: /me/messages/{message-id}/attachments or /me/events/{event-id}/attachments."
565
- );
572
+ return uploadError(UPLOAD_TARGET_ERROR);
566
573
  }
567
- const cleanName = Array.from(name.trim()).filter((char) => {
568
- const code = char.charCodeAt(0);
569
- return code > 31 && code !== 127;
570
- }).join("").slice(0, 255);
574
+ const cleanName = cleanAttachmentName(name);
571
575
  if (!cleanName) return uploadError("name is required.");
572
576
  if (!UPLOAD_CONTENT_TYPE.test(contentType)) {
573
577
  return uploadError("contentType must be a MIME type such as application/pdf.");
@@ -714,6 +718,111 @@ const UTILITY_TOOLS = [
714
718
  );
715
719
  }
716
720
  },
721
+ {
722
+ name: "upload-attachment-from-file",
723
+ method: "POST",
724
+ path: "tool:upload-attachment-from-file",
725
+ searchKeywords: "attach local file upload file from disk attach file path draft email attach file event attachment without base64",
726
+ description: "Attach a local file on the server to a draft message or an event, read straight from disk instead of passing as base64 through the agent context. target is the attachments collection, e.g. /me/messages/{message-id}/attachments or /me/events/{event-id}/attachments. Files up to 150 MB; the server picks an inline upload or an upload session by size. filePath must be absolute and inside a directory listed in MS365_MCP_UPLOAD_DIRS. stdio mode, or HTTP with --http-local-file-tools.",
727
+ readOnlyHint: false,
728
+ openWorldHint: true,
729
+ stdioOnly: true,
730
+ enabled: () => getUploadDirs().length > 0,
731
+ buildSchema: (ctx) => {
732
+ const schema = {
733
+ target: z.string().describe(
734
+ "Attachments collection of a draft message or an event, e.g. /me/messages/{message-id}/attachments or /me/events/{event-id}/attachments."
735
+ ),
736
+ filePath: z.string().describe(
737
+ "Absolute path of the file on the server's filesystem, e.g. /Users/me/documents/invitation.pdf. Must be inside a directory the server allows uploads from."
738
+ ),
739
+ name: z.string().optional().describe(
740
+ "Attachment file name as the recipient will see it. Defaults to the file name of filePath."
741
+ ),
742
+ contentType: z.string().optional().describe(
743
+ "MIME type of the file, e.g. application/pdf. Defaults to application/octet-stream."
744
+ )
745
+ };
746
+ if (ctx.multiAccount) {
747
+ schema["account"] = z.string().optional().describe(
748
+ "Account to use when multiple Microsoft accounts are configured. Required when multiple accounts exist (see list-accounts)."
749
+ );
750
+ }
751
+ return schema;
752
+ },
753
+ execute: async (params, { graphClient, authManager }) => {
754
+ const target = params.target;
755
+ const filePath = params.filePath;
756
+ const accountParam = params.account;
757
+ if (typeof target !== "string" || target.length === 0) {
758
+ return uploadError("target is required and must be a non-empty string.");
759
+ }
760
+ if (typeof filePath !== "string" || filePath.length === 0) {
761
+ return uploadError("filePath is required and must be a non-empty string.");
762
+ }
763
+ if (!UPLOAD_TARGET.test(target) || !isPlainGraphPath(target)) {
764
+ return uploadError(UPLOAD_TARGET_ERROR);
765
+ }
766
+ const contentType = typeof params.contentType === "string" && params.contentType.length > 0 ? params.contentType : "application/octet-stream";
767
+ if (!UPLOAD_CONTENT_TYPE.test(contentType)) {
768
+ return uploadError("contentType must be a MIME type such as application/pdf.");
769
+ }
770
+ const name = cleanAttachmentName(
771
+ typeof params.name === "string" && params.name.trim().length > 0 ? params.name : path.basename(filePath)
772
+ );
773
+ if (!name) return uploadError("name is required.");
774
+ let file;
775
+ try {
776
+ file = await openUploadFile(filePath, getUploadDirs());
777
+ } catch (error) {
778
+ return uploadError(error.message);
779
+ }
780
+ try {
781
+ if (file.size === 0) return uploadError("The file is empty.");
782
+ if (file.size > MAX_UPLOAD_BYTES) {
783
+ return uploadError(
784
+ `The file is ${file.size} bytes; the largest attachment is ${MAX_UPLOAD_BYTES} bytes.`
785
+ );
786
+ }
787
+ const accountModeError = await checkAccountParamInBearerMode(accountParam, authManager);
788
+ if (accountModeError) return uploadError(accountModeError);
789
+ let accountAccessToken;
790
+ if (authManager && !authManager.isOAuthModeEnabled() && !getRequestTokens()) {
791
+ accountAccessToken = await authManager.getTokenForAccount(accountParam);
792
+ }
793
+ const created = await uploadAttachment(
794
+ graphClient,
795
+ { target, name, contentType, size: file.size, accessToken: accountAccessToken },
796
+ // end pins the read to the size that was declared, should the file grow meanwhile
797
+ file.handle.createReadStream({
798
+ autoClose: false,
799
+ highWaterMark: 1024 * 1024,
800
+ end: file.size - 1
801
+ })
802
+ );
803
+ return {
804
+ content: [
805
+ {
806
+ type: "text",
807
+ text: JSON.stringify({
808
+ name,
809
+ size: file.size,
810
+ ...created.id ? { id: created.id } : {}
811
+ })
812
+ }
813
+ ]
814
+ };
815
+ } catch (error) {
816
+ const metadata = thrownErrorAuditFields(error);
817
+ return {
818
+ ...uploadError(error.message),
819
+ ...Object.keys(metadata).length > 0 ? { _meta: metadata } : {}
820
+ };
821
+ } finally {
822
+ await file.handle.close();
823
+ }
824
+ }
825
+ },
717
826
  {
718
827
  name: "parse-teams-url",
719
828
  method: "POST",
@@ -2062,6 +2171,7 @@ function registerGraphTools(server, graphClient, options = {}) {
2062
2171
  for (const utility of UTILITY_TOOLS) {
2063
2172
  if (readOnly && !utility.readOnlyHint) continue;
2064
2173
  if (httpMode && utility.stdioOnly) continue;
2174
+ if (utility.enabled && !utility.enabled()) continue;
2065
2175
  if (enabledToolsRegex && !enabledToolsRegex.test(utility.name)) continue;
2066
2176
  try {
2067
2177
  registerUtilityToolWithMcp(server, utility, utilityCtx);
@@ -2225,6 +2335,7 @@ function registerDiscoveryTools(server, graphClient, options = {}) {
2225
2335
  const utilityTools = UTILITY_TOOLS.filter((u) => {
2226
2336
  if (readOnly && !u.readOnlyHint) return false;
2227
2337
  if (httpMode && u.stdioOnly) return false;
2338
+ if (u.enabled && !u.enabled()) return false;
2228
2339
  if (enabledToolsRegex && !enabledToolsRegex.test(u.name)) return false;
2229
2340
  return true;
2230
2341
  });
@@ -0,0 +1,81 @@
1
+ const MAX_UPLOAD_BYTES = 150 * 1024 * 1024;
2
+ const INLINE_UPLOAD_LIMIT = 3 * 1024 * 1024;
3
+ const UPLOAD_CHUNK_BYTES = 320 * 1024 * 12;
4
+ async function putChunk(uploadUrl, chunk, start, total) {
5
+ const end = start + chunk.length - 1;
6
+ const response = await fetch(uploadUrl, {
7
+ method: "PUT",
8
+ headers: {
9
+ "Content-Type": "application/octet-stream",
10
+ "Content-Length": String(chunk.length),
11
+ "Content-Range": `bytes ${start}-${end}/${total}`
12
+ },
13
+ // A standalone ArrayBuffer: fetch's body typing wants a BufferSource, and a
14
+ // Buffer view over a pooled slab is neither the right type nor the right bytes.
15
+ body: chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength)
16
+ });
17
+ if (![200, 201, 202].includes(response.status)) {
18
+ throw new Error(`Upload session chunk ${start}-${end} answered HTTP ${response.status}`);
19
+ }
20
+ }
21
+ async function uploadAttachment(graphClient, upload, source) {
22
+ const { target, name, contentType, size, accessToken } = upload;
23
+ if (size < INLINE_UPLOAD_LIMIT) {
24
+ const chunks = [];
25
+ let received = 0;
26
+ for await (const chunk of source) {
27
+ received += chunk.length;
28
+ if (received > size) throw new Error(`Source is longer than the declared ${size} bytes`);
29
+ chunks.push(chunk);
30
+ }
31
+ if (received !== size) throw new Error(`Source ended before the declared ${size} bytes`);
32
+ const created = await graphClient.makeRequest(target, {
33
+ method: "POST",
34
+ accessToken,
35
+ body: JSON.stringify({
36
+ "@odata.type": "#microsoft.graph.fileAttachment",
37
+ name,
38
+ contentType,
39
+ contentBytes: Buffer.concat(chunks).toString("base64")
40
+ })
41
+ });
42
+ return { id: created?.id };
43
+ }
44
+ const session = await graphClient.makeRequest(`${target}/createUploadSession`, {
45
+ method: "POST",
46
+ accessToken,
47
+ body: JSON.stringify({
48
+ AttachmentItem: { attachmentType: "file", name, size, contentType }
49
+ })
50
+ });
51
+ if (!session?.uploadUrl) throw new Error("Upload session returned no uploadUrl");
52
+ let pending = [];
53
+ let pendingBytes = 0;
54
+ let offset = 0;
55
+ for await (const piece of source) {
56
+ pending.push(piece);
57
+ pendingBytes += piece.length;
58
+ if (offset + pendingBytes > size) {
59
+ throw new Error(`Source is longer than the declared ${size} bytes`);
60
+ }
61
+ while (pendingBytes >= UPLOAD_CHUNK_BYTES) {
62
+ const joined = Buffer.concat(pending);
63
+ const chunk = joined.subarray(0, UPLOAD_CHUNK_BYTES);
64
+ await putChunk(session.uploadUrl, chunk, offset, size);
65
+ offset += chunk.length;
66
+ const rest = joined.subarray(UPLOAD_CHUNK_BYTES);
67
+ pending = rest.length ? [Buffer.from(rest)] : [];
68
+ pendingBytes = rest.length;
69
+ }
70
+ }
71
+ if (pendingBytes > 0) {
72
+ await putChunk(session.uploadUrl, Buffer.concat(pending), offset, size);
73
+ offset += pendingBytes;
74
+ }
75
+ if (offset !== size) throw new Error(`Source ended before the declared ${size} bytes`);
76
+ return {};
77
+ }
78
+ export {
79
+ MAX_UPLOAD_BYTES,
80
+ uploadAttachment
81
+ };
@@ -0,0 +1,61 @@
1
+ import { constants } from "fs";
2
+ import { open, realpath, stat } from "fs/promises";
3
+ import path from "path";
4
+ const UPLOAD_DIRS_ENV = "MS365_MCP_UPLOAD_DIRS";
5
+ function uploadDirEntries() {
6
+ return (process.env[UPLOAD_DIRS_ENV] ?? "").split(path.delimiter).map((entry) => entry.trim()).filter((entry) => entry.length > 0);
7
+ }
8
+ function getUploadDirs() {
9
+ return uploadDirEntries().filter((entry) => path.isAbsolute(entry));
10
+ }
11
+ function assertUploadDirsAbsolute() {
12
+ const relative = uploadDirEntries().filter((entry) => !path.isAbsolute(entry));
13
+ if (relative.length > 0) {
14
+ throw new Error(
15
+ `${UPLOAD_DIRS_ENV} entries must be absolute paths, got: ${relative.join(", ")}`
16
+ );
17
+ }
18
+ }
19
+ function isInside(dir, file) {
20
+ const relative = path.relative(dir, file);
21
+ return relative.length > 0 && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
22
+ }
23
+ async function openUploadFile(filePath, dirs) {
24
+ const refused = new Error(
25
+ `filePath does not exist or is outside the directories allowed by ${UPLOAD_DIRS_ENV} (${dirs.join(", ")}).`
26
+ );
27
+ if (!path.isAbsolute(filePath)) {
28
+ throw new Error(`filePath must be an absolute path. Received: ${filePath}`);
29
+ }
30
+ let realFile;
31
+ try {
32
+ realFile = await realpath(filePath);
33
+ } catch {
34
+ throw refused;
35
+ }
36
+ const realDirs = await Promise.all(dirs.map((dir) => realpath(dir).catch(() => null)));
37
+ if (!realDirs.some((dir) => dir !== null && isInside(dir, realFile))) throw refused;
38
+ const checked = await stat(realFile, { bigint: true }).catch(() => null);
39
+ if (!checked) throw refused;
40
+ if (!checked.isFile()) throw new Error("filePath is not a regular file.");
41
+ let handle;
42
+ try {
43
+ handle = await open(realFile, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
44
+ } catch {
45
+ throw refused;
46
+ }
47
+ let stats;
48
+ try {
49
+ stats = await handle.stat({ bigint: true });
50
+ if (!stats.isFile() || stats.dev !== checked.dev || stats.ino !== checked.ino) throw refused;
51
+ } catch {
52
+ await handle.close();
53
+ throw refused;
54
+ }
55
+ return { handle, size: Number(stats.size) };
56
+ }
57
+ export {
58
+ assertUploadDirsAbsolute,
59
+ getUploadDirs,
60
+ openUploadFile
61
+ };
package/dist/server.js CHANGED
@@ -251,11 +251,11 @@ class MicrosoftGraphServer {
251
251
  );
252
252
  }
253
253
  logger.warn(
254
- "--http-local-file-tools: download-bytes-to-file is registered over HTTP; anyone who can reach this port can write files as the server's user."
254
+ "--http-local-file-tools: download-bytes-to-file is registered over HTTP, and upload-attachment-from-file too when MS365_MCP_UPLOAD_DIRS is set; anyone who can reach this port can write files, and read files from those directories, as the server's user."
255
255
  );
256
256
  } else if (this.options.httpLocalFileTools) {
257
257
  logger.warn(
258
- "--http-local-file-tools has no effect in stdio mode, where download-bytes-to-file is always registered."
258
+ "--http-local-file-tools has no effect in stdio mode, where the local file tools need no flag."
259
259
  );
260
260
  }
261
261
  if (this.options.obo) {
@@ -63,6 +63,7 @@ const UNIVERSAL_UTILITY_TOOLS = ["download-bytes", "download-bytes-to-file"];
63
63
  const SCOPED_UTILITY_TOOLS = {
64
64
  "get-download-url": ["files", "onedrive", "personal", "work", "search"],
65
65
  "get-upload-url": ["mail", "calendar", "outlook", "personal", "work"],
66
+ "upload-attachment-from-file": ["mail", "calendar", "outlook", "personal", "work"],
66
67
  "parse-teams-url": ["teams", "teams-write", "work"]
67
68
  };
68
69
  for (const [tool, presets] of Object.entries(SCOPED_UTILITY_TOOLS)) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@softeria/ms-365-mcp-server",
3
3
  "mcpName": "io.github.Softeria/ms-365-mcp-server",
4
- "version": "0.162.0",
4
+ "version": "0.164.0",
5
5
  "description": " A Model Context Protocol (MCP) server for interacting with Microsoft 365 and Office services through the Graph API",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
@@ -1322,7 +1322,7 @@
1322
1322
  "toolName": "create-todo-task",
1323
1323
  "presets": ["personal", "tasks"],
1324
1324
  "scopes": ["Tasks.ReadWrite"],
1325
- "llmTip": "Creates a new task in a Microsoft To Do list. Body: { title: \"...\" }; optional dueDateTime, reminderDateTime, importance, body (notes), recurrence, categories. Requires todoTaskListId from list-todo-task-lists."
1325
+ "llmTip": "Creates a new task in a Microsoft To Do list. Body: { title: \"...\" }; optional dueDateTime, reminderDateTime, importance, body (notes), recurrence, categories, and checklistItems: [{ displayName: \"...\", isChecked: false }] to create the task's steps inline (isChecked is optional). Requires todoTaskListId from list-todo-task-lists. To change steps after the task exists, use the *-todo-checklist-item tools."
1326
1326
  },
1327
1327
  {
1328
1328
  "pathPattern": "/me/todo/lists/{todoTaskList-id}/tasks/{todoTask-id}",
@@ -1330,7 +1330,7 @@
1330
1330
  "toolName": "update-todo-task",
1331
1331
  "presets": ["personal", "tasks"],
1332
1332
  "scopes": ["Tasks.ReadWrite"],
1333
- "llmTip": "Updates a Microsoft To Do task. Use this to mark a to-do item complete or done (body: { status: \"completed\" }), reopen it (status: \"notStarted\"), rename it (title), or change its due date (dueDateTime), reminder (reminderDateTime), importance, or notes (body). Requires todoTaskListId from list-todo-task-lists and todoTaskId from list-todo-tasks."
1333
+ "llmTip": "Updates a Microsoft To Do task. Use this to mark a to-do item complete or done (body: { status: \"completed\" }), reopen it (status: \"notStarted\"), rename it (title), or change its due date (dueDateTime), reminder (reminderDateTime), importance, or notes (body). Requires todoTaskListId from list-todo-task-lists and todoTaskId from list-todo-tasks. Steps (checklistItems) can't be changed here: Graph rejects checklistItems in a PATCH on the task with 400. Use create-todo-checklist-item, update-todo-checklist-item, or delete-todo-checklist-item instead."
1334
1334
  },
1335
1335
  {
1336
1336
  "pathPattern": "/me/todo/lists/{todoTaskList-id}/tasks/{todoTask-id}",
@@ -1363,6 +1363,38 @@
1363
1363
  "scopes": ["Tasks.ReadWrite"],
1364
1364
  "llmTip": "Removes a linked resource from a To Do task."
1365
1365
  },
1366
+ {
1367
+ "pathPattern": "/me/todo/lists/{todoTaskList-id}/tasks/{todoTask-id}/checklistItems",
1368
+ "method": "get",
1369
+ "toolName": "list-todo-checklist-items",
1370
+ "presets": ["personal", "tasks"],
1371
+ "scopes": ["Tasks.Read"],
1372
+ "llmTip": "Lists the steps (checklist items) of a To Do task. Each item has id, displayName, isChecked, checkedDateTime, and createdDateTime. Graph supports no OData query parameters on this endpoint, so do not pass select, filter, orderby, or top. Requires todoTaskListId from list-todo-task-lists and todoTaskId from list-todo-tasks."
1373
+ },
1374
+ {
1375
+ "pathPattern": "/me/todo/lists/{todoTaskList-id}/tasks/{todoTask-id}/checklistItems",
1376
+ "method": "post",
1377
+ "toolName": "create-todo-checklist-item",
1378
+ "presets": ["personal", "tasks"],
1379
+ "scopes": ["Tasks.ReadWrite"],
1380
+ "llmTip": "Adds a step (checklist item) to an existing To Do task. Body: { displayName: \"...\" }; optional isChecked (defaults to false). Returns the new item with its id. To add steps while creating a task, pass checklistItems in the create-todo-task body instead."
1381
+ },
1382
+ {
1383
+ "pathPattern": "/me/todo/lists/{todoTaskList-id}/tasks/{todoTask-id}/checklistItems/{checklistItem-id}",
1384
+ "method": "patch",
1385
+ "toolName": "update-todo-checklist-item",
1386
+ "presets": ["personal", "tasks"],
1387
+ "scopes": ["Tasks.ReadWrite"],
1388
+ "llmTip": "Updates a step (checklist item) of a To Do task. Use this to check a step off (body: { isChecked: true }), uncheck it (isChecked: false), or rename it (displayName). Requires checklistItemId from list-todo-checklist-items."
1389
+ },
1390
+ {
1391
+ "pathPattern": "/me/todo/lists/{todoTaskList-id}/tasks/{todoTask-id}/checklistItems/{checklistItem-id}",
1392
+ "method": "delete",
1393
+ "toolName": "delete-todo-checklist-item",
1394
+ "presets": ["personal", "tasks"],
1395
+ "scopes": ["Tasks.ReadWrite"],
1396
+ "llmTip": "Deletes a step (checklist item) from a To Do task. This cannot be undone. Requires checklistItemId from list-todo-checklist-items."
1397
+ },
1366
1398
  {
1367
1399
  "pathPattern": "/me/planner/tasks",
1368
1400
  "method": "get",