@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.
- package/dist/__tests__/graph-tools.test.js +352 -0
- package/dist/endpoints.json +2 -3
- package/dist/graph-tools.js +207 -3
- package/dist/mcp-instructions.js +1 -1
- package/package.json +1 -1
- package/src/endpoints.json +2 -3
|
@@ -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;
|
package/dist/endpoints.json
CHANGED
|
@@ -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
|
|
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
|
-
"
|
|
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",
|
package/dist/graph-tools.js
CHANGED
|
@@ -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
|
|
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 = [
|
|
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 };
|
package/dist/mcp-instructions.js
CHANGED
|
@@ -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:
|
|
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.
|
|
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",
|
package/src/endpoints.json
CHANGED
|
@@ -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
|
|
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
|
-
"
|
|
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",
|