@softeria/ms-365-mcp-server 0.150.3 → 0.152.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 +172 -0
- package/dist/__tests__/graph-client.test.js +46 -0
- package/dist/__tests__/graph-tools.test.js +166 -0
- package/dist/attachment-route.js +112 -0
- package/dist/cli.js +15 -0
- package/dist/graph-client.js +59 -0
- package/dist/graph-tools.js +101 -2
- package/dist/lib/attachment-minting.js +15 -0
- package/dist/lib/attachment-tickets.js +89 -0
- package/dist/lib/attachment-url-config.js +104 -0
- package/dist/lib/url-signing.js +81 -0
- package/dist/server.js +249 -17
- package/docs/deployment.md +1 -1
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -25,11 +25,16 @@ import {
|
|
|
25
25
|
toOAuthErrorResponse
|
|
26
26
|
} from "./lib/microsoft-auth.js";
|
|
27
27
|
import { isAllowedRedirectUri, parseAllowlist } from "./lib/redirect-uri-validation.js";
|
|
28
|
+
import { loadAttachmentUrlConfig, ATTACHMENT_ROUTE } from "./lib/attachment-url-config.js";
|
|
29
|
+
import { AttachmentTicketStore } from "./lib/attachment-tickets.js";
|
|
30
|
+
import { configureAttachmentMinting } from "./lib/attachment-minting.js";
|
|
31
|
+
import { createAttachmentHandler } from "./attachment-route.js";
|
|
28
32
|
import { getSecrets } from "./secrets.js";
|
|
29
33
|
import { getCloudEndpoints } from "./cloud-config.js";
|
|
30
34
|
import { requestContext } from "./request-context.js";
|
|
31
35
|
import { dumpError } from "./crash-logging.js";
|
|
32
36
|
import crypto from "node:crypto";
|
|
37
|
+
import { isIP, isIPv6 } from "node:net";
|
|
33
38
|
import OboClient from "./obo-client.js";
|
|
34
39
|
function parseHttpOption(httpOption) {
|
|
35
40
|
if (typeof httpOption === "boolean") {
|
|
@@ -45,12 +50,78 @@ function parseHttpOption(httpOption) {
|
|
|
45
50
|
const port = parseInt(httpString) || 3e3;
|
|
46
51
|
return { host: void 0, port };
|
|
47
52
|
}
|
|
53
|
+
function parseAttachmentPortOption(value) {
|
|
54
|
+
if (value === void 0 || value === null || value === "") return null;
|
|
55
|
+
const raw = String(value).trim();
|
|
56
|
+
if (!/^\d+$/.test(raw)) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`--attachment-port / MS365_MCP_ATTACHMENT_PORT must be a port number between 1 and 65535, got ${JSON.stringify(String(value))}`
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
const port = Number(raw);
|
|
62
|
+
if (port < 1 || port > 65535) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`--attachment-port / MS365_MCP_ATTACHMENT_PORT must be a port number between 1 and 65535, got ${port}`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return port;
|
|
68
|
+
}
|
|
69
|
+
const HOSTNAME_LABEL = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/;
|
|
70
|
+
function isHostname(value) {
|
|
71
|
+
const name = value.endsWith(".") ? value.slice(0, -1) : value;
|
|
72
|
+
if (name.length === 0 || name.length > 253) return false;
|
|
73
|
+
return name.split(".").every((label) => label.length > 0 && label.length <= 63 && HOSTNAME_LABEL.test(label));
|
|
74
|
+
}
|
|
75
|
+
function parseAttachmentHostOption(value) {
|
|
76
|
+
if (value === void 0 || value === null || value === "") return null;
|
|
77
|
+
const raw = String(value).trim();
|
|
78
|
+
const reject = (reason) => {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`--attachment-host / MS365_MCP_ATTACHMENT_HOST must be a bare IPv4 address, IPv6 address or hostname (${reason}), got ${JSON.stringify(String(value))}`
|
|
81
|
+
);
|
|
82
|
+
};
|
|
83
|
+
if (raw === "") reject("it is empty");
|
|
84
|
+
if (raw.startsWith("[") || raw.endsWith("]")) {
|
|
85
|
+
if (!raw.startsWith("[") || !raw.endsWith("]")) reject("unbalanced brackets");
|
|
86
|
+
const inner = raw.slice(1, -1);
|
|
87
|
+
if (!isIPv6(inner)) reject("the brackets do not contain an IPv6 address");
|
|
88
|
+
return inner;
|
|
89
|
+
}
|
|
90
|
+
if (isIP(raw) !== 0) return raw;
|
|
91
|
+
if (isHostname(raw)) return raw;
|
|
92
|
+
if (raw.includes(":")) {
|
|
93
|
+
reject("this takes a host only -- the port goes on --attachment-port");
|
|
94
|
+
}
|
|
95
|
+
return reject("not a valid address or hostname");
|
|
96
|
+
}
|
|
97
|
+
function isWildcardAddress(address) {
|
|
98
|
+
return address === "0.0.0.0" || address === "::" || address === "";
|
|
99
|
+
}
|
|
100
|
+
function formatAuthority(address, port) {
|
|
101
|
+
return isIPv6(address) ? `[${address}]:${port}` : `${address}:${port}`;
|
|
102
|
+
}
|
|
103
|
+
function describeBoundAddress(bound) {
|
|
104
|
+
const authority = formatAuthority(bound.address, bound.port);
|
|
105
|
+
if (isWildcardAddress(bound.address)) {
|
|
106
|
+
return { label: `all interfaces (${authority})`, authority: `localhost:${bound.port}` };
|
|
107
|
+
}
|
|
108
|
+
return { label: authority, authority };
|
|
109
|
+
}
|
|
48
110
|
const PKCE_MAX_AGE_MS = 60 * 60 * 1e3;
|
|
49
111
|
class MicrosoftGraphServer {
|
|
50
112
|
constructor(authManager, options = {}) {
|
|
51
113
|
this.version = "0.0.0";
|
|
52
114
|
this.multiAccount = false;
|
|
53
115
|
this.accountNames = [];
|
|
116
|
+
/**
|
|
117
|
+
* Every HTTP listener `start()` opened, so `stop()` can close every one.
|
|
118
|
+
*
|
|
119
|
+
* A list rather than a field because `--attachment-port` makes it two, and an
|
|
120
|
+
* untracked listener cannot be closed at all: it holds the event loop open
|
|
121
|
+
* for the life of the process. One that is only *usually* two is worse than
|
|
122
|
+
* either, so nothing here special-cases the count.
|
|
123
|
+
*/
|
|
124
|
+
this.httpServers = [];
|
|
54
125
|
// Two-leg PKCE: stores client's code_challenge and server's code_verifier, keyed by OAuth state
|
|
55
126
|
this.pkceStore = /* @__PURE__ */ new Map();
|
|
56
127
|
this.authManager = authManager;
|
|
@@ -171,6 +242,28 @@ class MicrosoftGraphServer {
|
|
|
171
242
|
if (this.options.readOnly) {
|
|
172
243
|
logger.info("Server running in READ-ONLY mode. Write operations are disabled.");
|
|
173
244
|
}
|
|
245
|
+
if (this.options.enableAttachmentUrls && !this.options.http) {
|
|
246
|
+
logger.warn(
|
|
247
|
+
"--enable-attachment-urls has no effect in stdio mode and is being ignored: the minted URL has to be reachable over HTTP. Start with --http to use it."
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
const attachmentPort = parseAttachmentPortOption(this.options.attachmentPort);
|
|
251
|
+
if (attachmentPort !== null && !this.options.enableAttachmentUrls) {
|
|
252
|
+
throw new Error(
|
|
253
|
+
"--attachment-port requires --enable-attachment-urls: on its own there is no attachment route to put on the second listener. Pass both, or neither."
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
const attachmentHost = parseAttachmentHostOption(this.options.attachmentHost);
|
|
257
|
+
if (attachmentHost !== null && attachmentPort === null) {
|
|
258
|
+
throw new Error(
|
|
259
|
+
"--attachment-host requires --attachment-port: without a second listener there is no separate interface to bind, and the attachment route stays on the MCP app."
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
if (attachmentPort !== null && !this.options.http) {
|
|
263
|
+
logger.warn(
|
|
264
|
+
"--attachment-port has no effect in stdio mode and is being ignored: there is no HTTP listener to split. Start with --http to use it."
|
|
265
|
+
);
|
|
266
|
+
}
|
|
174
267
|
if (this.options.http) {
|
|
175
268
|
const { host, port } = parseHttpOption(this.options.http);
|
|
176
269
|
const app = express();
|
|
@@ -538,27 +631,88 @@ class MicrosoftGraphServer {
|
|
|
538
631
|
}
|
|
539
632
|
}
|
|
540
633
|
);
|
|
634
|
+
const attachmentConfig = loadAttachmentUrlConfig(Boolean(this.options.enableAttachmentUrls));
|
|
635
|
+
const mintingAlwaysRefused = this.authManager?.isOAuthModeEnabled() === true || !this.options.trustProxyAuth;
|
|
636
|
+
if (attachmentConfig && mintingAlwaysRefused) {
|
|
637
|
+
logger.warn(
|
|
638
|
+
"--enable-attachment-urls is on, but this server takes its Graph identity from the request in plain --http mode, and minting is refused whenever it does (the URL is redeemed later with no Authorization header, so the bytes would be fetched as a different identity). get-download-url will keep answering with download-bytes. Server-minted URLs need --trust-proxy-auth, where the server uses its own token."
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
let attachmentApp = null;
|
|
642
|
+
if (attachmentConfig) {
|
|
643
|
+
const ticketStore = new AttachmentTicketStore(attachmentConfig.ttlSeconds);
|
|
644
|
+
configureAttachmentMinting({ store: ticketStore, config: attachmentConfig });
|
|
645
|
+
const dedicated = attachmentPort !== null;
|
|
646
|
+
attachmentApp = dedicated ? express() : app;
|
|
647
|
+
if (dedicated) {
|
|
648
|
+
attachmentApp.use(
|
|
649
|
+
helmet({
|
|
650
|
+
contentSecurityPolicy: false,
|
|
651
|
+
crossOriginEmbedderPolicy: false,
|
|
652
|
+
hsts: { maxAge: 31536e3, includeSubDomains: true, preload: true }
|
|
653
|
+
})
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
if (!rateLimitDisabled) {
|
|
657
|
+
attachmentApp.use(
|
|
658
|
+
ATTACHMENT_ROUTE,
|
|
659
|
+
rateLimit({
|
|
660
|
+
windowMs: 6e4,
|
|
661
|
+
max: 60,
|
|
662
|
+
standardHeaders: "draft-7",
|
|
663
|
+
legacyHeaders: false
|
|
664
|
+
})
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
attachmentApp.get(
|
|
668
|
+
ATTACHMENT_ROUTE,
|
|
669
|
+
createAttachmentHandler({
|
|
670
|
+
store: ticketStore,
|
|
671
|
+
getGraphClient: () => this.graphClient,
|
|
672
|
+
authManager: this.authManager
|
|
673
|
+
})
|
|
674
|
+
);
|
|
675
|
+
logger.info(
|
|
676
|
+
` - Attachment URLs: ${attachmentConfig.base}${ATTACHMENT_ROUTE} (ttl ${attachmentConfig.ttlSeconds}s, key id ${attachmentConfig.keyId})`
|
|
677
|
+
);
|
|
678
|
+
if (dedicated) {
|
|
679
|
+
logger.info(
|
|
680
|
+
` - Attachment listener: separate, port ${attachmentPort} (${ATTACHMENT_ROUTE} is NOT served on the MCP port; MS365_MCP_ATTACHMENT_URL_BASE must name this port)`
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
} else {
|
|
684
|
+
configureAttachmentMinting(null);
|
|
685
|
+
}
|
|
541
686
|
app.get("/", (req, res) => {
|
|
542
687
|
res.send("Microsoft 365 MCP Server is running");
|
|
543
688
|
});
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
689
|
+
const mcpBound = await this.listen(app, port, host);
|
|
690
|
+
const mcp = describeBoundAddress(mcpBound);
|
|
691
|
+
logger.info(`Server listening on ${mcp.label}`);
|
|
692
|
+
logger.info(` - MCP endpoint: http://${mcp.authority}/mcp`);
|
|
693
|
+
logger.info(` - OAuth endpoints: http://${mcp.authority}/auth/*`);
|
|
694
|
+
logger.info(
|
|
695
|
+
` - OAuth discovery: http://${mcp.authority}/.well-known/oauth-authorization-server`
|
|
696
|
+
);
|
|
697
|
+
if (attachmentApp && attachmentPort !== null) {
|
|
698
|
+
let attachmentBound;
|
|
699
|
+
try {
|
|
700
|
+
attachmentBound = await this.listen(
|
|
701
|
+
attachmentApp,
|
|
702
|
+
attachmentPort,
|
|
703
|
+
attachmentHost ?? host
|
|
551
704
|
);
|
|
552
|
-
})
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
705
|
+
} catch (error) {
|
|
706
|
+
await this.stop();
|
|
707
|
+
throw error;
|
|
708
|
+
}
|
|
709
|
+
const attachment = describeBoundAddress(attachmentBound);
|
|
710
|
+
logger.info(`Attachment listener on ${attachment.label} \u2014 serves ${ATTACHMENT_ROUTE} only`);
|
|
711
|
+
if (this.options.trustProxyAuth && this.listenersShareAnInterface(mcpBound, attachmentBound)) {
|
|
712
|
+
logger.warn(
|
|
713
|
+
`--attachment-port split ${ATTACHMENT_ROUTE} onto port ${attachmentBound.port}, but both listeners answer on the same interface (MCP ${mcp.label}, attachment ${attachment.label}), so the ports are not isolated from each other. With --trust-proxy-auth, /mcp requires no credential, so anything permitted to reach the attachment port can also call every tool. Bind them to different --http and --attachment-host addresses, or keep the MCP port off the network the attachment fetcher is on.`
|
|
560
714
|
);
|
|
561
|
-
}
|
|
715
|
+
}
|
|
562
716
|
}
|
|
563
717
|
} else {
|
|
564
718
|
const transport = new StdioServerTransport();
|
|
@@ -569,8 +723,86 @@ class MicrosoftGraphServer {
|
|
|
569
723
|
logger.info("Server connected to stdio transport");
|
|
570
724
|
}
|
|
571
725
|
}
|
|
726
|
+
/**
|
|
727
|
+
* Bind one Express app and record the listener.
|
|
728
|
+
*
|
|
729
|
+
* Awaited rather than fire-and-forget, which is what `app.listen(...)` on its
|
|
730
|
+
* own was. Two consequences, both wanted. A bind failure (EADDRINUSE is the
|
|
731
|
+
* live one now that there is a second port to collide) rejects `start()`, so
|
|
732
|
+
* `index.ts` reports it and exits 1 instead of the `error` event reaching the
|
|
733
|
+
* process-wide `uncaughtException` handler as an unattributed dump. And the
|
|
734
|
+
* caller knows the port is actually accepting connections when this resolves,
|
|
735
|
+
* so the second bind cannot race the first.
|
|
736
|
+
*
|
|
737
|
+
* **The callback's argument is read, and that is not a formality.** Express 5
|
|
738
|
+
* wraps the callback passed to `app.listen` in `once()` and registers that
|
|
739
|
+
* same wrapper as the server's `error` handler (`application.js`), so a bind
|
|
740
|
+
* that fails does not skip the callback -- it calls it with an Error. The
|
|
741
|
+
* zero-argument `() => logger.info('Server listening on ...')` this replaces
|
|
742
|
+
* is the shape every example uses, and it announced a port the process had
|
|
743
|
+
* not got: on EADDRINUSE the server logged that it was listening and stayed
|
|
744
|
+
* up serving nothing.
|
|
745
|
+
*/
|
|
746
|
+
async listen(app, port, host) {
|
|
747
|
+
let server;
|
|
748
|
+
await new Promise((resolve, reject) => {
|
|
749
|
+
const done = (error) => error ? reject(error) : resolve();
|
|
750
|
+
server = host ? app.listen(port, host, done) : app.listen(port, done);
|
|
751
|
+
server.once("error", reject);
|
|
752
|
+
this.httpServers.push(server);
|
|
753
|
+
});
|
|
754
|
+
const address = server.address();
|
|
755
|
+
if (address === null || typeof address === "string") {
|
|
756
|
+
throw new Error(`Listener on port ${port} reported no TCP address`);
|
|
757
|
+
}
|
|
758
|
+
return address;
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Whether the two listeners can be reached from a common interface.
|
|
762
|
+
*
|
|
763
|
+
* A wildcard on either side answers everywhere, so it overlaps whatever the
|
|
764
|
+
* other one bound -- and on Linux a dual-stack `::` accepts IPv4 too, so the
|
|
765
|
+
* families are not a distinction worth drawing here. Otherwise they overlap
|
|
766
|
+
* only if they bound the same address. Deliberately coarse in the direction of
|
|
767
|
+
* warning: `127.0.0.1` and `127.0.0.2` are two addresses on one interface,
|
|
768
|
+
* separate to `bind()` and to a container network, and a check that tried to
|
|
769
|
+
* reason about routes instead of addresses would be wrong more often than this.
|
|
770
|
+
*/
|
|
771
|
+
listenersShareAnInterface(a, b) {
|
|
772
|
+
if (isWildcardAddress(a.address) || isWildcardAddress(b.address)) return true;
|
|
773
|
+
return a.address === b.address;
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Close every listener this server opened.
|
|
777
|
+
*
|
|
778
|
+
* `close()` alone is not enough and the difference is not theoretical: it
|
|
779
|
+
* stops accepting but waits on established sockets, and a keep-alive client
|
|
780
|
+
* (Node's own `fetch` is one) holds one open by default, so the process hangs
|
|
781
|
+
* instead of exiting. `closeIdleConnections()` drops exactly those, while a
|
|
782
|
+
* transfer still in flight -- an attachment being streamed -- is allowed to
|
|
783
|
+
* finish.
|
|
784
|
+
*
|
|
785
|
+
* Minting is switched off at the same time. The tickets live in a store this
|
|
786
|
+
* server owns, and after this returns there is no listener left to redeem
|
|
787
|
+
* them on; continuing to hand out URLs for a dead route would be a lie the
|
|
788
|
+
* agent only discovers at fetch time.
|
|
789
|
+
*/
|
|
790
|
+
async stop() {
|
|
791
|
+
const servers = this.httpServers.splice(0);
|
|
792
|
+
configureAttachmentMinting(null);
|
|
793
|
+
await Promise.all(
|
|
794
|
+
servers.map(
|
|
795
|
+
(server) => new Promise((resolve) => {
|
|
796
|
+
server.close(() => resolve());
|
|
797
|
+
server.closeIdleConnections();
|
|
798
|
+
})
|
|
799
|
+
)
|
|
800
|
+
);
|
|
801
|
+
}
|
|
572
802
|
}
|
|
573
803
|
var server_default = MicrosoftGraphServer;
|
|
574
804
|
export {
|
|
575
|
-
server_default as default
|
|
805
|
+
server_default as default,
|
|
806
|
+
parseAttachmentHostOption,
|
|
807
|
+
parseAttachmentPortOption
|
|
576
808
|
};
|
package/docs/deployment.md
CHANGED
|
@@ -213,7 +213,7 @@ The client automatically discovers OAuth endpoints and opens a browser for authe
|
|
|
213
213
|
- **Tool filtering**: use `--enabled-tools <regex>` or `--preset <names>` to restrict available tools
|
|
214
214
|
- **CORS**: configure `MS365_MCP_CORS_ORIGIN` to restrict allowed origins (defaults to `http://localhost:3000`); set explicitly when clients run on a different origin
|
|
215
215
|
- **Disable Dynamic Client Registration**: when only a known client talks to the server, set `MS365_MCP_DISABLE_DCR=true` (or pass `--no-dynamic-registration`) to close the anonymous `/register` endpoint
|
|
216
|
-
- **Structured audit log**: enabled by default. Every tool invocation that reaches Microsoft Graph emits one JSON line on stderr (captured by the container platform's log collector) and to `~/.ms-365-mcp-server/logs/audit.log` (mode `0o600`, or under `MS365_MCP_LOG_DIR` when set) with `{ event, request_id, user_principal_name, tool, http_method, http_status?, status, duration_ms, recipient_count?, recipient_domains?, recipient_domains_truncated?, graph_batch_subrequest_count?, graph_batch_http_status_counts?, graph_batch_error_code_counts?, target_resource?, error_type?, error_code? }`. A few refusals short-circuit before that and emit nothing: a confirm-gate rejection, an `account` param that contradicts the bearer identity, and a failure to resolve an account token. Policy-blocked tool attempts emit `event: "tool.denied"` with `status: "denied"`, `reason` (`allowed_scopes` or `tool_allowlist`), and `missing_scopes` when applicable. When an audited generated Microsoft Graph tool targets a derivable resource through an ID-like path parameter such as `{message-id}` or `{driveItem-id}`, `target_resource` is `{ type, id }`, where `id` is the Graph path up to that resource ID. Later path parameters such as `{path}`, query values, returned content, and Graph response bodies are NEVER recorded, and error messages are reduced to `error_type` / `error_code` so upstream library errors do not leak token fragments or query-string PII. Two things derived from tool parameters **are** recorded, both deliberately. First, `target_resource.id` substitutes ID-like path parameters into the resource path, so a tool on `/users/{user-id}/...` records whatever identifier the caller passed, which may be a full email address. Second, a request whose body carries `toRecipients` / `ccRecipients` / `bccRecipients` / `attendees` / `recipients`, at any casing and several levels down, including inside a `graph-batch` sub-request, records `recipient_count`, the number of entries in those arrays, and `recipient_domains`, the **domain part only** of their addresses and only where it parses as a plain hostname, never the local part and never a subject or message body. An entry that names someone without an address (a `driveRecipient` given as `alias` or `objectId`) counts but contributes no domain. It keys on body shape rather than on the endpoint, so it covers sends, forwards, invites and file shares but equally draft creation and edits, event updates and `findMeetingTimes`, and it reads high rather than low: an attached message's own recipients are counted too. `recipient_domains` holds at most 50 **distinct domains**, alphabetically, and sets `recipient_domains_truncated: true` when there were more; `recipient_count` is unaffected by the cap. All three are absent when the body carries no recipient array at all. A request that fails after reaching Graph still records recipients, since a timeout is not proof of non-delivery. Gaps remain, so absence proves nothing: a draft composed outside this server and sent by id, the original thread's recipients on a reply (Graph resolves those server-side), and a body nesting recipients deeper than the walker descends. This describes the structured audit log only; the operational logger is separate and does log tool parameters. Forms the "who accessed what, when" trail required for GDPR / HIPAA / PIPEDA / SOC 2 audit. Opt-out: `MS365_MCP_AUDIT_LOG=false`
|
|
216
|
+
- **Structured audit log**: enabled by default. Every tool invocation that reaches Microsoft Graph emits one JSON line on stderr (captured by the container platform's log collector) and to `~/.ms-365-mcp-server/logs/audit.log` (mode `0o600`, or under `MS365_MCP_LOG_DIR` when set) with `{ event, request_id, user_principal_name, tool, http_method, http_status?, status, duration_ms, recipient_count?, recipient_domains?, recipient_domains_truncated?, graph_batch_subrequest_count?, graph_batch_http_status_counts?, graph_batch_error_code_counts?, result_count?, result_has_more?, response_bytes?, target_resource?, error_type?, error_code? }`. A few refusals short-circuit before that and emit nothing: a confirm-gate rejection, an `account` param that contradicts the bearer identity, and a failure to resolve an account token. Policy-blocked tool attempts emit `event: "tool.denied"` with `status: "denied"`, `reason` (`allowed_scopes` or `tool_allowlist`), and `missing_scopes` when applicable. When an audited generated Microsoft Graph tool targets a derivable resource through an ID-like path parameter such as `{message-id}` or `{driveItem-id}`, `target_resource` is `{ type, id }`, where `id` is the Graph path up to that resource ID. Later path parameters such as `{path}`, query values, returned content, and Graph response bodies are NEVER recorded, and error messages are reduced to `error_type` / `error_code` so upstream library errors do not leak token fragments or query-string PII. Response _metadata_ is recorded — `result_count`, `result_has_more` and `response_bytes` describe how much came back so that a bulk read is distinguishable from an ordinary one; none of them reveals any of its content. Two things derived from tool parameters **are** recorded, both deliberately. First, `target_resource.id` substitutes ID-like path parameters into the resource path, so a tool on `/users/{user-id}/...` records whatever identifier the caller passed, which may be a full email address. Second, a request whose body carries `toRecipients` / `ccRecipients` / `bccRecipients` / `attendees` / `recipients`, at any casing and several levels down, including inside a `graph-batch` sub-request, records `recipient_count`, the number of entries in those arrays, and `recipient_domains`, the **domain part only** of their addresses and only where it parses as a plain hostname, never the local part and never a subject or message body. An entry that names someone without an address (a `driveRecipient` given as `alias` or `objectId`) counts but contributes no domain. It keys on body shape rather than on the endpoint, so it covers sends, forwards, invites and file shares but equally draft creation and edits, event updates and `findMeetingTimes`, and it reads high rather than low: an attached message's own recipients are counted too. `recipient_domains` holds at most 50 **distinct domains**, alphabetically, and sets `recipient_domains_truncated: true` when there were more; `recipient_count` is unaffected by the cap. All three are absent when the body carries no recipient array at all. A request that fails after reaching Graph still records recipients, since a timeout is not proof of non-delivery. Gaps remain, so absence proves nothing: a draft composed outside this server and sent by id, the original thread's recipients on a reply (Graph resolves those server-side), and a body nesting recipients deeper than the walker descends. This describes the structured audit log only; the operational logger is separate and does log tool parameters. Forms the "who accessed what, when" trail required for GDPR / HIPAA / PIPEDA / SOC 2 audit. Opt-out: `MS365_MCP_AUDIT_LOG=false`
|
|
217
217
|
- **Graph resilience**: every call to Microsoft Graph is wrapped with a fetch timeout (default 100 s via `MS365_MCP_GRAPH_TIMEOUT_MS`), retry-with-backoff on 429 / 503 / 504 / network errors (default 3 retries, full-jitter exponential backoff, honours `Retry-After`; 503 / 504 / network errors only retried for idempotent methods, 429 retried on all methods), and a process-wide circuit breaker that opens after 5 consecutive failures and cools down for 30 s (`MS365_MCP_GRAPH_CIRCUIT_THRESHOLD` / `MS365_MCP_GRAPH_CIRCUIT_COOLDOWN_MS`). Disable the breaker for trusted automation: `MS365_MCP_GRAPH_CIRCUIT_DISABLED=true`
|
|
218
218
|
- **Confirm gate on destructive tools**: opt-in, **off by default**. Enable with `MS365_MCP_REQUIRE_CONFIRM=true`. When on, destructive tools (POST except `readOnly`, PATCH, PUT, DELETE — `delete-mail-message`, `send-mail`, `update-event`, etc.) return `{ "error": "confirmation_required" }` until the caller re-invokes them with `"confirm": true`. Mitigates accidental writes when an LLM misroutes a request or follows an injected instruction. Shipped opt-in so it is a non-breaking, additive layer that can coexist with client-side elicitation prompts (MCP Elicitation API) where the client supports them.
|
|
219
219
|
|
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.152.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",
|