@softeria/ms-365-mcp-server 0.142.0 → 0.143.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
@@ -619,6 +619,8 @@ Environment variables:
619
619
  - `MS365_MCP_MAX_ITEMS=<n>`: Maximum number of items accumulated when `fetchAllPages: true` (positive integer, default `10000`). Pagination stops and the response is truncated once this many items are collected.
620
620
  - `MS365_MCP_ALLOW_PAGINATION=0|false|no`: Disable multi-page following entirely. When set, the `fetchAllPages` parameter is not advertised on tools, and any request that still passes it returns only the first page (default: pagination enabled).
621
621
  - `MS365_MCP_BODY_FORMAT=html`: Return email bodies as HTML instead of plain text (default: text)
622
+ - `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)
623
+ - `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)
622
624
  - `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`)
623
625
  - `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
624
626
  - `MS365_MCP_CLOUD_TYPE=global|china`: Microsoft cloud environment (alternative to --cloud flag)
@@ -774,6 +776,14 @@ The Key Vault integration uses `DefaultAzureCredential` from the Azure Identity
774
776
 
775
777
  The Azure Key Vault packages (`@azure/identity` and `@azure/keyvault-secrets`) are optional dependencies. They are only loaded when `MS365_MCP_KEYVAULT_URL` is configured. If you don't use Key Vault, these packages are not required.
776
778
 
779
+ ## Message Signoff
780
+
781
+ Outgoing messages can be wrapped in a configurable signoff (e.g. a `🤖` prefix) so recipients can tell agent-sent messages from ones you typed yourself. Off by default — enable it with `--message-signoff-prefix` / `--message-signoff-suffix` (env: `MS365_MCP_MESSAGE_SIGNOFF_PREFIX` / `MS365_MCP_MESSAGE_SIGNOFF_SUFFIX`); `--no-message-signoff` or an empty env value turns it back off.
782
+
783
+ Once configured, it applies to all Teams messages (sends, replies and edits, including via `graph-batch`), to direct mail sends (`send-mail`, reply/forward, their shared-mailbox variants, and group thread replies), and to mail drafts as their content is written — `send-draft-message` sends a draft as-is, so a draft you wrote yourself goes out untouched. A message that already carries the marker is not signed twice, and a send whose body cannot take the signoff is refused rather than sent unsigned.
784
+
785
+ Markers may contain markup (e.g. a coloured `<span>`) as long as it renders visible text. Note that the signoff is a guardrail against an agent misusing the tools it was given, not a hard security boundary — an agent with shell access on the same machine could simply restart the server without it.
786
+
777
787
  ## Production Deployment
778
788
 
779
789
  See [docs/deployment.md](docs/deployment.md) for a full guide to hosting the server for organization-wide access, including Docker, Azure Container Apps, Azure App Service, Azure AD app registration, reverse proxy setup, client configuration, and exposed endpoints.
package/dist/cli.js CHANGED
@@ -3,6 +3,7 @@ import { readFileSync } from "fs";
3
3
  import path from "path";
4
4
  import { fileURLToPath } from "url";
5
5
  import { getCombinedPresetPattern, listPresets, presetRequiresOrgMode } from "./tool-categories.js";
6
+ import { assertSignoffMarkersVisible } from "./lib/message-signoff.js";
6
7
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
8
  const packageJsonPath = path.join(__dirname, "..", "package.json");
8
9
  const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
@@ -15,6 +16,12 @@ program.name("ms-365-mcp-server").description("Microsoft 365 MCP Server").versio
15
16
  "--expected-home-account-id <id>",
16
17
  "Require local MSAL authentication to use this exact MSAL homeAccountId"
17
18
  ).option("--read-only", "Start server in read-only mode, disabling write operations").option(
19
+ "--message-signoff-prefix <text>",
20
+ "Signoff prepended to outgoing messages (Teams sends, replies and edits; mail sends and drafts) so recipients can tell they were agent-sent, e.g. \u{1F916} (default: none). Equivalent env var: MS365_MCP_MESSAGE_SIGNOFF_PREFIX."
21
+ ).option(
22
+ "--message-signoff-suffix <text>",
23
+ "Signoff appended to outgoing messages (default: none). Equivalent env var: MS365_MCP_MESSAGE_SIGNOFF_SUFFIX."
24
+ ).option("--no-message-signoff", "Disable both message signoffs, overriding the env vars.").option(
18
25
  "--http [address]",
19
26
  'Use Streamable HTTP transport instead of stdio. Format: [host:]port (e.g., "localhost:3000", ":3000", "3000"). Default: all interfaces on port 3000'
20
27
  ).option(
@@ -65,6 +72,17 @@ program.name("ms-365-mcp-server").description("Microsoft 365 MCP Server").versio
65
72
  function parseArgs() {
66
73
  program.parse();
67
74
  const options = program.opts();
75
+ if (typeof options.messageSignoffSuffix === "string") {
76
+ process.env.MS365_MCP_MESSAGE_SIGNOFF_SUFFIX = options.messageSignoffSuffix;
77
+ }
78
+ if (typeof options.messageSignoffPrefix === "string") {
79
+ process.env.MS365_MCP_MESSAGE_SIGNOFF_PREFIX = options.messageSignoffPrefix;
80
+ }
81
+ if (options.messageSignoff === false) {
82
+ process.env.MS365_MCP_MESSAGE_SIGNOFF_SUFFIX = "";
83
+ process.env.MS365_MCP_MESSAGE_SIGNOFF_PREFIX = "";
84
+ }
85
+ assertSignoffMarkersVisible();
68
86
  if (options.listPresets) {
69
87
  const presets = listPresets();
70
88
  console.log(JSON.stringify({ presets }, null, 2));
@@ -7,6 +7,7 @@ import {
7
7
  getSharedBreaker,
8
8
  loadResilienceConfig
9
9
  } from "./lib/graph-resilience.js";
10
+ import { applyMessageSignoffToRequest } from "./lib/message-signoff.js";
10
11
  import { open, stat, unlink } from "fs/promises";
11
12
  import { pipeline } from "stream/promises";
12
13
  function isBinaryContentType(contentType) {
@@ -166,6 +167,8 @@ class GraphClient {
166
167
  const apiVersion = options.apiVersion || "v1.0";
167
168
  const url = `${cloudEndpoints.graphApi}/${apiVersion}${endpoint}`;
168
169
  logger.info(`[GRAPH CLIENT] Final URL being sent to Microsoft: ${url}`);
170
+ const method = options.method || "GET";
171
+ const body = applyMessageSignoffToRequest(method, endpoint, options.body);
169
172
  const headers = {
170
173
  Authorization: `Bearer ${accessToken}`,
171
174
  "Content-Type": "application/json",
@@ -174,10 +177,10 @@ class GraphClient {
174
177
  return fetchWithResilience(
175
178
  url,
176
179
  {
177
- method: options.method || "GET",
180
+ method,
178
181
  headers,
179
182
  // Node's fetch accepts Buffer/Uint8Array; TS BodyInit doesn't.
180
- body: options.body
183
+ body
181
184
  },
182
185
  loadResilienceConfig(),
183
186
  getSharedBreaker()
@@ -0,0 +1,230 @@
1
+ import { parseFragment, serialize } from "parse5";
2
+ class MessageSignoffError extends Error {
3
+ constructor(target, reason) {
4
+ super(
5
+ `${target}: a message signoff is configured but could not be applied - ${reason}. Provide the message as { body: { contentType, content } }, or disable the signoff (--no-message-signoff / unset the MS365_MCP_MESSAGE_SIGNOFF_* env vars).`
6
+ );
7
+ this.name = "MessageSignoffError";
8
+ }
9
+ }
10
+ function resolveEnvText(name, defaultValue) {
11
+ const raw = process.env[name];
12
+ if (raw === void 0) {
13
+ return defaultValue;
14
+ }
15
+ const trimmed = raw.trim();
16
+ return trimmed === "" ? void 0 : trimmed;
17
+ }
18
+ function resolveMessageSignoffPrefix() {
19
+ return resolveEnvText("MS365_MCP_MESSAGE_SIGNOFF_PREFIX");
20
+ }
21
+ function resolveMessageSignoffSuffix() {
22
+ return resolveEnvText("MS365_MCP_MESSAGE_SIGNOFF_SUFFIX");
23
+ }
24
+ function renderedText(html) {
25
+ const collect = (node) => node.nodeName === "#text" ? node.value ?? "" : (node.childNodes ?? []).map(collect).join("");
26
+ return collect(parseFragment(html));
27
+ }
28
+ function assertSignoffMarkersVisible() {
29
+ for (const [name, marker] of [
30
+ ["MS365_MCP_MESSAGE_SIGNOFF_PREFIX", resolveMessageSignoffPrefix()],
31
+ ["MS365_MCP_MESSAGE_SIGNOFF_SUFFIX", resolveMessageSignoffSuffix()]
32
+ ]) {
33
+ if (marker === void 0 || !marker.includes("<")) continue;
34
+ if (renderedText(marker).trim() === "") {
35
+ throw new Error(
36
+ `${name} ("${marker}") renders as empty text in html messages, so the signoff would be invisible. Put visible text inside the markup (e.g. <span style="color:gray">\u{1F916}</span>) or use plain text.`
37
+ );
38
+ }
39
+ }
40
+ }
41
+ function markerAlreadyAt(content, marker, isHtml, at) {
42
+ const text = isHtml ? renderedText(content) : content;
43
+ const markerText = (isHtml ? renderedText(marker) : marker).trim();
44
+ if (markerText === "") return false;
45
+ return at === "start" ? text.trimStart().startsWith(markerText) : text.trimEnd().endsWith(markerText);
46
+ }
47
+ function signContent(content, isHtml, prefix, suffix) {
48
+ let signed = content;
49
+ if (suffix !== void 0 && !markerAlreadyAt(signed, suffix, isHtml, "end")) {
50
+ signed = `${isHtml ? serialize(parseFragment(signed)) : signed} ${suffix}`;
51
+ }
52
+ if (prefix !== void 0 && !markerAlreadyAt(signed, prefix, isHtml, "start")) {
53
+ signed = `${prefix} ${signed}`;
54
+ }
55
+ return signed;
56
+ }
57
+ function isPlainObject(value) {
58
+ return typeof value === "object" && value !== null && !Array.isArray(value);
59
+ }
60
+ function signItemBodyContainer(target, payload, prefix, suffix) {
61
+ const message = payload.body;
62
+ if (!isPlainObject(message)) {
63
+ throw new MessageSignoffError(target, "the body has no nested body (itemBody) object");
64
+ }
65
+ const content = message.content;
66
+ if (typeof content !== "string") {
67
+ throw new MessageSignoffError(target, "the body has no content string");
68
+ }
69
+ const contentType = message.contentType;
70
+ const isHtml = typeof contentType === "string" && contentType.toLowerCase() === "html";
71
+ return {
72
+ ...payload,
73
+ body: { ...message, content: signContent(content, isHtml, prefix, suffix) }
74
+ };
75
+ }
76
+ function signMessageSend(target, payload, prefix, suffix) {
77
+ if (!isPlainObject(payload)) {
78
+ throw new MessageSignoffError(target, "the body is not a chatMessage object");
79
+ }
80
+ return signItemBodyContainer(target, payload, prefix, suffix);
81
+ }
82
+ function signWhenBodyPresent(target, payload, prefix, suffix) {
83
+ if (!isPlainObject(payload) || payload.body === void 0) return payload;
84
+ return signItemBodyContainer(target, payload, prefix, suffix);
85
+ }
86
+ function signReplyForwardDraft(target, payload, prefix, suffix) {
87
+ if (!isPlainObject(payload)) return payload;
88
+ let signed = payload;
89
+ if (typeof signed.comment === "string" && signed.comment.trim() !== "") {
90
+ signed = { ...signed, comment: signContent(signed.comment, false, prefix, suffix) };
91
+ }
92
+ const message = signed.message;
93
+ if (isPlainObject(message) && message.body !== void 0) {
94
+ signed = { ...signed, message: signItemBodyContainer(target, message, prefix, suffix) };
95
+ }
96
+ return signed;
97
+ }
98
+ function signSendMail(target, payload, prefix, suffix) {
99
+ if (!isPlainObject(payload) || !isPlainObject(payload.message)) {
100
+ throw new MessageSignoffError(target, "the body has no message object");
101
+ }
102
+ return { ...payload, message: signItemBodyContainer(target, payload.message, prefix, suffix) };
103
+ }
104
+ function signGroupThreadReply(target, payload, prefix, suffix) {
105
+ if (!isPlainObject(payload) || !isPlainObject(payload.post)) {
106
+ throw new MessageSignoffError(target, "the body has no post object");
107
+ }
108
+ return { ...payload, post: signItemBodyContainer(target, payload.post, prefix, suffix) };
109
+ }
110
+ const SIGNOFF_RULES = [
111
+ // Teams sends and replies
112
+ { method: "POST", pattern: /^\/chats\/[^/]+\/messages$/, apply: signMessageSend },
113
+ { method: "POST", pattern: /^\/chats\/[^/]+\/messages\/[^/]+\/replies$/, apply: signMessageSend },
114
+ {
115
+ method: "POST",
116
+ pattern: /^\/teams\/[^/]+\/channels\/[^/]+\/messages$/,
117
+ apply: signMessageSend
118
+ },
119
+ {
120
+ method: "POST",
121
+ pattern: /^\/teams\/[^/]+\/channels\/[^/]+\/messages\/[^/]+\/replies$/,
122
+ apply: signMessageSend
123
+ },
124
+ // Teams edits - a PATCH rewriting body.content must keep the signoff
125
+ { method: "PATCH", pattern: /^\/chats\/[^/]+\/messages\/[^/]+$/, apply: signWhenBodyPresent },
126
+ {
127
+ method: "PATCH",
128
+ pattern: /^\/teams\/[^/]+\/channels\/[^/]+\/messages\/[^/]+(?:\/replies\/[^/]+)?$/,
129
+ apply: signWhenBodyPresent
130
+ },
131
+ // Mail drafts - send-draft-message (POST .../send) carries no body to sign,
132
+ // so draft content is signed where it is written instead
133
+ {
134
+ method: "POST",
135
+ pattern: /^\/(?:me|users\/[^/]+)(?:\/mailFolders\/[^/]+)?\/messages$/,
136
+ apply: signWhenBodyPresent
137
+ },
138
+ {
139
+ method: "PATCH",
140
+ pattern: /^\/(?:me|users\/[^/]+)(?:\/mailFolders\/[^/]+)?\/messages\/[^/]+$/,
141
+ apply: signWhenBodyPresent
142
+ },
143
+ {
144
+ method: "POST",
145
+ pattern: /^\/(?:me|users\/[^/]+)\/messages\/[^/]+\/create(?:Reply|ReplyAll|Forward)$/,
146
+ apply: signReplyForwardDraft
147
+ },
148
+ // Direct mail sends - straight out with no draft step, so they are signed
149
+ // (and fail closed) like a Teams send. Covers the /users/{id} shared-mailbox
150
+ // variants of each
151
+ {
152
+ method: "POST",
153
+ pattern: /^\/(?:me|users\/[^/]+)\/sendMail$/,
154
+ apply: signSendMail
155
+ },
156
+ {
157
+ method: "POST",
158
+ pattern: /^\/(?:me|users\/[^/]+)\/messages\/[^/]+\/(?:reply|replyAll|forward)$/,
159
+ apply: signReplyForwardDraft
160
+ },
161
+ {
162
+ method: "POST",
163
+ pattern: /^\/groups\/[^/]+\/threads\/[^/]+\/reply$/,
164
+ apply: signGroupThreadReply
165
+ }
166
+ ];
167
+ function normalizePath(path) {
168
+ let clean = path.split("?")[0];
169
+ clean = clean.replace(/^\/(?:v1\.0|beta)(?=\/)/, "");
170
+ if (!clean.startsWith("/")) clean = `/${clean}`;
171
+ if (clean.length > 1 && clean.endsWith("/")) clean = clean.slice(0, -1);
172
+ return clean;
173
+ }
174
+ function parseIfString(target, body) {
175
+ if (typeof body !== "string") return body;
176
+ try {
177
+ return JSON.parse(body);
178
+ } catch {
179
+ throw new MessageSignoffError(target, "the body is a string that is not valid JSON");
180
+ }
181
+ }
182
+ function signBatchPayload(payload, prefix, suffix) {
183
+ if (!isPlainObject(payload) || !Array.isArray(payload.requests)) {
184
+ return payload;
185
+ }
186
+ const requests = payload.requests.map((entry) => {
187
+ if (!isPlainObject(entry) || typeof entry.url !== "string") return entry;
188
+ const method = typeof entry.method === "string" ? entry.method.toUpperCase() : "";
189
+ const path = normalizePath(entry.url);
190
+ const target = `${method} ${path} (in $batch)`;
191
+ if (path === "/$batch") {
192
+ return {
193
+ ...entry,
194
+ body: signBatchPayload(parseIfString(target, entry.body), prefix, suffix)
195
+ };
196
+ }
197
+ const rule = SIGNOFF_RULES.find((r) => r.method === method && r.pattern.test(path));
198
+ if (!rule) return entry;
199
+ return {
200
+ ...entry,
201
+ body: rule.apply(target, parseIfString(target, entry.body), prefix, suffix)
202
+ };
203
+ });
204
+ return { ...payload, requests };
205
+ }
206
+ function applyMessageSignoffToRequest(method, path, body) {
207
+ const prefix = resolveMessageSignoffPrefix();
208
+ const suffix = resolveMessageSignoffSuffix();
209
+ if (prefix === void 0 && suffix === void 0) return body;
210
+ const upperMethod = method.toUpperCase();
211
+ const cleanPath = normalizePath(path);
212
+ const isBatch = upperMethod === "POST" && cleanPath === "/$batch";
213
+ const rule = isBatch ? void 0 : SIGNOFF_RULES.find((r) => r.method === upperMethod && r.pattern.test(cleanPath));
214
+ if (!isBatch && !rule) return body;
215
+ const target = `${upperMethod} ${cleanPath}`;
216
+ if (body !== void 0 && typeof body !== "string") {
217
+ throw new MessageSignoffError(target, "the body is binary, not a signable JSON payload");
218
+ }
219
+ const payload = parseIfString(target, body);
220
+ const signed = isBatch ? signBatchPayload(payload, prefix, suffix) : rule.apply(target, payload, prefix, suffix);
221
+ if (signed === payload) return body;
222
+ return JSON.stringify(signed);
223
+ }
224
+ export {
225
+ MessageSignoffError,
226
+ applyMessageSignoffToRequest,
227
+ assertSignoffMarkersVisible,
228
+ resolveMessageSignoffPrefix,
229
+ resolveMessageSignoffSuffix
230
+ };
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.142.0",
4
+ "version": "0.143.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",
@@ -44,6 +44,7 @@
44
44
  "helmet": "^8.1.0",
45
45
  "js-yaml": "^4.1.0",
46
46
  "open": "^11.0.0",
47
+ "parse5": "^8.0.1",
47
48
  "winston": "^3.17.0",
48
49
  "zod": "^3.24.2",
49
50
  "zod-to-json-schema": "^3.25.1"