@softeria/ms-365-mcp-server 0.132.1 → 0.133.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.
@@ -1,5 +1,8 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
2
  import { z } from "zod";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
3
6
  vi.mock("../logger.js", () => ({
4
7
  default: {
5
8
  info: vi.fn(),
@@ -744,6 +747,145 @@ describe("graph-tools", () => {
744
747
  expect(payload.error).toMatch(/relative Microsoft Graph path/);
745
748
  });
746
749
  });
750
+ describe("download-bytes-to-file", () => {
751
+ let tmpDir;
752
+ beforeEach(() => {
753
+ tmpDir = mkdtempSync(join(tmpdir(), "dbtf-"));
754
+ });
755
+ afterEach(() => {
756
+ rmSync(tmpDir, { recursive: true, force: true });
757
+ });
758
+ it("writes decoded binary bytes to the output path and returns metadata", async () => {
759
+ mockEndpoints.length = 0;
760
+ mockEndpointsJson = [];
761
+ const graphClient = {
762
+ makeRequest: vi.fn().mockResolvedValue({
763
+ message: "OK!",
764
+ contentType: "image/jpeg",
765
+ encoding: "base64",
766
+ contentLength: 2,
767
+ contentBytes: "aGk="
768
+ })
769
+ };
770
+ const server = createMockServer();
771
+ const { registerGraphTools } = await loadModule();
772
+ registerGraphTools(server, graphClient);
773
+ const tool = server.tools.get("download-bytes-to-file");
774
+ expect(tool).toBeDefined();
775
+ const outputPath = join(tmpDir, "photo.jpg");
776
+ 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];
779
+ expect(reqPath).toBe("/me/photo/$value");
780
+ expect(options.rawResponse).toBe(true);
781
+ expect(result.isError).toBeUndefined();
782
+ const payload = JSON.parse(result.content[0].text);
783
+ 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
+ });
805
+ it("rejects a relative outputPath", async () => {
806
+ mockEndpoints.length = 0;
807
+ mockEndpointsJson = [];
808
+ const graphClient = { makeRequest: vi.fn() };
809
+ const server = createMockServer();
810
+ const { registerGraphTools } = await loadModule();
811
+ registerGraphTools(server, graphClient);
812
+ const result = await server.tools.get("download-bytes-to-file").handler({ target: "/me/photo/$value", outputPath: "relative/path.jpg" });
813
+ expect(result.isError).toBe(true);
814
+ const payload = JSON.parse(result.content[0].text);
815
+ expect(payload.error).toMatch(/absolute path/);
816
+ expect(graphClient.makeRequest).not.toHaveBeenCalled();
817
+ });
818
+ it("rejects absolute URLs in target (Graph paths only)", async () => {
819
+ mockEndpoints.length = 0;
820
+ mockEndpointsJson = [];
821
+ const server = createMockServer();
822
+ const { registerGraphTools } = await loadModule();
823
+ registerGraphTools(server, {});
824
+ const result = await server.tools.get("download-bytes-to-file").handler({
825
+ target: "https://example.sharepoint.com/d/abc?temp=signed",
826
+ outputPath: join(tmpDir, "x.bin")
827
+ });
828
+ expect(result.isError).toBe(true);
829
+ const payload = JSON.parse(result.content[0].text);
830
+ expect(payload.error).toMatch(/relative Microsoft Graph path/);
831
+ });
832
+ it("refuses to overwrite an existing file and skips the Graph call", async () => {
833
+ mockEndpoints.length = 0;
834
+ mockEndpointsJson = [];
835
+ const outputPath = join(tmpDir, "existing.bin");
836
+ writeFileSync(outputPath, "original");
837
+ const graphClient = { makeRequest: vi.fn() };
838
+ const server = createMockServer();
839
+ const { registerGraphTools } = await loadModule();
840
+ registerGraphTools(server, graphClient);
841
+ const result = await server.tools.get("download-bytes-to-file").handler({ target: "/me/photo/$value", outputPath });
842
+ expect(result.isError).toBe(true);
843
+ const payload = JSON.parse(result.content[0].text);
844
+ expect(payload.error).toMatch(/already exists/);
845
+ expect(graphClient.makeRequest).not.toHaveBeenCalled();
846
+ expect(readFileSync(outputPath).toString("utf8")).toBe("original");
847
+ });
848
+ it("surfaces a Graph error and writes no file when makeRequest throws", async () => {
849
+ mockEndpoints.length = 0;
850
+ mockEndpointsJson = [];
851
+ const graphClient = {
852
+ makeRequest: vi.fn().mockRejectedValue(new Error("Microsoft Graph API error: 404 Not Found"))
853
+ };
854
+ const server = createMockServer();
855
+ const { registerGraphTools } = await loadModule();
856
+ registerGraphTools(server, graphClient);
857
+ const outputPath = join(tmpDir, "missing.bin");
858
+ const result = await server.tools.get("download-bytes-to-file").handler({ target: "/me/messages/abc/attachments/xyz/$value", outputPath });
859
+ expect(result.isError).toBe(true);
860
+ const payload = JSON.parse(result.content[0].text);
861
+ expect(payload.error).toMatch(/404 Not Found/);
862
+ expect(existsSync(outputPath)).toBe(false);
863
+ });
864
+ it("is registered in stdio mode but hidden in HTTP mode", async () => {
865
+ mockEndpoints.length = 0;
866
+ mockEndpointsJson = [];
867
+ const { registerGraphTools } = await loadModule();
868
+ const stdioServer = createMockServer();
869
+ registerGraphTools(stdioServer, {});
870
+ expect(stdioServer.tools.has("download-bytes-to-file")).toBe(true);
871
+ expect(stdioServer.tools.has("download-bytes")).toBe(true);
872
+ const httpServer = createMockServer();
873
+ registerGraphTools(
874
+ httpServer,
875
+ {},
876
+ false,
877
+ void 0,
878
+ false,
879
+ void 0,
880
+ false,
881
+ [],
882
+ void 0,
883
+ true
884
+ );
885
+ expect(httpServer.tools.has("download-bytes-to-file")).toBe(false);
886
+ expect(httpServer.tools.has("download-bytes")).toBe(true);
887
+ });
888
+ });
747
889
  describe("get-download-url", () => {
748
890
  it("strips /content, fetches item metadata, and returns the pre-authed downloadUrl", async () => {
749
891
  mockEndpoints.length = 0;
@@ -12,6 +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
16
  import path from "path";
16
17
  import { fileURLToPath } from "url";
17
18
  import { TOOL_CATEGORIES } from "./tool-categories.js";
@@ -186,6 +187,161 @@ const UTILITY_TOOLS = [
186
187
  }
187
188
  }
188
189
  },
190
+ {
191
+ name: "download-bytes-to-file",
192
+ method: "GET",
193
+ path: "tool:download-bytes-to-file",
194
+ searchKeywords: "save to disk save to file write file to disk save attachment to disk save recording to disk write bytes to local file output path",
195
+ // Front-loaded on purpose: the discovery search index caps a tool's
196
+ // description at ~40 tokens, so the OneDrive/SharePoint guidance below
197
+ // sits past the cap. That keeps the hint for the reading LLM while letting
198
+ // get-download-url own the high-signal "drive"/"sharepoint" search terms.
199
+ description: "Write authenticated Microsoft Graph byte content to a local file on the server, returning { path, contentType, bytesWritten } instead of base64. The only out-of-band way to save mail attachments and meeting recordings, whose bytes are exposed solely through authenticated endpoints. Also handles profile photos and Teams hosted content. Writes to an absolute outputPath and never overwrites an existing file. stdio mode only: not available over HTTP. For OneDrive or SharePoint file content, get-download-url is preferred \u2014 it returns a pre-authenticated URL for fully out-of-band download without the server fetching the bytes.",
200
+ readOnlyHint: true,
201
+ openWorldHint: true,
202
+ stdioOnly: true,
203
+ buildSchema: (ctx) => {
204
+ const schema = {
205
+ target: z.string().describe(
206
+ '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.'
207
+ ),
208
+ outputPath: z.string().describe(
209
+ "Absolute path on the server's filesystem where the bytes are written, e.g. /Users/me/downloads/invoice.pdf. Must be absolute; relative paths are rejected. The parent directory must already exist, and an existing file is never overwritten (the call errors if outputPath already exists)."
210
+ )
211
+ };
212
+ if (ctx.multiAccount) {
213
+ schema["account"] = z.string().optional().describe(
214
+ "Account to use when multiple Microsoft accounts are configured. Required when multiple accounts exist (see list-accounts)."
215
+ );
216
+ }
217
+ return schema;
218
+ },
219
+ execute: async (params, { graphClient, authManager }) => {
220
+ const target = params.target;
221
+ const outputPath = params.outputPath;
222
+ const accountParam = params.account;
223
+ if (typeof target !== "string" || target.length === 0) {
224
+ return {
225
+ content: [
226
+ {
227
+ type: "text",
228
+ text: JSON.stringify({ error: "target is required and must be a non-empty string." })
229
+ }
230
+ ],
231
+ isError: true
232
+ };
233
+ }
234
+ if (!target.startsWith("/")) {
235
+ return {
236
+ content: [
237
+ {
238
+ type: "text",
239
+ text: JSON.stringify({
240
+ error: 'target must be a relative Microsoft Graph path starting with "/", e.g. /me/photo/$value or /drives/{drive-id}/items/{driveItem-id}/content. Absolute URLs are not accepted; if you have an @microsoft.graph.downloadUrl, use the equivalent /content or /$value path instead (Graph 302-redirects to the same bytes).'
241
+ })
242
+ }
243
+ ],
244
+ isError: true
245
+ };
246
+ }
247
+ if (typeof outputPath !== "string" || outputPath.length === 0) {
248
+ return {
249
+ content: [
250
+ {
251
+ type: "text",
252
+ text: JSON.stringify({
253
+ error: "outputPath is required and must be a non-empty string."
254
+ })
255
+ }
256
+ ],
257
+ isError: true
258
+ };
259
+ }
260
+ if (!path.isAbsolute(outputPath)) {
261
+ return {
262
+ content: [
263
+ {
264
+ type: "text",
265
+ text: JSON.stringify({
266
+ error: `outputPath must be an absolute path, e.g. /Users/me/downloads/file.ext. Received: ${outputPath}`
267
+ })
268
+ }
269
+ ],
270
+ isError: true
271
+ };
272
+ }
273
+ let fileExists = false;
274
+ try {
275
+ await access(outputPath);
276
+ fileExists = true;
277
+ } catch {
278
+ }
279
+ if (fileExists) {
280
+ return {
281
+ content: [
282
+ {
283
+ type: "text",
284
+ text: JSON.stringify({ error: `file already exists at ${outputPath}` })
285
+ }
286
+ ],
287
+ isError: true
288
+ };
289
+ }
290
+ try {
291
+ const accountModeError = await checkAccountParamInBearerMode(accountParam, authManager);
292
+ if (accountModeError) {
293
+ return {
294
+ content: [{ type: "text", text: JSON.stringify({ error: accountModeError }) }],
295
+ isError: true
296
+ };
297
+ }
298
+ let accountAccessToken;
299
+ if (authManager && !authManager.isOAuthModeEnabled() && !getRequestTokens()) {
300
+ accountAccessToken = await authManager.getTokenForAccount(accountParam);
301
+ }
302
+ const result = await graphClient.makeRequest(target, {
303
+ accessToken: accountAccessToken,
304
+ rawResponse: true
305
+ });
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
+ return {
326
+ content: [
327
+ {
328
+ type: "text",
329
+ text: JSON.stringify({
330
+ path: outputPath,
331
+ contentType: result.contentType,
332
+ bytesWritten: buffer.byteLength
333
+ })
334
+ }
335
+ ]
336
+ };
337
+ } catch (error) {
338
+ return {
339
+ content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
340
+ isError: true
341
+ };
342
+ }
343
+ }
344
+ },
189
345
  {
190
346
  name: "get-download-url",
191
347
  method: "GET",
@@ -810,7 +966,7 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
810
966
  };
811
967
  }
812
968
  }
813
- function registerGraphTools(server, graphClient, readOnly = false, enabledToolsPattern, orgMode = false, authManager, multiAccount = false, accountNames = [], allowedScopesValue) {
969
+ function registerGraphTools(server, graphClient, readOnly = false, enabledToolsPattern, orgMode = false, authManager, multiAccount = false, accountNames = [], allowedScopesValue, httpMode = false) {
814
970
  let enabledToolsRegex;
815
971
  if (enabledToolsPattern) {
816
972
  try {
@@ -983,6 +1139,7 @@ function registerGraphTools(server, graphClient, readOnly = false, enabledToolsP
983
1139
  };
984
1140
  for (const utility of UTILITY_TOOLS) {
985
1141
  if (readOnly && !utility.readOnlyHint) continue;
1142
+ if (httpMode && utility.stdioOnly) continue;
986
1143
  if (enabledToolsRegex && !enabledToolsRegex.test(utility.name)) continue;
987
1144
  try {
988
1145
  registerUtilityToolWithMcp(server, utility, utilityCtx);
@@ -1098,7 +1255,7 @@ function scoreDiscoveryQuery(query, index) {
1098
1255
  ranked.sort((a, b) => b.score - a.score);
1099
1256
  return ranked;
1100
1257
  }
1101
- function registerDiscoveryTools(server, graphClient, readOnly = false, orgMode = false, authManager, multiAccount = false, accountNames = [], enabledTools, allowedScopesValue) {
1258
+ function registerDiscoveryTools(server, graphClient, readOnly = false, orgMode = false, authManager, multiAccount = false, accountNames = [], enabledTools, allowedScopesValue, httpMode = false) {
1102
1259
  let enabledToolsRegex;
1103
1260
  if (enabledTools) {
1104
1261
  try {
@@ -1125,6 +1282,7 @@ function registerDiscoveryTools(server, graphClient, readOnly = false, orgMode =
1125
1282
  }
1126
1283
  const utilityTools = UTILITY_TOOLS.filter((u) => {
1127
1284
  if (readOnly && !u.readOnlyHint) return false;
1285
+ if (httpMode && u.stdioOnly) return false;
1128
1286
  if (enabledToolsRegex && !enabledToolsRegex.test(u.name)) return false;
1129
1287
  return true;
1130
1288
  });
@@ -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: 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."
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. In stdio mode, download-bytes-to-file writes those same authenticated bytes straight to a local absolute path instead of returning base64 \u2014 the only out-of-band option for large mail attachments and meeting recordings, which get-download-url cannot handle. These 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/dist/server.js CHANGED
@@ -88,7 +88,8 @@ class MicrosoftGraphServer {
88
88
  this.multiAccount,
89
89
  this.accountNames,
90
90
  this.options.enabledTools,
91
- this.options.allowedScopes
91
+ this.options.allowedScopes,
92
+ Boolean(this.options.http)
92
93
  );
93
94
  } else {
94
95
  registerGraphTools(
@@ -100,7 +101,8 @@ class MicrosoftGraphServer {
100
101
  this.authManager,
101
102
  this.multiAccount,
102
103
  this.accountNames,
103
- this.options.allowedScopes
104
+ this.options.allowedScopes,
105
+ Boolean(this.options.http)
104
106
  );
105
107
  }
106
108
  installToolSchemaRefNormalization(server);
@@ -53,7 +53,7 @@ const PRESET_META = {
53
53
  requiresOrgMode: true
54
54
  }
55
55
  };
56
- const UNIVERSAL_UTILITY_TOOLS = ["download-bytes"];
56
+ const UNIVERSAL_UTILITY_TOOLS = ["download-bytes", "download-bytes-to-file"];
57
57
  const SCOPED_UTILITY_TOOLS = {
58
58
  "get-download-url": ["files", "onedrive", "personal", "work", "search"],
59
59
  "parse-teams-url": ["teams", "work"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softeria/ms-365-mcp-server",
3
- "version": "0.132.1",
3
+ "version": "0.133.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",