@softeria/ms-365-mcp-server 0.150.3 → 0.151.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.
@@ -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
  }
@@ -167,6 +167,55 @@ class GraphClient {
167
167
  * memory or hit V8's max string length. Creates the file with wx + 0o600 (never
168
168
  * overwrites) and removes a partial file if the transfer fails.
169
169
  */
170
+ /**
171
+ * Fetch Graph binary content and hand back the undrained response stream.
172
+ *
173
+ * Same auth, same resilience and the same error mapping as `downloadToFile`,
174
+ * which is the reason this exists rather than the attachment route calling
175
+ * `fetch` for itself: token acquisition, the OBO/bearer context tokens, retry
176
+ * and the 403-scope special case are all in `performRequest`, which is
177
+ * private. Splitting them would give the route a second, quietly divergent
178
+ * copy of the auth path.
179
+ *
180
+ * The caller owns the body from here and MUST consume or cancel it -- an
181
+ * abandoned stream holds a socket open until the agent times out.
182
+ */
183
+ async downloadStream(endpoint, options = {}) {
184
+ const contextTokens = getRequestTokens();
185
+ const accessToken = options.accessToken ?? contextTokens?.accessToken ?? await this.authManager.getToken();
186
+ if (!accessToken) {
187
+ throw new Error("No access token available");
188
+ }
189
+ const response = await this.performRequest(endpoint, accessToken, options);
190
+ if (response.status === 403) {
191
+ const errorText = await response.text();
192
+ if (errorText.includes("scope") || errorText.includes("permission")) {
193
+ throw new Error(
194
+ `Microsoft Graph API scope error: ${response.status} ${response.statusText} - ${errorText}. This tool requires organization mode. Please restart with --org-mode flag.`
195
+ );
196
+ }
197
+ throw new Error(
198
+ `Microsoft Graph API error: ${response.status} ${response.statusText} - ${errorText}`
199
+ );
200
+ }
201
+ if (!response.ok) {
202
+ throw new Error(
203
+ `Microsoft Graph API error: ${response.status} ${response.statusText} - ${await response.text()}`
204
+ );
205
+ }
206
+ if (!response.body) {
207
+ throw new Error("Microsoft Graph returned an empty response body");
208
+ }
209
+ const rawLength = response.headers.get("content-length");
210
+ const decoded = response.headers.get("content-encoding") !== null;
211
+ const declaredLength = !decoded && rawLength !== null && /^\d+$/.test(rawLength.trim());
212
+ return {
213
+ body: response.body,
214
+ contentType: response.headers.get("content-type") || "application/octet-stream",
215
+ contentLength: declaredLength ? Number(rawLength) : null,
216
+ contentDisposition: response.headers.get("content-disposition")
217
+ };
218
+ }
170
219
  async downloadToFile(endpoint, destinationPath, options = {}) {
171
220
  const fileHandle = await open(destinationPath, "wx", 384);
172
221
  let completed = false;
@@ -4,6 +4,12 @@ import logger from "./logger.js";
4
4
  import { auditLog, getUserIdentityForAudit } from "./audit-log.js";
5
5
  import { isDestructiveOperation } from "./lib/destructive-ops.js";
6
6
  import { describePathParam } from "./lib/path-params.js";
7
+ import { getAttachmentMinting } from "./lib/attachment-minting.js";
8
+ import {
9
+ buildAttachmentUrl,
10
+ isPlainGraphPath,
11
+ TicketStoreFullError
12
+ } from "./lib/attachment-tickets.js";
7
13
  import {
8
14
  anyFieldPresent,
9
15
  isTransportEnvelope,
@@ -475,6 +481,68 @@ async function checkAccountParamInBearerMode(accountParam, authManager) {
475
481
  if (bearerIdentity && bearerIdentity.toLowerCase() === accountParam.toLowerCase()) return null;
476
482
  return `The 'account' parameter is not supported in HTTP/OAuth mode: every request uses the identity of the connecting client's bearer token` + (bearerIdentity ? ` ('${bearerIdentity}')` : "") + `, so account switching is not possible. To act as '${accountParam}', reconnect the MCP client authenticated as that account, or run the server in stdio mode (or HTTP with --trust-proxy-auth) where cached accounts are available.`;
477
483
  }
484
+ async function mintDownloadUrl(target, accountParam, authManager) {
485
+ const minting = getAttachmentMinting();
486
+ if (!minting) return null;
487
+ if (authManager?.isOAuthModeEnabled() || getRequestTokens()) {
488
+ return {
489
+ content: [
490
+ {
491
+ type: "text",
492
+ text: JSON.stringify({
493
+ error: "Server-minted download URLs are unavailable when Graph identity comes from the request (OAuth, OBO, or bearer mode): the URL is redeemed later without an Authorization header, so the bytes would be fetched as a different identity than the one that asked for them. Use download-bytes."
494
+ })
495
+ }
496
+ ],
497
+ isError: true
498
+ };
499
+ }
500
+ const accountModeError = await checkAccountParamInBearerMode(accountParam, authManager);
501
+ if (accountModeError) {
502
+ return {
503
+ content: [{ type: "text", text: JSON.stringify({ error: accountModeError }) }],
504
+ isError: true
505
+ };
506
+ }
507
+ if (!isPlainGraphPath(target)) {
508
+ return {
509
+ content: [
510
+ {
511
+ type: "text",
512
+ text: JSON.stringify({
513
+ error: 'target must be a plain relative Graph path: no fragment, no query, no "." or ".." segments, and no percent-encoded separators.'
514
+ })
515
+ }
516
+ ],
517
+ isError: true
518
+ };
519
+ }
520
+ let ticket;
521
+ try {
522
+ ticket = minting.store.mint(target, accountParam);
523
+ } catch (error) {
524
+ if (error instanceof TicketStoreFullError) {
525
+ return {
526
+ content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
527
+ isError: true
528
+ };
529
+ }
530
+ throw error;
531
+ }
532
+ return {
533
+ content: [
534
+ {
535
+ type: "text",
536
+ text: JSON.stringify({
537
+ downloadUrl: buildAttachmentUrl(minting.config, ticket.id),
538
+ expiresAt: new Date(ticket.expiresAtMs).toISOString(),
539
+ singleUse: true,
540
+ note: "Served by this server, not by Microsoft Graph. Valid for one fetch until it expires."
541
+ })
542
+ }
543
+ ]
544
+ };
545
+ }
478
546
  const UTILITY_TOOLS = [
479
547
  {
480
548
  name: "parse-teams-url",
@@ -719,7 +787,7 @@ const UTILITY_TOOLS = [
719
787
  method: "GET",
720
788
  path: "tool:get-download-url",
721
789
  searchKeywords: "download file download drive file download onedrive file sharepoint file download large drive file large sharepoint file large file out-of-band download pre-authenticated url",
722
- description: "Resolve a short-lived, pre-authenticated download URL for Microsoft Graph binary content that exposes one (drive/SharePoint file content). The returned URL streams the bytes with NO Authorization header, so the client can fetch it straight to disk (e.g. curl) without round-tripping base64 through the agent context. Prefer this over download-bytes for any file above a few KB or any bulk download. Returns { downloadUrl, name?, size?, contentType? }. NOTE: mail file attachments (/messages/{id}/attachments/{id}/$value) and meeting recordings do NOT expose a pre-authenticated URL \u2014 Graph offers no such link for them; use download-bytes for small ones.",
790
+ description: "Resolve a short-lived, pre-authenticated download URL for Microsoft Graph binary content that exposes one (drive/SharePoint file content). The returned URL streams the bytes with NO Authorization header, so the client can fetch it straight to disk (e.g. curl) without round-tripping base64 through the agent context. Prefer this over download-bytes for any file above a few KB or any bulk download. Returns { downloadUrl, name?, size?, contentType? }. Mail file attachments (/messages/{id}/attachments/{id}/$value), meeting recordings and other $value byte endpoints have no pre-authenticated URL from Graph itself, but call this tool for them anyway: a server running with --enable-attachment-urls mints its own single-use URL for them, and one without it answers with the reason and points at download-bytes.",
723
791
  readOnlyHint: true,
724
792
  openWorldHint: true,
725
793
  buildSchema: (ctx) => {
@@ -778,6 +846,8 @@ const UTILITY_TOOLS = [
778
846
  }
779
847
  const pathPart = target.replace(/\/+$/, "");
780
848
  if (/^(\/me|\/users\/[^/]+)\/messages\/[^/]+\/attachments\//.test(pathPart) || /^(\/me|\/users\/[^/]+)\/events\/[^/]+\/attachments\//.test(pathPart) || /^\/groups\/[^/]+\/messages\/[^/]+\/attachments\//.test(pathPart) || /^\/groups\/[^/]+\/events\/[^/]+\/attachments\//.test(pathPart)) {
849
+ const minted = await mintDownloadUrl(pathPart, accountParam, authManager);
850
+ if (minted) return minted;
781
851
  return {
782
852
  content: [
783
853
  {
@@ -793,6 +863,8 @@ const UTILITY_TOOLS = [
793
863
  if (/^(\/me|\/users\/[^/]+)\/onlineMeetings\/[^/]+\/recordings\/[^/]+(?:\/content)?$/.test(
794
864
  pathPart
795
865
  ) || /^\/communications\/calls\/[^/]+\/recordings\/[^/]+(?:\/content)?$/.test(pathPart)) {
866
+ const minted = await mintDownloadUrl(pathPart, accountParam, authManager);
867
+ if (minted) return minted;
796
868
  return {
797
869
  content: [
798
870
  {
@@ -806,6 +878,8 @@ const UTILITY_TOOLS = [
806
878
  };
807
879
  }
808
880
  if (pathPart.endsWith("/$value")) {
881
+ const minted = await mintDownloadUrl(pathPart, accountParam, authManager);
882
+ if (minted) return minted;
809
883
  return {
810
884
  content: [
811
885
  {
@@ -875,6 +949,8 @@ const UTILITY_TOOLS = [
875
949
  }
876
950
  const downloadUrl = item?.["@microsoft.graph.downloadUrl"];
877
951
  if (!downloadUrl) {
952
+ const minted = await mintDownloadUrl(`${itemPath}/content`, accountParam, authManager);
953
+ if (minted) return minted;
878
954
  return {
879
955
  content: [
880
956
  {
@@ -0,0 +1,15 @@
1
+ let current = null;
2
+ function configureAttachmentMinting(minting) {
3
+ current = minting;
4
+ }
5
+ function getAttachmentMinting() {
6
+ return current;
7
+ }
8
+ function resetAttachmentMinting() {
9
+ current = null;
10
+ }
11
+ export {
12
+ configureAttachmentMinting,
13
+ getAttachmentMinting,
14
+ resetAttachmentMinting
15
+ };
@@ -0,0 +1,89 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { ATTACHMENT_ROUTE } from "./attachment-url-config.js";
3
+ import { signUrl } from "./url-signing.js";
4
+ const TICKET_PARAM = "t";
5
+ function buildAttachmentUrl(config, ticketId, nowMs = Date.now()) {
6
+ const url = new URL(ATTACHMENT_ROUTE, config.base);
7
+ url.searchParams.set(TICKET_PARAM, ticketId);
8
+ return signUrl(
9
+ url.toString(),
10
+ { key: config.key, keyId: config.keyId, ttlSeconds: config.ttlSeconds },
11
+ nowMs
12
+ );
13
+ }
14
+ const PROBE_ORIGIN = "https://graph.invalid";
15
+ const PROBE_PREFIX = "/v1.0";
16
+ function isPlainGraphPath(target) {
17
+ if (!target.startsWith("/")) return false;
18
+ if (target.includes("//")) return false;
19
+ let resolved;
20
+ try {
21
+ resolved = new URL(PROBE_ORIGIN + PROBE_PREFIX + target);
22
+ } catch {
23
+ return false;
24
+ }
25
+ return resolved.origin === PROBE_ORIGIN && resolved.search === "" && resolved.hash === "" && resolved.pathname === PROBE_PREFIX + target;
26
+ }
27
+ const MAX_LIVE_TICKETS = 256;
28
+ const TICKET_BYTES = 32;
29
+ class TicketStoreFullError extends Error {
30
+ constructor(limit) {
31
+ super(`No ticket slots available (limit ${limit}); retry once outstanding tickets expire.`);
32
+ this.limit = limit;
33
+ this.name = "TicketStoreFullError";
34
+ }
35
+ }
36
+ class AttachmentTicketStore {
37
+ constructor(ttlSeconds) {
38
+ this.ttlSeconds = ttlSeconds;
39
+ this.tickets = /* @__PURE__ */ new Map();
40
+ }
41
+ /** Drop every expired ticket. Called before each mint and each redemption. */
42
+ sweep(nowMs) {
43
+ for (const [id, ticket] of this.tickets) {
44
+ if (ticket.expiresAtMs <= nowMs) this.tickets.delete(id);
45
+ }
46
+ }
47
+ mint(target, accountName, nowMs = Date.now()) {
48
+ this.sweep(nowMs);
49
+ if (this.tickets.size >= MAX_LIVE_TICKETS) {
50
+ throw new TicketStoreFullError(MAX_LIVE_TICKETS);
51
+ }
52
+ const id = randomBytes(TICKET_BYTES).toString("base64url");
53
+ const expiresAtMs = nowMs + this.ttlSeconds * 1e3;
54
+ this.tickets.set(id, { target, accountName, expiresAtMs });
55
+ return { id, expiresAtMs };
56
+ }
57
+ /**
58
+ * Return the ticket and burn it, or undefined.
59
+ *
60
+ * One `undefined` for every failure -- unknown id, already redeemed, expired.
61
+ * The caller answers 404 to all three, so a probe cannot use the response to
62
+ * tell "never existed" from "already used", which would confirm a guessed id.
63
+ *
64
+ * The delete happens before the value is returned rather than in the caller,
65
+ * so an exception on the streaming path cannot leave a redeemed ticket live.
66
+ */
67
+ redeem(id, nowMs = Date.now()) {
68
+ this.sweep(nowMs);
69
+ const ticket = this.tickets.get(id);
70
+ if (!ticket) return void 0;
71
+ this.tickets.delete(id);
72
+ return ticket;
73
+ }
74
+ /** Live ticket count, for tests and diagnostics. Never logged with ids. */
75
+ size(nowMs = Date.now()) {
76
+ this.sweep(nowMs);
77
+ return this.tickets.size;
78
+ }
79
+ clear() {
80
+ this.tickets.clear();
81
+ }
82
+ }
83
+ export {
84
+ AttachmentTicketStore,
85
+ TICKET_PARAM,
86
+ TicketStoreFullError,
87
+ buildAttachmentUrl,
88
+ isPlainGraphPath
89
+ };
@@ -0,0 +1,104 @@
1
+ import { readFileSync } from "node:fs";
2
+ const ATTACHMENT_ROUTE = "/attachment";
3
+ const DEFAULT_TTL_SECONDS = 120;
4
+ const MAX_TTL_SECONDS = 300;
5
+ class AttachmentUrlConfigError extends Error {
6
+ constructor(message) {
7
+ super(message);
8
+ this.name = "AttachmentUrlConfigError";
9
+ }
10
+ }
11
+ const PYTHON_WHITESPACE = " \n\v\f\r \x85\xA0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u2028\u2029\u202F\u205F\u3000";
12
+ function pythonStrip(value) {
13
+ let start = 0;
14
+ let end = value.length;
15
+ while (start < end && PYTHON_WHITESPACE.includes(value[start])) start += 1;
16
+ while (end > start && PYTHON_WHITESPACE.includes(value[end - 1])) end -= 1;
17
+ return value.slice(start, end);
18
+ }
19
+ function readKey(env) {
20
+ const keyFile = env.MS365_MCP_ATTACHMENT_URL_KEY_FILE;
21
+ if (keyFile) {
22
+ try {
23
+ return pythonStrip(readFileSync(keyFile, "utf8"));
24
+ } catch {
25
+ throw new AttachmentUrlConfigError(
26
+ `MS365_MCP_ATTACHMENT_URL_KEY_FILE could not be read: ${keyFile}`
27
+ );
28
+ }
29
+ }
30
+ return env.MS365_MCP_ATTACHMENT_URL_KEY ?? "";
31
+ }
32
+ function loadAttachmentUrlConfig(enabled, env = process.env) {
33
+ if (!enabled) return null;
34
+ const base = env.MS365_MCP_ATTACHMENT_URL_BASE ?? "";
35
+ if (!base) {
36
+ throw new AttachmentUrlConfigError(
37
+ "--enable-attachment-urls requires MS365_MCP_ATTACHMENT_URL_BASE (the origin a document-conversion sidecar will fetch, e.g. http://m365-mcp:3000). This is deliberately not MS365_MCP_PUBLIC_URL: that one is browser-facing for OAuth, while this is reached server-to-server and is commonly a container address. With --attachment-port, name that port here, not the --http one: the route is not served on the MCP port at all in that mode."
38
+ );
39
+ }
40
+ let parsedBase;
41
+ try {
42
+ parsedBase = new URL(base);
43
+ } catch {
44
+ throw new AttachmentUrlConfigError(
45
+ `MS365_MCP_ATTACHMENT_URL_BASE is not a valid absolute URL: ${base}`
46
+ );
47
+ }
48
+ if (parsedBase.protocol !== "http:" && parsedBase.protocol !== "https:") {
49
+ throw new AttachmentUrlConfigError(
50
+ `MS365_MCP_ATTACHMENT_URL_BASE must be http or https, got ${parsedBase.protocol}`
51
+ );
52
+ }
53
+ if (parsedBase.hostname.startsWith("[")) {
54
+ throw new AttachmentUrlConfigError(
55
+ `MS365_MCP_ATTACHMENT_URL_BASE must not be an IPv6 literal: the URL signature covers the host, and this runtime and the verifying sidecar normalise IPv6 spellings differently, so every minted URL would be refused. Use a hostname (a container or service name) instead of ${parsedBase.hostname}.`
56
+ );
57
+ }
58
+ if (parsedBase.search || parsedBase.hash) {
59
+ throw new AttachmentUrlConfigError(
60
+ "MS365_MCP_ATTACHMENT_URL_BASE must not carry a query string or fragment."
61
+ );
62
+ }
63
+ const key = readKey(env);
64
+ if (!key) {
65
+ throw new AttachmentUrlConfigError(
66
+ "--enable-attachment-urls requires MS365_MCP_ATTACHMENT_URL_KEY or MS365_MCP_ATTACHMENT_URL_KEY_FILE -- the HMAC key shared with the sidecar that will verify the minted URL."
67
+ );
68
+ }
69
+ for (let index = 0; index < key.length; index += 1) {
70
+ const code = key.charCodeAt(index);
71
+ if (code < 32 || code === 127) {
72
+ throw new AttachmentUrlConfigError(
73
+ `The attachment URL signing key contains a control character at offset ${index} (value not shown).`
74
+ );
75
+ }
76
+ }
77
+ const keyId = env.MS365_MCP_ATTACHMENT_URL_KEY_ID || "1";
78
+ const ttlRaw = env.MS365_MCP_ATTACHMENT_URL_TTL_S;
79
+ let ttlSeconds = DEFAULT_TTL_SECONDS;
80
+ if (ttlRaw !== void 0 && ttlRaw !== "") {
81
+ if (!/^\d+$/.test(ttlRaw)) {
82
+ throw new AttachmentUrlConfigError(
83
+ `MS365_MCP_ATTACHMENT_URL_TTL_S must be a positive integer, got ${JSON.stringify(ttlRaw)}`
84
+ );
85
+ }
86
+ ttlSeconds = Number(ttlRaw);
87
+ if (ttlSeconds <= 0 || ttlSeconds > MAX_TTL_SECONDS) {
88
+ throw new AttachmentUrlConfigError(
89
+ `MS365_MCP_ATTACHMENT_URL_TTL_S must be between 1 and ${MAX_TTL_SECONDS}, got ${ttlSeconds}`
90
+ );
91
+ }
92
+ }
93
+ return {
94
+ base: parsedBase.origin,
95
+ key,
96
+ keyId,
97
+ ttlSeconds
98
+ };
99
+ }
100
+ export {
101
+ ATTACHMENT_ROUTE,
102
+ AttachmentUrlConfigError,
103
+ loadAttachmentUrlConfig
104
+ };
@@ -0,0 +1,81 @@
1
+ import { createHmac } from "node:crypto";
2
+ function quoteAll(value) {
3
+ return Array.from(new TextEncoder().encode(value)).map((byte) => {
4
+ const char = String.fromCharCode(byte);
5
+ if (/[A-Za-z0-9_.\-~]/.test(char)) return char;
6
+ return "%" + byte.toString(16).toUpperCase().padStart(2, "0");
7
+ }).join("");
8
+ }
9
+ function unquotePlus(value) {
10
+ const withSpaces = value.replace(/\+/g, " ");
11
+ const bytes = [];
12
+ for (let i = 0; i < withSpaces.length; i += 1) {
13
+ const char = withSpaces[i];
14
+ if (char === "%" && /^[0-9A-Fa-f]{2}$/.test(withSpaces.slice(i + 1, i + 3))) {
15
+ bytes.push(parseInt(withSpaces.slice(i + 1, i + 3), 16));
16
+ i += 2;
17
+ continue;
18
+ }
19
+ for (const byte of new TextEncoder().encode(char)) bytes.push(byte);
20
+ }
21
+ return new TextDecoder("utf-8", { fatal: false }).decode(new Uint8Array(bytes));
22
+ }
23
+ function compareByCodePoint(a, b) {
24
+ const left = Array.from(a);
25
+ const right = Array.from(b);
26
+ const shared = Math.min(left.length, right.length);
27
+ for (let i = 0; i < shared; i += 1) {
28
+ const diff = left[i].codePointAt(0) - right[i].codePointAt(0);
29
+ if (diff !== 0) return diff;
30
+ }
31
+ return left.length - right.length;
32
+ }
33
+ const RESERVED_PARAMS = /* @__PURE__ */ new Set(["dgk", "dgx", "dgs"]);
34
+ function parseQsl(query) {
35
+ const pairs = [];
36
+ for (const field of query.split("&")) {
37
+ if (!field) continue;
38
+ const eq = field.indexOf("=");
39
+ const rawName = eq === -1 ? field : field.slice(0, eq);
40
+ const rawValue = eq === -1 ? "" : field.slice(eq + 1);
41
+ pairs.push([unquotePlus(rawName), unquotePlus(rawValue)]);
42
+ }
43
+ return pairs;
44
+ }
45
+ function canonicalQuery(query) {
46
+ const pairs = parseQsl(query).filter(([name]) => !RESERVED_PARAMS.has(name));
47
+ pairs.sort((a, b) => compareByCodePoint(a[0], b[0]) || compareByCodePoint(a[1], b[1]));
48
+ return pairs.map(([name, value]) => `${quoteAll(name)}=${quoteAll(value)}`).join("&");
49
+ }
50
+ function canonicalString(url, expiry) {
51
+ const parsed = new URL(url);
52
+ const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
53
+ const host = parsed.hostname.toLowerCase().replace(/^\[(.+)\]$/, "$1");
54
+ const port = parsed.port === "" ? scheme === "https" ? 443 : 80 : Number(parsed.port);
55
+ const path = parsed.pathname === "" ? "/" : parsed.pathname;
56
+ const query = canonicalQuery(parsed.search.replace(/^\?/, ""));
57
+ return ["v1", scheme, host, String(port), path, query, expiry].join("\n");
58
+ }
59
+ function digest(key, message) {
60
+ return createHmac("sha256", Buffer.from(key, "utf8")).update(Buffer.from(message, "utf8")).digest("base64url");
61
+ }
62
+ function signUrl(url, config, nowMs = Date.now()) {
63
+ const expiry = String(Math.floor(nowMs / 1e3) + config.ttlSeconds);
64
+ const signature = digest(config.key, canonicalString(url, expiry));
65
+ const parsed = new URL(url);
66
+ const kept = parseQsl(parsed.search.replace(/^\?/, "")).filter(
67
+ ([name]) => !RESERVED_PARAMS.has(name)
68
+ );
69
+ kept.push(["dgk", config.keyId], ["dgx", expiry], ["dgs", signature]);
70
+ parsed.search = kept.map(([name, value]) => `${quoteAll(name)}=${quoteAll(value)}`).join("&");
71
+ return parsed.toString();
72
+ }
73
+ export {
74
+ canonicalString,
75
+ compareByCodePoint,
76
+ digest,
77
+ parseQsl,
78
+ quoteAll,
79
+ signUrl,
80
+ unquotePlus
81
+ };
package/dist/server.js CHANGED
@@ -25,11 +25,16 @@ import {
25
25
  toOAuthErrorResponse
26
26
  } from "./lib/microsoft-auth.js";
27
27
  import { isAllowedRedirectUri, parseAllowlist } from "./lib/redirect-uri-validation.js";
28
+ import { loadAttachmentUrlConfig, ATTACHMENT_ROUTE } from "./lib/attachment-url-config.js";
29
+ import { AttachmentTicketStore } from "./lib/attachment-tickets.js";
30
+ import { configureAttachmentMinting } from "./lib/attachment-minting.js";
31
+ import { createAttachmentHandler } from "./attachment-route.js";
28
32
  import { getSecrets } from "./secrets.js";
29
33
  import { getCloudEndpoints } from "./cloud-config.js";
30
34
  import { requestContext } from "./request-context.js";
31
35
  import { dumpError } from "./crash-logging.js";
32
36
  import crypto from "node:crypto";
37
+ import { isIP, isIPv6 } from "node:net";
33
38
  import OboClient from "./obo-client.js";
34
39
  function parseHttpOption(httpOption) {
35
40
  if (typeof httpOption === "boolean") {
@@ -45,12 +50,78 @@ function parseHttpOption(httpOption) {
45
50
  const port = parseInt(httpString) || 3e3;
46
51
  return { host: void 0, port };
47
52
  }
53
+ function parseAttachmentPortOption(value) {
54
+ if (value === void 0 || value === null || value === "") return null;
55
+ const raw = String(value).trim();
56
+ if (!/^\d+$/.test(raw)) {
57
+ throw new Error(
58
+ `--attachment-port / MS365_MCP_ATTACHMENT_PORT must be a port number between 1 and 65535, got ${JSON.stringify(String(value))}`
59
+ );
60
+ }
61
+ const port = Number(raw);
62
+ if (port < 1 || port > 65535) {
63
+ throw new Error(
64
+ `--attachment-port / MS365_MCP_ATTACHMENT_PORT must be a port number between 1 and 65535, got ${port}`
65
+ );
66
+ }
67
+ return port;
68
+ }
69
+ const HOSTNAME_LABEL = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/;
70
+ function isHostname(value) {
71
+ const name = value.endsWith(".") ? value.slice(0, -1) : value;
72
+ if (name.length === 0 || name.length > 253) return false;
73
+ return name.split(".").every((label) => label.length > 0 && label.length <= 63 && HOSTNAME_LABEL.test(label));
74
+ }
75
+ function parseAttachmentHostOption(value) {
76
+ if (value === void 0 || value === null || value === "") return null;
77
+ const raw = String(value).trim();
78
+ const reject = (reason) => {
79
+ throw new Error(
80
+ `--attachment-host / MS365_MCP_ATTACHMENT_HOST must be a bare IPv4 address, IPv6 address or hostname (${reason}), got ${JSON.stringify(String(value))}`
81
+ );
82
+ };
83
+ if (raw === "") reject("it is empty");
84
+ if (raw.startsWith("[") || raw.endsWith("]")) {
85
+ if (!raw.startsWith("[") || !raw.endsWith("]")) reject("unbalanced brackets");
86
+ const inner = raw.slice(1, -1);
87
+ if (!isIPv6(inner)) reject("the brackets do not contain an IPv6 address");
88
+ return inner;
89
+ }
90
+ if (isIP(raw) !== 0) return raw;
91
+ if (isHostname(raw)) return raw;
92
+ if (raw.includes(":")) {
93
+ reject("this takes a host only -- the port goes on --attachment-port");
94
+ }
95
+ return reject("not a valid address or hostname");
96
+ }
97
+ function isWildcardAddress(address) {
98
+ return address === "0.0.0.0" || address === "::" || address === "";
99
+ }
100
+ function formatAuthority(address, port) {
101
+ return isIPv6(address) ? `[${address}]:${port}` : `${address}:${port}`;
102
+ }
103
+ function describeBoundAddress(bound) {
104
+ const authority = formatAuthority(bound.address, bound.port);
105
+ if (isWildcardAddress(bound.address)) {
106
+ return { label: `all interfaces (${authority})`, authority: `localhost:${bound.port}` };
107
+ }
108
+ return { label: authority, authority };
109
+ }
48
110
  const PKCE_MAX_AGE_MS = 60 * 60 * 1e3;
49
111
  class MicrosoftGraphServer {
50
112
  constructor(authManager, options = {}) {
51
113
  this.version = "0.0.0";
52
114
  this.multiAccount = false;
53
115
  this.accountNames = [];
116
+ /**
117
+ * Every HTTP listener `start()` opened, so `stop()` can close every one.
118
+ *
119
+ * A list rather than a field because `--attachment-port` makes it two, and an
120
+ * untracked listener cannot be closed at all: it holds the event loop open
121
+ * for the life of the process. One that is only *usually* two is worse than
122
+ * either, so nothing here special-cases the count.
123
+ */
124
+ this.httpServers = [];
54
125
  // Two-leg PKCE: stores client's code_challenge and server's code_verifier, keyed by OAuth state
55
126
  this.pkceStore = /* @__PURE__ */ new Map();
56
127
  this.authManager = authManager;
@@ -171,6 +242,28 @@ class MicrosoftGraphServer {
171
242
  if (this.options.readOnly) {
172
243
  logger.info("Server running in READ-ONLY mode. Write operations are disabled.");
173
244
  }
245
+ if (this.options.enableAttachmentUrls && !this.options.http) {
246
+ logger.warn(
247
+ "--enable-attachment-urls has no effect in stdio mode and is being ignored: the minted URL has to be reachable over HTTP. Start with --http to use it."
248
+ );
249
+ }
250
+ const attachmentPort = parseAttachmentPortOption(this.options.attachmentPort);
251
+ if (attachmentPort !== null && !this.options.enableAttachmentUrls) {
252
+ throw new Error(
253
+ "--attachment-port requires --enable-attachment-urls: on its own there is no attachment route to put on the second listener. Pass both, or neither."
254
+ );
255
+ }
256
+ const attachmentHost = parseAttachmentHostOption(this.options.attachmentHost);
257
+ if (attachmentHost !== null && attachmentPort === null) {
258
+ throw new Error(
259
+ "--attachment-host requires --attachment-port: without a second listener there is no separate interface to bind, and the attachment route stays on the MCP app."
260
+ );
261
+ }
262
+ if (attachmentPort !== null && !this.options.http) {
263
+ logger.warn(
264
+ "--attachment-port has no effect in stdio mode and is being ignored: there is no HTTP listener to split. Start with --http to use it."
265
+ );
266
+ }
174
267
  if (this.options.http) {
175
268
  const { host, port } = parseHttpOption(this.options.http);
176
269
  const app = express();
@@ -538,27 +631,88 @@ class MicrosoftGraphServer {
538
631
  }
539
632
  }
540
633
  );
634
+ const attachmentConfig = loadAttachmentUrlConfig(Boolean(this.options.enableAttachmentUrls));
635
+ const mintingAlwaysRefused = this.authManager?.isOAuthModeEnabled() === true || !this.options.trustProxyAuth;
636
+ if (attachmentConfig && mintingAlwaysRefused) {
637
+ logger.warn(
638
+ "--enable-attachment-urls is on, but this server takes its Graph identity from the request in plain --http mode, and minting is refused whenever it does (the URL is redeemed later with no Authorization header, so the bytes would be fetched as a different identity). get-download-url will keep answering with download-bytes. Server-minted URLs need --trust-proxy-auth, where the server uses its own token."
639
+ );
640
+ }
641
+ let attachmentApp = null;
642
+ if (attachmentConfig) {
643
+ const ticketStore = new AttachmentTicketStore(attachmentConfig.ttlSeconds);
644
+ configureAttachmentMinting({ store: ticketStore, config: attachmentConfig });
645
+ const dedicated = attachmentPort !== null;
646
+ attachmentApp = dedicated ? express() : app;
647
+ if (dedicated) {
648
+ attachmentApp.use(
649
+ helmet({
650
+ contentSecurityPolicy: false,
651
+ crossOriginEmbedderPolicy: false,
652
+ hsts: { maxAge: 31536e3, includeSubDomains: true, preload: true }
653
+ })
654
+ );
655
+ }
656
+ if (!rateLimitDisabled) {
657
+ attachmentApp.use(
658
+ ATTACHMENT_ROUTE,
659
+ rateLimit({
660
+ windowMs: 6e4,
661
+ max: 60,
662
+ standardHeaders: "draft-7",
663
+ legacyHeaders: false
664
+ })
665
+ );
666
+ }
667
+ attachmentApp.get(
668
+ ATTACHMENT_ROUTE,
669
+ createAttachmentHandler({
670
+ store: ticketStore,
671
+ getGraphClient: () => this.graphClient,
672
+ authManager: this.authManager
673
+ })
674
+ );
675
+ logger.info(
676
+ ` - Attachment URLs: ${attachmentConfig.base}${ATTACHMENT_ROUTE} (ttl ${attachmentConfig.ttlSeconds}s, key id ${attachmentConfig.keyId})`
677
+ );
678
+ if (dedicated) {
679
+ logger.info(
680
+ ` - Attachment listener: separate, port ${attachmentPort} (${ATTACHMENT_ROUTE} is NOT served on the MCP port; MS365_MCP_ATTACHMENT_URL_BASE must name this port)`
681
+ );
682
+ }
683
+ } else {
684
+ configureAttachmentMinting(null);
685
+ }
541
686
  app.get("/", (req, res) => {
542
687
  res.send("Microsoft 365 MCP Server is running");
543
688
  });
544
- if (host) {
545
- app.listen(port, host, () => {
546
- logger.info(`Server listening on ${host}:${port}`);
547
- logger.info(` - MCP endpoint: http://${host}:${port}/mcp`);
548
- logger.info(` - OAuth endpoints: http://${host}:${port}/auth/*`);
549
- logger.info(
550
- ` - OAuth discovery: http://${host}:${port}/.well-known/oauth-authorization-server`
689
+ const mcpBound = await this.listen(app, port, host);
690
+ const mcp = describeBoundAddress(mcpBound);
691
+ logger.info(`Server listening on ${mcp.label}`);
692
+ logger.info(` - MCP endpoint: http://${mcp.authority}/mcp`);
693
+ logger.info(` - OAuth endpoints: http://${mcp.authority}/auth/*`);
694
+ logger.info(
695
+ ` - OAuth discovery: http://${mcp.authority}/.well-known/oauth-authorization-server`
696
+ );
697
+ if (attachmentApp && attachmentPort !== null) {
698
+ let attachmentBound;
699
+ try {
700
+ attachmentBound = await this.listen(
701
+ attachmentApp,
702
+ attachmentPort,
703
+ attachmentHost ?? host
551
704
  );
552
- });
553
- } else {
554
- app.listen(port, () => {
555
- logger.info(`Server listening on all interfaces (0.0.0.0:${port})`);
556
- logger.info(` - MCP endpoint: http://localhost:${port}/mcp`);
557
- logger.info(` - OAuth endpoints: http://localhost:${port}/auth/*`);
558
- logger.info(
559
- ` - OAuth discovery: http://localhost:${port}/.well-known/oauth-authorization-server`
705
+ } catch (error) {
706
+ await this.stop();
707
+ throw error;
708
+ }
709
+ const attachment = describeBoundAddress(attachmentBound);
710
+ logger.info(`Attachment listener on ${attachment.label} \u2014 serves ${ATTACHMENT_ROUTE} only`);
711
+ if (this.options.trustProxyAuth && this.listenersShareAnInterface(mcpBound, attachmentBound)) {
712
+ logger.warn(
713
+ `--attachment-port split ${ATTACHMENT_ROUTE} onto port ${attachmentBound.port}, but both listeners answer on the same interface (MCP ${mcp.label}, attachment ${attachment.label}), so the ports are not isolated from each other. With --trust-proxy-auth, /mcp requires no credential, so anything permitted to reach the attachment port can also call every tool. Bind them to different --http and --attachment-host addresses, or keep the MCP port off the network the attachment fetcher is on.`
560
714
  );
561
- });
715
+ }
562
716
  }
563
717
  } else {
564
718
  const transport = new StdioServerTransport();
@@ -569,8 +723,86 @@ class MicrosoftGraphServer {
569
723
  logger.info("Server connected to stdio transport");
570
724
  }
571
725
  }
726
+ /**
727
+ * Bind one Express app and record the listener.
728
+ *
729
+ * Awaited rather than fire-and-forget, which is what `app.listen(...)` on its
730
+ * own was. Two consequences, both wanted. A bind failure (EADDRINUSE is the
731
+ * live one now that there is a second port to collide) rejects `start()`, so
732
+ * `index.ts` reports it and exits 1 instead of the `error` event reaching the
733
+ * process-wide `uncaughtException` handler as an unattributed dump. And the
734
+ * caller knows the port is actually accepting connections when this resolves,
735
+ * so the second bind cannot race the first.
736
+ *
737
+ * **The callback's argument is read, and that is not a formality.** Express 5
738
+ * wraps the callback passed to `app.listen` in `once()` and registers that
739
+ * same wrapper as the server's `error` handler (`application.js`), so a bind
740
+ * that fails does not skip the callback -- it calls it with an Error. The
741
+ * zero-argument `() => logger.info('Server listening on ...')` this replaces
742
+ * is the shape every example uses, and it announced a port the process had
743
+ * not got: on EADDRINUSE the server logged that it was listening and stayed
744
+ * up serving nothing.
745
+ */
746
+ async listen(app, port, host) {
747
+ let server;
748
+ await new Promise((resolve, reject) => {
749
+ const done = (error) => error ? reject(error) : resolve();
750
+ server = host ? app.listen(port, host, done) : app.listen(port, done);
751
+ server.once("error", reject);
752
+ this.httpServers.push(server);
753
+ });
754
+ const address = server.address();
755
+ if (address === null || typeof address === "string") {
756
+ throw new Error(`Listener on port ${port} reported no TCP address`);
757
+ }
758
+ return address;
759
+ }
760
+ /**
761
+ * Whether the two listeners can be reached from a common interface.
762
+ *
763
+ * A wildcard on either side answers everywhere, so it overlaps whatever the
764
+ * other one bound -- and on Linux a dual-stack `::` accepts IPv4 too, so the
765
+ * families are not a distinction worth drawing here. Otherwise they overlap
766
+ * only if they bound the same address. Deliberately coarse in the direction of
767
+ * warning: `127.0.0.1` and `127.0.0.2` are two addresses on one interface,
768
+ * separate to `bind()` and to a container network, and a check that tried to
769
+ * reason about routes instead of addresses would be wrong more often than this.
770
+ */
771
+ listenersShareAnInterface(a, b) {
772
+ if (isWildcardAddress(a.address) || isWildcardAddress(b.address)) return true;
773
+ return a.address === b.address;
774
+ }
775
+ /**
776
+ * Close every listener this server opened.
777
+ *
778
+ * `close()` alone is not enough and the difference is not theoretical: it
779
+ * stops accepting but waits on established sockets, and a keep-alive client
780
+ * (Node's own `fetch` is one) holds one open by default, so the process hangs
781
+ * instead of exiting. `closeIdleConnections()` drops exactly those, while a
782
+ * transfer still in flight -- an attachment being streamed -- is allowed to
783
+ * finish.
784
+ *
785
+ * Minting is switched off at the same time. The tickets live in a store this
786
+ * server owns, and after this returns there is no listener left to redeem
787
+ * them on; continuing to hand out URLs for a dead route would be a lie the
788
+ * agent only discovers at fetch time.
789
+ */
790
+ async stop() {
791
+ const servers = this.httpServers.splice(0);
792
+ configureAttachmentMinting(null);
793
+ await Promise.all(
794
+ servers.map(
795
+ (server) => new Promise((resolve) => {
796
+ server.close(() => resolve());
797
+ server.closeIdleConnections();
798
+ })
799
+ )
800
+ );
801
+ }
572
802
  }
573
803
  var server_default = MicrosoftGraphServer;
574
804
  export {
575
- server_default as default
805
+ server_default as default,
806
+ parseAttachmentHostOption,
807
+ parseAttachmentPortOption
576
808
  };
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.150.3",
4
+ "version": "0.151.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",