@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 +18 -2
- package/dist/auth.js +3 -1
- package/dist/cli.js +19 -1
- package/dist/endpoints.json +12 -12
- package/dist/graph-client.js +5 -2
- package/dist/lib/message-signoff.js +230 -0
- package/dist/tool-categories.js +8 -2
- package/package.json +2 -1
- package/src/endpoints.json +12 -12
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
|
|
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)
|
|
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));
|
package/dist/endpoints.json
CHANGED
|
@@ -1559,15 +1559,15 @@
|
|
|
1559
1559
|
"pathPattern": "/me/chats",
|
|
1560
1560
|
"method": "get",
|
|
1561
1561
|
"toolName": "list-chats",
|
|
1562
|
-
"presets": ["teams", "work"],
|
|
1563
|
-
"workScopes": [
|
|
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": [
|
|
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."
|
package/dist/graph-client.js
CHANGED
|
@@ -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
|
|
180
|
+
method,
|
|
178
181
|
headers,
|
|
179
182
|
// Node's fetch accepts Buffer/Uint8Array; TS BodyInit doesn't.
|
|
180
|
-
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/dist/tool-categories.js
CHANGED
|
@@ -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.
|
|
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"
|
package/src/endpoints.json
CHANGED
|
@@ -1559,15 +1559,15 @@
|
|
|
1559
1559
|
"pathPattern": "/me/chats",
|
|
1560
1560
|
"method": "get",
|
|
1561
1561
|
"toolName": "list-chats",
|
|
1562
|
-
"presets": ["teams", "work"],
|
|
1563
|
-
"workScopes": [
|
|
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": [
|
|
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."
|