@softeria/ms-365-mcp-server 0.133.0 → 0.133.1
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 +51 -37
- package/dist/graph-client.js +61 -0
- package/dist/graph-tools.js +4 -24
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { tmpdir } from "os";
|
|
4
4
|
import { join } from "path";
|
|
5
|
-
import {
|
|
5
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
6
6
|
vi.mock("../logger.js", () => ({
|
|
7
7
|
default: {
|
|
8
8
|
info: vi.fn(),
|
|
@@ -755,16 +755,13 @@ describe("graph-tools", () => {
|
|
|
755
755
|
afterEach(() => {
|
|
756
756
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
757
757
|
});
|
|
758
|
-
it("
|
|
758
|
+
it("streams bytes to the output path and returns metadata", async () => {
|
|
759
759
|
mockEndpoints.length = 0;
|
|
760
760
|
mockEndpointsJson = [];
|
|
761
761
|
const graphClient = {
|
|
762
|
-
|
|
763
|
-
message: "OK!",
|
|
762
|
+
downloadToFile: vi.fn().mockResolvedValue({
|
|
764
763
|
contentType: "image/jpeg",
|
|
765
|
-
|
|
766
|
-
contentLength: 2,
|
|
767
|
-
contentBytes: "aGk="
|
|
764
|
+
contentLength: 2
|
|
768
765
|
})
|
|
769
766
|
};
|
|
770
767
|
const server = createMockServer();
|
|
@@ -774,38 +771,19 @@ describe("graph-tools", () => {
|
|
|
774
771
|
expect(tool).toBeDefined();
|
|
775
772
|
const outputPath = join(tmpDir, "photo.jpg");
|
|
776
773
|
const result = await tool.handler({ target: "/me/photo/$value", outputPath });
|
|
777
|
-
expect(graphClient.
|
|
778
|
-
const [reqPath, options] = graphClient.
|
|
774
|
+
expect(graphClient.downloadToFile).toHaveBeenCalledTimes(1);
|
|
775
|
+
const [reqPath, dest, options] = graphClient.downloadToFile.mock.calls[0];
|
|
779
776
|
expect(reqPath).toBe("/me/photo/$value");
|
|
780
|
-
expect(
|
|
777
|
+
expect(dest).toBe(outputPath);
|
|
778
|
+
expect(options).toStrictEqual({ accessToken: void 0 });
|
|
781
779
|
expect(result.isError).toBeUndefined();
|
|
782
780
|
const payload = JSON.parse(result.content[0].text);
|
|
783
781
|
expect(payload).toEqual({ path: outputPath, contentType: "image/jpeg", bytesWritten: 2 });
|
|
784
|
-
expect(existsSync(outputPath)).toBe(true);
|
|
785
|
-
expect(readFileSync(outputPath).toString("utf8")).toBe("hi");
|
|
786
|
-
});
|
|
787
|
-
it("writes a text/JSON body verbatim from rawResponse", async () => {
|
|
788
|
-
mockEndpoints.length = 0;
|
|
789
|
-
mockEndpointsJson = [];
|
|
790
|
-
const graphClient = {
|
|
791
|
-
makeRequest: vi.fn().mockResolvedValue({
|
|
792
|
-
message: "OK!",
|
|
793
|
-
contentType: "text/plain",
|
|
794
|
-
rawResponse: "line1\nline2\n"
|
|
795
|
-
})
|
|
796
|
-
};
|
|
797
|
-
const server = createMockServer();
|
|
798
|
-
const { registerGraphTools } = await loadModule();
|
|
799
|
-
registerGraphTools(server, graphClient);
|
|
800
|
-
const outputPath = join(tmpDir, "note.txt");
|
|
801
|
-
const result = await server.tools.get("download-bytes-to-file").handler({ target: "/me/drive/root:/note.txt:/content", outputPath });
|
|
802
|
-
expect(result.isError).toBeUndefined();
|
|
803
|
-
expect(readFileSync(outputPath).toString("utf8")).toBe("line1\nline2\n");
|
|
804
782
|
});
|
|
805
783
|
it("rejects a relative outputPath", async () => {
|
|
806
784
|
mockEndpoints.length = 0;
|
|
807
785
|
mockEndpointsJson = [];
|
|
808
|
-
const graphClient = {
|
|
786
|
+
const graphClient = { downloadToFile: vi.fn() };
|
|
809
787
|
const server = createMockServer();
|
|
810
788
|
const { registerGraphTools } = await loadModule();
|
|
811
789
|
registerGraphTools(server, graphClient);
|
|
@@ -813,7 +791,7 @@ describe("graph-tools", () => {
|
|
|
813
791
|
expect(result.isError).toBe(true);
|
|
814
792
|
const payload = JSON.parse(result.content[0].text);
|
|
815
793
|
expect(payload.error).toMatch(/absolute path/);
|
|
816
|
-
expect(graphClient.
|
|
794
|
+
expect(graphClient.downloadToFile).not.toHaveBeenCalled();
|
|
817
795
|
});
|
|
818
796
|
it("rejects absolute URLs in target (Graph paths only)", async () => {
|
|
819
797
|
mockEndpoints.length = 0;
|
|
@@ -834,7 +812,7 @@ describe("graph-tools", () => {
|
|
|
834
812
|
mockEndpointsJson = [];
|
|
835
813
|
const outputPath = join(tmpDir, "existing.bin");
|
|
836
814
|
writeFileSync(outputPath, "original");
|
|
837
|
-
const graphClient = {
|
|
815
|
+
const graphClient = { downloadToFile: vi.fn() };
|
|
838
816
|
const server = createMockServer();
|
|
839
817
|
const { registerGraphTools } = await loadModule();
|
|
840
818
|
registerGraphTools(server, graphClient);
|
|
@@ -842,14 +820,14 @@ describe("graph-tools", () => {
|
|
|
842
820
|
expect(result.isError).toBe(true);
|
|
843
821
|
const payload = JSON.parse(result.content[0].text);
|
|
844
822
|
expect(payload.error).toMatch(/already exists/);
|
|
845
|
-
expect(graphClient.
|
|
823
|
+
expect(graphClient.downloadToFile).not.toHaveBeenCalled();
|
|
846
824
|
expect(readFileSync(outputPath).toString("utf8")).toBe("original");
|
|
847
825
|
});
|
|
848
|
-
it("surfaces a Graph error
|
|
826
|
+
it("surfaces a Graph error when downloadToFile throws", async () => {
|
|
849
827
|
mockEndpoints.length = 0;
|
|
850
828
|
mockEndpointsJson = [];
|
|
851
829
|
const graphClient = {
|
|
852
|
-
|
|
830
|
+
downloadToFile: vi.fn().mockRejectedValue(new Error("Microsoft Graph API error: 404 Not Found"))
|
|
853
831
|
};
|
|
854
832
|
const server = createMockServer();
|
|
855
833
|
const { registerGraphTools } = await loadModule();
|
|
@@ -859,7 +837,43 @@ describe("graph-tools", () => {
|
|
|
859
837
|
expect(result.isError).toBe(true);
|
|
860
838
|
const payload = JSON.parse(result.content[0].text);
|
|
861
839
|
expect(payload.error).toMatch(/404 Not Found/);
|
|
862
|
-
|
|
840
|
+
});
|
|
841
|
+
it("forwards the resolved account token to downloadToFile in multi-account mode", async () => {
|
|
842
|
+
mockEndpoints.length = 0;
|
|
843
|
+
mockEndpointsJson = [];
|
|
844
|
+
const graphClient = {
|
|
845
|
+
downloadToFile: vi.fn().mockResolvedValue({ contentType: "application/pdf", contentLength: 3 })
|
|
846
|
+
};
|
|
847
|
+
const authManager = {
|
|
848
|
+
isOAuthModeEnabled: vi.fn().mockReturnValue(false),
|
|
849
|
+
getToken: vi.fn().mockResolvedValue(null),
|
|
850
|
+
getTokenForAccount: vi.fn().mockResolvedValue("account-2-token")
|
|
851
|
+
};
|
|
852
|
+
const server = createMockServer();
|
|
853
|
+
const { registerGraphTools } = await loadModule();
|
|
854
|
+
registerGraphTools(
|
|
855
|
+
server,
|
|
856
|
+
graphClient,
|
|
857
|
+
false,
|
|
858
|
+
void 0,
|
|
859
|
+
false,
|
|
860
|
+
authManager,
|
|
861
|
+
true,
|
|
862
|
+
["user1@domain.com", "user2@domain.com"]
|
|
863
|
+
);
|
|
864
|
+
const outputPath = join(tmpDir, "invoice.pdf");
|
|
865
|
+
const result = await server.tools.get("download-bytes-to-file").handler({
|
|
866
|
+
target: "/me/messages/m1/attachments/a1/$value",
|
|
867
|
+
outputPath,
|
|
868
|
+
account: "user2@domain.com"
|
|
869
|
+
});
|
|
870
|
+
expect(result.isError).toBeUndefined();
|
|
871
|
+
expect(authManager.getTokenForAccount).toHaveBeenCalledWith("user2@domain.com");
|
|
872
|
+
expect(graphClient.downloadToFile).toHaveBeenCalledWith(
|
|
873
|
+
"/me/messages/m1/attachments/a1/$value",
|
|
874
|
+
outputPath,
|
|
875
|
+
{ accessToken: "account-2-token" }
|
|
876
|
+
);
|
|
863
877
|
});
|
|
864
878
|
it("is registered in stdio mode but hidden in HTTP mode", async () => {
|
|
865
879
|
mockEndpoints.length = 0;
|
package/dist/graph-client.js
CHANGED
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
getSharedBreaker,
|
|
8
8
|
loadResilienceConfig
|
|
9
9
|
} from "./lib/graph-resilience.js";
|
|
10
|
+
import { open, stat, unlink } from "fs/promises";
|
|
11
|
+
import { pipeline } from "stream/promises";
|
|
10
12
|
function isBinaryContentType(contentType) {
|
|
11
13
|
if (!contentType) return false;
|
|
12
14
|
const lower = contentType.toLowerCase().split(";")[0].trim();
|
|
@@ -100,6 +102,65 @@ class GraphClient {
|
|
|
100
102
|
throw error;
|
|
101
103
|
}
|
|
102
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* Stream Graph byte content straight to a file, without holding the whole
|
|
107
|
+
* payload in memory. download-bytes-to-file uses this for big mail attachments
|
|
108
|
+
* and meeting recordings, where makeRequest's base64 buffering would blow up
|
|
109
|
+
* memory or hit V8's max string length. Creates the file with wx + 0o600 (never
|
|
110
|
+
* overwrites) and removes a partial file if the transfer fails.
|
|
111
|
+
*/
|
|
112
|
+
async downloadToFile(endpoint, destinationPath, options = {}) {
|
|
113
|
+
const fileHandle = await open(destinationPath, "wx", 384);
|
|
114
|
+
let completed = false;
|
|
115
|
+
try {
|
|
116
|
+
const contextTokens = getRequestTokens();
|
|
117
|
+
const accessToken = options.accessToken ?? contextTokens?.accessToken ?? await this.authManager.getToken();
|
|
118
|
+
if (!accessToken) {
|
|
119
|
+
throw new Error("No access token available");
|
|
120
|
+
}
|
|
121
|
+
const response = await this.performRequest(endpoint, accessToken, options);
|
|
122
|
+
if (response.status === 403) {
|
|
123
|
+
const errorText = await response.text();
|
|
124
|
+
if (errorText.includes("scope") || errorText.includes("permission")) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`Microsoft Graph API scope error: ${response.status} ${response.statusText} - ${errorText}. This tool requires organization mode. Please restart with --org-mode flag.`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
throw new Error(
|
|
130
|
+
`Microsoft Graph API error: ${response.status} ${response.statusText} - ${errorText}`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
if (!response.ok) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`Microsoft Graph API error: ${response.status} ${response.statusText} - ${await response.text()}`
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
if (!response.body) {
|
|
139
|
+
throw new Error("Microsoft Graph returned an empty response body");
|
|
140
|
+
}
|
|
141
|
+
await pipeline(response.body, fileHandle.createWriteStream());
|
|
142
|
+
completed = true;
|
|
143
|
+
let contentLength;
|
|
144
|
+
try {
|
|
145
|
+
contentLength = (await stat(destinationPath)).size;
|
|
146
|
+
} catch {
|
|
147
|
+
const header = Number(response.headers.get("content-length"));
|
|
148
|
+
contentLength = Number.isFinite(header) ? header : 0;
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
contentType: response.headers.get("content-type") || "application/octet-stream",
|
|
152
|
+
contentLength
|
|
153
|
+
};
|
|
154
|
+
} catch (error) {
|
|
155
|
+
logger.error("Microsoft Graph file download failed:", error);
|
|
156
|
+
throw error;
|
|
157
|
+
} finally {
|
|
158
|
+
await fileHandle.close().catch(() => void 0);
|
|
159
|
+
if (!completed) {
|
|
160
|
+
await unlink(destinationPath).catch(() => void 0);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
103
164
|
async performRequest(endpoint, accessToken, options) {
|
|
104
165
|
const cloudEndpoints = getCloudEndpoints(this.secrets.cloudType);
|
|
105
166
|
const apiVersion = options.apiVersion || "v1.0";
|
package/dist/graph-tools.js
CHANGED
|
@@ -12,7 +12,7 @@ import { api as betaApi } from "./generated/client-beta.js";
|
|
|
12
12
|
const allEndpoints = [...api.endpoints, ...betaApi.endpoints];
|
|
13
13
|
import { z } from "zod";
|
|
14
14
|
import { readFileSync } from "fs";
|
|
15
|
-
import {
|
|
15
|
+
import { access } from "fs/promises";
|
|
16
16
|
import path from "path";
|
|
17
17
|
import { fileURLToPath } from "url";
|
|
18
18
|
import { TOOL_CATEGORIES } from "./tool-categories.js";
|
|
@@ -299,29 +299,9 @@ const UTILITY_TOOLS = [
|
|
|
299
299
|
if (authManager && !authManager.isOAuthModeEnabled() && !getRequestTokens()) {
|
|
300
300
|
accountAccessToken = await authManager.getTokenForAccount(accountParam);
|
|
301
301
|
}
|
|
302
|
-
const result = await graphClient.
|
|
303
|
-
accessToken: accountAccessToken
|
|
304
|
-
rawResponse: true
|
|
302
|
+
const result = await graphClient.downloadToFile(target, outputPath, {
|
|
303
|
+
accessToken: accountAccessToken
|
|
305
304
|
});
|
|
306
|
-
let buffer;
|
|
307
|
-
if (result.encoding === "base64" && typeof result.contentBytes === "string") {
|
|
308
|
-
buffer = Buffer.from(result.contentBytes, "base64");
|
|
309
|
-
} else if (typeof result.rawResponse === "string") {
|
|
310
|
-
buffer = Buffer.from(result.rawResponse, "utf8");
|
|
311
|
-
} else {
|
|
312
|
-
return {
|
|
313
|
-
content: [
|
|
314
|
-
{
|
|
315
|
-
type: "text",
|
|
316
|
-
text: JSON.stringify({
|
|
317
|
-
error: "Graph response contained no downloadable bytes for this target."
|
|
318
|
-
})
|
|
319
|
-
}
|
|
320
|
-
],
|
|
321
|
-
isError: true
|
|
322
|
-
};
|
|
323
|
-
}
|
|
324
|
-
await writeFile(outputPath, buffer, { flag: "wx" });
|
|
325
305
|
return {
|
|
326
306
|
content: [
|
|
327
307
|
{
|
|
@@ -329,7 +309,7 @@ const UTILITY_TOOLS = [
|
|
|
329
309
|
text: JSON.stringify({
|
|
330
310
|
path: outputPath,
|
|
331
311
|
contentType: result.contentType,
|
|
332
|
-
bytesWritten:
|
|
312
|
+
bytesWritten: result.contentLength
|
|
333
313
|
})
|
|
334
314
|
}
|
|
335
315
|
]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@softeria/ms-365-mcp-server",
|
|
3
|
-
"version": "0.133.
|
|
3
|
+
"version": "0.133.1",
|
|
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",
|