@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/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
  };
@@ -213,7 +213,7 @@ The client automatically discovers OAuth endpoints and opens a browser for authe
213
213
  - **Tool filtering**: use `--enabled-tools <regex>` or `--preset <names>` to restrict available tools
214
214
  - **CORS**: configure `MS365_MCP_CORS_ORIGIN` to restrict allowed origins (defaults to `http://localhost:3000`); set explicitly when clients run on a different origin
215
215
  - **Disable Dynamic Client Registration**: when only a known client talks to the server, set `MS365_MCP_DISABLE_DCR=true` (or pass `--no-dynamic-registration`) to close the anonymous `/register` endpoint
216
- - **Structured audit log**: enabled by default. Every tool invocation that reaches Microsoft Graph emits one JSON line on stderr (captured by the container platform's log collector) and to `~/.ms-365-mcp-server/logs/audit.log` (mode `0o600`, or under `MS365_MCP_LOG_DIR` when set) with `{ event, request_id, user_principal_name, tool, http_method, http_status?, status, duration_ms, recipient_count?, recipient_domains?, recipient_domains_truncated?, graph_batch_subrequest_count?, graph_batch_http_status_counts?, graph_batch_error_code_counts?, target_resource?, error_type?, error_code? }`. A few refusals short-circuit before that and emit nothing: a confirm-gate rejection, an `account` param that contradicts the bearer identity, and a failure to resolve an account token. Policy-blocked tool attempts emit `event: "tool.denied"` with `status: "denied"`, `reason` (`allowed_scopes` or `tool_allowlist`), and `missing_scopes` when applicable. When an audited generated Microsoft Graph tool targets a derivable resource through an ID-like path parameter such as `{message-id}` or `{driveItem-id}`, `target_resource` is `{ type, id }`, where `id` is the Graph path up to that resource ID. Later path parameters such as `{path}`, query values, returned content, and Graph response bodies are NEVER recorded, and error messages are reduced to `error_type` / `error_code` so upstream library errors do not leak token fragments or query-string PII. Two things derived from tool parameters **are** recorded, both deliberately. First, `target_resource.id` substitutes ID-like path parameters into the resource path, so a tool on `/users/{user-id}/...` records whatever identifier the caller passed, which may be a full email address. Second, a request whose body carries `toRecipients` / `ccRecipients` / `bccRecipients` / `attendees` / `recipients`, at any casing and several levels down, including inside a `graph-batch` sub-request, records `recipient_count`, the number of entries in those arrays, and `recipient_domains`, the **domain part only** of their addresses and only where it parses as a plain hostname, never the local part and never a subject or message body. An entry that names someone without an address (a `driveRecipient` given as `alias` or `objectId`) counts but contributes no domain. It keys on body shape rather than on the endpoint, so it covers sends, forwards, invites and file shares but equally draft creation and edits, event updates and `findMeetingTimes`, and it reads high rather than low: an attached message's own recipients are counted too. `recipient_domains` holds at most 50 **distinct domains**, alphabetically, and sets `recipient_domains_truncated: true` when there were more; `recipient_count` is unaffected by the cap. All three are absent when the body carries no recipient array at all. A request that fails after reaching Graph still records recipients, since a timeout is not proof of non-delivery. Gaps remain, so absence proves nothing: a draft composed outside this server and sent by id, the original thread's recipients on a reply (Graph resolves those server-side), and a body nesting recipients deeper than the walker descends. This describes the structured audit log only; the operational logger is separate and does log tool parameters. Forms the "who accessed what, when" trail required for GDPR / HIPAA / PIPEDA / SOC 2 audit. Opt-out: `MS365_MCP_AUDIT_LOG=false`
216
+ - **Structured audit log**: enabled by default. Every tool invocation that reaches Microsoft Graph emits one JSON line on stderr (captured by the container platform's log collector) and to `~/.ms-365-mcp-server/logs/audit.log` (mode `0o600`, or under `MS365_MCP_LOG_DIR` when set) with `{ event, request_id, user_principal_name, tool, http_method, http_status?, status, duration_ms, recipient_count?, recipient_domains?, recipient_domains_truncated?, graph_batch_subrequest_count?, graph_batch_http_status_counts?, graph_batch_error_code_counts?, result_count?, result_has_more?, response_bytes?, target_resource?, error_type?, error_code? }`. A few refusals short-circuit before that and emit nothing: a confirm-gate rejection, an `account` param that contradicts the bearer identity, and a failure to resolve an account token. Policy-blocked tool attempts emit `event: "tool.denied"` with `status: "denied"`, `reason` (`allowed_scopes` or `tool_allowlist`), and `missing_scopes` when applicable. When an audited generated Microsoft Graph tool targets a derivable resource through an ID-like path parameter such as `{message-id}` or `{driveItem-id}`, `target_resource` is `{ type, id }`, where `id` is the Graph path up to that resource ID. Later path parameters such as `{path}`, query values, returned content, and Graph response bodies are NEVER recorded, and error messages are reduced to `error_type` / `error_code` so upstream library errors do not leak token fragments or query-string PII. Response _metadata_ is recorded — `result_count`, `result_has_more` and `response_bytes` describe how much came back so that a bulk read is distinguishable from an ordinary one; none of them reveals any of its content. Two things derived from tool parameters **are** recorded, both deliberately. First, `target_resource.id` substitutes ID-like path parameters into the resource path, so a tool on `/users/{user-id}/...` records whatever identifier the caller passed, which may be a full email address. Second, a request whose body carries `toRecipients` / `ccRecipients` / `bccRecipients` / `attendees` / `recipients`, at any casing and several levels down, including inside a `graph-batch` sub-request, records `recipient_count`, the number of entries in those arrays, and `recipient_domains`, the **domain part only** of their addresses and only where it parses as a plain hostname, never the local part and never a subject or message body. An entry that names someone without an address (a `driveRecipient` given as `alias` or `objectId`) counts but contributes no domain. It keys on body shape rather than on the endpoint, so it covers sends, forwards, invites and file shares but equally draft creation and edits, event updates and `findMeetingTimes`, and it reads high rather than low: an attached message's own recipients are counted too. `recipient_domains` holds at most 50 **distinct domains**, alphabetically, and sets `recipient_domains_truncated: true` when there were more; `recipient_count` is unaffected by the cap. All three are absent when the body carries no recipient array at all. A request that fails after reaching Graph still records recipients, since a timeout is not proof of non-delivery. Gaps remain, so absence proves nothing: a draft composed outside this server and sent by id, the original thread's recipients on a reply (Graph resolves those server-side), and a body nesting recipients deeper than the walker descends. This describes the structured audit log only; the operational logger is separate and does log tool parameters. Forms the "who accessed what, when" trail required for GDPR / HIPAA / PIPEDA / SOC 2 audit. Opt-out: `MS365_MCP_AUDIT_LOG=false`
217
217
  - **Graph resilience**: every call to Microsoft Graph is wrapped with a fetch timeout (default 100 s via `MS365_MCP_GRAPH_TIMEOUT_MS`), retry-with-backoff on 429 / 503 / 504 / network errors (default 3 retries, full-jitter exponential backoff, honours `Retry-After`; 503 / 504 / network errors only retried for idempotent methods, 429 retried on all methods), and a process-wide circuit breaker that opens after 5 consecutive failures and cools down for 30 s (`MS365_MCP_GRAPH_CIRCUIT_THRESHOLD` / `MS365_MCP_GRAPH_CIRCUIT_COOLDOWN_MS`). Disable the breaker for trusted automation: `MS365_MCP_GRAPH_CIRCUIT_DISABLED=true`
218
218
  - **Confirm gate on destructive tools**: opt-in, **off by default**. Enable with `MS365_MCP_REQUIRE_CONFIRM=true`. When on, destructive tools (POST except `readOnly`, PATCH, PUT, DELETE — `delete-mail-message`, `send-mail`, `update-event`, etc.) return `{ "error": "confirmation_required" }` until the caller re-invokes them with `"confirm": true`. Mitigates accidental writes when an LLM misroutes a request or follows an injected instruction. Shipped opt-in so it is a non-breaking, additive layer that can coexist with client-side elicitation prompts (MCP Elicitation API) where the client supports them.
219
219
 
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.152.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",