@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.
@@ -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 { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
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("writes decoded binary bytes to the output path and returns metadata", async () => {
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
- makeRequest: vi.fn().mockResolvedValue({
763
- message: "OK!",
762
+ downloadToFile: vi.fn().mockResolvedValue({
764
763
  contentType: "image/jpeg",
765
- encoding: "base64",
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.makeRequest).toHaveBeenCalledTimes(1);
778
- const [reqPath, options] = graphClient.makeRequest.mock.calls[0];
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(options.rawResponse).toBe(true);
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 = { makeRequest: vi.fn() };
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.makeRequest).not.toHaveBeenCalled();
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 = { makeRequest: vi.fn() };
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.makeRequest).not.toHaveBeenCalled();
823
+ expect(graphClient.downloadToFile).not.toHaveBeenCalled();
846
824
  expect(readFileSync(outputPath).toString("utf8")).toBe("original");
847
825
  });
848
- it("surfaces a Graph error and writes no file when makeRequest throws", async () => {
826
+ it("surfaces a Graph error when downloadToFile throws", async () => {
849
827
  mockEndpoints.length = 0;
850
828
  mockEndpointsJson = [];
851
829
  const graphClient = {
852
- makeRequest: vi.fn().mockRejectedValue(new Error("Microsoft Graph API error: 404 Not Found"))
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
- expect(existsSync(outputPath)).toBe(false);
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;
@@ -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";
@@ -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 { writeFile, access } from "fs/promises";
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.makeRequest(target, {
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: buffer.byteLength
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.0",
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",