@softeria/ms-365-mcp-server 0.169.1 → 0.169.3

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.
@@ -46,6 +46,22 @@ function forceAttachment(header) {
46
46
  if (filename) return `attachment; filename=${filename}`;
47
47
  return "attachment";
48
48
  }
49
+ const GENERIC_CONTENT_TYPES = /* @__PURE__ */ new Set(["application/octet-stream", "application/binary", ""]);
50
+ const HEADER_SAFE_CHAR = /^[\t\x20-\x7e\x80-\xff]*$/;
51
+ function dispositionForProbedName(name) {
52
+ const safe = name.replace(/[\r\n"\\]/g, "").replace(/[^\t\x20-\x7e\x80-\xff]/g, "").trim();
53
+ const extension = /(\.[A-Za-z0-9]{1,10})$/.exec(name)?.[1] ?? "";
54
+ const fallback = safe && safe !== extension ? safe : extension ? `attachment${extension}` : safe;
55
+ let value = `attachment; filename="${fallback || "attachment"}"`;
56
+ if (!HEADER_SAFE_CHAR.test(name)) {
57
+ const encoded = encodeURIComponent(name).replace(
58
+ /['()*]/g,
59
+ (char) => "%" + char.charCodeAt(0).toString(16).toUpperCase()
60
+ );
61
+ value += `; filename*=UTF-8''${encoded}`;
62
+ }
63
+ return value;
64
+ }
49
65
  function refuse(res) {
50
66
  res.status(404).type("text/plain").send(NOT_FOUND_BODY);
51
67
  }
@@ -90,11 +106,18 @@ function createAttachmentHandler(deps) {
90
106
  return;
91
107
  }
92
108
  res.status(200);
93
- res.setHeader("content-type", stream.contentType);
109
+ const probed = ticket.probedContentType && HEADER_SAFE_CHAR.test(ticket.probedContentType) ? ticket.probedContentType : void 0;
110
+ res.setHeader(
111
+ "content-type",
112
+ probed && GENERIC_CONTENT_TYPES.has(stream.contentType.split(";")[0].trim().toLowerCase()) ? probed : stream.contentType
113
+ );
94
114
  if (stream.contentLength !== null) {
95
115
  res.setHeader("content-length", String(stream.contentLength));
96
116
  }
97
- res.setHeader("content-disposition", forceAttachment(stream.contentDisposition));
117
+ res.setHeader(
118
+ "content-disposition",
119
+ !stream.contentDisposition && ticket.probedName ? dispositionForProbedName(ticket.probedName) : forceAttachment(stream.contentDisposition)
120
+ );
98
121
  res.setHeader("cache-control", "no-store");
99
122
  res.setHeader("x-content-type-options", "nosniff");
100
123
  try {
@@ -688,7 +688,23 @@ async function mintUploadUrl(target, name, contentType, accountParam, authManage
688
688
  ]
689
689
  };
690
690
  }
691
- async function mintDownloadUrl(target, accountParam, authManager) {
691
+ const MAIL_EVENT_ATTACHMENT_VALUE = /^(?:\/me|\/users\/[^/]+|\/groups\/[^/]+)\/(?:messages|events)\/[^/]+\/attachments\/[^/]+\/\$value$/;
692
+ async function probeAttachment(target, graphClient, accessToken) {
693
+ if (!MAIL_EVENT_ATTACHMENT_VALUE.test(target)) return {};
694
+ try {
695
+ const meta = await graphClient.makeRequest(
696
+ `${target.slice(0, -"/$value".length)}?$select=name,contentType`,
697
+ { accessToken }
698
+ );
699
+ return {
700
+ ...typeof meta?.contentType === "string" && { probedContentType: meta.contentType },
701
+ ...typeof meta?.name === "string" && { probedName: meta.name }
702
+ };
703
+ } catch {
704
+ return {};
705
+ }
706
+ }
707
+ async function mintDownloadUrl(target, accountParam, authManager, probeWith) {
692
708
  const minting = getAttachmentMinting();
693
709
  if (!minting) return null;
694
710
  const identityFromRequest = Boolean(authManager?.isOAuthModeEnabled() || getRequestTokens());
@@ -726,9 +742,14 @@ async function mintDownloadUrl(target, accountParam, authManager) {
726
742
  isError: true
727
743
  };
728
744
  }
745
+ const probe = probeWith ? await probeAttachment(
746
+ target,
747
+ probeWith,
748
+ requestToken ?? await authManager?.getTokenForAccount(accountParam).catch(() => void 0)
749
+ ) : {};
729
750
  let ticket;
730
751
  try {
731
- ticket = requestToken ? minting.store.mintWithToken(target, requestToken) : minting.store.mint(target, accountParam);
752
+ ticket = requestToken ? minting.store.mintWithToken(target, requestToken, void 0, probe) : minting.store.mint(target, accountParam, void 0, probe);
732
753
  } catch (error) {
733
754
  if (error instanceof TicketStoreFullError) {
734
755
  return {
@@ -1380,7 +1401,7 @@ const UTILITY_TOOLS = [
1380
1401
  isError: true
1381
1402
  };
1382
1403
  }
1383
- const minted = await mintDownloadUrl(pathPart, accountParam, authManager);
1404
+ const minted = await mintDownloadUrl(pathPart, accountParam, authManager, graphClient);
1384
1405
  if (minted) return minted;
1385
1406
  return {
1386
1407
  content: [
@@ -65,13 +65,19 @@ class AttachmentTicketStore {
65
65
  return { id, expiresAtMs };
66
66
  }
67
67
  /** Mint a ticket redeemed with this server's own token for `accountName`. */
68
- mint(target, accountName, nowMs = Date.now()) {
69
- return this.add(target, { kind: "server-account", accountName }, nowMs);
68
+ mint(target, accountName, nowMs = Date.now(), probe = {}) {
69
+ return this.add(target, { kind: "server-account", accountName }, nowMs, {
70
+ purpose: "download",
71
+ ...probe
72
+ });
70
73
  }
71
74
  /** Mint a ticket redeemed with `accessToken` and nothing else. */
72
- mintWithToken(target, accessToken, nowMs = Date.now()) {
75
+ mintWithToken(target, accessToken, nowMs = Date.now(), probe = {}) {
73
76
  if (!accessToken) throw new Error("A request-token ticket needs a token");
74
- return this.add(target, { kind: "request-token", accessToken }, nowMs);
77
+ return this.add(target, { kind: "request-token", accessToken }, nowMs, {
78
+ purpose: "download",
79
+ ...probe
80
+ });
75
81
  }
76
82
  /** Mint an upload ticket attached as this server's own token for `accountName`. */
77
83
  mintUpload(target, accountName, upload, nowMs = Date.now()) {
@@ -3,10 +3,29 @@ const USER_ENTITY_PATH = /^\/users(?:\/[^/]+)?\/?$/i;
3
3
  const DIRECTORY_NAVIGATION_PATH = /^(?:\/me|\/users\/[^/]+)\/(?:manager|directReports)\/?$|^\/groups\/[^/]+\/(?:members|owners)\/?$/i;
4
4
  const EXPAND_USER_PATH = /^\/me\/?$|^\/groups(?:\/[^/]+)?\/?$/i;
5
5
  function normalizePath(path) {
6
- const withoutQuery = path.split("?")[0];
6
+ let withoutQuery = path.split("?")[0];
7
+ for (let i = 0; i < 4; i++) {
8
+ const next = withoutQuery.replace(
9
+ /%([0-9a-f]{2})/gi,
10
+ (_, hex) => String.fromCharCode(parseInt(hex, 16))
11
+ );
12
+ if (next === withoutQuery) break;
13
+ withoutQuery = next;
14
+ }
7
15
  return withoutQuery.startsWith("/") ? withoutQuery : `/${withoutQuery}`;
8
16
  }
17
+ const ENCODED_SEPARATOR = /%(?:25)*(?:2f|5c)/i;
18
+ function deleteOdataOption(params, name) {
19
+ const values = [];
20
+ for (const key of [...new Set(params.keys())]) {
21
+ if (key.replace(/^\$/, "").toLowerCase() !== name) continue;
22
+ values.push(...params.getAll(key));
23
+ params.delete(key);
24
+ }
25
+ return values;
26
+ }
9
27
  function userFieldEnforcement(path) {
28
+ if (ENCODED_SEPARATOR.test(path.split("?")[0])) return "request-and-response";
10
29
  const normalized = normalizePath(path);
11
30
  if (USER_ENTITY_PATH.test(normalized)) return "request-and-response";
12
31
  if (DIRECTORY_NAVIGATION_PATH.test(normalized)) return "response-only";
@@ -23,8 +42,7 @@ function stripUserFieldExpandFromUrl(url) {
23
42
  const [path, query = ""] = url.split("?");
24
43
  if (!shouldStripUserFieldExpand(path) || query === "") return url;
25
44
  const params = new URLSearchParams(query);
26
- if (!params.has("$expand")) return url;
27
- params.delete("$expand");
45
+ if (deleteOdataOption(params, "expand").length === 0) return url;
28
46
  const rebuilt = params.toString();
29
47
  if (rebuilt === "") return path;
30
48
  return `${path}?${rebuilt.replace(/%24/gi, "$").replace(/%2C/gi, ",")}`;
@@ -46,14 +64,15 @@ function restrictUserFieldQuery(queryParams, allowlist, enforcement) {
46
64
  function restrictUserFieldUrl(url, allowlist, enforcement) {
47
65
  const [path, query = ""] = url.split("?");
48
66
  const params = new URLSearchParams(query);
67
+ const requested = enforcement === "request-and-response" ? deleteOdataOption(params, "select") : params.getAll("$select");
49
68
  const fields = effectiveUserFields(
50
- parseSelectFields(params.get("$select") ?? void 0),
69
+ parseSelectFields(requested.join(",") || void 0),
51
70
  allowlist
52
71
  );
53
72
  if (enforcement === "request-and-response") {
54
73
  params.set("$select", fields.join(","));
55
74
  }
56
- params.delete("$expand");
75
+ deleteOdataOption(params, "expand");
57
76
  const rebuilt = params.toString();
58
77
  if (rebuilt === "") return { url: path, fields };
59
78
  return { url: `${path}?${rebuilt.replace(/%24/gi, "$").replace(/%2C/gi, ",")}`, fields };
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.169.1",
4
+ "version": "0.169.3",
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",