@softeria/ms-365-mcp-server 0.168.0 → 0.169.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
@@ -736,6 +736,7 @@ Environment variables:
736
736
  - `MS365_MCP_BLOB_MAX_BYTES=<n>`: With blob output on, the largest file a download tool reads (positive integer, default `5242880`). The server stops reading past it and answers an error naming the limit.
737
737
  - `MS365_MCP_BODY_FORMAT=html`: Return email bodies as HTML instead of plain text (default: text)
738
738
  - `MS365_MCP_OBO_ALLOWED_TENANTS=<ids>` / `MS365_MCP_OBO_ALLOWED_USERS=<ids>`: With `--obo`, only callers whose token `tid` (tenant id) / `oid` (user object id) is listed may use the server; others get 403 before any On-Behalf-Of exchange. Comma or space separated; unset = everyone. The claims are read without verifying the signature, which Entra verifies during the exchange, so a token that lies about them still gets no Graph token. A token Entra rejects at the exchange (bad signature, expired, wrong audience, consent needed) gets 401 with a `WWW-Authenticate` challenge so the client signs in again
739
+ - `MS365_MCP_READ_MAIL_TEXT_MAX_CHARS=<n>`: Largest result `read-mail-text` returns, in characters (default `80000`). A larger result is refused whole, naming the largest messages, rather than cut (see Reading Mail as Clean Text below)
739
740
  - `MS365_MCP_MESSAGE_SIGNOFF_PREFIX=<text>`: Signoff prepended to outgoing messages so recipients can tell they were agent-sent, e.g. `🤖`. Default: none. CLI equivalent: `--message-signoff-prefix <text>` (see Message Signoff below)
740
741
  - `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)
741
742
  - `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`)
@@ -760,6 +761,46 @@ Environment variables:
760
761
  - `MS365_MCP_EXPECTED_USERNAME`: Require local MSAL auth to use this Microsoft account username (case-insensitive; CLI flag takes precedence)
761
762
  - `MS365_MCP_EXPECTED_HOME_ACCOUNT_ID`: Require local MSAL auth to use this exact MSAL homeAccountId (CLI flag takes precedence)
762
763
 
764
+ ## Reading Mail as Clean Text
765
+
766
+ `read-mail-text` returns messages as text with the bulk junk removed. Graph's
767
+ plain-text body is still mostly not the message: links are wrapped in Safe Links
768
+ or tracking redirects several hundred characters long, marketing mail pads its
769
+ preheader with invisible characters, images become `[https://…png]` lines, and
770
+ replies carry the whole quoted thread. On a real mailbox, 20 inbox messages came
771
+ to about 280,000 characters through `list-mail-messages` and 70,000 through
772
+ `read-mail-text`.
773
+
774
+ The rule is: remove obvious junk, never cut or rewrite the message; anything
775
+ ambiguous is kept. It removes runs of invisible padding characters, image
776
+ placeholders (a bracketed address of an image file), and quoted reply history
777
+ from its header down (a dated `On … wrote:`, also `>`-quoted, an Outlook
778
+ `From:/Sent:` block, `-----Original Message-----`). Forwards keep the forwarded
779
+ content: a subject starting `FW:` / `Fwd:` or a localised prefix (`VS:`, `WG:`,
780
+ `TR:`, `RV:`, `I:`, `ENC:`, …, also after tags such as `[EXTERNAL]`), or history
781
+ directly under a `Forwarded message` / `Begin forwarded message:` marker. Links are kept:
782
+ Safe Links, Proofpoint URL Defense and Google redirects are unwrapped to the real
783
+ address, tracking parameters (`utm_*` and similar) are dropped, and only an
784
+ address still longer than 300 characters (a tracking redirect) is shortened to
785
+ `⟦link: domain⟧`. Line endings become `\n`, and the marker brackets are
786
+ reserved: a body's own `⟦ ⟧` appear as `[ ]`, so text cannot pose as a marker.
787
+ Everything else — signatures, footers, whitespace, repeated links — is left as
788
+ the message has it. `get-mail-message` returns the raw body.
789
+
790
+ - `messageIds` (up to 20, one `$batch`), or `folderId` with optional `top`
791
+ (max 20), `filter` or `search`.
792
+ - Each message is cut to `maxChars` characters, default 3,000 (marked
793
+ `truncated`): a page of 20 then fits the result limit, and a typical message
794
+ is whole. Ticket systems that repeat their history in every update put the
795
+ newest entry first, so the cut keeps it. Pass a larger `maxChars` (up to
796
+ 300,000; a raw body beyond that is cleaned only that far) for the whole of a
797
+ long message; `get-mail-message` has the raw body.
798
+ - Returns `[{ id, from, to, cc, subject, received, hasAttachments, text, chars, truncated }]`;
799
+ an unknown id comes back as `{ id, error }`.
800
+ - A result over `MS365_MCP_READ_MAIL_TEXT_MAX_CHARS` (default 80,000) is refused
801
+ whole with the largest messages listed, so the agent narrows the query instead
802
+ of silently getting part of it.
803
+
763
804
  ## Server-Minted Attachment URLs
764
805
 
765
806
  `get-download-url` returns Microsoft's own pre-authenticated `@microsoft.graph.downloadUrl`
@@ -1164,7 +1164,7 @@ describe("graph-tools", () => {
1164
1164
  expect(request).toContain("$skiptoken=next-token");
1165
1165
  });
1166
1166
  it.each(["list-calendar-events-delta", "list-calendar-view-delta"])(
1167
- "ignores stale mixed-case top values for %s",
1167
+ "rejects mixed-case top values before Graph dispatch for %s",
1168
1168
  async (alias) => {
1169
1169
  mockEndpoints.push(makeEndpoint({ alias }));
1170
1170
  mockEndpointsJson = [makeConfig({ toolName: alias })];
@@ -1173,12 +1173,10 @@ describe("graph-tools", () => {
1173
1173
  const { registerGraphTools } = await loadModule();
1174
1174
  registerGraphTools(server, graphClient);
1175
1175
  for (const key of ["top", "$top", "TOP", "$TOP", "$Top"]) {
1176
- await server.tools.get(alias).handler({ [key]: 10 });
1177
- }
1178
- expect(graphClient.graphRequest).toHaveBeenCalledTimes(5);
1179
- for (const call of graphClient.graphRequest.mock.calls) {
1180
- expect(JSON.stringify(call)).not.toContain("$top");
1176
+ const result = await server.tools.get(alias).handler({ [key]: 10 });
1177
+ expect(result.isError).toBe(true);
1181
1178
  }
1179
+ expect(graphClient.graphRequest).not.toHaveBeenCalled();
1182
1180
  }
1183
1181
  );
1184
1182
  it.each(["list-joined-teams", "list-my-associated-teams"])(
@@ -1212,7 +1210,7 @@ describe("graph-tools", () => {
1212
1210
  expect(graphClient.graphRequest).not.toHaveBeenCalled();
1213
1211
  });
1214
1212
  it("sends a calendar field array as comma-separated text in one Graph request", async () => {
1215
- const alias = "list-calendar-events-delta";
1213
+ const alias = "list-calendar-events";
1216
1214
  mockEndpoints.push(makeEndpoint({ alias }));
1217
1215
  mockEndpointsJson = [makeConfig({ toolName: alias })];
1218
1216
  const graphClient = createMockGraphClient();
@@ -2543,7 +2543,7 @@
2543
2543
  "toolName": "list-calendar-events-delta",
2544
2544
  "presets": ["calendar", "outlook", "personal"],
2545
2545
  "scopes": ["Calendars.Read"],
2546
- "llmTip": "Incremental sync of events across the default calendar. First call returns all events plus @odata.deltaLink. Subsequent calls with that link return only additions/updates/removals. Use $select to limit fields. Deltas expire after ~30 days — start over if the server returns 410 Gone. For a time-bounded view with delta semantics, use list-calendar-view-delta instead."
2546
+ "llmTip": "Incremental sync of events across the default calendar. First call returns all events plus @odata.deltaLink. Subsequent calls with that link return only additions/updates/removals. Deltas expire after ~30 days — start over if the server returns 410 Gone. For a time-bounded view with delta semantics, use list-calendar-view-delta instead."
2547
2547
  },
2548
2548
  {
2549
2549
  "pathPattern": "/me/calendarView/delta()",
@@ -38,10 +38,8 @@ import {
38
38
  isSkiptokenApplicable,
39
39
  paginationAllowed,
40
40
  positiveIntFromEnv,
41
- shouldOmitTopParam,
42
41
  SKIPTOKEN_PARAM_DESCRIPTION,
43
- TIMEZONE_PARAM_DESCRIPTION,
44
- TOP_UNSUPPORTED_DELTA_TOOLS
42
+ TIMEZONE_PARAM_DESCRIPTION
45
43
  } from "./lib/param-descriptions.js";
46
44
  import { describePathParam } from "./lib/path-params.js";
47
45
  import { queryParameterSchema } from "./lib/query-parameter-schema.js";
@@ -52,6 +50,18 @@ import {
52
50
  projectSelectedFields
53
51
  } from "./lib/select-projection.js";
54
52
  import { parseTeamsUrl } from "./lib/teams-url-parser.js";
53
+ import {
54
+ MAIL_TEXT_DEFAULT_MAX_RESPONSE_CHARS,
55
+ MAIL_TEXT_DEFAULT_MAX_CHARS,
56
+ MAIL_TEXT_MAX_INPUT_CHARS,
57
+ MAIL_TEXT_MAX_IDS,
58
+ MAIL_TEXT_MAX_TOP,
59
+ MAIL_TEXT_SELECT,
60
+ PREFER_TEXT_BODY,
61
+ oversizeError,
62
+ toMailTextEntry,
63
+ validateMailTextArgs
64
+ } from "./lib/mail-text.js";
55
65
  import { describeToolSchema, describeUtilityToolSchema } from "./lib/tool-schema.js";
56
66
  import {
57
67
  restrictUserFieldQuery,
@@ -921,6 +931,139 @@ const UTILITY_TOOLS = [
921
931
  }
922
932
  }
923
933
  },
934
+ {
935
+ name: "read-mail-text",
936
+ method: "GET",
937
+ path: "tool:read-mail-text",
938
+ searchKeywords: "triage inbox skim many emails at once without quoted history signature footer tracking links",
939
+ description: "Triage view of up to 20 mail messages at once, as text with the bulk junk removed: invisible padding, image placeholders and quoted reply history (forwards keep theirs). Links are kept: Safe Links, Proofpoint and Google redirects are unwrapped to the real address and tracking parameters (utm_* and similar) dropped; only an address still over 300 characters (a tracking redirect) is shortened to \u27E6link: domain\u27E7. Everything else (signatures, footers, whitespace) is left as the message has it; line endings become \\n and \u27E6 \u27E7 in the body itself appear as [ ]. Use get-mail-message for the raw body or the quoted thread. Give messageIds (up to 20), or folderId with optional top and filter or search for the newest messages in a folder. Each message is cut to maxChars characters (default 3,000; marked truncated: true): pass a larger maxChars, up to 300,000, for the whole of a long message; a body over 300,000 characters is cleaned only that far. If the whole result would exceed the server limit (MS365_MCP_READ_MAIL_TEXT_MAX_CHARS, default 80000) the call fails, listing the largest messages, instead of returning part of it. Returns [{ id, from, to, cc, subject, received, hasAttachments, text, chars, truncated }].",
940
+ readOnlyHint: true,
941
+ openWorldHint: true,
942
+ buildSchema: (ctx) => {
943
+ const schema = {
944
+ messageIds: z.array(z.string()).max(MAIL_TEXT_MAX_IDS).optional().describe(
945
+ `Ids of the messages to read (max ${MAIL_TEXT_MAX_IDS}). Give messageIds or folderId, not both.`
946
+ ),
947
+ folderId: z.string().optional().describe(
948
+ "Folder to read from: a well-known name (inbox, sentitems, drafts, archive, deleteditems, junkemail) or a folder id."
949
+ ),
950
+ top: z.number().int().min(1).max(MAIL_TEXT_MAX_TOP).optional().describe(
951
+ `With folderId: how many messages, newest first (default and max ${MAIL_TEXT_MAX_TOP}).`
952
+ ),
953
+ filter: z.string().optional().describe(
954
+ "With folderId: OData $filter, e.g. receivedDateTime ge 2026-10-01T00:00:00Z or from/emailAddress/address eq 'a@example.com'."
955
+ ),
956
+ search: z.string().optional().describe(
957
+ 'With folderId: KQL search, e.g. from:paul subject:"quarterly report" (quoting is normalised as for list-mail-messages). Graph cannot combine it with filter.'
958
+ ),
959
+ maxChars: z.number().int().min(1).max(MAIL_TEXT_MAX_INPUT_CHARS).optional().describe(
960
+ "Cut each message text to this many characters (marked truncated). Default 3,000; pass up to 300,000 for the whole of a long message (a body over 300,000 characters is cleaned only that far, also marked truncated)."
961
+ )
962
+ };
963
+ if (ctx.multiAccount) {
964
+ schema["account"] = z.string().optional().describe(
965
+ "Account to use when multiple Microsoft accounts are configured. Required when multiple accounts exist (see list-accounts)."
966
+ );
967
+ }
968
+ return schema;
969
+ },
970
+ execute: async (params, { graphClient, authManager }) => {
971
+ const fail = (error) => ({
972
+ content: [{ type: "text", text: JSON.stringify({ error }) }],
973
+ isError: true
974
+ });
975
+ const invalid = validateMailTextArgs(params);
976
+ if (invalid) return fail(invalid);
977
+ const ids = params.messageIds;
978
+ const folderId = params.folderId;
979
+ const top = params.top;
980
+ const filter = params.filter;
981
+ const search = params.search;
982
+ const maxChars = params.maxChars ?? MAIL_TEXT_DEFAULT_MAX_CHARS;
983
+ const accountParam = params.account;
984
+ const envLimit = Number(process.env.MS365_MCP_READ_MAIL_TEXT_MAX_CHARS);
985
+ const limit = Number.isInteger(envLimit) && envLimit > 0 ? envLimit : MAIL_TEXT_DEFAULT_MAX_RESPONSE_CHARS;
986
+ try {
987
+ const accountModeError = await checkAccountParamInBearerMode(accountParam, authManager);
988
+ if (accountModeError) return fail(accountModeError);
989
+ let accessToken;
990
+ if (authManager && !authManager.isOAuthModeEnabled() && !getRequestTokens()) {
991
+ accessToken = await authManager.getTokenForAccount(accountParam);
992
+ }
993
+ const requestJson = async (endpoint, options) => {
994
+ const response = await graphClient.graphRequest(endpoint, {
995
+ ...options,
996
+ accessToken,
997
+ forceJsonOutput: true
998
+ });
999
+ if (response?.isError) return { error: response };
1000
+ const text2 = response?.content?.[0]?.text;
1001
+ try {
1002
+ return { data: JSON.parse(typeof text2 === "string" ? text2 : "") };
1003
+ } catch {
1004
+ return { error: fail("Unexpected non-JSON response from Microsoft Graph.") };
1005
+ }
1006
+ };
1007
+ let entries;
1008
+ if (ids !== void 0) {
1009
+ const requests = ids.map((id, i) => ({
1010
+ id: String(i),
1011
+ method: "GET",
1012
+ url: `/me/messages/${encodeURIComponent(id)}?$select=${MAIL_TEXT_SELECT}`,
1013
+ headers: { Prefer: PREFER_TEXT_BODY }
1014
+ }));
1015
+ const r = await requestJson("/$batch", {
1016
+ method: "POST",
1017
+ body: JSON.stringify({ requests }),
1018
+ headers: { "Content-Type": "application/json" }
1019
+ });
1020
+ if (r.error) return r.error;
1021
+ const responses = r.data?.responses ?? [];
1022
+ const byId = new Map(responses.map((x) => [x.id, x]));
1023
+ entries = ids.map((id, i) => {
1024
+ const x = byId.get(String(i));
1025
+ if (!x) return { id, error: "no response" };
1026
+ if (x.status !== 200) {
1027
+ const e = x.body?.error;
1028
+ return {
1029
+ id,
1030
+ error: x.status === 404 ? "not found" : `${e?.code ?? x.status}: ${e?.message ?? ""}`.trim()
1031
+ };
1032
+ }
1033
+ return toMailTextEntry(x.body ?? {}, maxChars);
1034
+ });
1035
+ } else {
1036
+ const queryParams = {
1037
+ $select: MAIL_TEXT_SELECT,
1038
+ $top: String(top ?? MAIL_TEXT_MAX_TOP)
1039
+ };
1040
+ if (filter !== void 0) queryParams["$filter"] = filter;
1041
+ if (search !== void 0) {
1042
+ queryParams["$search"] = search;
1043
+ const searchError = normalizeSearchQueryParam(
1044
+ queryParams,
1045
+ "/me/mailFolders/{mailFolder-id}/messages",
1046
+ "read-mail-text"
1047
+ );
1048
+ if (searchError) return searchError;
1049
+ }
1050
+ const r = await requestJson(
1051
+ `/me/mailFolders/${encodeURIComponent(folderId)}/messages?${new URLSearchParams(queryParams)}`,
1052
+ { headers: { Prefer: PREFER_TEXT_BODY } }
1053
+ );
1054
+ if (r.error) return r.error;
1055
+ entries = (r.data?.value ?? []).map(
1056
+ (m) => toMailTextEntry(m, maxChars)
1057
+ );
1058
+ }
1059
+ const text = JSON.stringify(entries);
1060
+ if (text.length > limit) return fail(oversizeError(entries, text.length, limit));
1061
+ return { content: [{ type: "text", text }] };
1062
+ } catch (error) {
1063
+ return fail(error.message);
1064
+ }
1065
+ }
1066
+ },
924
1067
  {
925
1068
  name: "download-bytes",
926
1069
  method: "GET",
@@ -1645,8 +1788,7 @@ async function executeGraphTool(tool, config, graphClient, params, authManager,
1645
1788
  (p) => p.name === paramName || p.name === camelCaseParamName || isOdataParam && p.name.replace(/^\$/, "").toLowerCase() === normalizedParamName
1646
1789
  );
1647
1790
  const isQuery = paramDef?.type === "Query" || isOdataParam;
1648
- const ignoredDeltaTop = shouldOmitTopParam(tool.alias) && normalizedParamName === "top";
1649
- if (isQuery && !ignoredDeltaTop && paramValue != null && paramValue !== "") {
1791
+ if (isQuery && paramValue != null && paramValue !== "") {
1650
1792
  const schema = queryParameterSchema(
1651
1793
  tool.alias,
1652
1794
  normalizedParamName,
@@ -1797,9 +1939,6 @@ async function executeGraphTool(tool, config, graphClient, params, authManager,
1797
1939
  }
1798
1940
  body = rewritten.body;
1799
1941
  }
1800
- if (TOP_UNSUPPORTED_DELTA_TOOLS.has(tool.alias)) {
1801
- delete queryParams["$top"];
1802
- }
1803
1942
  const skiptokenError = normalizeSkiptokenQueryParam(queryParams, tool.alias);
1804
1943
  if (skiptokenError) return skiptokenError;
1805
1944
  clampTopQueryParam(queryParams);
@@ -0,0 +1,293 @@
1
+ const MAIL_TEXT_MAX_IDS = 20;
2
+ const MAIL_TEXT_MAX_TOP = 20;
3
+ const MAIL_TEXT_DEFAULT_MAX_RESPONSE_CHARS = 8e4;
4
+ const MAIL_TEXT_DEFAULT_MAX_CHARS = 3e3;
5
+ const MAIL_TEXT_MAX_INPUT_CHARS = 3e5;
6
+ const MAIL_TEXT_SELECT = "id,subject,from,toRecipients,ccRecipients,receivedDateTime,hasAttachments,body";
7
+ const PREFER_TEXT_BODY = 'outlook.body-content-type="text"';
8
+ const QUOTE_START = [
9
+ /^On .{1,300}wrote:\s*$/i,
10
+ // Gmail / Apple Mail
11
+ /^-{2,}\s*Original Message\s*-{2,}/i,
12
+ // Outlook classic
13
+ /^Le .{1,300}a écrit\s*:\s*$/i,
14
+ /^Am .{1,300}schrieb .{0,200}:\s*$/i
15
+ ];
16
+ const DATED = [true, false, true, true];
17
+ function isReplyHeader(l) {
18
+ return QUOTE_START.some((r, k) => r.test(l) && (!DATED[k] || /\d/.test(l)));
19
+ }
20
+ const HEADER_FROM = /^\*?From:\*?\s+\S/i;
21
+ const HEADER_NEXT = /^\*?(Sent|Date|To|Subject):\*?\s/i;
22
+ const SEPARATOR = /^_{10,}\s*$/;
23
+ function isHeaderBlock(lines, i) {
24
+ const l = lines[i]?.trim() ?? "";
25
+ if (l.length > 2e3 || !HEADER_FROM.test(l)) return false;
26
+ let headers = 0;
27
+ for (let j = i + 1; j < Math.min(lines.length, i + 6); j++) {
28
+ if (lines[j].length < 2e3 && HEADER_NEXT.test(lines[j].trim())) headers++;
29
+ }
30
+ return headers >= 2;
31
+ }
32
+ const FORWARD_MARKER = /^(?:-{2,}\s*Forwarded message\s*-{2,}|Begin forwarded message:)$/i;
33
+ function forwardMarkerAbove(lines, q) {
34
+ let seen = 0;
35
+ for (let j = q - 1; j >= 0 && seen < 3; j--) {
36
+ const l = lines[j].trim();
37
+ if (!l) continue;
38
+ if (l.length < 100 && FORWARD_MARKER.test(l)) return true;
39
+ seen++;
40
+ }
41
+ return false;
42
+ }
43
+ function quoteStart(lines) {
44
+ for (let i = 0; i < lines.length; i++) {
45
+ const l = lines[i].trim();
46
+ if (l.length > 2e3) continue;
47
+ if (isReplyHeader(l)) return i;
48
+ if (l.startsWith(">") && isReplyHeader(l.replace(/^(?:>[ \t]?){1,20}/, "").trim())) return i;
49
+ if (/^On .{1,200}/i.test(l) && /\d/.test(l) && i + 1 < lines.length && l.length + lines[i + 1].length < 320 && /wrote:\s*$/i.test(lines[i + 1].trim())) {
50
+ return i;
51
+ }
52
+ if (isHeaderBlock(lines, i)) return i;
53
+ if (SEPARATOR.test(l)) {
54
+ let j = i + 1;
55
+ while (j < lines.length && j <= i + 2 && !lines[j].trim()) j++;
56
+ if (isHeaderBlock(lines, j)) return i;
57
+ }
58
+ }
59
+ return -1;
60
+ }
61
+ const MARK_OPEN = "\u27E6";
62
+ const mark = (host) => `\u27E6link: ${host}\u27E7`;
63
+ const MAX_KEPT_URL = 300;
64
+ const TRACKING_PARAM = /^(utm_[a-z_]+|mc_cid|mc_eid|_hsenc|_hsmi|__hstc|__hssc|__hsfp|mkt_tok|gclid|dclid|fbclid|msclkid|yclid|igshid)$/i;
65
+ const LINKEDIN_TRACKING_PARAM = /^(trackingid|refid|lipi|midtoken|midsig|trk|trkemail)$/i;
66
+ const RUN_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
67
+ function unwrapUrlDefenseV3(u) {
68
+ const m = /^https:\/\/urldefense\.com\/v3\/__(.{1,4096}?)__;([A-Za-z0-9_-]{0,4096})!/.exec(u);
69
+ if (!m) return void 0;
70
+ const replaced = [...Buffer.from(m[2], "base64url").toString("utf8")];
71
+ let next = 0;
72
+ let out = m[1].replace(/\*\*(.)|\*/g, (tok, run) => {
73
+ const n = run === void 0 ? 1 : RUN_CHARS.indexOf(run) + 2;
74
+ if (n < 1) return tok;
75
+ const s = replaced.slice(next, next + n).join("");
76
+ next += n;
77
+ return s;
78
+ });
79
+ out = out.replace(/^(https?:)\/(?!\/)/i, "$1//");
80
+ return new URL(out).href;
81
+ }
82
+ function unwrapOnce(u) {
83
+ const url = new URL(u);
84
+ const host = url.hostname.toLowerCase();
85
+ if (/(^|\.)safelinks\.protection\.outlook\.com$/.test(host)) {
86
+ const w = url.searchParams.get("url");
87
+ return w ? new URL(w).href : void 0;
88
+ }
89
+ if (host === "urldefense.com") return unwrapUrlDefenseV3(u);
90
+ if (host === "urldefense.proofpoint.com") {
91
+ const w = url.searchParams.get("u");
92
+ return w ? new URL(decodeURIComponent(w.replace(/_/g, "/").replace(/-/g, "%"))).href : void 0;
93
+ }
94
+ if (/^(www\.)?google\.[a-z.]{2,6}$/.test(host) && url.pathname === "/url") {
95
+ const w = url.searchParams.get("q") ?? url.searchParams.get("url");
96
+ return w && /^https?:\/\//i.test(w) ? new URL(w).href : void 0;
97
+ }
98
+ return void 0;
99
+ }
100
+ function unwrapLink(u) {
101
+ let real = u;
102
+ for (let i = 0; i < 3; i++) {
103
+ let next;
104
+ try {
105
+ next = unwrapOnce(real);
106
+ } catch {
107
+ next = void 0;
108
+ }
109
+ if (!next || next === real) break;
110
+ real = next;
111
+ }
112
+ try {
113
+ const r = new URL(real);
114
+ const linkedIn = /(^|\.)linkedin\.com$/i.test(r.hostname);
115
+ let dropped = false;
116
+ for (const k of [...r.searchParams.keys()]) {
117
+ if (TRACKING_PARAM.test(k) || linkedIn && LINKEDIN_TRACKING_PARAM.test(k)) {
118
+ r.searchParams.delete(k);
119
+ dropped = true;
120
+ }
121
+ }
122
+ return dropped ? r.href : real;
123
+ } catch {
124
+ return real;
125
+ }
126
+ }
127
+ function hostOf(u) {
128
+ try {
129
+ return new URL(u).hostname.replace(/^www\./, "") || "link";
130
+ } catch {
131
+ return "link";
132
+ }
133
+ }
134
+ function showLink(u) {
135
+ const real = unwrapLink(u);
136
+ return real.length <= MAX_KEPT_URL ? real : mark(hostOf(real));
137
+ }
138
+ function splitUrlTail(u) {
139
+ let opens = 0;
140
+ let closes = 0;
141
+ for (const c of u) {
142
+ if (c === "(") opens++;
143
+ else if (c === ")") closes++;
144
+ }
145
+ let end = u.length;
146
+ while (end > 0) {
147
+ const c = u[end - 1];
148
+ if (".,;:!?".includes(c)) {
149
+ end--;
150
+ } else if (c === ")" && closes > opens) {
151
+ end--;
152
+ closes--;
153
+ } else {
154
+ break;
155
+ }
156
+ }
157
+ return [u.slice(0, end), u.slice(end)];
158
+ }
159
+ function isImageUrl(u) {
160
+ try {
161
+ return /\.(png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(new URL(u).pathname);
162
+ } catch {
163
+ return false;
164
+ }
165
+ }
166
+ function cleanLinks(t) {
167
+ t = t.replace(/<((?:https?|mailto|tel):[^<>\s]{1,4096})>/gi, (m, u) => {
168
+ if (/^(mailto|tel):/i.test(u)) return m;
169
+ const shown = showLink(u);
170
+ return shown.startsWith(MARK_OPEN) ? shown : `<${shown}>`;
171
+ });
172
+ return t.replace(
173
+ /(^|[^<])\b(https?:\/\/[^\s<>"'[\]\u27e6\u27e7]{1,4096})/gi,
174
+ (_m, pre, raw) => {
175
+ const [u, tail] = splitUrlTail(raw);
176
+ return pre + showLink(u) + tail;
177
+ }
178
+ );
179
+ }
180
+ const IMAGE_PLACEHOLDER = /\[[ \t]{0,20}(https?:\/\/[^\s<>"'[\]\u27e6\u27e7]{1,4096})[ \t]{0,20}\]/gi;
181
+ function cleanMailText(raw, opts = {}) {
182
+ const t = String(raw ?? "").replace(/\r\n?/g, "\n").replace(/(?:(?:[\u200b-\u200d\u2060\ufeff\u00ad]|\u034f)[ \t\u00a0\u2007\u202f]*){3,}/g, " ").replace(/\u27e6/g, "[").replace(/\u27e7/g, "]");
183
+ let lines = t.split("\n");
184
+ if (!opts.keepQuoted) {
185
+ const q = quoteStart(lines);
186
+ if (q >= 0 && lines.slice(0, q).join("").trim() && !forwardMarkerAbove(lines, q)) {
187
+ lines = lines.slice(0, q).concat("");
188
+ }
189
+ }
190
+ const body = lines.join("\n").replace(IMAGE_PLACEHOLDER, (m, u) => isImageUrl(u) ? "" : m);
191
+ return cleanLinks(body);
192
+ }
193
+ function validateMailTextArgs(p) {
194
+ const isInt = (v, min, max = Number.MAX_SAFE_INTEGER) => typeof v === "number" && Number.isInteger(v) && v >= min && v <= max;
195
+ const hasIds = p.messageIds !== void 0;
196
+ const hasFolder = p.folderId !== void 0;
197
+ if (hasIds === hasFolder) return "Give messageIds or folderId (exactly one).";
198
+ if (p.maxChars !== void 0 && !isInt(p.maxChars, 1, MAIL_TEXT_MAX_INPUT_CHARS)) {
199
+ return `maxChars must be an integer from 1 to ${MAIL_TEXT_MAX_INPUT_CHARS}.`;
200
+ }
201
+ if (hasIds) {
202
+ const ids = p.messageIds;
203
+ if (!Array.isArray(ids) || ids.length === 0 || ids.some((x) => typeof x !== "string" || !x.trim())) {
204
+ return "messageIds must be a non-empty list of message ids.";
205
+ }
206
+ if (ids.length > MAIL_TEXT_MAX_IDS) return `At most ${MAIL_TEXT_MAX_IDS} messageIds per call.`;
207
+ if (p.top !== void 0 || p.filter !== void 0 || p.search !== void 0) {
208
+ return "top, filter and search apply only with folderId.";
209
+ }
210
+ return void 0;
211
+ }
212
+ if (typeof p.folderId !== "string" || !p.folderId.trim()) {
213
+ return "folderId must be a non-empty string.";
214
+ }
215
+ if (p.top !== void 0 && !isInt(p.top, 1, MAIL_TEXT_MAX_TOP)) {
216
+ return `top must be an integer from 1 to ${MAIL_TEXT_MAX_TOP}.`;
217
+ }
218
+ for (const k of ["filter", "search"]) {
219
+ const v = p[k];
220
+ if (v !== void 0 && (typeof v !== "string" || !v.trim())) {
221
+ return `${k} must be a non-empty string (omit it to read the newest messages).`;
222
+ }
223
+ }
224
+ if (p.filter !== void 0 && p.search !== void 0) {
225
+ return "Microsoft Graph cannot combine filter and search on messages; use one.";
226
+ }
227
+ return void 0;
228
+ }
229
+ function formatAddress(r) {
230
+ const e = r?.emailAddress;
231
+ if (!e) return "";
232
+ return e.name && e.address && e.name !== e.address ? `${e.name} <${e.address}>` : e.address || e.name || "";
233
+ }
234
+ const FORWARD_SUBJECT = new RegExp(
235
+ "^\\s*(?:\\[[^\\]\\n]{0,60}\\]\\s*){0,5}(?:fwd?|vs|vb|wg|tr|trs|rv|i|enc|doorst|vl|ed|fs|pd|yml|ilt|\u0130lt|tov\xE1bb\xEDt\xE1s|p\u0101rs\\.|\u03C0\u03C1\u03B8|\u8F6C\u53D1|\u8F49\u5BC4|\u05D4\u05D5\u05E2\u05D1\u05E8|\u05D4\u05E2\u05D1\u05E8\u05D4|\u0625\u0639\u0627\u062F\u0629 \u062A\u0648\u062C\u064A\u0647|\u0BAE\u0BC1\u0BA9\u0BCD\u0BA9\u0BA9\u0BC1\u0BAA\u0BCD\u0BAA\u0BC1)\\s*[:\uFF1A]",
236
+ "iu"
237
+ );
238
+ function isForwardSubject(subject) {
239
+ return FORWARD_SUBJECT.test(subject.slice(0, 500).normalize("NFC"));
240
+ }
241
+ function cutText(s, n) {
242
+ if (s.length <= n) return s;
243
+ const c = s.charCodeAt(n - 1);
244
+ return s.slice(0, c >= 55296 && c <= 56319 ? n - 1 : n);
245
+ }
246
+ function toMailTextEntry(m, maxChars) {
247
+ const subject = m.subject ?? "";
248
+ const keepQuoted = isForwardSubject(subject);
249
+ let raw = m.body?.content ?? "";
250
+ let truncated = false;
251
+ if (raw.length > MAIL_TEXT_MAX_INPUT_CHARS) {
252
+ raw = cutText(raw, MAIL_TEXT_MAX_INPUT_CHARS);
253
+ truncated = true;
254
+ }
255
+ let text = cleanMailText(raw, { keepQuoted });
256
+ const chars = text.length;
257
+ if (maxChars && chars > maxChars) {
258
+ text = cutText(text, maxChars);
259
+ truncated = true;
260
+ }
261
+ return {
262
+ id: m.id ?? "",
263
+ from: formatAddress(m.from),
264
+ to: (m.toRecipients ?? []).map(formatAddress),
265
+ cc: (m.ccRecipients ?? []).map(formatAddress),
266
+ subject,
267
+ received: m.receivedDateTime,
268
+ hasAttachments: !!m.hasAttachments,
269
+ text,
270
+ chars,
271
+ truncated
272
+ };
273
+ }
274
+ function oversizeError(entries, size, limit) {
275
+ const fmt = (n) => n.toLocaleString("en-US");
276
+ const largest = entries.filter((e) => "text" in e).sort((a, b) => b.text.length - a.text.length).slice(0, 5).map((e) => `"${(e.subject || "(no subject)").slice(0, 60)}" ${fmt(e.text.length)}`).join(", ");
277
+ return `Response would be ${fmt(size)} characters (limit ${fmt(limit)}). Largest: ${largest}. Narrow the query (fewer messages via top, a filter such as a date range or sender, specific messageIds) or set a smaller maxChars.`;
278
+ }
279
+ export {
280
+ MAIL_TEXT_DEFAULT_MAX_CHARS,
281
+ MAIL_TEXT_DEFAULT_MAX_RESPONSE_CHARS,
282
+ MAIL_TEXT_MAX_IDS,
283
+ MAIL_TEXT_MAX_INPUT_CHARS,
284
+ MAIL_TEXT_MAX_TOP,
285
+ MAIL_TEXT_SELECT,
286
+ PREFER_TEXT_BODY,
287
+ cleanMailText,
288
+ cutText,
289
+ isForwardSubject,
290
+ oversizeError,
291
+ toMailTextEntry,
292
+ validateMailTextArgs
293
+ };
@@ -1,10 +1,23 @@
1
1
  import logger from "../logger.js";
2
- const TOP_UNSUPPORTED_DELTA_TOOLS = /* @__PURE__ */ new Set([
3
- "list-calendar-events-delta",
4
- "list-calendar-view-delta"
5
- ]);
6
- function shouldOmitTopParam(toolAlias) {
7
- return TOP_UNSUPPORTED_DELTA_TOOLS.has(toolAlias);
2
+ const SUPPORTED_QUERY_PARAMS = {
3
+ // https://learn.microsoft.com/en-us/graph/api/user-list-joinedteams
4
+ "list-joined-teams": [],
5
+ // https://learn.microsoft.com/en-us/graph/api/associatedteaminfo-list
6
+ "list-my-associated-teams": [],
7
+ // Documents only $top/$filter; $select is needed to opt in to image bytes.
8
+ // https://learn.microsoft.com/graph/api/teamworkmessaging-list-customemojis?view=graph-rest-beta
9
+ "list-custom-emojis": ["top", "filter", "select", "skiptoken"],
10
+ // https://learn.microsoft.com/en-us/graph/api/channel-list-messages
11
+ "list-channel-messages": ["top", "expand", "skiptoken"],
12
+ // https://learn.microsoft.com/en-us/graph/api/chatmessage-list-replies
13
+ "list-channel-message-replies": ["top", "skiptoken"],
14
+ // Delta rejects $select/$expand/$filter/$orderby/$search and documents no $top/$skip/$count;
15
+ // events delta takes no endDateTime. https://learn.microsoft.com/graph/api/event-delta?view=graph-rest-beta
16
+ "list-calendar-events-delta": ["startdatetime", "skiptoken"],
17
+ "list-calendar-view-delta": ["startdatetime", "enddatetime", "skiptoken"]
18
+ };
19
+ function isQueryParamSupported(toolAlias, bareName) {
20
+ return SUPPORTED_QUERY_PARAMS[toolAlias]?.includes(bareName) ?? true;
8
21
  }
9
22
  function paginationAllowed() {
10
23
  const raw = process.env.MS365_MCP_ALLOW_PAGINATION;
@@ -39,6 +52,8 @@ const SKIPTOKEN_PARAM_DESCRIPTION = "Next page: the @odata.nextLink from the pre
39
52
  const COLLECTION_QUERY_PARAMS = /* @__PURE__ */ new Set(["top", "filter", "orderby", "count"]);
40
53
  function isSkiptokenApplicable(tool, paramNames) {
41
54
  if (tool.method.toUpperCase() !== "GET") return false;
55
+ const supported = tool.alias ? SUPPORTED_QUERY_PARAMS[tool.alias] : void 0;
56
+ if (supported) return supported.includes("skiptoken");
42
57
  for (const name of paramNames) {
43
58
  if (COLLECTION_QUERY_PARAMS.has(name.replace(/^\$/, "").toLowerCase())) return true;
44
59
  }
@@ -95,15 +110,14 @@ export {
95
110
  SKIP_PARAM_DESCRIPTION,
96
111
  TIMEZONE_PARAM_DESCRIPTION,
97
112
  TOP_PARAM_DESCRIPTION,
98
- TOP_UNSUPPORTED_DELTA_TOOLS,
99
113
  getAcceptParamDescription,
100
114
  getAccountParamDescription,
101
115
  getFetchAllPagesParamDescription,
102
116
  getMaxPages,
103
117
  getODataParamDescription,
104
118
  isFetchAllPagesApplicable,
119
+ isQueryParamSupported,
105
120
  isSkiptokenApplicable,
106
121
  paginationAllowed,
107
- positiveIntFromEnv,
108
- shouldOmitTopParam
122
+ positiveIntFromEnv
109
123
  };
@@ -1,6 +1,5 @@
1
1
  import { z } from "zod";
2
- import { getODataParamDescription, shouldOmitTopParam } from "./param-descriptions.js";
3
- const TEAM_LIST_TOOLS = /* @__PURE__ */ new Set(["list-joined-teams", "list-my-associated-teams"]);
2
+ import { getODataParamDescription, isQueryParamSupported } from "./param-descriptions.js";
4
3
  const CUSTOM_EMOJI_QUERY_DESCRIPTIONS = {
5
4
  top: "Number of custom emojis to return in one page. Base64 image content is opt-in with select; use a small page size when selecting contentBytes.",
6
5
  filter: "OData filter expression for custom emojis, forwarded to Microsoft Graph. Filter support is determined by the beta API.",
@@ -8,11 +7,7 @@ const CUSTOM_EMOJI_QUERY_DESCRIPTIONS = {
8
7
  };
9
8
  function queryParameterSchema(toolName, name, providerSchema) {
10
9
  const bareName = name.replace(/^\$/, "").toLowerCase();
11
- if (TEAM_LIST_TOOLS.has(toolName)) return void 0;
12
- if (toolName === "list-custom-emojis" && !["top", "filter", "select", "skiptoken"].includes(bareName)) {
13
- return void 0;
14
- }
15
- if (bareName === "top" && shouldOmitTopParam(toolName)) return void 0;
10
+ if (!isQueryParamSupported(toolName, bareName)) return void 0;
16
11
  const source = providerSchema instanceof z.ZodOptional ? providerSchema.unwrap() : providerSchema;
17
12
  let schema = source;
18
13
  switch (bareName) {
@@ -58,7 +58,7 @@ function describeToolSchema(tool, config, ctx = {}) {
58
58
  });
59
59
  }
60
60
  if (isSkiptokenApplicable(
61
- { method: tool.method },
61
+ { method: tool.method, alias: tool.alias },
62
62
  params.map((p) => p.name)
63
63
  )) {
64
64
  params.push({
@@ -64,7 +64,8 @@ const SCOPED_UTILITY_TOOLS = {
64
64
  "get-download-url": ["files", "onedrive", "personal", "work", "search"],
65
65
  "get-upload-url": ["mail", "calendar", "outlook", "personal", "work"],
66
66
  "upload-attachment-from-file": ["mail", "calendar", "outlook", "personal", "work"],
67
- "parse-teams-url": ["teams", "teams-write", "work"]
67
+ "parse-teams-url": ["teams", "teams-write", "work"],
68
+ "read-mail-text": ["mail", "outlook", "personal", "work"]
68
69
  };
69
70
  for (const [tool, presets] of Object.entries(SCOPED_UTILITY_TOOLS)) {
70
71
  for (const preset of presets) {
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.168.0",
4
+ "version": "0.169.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",
@@ -2543,7 +2543,7 @@
2543
2543
  "toolName": "list-calendar-events-delta",
2544
2544
  "presets": ["calendar", "outlook", "personal"],
2545
2545
  "scopes": ["Calendars.Read"],
2546
- "llmTip": "Incremental sync of events across the default calendar. First call returns all events plus @odata.deltaLink. Subsequent calls with that link return only additions/updates/removals. Use $select to limit fields. Deltas expire after ~30 days — start over if the server returns 410 Gone. For a time-bounded view with delta semantics, use list-calendar-view-delta instead."
2546
+ "llmTip": "Incremental sync of events across the default calendar. First call returns all events plus @odata.deltaLink. Subsequent calls with that link return only additions/updates/removals. Deltas expire after ~30 days — start over if the server returns 410 Gone. For a time-bounded view with delta semantics, use list-calendar-view-delta instead."
2547
2547
  },
2548
2548
  {
2549
2549
  "pathPattern": "/me/calendarView/delta()",