@softeria/ms-365-mcp-server 0.141.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
@@ -539,9 +539,9 @@ npx @softeria/ms-365-mcp-server --preset mail
539
539
  npx @softeria/ms-365-mcp-server --list-presets # See all available presets
540
540
  ```
541
541
 
542
- Available presets: `mail`, `calendar`, `files`, `personal`, `work`, `excel`, `contacts`, `tasks`, `onenote`, `search`, `users`, `outlook`, `onedrive`, `teams`, `all`
542
+ Available presets: `mail`, `calendar`, `files`, `personal`, `work`, `excel`, `contacts`, `tasks`, `onenote`, `search`, `users`, `outlook`, `onedrive`, `teams`, `teams-write`, `all`
543
543
 
544
- Each endpoint in `endpoints.json` declares which presets it belongs to via a `presets` array, so every preset is an exact tool-name allow-list that never over-matches across apps (e.g. `mail` does not include shared-mailbox tools; those are in `work`). The universal binary reader `download-bytes` is included in every preset, so whatever an app returns (a file, an attachment, a photo, a recording) can always be fetched; `get-download-url` (a pre-authenticated URL for drive/SharePoint files) rides with the drive-backed presets. So a preset that can find a file can always read its bytes.
544
+ Each endpoint in `endpoints.json` declares which presets it belongs to via a `presets` array, so every preset is an exact tool-name allow-list that never over-matches across apps (e.g. `mail` does not include shared-mailbox tools; those are in `work`). The universal binary reader `download-bytes` is included in every preset except `teams-write`, so whatever an app returns (a file, an attachment, a photo, a recording) can always be fetched; `get-download-url` (a pre-authenticated URL for drive/SharePoint files) rides with the drive-backed presets. So a preset that can find a file can always read its bytes.
545
545
 
546
546
  The `outlook`, `onedrive` and `teams` presets are app-scoped: they expose exactly one Microsoft app. Use these for "expose exactly one app" deployments:
547
547
 
@@ -553,6 +553,12 @@ npx @softeria/ms-365-mcp-server --preset outlook
553
553
  npx @softeria/ms-365-mcp-server --org-mode --preset teams
554
554
  ```
555
555
 
556
+ The `teams-write` preset is the send-only counterpart to `--read-only`: send in chats, send/reply in channels, list chats/teams/channels by name, and activity notifications - no message reading and no byte downloaders. The requested token is minimal by construction (`Chat.ReadBasic`, the `*.Send` scopes, and basic team/channel listing - nothing that can read message content):
557
+
558
+ ```bash
559
+ npx @softeria/ms-365-mcp-server --org-mode --preset teams-write
560
+ ```
561
+
556
562
  ## Dynamic Tool Discovery
557
563
 
558
564
  Instead of loading every tool upfront, use dynamic discovery so the LLM finds and loads tools only when it needs them:
@@ -613,6 +619,8 @@ Environment variables:
613
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.
614
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).
615
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)
616
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`)
617
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
618
626
  - `MS365_MCP_CLOUD_TYPE=global|china`: Microsoft cloud environment (alternative to --cloud flag)
@@ -768,6 +776,14 @@ The Key Vault integration uses `DefaultAzureCredential` from the Azure Identity
768
776
 
769
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.
770
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
+
771
787
  ## Production Deployment
772
788
 
773
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/auth.js CHANGED
@@ -62,6 +62,8 @@ function buildDiskCoherencyCachePlugin(storage) {
62
62
  };
63
63
  }
64
64
  const SCOPE_HIERARCHY = {
65
+ "Chat.ReadWrite": ["Chat.Read", "Chat.ReadBasic"],
66
+ "Chat.Read": ["Chat.ReadBasic"],
65
67
  "Mail.ReadWrite": ["Mail.Read"],
66
68
  "Calendars.ReadWrite": ["Calendars.Read"],
67
69
  "Files.ReadWrite": ["Files.Read"],
@@ -137,7 +139,7 @@ function getEndpointEffectiveLoginScopes(scopeGroups, allowedScopes) {
137
139
  function collapseRedundantScopes(scopes) {
138
140
  const scopesSet = new Set(scopes);
139
141
  Object.entries(SCOPE_HIERARCHY).forEach(([higherScope, lowerScopes]) => {
140
- if (scopesSet.has(higherScope) && lowerScopes.every((scope) => scopesSet.has(scope))) {
142
+ if (scopesSet.has(higherScope)) {
141
143
  lowerScopes.forEach((scope) => scopesSet.delete(scope));
142
144
  }
143
145
  });
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(
@@ -31,7 +38,7 @@ program.name("ms-365-mcp-server").description("Microsoft 365 MCP Server").versio
31
38
  "Append additional Graph scopes (whitespace-separated) to the token request, beyond those derived from enabled tools. Use with your own app registration (MS365_MCP_CLIENT_ID/SECRET) to request scopes the default app does not declare, then call the endpoints via graph-batch."
32
39
  ).option(
33
40
  "--preset <names>",
34
- "Use preset tool categories (comma-separated). Available: mail, calendar, files, personal, work, excel, contacts, tasks, onenote, search, users, all"
41
+ "Use preset tool categories (comma-separated). Available: mail, calendar, files, personal, work, excel, contacts, tasks, onenote, search, users, outlook, onedrive, teams, teams-write, all"
35
42
  ).option("--list-presets", "List all available presets and exit").option("--list-permissions", "List all required Graph API permissions and exit").option(
36
43
  "--org-mode",
37
44
  "Enable organization/work mode from start (includes Teams, SharePoint, etc.)"
@@ -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));
@@ -1559,15 +1559,15 @@
1559
1559
  "pathPattern": "/me/chats",
1560
1560
  "method": "get",
1561
1561
  "toolName": "list-chats",
1562
- "presets": ["teams", "work"],
1563
- "workScopes": [["Chat.Read"], ["Chat.ReadBasic"]]
1562
+ "presets": ["teams", "teams-write", "work"],
1563
+ "workScopes": ["Chat.ReadBasic"]
1564
1564
  },
1565
1565
  {
1566
1566
  "pathPattern": "/chats/{chat-id}",
1567
1567
  "method": "get",
1568
1568
  "toolName": "get-chat",
1569
- "presets": ["teams", "work"],
1570
- "workScopes": [["Chat.Read"], ["Chat.ReadBasic"]]
1569
+ "presets": ["teams", "teams-write", "work"],
1570
+ "workScopes": ["Chat.ReadBasic"]
1571
1571
  },
1572
1572
  {
1573
1573
  "pathPattern": "/chats/{chat-id}/members",
@@ -1610,7 +1610,7 @@
1610
1610
  "pathPattern": "/chats/{chat-id}/messages",
1611
1611
  "method": "post",
1612
1612
  "toolName": "send-chat-message",
1613
- "presets": ["teams", "work"],
1613
+ "presets": ["teams", "teams-write", "work"],
1614
1614
  "workScopes": ["ChatMessage.Send"],
1615
1615
  "llmTip": "Use contentType 'html' in the body — plain text contentType gets mangled by Graph API. To @mention someone, put an <at id=\"0\">Name</at> tag in the html content and add a mentions array to the request body, as a sibling of the body property (not a separate top-level tool parameter): { body: { contentType: 'html', content: 'Hi <at id=\"0\">Name</at>' }, mentions: [{ id: 0, mentionText: 'Name', mentioned: { user: { id: '<aad-user-id>', displayName: 'Name', userIdentityType: 'aadUser' } } }] }. Each mention id must match its <at id> value; include userIdentityType: 'aadUser' or Graph returns 400 'value without a type name'."
1616
1616
  },
@@ -1618,28 +1618,28 @@
1618
1618
  "pathPattern": "/me/joinedTeams",
1619
1619
  "method": "get",
1620
1620
  "toolName": "list-joined-teams",
1621
- "presets": ["teams", "work"],
1621
+ "presets": ["teams", "teams-write", "work"],
1622
1622
  "workScopes": ["Team.ReadBasic.All"]
1623
1623
  },
1624
1624
  {
1625
1625
  "pathPattern": "/teams/{team-id}",
1626
1626
  "method": "get",
1627
1627
  "toolName": "get-team",
1628
- "presets": ["teams", "work"],
1628
+ "presets": ["teams", "teams-write", "work"],
1629
1629
  "workScopes": ["Team.ReadBasic.All"]
1630
1630
  },
1631
1631
  {
1632
1632
  "pathPattern": "/teams/{team-id}/channels",
1633
1633
  "method": "get",
1634
1634
  "toolName": "list-team-channels",
1635
- "presets": ["teams", "work"],
1635
+ "presets": ["teams", "teams-write", "work"],
1636
1636
  "workScopes": ["Channel.ReadBasic.All"]
1637
1637
  },
1638
1638
  {
1639
1639
  "pathPattern": "/teams/{team-id}/channels/{channel-id}",
1640
1640
  "method": "get",
1641
1641
  "toolName": "get-team-channel",
1642
- "presets": ["teams", "work"],
1642
+ "presets": ["teams", "teams-write", "work"],
1643
1643
  "workScopes": ["Channel.ReadBasic.All"]
1644
1644
  },
1645
1645
  {
@@ -1696,7 +1696,7 @@
1696
1696
  "pathPattern": "/teams/{team-id}/channels/{channel-id}/messages",
1697
1697
  "method": "post",
1698
1698
  "toolName": "send-channel-message",
1699
- "presets": ["teams", "work"],
1699
+ "presets": ["teams", "teams-write", "work"],
1700
1700
  "workScopes": ["ChannelMessage.Send"],
1701
1701
  "llmTip": "Use contentType 'html' in the body — plain text contentType gets mangled by Graph API. To @mention someone, put an <at id=\"0\">Name</at> tag in the html content and add a mentions array to the request body, as a sibling of the body property (not a separate top-level tool parameter): { body: { contentType: 'html', content: 'Hi <at id=\"0\">Name</at>' }, mentions: [{ id: 0, mentionText: 'Name', mentioned: { user: { id: '<aad-user-id>', displayName: 'Name', userIdentityType: 'aadUser' } } }] }. Each mention id must match its <at id> value; include userIdentityType: 'aadUser' or Graph returns 400 'value without a type name'."
1702
1702
  },
@@ -1704,7 +1704,7 @@
1704
1704
  "pathPattern": "/teams/{team-id}/channels/{channel-id}/messages/{chatMessage-id}/replies",
1705
1705
  "method": "post",
1706
1706
  "toolName": "reply-to-channel-message",
1707
- "presets": ["teams", "work"],
1707
+ "presets": ["teams", "teams-write", "work"],
1708
1708
  "workScopes": ["ChannelMessage.Send"],
1709
1709
  "llmTip": "Use contentType 'html' in the body — plain text contentType gets mangled by Graph API. To @mention someone, put an <at id=\"0\">Name</at> tag in the html content and add a mentions array to the request body, as a sibling of the body property (not a separate top-level tool parameter): { body: { contentType: 'html', content: 'Hi <at id=\"0\">Name</at>' }, mentions: [{ id: 0, mentionText: 'Name', mentioned: { user: { id: '<aad-user-id>', displayName: 'Name', userIdentityType: 'aadUser' } } }] }. Each mention id must match its <at id> value; include userIdentityType: 'aadUser' or Graph returns 400 'value without a type name'."
1710
1710
  },
@@ -2726,7 +2726,7 @@
2726
2726
  "pathPattern": "/me/teamwork/sendActivityNotification",
2727
2727
  "method": "post",
2728
2728
  "toolName": "send-my-activity-notification",
2729
- "presets": ["teams", "work"],
2729
+ "presets": ["teams", "teams-write", "work"],
2730
2730
  "workScopes": ["TeamsActivity.Send"],
2731
2731
  "contentType": "application/json",
2732
2732
  "llmTip": "Sends a Teams activity feed notification to the current user (the badge + entry in their Activity tab). Body: { topic: { source: 'entityUrl' | 'text', value: <Graph URL or plain text>, webUrl: <required when source='text', click-through URL> }, activityType: <must be declared in the calling Teams app's manifest, OR the reserved 'systemDefault' which provides free-form Actor+Reason text>, previewText: { content: 'short preview' }, templateParameters?: [{ name, value }] (substituted into the manifest's localized notification template), teamsAppId?: <optional disambiguator when multiple installed apps share a Microsoft Entra app ID — fetch via list-my-installed-teams-apps>, chainId?, iconId? }. Use to ping the user with 'action required' notifications from agentic workflows. See https://learn.microsoft.com/graph/teams-send-activityfeednotifications."
@@ -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
+ };
@@ -51,12 +51,18 @@ const PRESET_META = {
51
51
  teams: {
52
52
  description: "Teams app only: chats, channels, meetings and presence",
53
53
  requiresOrgMode: true
54
+ },
55
+ "teams-write": {
56
+ description: "Teams send-only: send/reply in chats and channels, list chats/teams/channels by name, activity notifications - no message reading",
57
+ requiresOrgMode: true,
58
+ // A write-only preset must not include the generic byte readers.
59
+ omitUniversalUtilities: true
54
60
  }
55
61
  };
56
62
  const UNIVERSAL_UTILITY_TOOLS = ["download-bytes", "download-bytes-to-file"];
57
63
  const SCOPED_UTILITY_TOOLS = {
58
64
  "get-download-url": ["files", "onedrive", "personal", "work", "search"],
59
- "parse-teams-url": ["teams", "work"]
65
+ "parse-teams-url": ["teams", "teams-write", "work"]
60
66
  };
61
67
  for (const [tool, presets] of Object.entries(SCOPED_UTILITY_TOOLS)) {
62
68
  for (const preset of presets) {
@@ -76,7 +82,7 @@ function presetPattern(preset) {
76
82
  }
77
83
  const names = [
78
84
  ...endpointNames,
79
- ...UNIVERSAL_UTILITY_TOOLS,
85
+ ...PRESET_META[preset]?.omitUniversalUtilities ? [] : UNIVERSAL_UTILITY_TOOLS,
80
86
  ...Object.entries(SCOPED_UTILITY_TOOLS).filter(([, presets]) => presets.includes(preset)).map(([name]) => name)
81
87
  ];
82
88
  return new RegExp(`^(?:${names.join("|")})$`);
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.141.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"
@@ -1559,15 +1559,15 @@
1559
1559
  "pathPattern": "/me/chats",
1560
1560
  "method": "get",
1561
1561
  "toolName": "list-chats",
1562
- "presets": ["teams", "work"],
1563
- "workScopes": [["Chat.Read"], ["Chat.ReadBasic"]]
1562
+ "presets": ["teams", "teams-write", "work"],
1563
+ "workScopes": ["Chat.ReadBasic"]
1564
1564
  },
1565
1565
  {
1566
1566
  "pathPattern": "/chats/{chat-id}",
1567
1567
  "method": "get",
1568
1568
  "toolName": "get-chat",
1569
- "presets": ["teams", "work"],
1570
- "workScopes": [["Chat.Read"], ["Chat.ReadBasic"]]
1569
+ "presets": ["teams", "teams-write", "work"],
1570
+ "workScopes": ["Chat.ReadBasic"]
1571
1571
  },
1572
1572
  {
1573
1573
  "pathPattern": "/chats/{chat-id}/members",
@@ -1610,7 +1610,7 @@
1610
1610
  "pathPattern": "/chats/{chat-id}/messages",
1611
1611
  "method": "post",
1612
1612
  "toolName": "send-chat-message",
1613
- "presets": ["teams", "work"],
1613
+ "presets": ["teams", "teams-write", "work"],
1614
1614
  "workScopes": ["ChatMessage.Send"],
1615
1615
  "llmTip": "Use contentType 'html' in the body — plain text contentType gets mangled by Graph API. To @mention someone, put an <at id=\"0\">Name</at> tag in the html content and add a mentions array to the request body, as a sibling of the body property (not a separate top-level tool parameter): { body: { contentType: 'html', content: 'Hi <at id=\"0\">Name</at>' }, mentions: [{ id: 0, mentionText: 'Name', mentioned: { user: { id: '<aad-user-id>', displayName: 'Name', userIdentityType: 'aadUser' } } }] }. Each mention id must match its <at id> value; include userIdentityType: 'aadUser' or Graph returns 400 'value without a type name'."
1616
1616
  },
@@ -1618,28 +1618,28 @@
1618
1618
  "pathPattern": "/me/joinedTeams",
1619
1619
  "method": "get",
1620
1620
  "toolName": "list-joined-teams",
1621
- "presets": ["teams", "work"],
1621
+ "presets": ["teams", "teams-write", "work"],
1622
1622
  "workScopes": ["Team.ReadBasic.All"]
1623
1623
  },
1624
1624
  {
1625
1625
  "pathPattern": "/teams/{team-id}",
1626
1626
  "method": "get",
1627
1627
  "toolName": "get-team",
1628
- "presets": ["teams", "work"],
1628
+ "presets": ["teams", "teams-write", "work"],
1629
1629
  "workScopes": ["Team.ReadBasic.All"]
1630
1630
  },
1631
1631
  {
1632
1632
  "pathPattern": "/teams/{team-id}/channels",
1633
1633
  "method": "get",
1634
1634
  "toolName": "list-team-channels",
1635
- "presets": ["teams", "work"],
1635
+ "presets": ["teams", "teams-write", "work"],
1636
1636
  "workScopes": ["Channel.ReadBasic.All"]
1637
1637
  },
1638
1638
  {
1639
1639
  "pathPattern": "/teams/{team-id}/channels/{channel-id}",
1640
1640
  "method": "get",
1641
1641
  "toolName": "get-team-channel",
1642
- "presets": ["teams", "work"],
1642
+ "presets": ["teams", "teams-write", "work"],
1643
1643
  "workScopes": ["Channel.ReadBasic.All"]
1644
1644
  },
1645
1645
  {
@@ -1696,7 +1696,7 @@
1696
1696
  "pathPattern": "/teams/{team-id}/channels/{channel-id}/messages",
1697
1697
  "method": "post",
1698
1698
  "toolName": "send-channel-message",
1699
- "presets": ["teams", "work"],
1699
+ "presets": ["teams", "teams-write", "work"],
1700
1700
  "workScopes": ["ChannelMessage.Send"],
1701
1701
  "llmTip": "Use contentType 'html' in the body — plain text contentType gets mangled by Graph API. To @mention someone, put an <at id=\"0\">Name</at> tag in the html content and add a mentions array to the request body, as a sibling of the body property (not a separate top-level tool parameter): { body: { contentType: 'html', content: 'Hi <at id=\"0\">Name</at>' }, mentions: [{ id: 0, mentionText: 'Name', mentioned: { user: { id: '<aad-user-id>', displayName: 'Name', userIdentityType: 'aadUser' } } }] }. Each mention id must match its <at id> value; include userIdentityType: 'aadUser' or Graph returns 400 'value without a type name'."
1702
1702
  },
@@ -1704,7 +1704,7 @@
1704
1704
  "pathPattern": "/teams/{team-id}/channels/{channel-id}/messages/{chatMessage-id}/replies",
1705
1705
  "method": "post",
1706
1706
  "toolName": "reply-to-channel-message",
1707
- "presets": ["teams", "work"],
1707
+ "presets": ["teams", "teams-write", "work"],
1708
1708
  "workScopes": ["ChannelMessage.Send"],
1709
1709
  "llmTip": "Use contentType 'html' in the body — plain text contentType gets mangled by Graph API. To @mention someone, put an <at id=\"0\">Name</at> tag in the html content and add a mentions array to the request body, as a sibling of the body property (not a separate top-level tool parameter): { body: { contentType: 'html', content: 'Hi <at id=\"0\">Name</at>' }, mentions: [{ id: 0, mentionText: 'Name', mentioned: { user: { id: '<aad-user-id>', displayName: 'Name', userIdentityType: 'aadUser' } } }] }. Each mention id must match its <at id> value; include userIdentityType: 'aadUser' or Graph returns 400 'value without a type name'."
1710
1710
  },
@@ -2726,7 +2726,7 @@
2726
2726
  "pathPattern": "/me/teamwork/sendActivityNotification",
2727
2727
  "method": "post",
2728
2728
  "toolName": "send-my-activity-notification",
2729
- "presets": ["teams", "work"],
2729
+ "presets": ["teams", "teams-write", "work"],
2730
2730
  "workScopes": ["TeamsActivity.Send"],
2731
2731
  "contentType": "application/json",
2732
2732
  "llmTip": "Sends a Teams activity feed notification to the current user (the badge + entry in their Activity tab). Body: { topic: { source: 'entityUrl' | 'text', value: <Graph URL or plain text>, webUrl: <required when source='text', click-through URL> }, activityType: <must be declared in the calling Teams app's manifest, OR the reserved 'systemDefault' which provides free-form Actor+Reason text>, previewText: { content: 'short preview' }, templateParameters?: [{ name, value }] (substituted into the manifest's localized notification template), teamsAppId?: <optional disambiguator when multiple installed apps share a Microsoft Entra app ID — fetch via list-my-installed-teams-apps>, chainId?, iconId? }. Use to ping the user with 'action required' notifications from agentic workflows. See https://learn.microsoft.com/graph/teams-send-activityfeednotifications."