@softeria/ms-365-mcp-server 0.125.1 → 0.127.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.
@@ -145,6 +145,10 @@ export function createAndSaveSimplifiedOpenAPI(
145
145
  }
146
146
  }
147
147
 
148
+ if (apiVersion === 'beta') {
149
+ normalizeWildcardSuccessResponses(openApiSpec.paths);
150
+ }
151
+
148
152
  if (openApiSpec.components && openApiSpec.components.schemas) {
149
153
  removeODataTypeRecursively(openApiSpec.components.schemas);
150
154
  flattenComplexSchemasRecursively(openApiSpec.components.schemas);
@@ -162,6 +166,29 @@ export function createAndSaveSimplifiedOpenAPI(
162
166
  fs.writeFileSync(openapiTrimmedFile, yaml.dump(openApiSpec));
163
167
  }
164
168
 
169
+ function normalizeWildcardSuccessResponses(paths) {
170
+ Object.values(paths || {}).forEach((pathItem) => {
171
+ if (!pathItem || typeof pathItem !== 'object') return;
172
+
173
+ Object.entries(pathItem).forEach(([method, operation]) => {
174
+ if (!operation || typeof operation !== 'object') return;
175
+ if (!operation.responses || !operation.responses['2XX']) return;
176
+
177
+ const hasConcreteSuccess = Object.keys(operation.responses).some((statusCode) =>
178
+ /^2\d\d$/.test(statusCode)
179
+ );
180
+ if (hasConcreteSuccess) {
181
+ delete operation.responses['2XX'];
182
+ return;
183
+ }
184
+
185
+ const successStatus = method === 'post' ? '201' : method === 'delete' ? '204' : '200';
186
+ operation.responses[successStatus] = operation.responses['2XX'];
187
+ delete operation.responses['2XX'];
188
+ });
189
+ });
190
+ }
191
+
165
192
  function removeODataTypeRecursively(obj) {
166
193
  if (!obj || typeof obj !== 'object') return;
167
194
 
@@ -91,6 +91,10 @@ function createMockServer() {
91
91
  tools
92
92
  };
93
93
  }
94
+ function makeJwt(payload) {
95
+ const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
96
+ return `header.${body}.signature`;
97
+ }
94
98
  describe("graph-tools", () => {
95
99
  beforeEach(() => {
96
100
  mockEndpoints.length = 0;
@@ -665,6 +669,351 @@ describe("graph-tools", () => {
665
669
  expect(payload.error).toMatch(/relative Microsoft Graph path/);
666
670
  });
667
671
  });
672
+ describe("get-download-url", () => {
673
+ it("strips /content, fetches item metadata, and returns the pre-authed downloadUrl", async () => {
674
+ mockEndpoints.length = 0;
675
+ mockEndpointsJson = [];
676
+ const downloadUrl = "https://contoso.sharepoint.com/download.aspx?tempauth=abc";
677
+ const graphClient = {
678
+ graphRequest: vi.fn().mockResolvedValue({
679
+ content: [
680
+ {
681
+ type: "text",
682
+ text: JSON.stringify({
683
+ id: "item1",
684
+ name: "report.pdf",
685
+ size: 12727,
686
+ file: { mimeType: "application/pdf" },
687
+ "@microsoft.graph.downloadUrl": downloadUrl
688
+ })
689
+ }
690
+ ]
691
+ })
692
+ };
693
+ const server = createMockServer();
694
+ const { registerGraphTools } = await loadModule();
695
+ registerGraphTools(server, graphClient);
696
+ const tool = server.tools.get("get-download-url");
697
+ expect(tool).toBeDefined();
698
+ const result = await tool.handler({
699
+ target: "/drives/d1/items/item1/content"
700
+ });
701
+ const [requestedPath] = graphClient.graphRequest.mock.calls[0];
702
+ expect(requestedPath).toBe("/drives/d1/items/item1");
703
+ const payload = JSON.parse(result.content[0].text);
704
+ expect(payload.downloadUrl).toBe(downloadUrl);
705
+ expect(payload.name).toBe("report.pdf");
706
+ expect(payload.size).toBe(12727);
707
+ expect(payload.contentType).toBe("application/pdf");
708
+ });
709
+ it("rejects query-shaped targets instead of silently changing request semantics", async () => {
710
+ mockEndpoints.length = 0;
711
+ mockEndpointsJson = [];
712
+ const graphClient = { graphRequest: vi.fn() };
713
+ const server = createMockServer();
714
+ const { registerGraphTools } = await loadModule();
715
+ registerGraphTools(server, graphClient);
716
+ const tool = server.tools.get("get-download-url");
717
+ const result = await tool.handler({
718
+ target: "/drives/d1/items/item1/content?$select=id,name"
719
+ });
720
+ expect(result.isError).toBe(true);
721
+ expect(graphClient.graphRequest).not.toHaveBeenCalled();
722
+ const payload = JSON.parse(result.content[0].text);
723
+ expect(payload.error).toContain("must not include query parameters");
724
+ });
725
+ it("rejects non-drive Graph targets before making an authenticated request", async () => {
726
+ mockEndpoints.length = 0;
727
+ mockEndpointsJson = [];
728
+ const graphClient = { graphRequest: vi.fn() };
729
+ const server = createMockServer();
730
+ const { registerGraphTools } = await loadModule();
731
+ registerGraphTools(server, graphClient);
732
+ const tool = server.tools.get("get-download-url");
733
+ const result = await tool.handler({
734
+ target: "/me/messages/m1"
735
+ });
736
+ expect(result.isError).toBe(true);
737
+ expect(graphClient.graphRequest).not.toHaveBeenCalled();
738
+ const payload = JSON.parse(result.content[0].text);
739
+ expect(payload.error).toContain("target must identify a driveItem");
740
+ });
741
+ it("rejects mail attachment $value paths (no pre-authed URL exists)", async () => {
742
+ mockEndpoints.length = 0;
743
+ mockEndpointsJson = [];
744
+ const graphClient = { graphRequest: vi.fn() };
745
+ const server = createMockServer();
746
+ const { registerGraphTools } = await loadModule();
747
+ registerGraphTools(server, graphClient);
748
+ const tool = server.tools.get("get-download-url");
749
+ const result = await tool.handler({
750
+ target: "/me/messages/m1/attachments/a1/$value"
751
+ });
752
+ expect(result.isError).toBe(true);
753
+ expect(graphClient.graphRequest).not.toHaveBeenCalled();
754
+ const payload = JSON.parse(result.content[0].text);
755
+ expect(payload.error).toMatch(/do not expose a pre-authenticated download URL/);
756
+ });
757
+ it("rejects calendar event attachment $value paths (no pre-authed URL exists)", async () => {
758
+ mockEndpoints.length = 0;
759
+ mockEndpointsJson = [];
760
+ const graphClient = { graphRequest: vi.fn() };
761
+ const server = createMockServer();
762
+ const { registerGraphTools } = await loadModule();
763
+ registerGraphTools(server, graphClient);
764
+ const tool = server.tools.get("get-download-url");
765
+ const result = await tool.handler({
766
+ target: "/me/events/e1/attachments/a1/$value"
767
+ });
768
+ expect(result.isError).toBe(true);
769
+ expect(graphClient.graphRequest).not.toHaveBeenCalled();
770
+ const payload = JSON.parse(result.content[0].text);
771
+ expect(payload.error).toMatch(/Mail and calendar event attachments/);
772
+ });
773
+ it("rejects group mailbox attachment paths (no pre-authed URL exists)", async () => {
774
+ mockEndpoints.length = 0;
775
+ mockEndpointsJson = [];
776
+ const graphClient = { graphRequest: vi.fn() };
777
+ const server = createMockServer();
778
+ const { registerGraphTools } = await loadModule();
779
+ registerGraphTools(server, graphClient);
780
+ const tool = server.tools.get("get-download-url");
781
+ const result = await tool.handler({
782
+ target: "/groups/g1/messages/m1/attachments/a1/$value"
783
+ });
784
+ expect(result.isError).toBe(true);
785
+ expect(graphClient.graphRequest).not.toHaveBeenCalled();
786
+ const payload = JSON.parse(result.content[0].text);
787
+ expect(payload.error).toMatch(/Mail and calendar event attachments/);
788
+ });
789
+ it("rejects list-item driveItem relationships until callers provide a drive item path", async () => {
790
+ mockEndpoints.length = 0;
791
+ mockEndpointsJson = [];
792
+ const graphClient = { graphRequest: vi.fn() };
793
+ const server = createMockServer();
794
+ const { registerGraphTools } = await loadModule();
795
+ registerGraphTools(server, graphClient);
796
+ const tool = server.tools.get("get-download-url");
797
+ const result = await tool.handler({
798
+ target: "/sites/site1/lists/list1/items/item1/driveItem"
799
+ });
800
+ expect(result.isError).toBe(true);
801
+ expect(graphClient.graphRequest).not.toHaveBeenCalled();
802
+ const payload = JSON.parse(result.content[0].text);
803
+ expect(payload.error).toContain("target must identify a driveItem");
804
+ });
805
+ it("errors when the resource exposes no downloadUrl", async () => {
806
+ mockEndpoints.length = 0;
807
+ mockEndpointsJson = [];
808
+ const graphClient = {
809
+ graphRequest: vi.fn().mockResolvedValue({
810
+ content: [{ type: "text", text: JSON.stringify({ id: "item1", name: "x" }) }]
811
+ })
812
+ };
813
+ const server = createMockServer();
814
+ const { registerGraphTools } = await loadModule();
815
+ registerGraphTools(server, graphClient);
816
+ const tool = server.tools.get("get-download-url");
817
+ const result = await tool.handler({ target: "/drives/d1/items/item1" });
818
+ expect(result.isError).toBe(true);
819
+ const payload = JSON.parse(result.content[0].text);
820
+ expect(payload.error).toMatch(/No pre-authenticated download URL/);
821
+ });
822
+ it("surfaces the underlying Graph error instead of masking it as no-downloadUrl", async () => {
823
+ mockEndpoints.length = 0;
824
+ mockEndpointsJson = [];
825
+ const graphClient = {
826
+ graphRequest: vi.fn().mockResolvedValue({
827
+ isError: true,
828
+ content: [
829
+ {
830
+ type: "text",
831
+ text: JSON.stringify({ error: "Microsoft Graph API error: 403 Forbidden" })
832
+ }
833
+ ]
834
+ })
835
+ };
836
+ const server = createMockServer();
837
+ const { registerGraphTools } = await loadModule();
838
+ registerGraphTools(server, graphClient);
839
+ const tool = server.tools.get("get-download-url");
840
+ const result = await tool.handler({ target: "/drives/d1/items/item1/content" });
841
+ expect(result.isError).toBe(true);
842
+ const payload = JSON.parse(result.content[0].text);
843
+ expect(payload.error).toMatch(/403 Forbidden/);
844
+ expect(payload.error).not.toMatch(/No pre-authenticated download URL/);
845
+ });
846
+ it('does not falsely reject drive folders literally named "attachments"', async () => {
847
+ mockEndpoints.length = 0;
848
+ mockEndpointsJson = [];
849
+ const downloadUrl = "https://contoso.sharepoint.com/download.aspx?tempauth=xyz";
850
+ const graphClient = {
851
+ graphRequest: vi.fn().mockResolvedValue({
852
+ content: [
853
+ {
854
+ type: "text",
855
+ text: JSON.stringify({
856
+ name: "report.pdf",
857
+ "@microsoft.graph.downloadUrl": downloadUrl
858
+ })
859
+ }
860
+ ]
861
+ })
862
+ };
863
+ const server = createMockServer();
864
+ const { registerGraphTools } = await loadModule();
865
+ registerGraphTools(server, graphClient);
866
+ const tool = server.tools.get("get-download-url");
867
+ const result = await tool.handler({
868
+ target: "/me/drive/root:/Project/attachments/report.pdf:/content"
869
+ });
870
+ expect(result.isError).toBeFalsy();
871
+ const payload = JSON.parse(result.content[0].text);
872
+ expect(payload.downloadUrl).toBe(downloadUrl);
873
+ });
874
+ it("does not falsely reject drive item paths containing messages and attachments folders", async () => {
875
+ mockEndpoints.length = 0;
876
+ mockEndpointsJson = [];
877
+ const downloadUrl = "https://contoso.sharepoint.com/download.aspx?tempauth=folders";
878
+ const graphClient = {
879
+ graphRequest: vi.fn().mockResolvedValue({
880
+ content: [
881
+ {
882
+ type: "text",
883
+ text: JSON.stringify({
884
+ name: "report.pdf",
885
+ "@microsoft.graph.downloadUrl": downloadUrl
886
+ })
887
+ }
888
+ ]
889
+ })
890
+ };
891
+ const server = createMockServer();
892
+ const { registerGraphTools } = await loadModule();
893
+ registerGraphTools(server, graphClient);
894
+ const tool = server.tools.get("get-download-url");
895
+ const result = await tool.handler({
896
+ target: "/me/drive/root:/messages/m1/attachments/a1/report.pdf:/content"
897
+ });
898
+ expect(result.isError).toBeFalsy();
899
+ const [requestedPath] = graphClient.graphRequest.mock.calls[0];
900
+ expect(requestedPath).toBe("/me/drive/root:/messages/m1/attachments/a1/report.pdf:");
901
+ const payload = JSON.parse(result.content[0].text);
902
+ expect(payload.downloadUrl).toBe(downloadUrl);
903
+ });
904
+ it("allows SharePoint site drive item paths", async () => {
905
+ mockEndpoints.length = 0;
906
+ mockEndpointsJson = [];
907
+ const downloadUrl = "https://contoso.sharepoint.com/download.aspx?tempauth=site-drive";
908
+ const graphClient = {
909
+ graphRequest: vi.fn().mockResolvedValue({
910
+ content: [
911
+ {
912
+ type: "text",
913
+ text: JSON.stringify({
914
+ name: "site-report.pdf",
915
+ "@microsoft.graph.downloadUrl": downloadUrl
916
+ })
917
+ }
918
+ ]
919
+ })
920
+ };
921
+ const server = createMockServer();
922
+ const { registerGraphTools } = await loadModule();
923
+ registerGraphTools(server, graphClient);
924
+ const tool = server.tools.get("get-download-url");
925
+ const result = await tool.handler({
926
+ target: "/sites/site1/drive/items/item1/content"
927
+ });
928
+ expect(result.isError).toBeFalsy();
929
+ const [requestedPath] = graphClient.graphRequest.mock.calls[0];
930
+ expect(requestedPath).toBe("/sites/site1/drive/items/item1");
931
+ const payload = JSON.parse(result.content[0].text);
932
+ expect(payload.downloadUrl).toBe(downloadUrl);
933
+ });
934
+ it("does not strip a drive item path whose item name is content", async () => {
935
+ mockEndpoints.length = 0;
936
+ mockEndpointsJson = [];
937
+ const downloadUrl = "https://contoso.sharepoint.com/download.aspx?tempauth=content-file";
938
+ const graphClient = {
939
+ graphRequest: vi.fn().mockResolvedValue({
940
+ content: [
941
+ {
942
+ type: "text",
943
+ text: JSON.stringify({
944
+ name: "content",
945
+ "@microsoft.graph.downloadUrl": downloadUrl
946
+ })
947
+ }
948
+ ]
949
+ })
950
+ };
951
+ const server = createMockServer();
952
+ const { registerGraphTools } = await loadModule();
953
+ registerGraphTools(server, graphClient);
954
+ const tool = server.tools.get("get-download-url");
955
+ const result = await tool.handler({
956
+ target: "/me/drive/root:/Project/content:"
957
+ });
958
+ expect(result.isError).toBeFalsy();
959
+ const [requestedPath] = graphClient.graphRequest.mock.calls[0];
960
+ expect(requestedPath).toBe("/me/drive/root:/Project/content:");
961
+ const payload = JSON.parse(result.content[0].text);
962
+ expect(payload.downloadUrl).toBe(downloadUrl);
963
+ });
964
+ it("rejects meeting recording content paths because Graph returns authenticated bytes", async () => {
965
+ mockEndpoints.length = 0;
966
+ mockEndpointsJson = [];
967
+ const graphClient = { graphRequest: vi.fn() };
968
+ const server = createMockServer();
969
+ const { registerGraphTools } = await loadModule();
970
+ registerGraphTools(server, graphClient);
971
+ const tool = server.tools.get("get-download-url");
972
+ const result = await tool.handler({
973
+ target: "/me/onlineMeetings/meeting1/recordings/recording1/content"
974
+ });
975
+ expect(result.isError).toBe(true);
976
+ expect(graphClient.graphRequest).not.toHaveBeenCalled();
977
+ const payload = JSON.parse(result.content[0].text);
978
+ expect(payload.error).toMatch(/Meeting recordings do not expose/);
979
+ });
980
+ it("refuses mismatched account param in bearer mode before resolving download URL", async () => {
981
+ mockEndpoints.length = 0;
982
+ mockEndpointsJson = [];
983
+ const graphClient = { graphRequest: vi.fn() };
984
+ const authManager = {
985
+ isOAuthModeEnabled: vi.fn().mockReturnValue(false),
986
+ getToken: vi.fn().mockResolvedValue(null),
987
+ getTokenForAccount: vi.fn()
988
+ };
989
+ const server = createMockServer();
990
+ const { registerGraphTools } = await loadModule();
991
+ registerGraphTools(
992
+ server,
993
+ graphClient,
994
+ false,
995
+ void 0,
996
+ false,
997
+ authManager,
998
+ true,
999
+ ["user1@domain.com", "user2@domain.com"]
1000
+ );
1001
+ const { requestContext } = await import("../request-context.js");
1002
+ const tool = server.tools.get("get-download-url");
1003
+ const bearer = makeJwt({ upn: "user1@domain.com" });
1004
+ const result = await requestContext.run(
1005
+ { accessToken: bearer },
1006
+ () => tool.handler({
1007
+ target: "/drives/d1/items/item1/content",
1008
+ account: "user2@domain.com"
1009
+ })
1010
+ );
1011
+ expect(result.isError).toBe(true);
1012
+ expect(result.content[0].text).toContain("'account' parameter is not supported");
1013
+ expect(graphClient.graphRequest).not.toHaveBeenCalled();
1014
+ expect(authManager.getTokenForAccount).not.toHaveBeenCalled();
1015
+ });
1016
+ });
668
1017
  describe("allowed scopes filtering", () => {
669
1018
  it("registerGraphTools hides Graph tools outside the allowed scopes", async () => {
670
1019
  mockEndpoints.push(
@@ -788,6 +1137,9 @@ describe("graph-tools", () => {
788
1137
  const targetParam = schema.parameters.find((p) => p.name === "target");
789
1138
  expect(targetParam).toBeDefined();
790
1139
  expect(targetParam.required).toBe(true);
1140
+ expect(targetParam.description).toContain("authenticated recording bytes");
1141
+ expect(targetParam.description).not.toContain("returns a URL");
1142
+ expect(schema.description).toContain("For large drive/SharePoint file content");
791
1143
  });
792
1144
  it("execute-tool dispatches to download-bytes for a Graph path", async () => {
793
1145
  mockEndpoints.length = 0;
@@ -594,7 +594,7 @@
594
594
  "toolName": "get-drive-item",
595
595
  "presets": ["files", "onedrive", "personal"],
596
596
  "scopes": ["Files.Read"],
597
- "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)."
597
+ "llmTip": "Gets metadata for a file or folder: name, size, lastModifiedDateTime, createdBy, webUrl, file (mimeType, hashes), folder (childCount), parentReference, and @microsoft.graph.downloadUrl. For large drive/SharePoint files, call get-download-url with target=/drives/{drive-id}/items/{driveItem-id}/content to fetch out-of-band with no Authorization header. For small files where base64 in the tool response is acceptable, call download-bytes with the same /content target."
598
598
  },
599
599
  {
600
600
  "pathPattern": "/drives/{drive-id}/items/{driveItem-id}",
@@ -1167,6 +1167,30 @@
1167
1167
  "scopes": ["Tasks.ReadWrite"],
1168
1168
  "llmTip": "CRITICAL: Requires If-Match header with ETag from get-planner-bucket (use includeHeaders=true)."
1169
1169
  },
1170
+ {
1171
+ "pathPattern": "/planner/tasks/{plannerTask-id}/messages",
1172
+ "method": "get",
1173
+ "toolName": "list-planner-task-messages",
1174
+ "scopes": ["Tasks.Read"],
1175
+ "apiVersion": "beta",
1176
+ "llmTip": "Lists messages in a Planner task's chat — the modern Planner 'task chat', distinct from the legacy conversationThreadId comments (which live in the M365 group conversation thread). Each message has id, content (HTML), createdBy, createdDateTime, mentions, reactions. BETA Graph API: subject to change; delegated work/school accounts only — no application permissions, no personal Microsoft accounts, global cloud only (not GCC/DoD/21Vianet)."
1177
+ },
1178
+ {
1179
+ "pathPattern": "/planner/tasks/{plannerTask-id}/messages",
1180
+ "method": "post",
1181
+ "toolName": "create-planner-task-message",
1182
+ "scopes": ["Tasks.ReadWrite"],
1183
+ "apiVersion": "beta",
1184
+ "llmTip": "Posts a message to a Planner task's chat (the modern 'task chat', not the legacy conversationThreadId comment). Microsoft is retiring the classic task comments experience and hiding it from the task (Planner 2026 update), so task chat is the current supported way to post per-task updates teammates will see, with @mentions. Body: { content: 'plain text or sanitized HTML', mentions?: [{ mentioned: 'user-id', position: 0, mentionType: 'user' }] } (mentions optional). No ETag/If-Match required. BETA Graph API: subject to change; delegated work/school accounts only — no application permissions, no personal Microsoft accounts, global cloud only (not GCC/DoD/21Vianet)."
1185
+ },
1186
+ {
1187
+ "pathPattern": "/planner/tasks/{plannerTask-id}/messages/{plannerTaskChatMessage-id}",
1188
+ "method": "delete",
1189
+ "toolName": "delete-planner-task-message",
1190
+ "scopes": ["Tasks.ReadWrite"],
1191
+ "apiVersion": "beta",
1192
+ "llmTip": "Deletes a message from a Planner task's chat. No request body; If-Match is optional if you want conditional deletion; returns 204. BETA Graph API: subject to change; delegated work/school accounts only — no application permissions, no personal Microsoft accounts, global cloud only (not GCC/DoD/21Vianet)."
1193
+ },
1170
1194
  {
1171
1195
  "pathPattern": "/me/contacts",
1172
1196
  "method": "get",
@@ -1880,8 +1904,7 @@
1880
1904
  "toolName": "get-meeting-recording-content",
1881
1905
  "presets": ["teams", "work"],
1882
1906
  "workScopes": ["OnlineMeetingRecording.Read.All"],
1883
- "returnDownloadUrl": true,
1884
- "llmTip": "Returns a temporary download URL for the meeting recording in MP4 format. Use the download URL to access the video file. The recording may be large — do not attempt to stream it inline."
1907
+ "llmTip": "Returns the authenticated meeting recording video bytes in MP4 format. The recording may be large; prefer out-of-band handling by the client when possible, but Microsoft Graph does not expose a pre-authenticated download URL for this resource."
1885
1908
  },
1886
1909
  {
1887
1910
  "pathPattern": "/me/onlineMeetings/{onlineMeeting-id}/attendanceReports",
@@ -690,6 +690,58 @@ const microsoft_graph_ODataErrors_MainError = z.object({
690
690
  innerError: microsoft_graph_ODataErrors_InnerError.optional()
691
691
  }).passthrough();
692
692
  const microsoft_graph_ODataErrors_ODataError = z.object({ error: microsoft_graph_ODataErrors_MainError }).passthrough();
693
+ const microsoft_graph_plannerTaskChatMentionType = z.enum([
694
+ "user",
695
+ "application",
696
+ "unknownFutureValue"
697
+ ]);
698
+ const microsoft_graph_plannerTaskChatMention = z.object({
699
+ mentioned: z.string().describe("The ID of the mentioned user.").nullish(),
700
+ mentionType: microsoft_graph_plannerTaskChatMentionType.optional(),
701
+ position: z.number().gte(-2147483648).lte(2147483647).describe("The zero-based position of the mention in the message content.").optional()
702
+ }).passthrough();
703
+ const microsoft_graph_plannerTaskChatMessageType = z.enum([
704
+ "richTextHtml",
705
+ "plainText",
706
+ "unknownFutureValue"
707
+ ]);
708
+ const microsoft_graph_plannerTaskChatReactionEvent = z.object({
709
+ createdBy: microsoft_graph_identitySet.optional(),
710
+ createdDateTime: z.string().regex(
711
+ /^[0-9]{4,}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]{1,12})?(Z|[+-][0-9][0-9]:[0-9][0-9])$/
712
+ ).datetime({ offset: true }).describe(
713
+ "The date and time when the reaction was added. The timestamp type represents date and time information using ISO 8601 format and is always in UTC. For example, midnight UTC on Jan 1, 2014 is 2014-01-01T00:00:00Z."
714
+ ).optional()
715
+ }).passthrough();
716
+ const microsoft_graph_plannerTaskChatReaction = z.object({
717
+ reactionEvents: z.array(microsoft_graph_plannerTaskChatReactionEvent).optional(),
718
+ reactionType: z.string().describe("The type of reaction, such as like, heart, or emoji characters.").nullish()
719
+ }).passthrough();
720
+ const microsoft_graph_plannerTaskChatMessage = z.object({
721
+ id: z.string().describe("The unique identifier for an entity. Read-only.").optional(),
722
+ content: z.string().describe("The content of the chat message. Supports plain text and sanitized HTML.").nullish(),
723
+ createdBy: microsoft_graph_identitySet.optional(),
724
+ createdDateTime: z.string().regex(
725
+ /^[0-9]{4,}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]{1,12})?(Z|[+-][0-9][0-9]:[0-9][0-9])$/
726
+ ).datetime({ offset: true }).describe(
727
+ "The date and time when the message was created. The timestamp type represents date and time information using ISO 8601 format and is always in UTC. For example, midnight UTC on Jan 1, 2014 is 2014-01-01T00:00:00Z."
728
+ ).optional(),
729
+ deletedDateTime: z.string().regex(
730
+ /^[0-9]{4,}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]{1,12})?(Z|[+-][0-9][0-9]:[0-9][0-9])$/
731
+ ).datetime({ offset: true }).nullish(),
732
+ editedDateTime: z.string().regex(
733
+ /^[0-9]{4,}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]{1,12})?(Z|[+-][0-9][0-9]:[0-9][0-9])$/
734
+ ).datetime({ offset: true }).nullish(),
735
+ mentions: z.array(microsoft_graph_plannerTaskChatMention).describe("The list of mentions in the message.").optional(),
736
+ messageType: microsoft_graph_plannerTaskChatMessageType.optional(),
737
+ parentEntityId: z.string().describe("The ID of the parent plannerTask that this message belongs to.").nullish(),
738
+ reactions: z.array(microsoft_graph_plannerTaskChatReaction).describe("The reactions on the message.").optional()
739
+ }).passthrough();
740
+ const microsoft_graph_plannerTaskChatMessageCollectionResponse = z.object({
741
+ "@odata.count": z.number().int().nullable(),
742
+ "@odata.nextLink": z.string().nullable(),
743
+ value: z.array(microsoft_graph_plannerTaskChatMessage)
744
+ }).partial().passthrough();
693
745
  const schemas = {
694
746
  microsoft_graph_allowedAudiences,
695
747
  microsoft_graph_identity,
@@ -741,7 +793,14 @@ const schemas = {
741
793
  microsoft_graph_ODataErrors_ErrorDetails,
742
794
  microsoft_graph_ODataErrors_InnerError,
743
795
  microsoft_graph_ODataErrors_MainError,
744
- microsoft_graph_ODataErrors_ODataError
796
+ microsoft_graph_ODataErrors_ODataError,
797
+ microsoft_graph_plannerTaskChatMentionType,
798
+ microsoft_graph_plannerTaskChatMention,
799
+ microsoft_graph_plannerTaskChatMessageType,
800
+ microsoft_graph_plannerTaskChatReactionEvent,
801
+ microsoft_graph_plannerTaskChatReaction,
802
+ microsoft_graph_plannerTaskChatMessage,
803
+ microsoft_graph_plannerTaskChatMessageCollectionResponse
745
804
  };
746
805
  const endpoints = makeApi([
747
806
  {
@@ -762,6 +821,87 @@ const endpoints = makeApi([
762
821
  schema: z.array(z.string()).describe("Expand related entities").optional()
763
822
  }
764
823
  ],
824
+ response: microsoft_graph_profile
825
+ },
826
+ {
827
+ method: "get",
828
+ path: "/planner/tasks/:plannerTaskId/messages",
829
+ alias: "list-planner-task-messages",
830
+ description: `Retrieve a list of plannerTaskChatMessage objects associated with a plannerTask.`,
831
+ requestFormat: "json",
832
+ parameters: [
833
+ {
834
+ name: "$top",
835
+ type: "Query",
836
+ schema: z.number().int().gte(0).describe("Show only the first n items").optional()
837
+ },
838
+ {
839
+ name: "$skip",
840
+ type: "Query",
841
+ schema: z.number().int().gte(0).describe("Skip the first n items").optional()
842
+ },
843
+ {
844
+ name: "$search",
845
+ type: "Query",
846
+ schema: z.string().describe("Search items by search phrases").optional()
847
+ },
848
+ {
849
+ name: "$filter",
850
+ type: "Query",
851
+ schema: z.string().describe("Filter items by property values").optional()
852
+ },
853
+ {
854
+ name: "$count",
855
+ type: "Query",
856
+ schema: z.boolean().describe("Include count of items").optional()
857
+ },
858
+ {
859
+ name: "$orderby",
860
+ type: "Query",
861
+ schema: z.array(z.string()).describe("Order items by property values").optional()
862
+ },
863
+ {
864
+ name: "$select",
865
+ type: "Query",
866
+ schema: z.array(z.string()).describe("Select properties to be returned").optional()
867
+ },
868
+ {
869
+ name: "$expand",
870
+ type: "Query",
871
+ schema: z.array(z.string()).describe("Expand related entities").optional()
872
+ }
873
+ ],
874
+ response: microsoft_graph_plannerTaskChatMessageCollectionResponse
875
+ },
876
+ {
877
+ method: "post",
878
+ path: "/planner/tasks/:plannerTaskId/messages",
879
+ alias: "create-planner-task-message",
880
+ description: `Create a new plannerTaskChatMessage on a plannerTask.`,
881
+ requestFormat: "json",
882
+ parameters: [
883
+ {
884
+ name: "body",
885
+ description: `New navigation property`,
886
+ type: "Body",
887
+ schema: microsoft_graph_plannerTaskChatMessage
888
+ }
889
+ ],
890
+ response: microsoft_graph_plannerTaskChatMessage
891
+ },
892
+ {
893
+ method: "delete",
894
+ path: "/planner/tasks/:plannerTaskId/messages/:plannerTaskChatMessageId",
895
+ alias: "delete-planner-task-message",
896
+ description: `Delete a plannerTaskChatMessage object.`,
897
+ requestFormat: "json",
898
+ parameters: [
899
+ {
900
+ name: "If-Match",
901
+ type: "Header",
902
+ schema: z.string().describe("ETag").optional()
903
+ }
904
+ ],
765
905
  response: z.void()
766
906
  }
767
907
  ]);
@@ -115,13 +115,13 @@ const UTILITY_TOOLS = [
115
115
  name: "download-bytes",
116
116
  method: "GET",
117
117
  path: "tool:download-bytes",
118
- 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 }.',
118
+ 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 }. For large drive/SharePoint file content, prefer get-download-url, which returns a pre-authenticated URL to stream bytes out-of-band instead of base64 through the agent context.',
119
119
  readOnlyHint: true,
120
120
  openWorldHint: true,
121
121
  buildSchema: (ctx) => {
122
122
  const schema = {
123
123
  target: z.string().describe(
124
- '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.'
124
+ '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, use get-meeting-recording-content where available; Microsoft Graph returns authenticated recording bytes, not a pre-authenticated download URL.'
125
125
  )
126
126
  };
127
127
  if (ctx.multiAccount) {
@@ -178,6 +178,198 @@ const UTILITY_TOOLS = [
178
178
  };
179
179
  }
180
180
  }
181
+ },
182
+ {
183
+ name: "get-download-url",
184
+ method: "GET",
185
+ path: "tool:get-download-url",
186
+ searchKeywords: "download file download drive file download onedrive file sharepoint file download large drive file large sharepoint file large file out-of-band download pre-authenticated url",
187
+ description: "Resolve a short-lived, pre-authenticated download URL for Microsoft Graph binary content that exposes one (drive/SharePoint file content). The returned URL streams the bytes with NO Authorization header, so the client can fetch it straight to disk (e.g. curl) without round-tripping base64 through the agent context. Prefer this over download-bytes for any file above a few KB or any bulk download. Returns { downloadUrl, name?, size?, contentType? }. NOTE: mail file attachments (/messages/{id}/attachments/{id}/$value) and meeting recordings do NOT expose a pre-authenticated URL \u2014 Graph offers no such link for them; use download-bytes for small ones.",
188
+ readOnlyHint: true,
189
+ openWorldHint: true,
190
+ buildSchema: (ctx) => {
191
+ const schema = {
192
+ target: z.string().describe(
193
+ 'Relative Microsoft Graph path starting with "/". Either a driveItem content path or the item path itself, e.g. /drives/{drive-id}/items/{driveItem-id}/content, /me/drive/items/{driveItem-id}/content, or /sites/{site-id}/drive/items/{driveItem-id}. A trailing /content is optional and is stripped automatically for drive items. Mail attachment $value paths and meeting recordings are not supported (Graph exposes no pre-authenticated URL for them).'
194
+ )
195
+ };
196
+ if (ctx.multiAccount) {
197
+ schema["account"] = z.string().optional().describe(
198
+ "Account to use when multiple Microsoft accounts are configured. Required when multiple accounts exist (see list-accounts)."
199
+ );
200
+ }
201
+ return schema;
202
+ },
203
+ execute: async (params, { graphClient, authManager }) => {
204
+ const target = params.target;
205
+ const accountParam = params.account;
206
+ if (typeof target !== "string" || target.length === 0) {
207
+ return {
208
+ content: [
209
+ {
210
+ type: "text",
211
+ text: JSON.stringify({ error: "target is required and must be a non-empty string." })
212
+ }
213
+ ],
214
+ isError: true
215
+ };
216
+ }
217
+ if (!target.startsWith("/")) {
218
+ return {
219
+ content: [
220
+ {
221
+ type: "text",
222
+ text: JSON.stringify({
223
+ error: 'target must be a relative Microsoft Graph path starting with "/", e.g. /drives/{drive-id}/items/{driveItem-id}/content.'
224
+ })
225
+ }
226
+ ],
227
+ isError: true
228
+ };
229
+ }
230
+ const queryIdx = target.indexOf("?");
231
+ if (queryIdx >= 0) {
232
+ return {
233
+ content: [
234
+ {
235
+ type: "text",
236
+ text: JSON.stringify({
237
+ error: "target must not include query parameters. Pass the drive item /content path or item metadata path without $select, $expand, or other query options."
238
+ })
239
+ }
240
+ ],
241
+ isError: true
242
+ };
243
+ }
244
+ const pathPart = target.replace(/\/+$/, "");
245
+ if (/^(\/me|\/users\/[^/]+)\/messages\/[^/]+\/attachments\//.test(pathPart) || /^(\/me|\/users\/[^/]+)\/events\/[^/]+\/attachments\//.test(pathPart) || /^\/groups\/[^/]+\/messages\/[^/]+\/attachments\//.test(pathPart) || /^\/groups\/[^/]+\/events\/[^/]+\/attachments\//.test(pathPart)) {
246
+ return {
247
+ content: [
248
+ {
249
+ type: "text",
250
+ text: JSON.stringify({
251
+ error: "Mail and calendar event attachments do not expose a pre-authenticated download URL. Use download-bytes for small attachments."
252
+ })
253
+ }
254
+ ],
255
+ isError: true
256
+ };
257
+ }
258
+ if (/^(\/me|\/users\/[^/]+)\/onlineMeetings\/[^/]+\/recordings\/[^/]+(?:\/content)?$/.test(
259
+ pathPart
260
+ ) || /^\/communications\/calls\/[^/]+\/recordings\/[^/]+(?:\/content)?$/.test(pathPart)) {
261
+ return {
262
+ content: [
263
+ {
264
+ type: "text",
265
+ text: JSON.stringify({
266
+ error: "Meeting recordings do not expose a pre-authenticated download URL. Use download-bytes for small recordings or get-meeting-recording-content where available."
267
+ })
268
+ }
269
+ ],
270
+ isError: true
271
+ };
272
+ }
273
+ if (pathPart.endsWith("/$value")) {
274
+ return {
275
+ content: [
276
+ {
277
+ type: "text",
278
+ text: JSON.stringify({
279
+ error: "$value byte endpoints do not expose a pre-authenticated download URL. Use download-bytes to read these bytes."
280
+ })
281
+ }
282
+ ],
283
+ isError: true
284
+ };
285
+ }
286
+ const isDriveItemById = /^\/drives\/[^/]+\/items\/[^/]+(?:\/content)?$/.test(pathPart) || /^\/(?:me|users\/[^/]+|groups\/[^/]+|sites\/[^/]+)\/drive\/items\/[^/]+(?:\/content)?$/.test(
287
+ pathPart
288
+ ) || /^\/(?:groups\/[^/]+|sites\/[^/]+)\/drives\/[^/]+\/items\/[^/]+(?:\/content)?$/.test(
289
+ pathPart
290
+ );
291
+ const isDriveItemByPath = /^\/drives\/[^/]+\/root:\/.+:(?:\/content)?$/.test(pathPart) || /^\/(?:me|users\/[^/]+|groups\/[^/]+|sites\/[^/]+)\/drive\/root:\/.+:(?:\/content)?$/.test(
292
+ pathPart
293
+ ) || /^\/(?:groups\/[^/]+|sites\/[^/]+)\/drives\/[^/]+\/root:\/.+:(?:\/content)?$/.test(
294
+ pathPart
295
+ );
296
+ if (!isDriveItemById && !isDriveItemByPath) {
297
+ return {
298
+ content: [
299
+ {
300
+ type: "text",
301
+ text: JSON.stringify({
302
+ error: "target must identify a driveItem in OneDrive or SharePoint. Use a drive item metadata path or /content path, such as /drives/{drive-id}/items/{driveItem-id}/content, /me/drive/items/{driveItem-id}, /sites/{site-id}/drive/items/{driveItem-id}, or /me/drive/root:/path/file.ext:/content. Other Graph byte resources must use download-bytes."
303
+ })
304
+ }
305
+ ],
306
+ isError: true
307
+ };
308
+ }
309
+ const isDriveContentEndpoint = /\/items\/[^/]+\/content$/.test(pathPart) || pathPart.endsWith(":/content");
310
+ const itemPath = isDriveContentEndpoint ? pathPart.slice(0, -"/content".length) : pathPart;
311
+ try {
312
+ const accountModeError = await checkAccountParamInBearerMode(accountParam, authManager);
313
+ if (accountModeError) {
314
+ return {
315
+ content: [{ type: "text", text: JSON.stringify({ error: accountModeError }) }],
316
+ isError: true
317
+ };
318
+ }
319
+ let accountAccessToken;
320
+ if (authManager && !authManager.isOAuthModeEnabled() && !getRequestTokens()) {
321
+ accountAccessToken = await authManager.getTokenForAccount(accountParam);
322
+ }
323
+ const response = await graphClient.graphRequest(itemPath, {
324
+ accessToken: accountAccessToken
325
+ });
326
+ if (response?.isError) {
327
+ return response;
328
+ }
329
+ const text = response?.content?.[0]?.text;
330
+ let item;
331
+ if (typeof text === "string") {
332
+ try {
333
+ item = JSON.parse(text);
334
+ } catch {
335
+ item = void 0;
336
+ }
337
+ }
338
+ const downloadUrl = item?.["@microsoft.graph.downloadUrl"];
339
+ if (!downloadUrl) {
340
+ return {
341
+ content: [
342
+ {
343
+ type: "text",
344
+ text: JSON.stringify({
345
+ error: "No pre-authenticated download URL is available for this resource. It may not be a drive item, or it exposes bytes only via download-bytes."
346
+ })
347
+ }
348
+ ],
349
+ isError: true
350
+ };
351
+ }
352
+ const file = item?.file;
353
+ return {
354
+ content: [
355
+ {
356
+ type: "text",
357
+ text: JSON.stringify({
358
+ downloadUrl,
359
+ name: item?.name,
360
+ size: item?.size,
361
+ contentType: file?.mimeType
362
+ })
363
+ }
364
+ ]
365
+ };
366
+ } catch (error) {
367
+ return {
368
+ content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
369
+ isError: true
370
+ };
371
+ }
372
+ }
181
373
  }
182
374
  ];
183
375
  function registerUtilityToolWithMcp(server, utility, ctx) {
@@ -747,8 +939,20 @@ function buildDiscoverySearchIndex(toolsRegistry, utilityTools = []) {
747
939
  const nt = tokenize(utility.name);
748
940
  nameTokens.set(utility.name, new Set(nt));
749
941
  const pathTokens = tokenize(utility.path);
942
+ const keywordTokens = tokenize(utility.searchKeywords);
750
943
  const descTokens = tokenize(utility.description).slice(0, DESC_CAP_TOKENS);
751
- const tokens = [...nt, ...nt, ...nt, ...nt, ...nt, ...pathTokens, ...pathTokens, ...descTokens];
944
+ const tokens = [
945
+ ...nt,
946
+ ...nt,
947
+ ...nt,
948
+ ...nt,
949
+ ...nt,
950
+ ...pathTokens,
951
+ ...pathTokens,
952
+ ...keywordTokens,
953
+ ...keywordTokens,
954
+ ...descTokens
955
+ ];
752
956
  docs.push({ id: utility.name, tokens });
753
957
  }
754
958
  return { bm25: buildBM25Index(docs), nameTokens };
@@ -6,7 +6,7 @@ function buildGeneralMcpInstructions(opts) {
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
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
+ "Files / binary content: for large drive/SharePoint file content, prefer get-download-url to resolve a pre-authenticated URL for out-of-band download. Use download-bytes for authenticated byte reads such as mail attachments, profile photos, Teams hosted content, and meeting recordings. Both tools take relative Microsoft Graph paths, not absolute URLs. For uploads, upload-file-content takes a base64 string body up to 4MB; use create-upload-session above that."
10
10
  ];
11
11
  if (opts.readOnly) parts.push("This server is read-only; write operations are disabled.");
12
12
  if (opts.multiAccount)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softeria/ms-365-mcp-server",
3
- "version": "0.125.1",
3
+ "version": "0.127.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",
@@ -594,7 +594,7 @@
594
594
  "toolName": "get-drive-item",
595
595
  "presets": ["files", "onedrive", "personal"],
596
596
  "scopes": ["Files.Read"],
597
- "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)."
597
+ "llmTip": "Gets metadata for a file or folder: name, size, lastModifiedDateTime, createdBy, webUrl, file (mimeType, hashes), folder (childCount), parentReference, and @microsoft.graph.downloadUrl. For large drive/SharePoint files, call get-download-url with target=/drives/{drive-id}/items/{driveItem-id}/content to fetch out-of-band with no Authorization header. For small files where base64 in the tool response is acceptable, call download-bytes with the same /content target."
598
598
  },
599
599
  {
600
600
  "pathPattern": "/drives/{drive-id}/items/{driveItem-id}",
@@ -1167,6 +1167,30 @@
1167
1167
  "scopes": ["Tasks.ReadWrite"],
1168
1168
  "llmTip": "CRITICAL: Requires If-Match header with ETag from get-planner-bucket (use includeHeaders=true)."
1169
1169
  },
1170
+ {
1171
+ "pathPattern": "/planner/tasks/{plannerTask-id}/messages",
1172
+ "method": "get",
1173
+ "toolName": "list-planner-task-messages",
1174
+ "scopes": ["Tasks.Read"],
1175
+ "apiVersion": "beta",
1176
+ "llmTip": "Lists messages in a Planner task's chat — the modern Planner 'task chat', distinct from the legacy conversationThreadId comments (which live in the M365 group conversation thread). Each message has id, content (HTML), createdBy, createdDateTime, mentions, reactions. BETA Graph API: subject to change; delegated work/school accounts only — no application permissions, no personal Microsoft accounts, global cloud only (not GCC/DoD/21Vianet)."
1177
+ },
1178
+ {
1179
+ "pathPattern": "/planner/tasks/{plannerTask-id}/messages",
1180
+ "method": "post",
1181
+ "toolName": "create-planner-task-message",
1182
+ "scopes": ["Tasks.ReadWrite"],
1183
+ "apiVersion": "beta",
1184
+ "llmTip": "Posts a message to a Planner task's chat (the modern 'task chat', not the legacy conversationThreadId comment). Microsoft is retiring the classic task comments experience and hiding it from the task (Planner 2026 update), so task chat is the current supported way to post per-task updates teammates will see, with @mentions. Body: { content: 'plain text or sanitized HTML', mentions?: [{ mentioned: 'user-id', position: 0, mentionType: 'user' }] } (mentions optional). No ETag/If-Match required. BETA Graph API: subject to change; delegated work/school accounts only — no application permissions, no personal Microsoft accounts, global cloud only (not GCC/DoD/21Vianet)."
1185
+ },
1186
+ {
1187
+ "pathPattern": "/planner/tasks/{plannerTask-id}/messages/{plannerTaskChatMessage-id}",
1188
+ "method": "delete",
1189
+ "toolName": "delete-planner-task-message",
1190
+ "scopes": ["Tasks.ReadWrite"],
1191
+ "apiVersion": "beta",
1192
+ "llmTip": "Deletes a message from a Planner task's chat. No request body; If-Match is optional if you want conditional deletion; returns 204. BETA Graph API: subject to change; delegated work/school accounts only — no application permissions, no personal Microsoft accounts, global cloud only (not GCC/DoD/21Vianet)."
1193
+ },
1170
1194
  {
1171
1195
  "pathPattern": "/me/contacts",
1172
1196
  "method": "get",
@@ -1880,8 +1904,7 @@
1880
1904
  "toolName": "get-meeting-recording-content",
1881
1905
  "presets": ["teams", "work"],
1882
1906
  "workScopes": ["OnlineMeetingRecording.Read.All"],
1883
- "returnDownloadUrl": true,
1884
- "llmTip": "Returns a temporary download URL for the meeting recording in MP4 format. Use the download URL to access the video file. The recording may be large — do not attempt to stream it inline."
1907
+ "llmTip": "Returns the authenticated meeting recording video bytes in MP4 format. The recording may be large; prefer out-of-band handling by the client when possible, but Microsoft Graph does not expose a pre-authenticated download URL for this resource."
1885
1908
  },
1886
1909
  {
1887
1910
  "pathPattern": "/me/onlineMeetings/{onlineMeeting-id}/attendanceReports",