@softeria/ms-365-mcp-server 0.163.0 → 0.165.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/README.md CHANGED
@@ -695,6 +695,8 @@ When running as an MCP server, the following options can be used:
695
695
  --http-local-file-tools Register download-bytes-to-file over HTTP. Anyone who can reach the port
696
696
  can write files as the server's user (without a valid token, only an empty
697
697
  file that is removed again), so enable it only on a single-user machine.
698
+ With MS365_MCP_UPLOAD_DIRS set it also registers upload-attachment-from-file,
699
+ which reads files from those directories.
698
700
  Refused unless --http binds a loopback host with no --public-url and no
699
701
  --trust-proxy-auth
700
702
  --enable-auth-tools Enable login/logout tools when using HTTP mode (disabled by default in HTTP mode)
@@ -713,6 +715,8 @@ When running as an MCP server, the following options can be used:
713
715
  --preset <names> Use preset tool categories (comma-separated). See "Tool Presets" section above
714
716
  --list-presets List all available presets and exit
715
717
  --toon (experimental) Enable TOON output format for 30-60% token reduction
718
+ --blob-output File download tools answer an MCP EmbeddedResource with the file instead of
719
+ base64 JSON in a text block (see MS365_MCP_BLOB_OUTPUT below)
716
720
  --discovery Dynamic tool discovery: loads tools on demand to reduce initial token usage (see "Dynamic Tool Discovery" above)
717
721
  --public-url <url> Public base URL for OAuth when behind a reverse proxy (see Open WebUI section and docs/deployment.md)
718
722
  ```
@@ -728,6 +732,8 @@ Environment variables:
728
732
  - `MS365_MCP_MAX_PAGES=<n>`: Maximum number of pages followed when a tool is called with `fetchAllPages: true` (positive integer, default `100`). Bounds memory and latency for large result sets.
729
733
  - `MS365_MCP_MAX_ITEMS=<n>`: Maximum number of items accumulated when `fetchAllPages: true` (positive integer, default `10000`). Pagination stops and the response is truncated once this many items are collected.
730
734
  - `MS365_MCP_ALLOW_PAGINATION=0|false|no`: Disable multi-page following entirely. When set, the `fetchAllPages` parameter is not advertised on tools, and any request that still passes it returns only the first page (default: pagination enabled).
735
+ - `MS365_MCP_BLOB_OUTPUT=true|1`: File download tools (`download-bytes`, `get-mail-message-mime`, `get-shared-mailbox-message-mime`, `get-mail-attachment-content`, `get-shared-mailbox-attachment-content`) answer a short JSON text block (`name`, `contentType`, `size`) and an MCP `EmbeddedResource` with `BlobResourceContents` (`uri`, `mimeType`, `blob`) instead of base64 JSON in text. The `uri`'s last segment is the optional `fileName` argument. Default: off. Alternative to the --blob-output flag.
736
+ - `MS365_MCP_BLOB_MAX_BYTES=<n>`: With blob output on, the largest file a download tool reads (positive integer, default `5242880`). The server stops reading past it and answers an error naming the limit.
731
737
  - `MS365_MCP_BODY_FORMAT=html`: Return email bodies as HTML instead of plain text (default: text)
732
738
  - `MS365_MCP_MESSAGE_SIGNOFF_PREFIX=<text>`: Signoff prepended to outgoing messages so recipients can tell they were agent-sent, e.g. `🤖`. Default: none. CLI equivalent: `--message-signoff-prefix <text>` (see Message Signoff below)
733
739
  - `MS365_MCP_MESSAGE_SIGNOFF_SUFFIX=<text>`: Signoff appended to outgoing messages. Default: none. CLI equivalent: `--message-signoff-suffix <text>`. `--no-message-signoff` disables both (see Message Signoff below)
@@ -735,7 +741,8 @@ Environment variables:
735
741
  - `MS365_MCP_TRUST_PROXY_HOPS=<n>`: Number of trusted reverse-proxy hops in HTTP mode (default `1`). Accurate per-IP rate limiting depends on this matching your deployment — set to the number of proxies in front of the server, `0` to use the raw socket peer IP, or a comma-separated subnet list
736
742
  - `MS365_MCP_ATTACHMENT_PORT=<port>`: Serve the attachment route on its own listener on this port (alternative to --attachment-port; requires `--enable-attachment-urls`)
737
743
  - `MS365_MCP_ATTACHMENT_HOST=<host>`: Interface the `MS365_MCP_ATTACHMENT_PORT` listener binds (alternative to --attachment-host; requires `--attachment-port`). Defaults to the host `--http` bound — which for a wildcard `--http` means both ports answer everywhere and the port split isolates nothing. See "Splitting the attachment listener"
738
- - `MS365_MCP_HTTP_LOCAL_FILE_TOOLS=true|1`: Register download-bytes-to-file over HTTP (alternative to --http-local-file-tools; same restrictions)
744
+ - `MS365_MCP_HTTP_LOCAL_FILE_TOOLS=true|1`: Register download-bytes-to-file, and upload-attachment-from-file when `MS365_MCP_UPLOAD_DIRS` is set, over HTTP (alternative to --http-local-file-tools; same restrictions)
745
+ - `MS365_MCP_UPLOAD_DIRS=<dirs>`: Directories `upload-attachment-from-file` may read from, as absolute paths separated like `PATH`. Unset (the default) leaves the tool unregistered. See "Attaching a local file in stdio mode"
739
746
  - `MS365_MCP_CLOUD_TYPE=global|china`: Microsoft cloud environment (alternative to --cloud flag)
740
747
  - `MS365_MCP_GRAPH_BASE_URL=<url>`: Send Graph API requests to this base URL instead of the cloud's `graph.microsoft.com` (e.g. `http://127.0.0.1:10255/tenant-a/graph` for an egress proxy that originates TLS itself). Absolute http(s) URL without query or fragment; a path prefix is kept, so requests go to `<url>/v1.0/...`. The login authority and the On-Behalf-Of token resource are unchanged. Two things bypass it: `get-download-url` returns Graph's pre-authenticated SharePoint URL, and the `302` from `/content` points straight at SharePoint, so whoever follows either connects to SharePoint directly; if you control egress, allow those SharePoint hosts or disable those tools. Not read from `.env`
741
748
  - `LOG_LEVEL`: Set logging level (default: 'info')
@@ -805,6 +812,26 @@ whatever the outcome, and the upload is attached as the identity that minted it,
805
812
  downloads are fetched. An upload ticket presented to `GET` (or a download ticket to `PUT`)
806
813
  is refused with the same 404 as any other bad ticket.
807
814
 
815
+ ### Attaching a local file in stdio mode
816
+
817
+ In stdio mode the server and the files are on the same machine, so no URL is needed.
818
+ `upload-attachment-from-file` takes the same `target` and an absolute `filePath`, reads the
819
+ file from disk and attaches it, with the same 3 MB and 150 MB limits. It is the upload
820
+ counterpart of `download-bytes-to-file`.
821
+
822
+ The tool is off until you list the directories it may read from:
823
+
824
+ ```
825
+ MS365_MCP_UPLOAD_DIRS=/Users/me/Documents/invitations:/Users/me/Scans
826
+ ```
827
+
828
+ Entries are absolute paths, separated like `PATH` (`:` on macOS and Linux, `;` on Windows).
829
+ A file outside them is refused, and symlinks are resolved first, so a link inside an allowed
830
+ directory cannot point the read somewhere else. The variable is not read from `.env`.
831
+
832
+ Over HTTP the tool is registered only with `--http-local-file-tools`, like
833
+ `download-bytes-to-file`.
834
+
808
835
  ### Configuration
809
836
 
810
837
  ```
@@ -0,0 +1,106 @@
1
+ import http from "http";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import GraphClient from "../graph-client.js";
4
+ import { fetchWithResilience, loadResilienceConfig } from "../lib/graph-resilience.js";
5
+ vi.mock("../logger.js", () => ({
6
+ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }
7
+ }));
8
+ vi.mock("../lib/graph-resilience.js", async (importOriginal) => ({
9
+ ...await importOriginal(),
10
+ fetchWithResilience: vi.fn()
11
+ }));
12
+ const fetchMock = vi.mocked(fetchWithResilience);
13
+ const client = () => new GraphClient({ getToken: vi.fn().mockResolvedValue("token") }, {
14
+ clientId: "c",
15
+ tenantId: "t",
16
+ cloudType: "global"
17
+ });
18
+ const pdf = Buffer.from("%PDF-1.4 test bytes \0\xFF", "latin1");
19
+ const opts = { forceBinary: true, maxBytes: 1024, blobOutput: { fileName: "a b.pdf" } };
20
+ describe("blob output", () => {
21
+ beforeEach(() => vi.clearAllMocks());
22
+ it("answers a text block and an EmbeddedResource with the exact bytes", async () => {
23
+ fetchMock.mockResolvedValue(
24
+ new Response(pdf, { status: 200, headers: { "content-type": "application/pdf" } })
25
+ );
26
+ const res = await client().graphRequest("/me/messages/m/attachments/a/$value", opts);
27
+ expect(JSON.parse(res.content[0].text)).toEqual({
28
+ name: "a b.pdf",
29
+ contentType: "application/pdf",
30
+ size: pdf.length
31
+ });
32
+ const item = res.content[1];
33
+ expect(item.type).toBe("resource");
34
+ expect(item.resource.uri).toBe("file:///a%20b.pdf");
35
+ expect(item.resource.mimeType).toBe("application/pdf");
36
+ expect(Buffer.from(item.resource.blob, "base64").equals(pdf)).toBe(true);
37
+ });
38
+ it("keeps the old base64 JSON when blob output is not asked for", async () => {
39
+ fetchMock.mockResolvedValue(
40
+ new Response(pdf, { headers: { "content-type": "application/pdf" } })
41
+ );
42
+ const res = await client().graphRequest("/x/$value", { forceBinary: true });
43
+ expect(res.content).toHaveLength(1);
44
+ expect(JSON.parse(res.content[0].text).contentBytes).toBe(pdf.toString("base64"));
45
+ });
46
+ it("refuses a body over the cap by Content-Length without reading it", async () => {
47
+ fetchMock.mockResolvedValue(
48
+ new Response(Buffer.alloc(2048), { headers: { "content-length": "2048" } })
49
+ );
50
+ const res = await client().graphRequest("/x/$value", opts);
51
+ expect(res.isError).toBe(true);
52
+ expect(res.content[0].text).toContain("download limit of 1024 bytes");
53
+ });
54
+ it("stops reading a body over the cap when no length is declared", async () => {
55
+ let pulled = 0;
56
+ const body = new ReadableStream({
57
+ pull(controller) {
58
+ pulled += 512;
59
+ controller.enqueue(new Uint8Array(512));
60
+ if (pulled >= 1024 * 1024) controller.close();
61
+ }
62
+ });
63
+ fetchMock.mockResolvedValue(new Response(body));
64
+ const res = await client().graphRequest("/x/$value", opts);
65
+ expect(res.isError).toBe(true);
66
+ expect(res.content[0].text).toContain("download limit of 1024 bytes");
67
+ expect(pulled).toBeLessThan(16 * 1024);
68
+ });
69
+ it("accepts a body exactly at the cap", async () => {
70
+ fetchMock.mockResolvedValue(new Response(Buffer.alloc(1024)));
71
+ const res = await client().graphRequest("/x/$value", opts);
72
+ expect(res.isError).toBeUndefined();
73
+ expect(JSON.parse(res.content[0].text).size).toBe(1024);
74
+ });
75
+ });
76
+ describe("redirects that leave the Graph origin", () => {
77
+ const servers = [];
78
+ const listen = (h) => new Promise((resolve) => {
79
+ const s = http.createServer(h).listen(0, "127.0.0.1", () => {
80
+ servers.push(s);
81
+ resolve(`http://127.0.0.1:${s.address().port}`);
82
+ });
83
+ });
84
+ afterEach(() => servers.splice(0).forEach((s) => s.close()));
85
+ it("arrive without the Authorization header", async () => {
86
+ const real = await vi.importActual(
87
+ "../lib/graph-resilience.js"
88
+ );
89
+ let seen = "unset";
90
+ const other = await listen((req, res2) => {
91
+ seen = req.headers.authorization;
92
+ res2.end("bytes");
93
+ });
94
+ const graph = await listen((_req, res2) => {
95
+ res2.writeHead(302, { location: `${other}/file` }).end();
96
+ });
97
+ const res = await real.fetchWithResilience(
98
+ `${graph}/v1.0/x`,
99
+ { headers: { Authorization: "Bearer secret" } },
100
+ loadResilienceConfig(),
101
+ real.getSharedBreaker()
102
+ );
103
+ expect(await res.text()).toBe("bytes");
104
+ expect(seen).toBeUndefined();
105
+ });
106
+ });
@@ -1,4 +1,12 @@
1
- import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
1
+ import {
2
+ mkdirSync,
3
+ mkdtempSync,
4
+ readFileSync,
5
+ realpathSync,
6
+ rmSync,
7
+ symlinkSync,
8
+ writeFileSync
9
+ } from "fs";
2
10
  import { tmpdir } from "os";
3
11
  import { join } from "path";
4
12
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -2248,6 +2256,28 @@ describe("graph-tools", () => {
2248
2256
  expect(options.accessToken).toBeUndefined();
2249
2257
  expect(options.forceBinary).toBe(true);
2250
2258
  expect(options.rawResponse).toBe(true);
2259
+ expect(options.blobOutput).toBeUndefined();
2260
+ expect(options.maxBytes).toBeUndefined();
2261
+ });
2262
+ it("asks for a capped blob resource when blob output is on", async () => {
2263
+ mockEndpoints.length = 0;
2264
+ mockEndpointsJson = [];
2265
+ process.env.MS365_MCP_BLOB_OUTPUT = "true";
2266
+ process.env.MS365_MCP_BLOB_MAX_BYTES = "1234";
2267
+ try {
2268
+ const graphClient = { graphRequest: vi.fn().mockResolvedValue({ content: [] }) };
2269
+ const server = createMockServer();
2270
+ const { registerGraphTools } = await loadModule();
2271
+ registerGraphTools(server, graphClient);
2272
+ await server.tools.get("download-bytes").handler({ target: "/me/messages/m1/attachments/a1/$value", fileName: "drawing.pdf" });
2273
+ const [, options] = graphClient.graphRequest.mock.calls[0];
2274
+ expect(options.forceBinary).toBe(true);
2275
+ expect(options.maxBytes).toBe(1234);
2276
+ expect(options.blobOutput).toEqual({ fileName: "drawing.pdf" });
2277
+ } finally {
2278
+ delete process.env.MS365_MCP_BLOB_OUTPUT;
2279
+ delete process.env.MS365_MCP_BLOB_MAX_BYTES;
2280
+ }
2251
2281
  });
2252
2282
  it("rejects absolute URLs (Graph paths only)", async () => {
2253
2283
  mockEndpoints.length = 0;
@@ -2423,6 +2453,148 @@ describe("graph-tools", () => {
2423
2453
  expect(httpServer.tools.has("download-bytes")).toBe(true);
2424
2454
  });
2425
2455
  });
2456
+ describe("upload-attachment-from-file", () => {
2457
+ const TOOL = "upload-attachment-from-file";
2458
+ const TARGET = "/me/events/evt1/attachments";
2459
+ let tmpDir;
2460
+ let allowed;
2461
+ beforeEach(() => {
2462
+ mockEndpoints.length = 0;
2463
+ mockEndpointsJson = [];
2464
+ tmpDir = realpathSync(mkdtempSync(join(tmpdir(), "uaff-")));
2465
+ allowed = join(tmpDir, "allowed");
2466
+ mkdirSync(allowed);
2467
+ process.env.MS365_MCP_UPLOAD_DIRS = allowed;
2468
+ });
2469
+ afterEach(() => {
2470
+ delete process.env.MS365_MCP_UPLOAD_DIRS;
2471
+ vi.unstubAllGlobals();
2472
+ rmSync(tmpDir, { recursive: true, force: true });
2473
+ });
2474
+ async function register(graphClient, options) {
2475
+ const server = createMockServer();
2476
+ const { registerGraphTools } = await loadModule();
2477
+ registerGraphTools(server, graphClient, options);
2478
+ return server;
2479
+ }
2480
+ it("is not registered unless MS365_MCP_UPLOAD_DIRS is set", async () => {
2481
+ delete process.env.MS365_MCP_UPLOAD_DIRS;
2482
+ const server = await register({});
2483
+ expect(server.tools.has(TOOL)).toBe(false);
2484
+ expect(server.tools.has("download-bytes-to-file")).toBe(true);
2485
+ });
2486
+ it("is not registered in read-only mode or over HTTP", async () => {
2487
+ expect((await register({}, { readOnly: true })).tools.has(TOOL)).toBe(false);
2488
+ expect((await register({}, { httpMode: true })).tools.has(TOOL)).toBe(false);
2489
+ });
2490
+ it("attaches a small file inline, named after the file", async () => {
2491
+ const bytes = Buffer.from([37, 80, 68, 70, 0, 255]);
2492
+ writeFileSync(join(allowed, "invitation.pdf"), bytes);
2493
+ const graphClient = { makeRequest: vi.fn().mockResolvedValue({ id: "att1" }) };
2494
+ const server = await register(graphClient);
2495
+ const result = await server.tools.get(TOOL).handler({ target: TARGET, filePath: join(allowed, "invitation.pdf") });
2496
+ expect(result.isError).toBeUndefined();
2497
+ expect(JSON.parse(result.content[0].text)).toEqual({
2498
+ name: "invitation.pdf",
2499
+ size: bytes.length,
2500
+ id: "att1"
2501
+ });
2502
+ expect(graphClient.makeRequest).toHaveBeenCalledTimes(1);
2503
+ const [reqPath, options] = graphClient.makeRequest.mock.calls[0];
2504
+ expect(reqPath).toBe(TARGET);
2505
+ expect(options.method).toBe("POST");
2506
+ expect(JSON.parse(options.body)).toEqual({
2507
+ "@odata.type": "#microsoft.graph.fileAttachment",
2508
+ name: "invitation.pdf",
2509
+ contentType: "application/octet-stream",
2510
+ contentBytes: bytes.toString("base64")
2511
+ });
2512
+ expect(auditLogMock).toHaveBeenCalledWith(
2513
+ expect.objectContaining({ event: "tool.call", tool: TOOL, status: "success" })
2514
+ );
2515
+ });
2516
+ it("uses the given name and content type", async () => {
2517
+ writeFileSync(join(allowed, "scan-0042.bin"), "x");
2518
+ const graphClient = { makeRequest: vi.fn().mockResolvedValue({ id: "att1" }) };
2519
+ const server = await register(graphClient);
2520
+ await server.tools.get(TOOL).handler({
2521
+ target: "/me/messages/msg1/attachments",
2522
+ filePath: join(allowed, "scan-0042.bin"),
2523
+ name: "flyer.pdf",
2524
+ contentType: "application/pdf"
2525
+ });
2526
+ const body = JSON.parse(graphClient.makeRequest.mock.calls[0][1].body);
2527
+ expect(body.name).toBe("flyer.pdf");
2528
+ expect(body.contentType).toBe("application/pdf");
2529
+ });
2530
+ it("sends a file of 3 MB or more through an upload session in chunks", async () => {
2531
+ const size = 4 * 1024 * 1024 + 123;
2532
+ const bytes = Buffer.alloc(size, 7);
2533
+ writeFileSync(join(allowed, "big.bin"), bytes);
2534
+ const graphClient = {
2535
+ makeRequest: vi.fn().mockResolvedValue({ uploadUrl: "https://upload.example/session" })
2536
+ };
2537
+ const ranges = [];
2538
+ let uploaded = 0;
2539
+ vi.stubGlobal(
2540
+ "fetch",
2541
+ vi.fn(
2542
+ async (_url, init) => {
2543
+ ranges.push(init.headers["Content-Range"]);
2544
+ uploaded += init.body.byteLength;
2545
+ return new Response(null, { status: 202 });
2546
+ }
2547
+ )
2548
+ );
2549
+ const server = await register(graphClient);
2550
+ const result = await server.tools.get(TOOL).handler({ target: TARGET, filePath: join(allowed, "big.bin") });
2551
+ expect(result.isError).toBeUndefined();
2552
+ expect(JSON.parse(result.content[0].text)).toEqual({ name: "big.bin", size });
2553
+ const [reqPath, options] = graphClient.makeRequest.mock.calls[0];
2554
+ expect(reqPath).toBe(`${TARGET}/createUploadSession`);
2555
+ expect(JSON.parse(options.body).AttachmentItem).toMatchObject({ name: "big.bin", size });
2556
+ const chunk = 320 * 1024 * 12;
2557
+ expect(ranges).toEqual([
2558
+ `bytes 0-${chunk - 1}/${size}`,
2559
+ `bytes ${chunk}-${size - 1}/${size}`
2560
+ ]);
2561
+ expect(uploaded).toBe(size);
2562
+ });
2563
+ it("refuses a file outside the allowed directories, also through a symlink", async () => {
2564
+ writeFileSync(join(tmpDir, "secret.txt"), "secret");
2565
+ symlinkSync(join(tmpDir, "secret.txt"), join(allowed, "link.txt"));
2566
+ const graphClient = { makeRequest: vi.fn() };
2567
+ const server = await register(graphClient);
2568
+ for (const filePath of [join(tmpDir, "secret.txt"), join(allowed, "link.txt")]) {
2569
+ const result = await server.tools.get(TOOL).handler({ target: TARGET, filePath });
2570
+ expect(result.isError).toBe(true);
2571
+ expect(JSON.parse(result.content[0].text).error).toMatch(/does not exist or is outside/);
2572
+ }
2573
+ expect(graphClient.makeRequest).not.toHaveBeenCalled();
2574
+ });
2575
+ it("refuses an empty file and a target that is not an attachments collection", async () => {
2576
+ writeFileSync(join(allowed, "empty.txt"), "");
2577
+ writeFileSync(join(allowed, "a.txt"), "a");
2578
+ const graphClient = { makeRequest: vi.fn() };
2579
+ const server = await register(graphClient);
2580
+ const call = async (args) => JSON.parse((await server.tools.get(TOOL).handler(args)).content[0].text).error;
2581
+ expect(await call({ target: TARGET, filePath: join(allowed, "empty.txt") })).toMatch(
2582
+ /file is empty/
2583
+ );
2584
+ expect(
2585
+ await call({ target: "/me/drive/root:/a.txt:/content", filePath: join(allowed, "a.txt") })
2586
+ ).toMatch(/attachments collection/);
2587
+ expect(graphClient.makeRequest).not.toHaveBeenCalled();
2588
+ });
2589
+ it("answers a Graph failure as a tool error", async () => {
2590
+ writeFileSync(join(allowed, "a.txt"), "a");
2591
+ const graphClient = { makeRequest: vi.fn().mockRejectedValue(new Error("Graph said no")) };
2592
+ const server = await register(graphClient);
2593
+ const result = await server.tools.get(TOOL).handler({ target: TARGET, filePath: join(allowed, "a.txt") });
2594
+ expect(result.isError).toBe(true);
2595
+ expect(JSON.parse(result.content[0].text).error).toBe("Graph said no");
2596
+ });
2597
+ });
2426
2598
  describe("get-download-url", () => {
2427
2599
  it("strips /content, fetches item metadata, and returns the pre-authed downloadUrl", async () => {
2428
2600
  mockEndpoints.length = 0;
@@ -5,6 +5,7 @@ import {
5
5
  isPlainGraphPath,
6
6
  TICKET_PARAM
7
7
  } from "./lib/attachment-tickets.js";
8
+ import { MAX_UPLOAD_BYTES, uploadAttachment } from "./lib/attachment-upload.js";
8
9
  const NOT_FOUND_BODY = "Not found";
9
10
  function forceAttachment(header) {
10
11
  if (!header) return "attachment";
@@ -104,26 +105,6 @@ function createAttachmentHandler(deps) {
104
105
  }
105
106
  };
106
107
  }
107
- const MAX_UPLOAD_BYTES = 150 * 1024 * 1024;
108
- const INLINE_UPLOAD_LIMIT = 3 * 1024 * 1024;
109
- const UPLOAD_CHUNK_BYTES = 320 * 1024 * 12;
110
- async function putChunk(uploadUrl, chunk, start, total) {
111
- const end = start + chunk.length - 1;
112
- const response = await fetch(uploadUrl, {
113
- method: "PUT",
114
- headers: {
115
- "Content-Type": "application/octet-stream",
116
- "Content-Length": String(chunk.length),
117
- "Content-Range": `bytes ${start}-${end}/${total}`
118
- },
119
- // A standalone ArrayBuffer: fetch's body typing wants a BufferSource, and a
120
- // Buffer view over a pooled slab is neither the right type nor the right bytes.
121
- body: chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength)
122
- });
123
- if (![200, 201, 202].includes(response.status)) {
124
- throw new Error(`Upload session chunk ${start}-${end} answered HTTP ${response.status}`);
125
- }
126
- }
127
108
  function createAttachmentUploadHandler(deps) {
128
109
  return async (req, res) => {
129
110
  if (req.method !== "PUT") {
@@ -177,64 +158,18 @@ function createAttachmentUploadHandler(deps) {
177
158
  res.setHeader("cache-control", "no-store");
178
159
  res.setHeader("x-content-type-options", "nosniff");
179
160
  try {
180
- if (declared < INLINE_UPLOAD_LIMIT) {
181
- const chunks = [];
182
- let received = 0;
183
- for await (const chunk of req) {
184
- received += chunk.length;
185
- if (received > declared) throw new Error("Body exceeds Content-Length");
186
- chunks.push(chunk);
187
- }
188
- if (received !== declared) throw new Error("Body shorter than Content-Length");
189
- const created = await graphClient.makeRequest(ticket.target, {
190
- method: "POST",
191
- accessToken,
192
- body: JSON.stringify({
193
- "@odata.type": "#microsoft.graph.fileAttachment",
194
- name: ticket.name,
195
- contentType: ticket.contentType,
196
- contentBytes: Buffer.concat(chunks).toString("base64")
197
- })
198
- });
199
- res.status(201).json({ ok: true, name: ticket.name, size: declared, id: created?.id });
200
- return;
201
- }
202
- const session = await graphClient.makeRequest(`${ticket.target}/createUploadSession`, {
203
- method: "POST",
204
- accessToken,
205
- body: JSON.stringify({
206
- AttachmentItem: {
207
- attachmentType: "file",
208
- name: ticket.name,
209
- size: declared,
210
- contentType: ticket.contentType
211
- }
212
- })
213
- });
214
- if (!session?.uploadUrl) throw new Error("Upload session returned no uploadUrl");
215
- let pending = [];
216
- let pendingBytes = 0;
217
- let offset = 0;
218
- for await (const piece of req) {
219
- pending.push(piece);
220
- pendingBytes += piece.length;
221
- if (offset + pendingBytes > declared) throw new Error("Body exceeds Content-Length");
222
- while (pendingBytes >= UPLOAD_CHUNK_BYTES) {
223
- const joined = Buffer.concat(pending);
224
- const chunk = joined.subarray(0, UPLOAD_CHUNK_BYTES);
225
- await putChunk(session.uploadUrl, chunk, offset, declared);
226
- offset += chunk.length;
227
- const rest = joined.subarray(UPLOAD_CHUNK_BYTES);
228
- pending = rest.length ? [Buffer.from(rest)] : [];
229
- pendingBytes = rest.length;
230
- }
231
- }
232
- if (pendingBytes > 0) {
233
- await putChunk(session.uploadUrl, Buffer.concat(pending), offset, declared);
234
- offset += pendingBytes;
235
- }
236
- if (offset !== declared) throw new Error("Body shorter than Content-Length");
237
- res.status(201).json({ ok: true, name: ticket.name, size: declared });
161
+ const created = await uploadAttachment(
162
+ graphClient,
163
+ {
164
+ target: ticket.target,
165
+ name: ticket.name,
166
+ contentType: ticket.contentType,
167
+ size: declared,
168
+ accessToken
169
+ },
170
+ req
171
+ );
172
+ res.status(201).json({ ok: true, name: ticket.name, size: declared, id: created.id });
238
173
  } catch (error) {
239
174
  logger.error(`Attachment upload failed for ${ticket.target}: ${error.message}`);
240
175
  if (!res.headersSent) res.status(502).type("text/plain").send("Upstream upload failed");
@@ -242,7 +177,6 @@ function createAttachmentUploadHandler(deps) {
242
177
  };
243
178
  }
244
179
  export {
245
- MAX_UPLOAD_BYTES,
246
180
  createAttachmentHandler,
247
181
  createAttachmentUploadHandler,
248
182
  forceAttachment
package/dist/cli.js CHANGED
@@ -3,6 +3,7 @@ import { readFileSync } from "fs";
3
3
  import path from "path";
4
4
  import { fileURLToPath } from "url";
5
5
  import { assertSignoffMarkersVisible } from "./lib/message-signoff.js";
6
+ import { assertUploadDirsAbsolute } from "./lib/upload-dirs.js";
6
7
  import { getCombinedPresetPattern, listPresets, presetRequiresOrgMode } from "./tool-categories.js";
7
8
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
9
  const packageJsonPath = path.join(__dirname, "..", "package.json");
@@ -74,7 +75,10 @@ program.name("ms-365-mcp-server").description("Microsoft 365 MCP Server").versio
74
75
  "In HTTP mode, skip the built-in Bearer-token check on /mcp and ignore any forwarded Authorization header. All callers share the locally cached MSAL identity (same path stdio mode uses). Use only when an upstream reverse proxy has already authenticated the caller."
75
76
  ).option(
76
77
  "--http-local-file-tools",
77
- "In HTTP mode, also register download-bytes-to-file, which writes files on the server as the server's user. Anyone who can reach the port can use it, so enable only on a single-user machine. Refused unless --http binds a loopback host with no --public-url and no --trust-proxy-auth."
78
+ "In HTTP mode, also register the local file tools: download-bytes-to-file, which writes files on the server as the server's user, and upload-attachment-from-file, which reads files from the MS365_MCP_UPLOAD_DIRS directories when that is set. Anyone who can reach the port can use them, so enable only on a single-user machine. Refused unless --http binds a loopback host with no --public-url and no --trust-proxy-auth."
79
+ ).option(
80
+ "--blob-output",
81
+ "File download tools (get-mail-message-mime, get-shared-mailbox-message-mime, get-mail-attachment-content, get-shared-mailbox-attachment-content) answer an MCP EmbeddedResource with the file instead of base64 JSON in a text block. Off by default."
78
82
  ).option(
79
83
  "--allow-unauthenticated-discovery",
80
84
  "In HTTP mode, allow MCP discovery requests (initialize, tools/list, prompts/list, resources/list, ping) without a bearer token, so a gateway can enumerate the tool catalog before any user has authenticated. Non-discovery requests (e.g. tools/call) still require a token. Off by default."
@@ -97,7 +101,11 @@ function parseArgs() {
97
101
  process.env.MS365_MCP_MESSAGE_SIGNOFF_SUFFIX = "";
98
102
  process.env.MS365_MCP_MESSAGE_SIGNOFF_PREFIX = "";
99
103
  }
104
+ if (options.blobOutput) {
105
+ process.env.MS365_MCP_BLOB_OUTPUT = "true";
106
+ }
100
107
  assertSignoffMarkersVisible();
108
+ assertUploadDirsAbsolute();
101
109
  if (options.listPresets) {
102
110
  const presets = listPresets();
103
111
  console.log(JSON.stringify({ presets }, null, 2));
@@ -949,8 +949,40 @@
949
949
  "presets": ["mail", "outlook", "personal"],
950
950
  "scopes": ["Mail.Read"],
951
951
  "acceptType": "text/plain",
952
+ "blobOutput": true,
952
953
  "llmTip": "Download an email message as raw RFC 5322 MIME content (.eml format). Use this when archiving an email to disk preserving all original headers, body, and inline-encoded attachments. Returns the MIME stream as text. Find the message id with list-mail-messages first."
953
954
  },
955
+ {
956
+ "pathPattern": "/users/{user-id}/messages/{message-id}/$value",
957
+ "method": "get",
958
+ "toolName": "get-shared-mailbox-message-mime",
959
+ "descriptionOverride": "Download the raw MIME source (RFC 5322 .eml content) of a message in another user's mailbox or a shared mailbox, by message ID. The user-id is the mailbox owner's ID or email address, and delegated access to that mailbox is required.",
960
+ "presets": ["work"],
961
+ "workScopes": ["Mail.Read.Shared"],
962
+ "acceptType": "text/plain",
963
+ "blobOutput": true,
964
+ "llmTip": "Download a message of a shared mailbox as an .eml. Find the message id with list-shared-mailbox-messages first. With the server's blob output on, the bytes come back as a file resource; pass fileName to name it."
965
+ },
966
+ {
967
+ "pathPattern": "/me/messages/{message-id}/attachments/{attachment-id}/$value",
968
+ "method": "get",
969
+ "toolName": "get-mail-attachment-content",
970
+ "descriptionOverride": "Download the raw bytes of a file attachment of an Outlook message in the signed-in user's mailbox, by message ID and attachment ID.",
971
+ "presets": ["mail", "outlook", "personal"],
972
+ "scopes": ["Mail.Read"],
973
+ "blobOutput": true,
974
+ "llmTip": "Find the attachment id with list-mail-attachments first. With the server's blob output on, the bytes come back as a file resource; pass fileName (the attachment's name) to name it. Files over the server's download limit are refused with an error naming the limit."
975
+ },
976
+ {
977
+ "pathPattern": "/users/{user-id}/messages/{message-id}/attachments/{attachment-id}/$value",
978
+ "method": "get",
979
+ "toolName": "get-shared-mailbox-attachment-content",
980
+ "descriptionOverride": "Download the raw bytes of a file attachment of a message in another user's mailbox or a shared mailbox, by message ID and attachment ID. The user-id is the mailbox owner's ID or email address, and delegated access to that mailbox is required.",
981
+ "presets": ["work"],
982
+ "workScopes": ["Mail.Read.Shared"],
983
+ "blobOutput": true,
984
+ "llmTip": "Find the attachment id with list-shared-mailbox-attachments first. With the server's blob output on, the bytes come back as a file resource; pass fileName (the attachment's name) to name it. Files over the server's download limit are refused with an error naming the limit."
985
+ },
954
986
  {
955
987
  "pathPattern": "/drives/{drive-id}/items/{driveItem-id}/workbook/worksheets/{workbookWorksheet-id}/range(address='{address}')",
956
988
  "method": "patch",
@@ -10618,6 +10618,26 @@ resource.`,
10618
10618
  ],
10619
10619
  response: z.void()
10620
10620
  },
10621
+ {
10622
+ method: "get",
10623
+ path: "/me/messages/:messageId/attachments/:attachmentId/$value",
10624
+ alias: "get-mail-attachment-content",
10625
+ description: `Get mail attachment content.`,
10626
+ requestFormat: "json",
10627
+ parameters: [
10628
+ {
10629
+ name: "messageId",
10630
+ type: "Path",
10631
+ schema: z.string().describe("Path parameter: message-id")
10632
+ },
10633
+ {
10634
+ name: "attachmentId",
10635
+ type: "Path",
10636
+ schema: z.string().describe("Path parameter: attachment-id")
10637
+ }
10638
+ ],
10639
+ response: z.void()
10640
+ },
10621
10641
  {
10622
10642
  method: "post",
10623
10643
  path: "/me/messages/:messageId/attachments/createUploadSession",
@@ -15571,6 +15591,14 @@ Only the policyViolation property of a chatMessage can be updated in application
15571
15591
  ],
15572
15592
  response: z.void()
15573
15593
  },
15594
+ {
15595
+ method: "get",
15596
+ path: "/users/:userId/messages/:messageId/$value",
15597
+ alias: "get-shared-mailbox-message-mime",
15598
+ description: `The unique identifier for an entity. Read-only.`,
15599
+ requestFormat: "json",
15600
+ response: z.void()
15601
+ },
15574
15602
  {
15575
15603
  method: "get",
15576
15604
  path: "/users/:userId/messages/:messageId/attachments",
@@ -15652,6 +15680,31 @@ Only the policyViolation property of a chatMessage can be updated in application
15652
15680
  ],
15653
15681
  response: z.void()
15654
15682
  },
15683
+ {
15684
+ method: "get",
15685
+ path: "/users/:userId/messages/:messageId/attachments/:attachmentId/$value",
15686
+ alias: "get-shared-mailbox-attachment-content",
15687
+ description: `Get shared mailbox attachment content.`,
15688
+ requestFormat: "json",
15689
+ parameters: [
15690
+ {
15691
+ name: "userId",
15692
+ type: "Path",
15693
+ schema: z.string().describe("Path parameter: user-id")
15694
+ },
15695
+ {
15696
+ name: "messageId",
15697
+ type: "Path",
15698
+ schema: z.string().describe("Path parameter: message-id")
15699
+ },
15700
+ {
15701
+ name: "attachmentId",
15702
+ type: "Path",
15703
+ schema: z.string().describe("Path parameter: attachment-id")
15704
+ }
15705
+ ],
15706
+ response: z.void()
15707
+ },
15655
15708
  {
15656
15709
  method: "post",
15657
15710
  path: "/users/:userId/messages/:messageId/createForward",
@@ -97,6 +97,58 @@ function extractBatchMetadata(data) {
97
97
  ...Object.keys(errorCodeCounts).length > 0 ? { graph_batch_error_code_counts: errorCodeCounts } : {}
98
98
  };
99
99
  }
100
+ const DEFAULT_BLOB_MAX_BYTES = 5 * 1024 * 1024;
101
+ function isBlobOutputEnabled() {
102
+ return /^(1|true)$/i.test(process.env.MS365_MCP_BLOB_OUTPUT ?? "");
103
+ }
104
+ function getBlobMaxBytes() {
105
+ const n = Number(process.env.MS365_MCP_BLOB_MAX_BYTES);
106
+ return Number.isInteger(n) && n > 0 ? n : DEFAULT_BLOB_MAX_BYTES;
107
+ }
108
+ async function readBody(response, maxBytes) {
109
+ if (maxBytes === void 0) return Buffer.from(await response.arrayBuffer());
110
+ const tooLarge = () => new Error(`The file is larger than the download limit of ${maxBytes} bytes.`);
111
+ const declared = Number(response.headers?.get?.("content-length"));
112
+ if (declared > maxBytes) {
113
+ await response.body?.cancel();
114
+ throw tooLarge();
115
+ }
116
+ const chunks = [];
117
+ let total = 0;
118
+ const reader = response.body?.getReader();
119
+ while (reader) {
120
+ const { done, value } = await reader.read();
121
+ if (done) break;
122
+ total += value.byteLength;
123
+ if (total > maxBytes) {
124
+ await reader.cancel();
125
+ throw tooLarge();
126
+ }
127
+ chunks.push(value);
128
+ }
129
+ return Buffer.concat(chunks);
130
+ }
131
+ function toBlobResponse(data, blob) {
132
+ const d = data;
133
+ if (!d || typeof d.contentBytes !== "string") return void 0;
134
+ const mimeType = d.contentType?.split(";")[0].trim() || "application/octet-stream";
135
+ const name = blob.fileName?.split("/").join("_").split(String.fromCharCode(92)).join("_") || "download";
136
+ const size = Buffer.byteLength(d.contentBytes, "base64");
137
+ return {
138
+ content: [
139
+ { type: "text", text: JSON.stringify({ name, contentType: mimeType, size }) },
140
+ // shortcut: ContentItem is text-only; this is the one non-text item, passed through as is.
141
+ {
142
+ type: "resource",
143
+ resource: {
144
+ uri: `file:///${encodeURIComponent(name)}`,
145
+ mimeType,
146
+ blob: d.contentBytes
147
+ }
148
+ }
149
+ ]
150
+ };
151
+ }
100
152
  class GraphClient {
101
153
  constructor(authManager, secrets, outputFormat = "json") {
102
154
  this.outputFormat = "json";
@@ -125,7 +177,7 @@ class GraphClient {
125
177
  const isBinaryResponse = options.forceBinary === true || isBinaryContentType(contentTypeHeader);
126
178
  let metadata = { http_status: response.status };
127
179
  let result;
128
- const buffer = Buffer.from(await response.arrayBuffer());
180
+ const buffer = await readBody(response, options.maxBytes);
129
181
  metadata = { ...metadata, response_bytes: buffer.byteLength };
130
182
  let text;
131
183
  if (!isBinaryResponse) {
@@ -343,8 +395,9 @@ class GraphClient {
343
395
  );
344
396
  const { data, metadata } = await this.makeRequestWithMetadata(endpoint, options);
345
397
  const outputFormat = options.forceJsonOutput ? "json" : this.outputFormat;
398
+ const blobResponse = options.blobOutput && !options.excludeResponse ? toBlobResponse(data, options.blobOutput) : void 0;
346
399
  return this.withResponseMetadata(
347
- this.formatJsonResponse(data, options.rawResponse, options.excludeResponse, outputFormat),
400
+ blobResponse ?? this.formatJsonResponse(data, options.rawResponse, options.excludeResponse, outputFormat),
348
401
  metadata
349
402
  );
350
403
  } catch (error) {
@@ -446,7 +499,10 @@ class GraphClient {
446
499
  }
447
500
  var graph_client_default = GraphClient;
448
501
  export {
502
+ DEFAULT_BLOB_MAX_BYTES,
449
503
  GraphApiError,
450
504
  graph_client_default as default,
451
- isBinaryContentType
505
+ getBlobMaxBytes,
506
+ isBinaryContentType,
507
+ isBlobOutputEnabled
452
508
  };
@@ -14,8 +14,10 @@ import {
14
14
  } from "./auth.js";
15
15
  import { api as betaApi } from "./generated/client-beta.js";
16
16
  import { api } from "./generated/client.js";
17
+ import { getBlobMaxBytes, isBlobOutputEnabled } from "./graph-client.js";
17
18
  import { getAttachmentMinting } from "./lib/attachment-minting.js";
18
- import { MAX_UPLOAD_BYTES } from "./attachment-route.js";
19
+ import { MAX_UPLOAD_BYTES, uploadAttachment } from "./lib/attachment-upload.js";
20
+ import { getUploadDirs, openUploadFile } from "./lib/upload-dirs.js";
19
21
  import {
20
22
  buildAttachmentUrl,
21
23
  isPlainGraphPath,
@@ -497,6 +499,7 @@ function collectDeniedToolPolicies(options) {
497
499
  for (const utility of UTILITY_TOOLS) {
498
500
  if (options.readOnly && !utility.readOnlyHint) continue;
499
501
  if (options.httpMode && utility.stdioOnly) continue;
502
+ if (utility.enabled && !utility.enabled()) continue;
500
503
  if (options.enabledToolsRegex && !options.enabledToolsRegex.test(utility.name)) {
501
504
  deniedTools.set(utility.name, {
502
505
  toolName: utility.name,
@@ -548,9 +551,16 @@ async function checkAccountParamInBearerMode(accountParam, authManager) {
548
551
  }
549
552
  const UPLOAD_TARGET = /^(\/me|\/users\/[^/]+)\/(messages|events)\/[^/]+\/attachments$/;
550
553
  const UPLOAD_CONTENT_TYPE = /^[\w.+-]+\/[\w.+-]+$/;
554
+ const UPLOAD_TARGET_ERROR = "target must be the attachments collection of a draft message or an event: /me/messages/{message-id}/attachments or /me/events/{event-id}/attachments.";
551
555
  function uploadError(message) {
552
556
  return { content: [{ type: "text", text: JSON.stringify({ error: message }) }], isError: true };
553
557
  }
558
+ function cleanAttachmentName(name) {
559
+ return Array.from(name.trim()).filter((char) => {
560
+ const code = char.charCodeAt(0);
561
+ return code > 31 && code !== 127;
562
+ }).join("").slice(0, 255);
563
+ }
554
564
  async function mintUploadUrl(target, name, contentType, accountParam, authManager) {
555
565
  const minting = getAttachmentMinting();
556
566
  if (!minting) {
@@ -560,14 +570,9 @@ async function mintUploadUrl(target, name, contentType, accountParam, authManage
560
570
  );
561
571
  }
562
572
  if (!UPLOAD_TARGET.test(target) || !isPlainGraphPath(target)) {
563
- return uploadError(
564
- "target must be the attachments collection of a draft message or an event: /me/messages/{message-id}/attachments or /me/events/{event-id}/attachments."
565
- );
573
+ return uploadError(UPLOAD_TARGET_ERROR);
566
574
  }
567
- const cleanName = Array.from(name.trim()).filter((char) => {
568
- const code = char.charCodeAt(0);
569
- return code > 31 && code !== 127;
570
- }).join("").slice(0, 255);
575
+ const cleanName = cleanAttachmentName(name);
571
576
  if (!cleanName) return uploadError("name is required.");
572
577
  if (!UPLOAD_CONTENT_TYPE.test(contentType)) {
573
578
  return uploadError("contentType must be a MIME type such as application/pdf.");
@@ -714,6 +719,111 @@ const UTILITY_TOOLS = [
714
719
  );
715
720
  }
716
721
  },
722
+ {
723
+ name: "upload-attachment-from-file",
724
+ method: "POST",
725
+ path: "tool:upload-attachment-from-file",
726
+ searchKeywords: "attach local file upload file from disk attach file path draft email attach file event attachment without base64",
727
+ description: "Attach a local file on the server to a draft message or an event, read straight from disk instead of passing as base64 through the agent context. target is the attachments collection, e.g. /me/messages/{message-id}/attachments or /me/events/{event-id}/attachments. Files up to 150 MB; the server picks an inline upload or an upload session by size. filePath must be absolute and inside a directory listed in MS365_MCP_UPLOAD_DIRS. stdio mode, or HTTP with --http-local-file-tools.",
728
+ readOnlyHint: false,
729
+ openWorldHint: true,
730
+ stdioOnly: true,
731
+ enabled: () => getUploadDirs().length > 0,
732
+ buildSchema: (ctx) => {
733
+ const schema = {
734
+ target: z.string().describe(
735
+ "Attachments collection of a draft message or an event, e.g. /me/messages/{message-id}/attachments or /me/events/{event-id}/attachments."
736
+ ),
737
+ filePath: z.string().describe(
738
+ "Absolute path of the file on the server's filesystem, e.g. /Users/me/documents/invitation.pdf. Must be inside a directory the server allows uploads from."
739
+ ),
740
+ name: z.string().optional().describe(
741
+ "Attachment file name as the recipient will see it. Defaults to the file name of filePath."
742
+ ),
743
+ contentType: z.string().optional().describe(
744
+ "MIME type of the file, e.g. application/pdf. Defaults to application/octet-stream."
745
+ )
746
+ };
747
+ if (ctx.multiAccount) {
748
+ schema["account"] = z.string().optional().describe(
749
+ "Account to use when multiple Microsoft accounts are configured. Required when multiple accounts exist (see list-accounts)."
750
+ );
751
+ }
752
+ return schema;
753
+ },
754
+ execute: async (params, { graphClient, authManager }) => {
755
+ const target = params.target;
756
+ const filePath = params.filePath;
757
+ const accountParam = params.account;
758
+ if (typeof target !== "string" || target.length === 0) {
759
+ return uploadError("target is required and must be a non-empty string.");
760
+ }
761
+ if (typeof filePath !== "string" || filePath.length === 0) {
762
+ return uploadError("filePath is required and must be a non-empty string.");
763
+ }
764
+ if (!UPLOAD_TARGET.test(target) || !isPlainGraphPath(target)) {
765
+ return uploadError(UPLOAD_TARGET_ERROR);
766
+ }
767
+ const contentType = typeof params.contentType === "string" && params.contentType.length > 0 ? params.contentType : "application/octet-stream";
768
+ if (!UPLOAD_CONTENT_TYPE.test(contentType)) {
769
+ return uploadError("contentType must be a MIME type such as application/pdf.");
770
+ }
771
+ const name = cleanAttachmentName(
772
+ typeof params.name === "string" && params.name.trim().length > 0 ? params.name : path.basename(filePath)
773
+ );
774
+ if (!name) return uploadError("name is required.");
775
+ let file;
776
+ try {
777
+ file = await openUploadFile(filePath, getUploadDirs());
778
+ } catch (error) {
779
+ return uploadError(error.message);
780
+ }
781
+ try {
782
+ if (file.size === 0) return uploadError("The file is empty.");
783
+ if (file.size > MAX_UPLOAD_BYTES) {
784
+ return uploadError(
785
+ `The file is ${file.size} bytes; the largest attachment is ${MAX_UPLOAD_BYTES} bytes.`
786
+ );
787
+ }
788
+ const accountModeError = await checkAccountParamInBearerMode(accountParam, authManager);
789
+ if (accountModeError) return uploadError(accountModeError);
790
+ let accountAccessToken;
791
+ if (authManager && !authManager.isOAuthModeEnabled() && !getRequestTokens()) {
792
+ accountAccessToken = await authManager.getTokenForAccount(accountParam);
793
+ }
794
+ const created = await uploadAttachment(
795
+ graphClient,
796
+ { target, name, contentType, size: file.size, accessToken: accountAccessToken },
797
+ // end pins the read to the size that was declared, should the file grow meanwhile
798
+ file.handle.createReadStream({
799
+ autoClose: false,
800
+ highWaterMark: 1024 * 1024,
801
+ end: file.size - 1
802
+ })
803
+ );
804
+ return {
805
+ content: [
806
+ {
807
+ type: "text",
808
+ text: JSON.stringify({
809
+ name,
810
+ size: file.size,
811
+ ...created.id ? { id: created.id } : {}
812
+ })
813
+ }
814
+ ]
815
+ };
816
+ } catch (error) {
817
+ const metadata = thrownErrorAuditFields(error);
818
+ return {
819
+ ...uploadError(error.message),
820
+ ...Object.keys(metadata).length > 0 ? { _meta: metadata } : {}
821
+ };
822
+ } finally {
823
+ await file.handle.close();
824
+ }
825
+ }
826
+ },
717
827
  {
718
828
  name: "parse-teams-url",
719
829
  method: "POST",
@@ -747,14 +857,17 @@ const UTILITY_TOOLS = [
747
857
  name: "download-bytes",
748
858
  method: "GET",
749
859
  path: "tool:download-bytes",
750
- 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.',
860
+ 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. With the server's blob output on, the bytes come back as a file resource instead (capped by the server's download limit); pass fileName to name it.`,
751
861
  readOnlyHint: true,
752
862
  openWorldHint: true,
753
863
  buildSchema: (ctx) => {
754
864
  const schema = {
755
865
  target: z.string().describe(
756
866
  '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.'
757
- )
867
+ ),
868
+ fileName: z.string().describe(
869
+ "Name of the file, used as the last segment of the returned resource URI when the server runs with blob output. Optional."
870
+ ).optional()
758
871
  };
759
872
  if (ctx.multiAccount) {
760
873
  schema["account"] = z.string().optional().describe(
@@ -806,10 +919,17 @@ const UTILITY_TOOLS = [
806
919
  if (authManager && !authManager.isOAuthModeEnabled() && !getRequestTokens()) {
807
920
  accountAccessToken = await authManager.getTokenForAccount(accountParam);
808
921
  }
922
+ const blob = isBlobOutputEnabled() ? {
923
+ maxBytes: getBlobMaxBytes(),
924
+ blobOutput: {
925
+ fileName: typeof params.fileName === "string" ? params.fileName : void 0
926
+ }
927
+ } : {};
809
928
  return await graphClient.graphRequest(target, {
810
929
  accessToken: accountAccessToken,
811
930
  rawResponse: true,
812
- forceBinary: true
931
+ forceBinary: true,
932
+ ...blob
813
933
  });
814
934
  } catch (error) {
815
935
  return {
@@ -1422,6 +1542,7 @@ async function executeGraphTool(tool, config, graphClient, params, authManager,
1422
1542
  );
1423
1543
  const strayBodyFields = {};
1424
1544
  for (const [paramName, paramValue] of Object.entries(params)) {
1545
+ if (paramName === "fileName" && config?.blobOutput) continue;
1425
1546
  if ([
1426
1547
  "account",
1427
1548
  "confirm",
@@ -1663,6 +1784,13 @@ async function executeGraphTool(tool, config, graphClient, params, authManager,
1663
1784
  method: tool.method.toUpperCase(),
1664
1785
  headers
1665
1786
  };
1787
+ if (config?.blobOutput && isBlobOutputEnabled()) {
1788
+ options.forceBinary = true;
1789
+ options.maxBytes = getBlobMaxBytes();
1790
+ options.blobOutput = {
1791
+ fileName: typeof params.fileName === "string" ? params.fileName : void 0
1792
+ };
1793
+ }
1666
1794
  if (config?.apiVersion) {
1667
1795
  options.apiVersion = config.apiVersion;
1668
1796
  }
@@ -1855,10 +1983,9 @@ async function executeGraphTool(tool, config, graphClient, params, authManager,
1855
1983
  } catch {
1856
1984
  }
1857
1985
  }
1858
- const content = response.content.map((item) => ({
1859
- type: "text",
1860
- text: item.text
1861
- }));
1986
+ const content = response.content.map(
1987
+ (item) => item.type === "resource" ? item : { type: "text", text: item.text }
1988
+ );
1862
1989
  auditLog({
1863
1990
  event: "tool.call",
1864
1991
  request_id: requestId,
@@ -1989,6 +2116,11 @@ function registerGraphTools(server, graphClient, options = {}) {
1989
2116
  if (endpointConfig?.acceptType && paramSchema["Accept"] === void 0) {
1990
2117
  paramSchema["Accept"] = z.string().describe(getAcceptParamDescription(endpointConfig.acceptType)).optional();
1991
2118
  }
2119
+ if (endpointConfig?.blobOutput && paramSchema["fileName"] === void 0) {
2120
+ paramSchema["fileName"] = z.string().describe(
2121
+ "Name of the file, used as the last segment of the returned resource URI when the server runs with blob output. Optional."
2122
+ ).optional();
2123
+ }
1992
2124
  if (isFetchAllPagesApplicable(tool)) {
1993
2125
  const maxPages = getMaxPages();
1994
2126
  paramSchema["fetchAllPages"] = z.boolean().describe(getFetchAllPagesParamDescription(maxPages, tool.alias)).optional();
@@ -2062,6 +2194,7 @@ function registerGraphTools(server, graphClient, options = {}) {
2062
2194
  for (const utility of UTILITY_TOOLS) {
2063
2195
  if (readOnly && !utility.readOnlyHint) continue;
2064
2196
  if (httpMode && utility.stdioOnly) continue;
2197
+ if (utility.enabled && !utility.enabled()) continue;
2065
2198
  if (enabledToolsRegex && !enabledToolsRegex.test(utility.name)) continue;
2066
2199
  try {
2067
2200
  registerUtilityToolWithMcp(server, utility, utilityCtx);
@@ -2225,6 +2358,7 @@ function registerDiscoveryTools(server, graphClient, options = {}) {
2225
2358
  const utilityTools = UTILITY_TOOLS.filter((u) => {
2226
2359
  if (readOnly && !u.readOnlyHint) return false;
2227
2360
  if (httpMode && u.stdioOnly) return false;
2361
+ if (u.enabled && !u.enabled()) return false;
2228
2362
  if (enabledToolsRegex && !enabledToolsRegex.test(u.name)) return false;
2229
2363
  return true;
2230
2364
  });
@@ -0,0 +1,81 @@
1
+ const MAX_UPLOAD_BYTES = 150 * 1024 * 1024;
2
+ const INLINE_UPLOAD_LIMIT = 3 * 1024 * 1024;
3
+ const UPLOAD_CHUNK_BYTES = 320 * 1024 * 12;
4
+ async function putChunk(uploadUrl, chunk, start, total) {
5
+ const end = start + chunk.length - 1;
6
+ const response = await fetch(uploadUrl, {
7
+ method: "PUT",
8
+ headers: {
9
+ "Content-Type": "application/octet-stream",
10
+ "Content-Length": String(chunk.length),
11
+ "Content-Range": `bytes ${start}-${end}/${total}`
12
+ },
13
+ // A standalone ArrayBuffer: fetch's body typing wants a BufferSource, and a
14
+ // Buffer view over a pooled slab is neither the right type nor the right bytes.
15
+ body: chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength)
16
+ });
17
+ if (![200, 201, 202].includes(response.status)) {
18
+ throw new Error(`Upload session chunk ${start}-${end} answered HTTP ${response.status}`);
19
+ }
20
+ }
21
+ async function uploadAttachment(graphClient, upload, source) {
22
+ const { target, name, contentType, size, accessToken } = upload;
23
+ if (size < INLINE_UPLOAD_LIMIT) {
24
+ const chunks = [];
25
+ let received = 0;
26
+ for await (const chunk of source) {
27
+ received += chunk.length;
28
+ if (received > size) throw new Error(`Source is longer than the declared ${size} bytes`);
29
+ chunks.push(chunk);
30
+ }
31
+ if (received !== size) throw new Error(`Source ended before the declared ${size} bytes`);
32
+ const created = await graphClient.makeRequest(target, {
33
+ method: "POST",
34
+ accessToken,
35
+ body: JSON.stringify({
36
+ "@odata.type": "#microsoft.graph.fileAttachment",
37
+ name,
38
+ contentType,
39
+ contentBytes: Buffer.concat(chunks).toString("base64")
40
+ })
41
+ });
42
+ return { id: created?.id };
43
+ }
44
+ const session = await graphClient.makeRequest(`${target}/createUploadSession`, {
45
+ method: "POST",
46
+ accessToken,
47
+ body: JSON.stringify({
48
+ AttachmentItem: { attachmentType: "file", name, size, contentType }
49
+ })
50
+ });
51
+ if (!session?.uploadUrl) throw new Error("Upload session returned no uploadUrl");
52
+ let pending = [];
53
+ let pendingBytes = 0;
54
+ let offset = 0;
55
+ for await (const piece of source) {
56
+ pending.push(piece);
57
+ pendingBytes += piece.length;
58
+ if (offset + pendingBytes > size) {
59
+ throw new Error(`Source is longer than the declared ${size} bytes`);
60
+ }
61
+ while (pendingBytes >= UPLOAD_CHUNK_BYTES) {
62
+ const joined = Buffer.concat(pending);
63
+ const chunk = joined.subarray(0, UPLOAD_CHUNK_BYTES);
64
+ await putChunk(session.uploadUrl, chunk, offset, size);
65
+ offset += chunk.length;
66
+ const rest = joined.subarray(UPLOAD_CHUNK_BYTES);
67
+ pending = rest.length ? [Buffer.from(rest)] : [];
68
+ pendingBytes = rest.length;
69
+ }
70
+ }
71
+ if (pendingBytes > 0) {
72
+ await putChunk(session.uploadUrl, Buffer.concat(pending), offset, size);
73
+ offset += pendingBytes;
74
+ }
75
+ if (offset !== size) throw new Error(`Source ended before the declared ${size} bytes`);
76
+ return {};
77
+ }
78
+ export {
79
+ MAX_UPLOAD_BYTES,
80
+ uploadAttachment
81
+ };
@@ -0,0 +1,61 @@
1
+ import { constants } from "fs";
2
+ import { open, realpath, stat } from "fs/promises";
3
+ import path from "path";
4
+ const UPLOAD_DIRS_ENV = "MS365_MCP_UPLOAD_DIRS";
5
+ function uploadDirEntries() {
6
+ return (process.env[UPLOAD_DIRS_ENV] ?? "").split(path.delimiter).map((entry) => entry.trim()).filter((entry) => entry.length > 0);
7
+ }
8
+ function getUploadDirs() {
9
+ return uploadDirEntries().filter((entry) => path.isAbsolute(entry));
10
+ }
11
+ function assertUploadDirsAbsolute() {
12
+ const relative = uploadDirEntries().filter((entry) => !path.isAbsolute(entry));
13
+ if (relative.length > 0) {
14
+ throw new Error(
15
+ `${UPLOAD_DIRS_ENV} entries must be absolute paths, got: ${relative.join(", ")}`
16
+ );
17
+ }
18
+ }
19
+ function isInside(dir, file) {
20
+ const relative = path.relative(dir, file);
21
+ return relative.length > 0 && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
22
+ }
23
+ async function openUploadFile(filePath, dirs) {
24
+ const refused = new Error(
25
+ `filePath does not exist or is outside the directories allowed by ${UPLOAD_DIRS_ENV} (${dirs.join(", ")}).`
26
+ );
27
+ if (!path.isAbsolute(filePath)) {
28
+ throw new Error(`filePath must be an absolute path. Received: ${filePath}`);
29
+ }
30
+ let realFile;
31
+ try {
32
+ realFile = await realpath(filePath);
33
+ } catch {
34
+ throw refused;
35
+ }
36
+ const realDirs = await Promise.all(dirs.map((dir) => realpath(dir).catch(() => null)));
37
+ if (!realDirs.some((dir) => dir !== null && isInside(dir, realFile))) throw refused;
38
+ const checked = await stat(realFile, { bigint: true }).catch(() => null);
39
+ if (!checked) throw refused;
40
+ if (!checked.isFile()) throw new Error("filePath is not a regular file.");
41
+ let handle;
42
+ try {
43
+ handle = await open(realFile, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
44
+ } catch {
45
+ throw refused;
46
+ }
47
+ let stats;
48
+ try {
49
+ stats = await handle.stat({ bigint: true });
50
+ if (!stats.isFile() || stats.dev !== checked.dev || stats.ino !== checked.ino) throw refused;
51
+ } catch {
52
+ await handle.close();
53
+ throw refused;
54
+ }
55
+ return { handle, size: Number(stats.size) };
56
+ }
57
+ export {
58
+ assertUploadDirsAbsolute,
59
+ getUploadDirs,
60
+ openUploadFile
61
+ };
package/dist/server.js CHANGED
@@ -251,11 +251,11 @@ class MicrosoftGraphServer {
251
251
  );
252
252
  }
253
253
  logger.warn(
254
- "--http-local-file-tools: download-bytes-to-file is registered over HTTP; anyone who can reach this port can write files as the server's user."
254
+ "--http-local-file-tools: download-bytes-to-file is registered over HTTP, and upload-attachment-from-file too when MS365_MCP_UPLOAD_DIRS is set; anyone who can reach this port can write files, and read files from those directories, as the server's user."
255
255
  );
256
256
  } else if (this.options.httpLocalFileTools) {
257
257
  logger.warn(
258
- "--http-local-file-tools has no effect in stdio mode, where download-bytes-to-file is always registered."
258
+ "--http-local-file-tools has no effect in stdio mode, where the local file tools need no flag."
259
259
  );
260
260
  }
261
261
  if (this.options.obo) {
@@ -63,6 +63,7 @@ const UNIVERSAL_UTILITY_TOOLS = ["download-bytes", "download-bytes-to-file"];
63
63
  const SCOPED_UTILITY_TOOLS = {
64
64
  "get-download-url": ["files", "onedrive", "personal", "work", "search"],
65
65
  "get-upload-url": ["mail", "calendar", "outlook", "personal", "work"],
66
+ "upload-attachment-from-file": ["mail", "calendar", "outlook", "personal", "work"],
66
67
  "parse-teams-url": ["teams", "teams-write", "work"]
67
68
  };
68
69
  for (const [tool, presets] of Object.entries(SCOPED_UTILITY_TOOLS)) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@softeria/ms-365-mcp-server",
3
3
  "mcpName": "io.github.Softeria/ms-365-mcp-server",
4
- "version": "0.163.0",
4
+ "version": "0.165.0",
5
5
  "description": " A Model Context Protocol (MCP) server for interacting with Microsoft 365 and Office services through the Graph API",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
@@ -949,8 +949,40 @@
949
949
  "presets": ["mail", "outlook", "personal"],
950
950
  "scopes": ["Mail.Read"],
951
951
  "acceptType": "text/plain",
952
+ "blobOutput": true,
952
953
  "llmTip": "Download an email message as raw RFC 5322 MIME content (.eml format). Use this when archiving an email to disk preserving all original headers, body, and inline-encoded attachments. Returns the MIME stream as text. Find the message id with list-mail-messages first."
953
954
  },
955
+ {
956
+ "pathPattern": "/users/{user-id}/messages/{message-id}/$value",
957
+ "method": "get",
958
+ "toolName": "get-shared-mailbox-message-mime",
959
+ "descriptionOverride": "Download the raw MIME source (RFC 5322 .eml content) of a message in another user's mailbox or a shared mailbox, by message ID. The user-id is the mailbox owner's ID or email address, and delegated access to that mailbox is required.",
960
+ "presets": ["work"],
961
+ "workScopes": ["Mail.Read.Shared"],
962
+ "acceptType": "text/plain",
963
+ "blobOutput": true,
964
+ "llmTip": "Download a message of a shared mailbox as an .eml. Find the message id with list-shared-mailbox-messages first. With the server's blob output on, the bytes come back as a file resource; pass fileName to name it."
965
+ },
966
+ {
967
+ "pathPattern": "/me/messages/{message-id}/attachments/{attachment-id}/$value",
968
+ "method": "get",
969
+ "toolName": "get-mail-attachment-content",
970
+ "descriptionOverride": "Download the raw bytes of a file attachment of an Outlook message in the signed-in user's mailbox, by message ID and attachment ID.",
971
+ "presets": ["mail", "outlook", "personal"],
972
+ "scopes": ["Mail.Read"],
973
+ "blobOutput": true,
974
+ "llmTip": "Find the attachment id with list-mail-attachments first. With the server's blob output on, the bytes come back as a file resource; pass fileName (the attachment's name) to name it. Files over the server's download limit are refused with an error naming the limit."
975
+ },
976
+ {
977
+ "pathPattern": "/users/{user-id}/messages/{message-id}/attachments/{attachment-id}/$value",
978
+ "method": "get",
979
+ "toolName": "get-shared-mailbox-attachment-content",
980
+ "descriptionOverride": "Download the raw bytes of a file attachment of a message in another user's mailbox or a shared mailbox, by message ID and attachment ID. The user-id is the mailbox owner's ID or email address, and delegated access to that mailbox is required.",
981
+ "presets": ["work"],
982
+ "workScopes": ["Mail.Read.Shared"],
983
+ "blobOutput": true,
984
+ "llmTip": "Find the attachment id with list-shared-mailbox-attachments first. With the server's blob output on, the bytes come back as a file resource; pass fileName (the attachment's name) to name it. Files over the server's download limit are refused with an error naming the limit."
985
+ },
954
986
  {
955
987
  "pathPattern": "/drives/{drive-id}/items/{driveItem-id}/workbook/worksheets/{workbookWorksheet-id}/range(address='{address}')",
956
988
  "method": "patch",