@softeria/ms-365-mcp-server 0.155.0 → 0.156.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
@@ -610,7 +610,15 @@ When running as an MCP server, the following options can be used:
610
610
  -v Enable verbose logging
611
611
  --read-only Start server in read-only mode, disabling write operations
612
612
  --http [port] Use Streamable HTTP transport instead of stdio (optionally specify port, default: 3000)
613
- Starts Express.js server with MCP endpoint at /mcp
613
+ Starts Express.js server with MCP endpoint at /mcp. Bound to a loopback host
614
+ (e.g. --http 127.0.0.1:3000 or --http [::1]:3000) with no --public-url, it
615
+ rejects requests whose Host or Origin is not localhost (not applied to the
616
+ --attachment-port listener)
617
+ --http-local-file-tools Register download-bytes-to-file over HTTP. Anyone who can reach the port
618
+ can write files as the server's user (without a valid token, only an empty
619
+ file that is removed again), so enable it only on a single-user machine.
620
+ Refused unless --http binds a loopback host with no --public-url and no
621
+ --trust-proxy-auth
614
622
  --enable-auth-tools Enable login/logout tools when using HTTP mode (disabled by default in HTTP mode)
615
623
  --enable-attachment-urls Let get-download-url mint a server-served URL for byte resources Graph
616
624
  exposes no pre-authenticated URL for (see "Server-Minted Attachment URLs")
@@ -648,6 +656,7 @@ Environment variables:
648
656
  - `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
649
657
  - `MS365_MCP_ATTACHMENT_PORT=<port>`: Serve the attachment route on its own listener on this port (alternative to --attachment-port; requires `--enable-attachment-urls`)
650
658
  - `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"
659
+ - `MS365_MCP_HTTP_LOCAL_FILE_TOOLS=true|1`: Register download-bytes-to-file over HTTP (alternative to --http-local-file-tools; same restrictions)
651
660
  - `MS365_MCP_CLOUD_TYPE=global|china`: Microsoft cloud environment (alternative to --cloud flag)
652
661
  - `LOG_LEVEL`: Set logging level (default: 'info')
653
662
  - `SILENT=true|1`: Disable console output
package/dist/cli.js CHANGED
@@ -69,6 +69,9 @@ program.name("ms-365-mcp-server").description("Microsoft 365 MCP Server").versio
69
69
  ).option(
70
70
  "--trust-proxy-auth",
71
71
  "In HTTP mode, skip the built-in Bearer-token check on /mcp and ignore any forwarded Authorization header. All callers share the locally cached MSAL identity (same path stdio mode uses). Use only when an upstream reverse proxy has already authenticated the caller."
72
+ ).option(
73
+ "--http-local-file-tools",
74
+ "In HTTP mode, also register download-bytes-to-file, which writes files on the server as the server's user. Anyone who can reach the port can use it, so enable only on a single-user machine. Refused unless --http binds a loopback host with no --public-url and no --trust-proxy-auth."
72
75
  ).option(
73
76
  "--allow-unauthenticated-discovery",
74
77
  "In HTTP mode, allow MCP discovery requests (initialize, tools/list, prompts/list, resources/list, ping) without a bearer token, so a gateway can enumerate the tool catalog before any user has authenticated. Non-discovery requests (e.g. tools/call) still require a token. Off by default."
@@ -204,6 +207,9 @@ function parseArgs() {
204
207
  if (process.env.MS365_MCP_TRUST_PROXY_AUTH === "true" || process.env.MS365_MCP_TRUST_PROXY_AUTH === "1") {
205
208
  options.trustProxyAuth = true;
206
209
  }
210
+ if (process.env.MS365_MCP_HTTP_LOCAL_FILE_TOOLS === "true" || process.env.MS365_MCP_HTTP_LOCAL_FILE_TOOLS === "1") {
211
+ options.httpLocalFileTools = true;
212
+ }
207
213
  if (process.env.MS365_MCP_ALLOW_UNAUTHENTICATED_DISCOVERY === "true" || process.env.MS365_MCP_ALLOW_UNAUTHENTICATED_DISCOVERY === "1") {
208
214
  options.allowUnauthenticatedDiscovery = true;
209
215
  }
@@ -699,7 +699,7 @@ const UTILITY_TOOLS = [
699
699
  // description at ~40 tokens, so the OneDrive/SharePoint guidance below
700
700
  // sits past the cap. That keeps the hint for the reading LLM while letting
701
701
  // get-download-url own the high-signal "drive"/"sharepoint" search terms.
702
- description: "Write authenticated Microsoft Graph byte content to a local file on the server, returning { path, contentType, bytesWritten } instead of base64. The only out-of-band way to save mail attachments and meeting recordings, whose bytes are exposed solely through authenticated endpoints. Also handles profile photos and Teams hosted content. Writes to an absolute outputPath and never overwrites an existing file. stdio mode only: not available over HTTP. For OneDrive or SharePoint file content, get-download-url is preferred \u2014 it returns a pre-authenticated URL for fully out-of-band download without the server fetching the bytes.",
702
+ description: "Write authenticated Microsoft Graph byte content to a local file on the server, returning { path, contentType, bytesWritten } instead of base64. The only out-of-band way to save mail attachments and meeting recordings, whose bytes are exposed solely through authenticated endpoints. Also handles profile photos and Teams hosted content. Writes to an absolute outputPath and never overwrites an existing file. stdio mode, or HTTP with --http-local-file-tools. For OneDrive or SharePoint file content, get-download-url is preferred \u2014 it returns a pre-authenticated URL for fully out-of-band download without the server fetching the bytes.",
703
703
  readOnlyHint: true,
704
704
  openWorldHint: true,
705
705
  stdioOnly: true,
@@ -6,7 +6,7 @@ function buildGeneralMcpInstructions(opts) {
6
6
  "When you need an organizational user or recipient address, resolve it with list-users (or another directory tool); do not invent SMTP addresses.",
7
7
  "Directory $search on collections such as /users or /groups requires ConsistencyLevel: eventual when the tool exposes that header.",
8
8
  "Teams chat and channel messages: prefer HTML contentType in the body; plain text is often mangled by Graph.",
9
- "Files / binary content: for large drive/SharePoint file content, prefer get-download-url to resolve a pre-authenticated URL for out-of-band download. Use download-bytes for authenticated byte reads such as mail attachments, profile photos, Teams hosted content, and meeting recordings. In stdio mode, download-bytes-to-file writes those same authenticated bytes straight to a local absolute path instead of returning base64 \u2014 the only out-of-band option for large mail attachments and meeting recordings, which get-download-url cannot handle. These tools take relative Microsoft Graph paths, not absolute URLs. For uploads, upload-file-content takes a base64 string body (Graph allows 250MB, but the whole string passes through the agent context and a truncated one is written without error); use create-upload-session for anything but small files."
9
+ "Files / binary content: for large drive/SharePoint file content, prefer get-download-url to resolve a pre-authenticated URL for out-of-band download. Use download-bytes for authenticated byte reads such as mail attachments, profile photos, Teams hosted content, and meeting recordings. In stdio mode (or HTTP with --http-local-file-tools), download-bytes-to-file writes those same authenticated bytes straight to a local absolute path instead of returning base64 \u2014 the only out-of-band option for large mail attachments and meeting recordings, which get-download-url cannot handle. These tools take relative Microsoft Graph paths, not absolute URLs. For uploads, upload-file-content takes a base64 string body (Graph allows 250MB, but the whole string passes through the agent context and a truncated one is written without error); use create-upload-session for anything but small files."
10
10
  ];
11
11
  if (opts.readOnly) parts.push("This server is read-only; write operations are disabled.");
12
12
  if (opts.multiAccount)
package/dist/server.js CHANGED
@@ -36,11 +36,40 @@ import { dumpError } from "./crash-logging.js";
36
36
  import crypto from "node:crypto";
37
37
  import { isIP, isIPv6 } from "node:net";
38
38
  import OboClient from "./obo-client.js";
39
+ import { hostHeaderValidation } from "@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js";
40
+ const LOOPBACK_HOSTNAMES = ["localhost", "127.0.0.1", "[::1]"];
41
+ function isLoopbackHost(host) {
42
+ if (!host) return false;
43
+ const bare = host.toLowerCase().replace(/^\[(.*)\]$/, "$1");
44
+ return bare === "localhost" || bare === "127.0.0.1" || bare === "::1";
45
+ }
46
+ function loopbackOriginValidation() {
47
+ return (req, res, next) => {
48
+ const origin = req.headers.origin;
49
+ if (origin === void 0) return next();
50
+ let hostname;
51
+ try {
52
+ hostname = new URL(origin).hostname;
53
+ } catch {
54
+ hostname = void 0;
55
+ }
56
+ if (hostname && LOOPBACK_HOSTNAMES.includes(hostname)) return next();
57
+ res.status(403).json({
58
+ jsonrpc: "2.0",
59
+ error: { code: -32e3, message: `Invalid Origin: ${origin}` },
60
+ id: null
61
+ });
62
+ };
63
+ }
39
64
  function parseHttpOption(httpOption) {
40
65
  if (typeof httpOption === "boolean") {
41
66
  return { host: void 0, port: 3e3 };
42
67
  }
43
68
  const httpString = httpOption.trim();
69
+ const bracketed = /^\[([^\]]+)\](?::(.*))?$/.exec(httpString);
70
+ if (bracketed) {
71
+ return { host: bracketed[1], port: parseInt(bracketed[2] ?? "") || 3e3 };
72
+ }
44
73
  if (httpString.includes(":")) {
45
74
  const [hostPart, portPart] = httpString.split(":");
46
75
  const host = hostPart || void 0;
@@ -131,6 +160,14 @@ class MicrosoftGraphServer {
131
160
  this.secrets = null;
132
161
  this.oboClient = null;
133
162
  }
163
+ isLoopbackOnlyHttp() {
164
+ if (!this.options.http) return false;
165
+ const publicUrl = this.options.publicUrl || process.env.MS365_MCP_PUBLIC_URL || this.options.baseUrl || process.env.MS365_MCP_BASE_URL;
166
+ return !publicUrl && isLoopbackHost(parseHttpOption(this.options.http).host);
167
+ }
168
+ hidesStdioOnlyTools() {
169
+ return Boolean(this.options.http) && !this.options.httpLocalFileTools;
170
+ }
134
171
  createMcpServer() {
135
172
  const server = new McpServer(
136
173
  {
@@ -161,7 +198,7 @@ class MicrosoftGraphServer {
161
198
  this.accountNames,
162
199
  this.options.enabledTools,
163
200
  this.options.allowedScopes,
164
- Boolean(this.options.http)
201
+ this.hidesStdioOnlyTools()
165
202
  );
166
203
  } else {
167
204
  registerGraphTools(
@@ -174,7 +211,7 @@ class MicrosoftGraphServer {
174
211
  this.multiAccount,
175
212
  this.accountNames,
176
213
  this.options.allowedScopes,
177
- Boolean(this.options.http)
214
+ this.hidesStdioOnlyTools()
178
215
  );
179
216
  }
180
217
  installToolSchemaRefNormalization(server);
@@ -202,6 +239,25 @@ class MicrosoftGraphServer {
202
239
  'Account routing disabled: requests use the OAuth bearer identity, so the "account" parameter is not injected into tool schemas'
203
240
  );
204
241
  }
242
+ if (this.options.httpLocalFileTools && this.options.http) {
243
+ if (!this.isLoopbackOnlyHttp()) {
244
+ throw new Error(
245
+ "--http-local-file-tools requires --http bound to a loopback host (localhost, 127.0.0.1 or [::1]) and no --public-url: anyone who can reach the port can write files as the server's user."
246
+ );
247
+ }
248
+ if (this.options.trustProxyAuth) {
249
+ throw new Error(
250
+ "--http-local-file-tools cannot be combined with --trust-proxy-auth: a proxy in front of the loopback port would let remote callers write files as the server's user."
251
+ );
252
+ }
253
+ logger.warn(
254
+ "--http-local-file-tools: download-bytes-to-file is registered over HTTP; anyone who can reach this port can write files as the server's user."
255
+ );
256
+ } else if (this.options.httpLocalFileTools) {
257
+ logger.warn(
258
+ "--http-local-file-tools has no effect in stdio mode, where download-bytes-to-file is always registered."
259
+ );
260
+ }
205
261
  if (this.options.obo) {
206
262
  if (!this.options.http) {
207
263
  throw new Error("--obo requires --http (On-Behalf-Of flow only works in HTTP mode).");
@@ -267,6 +323,10 @@ class MicrosoftGraphServer {
267
323
  if (this.options.http) {
268
324
  const { host, port } = parseHttpOption(this.options.http);
269
325
  const app = express();
326
+ if (this.isLoopbackOnlyHttp()) {
327
+ app.use(hostHeaderValidation(LOOPBACK_HOSTNAMES));
328
+ app.use(loopbackOriginValidation());
329
+ }
270
330
  const trustProxyEnv = process.env.MS365_MCP_TRUST_PROXY_HOPS;
271
331
  if (trustProxyEnv !== void 0 && trustProxyEnv !== "") {
272
332
  const asNum = Number(trustProxyEnv);
@@ -803,6 +863,9 @@ class MicrosoftGraphServer {
803
863
  var server_default = MicrosoftGraphServer;
804
864
  export {
805
865
  server_default as default,
866
+ isLoopbackHost,
867
+ loopbackOriginValidation,
806
868
  parseAttachmentHostOption,
807
- parseAttachmentPortOption
869
+ parseAttachmentPortOption,
870
+ parseHttpOption
808
871
  };
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.155.0",
4
+ "version": "0.156.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",