@softeria/ms-365-mcp-server 0.96.0 → 0.98.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.
@@ -51,6 +51,13 @@ export function generateMcpTools(openApiSpec, outputDir) {
51
51
  // Replace with: path: `/...range(param=':value')...`,
52
52
  clientCode = clientCode.replace(/(path:\s*)'(\/[^']*\([^)]*=':[\w]+'\)[^']*)'/g, '$1`$2`');
53
53
 
54
+ // openapi-zod-client emits z.instanceof(File) for `format: binary` bodies; MCP
55
+ // transports JSON so no caller produces File. Body marshaller decodes the string.
56
+ clientCode = clientCode.replace(
57
+ /z\.instanceof\(File\)/g,
58
+ "z.string().describe('Base64-encoded file content. The server decodes it and PUTs the raw bytes to Microsoft Graph.')"
59
+ );
60
+
54
61
  fs.writeFileSync(clientFilePath, clientCode);
55
62
 
56
63
  return true;
@@ -441,4 +441,288 @@ describe("graph-tools", () => {
441
441
  expect(prefer === void 0 || !prefer.includes("outlook.body-content-type")).toBe(true);
442
442
  });
443
443
  });
444
+ describe("binary upload bodies", () => {
445
+ it("decodes base64 body to bytes and sets octet-stream Content-Type", async () => {
446
+ const endpoint = makeEndpoint({
447
+ alias: "upload-file-content",
448
+ method: "put",
449
+ path: "/drives/:driveId/items/:driveItemId/content",
450
+ requestFormat: "binary",
451
+ parameters: [
452
+ { name: "driveId", type: "Path", schema: z.string() },
453
+ { name: "driveItemId", type: "Path", schema: z.string() },
454
+ {
455
+ name: "body",
456
+ type: "Body",
457
+ schema: z.string().describe("Base64-encoded file content")
458
+ }
459
+ ]
460
+ });
461
+ const config = makeConfig({
462
+ toolName: "upload-file-content",
463
+ method: "put",
464
+ pathPattern: "/drives/{drive-id}/items/{driveItem-id}/content",
465
+ scopes: ["Files.ReadWrite"]
466
+ });
467
+ mockEndpoints.push(endpoint);
468
+ mockEndpointsJson = [config];
469
+ const graphClient = createMockGraphClient([{ content: [{ type: "text", text: "{}" }] }]);
470
+ const server = createMockServer();
471
+ const { registerGraphTools } = await loadModule();
472
+ registerGraphTools(server, graphClient);
473
+ const original = "Hello, world!";
474
+ const base64 = Buffer.from(original, "utf-8").toString("base64");
475
+ await server.tools.get("upload-file-content").handler({
476
+ driveId: "drive123",
477
+ driveItemId: "item456",
478
+ body: base64
479
+ });
480
+ const [path, options] = graphClient.graphRequest.mock.calls[0];
481
+ expect(path).toBe("/drives/drive123/items/item456/content");
482
+ expect(options.headers["Content-Type"]).toBe("application/octet-stream");
483
+ expect(Buffer.isBuffer(options.body) || options.body instanceof Uint8Array).toBe(true);
484
+ expect(Buffer.from(options.body).toString("utf-8")).toBe(original);
485
+ });
486
+ it("honors endpoints.json contentType override on binary uploads", async () => {
487
+ const endpoint = makeEndpoint({
488
+ alias: "upload-file-content",
489
+ method: "put",
490
+ path: "/drives/:driveId/items/:driveItemId/content",
491
+ requestFormat: "binary",
492
+ parameters: [
493
+ { name: "driveId", type: "Path", schema: z.string() },
494
+ { name: "driveItemId", type: "Path", schema: z.string() },
495
+ { name: "body", type: "Body", schema: z.string() }
496
+ ]
497
+ });
498
+ const config = makeConfig({
499
+ toolName: "upload-file-content",
500
+ method: "put",
501
+ pathPattern: "/drives/{drive-id}/items/{driveItem-id}/content",
502
+ scopes: ["Files.ReadWrite"],
503
+ contentType: "application/pdf"
504
+ });
505
+ mockEndpoints.push(endpoint);
506
+ mockEndpointsJson = [config];
507
+ const graphClient = createMockGraphClient([{ content: [{ type: "text", text: "{}" }] }]);
508
+ const server = createMockServer();
509
+ const { registerGraphTools } = await loadModule();
510
+ registerGraphTools(server, graphClient);
511
+ await server.tools.get("upload-file-content").handler({
512
+ driveId: "d",
513
+ driveItemId: "i",
514
+ body: Buffer.from("%PDF-1.4").toString("base64")
515
+ });
516
+ const [, options] = graphClient.graphRequest.mock.calls[0];
517
+ expect(options.headers["Content-Type"]).toBe("application/pdf");
518
+ });
519
+ });
520
+ describe("download-bytes", () => {
521
+ it("routes a relative Graph path through graphRequest", async () => {
522
+ mockEndpoints.length = 0;
523
+ mockEndpointsJson = [];
524
+ const graphClient = {
525
+ graphRequest: vi.fn().mockResolvedValue({
526
+ content: [
527
+ {
528
+ type: "text",
529
+ text: JSON.stringify({
530
+ contentType: "image/jpeg",
531
+ encoding: "base64",
532
+ contentBytes: "aGk="
533
+ })
534
+ }
535
+ ]
536
+ })
537
+ };
538
+ const server = createMockServer();
539
+ const { registerGraphTools } = await loadModule();
540
+ registerGraphTools(server, graphClient);
541
+ const tool = server.tools.get("download-bytes");
542
+ expect(tool).toBeDefined();
543
+ await tool.handler({ target: "/me/photo/$value" });
544
+ expect(graphClient.graphRequest).toHaveBeenCalledTimes(1);
545
+ const [path, options] = graphClient.graphRequest.mock.calls[0];
546
+ expect(path).toBe("/me/photo/$value");
547
+ expect(options.accessToken).toBeUndefined();
548
+ });
549
+ it("rejects absolute URLs (Graph paths only)", async () => {
550
+ mockEndpoints.length = 0;
551
+ mockEndpointsJson = [];
552
+ const server = createMockServer();
553
+ const { registerGraphTools } = await loadModule();
554
+ registerGraphTools(server, {});
555
+ const tool = server.tools.get("download-bytes");
556
+ const result = await tool.handler({
557
+ target: "https://example.sharepoint.com/d/abc?temp=signed"
558
+ });
559
+ expect(result.isError).toBe(true);
560
+ const payload = JSON.parse(result.content[0].text);
561
+ expect(payload.error).toMatch(/relative Microsoft Graph path/);
562
+ });
563
+ it("rejects targets that do not start with /", async () => {
564
+ mockEndpoints.length = 0;
565
+ mockEndpointsJson = [];
566
+ const server = createMockServer();
567
+ const { registerGraphTools } = await loadModule();
568
+ registerGraphTools(server, {});
569
+ const tool = server.tools.get("download-bytes");
570
+ const result = await tool.handler({ target: "ftp://example.com/x" });
571
+ expect(result.isError).toBe(true);
572
+ const payload = JSON.parse(result.content[0].text);
573
+ expect(payload.error).toMatch(/relative Microsoft Graph path/);
574
+ });
575
+ });
576
+ describe("discovery mode: utility tools", () => {
577
+ it('search-tools surfaces download-bytes for "download" queries', async () => {
578
+ mockEndpoints.length = 0;
579
+ mockEndpointsJson = [];
580
+ const server = createMockServer();
581
+ const { registerDiscoveryTools } = await loadModule();
582
+ registerDiscoveryTools(server, {});
583
+ const result = await server.tools.get("search-tools").handler({ query: "download" });
584
+ const payload = JSON.parse(result.content[0].text);
585
+ const names = payload.tools.map((t) => t.name);
586
+ expect(names).toContain("download-bytes");
587
+ });
588
+ it("get-tool-schema returns the download-bytes parameter schema", async () => {
589
+ mockEndpoints.length = 0;
590
+ mockEndpointsJson = [];
591
+ const server = createMockServer();
592
+ const { registerDiscoveryTools } = await loadModule();
593
+ registerDiscoveryTools(server, {});
594
+ const result = await server.tools.get("get-tool-schema").handler({ tool_name: "download-bytes" });
595
+ const schema = JSON.parse(result.content[0].text);
596
+ expect(schema.name).toBe("download-bytes");
597
+ expect(schema.path).toBe("tool:download-bytes");
598
+ const targetParam = schema.parameters.find((p) => p.name === "target");
599
+ expect(targetParam).toBeDefined();
600
+ expect(targetParam.required).toBe(true);
601
+ });
602
+ it("execute-tool dispatches to download-bytes for a Graph path", async () => {
603
+ mockEndpoints.length = 0;
604
+ mockEndpointsJson = [];
605
+ const graphClient = {
606
+ graphRequest: vi.fn().mockResolvedValue({
607
+ content: [
608
+ {
609
+ type: "text",
610
+ text: JSON.stringify({
611
+ contentType: "image/png",
612
+ encoding: "base64",
613
+ contentBytes: "iVBORw0K"
614
+ })
615
+ }
616
+ ]
617
+ })
618
+ };
619
+ const server = createMockServer();
620
+ const { registerDiscoveryTools } = await loadModule();
621
+ registerDiscoveryTools(server, graphClient);
622
+ const result = await server.tools.get("execute-tool").handler({
623
+ tool_name: "download-bytes",
624
+ parameters: { target: "/me/photo/$value" }
625
+ });
626
+ expect(result.isError).toBeFalsy();
627
+ expect(graphClient.graphRequest).toHaveBeenCalledTimes(1);
628
+ const [path] = graphClient.graphRequest.mock.calls[0];
629
+ expect(path).toBe("/me/photo/$value");
630
+ });
631
+ it("execute-tool reports unknown tool when name matches neither registry", async () => {
632
+ mockEndpoints.length = 0;
633
+ mockEndpointsJson = [];
634
+ const server = createMockServer();
635
+ const { registerDiscoveryTools } = await loadModule();
636
+ registerDiscoveryTools(server, {});
637
+ const result = await server.tools.get("execute-tool").handler({
638
+ tool_name: "no-such-tool",
639
+ parameters: {}
640
+ });
641
+ expect(result.isError).toBe(true);
642
+ const payload = JSON.parse(result.content[0].text);
643
+ expect(payload.error).toMatch(/not found/i);
644
+ });
645
+ });
646
+ describe("discovery mode: --enabled-tools filter", () => {
647
+ it("search-tools only surfaces Graph tools matching the regex", async () => {
648
+ mockEndpoints.push(
649
+ {
650
+ alias: "list-mail-messages",
651
+ method: "get",
652
+ path: "/me/messages",
653
+ description: "List mail",
654
+ parameters: []
655
+ },
656
+ {
657
+ alias: "list-calendar-events",
658
+ method: "get",
659
+ path: "/me/events",
660
+ description: "List events",
661
+ parameters: []
662
+ }
663
+ );
664
+ mockEndpointsJson = [
665
+ { toolName: "list-mail-messages", method: "get", pathPattern: "/me/messages" },
666
+ { toolName: "list-calendar-events", method: "get", pathPattern: "/me/events" }
667
+ ];
668
+ const server = createMockServer();
669
+ const { registerDiscoveryTools } = await loadModule();
670
+ registerDiscoveryTools(server, {}, false, false, void 0, false, [], "mail");
671
+ const result = await server.tools.get("search-tools").handler({ limit: 50 });
672
+ const found = JSON.parse(result.content[0].text).tools.map((t) => t.name);
673
+ expect(found).toContain("list-mail-messages");
674
+ expect(found).not.toContain("list-calendar-events");
675
+ });
676
+ it("utility tools obey the regex too", async () => {
677
+ mockEndpoints.length = 0;
678
+ mockEndpointsJson = [];
679
+ const server = createMockServer();
680
+ const { registerDiscoveryTools } = await loadModule();
681
+ registerDiscoveryTools(
682
+ server,
683
+ {},
684
+ false,
685
+ false,
686
+ void 0,
687
+ false,
688
+ [],
689
+ "^download-bytes$"
690
+ );
691
+ const result = await server.tools.get("search-tools").handler({ limit: 50 });
692
+ const found = JSON.parse(result.content[0].text).tools.map((t) => t.name);
693
+ expect(found).toContain("download-bytes");
694
+ expect(found).not.toContain("parse-teams-url");
695
+ });
696
+ it("invalid regex pattern is ignored, all tools surface", async () => {
697
+ mockEndpoints.length = 0;
698
+ mockEndpointsJson = [];
699
+ const server = createMockServer();
700
+ const { registerDiscoveryTools } = await loadModule();
701
+ registerDiscoveryTools(
702
+ server,
703
+ {},
704
+ false,
705
+ false,
706
+ void 0,
707
+ false,
708
+ [],
709
+ "[invalid"
710
+ );
711
+ const result = await server.tools.get("search-tools").handler({ limit: 50 });
712
+ const found = JSON.parse(result.content[0].text).tools.map((t) => t.name);
713
+ expect(found).toContain("download-bytes");
714
+ expect(found).toContain("parse-teams-url");
715
+ });
716
+ });
717
+ describe("utility tools in read-only mode", () => {
718
+ it("skips utility tools whose readOnlyHint is not true", async () => {
719
+ mockEndpoints.length = 0;
720
+ mockEndpointsJson = [];
721
+ const server = createMockServer();
722
+ const { registerGraphTools } = await loadModule();
723
+ registerGraphTools(server, {}, true);
724
+ expect(server.tools.has("download-bytes")).toBe(true);
725
+ expect(server.tools.has("parse-teams-url")).toBe(true);
726
+ });
727
+ });
444
728
  });
@@ -151,13 +151,8 @@
151
151
  "pathPattern": "/me/messages/{message-id}/attachments",
152
152
  "method": "get",
153
153
  "toolName": "list-mail-attachments",
154
- "scopes": ["Mail.Read"]
155
- },
156
- {
157
- "pathPattern": "/me/messages/{message-id}/attachments/{attachment-id}",
158
- "method": "get",
159
- "toolName": "get-mail-attachment",
160
- "scopes": ["Mail.Read"]
154
+ "scopes": ["Mail.Read"],
155
+ "llmTip": "Lists attachments on a message: id, name, contentType, size, isInline. To download the bytes, call download-bytes with target=/me/messages/{message-id}/attachments/{attachment-id}/$value (the /$value suffix returns raw bytes; the bare attachment URL embeds contentBytes in JSON which can truncate large files)."
161
156
  },
162
157
  {
163
158
  "pathPattern": "/me/messages/{message-id}/attachments/{attachment-id}",
@@ -479,14 +474,6 @@
479
474
  "toolName": "list-folder-files",
480
475
  "scopes": ["Files.Read"]
481
476
  },
482
- {
483
- "pathPattern": "/drives/{drive-id}/items/{driveItem-id}/content",
484
- "method": "get",
485
- "toolName": "download-onedrive-file-content",
486
- "scopes": ["Files.Read"],
487
- "returnDownloadUrl": true,
488
- "llmTip": "Returns a temporary download URL, NOT the file content directly."
489
- },
490
477
  {
491
478
  "pathPattern": "/drives/{drive-id}/items/{driveItem-id}",
492
479
  "method": "delete",
@@ -498,7 +485,7 @@
498
485
  "method": "put",
499
486
  "toolName": "upload-file-content",
500
487
  "scopes": ["Files.ReadWrite"],
501
- "llmTip": "Max 4MB. For new files use path format: /items/root:/path/to/file.txt:/content. Overwrites existing files without warning."
488
+ "llmTip": "Body is a base64-encoded string of the file bytes; the server decodes it before PUT. Max 4MB inline (use create-upload-session above 4MB). For new files use path format: /items/root:/path/to/file.txt:/content. Overwrites existing files without warning."
502
489
  },
503
490
  {
504
491
  "pathPattern": "/drives/{drive-id}/items/{driveItem-id}/createUploadSession",
@@ -512,7 +499,7 @@
512
499
  "method": "get",
513
500
  "toolName": "get-drive-item",
514
501
  "scopes": ["Files.Read"],
515
- "llmTip": "Gets metadata for a file or folder: name, size, lastModifiedDateTime, createdBy, webUrl, file (mimeType, hashes), folder (childCount), parentReference. Does not return content use download-onedrive-file-content for that."
502
+ "llmTip": "Gets metadata for a file or folder: name, size, lastModifiedDateTime, createdBy, webUrl, file (mimeType, hashes), folder (childCount), parentReference, and @microsoft.graph.downloadUrl. For the bytes, call download-bytes with target=/drives/{drive-id}/items/{driveItem-id}/content (Graph redirects to the same downloadUrl)."
516
503
  },
517
504
  {
518
505
  "pathPattern": "/drives/{drive-id}/items/{driveItem-id}",
@@ -1077,22 +1064,6 @@
1077
1064
  "workScopes": ["User.Read.All"],
1078
1065
  "llmTip": "Lists users who report directly to a specific user. Use with get-user-manager to traverse the org hierarchy."
1079
1066
  },
1080
- {
1081
- "pathPattern": "/me/photo/$value",
1082
- "method": "get",
1083
- "toolName": "get-my-profile-photo",
1084
- "scopes": ["User.Read"],
1085
- "returnDownloadUrl": true,
1086
- "llmTip": "Returns the current user's profile photo as binary image data. The photo is typically in JPEG format."
1087
- },
1088
- {
1089
- "pathPattern": "/users/{user-id}/photo/$value",
1090
- "method": "get",
1091
- "toolName": "get-user-profile-photo",
1092
- "workScopes": ["User.Read.All"],
1093
- "returnDownloadUrl": true,
1094
- "llmTip": "Returns a specific user's profile photo as binary image data. Use the user ID or UPN (email)."
1095
- },
1096
1067
  {
1097
1068
  "pathPattern": "/me/people",
1098
1069
  "method": "get",
@@ -1185,15 +1156,7 @@
1185
1156
  "method": "get",
1186
1157
  "toolName": "list-chat-message-hosted-contents",
1187
1158
  "workScopes": ["ChatMessage.Read"],
1188
- "llmTip": "Lists hosted-content references (inline images, code snippets) attached to a Teams chat message. Returns an array of { id, contentType }. Use get-chat-message-hosted-content with the id to download the bytes. Hosted content IDs can also be parsed from the <img src> URL in the message body between /hostedContents/ and /$value."
1189
- },
1190
- {
1191
- "pathPattern": "/chats/{chat-id}/messages/{chatMessage-id}/hostedContents/{chatMessageHostedContent-id}/$value",
1192
- "method": "get",
1193
- "toolName": "get-chat-message-hosted-content",
1194
- "workScopes": ["ChatMessage.Read"],
1195
- "returnDownloadUrl": true,
1196
- "llmTip": "Returns raw bytes of a hosted content item (typically image/png or image/jpeg) attached to a Teams 1:1 or group chat message. Use list-chat-message-hosted-contents to discover IDs, or extract the ID from the <img src> URL in the message body."
1159
+ "llmTip": "Lists hosted-content references (inline images, code snippets) attached to a Teams chat message. Returns { id, contentType } per item. To download bytes, call download-bytes with target=/chats/{chat-id}/messages/{chatMessage-id}/hostedContents/{id}/$value. IDs can also be extracted from the <img src> URL in the message body between /hostedContents/ and /$value."
1197
1160
  },
1198
1161
  {
1199
1162
  "pathPattern": "/chats/{chat-id}/messages",
@@ -1261,15 +1224,7 @@
1261
1224
  "method": "get",
1262
1225
  "toolName": "list-channel-message-hosted-contents",
1263
1226
  "workScopes": ["ChannelMessage.Read.All"],
1264
- "llmTip": "Lists hosted-content references (inline images, code snippets) attached to a Teams channel message. Returns an array of { id, contentType }. Use get-channel-message-hosted-content with the id to download bytes."
1265
- },
1266
- {
1267
- "pathPattern": "/teams/{team-id}/channels/{channel-id}/messages/{chatMessage-id}/hostedContents/{chatMessageHostedContent-id}/$value",
1268
- "method": "get",
1269
- "toolName": "get-channel-message-hosted-content",
1270
- "workScopes": ["ChannelMessage.Read.All"],
1271
- "returnDownloadUrl": true,
1272
- "llmTip": "Returns raw bytes of a hosted content item attached to a Teams channel message. Use list-channel-message-hosted-contents to discover IDs."
1227
+ "llmTip": "Lists hosted-content references (inline images, code snippets) attached to a Teams channel message. Returns { id, contentType } per item. To download bytes, call download-bytes with target=/teams/{team-id}/channels/{channel-id}/messages/{chatMessage-id}/hostedContents/{id}/$value."
1273
1228
  },
1274
1229
  {
1275
1230
  "pathPattern": "/teams/{team-id}/channels/{channel-id}/messages",
@@ -788,8 +788,8 @@ const microsoft_graph_sharePointGroupIdentity = z.object({
788
788
  id: z.string().describe(
789
789
  "Unique identifier for the identity or actor. For example, in the access reviews decisions API, this property might record the id of the principal, that is, the group, user, or application that's subject to review."
790
790
  ).nullish(),
791
- principalId: z.string().nullish(),
792
- title: z.string().nullish()
791
+ principalId: z.string().describe("The principal ID of the SharePoint group in the tenant. Read-only.").nullish(),
792
+ title: z.string().describe("The title of the SharePoint group. Read-only.").nullish()
793
793
  }).passthrough();
794
794
  const microsoft_graph_sharePointIdentity = z.object({
795
795
  displayName: z.string().describe(
@@ -5200,14 +5200,6 @@ const endpoints = makeApi([
5200
5200
  ],
5201
5201
  response: z.void()
5202
5202
  },
5203
- {
5204
- method: "get",
5205
- path: "/chats/:chatId/messages/:chatMessageId/hostedContents/:chatMessageHostedContentId/$value",
5206
- alias: "get-chat-message-hosted-content",
5207
- description: `Retrieve the list of chatMessageHostedContent objects from a message. This API only lists the hosted content objects. To get the content bytes, see get chatmessage hosted content.`,
5208
- requestFormat: "json",
5209
- response: z.void()
5210
- },
5211
5203
  {
5212
5204
  method: "get",
5213
5205
  path: "/chats/:chatId/messages/:chatMessageId/replies",
@@ -5588,21 +5580,6 @@ const endpoints = makeApi([
5588
5580
  ],
5589
5581
  response: z.void()
5590
5582
  },
5591
- {
5592
- method: "get",
5593
- path: "/drives/:driveId/items/:driveItemId/content",
5594
- alias: "download-onedrive-file-content",
5595
- description: `The content stream, if the item represents a file.`,
5596
- requestFormat: "json",
5597
- parameters: [
5598
- {
5599
- name: "$format",
5600
- type: "Query",
5601
- schema: z.string().describe("Format of the content").optional()
5602
- }
5603
- ],
5604
- response: z.void()
5605
- },
5606
5583
  {
5607
5584
  method: "put",
5608
5585
  path: "/drives/:driveId/items/:driveItemId/content",
@@ -5614,7 +5591,7 @@ const endpoints = makeApi([
5614
5591
  name: "body",
5615
5592
  description: `New media content.`,
5616
5593
  type: "Body",
5617
- schema: z.instanceof(File)
5594
+ schema: z.string().describe("Base64-encoded file content. The server decodes it and PUTs the raw bytes to Microsoft Graph.")
5618
5595
  }
5619
5596
  ],
5620
5597
  response: z.void()
@@ -9593,26 +9570,6 @@ resource.`,
9593
9570
  ],
9594
9571
  response: z.void()
9595
9572
  },
9596
- {
9597
- method: "get",
9598
- path: "/me/messages/:messageId/attachments/:attachmentId",
9599
- alias: "get-mail-attachment",
9600
- description: `Read the properties, relationships, or raw contents of an attachment that is attached to a user event, message, or group post. An attachment can be one of the following types: All these types of attachments are derived from the attachment resource.`,
9601
- requestFormat: "json",
9602
- parameters: [
9603
- {
9604
- name: "$select",
9605
- type: "Query",
9606
- schema: z.array(z.string()).describe("Select properties to be returned").optional()
9607
- },
9608
- {
9609
- name: "$expand",
9610
- type: "Query",
9611
- schema: z.array(z.string()).describe("Expand related entities").optional()
9612
- }
9613
- ],
9614
- response: z.void()
9615
- },
9616
9573
  {
9617
9574
  method: "delete",
9618
9575
  path: "/me/messages/:messageId/attachments/:attachmentId",
@@ -10670,17 +10627,6 @@ resource.`,
10670
10627
  ],
10671
10628
  response: z.void()
10672
10629
  },
10673
- {
10674
- method: "get",
10675
- path: "/me/photo/$value",
10676
- alias: "get-my-profile-photo",
10677
- description: `Get the specified profilePhoto or its metadata (profilePhoto properties). The supported sizes of HD photos on Microsoft 365 are as follows: 48x48, 64x64, 96x96, 120x120, 240x240,
10678
- 360x360, 432x432, 504x504, and 648x648. Photos can be any dimension if they're stored in Microsoft Entra ID. You can get the metadata of the largest available photo or specify a size to get the metadata for that photo size.
10679
- If the size you request is unavailable, you can still get a smaller size that the user has uploaded and made available.
10680
- For example, if the user uploads a photo that is 504x504 pixels, all but the 648x648 size of the photo is available for download.`,
10681
- requestFormat: "json",
10682
- response: z.void()
10683
- },
10684
10630
  {
10685
10631
  method: "get",
10686
10632
  path: "/me/planner/tasks",
@@ -12879,14 +12825,6 @@ To monitor future changes, call the delta API by using the @odata.deltaLink in t
12879
12825
  ],
12880
12826
  response: z.void()
12881
12827
  },
12882
- {
12883
- method: "get",
12884
- path: "/teams/:teamId/channels/:channelId/messages/:chatMessageId/hostedContents/:chatMessageHostedContentId/$value",
12885
- alias: "get-channel-message-hosted-content",
12886
- description: `Retrieve the list of chatMessageHostedContent objects from a message. This API only lists the hosted content objects. To get the content bytes, see get chatmessage hosted content.`,
12887
- requestFormat: "json",
12888
- response: z.void()
12889
- },
12890
12828
  {
12891
12829
  method: "get",
12892
12830
  path: "/teams/:teamId/channels/:channelId/messages/:chatMessageId/replies",
@@ -13286,7 +13224,7 @@ To monitor future changes, call the delta API by using the @odata.deltaLink in t
13286
13224
  method: "get",
13287
13225
  path: "/users/:userId/directReports",
13288
13226
  alias: "list-user-direct-reports",
13289
- description: `The users and contacts that report to the user. (The users and contacts that have their manager property set to this user.) Read-only. Nullable. Supports $expand.`,
13227
+ description: `Get an agentUser's direct reports. Returns the users and contacts for whom this agent user is assigned as manager.`,
13290
13228
  requestFormat: "json",
13291
13229
  parameters: [
13292
13230
  {
@@ -13393,7 +13331,7 @@ To monitor future changes, call the delta API by using the @odata.deltaLink in t
13393
13331
  method: "get",
13394
13332
  path: "/users/:userId/manager",
13395
13333
  alias: "get-user-manager",
13396
- description: `Returns the user or organizational contact assigned as the user's manager. Optionally, you can expand the manager's chain up to the root node.`,
13334
+ description: `Returns the user or organizational contact assigned as the agentUser's manager.`,
13397
13335
  requestFormat: "json",
13398
13336
  parameters: [
13399
13337
  {
@@ -13496,14 +13434,6 @@ To monitor future changes, call the delta API by using the @odata.deltaLink in t
13496
13434
  ],
13497
13435
  response: z.void()
13498
13436
  },
13499
- {
13500
- method: "get",
13501
- path: "/users/:userId/photo/$value",
13502
- alias: "get-user-profile-photo",
13503
- description: `The user's profile photo. Read-only.`,
13504
- requestFormat: "json",
13505
- response: z.void()
13506
- },
13507
13437
  {
13508
13438
  method: "get",
13509
13439
  path: "/users/:userId/presence",
@@ -105,6 +105,7 @@ class GraphClient {
105
105
  return fetch(url, {
106
106
  method: options.method || "GET",
107
107
  headers,
108
+ // Node's fetch accepts Buffer/Uint8Array; TS BodyInit doesn't.
108
109
  body: options.body
109
110
  });
110
111
  }
@@ -8,7 +8,7 @@ import { TOOL_CATEGORIES } from "./tool-categories.js";
8
8
  import { getRequestTokens } from "./request-context.js";
9
9
  import { parseTeamsUrl } from "./lib/teams-url-parser.js";
10
10
  import { buildBM25Index, scoreQuery, tokenize } from "./lib/bm25.js";
11
- import { describeToolSchema } from "./lib/tool-schema.js";
11
+ import { describeToolSchema, describeUtilityToolSchema } from "./lib/tool-schema.js";
12
12
  const __filename = fileURLToPath(import.meta.url);
13
13
  const __dirname = path.dirname(__filename);
14
14
  const endpointsData = JSON.parse(
@@ -34,6 +34,111 @@ function clampTopQueryParam(queryParams) {
34
34
  logger.info(`Clamping $top from ${requested} to ${cap} (MS365_MCP_MAX_TOP)`);
35
35
  queryParams["$top"] = String(cap);
36
36
  }
37
+ const UTILITY_TOOLS = [
38
+ {
39
+ name: "parse-teams-url",
40
+ method: "POST",
41
+ path: "tool:parse-teams-url",
42
+ description: "Converts any Teams meeting URL format (short /meet/, full /meetup-join/, or recap ?threadId=) into a standard joinWebUrl. Use this before list-online-meetings when the user provides a recap or short URL.",
43
+ readOnlyHint: true,
44
+ openWorldHint: false,
45
+ buildSchema: () => ({
46
+ url: z.string().describe("Teams meeting URL in any format")
47
+ }),
48
+ execute: async (params) => {
49
+ const url = params.url;
50
+ if (typeof url !== "string") {
51
+ return {
52
+ content: [{ type: "text", text: JSON.stringify({ error: "url is required." }) }],
53
+ isError: true
54
+ };
55
+ }
56
+ try {
57
+ const joinWebUrl = parseTeamsUrl(url);
58
+ return { content: [{ type: "text", text: joinWebUrl }] };
59
+ } catch (error) {
60
+ return {
61
+ content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
62
+ isError: true
63
+ };
64
+ }
65
+ }
66
+ },
67
+ {
68
+ name: "download-bytes",
69
+ method: "GET",
70
+ path: "tool:download-bytes",
71
+ description: 'Download binary content from Microsoft Graph and return it as base64. Single tool for any binary read: drive file content, mail attachment, profile photo, Teams hosted content, meeting recording. Returns { contentType, encoding: "base64", contentLength, contentBytes }.',
72
+ readOnlyHint: true,
73
+ openWorldHint: true,
74
+ buildSchema: (ctx) => {
75
+ const schema = {
76
+ target: z.string().describe(
77
+ 'Relative Microsoft Graph path starting with "/". Common paths: /drives/{drive-id}/items/{driveItem-id}/content (drive file content); /me/messages/{message-id}/attachments/{attachment-id}/$value (mail attachment, list-mail-attachments returns the IDs); /me/photo/$value or /users/{user-id}/photo/$value (profile photo); /chats/{chat-id}/messages/{chatMessage-id}/hostedContents/{chatMessageHostedContent-id}/$value (Teams chat hosted content, list-chat-message-hosted-contents returns the IDs); /teams/{team-id}/channels/{channel-id}/messages/{chatMessage-id}/hostedContents/{chatMessageHostedContent-id}/$value (Teams channel hosted content). For meeting recordings (often large), use get-meeting-recording-content which returns a URL for out-of-band download by the client.'
78
+ )
79
+ };
80
+ if (ctx.multiAccount) {
81
+ schema["account"] = z.string().optional().describe(
82
+ "Account to use when multiple Microsoft accounts are configured. Required when multiple accounts exist (see list-accounts)."
83
+ );
84
+ }
85
+ return schema;
86
+ },
87
+ execute: async (params, { graphClient, authManager }) => {
88
+ const target = params.target;
89
+ const accountParam = params.account;
90
+ if (typeof target !== "string" || target.length === 0) {
91
+ return {
92
+ content: [
93
+ {
94
+ type: "text",
95
+ text: JSON.stringify({ error: "target is required and must be a non-empty string." })
96
+ }
97
+ ],
98
+ isError: true
99
+ };
100
+ }
101
+ if (!target.startsWith("/")) {
102
+ return {
103
+ content: [
104
+ {
105
+ type: "text",
106
+ text: JSON.stringify({
107
+ error: 'target must be a relative Microsoft Graph path starting with "/", e.g. /me/photo/$value or /drives/{drive-id}/items/{driveItem-id}/content. Absolute URLs are not accepted; if you have an @microsoft.graph.downloadUrl, use the equivalent /content or /$value path instead (Graph 302-redirects to the same bytes).'
108
+ })
109
+ }
110
+ ],
111
+ isError: true
112
+ };
113
+ }
114
+ try {
115
+ let accountAccessToken;
116
+ if (authManager && !authManager.isOAuthModeEnabled() && !getRequestTokens()) {
117
+ accountAccessToken = await authManager.getTokenForAccount(accountParam);
118
+ }
119
+ return await graphClient.graphRequest(target, { accessToken: accountAccessToken });
120
+ } catch (error) {
121
+ return {
122
+ content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
123
+ isError: true
124
+ };
125
+ }
126
+ }
127
+ }
128
+ ];
129
+ function registerUtilityToolWithMcp(server, utility, ctx) {
130
+ server.tool(
131
+ utility.name,
132
+ utility.description,
133
+ utility.buildSchema(ctx),
134
+ {
135
+ title: utility.name,
136
+ readOnlyHint: utility.readOnlyHint ?? true,
137
+ openWorldHint: utility.openWorldHint ?? true
138
+ },
139
+ async (params) => utility.execute(params, ctx)
140
+ );
141
+ }
37
142
  async function executeGraphTool(tool, config, graphClient, params, authManager) {
38
143
  logger.info(`Tool ${tool.alias} called with params: ${JSON.stringify(params)}`);
39
144
  try {
@@ -174,7 +279,12 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
174
279
  headers
175
280
  };
176
281
  if (options.method !== "GET" && body) {
177
- if (config?.contentType === "text/html") {
282
+ if (tool.requestFormat === "binary" && typeof body === "string") {
283
+ options.body = Buffer.from(body, "base64");
284
+ if (!config?.contentType) {
285
+ headers["Content-Type"] = "application/octet-stream";
286
+ }
287
+ } else if (config?.contentType === "text/html") {
178
288
  if (typeof body === "string") {
179
289
  options.body = body;
180
290
  } else if (typeof body === "object" && "content" in body) {
@@ -426,36 +536,20 @@ function registerGraphTools(server, graphClient, readOnly = false, enabledToolsP
426
536
  if (multiAccount) {
427
537
  logger.info('Multi-account mode: "account" parameter injected into all tool schemas');
428
538
  }
429
- if (!enabledToolsRegex || enabledToolsRegex.test("parse-teams-url")) {
539
+ const utilityCtx = {
540
+ graphClient,
541
+ authManager,
542
+ multiAccount,
543
+ accountNames
544
+ };
545
+ for (const utility of UTILITY_TOOLS) {
546
+ if (readOnly && !utility.readOnlyHint) continue;
547
+ if (enabledToolsRegex && !enabledToolsRegex.test(utility.name)) continue;
430
548
  try {
431
- server.tool(
432
- "parse-teams-url",
433
- "Converts any Teams meeting URL format (short /meet/, full /meetup-join/, or recap ?threadId=) into a standard joinWebUrl. Use this before list-online-meetings when the user provides a recap or short URL.",
434
- {
435
- url: z.string().describe("Teams meeting URL in any format")
436
- },
437
- {
438
- title: "parse-teams-url",
439
- readOnlyHint: true,
440
- openWorldHint: false
441
- },
442
- async ({ url }) => {
443
- try {
444
- const joinWebUrl = parseTeamsUrl(url);
445
- return { content: [{ type: "text", text: joinWebUrl }] };
446
- } catch (error) {
447
- return {
448
- content: [
449
- { type: "text", text: JSON.stringify({ error: error.message }) }
450
- ],
451
- isError: true
452
- };
453
- }
454
- }
455
- );
549
+ registerUtilityToolWithMcp(server, utility, utilityCtx);
456
550
  registeredCount++;
457
551
  } catch (error) {
458
- logger.error(`Failed to register tool parse-teams-url: ${error.message}`);
552
+ logger.error(`Failed to register tool ${utility.name}: ${error.message}`);
459
553
  failedCount++;
460
554
  }
461
555
  }
@@ -464,7 +558,7 @@ function registerGraphTools(server, graphClient, readOnly = false, enabledToolsP
464
558
  );
465
559
  return registeredCount;
466
560
  }
467
- function buildToolsRegistry(readOnly, orgMode) {
561
+ function buildToolsRegistry(readOnly, orgMode, enabledToolsRegex) {
468
562
  const toolsMap = /* @__PURE__ */ new Map();
469
563
  for (const tool of api.endpoints) {
470
564
  const endpointConfig = endpointsData.find((e) => e.toolName === tool.alias);
@@ -477,11 +571,14 @@ function buildToolsRegistry(readOnly, orgMode) {
477
571
  continue;
478
572
  }
479
573
  }
574
+ if (enabledToolsRegex && !enabledToolsRegex.test(tool.alias)) {
575
+ continue;
576
+ }
480
577
  toolsMap.set(tool.alias, { tool, config: endpointConfig });
481
578
  }
482
579
  return toolsMap;
483
580
  }
484
- function buildDiscoverySearchIndex(toolsRegistry) {
581
+ function buildDiscoverySearchIndex(toolsRegistry, utilityTools = []) {
485
582
  const TIP_EXCERPT_TOKENS = 12;
486
583
  const DESC_CAP_TOKENS = 40;
487
584
  const docs = [];
@@ -505,6 +602,14 @@ function buildDiscoverySearchIndex(toolsRegistry) {
505
602
  ];
506
603
  docs.push({ id: name, tokens });
507
604
  }
605
+ for (const utility of utilityTools) {
606
+ const nt = tokenize(utility.name);
607
+ nameTokens.set(utility.name, new Set(nt));
608
+ const pathTokens = tokenize(utility.path);
609
+ const descTokens = tokenize(utility.description).slice(0, DESC_CAP_TOKENS);
610
+ const tokens = [...nt, ...nt, ...nt, ...nt, ...nt, ...pathTokens, ...pathTokens, ...descTokens];
611
+ docs.push({ id: utility.name, tokens });
612
+ }
508
613
  return { bm25: buildBM25Index(docs), nameTokens };
509
614
  }
510
615
  function scoreDiscoveryQuery(query, index) {
@@ -530,29 +635,66 @@ function scoreDiscoveryQuery(query, index) {
530
635
  ranked.sort((a, b) => b.score - a.score);
531
636
  return ranked;
532
637
  }
533
- function registerDiscoveryTools(server, graphClient, readOnly = false, orgMode = false, authManager, _multiAccount = false) {
534
- const toolsRegistry = buildToolsRegistry(readOnly, orgMode);
535
- const searchIndex = buildDiscoverySearchIndex(toolsRegistry);
536
- logger.info(`Discovery mode: ${toolsRegistry.size} tools available in registry`);
638
+ function registerDiscoveryTools(server, graphClient, readOnly = false, orgMode = false, authManager, multiAccount = false, accountNames = [], enabledTools) {
639
+ let enabledToolsRegex;
640
+ if (enabledTools) {
641
+ try {
642
+ enabledToolsRegex = new RegExp(enabledTools, "i");
643
+ logger.info(`Discovery mode: filtering tools with pattern ${enabledTools}`);
644
+ } catch (error) {
645
+ logger.error(
646
+ `Invalid --enabled-tools regex ${JSON.stringify(enabledTools)} \u2014 ignoring filter: ${error.message}`
647
+ );
648
+ }
649
+ }
650
+ const toolsRegistry = buildToolsRegistry(readOnly, orgMode, enabledToolsRegex);
651
+ const utilityTools = UTILITY_TOOLS.filter((u) => {
652
+ if (readOnly && !u.readOnlyHint) return false;
653
+ if (enabledToolsRegex && !enabledToolsRegex.test(u.name)) return false;
654
+ return true;
655
+ });
656
+ const searchIndex = buildDiscoverySearchIndex(toolsRegistry, utilityTools);
657
+ const totalCount = toolsRegistry.size + utilityTools.length;
658
+ logger.info(
659
+ `Discovery mode: ${totalCount} tools (${toolsRegistry.size} Graph + ${utilityTools.length} utility)`
660
+ );
661
+ const utilityCtx = {
662
+ graphClient,
663
+ authManager,
664
+ multiAccount,
665
+ accountNames
666
+ };
667
+ const utilityByName = new Map(utilityTools.map((u) => [u.name, u]));
537
668
  const categoryNames = Object.keys(TOOL_CATEGORIES).join(", ");
538
669
  const toResultEntry = (name) => {
539
670
  const entry = toolsRegistry.get(name);
540
- if (!entry) return null;
541
- const { tool, config } = entry;
542
- return {
543
- name,
544
- method: tool.method.toUpperCase(),
545
- path: tool.path,
546
- description: tool.description || `${tool.method.toUpperCase()} ${tool.path}`,
547
- ...config?.llmTip ? { llmTip: config.llmTip } : {}
548
- };
671
+ if (entry) {
672
+ const { tool, config } = entry;
673
+ return {
674
+ name,
675
+ method: tool.method.toUpperCase(),
676
+ path: tool.path,
677
+ description: tool.description || `${tool.method.toUpperCase()} ${tool.path}`,
678
+ ...config?.llmTip ? { llmTip: config.llmTip } : {}
679
+ };
680
+ }
681
+ const utility = utilityByName.get(name);
682
+ if (utility) {
683
+ return {
684
+ name: utility.name,
685
+ method: utility.method,
686
+ path: utility.path,
687
+ description: utility.description
688
+ };
689
+ }
690
+ return null;
549
691
  };
550
692
  server.tool(
551
693
  "search-tools",
552
- `Search through ${toolsRegistry.size} Microsoft Graph API tools. Ranks results by BM25 over tool name, llmTip, description, and path (tokenized on hyphens, camelCase, and whitespace). After picking a tool, call get-tool-schema to see its parameters, then execute-tool to invoke it.`,
694
+ `Search through ${totalCount} tools (${toolsRegistry.size} Microsoft Graph API operations + ${utilityTools.length} server utilities like download-bytes). Ranks results by BM25 over tool name, llmTip, description, and path. After picking a tool, call get-tool-schema for parameters, then execute-tool.`,
553
695
  {
554
696
  query: z.string().describe(
555
- 'Natural-language query. Tokenized and BM25-ranked. E.g. "send email", "create calendar event", "list unread messages".'
697
+ 'Natural-language query. Tokenized and BM25-ranked. E.g. "send email", "download photo", "list unread messages".'
556
698
  ).optional(),
557
699
  category: z.string().describe(`Optional pre-filter by category: ${categoryNames}`).optional(),
558
700
  limit: z.number().describe("Maximum results (default: 10, max: 50)").optional()
@@ -571,7 +713,9 @@ function registerDiscoveryTools(server, graphClient, readOnly = false, orgMode =
571
713
  const ranked = scoreDiscoveryQuery(query, searchIndex);
572
714
  orderedNames = ranked.map((r) => r.id).filter(categoryFilter);
573
715
  } else {
574
- orderedNames = [...toolsRegistry.keys()].filter(categoryFilter);
716
+ orderedNames = [...toolsRegistry.keys(), ...utilityTools.map((u) => u.name)].filter(
717
+ categoryFilter
718
+ );
575
719
  }
576
720
  const tools = orderedNames.slice(0, maxLimit).map(toResultEntry).filter(Boolean);
577
721
  return {
@@ -581,7 +725,7 @@ function registerDiscoveryTools(server, graphClient, readOnly = false, orgMode =
581
725
  text: JSON.stringify(
582
726
  {
583
727
  found: tools.length,
584
- total: toolsRegistry.size,
728
+ total: totalCount,
585
729
  tools,
586
730
  tip: "Call get-tool-schema(tool_name) to see parameters before invoking execute-tool."
587
731
  },
@@ -606,23 +750,30 @@ function registerDiscoveryTools(server, graphClient, readOnly = false, orgMode =
606
750
  },
607
751
  async ({ tool_name }) => {
608
752
  const entry = toolsRegistry.get(tool_name);
609
- if (!entry) {
753
+ if (entry) {
754
+ const schema = describeToolSchema(entry.tool, entry.config?.llmTip);
610
755
  return {
611
- content: [
612
- {
613
- type: "text",
614
- text: JSON.stringify({
615
- error: `Tool not found: ${tool_name}`,
616
- tip: "Use search-tools to find available tools."
617
- })
618
- }
619
- ],
620
- isError: true
756
+ content: [{ type: "text", text: JSON.stringify(schema, null, 2) }]
757
+ };
758
+ }
759
+ const utility = utilityByName.get(tool_name);
760
+ if (utility) {
761
+ const schema = describeUtilityToolSchema(utility, utilityCtx);
762
+ return {
763
+ content: [{ type: "text", text: JSON.stringify(schema, null, 2) }]
621
764
  };
622
765
  }
623
- const schema = describeToolSchema(entry.tool, entry.config?.llmTip);
624
766
  return {
625
- content: [{ type: "text", text: JSON.stringify(schema, null, 2) }]
767
+ content: [
768
+ {
769
+ type: "text",
770
+ text: JSON.stringify({
771
+ error: `Tool not found: ${tool_name}`,
772
+ tip: "Use search-tools to find available tools."
773
+ })
774
+ }
775
+ ],
776
+ isError: true
626
777
  };
627
778
  }
628
779
  );
@@ -643,25 +794,36 @@ function registerDiscoveryTools(server, graphClient, readOnly = false, orgMode =
643
794
  },
644
795
  async ({ tool_name, parameters = {} }) => {
645
796
  const toolData = toolsRegistry.get(tool_name);
646
- if (!toolData) {
647
- return {
648
- content: [
649
- {
650
- type: "text",
651
- text: JSON.stringify({
652
- error: `Tool not found: ${tool_name}`,
653
- tip: "Use search-tools to find available tools."
654
- })
655
- }
656
- ],
657
- isError: true
658
- };
797
+ if (toolData) {
798
+ return executeGraphTool(
799
+ toolData.tool,
800
+ toolData.config,
801
+ graphClient,
802
+ parameters,
803
+ authManager
804
+ );
659
805
  }
660
- return executeGraphTool(toolData.tool, toolData.config, graphClient, parameters, authManager);
806
+ const utility = utilityByName.get(tool_name);
807
+ if (utility) {
808
+ return utility.execute(parameters, utilityCtx);
809
+ }
810
+ return {
811
+ content: [
812
+ {
813
+ type: "text",
814
+ text: JSON.stringify({
815
+ error: `Tool not found: ${tool_name}`,
816
+ tip: "Use search-tools to find available tools."
817
+ })
818
+ }
819
+ ],
820
+ isError: true
821
+ };
661
822
  }
662
823
  );
663
824
  }
664
825
  export {
826
+ UTILITY_TOOLS,
665
827
  buildDiscoverySearchIndex,
666
828
  buildToolsRegistry,
667
829
  registerDiscoveryTools,
@@ -30,6 +30,29 @@ function describeToolSchema(tool, llmTip) {
30
30
  parameters: params
31
31
  };
32
32
  }
33
+ function describeUtilityToolSchema(utility, ctx) {
34
+ const schemaMap = utility.buildSchema(ctx);
35
+ const params = Object.entries(schemaMap).map(([name, zodSchema]) => {
36
+ const { inner, optional } = unwrapOptional(zodSchema);
37
+ const jsonSchema = zodToJsonSchema(inner, { target: "jsonSchema7", $refStrategy: "none" });
38
+ const { $schema: _s, ...schema } = jsonSchema;
39
+ return {
40
+ name,
41
+ in: "Query",
42
+ required: !optional,
43
+ description: zodSchema.description,
44
+ schema
45
+ };
46
+ });
47
+ return {
48
+ name: utility.name,
49
+ method: utility.method,
50
+ path: utility.path,
51
+ description: utility.description,
52
+ parameters: params
53
+ };
54
+ }
33
55
  export {
34
- describeToolSchema
56
+ describeToolSchema,
57
+ describeUtilityToolSchema
35
58
  };
@@ -5,7 +5,8 @@ function buildGeneralMcpInstructions(opts) {
5
5
  "Mail and message $search uses KQL; the $search query parameter value must be double-quoted per Graph (see search-query-parameter in Microsoft Graph docs).",
6
6
  "When you need an organizational user or recipient address, resolve it with list-users (or another directory tool); do not invent SMTP addresses.",
7
7
  "Directory $search on collections such as /users or /groups requires ConsistencyLevel: eventual when the tool exposes that header.",
8
- "Teams chat and channel messages: prefer HTML contentType in the body; plain text is often mangled by Graph."
8
+ "Teams chat and channel messages: prefer HTML contentType in the body; plain text is often mangled by Graph.",
9
+ "Files / binary content: use download-bytes for any binary read (drive file content, mail attachments, profile photos, Teams hosted content, meeting recordings); pass it a Graph path or an absolute @microsoft.graph.downloadUrl from a metadata response. For uploads, upload-file-content takes a base64 string body up to 4MB; use create-upload-session above that."
9
10
  ];
10
11
  if (opts.readOnly) parts.push("This server is read-only; write operations are disabled.");
11
12
  if (opts.multiAccount)
package/dist/server.js CHANGED
@@ -74,7 +74,9 @@ class MicrosoftGraphServer {
74
74
  this.options.readOnly,
75
75
  this.options.orgMode,
76
76
  this.authManager,
77
- this.multiAccount
77
+ this.multiAccount,
78
+ this.accountNames,
79
+ this.options.enabledTools
78
80
  );
79
81
  } else {
80
82
  registerGraphTools(
@@ -1,7 +1,7 @@
1
1
  const TOOL_CATEGORIES = {
2
2
  mail: {
3
3
  name: "mail",
4
- pattern: /mail|attachment|draft/i,
4
+ pattern: /mail|attachment|draft|download-bytes/i,
5
5
  description: "Email operations (read, send, manage folders, attachments)"
6
6
  },
7
7
  calendar: {
@@ -16,12 +16,12 @@ const TOOL_CATEGORIES = {
16
16
  },
17
17
  personal: {
18
18
  name: "personal",
19
- pattern: /mail|calendar|drive|contact|todo|onenote|attachment|draft|event|file|folder|search|query/i,
19
+ pattern: /mail|calendar|drive|contact|todo|onenote|attachment|draft|event|file|folder|search|query|download-bytes|parse-teams-url/i,
20
20
  description: "Personal productivity tools (mail, calendar, files, contacts, tasks, notes, search)"
21
21
  },
22
22
  work: {
23
23
  name: "work",
24
- pattern: /team|channel|chat|sharepoint|planner|site|list|shared|search|query/i,
24
+ pattern: /team|channel|chat|sharepoint|planner|site|list|shared|search|query|download-bytes/i,
25
25
  description: "Organization/work tools (Teams, SharePoint, shared mailboxes, search)",
26
26
  requiresOrgMode: true
27
27
  },
@@ -52,7 +52,7 @@ const TOOL_CATEGORIES = {
52
52
  },
53
53
  users: {
54
54
  name: "users",
55
- pattern: /user|list-users/i,
55
+ pattern: /user|list-users|download-bytes/i,
56
56
  description: "User directory access",
57
57
  requiresOrgMode: true
58
58
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softeria/ms-365-mcp-server",
3
- "version": "0.96.0",
3
+ "version": "0.98.0",
4
4
  "description": " A Model Context Protocol (MCP) server for interacting with Microsoft 365 and Office services through the Graph API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -151,13 +151,8 @@
151
151
  "pathPattern": "/me/messages/{message-id}/attachments",
152
152
  "method": "get",
153
153
  "toolName": "list-mail-attachments",
154
- "scopes": ["Mail.Read"]
155
- },
156
- {
157
- "pathPattern": "/me/messages/{message-id}/attachments/{attachment-id}",
158
- "method": "get",
159
- "toolName": "get-mail-attachment",
160
- "scopes": ["Mail.Read"]
154
+ "scopes": ["Mail.Read"],
155
+ "llmTip": "Lists attachments on a message: id, name, contentType, size, isInline. To download the bytes, call download-bytes with target=/me/messages/{message-id}/attachments/{attachment-id}/$value (the /$value suffix returns raw bytes; the bare attachment URL embeds contentBytes in JSON which can truncate large files)."
161
156
  },
162
157
  {
163
158
  "pathPattern": "/me/messages/{message-id}/attachments/{attachment-id}",
@@ -479,14 +474,6 @@
479
474
  "toolName": "list-folder-files",
480
475
  "scopes": ["Files.Read"]
481
476
  },
482
- {
483
- "pathPattern": "/drives/{drive-id}/items/{driveItem-id}/content",
484
- "method": "get",
485
- "toolName": "download-onedrive-file-content",
486
- "scopes": ["Files.Read"],
487
- "returnDownloadUrl": true,
488
- "llmTip": "Returns a temporary download URL, NOT the file content directly."
489
- },
490
477
  {
491
478
  "pathPattern": "/drives/{drive-id}/items/{driveItem-id}",
492
479
  "method": "delete",
@@ -498,7 +485,7 @@
498
485
  "method": "put",
499
486
  "toolName": "upload-file-content",
500
487
  "scopes": ["Files.ReadWrite"],
501
- "llmTip": "Max 4MB. For new files use path format: /items/root:/path/to/file.txt:/content. Overwrites existing files without warning."
488
+ "llmTip": "Body is a base64-encoded string of the file bytes; the server decodes it before PUT. Max 4MB inline (use create-upload-session above 4MB). For new files use path format: /items/root:/path/to/file.txt:/content. Overwrites existing files without warning."
502
489
  },
503
490
  {
504
491
  "pathPattern": "/drives/{drive-id}/items/{driveItem-id}/createUploadSession",
@@ -512,7 +499,7 @@
512
499
  "method": "get",
513
500
  "toolName": "get-drive-item",
514
501
  "scopes": ["Files.Read"],
515
- "llmTip": "Gets metadata for a file or folder: name, size, lastModifiedDateTime, createdBy, webUrl, file (mimeType, hashes), folder (childCount), parentReference. Does not return content use download-onedrive-file-content for that."
502
+ "llmTip": "Gets metadata for a file or folder: name, size, lastModifiedDateTime, createdBy, webUrl, file (mimeType, hashes), folder (childCount), parentReference, and @microsoft.graph.downloadUrl. For the bytes, call download-bytes with target=/drives/{drive-id}/items/{driveItem-id}/content (Graph redirects to the same downloadUrl)."
516
503
  },
517
504
  {
518
505
  "pathPattern": "/drives/{drive-id}/items/{driveItem-id}",
@@ -1077,22 +1064,6 @@
1077
1064
  "workScopes": ["User.Read.All"],
1078
1065
  "llmTip": "Lists users who report directly to a specific user. Use with get-user-manager to traverse the org hierarchy."
1079
1066
  },
1080
- {
1081
- "pathPattern": "/me/photo/$value",
1082
- "method": "get",
1083
- "toolName": "get-my-profile-photo",
1084
- "scopes": ["User.Read"],
1085
- "returnDownloadUrl": true,
1086
- "llmTip": "Returns the current user's profile photo as binary image data. The photo is typically in JPEG format."
1087
- },
1088
- {
1089
- "pathPattern": "/users/{user-id}/photo/$value",
1090
- "method": "get",
1091
- "toolName": "get-user-profile-photo",
1092
- "workScopes": ["User.Read.All"],
1093
- "returnDownloadUrl": true,
1094
- "llmTip": "Returns a specific user's profile photo as binary image data. Use the user ID or UPN (email)."
1095
- },
1096
1067
  {
1097
1068
  "pathPattern": "/me/people",
1098
1069
  "method": "get",
@@ -1185,15 +1156,7 @@
1185
1156
  "method": "get",
1186
1157
  "toolName": "list-chat-message-hosted-contents",
1187
1158
  "workScopes": ["ChatMessage.Read"],
1188
- "llmTip": "Lists hosted-content references (inline images, code snippets) attached to a Teams chat message. Returns an array of { id, contentType }. Use get-chat-message-hosted-content with the id to download the bytes. Hosted content IDs can also be parsed from the <img src> URL in the message body between /hostedContents/ and /$value."
1189
- },
1190
- {
1191
- "pathPattern": "/chats/{chat-id}/messages/{chatMessage-id}/hostedContents/{chatMessageHostedContent-id}/$value",
1192
- "method": "get",
1193
- "toolName": "get-chat-message-hosted-content",
1194
- "workScopes": ["ChatMessage.Read"],
1195
- "returnDownloadUrl": true,
1196
- "llmTip": "Returns raw bytes of a hosted content item (typically image/png or image/jpeg) attached to a Teams 1:1 or group chat message. Use list-chat-message-hosted-contents to discover IDs, or extract the ID from the <img src> URL in the message body."
1159
+ "llmTip": "Lists hosted-content references (inline images, code snippets) attached to a Teams chat message. Returns { id, contentType } per item. To download bytes, call download-bytes with target=/chats/{chat-id}/messages/{chatMessage-id}/hostedContents/{id}/$value. IDs can also be extracted from the <img src> URL in the message body between /hostedContents/ and /$value."
1197
1160
  },
1198
1161
  {
1199
1162
  "pathPattern": "/chats/{chat-id}/messages",
@@ -1261,15 +1224,7 @@
1261
1224
  "method": "get",
1262
1225
  "toolName": "list-channel-message-hosted-contents",
1263
1226
  "workScopes": ["ChannelMessage.Read.All"],
1264
- "llmTip": "Lists hosted-content references (inline images, code snippets) attached to a Teams channel message. Returns an array of { id, contentType }. Use get-channel-message-hosted-content with the id to download bytes."
1265
- },
1266
- {
1267
- "pathPattern": "/teams/{team-id}/channels/{channel-id}/messages/{chatMessage-id}/hostedContents/{chatMessageHostedContent-id}/$value",
1268
- "method": "get",
1269
- "toolName": "get-channel-message-hosted-content",
1270
- "workScopes": ["ChannelMessage.Read.All"],
1271
- "returnDownloadUrl": true,
1272
- "llmTip": "Returns raw bytes of a hosted content item attached to a Teams channel message. Use list-channel-message-hosted-contents to discover IDs."
1227
+ "llmTip": "Lists hosted-content references (inline images, code snippets) attached to a Teams channel message. Returns { id, contentType } per item. To download bytes, call download-bytes with target=/teams/{team-id}/channels/{channel-id}/messages/{chatMessage-id}/hostedContents/{id}/$value."
1273
1228
  },
1274
1229
  {
1275
1230
  "pathPattern": "/teams/{team-id}/channels/{channel-id}/messages",
@@ -14,7 +14,7 @@ export type Endpoint = {
14
14
  path: string;
15
15
  alias: string;
16
16
  description?: string;
17
- requestFormat: 'json';
17
+ requestFormat: 'json' | 'binary' | 'form-data' | 'form-url' | 'text';
18
18
  parameters?: Parameter[];
19
19
  response: z.ZodType<any>;
20
20
  errors?: Array<{