@softeria/ms-365-mcp-server 0.126.0 → 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.
@@ -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}",
@@ -1904,8 +1904,7 @@
1904
1904
  "toolName": "get-meeting-recording-content",
1905
1905
  "presets": ["teams", "work"],
1906
1906
  "workScopes": ["OnlineMeetingRecording.Read.All"],
1907
- "returnDownloadUrl": true,
1908
- "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."
1909
1908
  },
1910
1909
  {
1911
1910
  "pathPattern": "/me/onlineMeetings/{onlineMeeting-id}/attendanceReports",
@@ -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.126.0",
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}",
@@ -1904,8 +1904,7 @@
1904
1904
  "toolName": "get-meeting-recording-content",
1905
1905
  "presets": ["teams", "work"],
1906
1906
  "workScopes": ["OnlineMeetingRecording.Read.All"],
1907
- "returnDownloadUrl": true,
1908
- "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."
1909
1908
  },
1910
1909
  {
1911
1910
  "pathPattern": "/me/onlineMeetings/{onlineMeeting-id}/attendanceReports",