@softeria/ms-365-mcp-server 0.150.3 → 0.152.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
@@ -598,6 +598,15 @@ When running as an MCP server, the following options can be used:
598
598
  --http [port] Use Streamable HTTP transport instead of stdio (optionally specify port, default: 3000)
599
599
  Starts Express.js server with MCP endpoint at /mcp
600
600
  --enable-auth-tools Enable login/logout tools when using HTTP mode (disabled by default in HTTP mode)
601
+ --enable-attachment-urls Let get-download-url mint a server-served URL for byte resources Graph
602
+ exposes no pre-authenticated URL for (see "Server-Minted Attachment URLs")
603
+ --attachment-port <port> Serve /attachment on its own listener on this port instead of on the
604
+ MCP app, so a fetcher that can read attachments cannot also reach /mcp
605
+ (requires --enable-attachment-urls; see "Splitting the attachment listener")
606
+ --attachment-host <host> Interface the --attachment-port listener binds. Defaults to whatever
607
+ --http bound, which with a wildcard --http leaves BOTH ports on every
608
+ interface and so isolates nothing — set this to make the split real
609
+ (requires --attachment-port; see "Splitting the attachment listener")
601
610
  --no-dynamic-registration Disable OAuth Dynamic Client Registration (enabled by default in HTTP mode)
602
611
  --enabled-tools <pattern> Filter tools using regex pattern (e.g., "excel|contact" to enable Excel and Contact tools)
603
612
  --preset <names> Use preset tool categories (comma-separated). See "Tool Presets" section above
@@ -623,6 +632,8 @@ Environment variables:
623
632
  - `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)
624
633
  - `MS365_MCP_RATE_LIMIT_DISABLED=true|1`: Disable per-IP rate limiting in HTTP mode (default: enabled — 30 req/min on `/authorize`, `/token`, `/register`; 120 req/min on `/mcp`)
625
634
  - `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
635
+ - `MS365_MCP_ATTACHMENT_PORT=<port>`: Serve the attachment route on its own listener on this port (alternative to --attachment-port; requires `--enable-attachment-urls`)
636
+ - `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"
626
637
  - `MS365_MCP_CLOUD_TYPE=global|china`: Microsoft cloud environment (alternative to --cloud flag)
627
638
  - `LOG_LEVEL`: Set logging level (default: 'info')
628
639
  - `SILENT=true|1`: Disable console output
@@ -638,6 +649,167 @@ Environment variables:
638
649
  - `MS365_MCP_EXPECTED_USERNAME`: Require local MSAL auth to use this Microsoft account username (case-insensitive; CLI flag takes precedence)
639
650
  - `MS365_MCP_EXPECTED_HOME_ACCOUNT_ID`: Require local MSAL auth to use this exact MSAL homeAccountId (CLI flag takes precedence)
640
651
 
652
+ ## Server-Minted Attachment URLs
653
+
654
+ `get-download-url` returns Microsoft's own pre-authenticated `@microsoft.graph.downloadUrl`
655
+ for OneDrive and SharePoint items. Graph publishes no such URL for **mail and calendar
656
+ attachments, meeting recordings, or any other `/$value` byte endpoint** — for those, the
657
+ only way to read the bytes has been `download-bytes`, which returns base64 into the
658
+ agent's context. A 73 KB, 3-page PDF costs about 24,500 tokens that way, and the model
659
+ cannot parse them anyway.
660
+
661
+ `--enable-attachment-urls` (HTTP mode, off by default) closes that gap. When Graph has no
662
+ URL of its own, `get-download-url` mints one this server serves:
663
+
664
+ ```
665
+ GET /attachment?t=<ticket>&dgk=<key-id>&dgx=<expiry>&dgs=<signature>
666
+ ```
667
+
668
+ The ticket is 32 bytes of CSPRNG output, **single-use**, memory-only, and expires after
669
+ `MS365_MCP_ATTACHMENT_URL_TTL_S` seconds. Redeeming it streams the Graph bytes with this
670
+ server's own token; the fetcher sends no Authorization header and holds no Microsoft
671
+ credential.
672
+
673
+ **This grants no authority the calling agent did not already have.** Every target that can
674
+ be minted is one `download-bytes` would fetch for the same caller on the same account. The
675
+ ticket only moves those bytes out of the context window and into a direct transfer.
676
+
677
+ ### Configuration
678
+
679
+ ```
680
+ MS365_MCP_ATTACHMENT_URL_BASE=http://m365-mcp:3000 # required
681
+ MS365_MCP_ATTACHMENT_URL_KEY=... # required (or _KEY_FILE=/path)
682
+ MS365_MCP_ATTACHMENT_URL_KEY_ID=1 # optional, default 1
683
+ MS365_MCP_ATTACHMENT_URL_TTL_S=120 # optional, default 120, max 300
684
+ ```
685
+
686
+ `MS365_MCP_ATTACHMENT_URL_BASE` is deliberately **not** `MS365_MCP_PUBLIC_URL`: that one is
687
+ browser-facing, for OAuth redirects, while this is fetched server-to-server and is
688
+ commonly a container address. A missing or malformed setting fails at startup rather than
689
+ per-request — a signing feature that comes up without a key would mint URLs nothing can
690
+ verify, silently.
691
+
692
+ ### Splitting the attachment listener
693
+
694
+ By default `/attachment` is served by the same Express app, on the same port, as `/mcp`.
695
+ That is fine when callers are authenticated by a bearer token, and it is a problem when
696
+ they are not. Under `--trust-proxy-auth` the MCP endpoint reads no `Authorization` header
697
+ at all — **reachability is the authentication** — so one shared port means the sidecar you
698
+ allowed through in order to fetch a PDF can also call every tool on the server.
699
+
700
+ `--attachment-port <port>` (or `MS365_MCP_ATTACHMENT_PORT`) moves the route onto a listener
701
+ of its own, and `--attachment-host <host>` (or `MS365_MCP_ATTACHMENT_HOST`) says which
702
+ interface that listener binds:
703
+
704
+ ```
705
+ ms-365-mcp-server --http 10.89.0.2:3000 --trust-proxy-auth \
706
+ --enable-attachment-urls \
707
+ --attachment-port 3001 --attachment-host 10.89.1.2
708
+ MS365_MCP_ATTACHMENT_URL_BASE=http://m365-mcp:3001 # note: the attachment port
709
+ ```
710
+
711
+ - `GET /attachment` on **3001** works; on 3000 it is **404** — the MCP app never mounts it.
712
+ - `/mcp` on **3001** is **404**, as is everything else: the second app has the attachment
713
+ route and nothing more. No OAuth router, no body parsers, no CORS, no health check.
714
+ - The 60 req/min limiter that guards the route follows it onto the new listener.
715
+ - `trust proxy` is **off** on the attachment listener (and `MS365_MCP_TRUST_PROXY_HOPS` is
716
+ not read for it), unlike the MCP listener, which trusts one hop. This port is meant to be
717
+ dialled directly on a container network; honouring `X-Forwarded-For` on the server's one
718
+ uncredentialed surface would let a caller choose its own rate-limit bucket.
719
+
720
+ The flag requires `--enable-attachment-urls` and refuses to start without it — on its own
721
+ it would open a port with nothing on it while the operator believed the surfaces were
722
+ separated. In stdio mode it warns and is ignored, like the flag it depends on.
723
+ `--attachment-host` likewise requires `--attachment-port`: alone it would name an interface
724
+ for a listener that does not exist.
725
+
726
+ #### Two ports are not two surfaces unless they bind two interfaces
727
+
728
+ **This is the part that decides whether any of the above is worth anything.** Read it
729
+ before you deploy the split.
730
+
731
+ `--attachment-port` on its own separates the two surfaces _inside the process_. It does not
732
+ separate them _on the network_. Without `--attachment-host` the attachment listener inherits
733
+ whatever host `--http` bound — and `--http 3000`, the common form, names no host at all, so
734
+ Node binds the wildcard and **both** ports answer on **every** interface:
735
+
736
+ ```
737
+ ms-365-mcp-server --http 3000 --trust-proxy-auth \
738
+ --enable-attachment-urls --attachment-port 3001 # NOT isolated
739
+ ```
740
+
741
+ Container networks grant a peer every port on a container, not one port. Put a
742
+ document-conversion sidecar on a shared bridge so it can fetch `/attachment` on 3001, and
743
+ that same sidecar can dial `:3000/mcp` — which under `--trust-proxy-auth` reads no
744
+ `Authorization` header at all and hands back the full tool catalogue. Nothing fails, nothing
745
+ is logged as an error, and the config looks exactly like the isolated one.
746
+
747
+ To make it real, give the two listeners **different addresses**, and put only the attachment
748
+ address on the network the fetcher is on:
749
+
750
+ ```yaml
751
+ # docker compose — the MCP port on the agent's own bridge, the attachment port on the
752
+ # bridge shared with the converter. The converter can reach 3001 and cannot route to 3000.
753
+ services:
754
+ m365-mcp:
755
+ networks: { agent-net: { ipv4_address: 10.89.0.2 }, convert-net: { ipv4_address: 10.89.1.2 } }
756
+ command: >
757
+ --http 10.89.0.2:3000 --trust-proxy-auth
758
+ --enable-attachment-urls
759
+ --attachment-port 3001 --attachment-host 10.89.1.2
760
+ docglean:
761
+ networks: [convert-net]
762
+ ```
763
+
764
+ The MCP port is then unreachable from `convert-net` **by binding** — there is no socket
765
+ listening on that interface — rather than by a firewall rule that has to keep matching.
766
+
767
+ The server warns at startup if you run `--trust-proxy-auth` with `--attachment-port` while
768
+ both listeners still answer on a common interface (either sharing an address, or either one
769
+ on the wildcard). Both bound addresses are logged, read back from the socket rather than
770
+ from the flags, so `Server listening on …` and `Attachment listener on …` can be compared
771
+ directly.
772
+
773
+ `--attachment-host` takes a bare IPv4 address, IPv6 address (bracketed `[::1]` or bare
774
+ `::1`) or hostname. It is refused rather than coerced — `--attachment-host 10.0.0.5:3001`
775
+ is an error naming `--attachment-port`, not a bind to something else. Note that
776
+ `MS365_MCP_ATTACHMENT_URL_BASE` still must not be an IPv6 literal (the URL signature covers
777
+ the host and the two implementations normalise IPv6 differently); if you bind the listener
778
+ to an IPv6 address, name it in the base by hostname.
779
+
780
+ Point `MS365_MCP_ATTACHMENT_URL_BASE` at the attachment port. The server cannot check this
781
+ for you: the base is usually a container name on a network this process cannot resolve, so
782
+ a wrong port here shows up as a fetch failure in the sidecar, not an error here. Both the
783
+ base and the bound port are logged at startup, one line apart, for exactly that comparison.
784
+
785
+ ### The signature, and who checks what
786
+
787
+ `dgk`/`dgx`/`dgs` are **not** checked by this server on redemption, and that is deliberate.
788
+ They exist for the fetcher: a document-conversion sidecar that refuses to dial a private
789
+ address unless the URL carries a valid HMAC from an origin it has been configured to trust.
790
+ What authorises redemption _here_ is the ticket. Verifying the signature on the way back in
791
+ would prove only that we minted the URL — which the ticket already proves — while coupling
792
+ redemption to the sidecar's clock and to the key surviving a restart.
793
+
794
+ The wire format is [docglean-mcp](https://github.com/msoukhomlinov/docglean-mcp)'s
795
+ `signing.py` (`canonical_string`), and `src/lib/url-signing.ts` is a port of it. The
796
+ canonical string is `\n`-joined: `v1`, lowercased scheme, lowercased host, the port always
797
+ explicit, the path, the remaining query with `dgk`/`dgx`/`dgs` removed and the rest sorted
798
+ and re-encoded, and the expiry. The test vectors in
799
+ `test/attachment-url-signing.test.ts` were verified against the Python implementation byte
800
+ for byte — three places where the obvious JavaScript disagrees with Python (`!*'()`
801
+ escaping, `+` decoding as a space, and code-point vs UTF-16 sort order) are why that check
802
+ exists rather than being assumed.
803
+
804
+ The ticket travels in the **query, not the path**, because the verifying sidecar keeps a
805
+ fetched URL's path in its error messages and strips the query.
806
+
807
+ ### Not available in OAuth/OBO mode
808
+
809
+ Identity there arrives per request on the caller's `Authorization` header, and a ticket is
810
+ redeemed later by a fetcher that sends none. Minting refuses with an explanation rather
811
+ than producing a URL that always fails.
812
+
641
813
  ## Token Storage
642
814
 
643
815
  Authentication tokens are stored in an encrypted file (AES-256-GCM). Only the 32-byte encryption key goes to the OS credential store via keytar.
@@ -42,6 +42,52 @@ describe("GraphClient audit metadata", () => {
42
42
  expect(result._meta).toMatchObject({ http_status: 200 });
43
43
  expect(JSON.parse(result.content[0].text)).toEqual({ id: "user-1" });
44
44
  });
45
+ it("derives result volume from a collection response", async () => {
46
+ const body = JSON.stringify({
47
+ value: [{ id: "m1" }, { id: "m2" }, { id: "m3" }],
48
+ "@odata.nextLink": "https://graph.microsoft.com/v1.0/me/messages?$skip=3"
49
+ });
50
+ fetchWithResilienceMock.mockResolvedValue(
51
+ new Response(body, { status: 200, headers: { "content-type": "application/json" } })
52
+ );
53
+ const result = await createGraphClient().graphRequest("/me/messages");
54
+ expect(result._meta).toMatchObject({
55
+ result_count: 3,
56
+ result_has_more: true,
57
+ response_bytes: Buffer.byteLength(body, "utf8")
58
+ });
59
+ });
60
+ it("reports result_has_more false, not absent, for a complete collection", async () => {
61
+ fetchWithResilienceMock.mockResolvedValue(
62
+ new Response(JSON.stringify({ value: [{ id: "m1" }] }), {
63
+ status: 200,
64
+ headers: { "content-type": "application/json" }
65
+ })
66
+ );
67
+ const result = await createGraphClient().graphRequest("/me/messages");
68
+ expect(result._meta).toMatchObject({ result_count: 1, result_has_more: false });
69
+ });
70
+ it("omits result fields for a single-object response but still records size", async () => {
71
+ const body = JSON.stringify({ id: "user-1" });
72
+ fetchWithResilienceMock.mockResolvedValue(
73
+ new Response(body, { status: 200, headers: { "content-type": "application/json" } })
74
+ );
75
+ const result = await createGraphClient().graphRequest("/me");
76
+ expect(result._meta).toMatchObject({ response_bytes: Buffer.byteLength(body, "utf8") });
77
+ expect(result._meta).not.toHaveProperty("result_count");
78
+ expect(result._meta).not.toHaveProperty("result_has_more");
79
+ });
80
+ it("records the pre-base64 byte count for binary content", async () => {
81
+ const raw = Buffer.from("binary-attachment-payload-\xFF\xFE");
82
+ fetchWithResilienceMock.mockResolvedValue(
83
+ new Response(raw, {
84
+ status: 200,
85
+ headers: { "content-type": "application/octet-stream" }
86
+ })
87
+ );
88
+ const result = await createGraphClient().graphRequest("/me/messages/m1/$value");
89
+ expect(result._meta).toMatchObject({ response_bytes: raw.byteLength });
90
+ });
45
91
  it("preserves HTTP status metadata when response headers are requested", async () => {
46
92
  fetchWithResilienceMock.mockResolvedValue(
47
93
  new Response(JSON.stringify({ id: "task-1" }), {
@@ -333,6 +333,172 @@ describe("graph-tools", () => {
333
333
  );
334
334
  });
335
335
  });
336
+ describe("audit response volume", () => {
337
+ it("lifts result volume from _meta onto the audit event", async () => {
338
+ const endpoint = makeEndpoint({
339
+ method: "get",
340
+ path: "/me/messages",
341
+ alias: "list-mail-messages"
342
+ });
343
+ const config = makeConfig({
344
+ pathPattern: "/me/messages",
345
+ method: "get",
346
+ toolName: "list-mail-messages"
347
+ });
348
+ mockEndpoints.push(endpoint);
349
+ mockEndpointsJson = [config];
350
+ const graphClient = createMockGraphClient([
351
+ {
352
+ content: [{ type: "text", text: JSON.stringify({ value: [{ id: "m1" }] }) }],
353
+ _meta: {
354
+ http_status: 200,
355
+ result_count: 4821,
356
+ result_has_more: true,
357
+ response_bytes: 8412004
358
+ }
359
+ }
360
+ ]);
361
+ const server = createMockServer();
362
+ const { registerGraphTools } = await loadModule();
363
+ registerGraphTools(
364
+ server,
365
+ graphClient
366
+ );
367
+ await server.tools.get("list-mail-messages").handler({});
368
+ expect(auditLogMock).toHaveBeenCalledWith(
369
+ expect.objectContaining({
370
+ tool: "list-mail-messages",
371
+ status: "success",
372
+ result_count: 4821,
373
+ result_has_more: true,
374
+ response_bytes: 8412004
375
+ })
376
+ );
377
+ });
378
+ it("keeps result_has_more when it is false rather than dropping it", async () => {
379
+ const endpoint = makeEndpoint({
380
+ method: "get",
381
+ path: "/me/messages",
382
+ alias: "list-mail-messages"
383
+ });
384
+ const config = makeConfig({
385
+ pathPattern: "/me/messages",
386
+ method: "get",
387
+ toolName: "list-mail-messages"
388
+ });
389
+ mockEndpoints.push(endpoint);
390
+ mockEndpointsJson = [config];
391
+ const graphClient = createMockGraphClient([
392
+ {
393
+ content: [{ type: "text", text: JSON.stringify({ value: [] }) }],
394
+ _meta: { http_status: 200, result_count: 0, result_has_more: false }
395
+ }
396
+ ]);
397
+ const server = createMockServer();
398
+ const { registerGraphTools } = await loadModule();
399
+ registerGraphTools(
400
+ server,
401
+ graphClient
402
+ );
403
+ await server.tools.get("list-mail-messages").handler({});
404
+ const [payload] = auditLogMock.mock.calls[0];
405
+ expect(payload.result_count).toBe(0);
406
+ expect(payload.result_has_more).toBe(false);
407
+ });
408
+ it("restates count and bytes for the whole read when pages are merged", async () => {
409
+ mockEndpoints.push(makeEndpoint());
410
+ mockEndpointsJson = [makeConfig()];
411
+ const graphClient = createMockGraphClient([
412
+ {
413
+ content: [
414
+ {
415
+ type: "text",
416
+ text: JSON.stringify({
417
+ value: [{ id: "1" }, { id: "2" }],
418
+ "@odata.nextLink": "https://graph.microsoft.com/v1.0/me/messages?$skip=2"
419
+ })
420
+ }
421
+ ],
422
+ _meta: { http_status: 200, result_count: 2, result_has_more: true, response_bytes: 1e3 }
423
+ },
424
+ {
425
+ content: [{ type: "text", text: JSON.stringify({ value: [{ id: "3" }] }) }],
426
+ _meta: { http_status: 200, result_count: 1, result_has_more: false, response_bytes: 700 }
427
+ }
428
+ ]);
429
+ const server = createMockServer();
430
+ const { registerGraphTools } = await loadModule();
431
+ registerGraphTools(server, graphClient);
432
+ await server.tools.get("test-tool").handler({ fetchAllPages: true });
433
+ const [payload] = auditLogMock.mock.calls[0];
434
+ expect(payload.result_count).toBe(3);
435
+ expect(payload.result_has_more).toBe(false);
436
+ expect(payload.response_bytes).toBe(1700);
437
+ });
438
+ it("reports result_has_more when the merge loop stopped on a page cap", async () => {
439
+ const prevMaxPages = process.env.MS365_MCP_MAX_PAGES;
440
+ process.env.MS365_MCP_MAX_PAGES = "2";
441
+ try {
442
+ mockEndpoints.push(makeEndpoint());
443
+ mockEndpointsJson = [makeConfig()];
444
+ const graphClient = createMockGraphClient(
445
+ Array.from({ length: 5 }, (_, i) => ({
446
+ content: [
447
+ {
448
+ type: "text",
449
+ text: JSON.stringify({
450
+ value: [{ id: `item-${i}` }],
451
+ "@odata.nextLink": `https://graph.microsoft.com/v1.0/me/messages?$skip=${i + 1}`
452
+ })
453
+ }
454
+ ],
455
+ _meta: { http_status: 200, result_count: 1, result_has_more: true, response_bytes: 50 }
456
+ }))
457
+ );
458
+ const server = createMockServer();
459
+ const { registerGraphTools } = await loadModule();
460
+ registerGraphTools(server, graphClient);
461
+ await server.tools.get("test-tool").handler({ fetchAllPages: true });
462
+ const [payload] = auditLogMock.mock.calls[0];
463
+ expect(payload.result_count).toBe(2);
464
+ expect(payload.result_has_more).toBe(true);
465
+ expect(payload.response_bytes).toBe(100);
466
+ } finally {
467
+ if (prevMaxPages === void 0) {
468
+ delete process.env.MS365_MCP_MAX_PAGES;
469
+ } else {
470
+ process.env.MS365_MCP_MAX_PAGES = prevMaxPages;
471
+ }
472
+ }
473
+ });
474
+ it("omits the volume fields when the client supplied none", async () => {
475
+ const endpoint = makeEndpoint({ method: "get", path: "/me", alias: "get-current-user" });
476
+ const config = makeConfig({
477
+ pathPattern: "/me",
478
+ method: "get",
479
+ toolName: "get-current-user"
480
+ });
481
+ mockEndpoints.push(endpoint);
482
+ mockEndpointsJson = [config];
483
+ const graphClient = createMockGraphClient([
484
+ {
485
+ content: [{ type: "text", text: JSON.stringify({ id: "user-1" }) }],
486
+ _meta: { http_status: 200 }
487
+ }
488
+ ]);
489
+ const server = createMockServer();
490
+ const { registerGraphTools } = await loadModule();
491
+ registerGraphTools(
492
+ server,
493
+ graphClient
494
+ );
495
+ await server.tools.get("get-current-user").handler({});
496
+ const [payload] = auditLogMock.mock.calls[0];
497
+ expect(payload).not.toHaveProperty("result_count");
498
+ expect(payload).not.toHaveProperty("result_has_more");
499
+ expect(payload).not.toHaveProperty("response_bytes");
500
+ });
501
+ });
336
502
  describe("audit recipient metadata", () => {
337
503
  const draftEndpoint = () => {
338
504
  const endpoint = makeEndpoint({
@@ -0,0 +1,112 @@
1
+ import { Readable } from "node:stream";
2
+ import { pipeline } from "node:stream/promises";
3
+ import logger from "./logger.js";
4
+ import {
5
+ isPlainGraphPath,
6
+ TICKET_PARAM
7
+ } from "./lib/attachment-tickets.js";
8
+ const NOT_FOUND_BODY = "Not found";
9
+ function forceAttachment(header) {
10
+ if (!header) return "attachment";
11
+ const params = [];
12
+ let current = "";
13
+ let quoted = false;
14
+ for (let i = 0; i < header.length; i += 1) {
15
+ const char = header[i];
16
+ if (quoted && char === "\\" && i + 1 < header.length) {
17
+ current += char + header[i + 1];
18
+ i += 1;
19
+ continue;
20
+ }
21
+ if (char === '"') quoted = !quoted;
22
+ else if (char === ";" && !quoted) {
23
+ params.push(current);
24
+ current = "";
25
+ continue;
26
+ }
27
+ current += char;
28
+ }
29
+ params.push(current);
30
+ let filename;
31
+ let extended;
32
+ for (const param of params.slice(1)) {
33
+ const eq = param.indexOf("=");
34
+ if (eq === -1) continue;
35
+ const name = param.slice(0, eq).trim().toLowerCase();
36
+ const value = Array.from(param.slice(eq + 1).trim()).filter((char) => {
37
+ const code = char.charCodeAt(0);
38
+ return code > 31 && code !== 127;
39
+ }).join("");
40
+ if (!value) continue;
41
+ if (name === "filename*") extended = value;
42
+ else if (name === "filename") filename = value;
43
+ }
44
+ if (extended) return `attachment; filename*=${extended}`;
45
+ if (filename) return `attachment; filename=${filename}`;
46
+ return "attachment";
47
+ }
48
+ function refuse(res) {
49
+ res.status(404).type("text/plain").send(NOT_FOUND_BODY);
50
+ }
51
+ function createAttachmentHandler(deps) {
52
+ return async (req, res) => {
53
+ if (req.method !== "GET") {
54
+ res.setHeader("allow", "GET");
55
+ res.status(405).type("text/plain").send("Method not allowed");
56
+ return;
57
+ }
58
+ const raw = req.query[TICKET_PARAM];
59
+ if (typeof raw !== "string" || raw.length === 0) {
60
+ refuse(res);
61
+ return;
62
+ }
63
+ const ticket = deps.store.redeem(raw);
64
+ if (!ticket) {
65
+ refuse(res);
66
+ return;
67
+ }
68
+ if (!isPlainGraphPath(ticket.target)) {
69
+ logger.error("Attachment redemption refused: ticket target is not a plain Graph path");
70
+ refuse(res);
71
+ return;
72
+ }
73
+ const graphClient = deps.getGraphClient();
74
+ if (!graphClient) {
75
+ logger.error("Attachment redemption failed: Graph client is not initialised");
76
+ res.status(503).type("text/plain").send("Service unavailable");
77
+ return;
78
+ }
79
+ let stream;
80
+ try {
81
+ let accessToken;
82
+ if (!deps.authManager.isOAuthModeEnabled()) {
83
+ accessToken = await deps.authManager.getTokenForAccount(ticket.accountName);
84
+ }
85
+ stream = await graphClient.downloadStream(ticket.target, { accessToken });
86
+ } catch (error) {
87
+ logger.error(
88
+ `Attachment redemption failed for ${ticket.target}: ${error.message}`
89
+ );
90
+ res.status(502).type("text/plain").send("Upstream fetch failed");
91
+ return;
92
+ }
93
+ res.status(200);
94
+ res.setHeader("content-type", stream.contentType);
95
+ if (stream.contentLength !== null) {
96
+ res.setHeader("content-length", String(stream.contentLength));
97
+ }
98
+ res.setHeader("content-disposition", forceAttachment(stream.contentDisposition));
99
+ res.setHeader("cache-control", "no-store");
100
+ res.setHeader("x-content-type-options", "nosniff");
101
+ try {
102
+ await pipeline(Readable.fromWeb(stream.body), res);
103
+ } catch (error) {
104
+ logger.error(`Attachment stream aborted for ${ticket.target}: ${error.message}`);
105
+ res.destroy();
106
+ }
107
+ };
108
+ }
109
+ export {
110
+ createAttachmentHandler,
111
+ forceAttachment
112
+ };
package/dist/cli.js CHANGED
@@ -27,6 +27,15 @@ program.name("ms-365-mcp-server").description("Microsoft 365 MCP Server").versio
27
27
  ).option(
28
28
  "--enable-auth-tools",
29
29
  "Enable login/logout tools when using HTTP mode (disabled by default in HTTP mode)"
30
+ ).option(
31
+ "--enable-attachment-urls",
32
+ "HTTP mode only. Let get-download-url mint a short-TTL, single-use URL served by this server for Graph byte resources that expose no pre-authenticated URL of their own (mail and event attachments, meeting recordings, other $value endpoints). Requires MS365_MCP_ATTACHMENT_URL_BASE and MS365_MCP_ATTACHMENT_URL_KEY (or _KEY_FILE)"
33
+ ).option(
34
+ "--attachment-port <port>",
35
+ "HTTP mode only. Serve the attachment download route on its own listener on this port instead of on the MCP app, so a caller that can fetch attachments cannot also reach /mcp. Requires --enable-attachment-urls, and MS365_MCP_ATTACHMENT_URL_BASE must name this port. A separate port only isolates the two surfaces if they also bind separate interfaces \u2014 see --attachment-host. Equivalent env var: MS365_MCP_ATTACHMENT_PORT."
36
+ ).option(
37
+ "--attachment-host <host>",
38
+ "Interface the --attachment-port listener binds (IPv4, IPv6 or hostname). Defaults to whatever --http bound, which with a wildcard --http means both ports answer on every interface \u2014 so a peer allowed onto the network to fetch attachments can also reach /mcp. Bind this to the address the fetcher uses and --http to a different one to make that unreachable rather than merely un-advertised. Requires --attachment-port. Equivalent env var: MS365_MCP_ATTACHMENT_HOST."
30
39
  ).option(
31
40
  "--enabled-tools <pattern>",
32
41
  'Filter tools using regex pattern (e.g., "excel|contact" to enable Excel and Contact tools)'
@@ -127,6 +136,12 @@ function parseArgs() {
127
136
  );
128
137
  process.exit(1);
129
138
  }
139
+ if (options.attachmentPort === void 0 && process.env.MS365_MCP_ATTACHMENT_PORT !== void 0) {
140
+ options.attachmentPort = process.env.MS365_MCP_ATTACHMENT_PORT;
141
+ }
142
+ if (options.attachmentHost === void 0 && process.env.MS365_MCP_ATTACHMENT_HOST !== void 0) {
143
+ options.attachmentHost = process.env.MS365_MCP_ATTACHMENT_HOST;
144
+ }
130
145
  if (options.expectedUsername === void 0 && process.env.MS365_MCP_EXPECTED_USERNAME !== void 0) {
131
146
  options.expectedUsername = process.env.MS365_MCP_EXPECTED_USERNAME;
132
147
  }
@@ -66,6 +66,13 @@ function extractGraphErrorCodeFromBody(body) {
66
66
  const code = isRecord(error) ? error.code : body.code;
67
67
  return typeof code === "string" ? code : void 0;
68
68
  }
69
+ function extractPayloadMetadata(data) {
70
+ if (!isRecord(data) || !Array.isArray(data.value)) return {};
71
+ return {
72
+ result_count: data.value.length,
73
+ result_has_more: typeof data["@odata.nextLink"] === "string"
74
+ };
75
+ }
69
76
  function extractBatchMetadata(data) {
70
77
  if (!isRecord(data) || !Array.isArray(data.responses)) return {};
71
78
  const httpStatusCounts = {};
@@ -125,8 +132,10 @@ class GraphClient {
125
132
  contentLength: buffer.byteLength,
126
133
  contentBytes: buffer.toString("base64")
127
134
  };
135
+ metadata = { ...metadata, response_bytes: buffer.byteLength };
128
136
  } else {
129
137
  const text = await response.text();
138
+ metadata = { ...metadata, response_bytes: Buffer.byteLength(text, "utf8") };
130
139
  if (text === "") {
131
140
  result = { message: TRANSPORT_OK_MESSAGE };
132
141
  } else if (options.rawResponse) {
@@ -142,6 +151,7 @@ class GraphClient {
142
151
  if (endpoint === "/$batch") {
143
152
  metadata = { ...metadata, ...extractBatchMetadata(result) };
144
153
  }
154
+ metadata = { ...metadata, ...extractPayloadMetadata(result) };
145
155
  if (options.includeHeaders) {
146
156
  const etag = response.headers.get("ETag") || response.headers.get("etag");
147
157
  if (result && typeof result === "object" && !Array.isArray(result)) {
@@ -167,6 +177,55 @@ class GraphClient {
167
177
  * memory or hit V8's max string length. Creates the file with wx + 0o600 (never
168
178
  * overwrites) and removes a partial file if the transfer fails.
169
179
  */
180
+ /**
181
+ * Fetch Graph binary content and hand back the undrained response stream.
182
+ *
183
+ * Same auth, same resilience and the same error mapping as `downloadToFile`,
184
+ * which is the reason this exists rather than the attachment route calling
185
+ * `fetch` for itself: token acquisition, the OBO/bearer context tokens, retry
186
+ * and the 403-scope special case are all in `performRequest`, which is
187
+ * private. Splitting them would give the route a second, quietly divergent
188
+ * copy of the auth path.
189
+ *
190
+ * The caller owns the body from here and MUST consume or cancel it -- an
191
+ * abandoned stream holds a socket open until the agent times out.
192
+ */
193
+ async downloadStream(endpoint, options = {}) {
194
+ const contextTokens = getRequestTokens();
195
+ const accessToken = options.accessToken ?? contextTokens?.accessToken ?? await this.authManager.getToken();
196
+ if (!accessToken) {
197
+ throw new Error("No access token available");
198
+ }
199
+ const response = await this.performRequest(endpoint, accessToken, options);
200
+ if (response.status === 403) {
201
+ const errorText = await response.text();
202
+ if (errorText.includes("scope") || errorText.includes("permission")) {
203
+ throw new Error(
204
+ `Microsoft Graph API scope error: ${response.status} ${response.statusText} - ${errorText}. This tool requires organization mode. Please restart with --org-mode flag.`
205
+ );
206
+ }
207
+ throw new Error(
208
+ `Microsoft Graph API error: ${response.status} ${response.statusText} - ${errorText}`
209
+ );
210
+ }
211
+ if (!response.ok) {
212
+ throw new Error(
213
+ `Microsoft Graph API error: ${response.status} ${response.statusText} - ${await response.text()}`
214
+ );
215
+ }
216
+ if (!response.body) {
217
+ throw new Error("Microsoft Graph returned an empty response body");
218
+ }
219
+ const rawLength = response.headers.get("content-length");
220
+ const decoded = response.headers.get("content-encoding") !== null;
221
+ const declaredLength = !decoded && rawLength !== null && /^\d+$/.test(rawLength.trim());
222
+ return {
223
+ body: response.body,
224
+ contentType: response.headers.get("content-type") || "application/octet-stream",
225
+ contentLength: declaredLength ? Number(rawLength) : null,
226
+ contentDisposition: response.headers.get("content-disposition")
227
+ };
228
+ }
170
229
  async downloadToFile(endpoint, destinationPath, options = {}) {
171
230
  const fileHandle = await open(destinationPath, "wx", 384);
172
231
  let completed = false;