@softeria/ms-365-mcp-server 0.150.3 → 0.151.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/attachment-route.js +112 -0
- package/dist/cli.js +15 -0
- package/dist/graph-client.js +49 -0
- package/dist/graph-tools.js +77 -1
- 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/package.json +1 -1
package/README.md
CHANGED
|
@@ -598,6 +598,15 @@ When running as an MCP server, the following options can be used:
|
|
|
598
598
|
--http [port] Use Streamable HTTP transport instead of stdio (optionally specify port, default: 3000)
|
|
599
599
|
Starts Express.js server with MCP endpoint at /mcp
|
|
600
600
|
--enable-auth-tools Enable login/logout tools when using HTTP mode (disabled by default in HTTP mode)
|
|
601
|
+
--enable-attachment-urls Let get-download-url mint a server-served URL for byte resources Graph
|
|
602
|
+
exposes no pre-authenticated URL for (see "Server-Minted Attachment URLs")
|
|
603
|
+
--attachment-port <port> Serve /attachment on its own listener on this port instead of on the
|
|
604
|
+
MCP app, so a fetcher that can read attachments cannot also reach /mcp
|
|
605
|
+
(requires --enable-attachment-urls; see "Splitting the attachment listener")
|
|
606
|
+
--attachment-host <host> Interface the --attachment-port listener binds. Defaults to whatever
|
|
607
|
+
--http bound, which with a wildcard --http leaves BOTH ports on every
|
|
608
|
+
interface and so isolates nothing — set this to make the split real
|
|
609
|
+
(requires --attachment-port; see "Splitting the attachment listener")
|
|
601
610
|
--no-dynamic-registration Disable OAuth Dynamic Client Registration (enabled by default in HTTP mode)
|
|
602
611
|
--enabled-tools <pattern> Filter tools using regex pattern (e.g., "excel|contact" to enable Excel and Contact tools)
|
|
603
612
|
--preset <names> Use preset tool categories (comma-separated). See "Tool Presets" section above
|
|
@@ -623,6 +632,8 @@ Environment variables:
|
|
|
623
632
|
- `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)
|
|
624
633
|
- `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`)
|
|
625
634
|
- `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
|
|
635
|
+
- `MS365_MCP_ATTACHMENT_PORT=<port>`: Serve the attachment route on its own listener on this port (alternative to --attachment-port; requires `--enable-attachment-urls`)
|
|
636
|
+
- `MS365_MCP_ATTACHMENT_HOST=<host>`: Interface the `MS365_MCP_ATTACHMENT_PORT` listener binds (alternative to --attachment-host; requires `--attachment-port`). Defaults to the host `--http` bound — which for a wildcard `--http` means both ports answer everywhere and the port split isolates nothing. See "Splitting the attachment listener"
|
|
626
637
|
- `MS365_MCP_CLOUD_TYPE=global|china`: Microsoft cloud environment (alternative to --cloud flag)
|
|
627
638
|
- `LOG_LEVEL`: Set logging level (default: 'info')
|
|
628
639
|
- `SILENT=true|1`: Disable console output
|
|
@@ -638,6 +649,167 @@ Environment variables:
|
|
|
638
649
|
- `MS365_MCP_EXPECTED_USERNAME`: Require local MSAL auth to use this Microsoft account username (case-insensitive; CLI flag takes precedence)
|
|
639
650
|
- `MS365_MCP_EXPECTED_HOME_ACCOUNT_ID`: Require local MSAL auth to use this exact MSAL homeAccountId (CLI flag takes precedence)
|
|
640
651
|
|
|
652
|
+
## Server-Minted Attachment URLs
|
|
653
|
+
|
|
654
|
+
`get-download-url` returns Microsoft's own pre-authenticated `@microsoft.graph.downloadUrl`
|
|
655
|
+
for OneDrive and SharePoint items. Graph publishes no such URL for **mail and calendar
|
|
656
|
+
attachments, meeting recordings, or any other `/$value` byte endpoint** — for those, the
|
|
657
|
+
only way to read the bytes has been `download-bytes`, which returns base64 into the
|
|
658
|
+
agent's context. A 73 KB, 3-page PDF costs about 24,500 tokens that way, and the model
|
|
659
|
+
cannot parse them anyway.
|
|
660
|
+
|
|
661
|
+
`--enable-attachment-urls` (HTTP mode, off by default) closes that gap. When Graph has no
|
|
662
|
+
URL of its own, `get-download-url` mints one this server serves:
|
|
663
|
+
|
|
664
|
+
```
|
|
665
|
+
GET /attachment?t=<ticket>&dgk=<key-id>&dgx=<expiry>&dgs=<signature>
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
The ticket is 32 bytes of CSPRNG output, **single-use**, memory-only, and expires after
|
|
669
|
+
`MS365_MCP_ATTACHMENT_URL_TTL_S` seconds. Redeeming it streams the Graph bytes with this
|
|
670
|
+
server's own token; the fetcher sends no Authorization header and holds no Microsoft
|
|
671
|
+
credential.
|
|
672
|
+
|
|
673
|
+
**This grants no authority the calling agent did not already have.** Every target that can
|
|
674
|
+
be minted is one `download-bytes` would fetch for the same caller on the same account. The
|
|
675
|
+
ticket only moves those bytes out of the context window and into a direct transfer.
|
|
676
|
+
|
|
677
|
+
### Configuration
|
|
678
|
+
|
|
679
|
+
```
|
|
680
|
+
MS365_MCP_ATTACHMENT_URL_BASE=http://m365-mcp:3000 # required
|
|
681
|
+
MS365_MCP_ATTACHMENT_URL_KEY=... # required (or _KEY_FILE=/path)
|
|
682
|
+
MS365_MCP_ATTACHMENT_URL_KEY_ID=1 # optional, default 1
|
|
683
|
+
MS365_MCP_ATTACHMENT_URL_TTL_S=120 # optional, default 120, max 300
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
`MS365_MCP_ATTACHMENT_URL_BASE` is deliberately **not** `MS365_MCP_PUBLIC_URL`: that one is
|
|
687
|
+
browser-facing, for OAuth redirects, while this is fetched server-to-server and is
|
|
688
|
+
commonly a container address. A missing or malformed setting fails at startup rather than
|
|
689
|
+
per-request — a signing feature that comes up without a key would mint URLs nothing can
|
|
690
|
+
verify, silently.
|
|
691
|
+
|
|
692
|
+
### Splitting the attachment listener
|
|
693
|
+
|
|
694
|
+
By default `/attachment` is served by the same Express app, on the same port, as `/mcp`.
|
|
695
|
+
That is fine when callers are authenticated by a bearer token, and it is a problem when
|
|
696
|
+
they are not. Under `--trust-proxy-auth` the MCP endpoint reads no `Authorization` header
|
|
697
|
+
at all — **reachability is the authentication** — so one shared port means the sidecar you
|
|
698
|
+
allowed through in order to fetch a PDF can also call every tool on the server.
|
|
699
|
+
|
|
700
|
+
`--attachment-port <port>` (or `MS365_MCP_ATTACHMENT_PORT`) moves the route onto a listener
|
|
701
|
+
of its own, and `--attachment-host <host>` (or `MS365_MCP_ATTACHMENT_HOST`) says which
|
|
702
|
+
interface that listener binds:
|
|
703
|
+
|
|
704
|
+
```
|
|
705
|
+
ms-365-mcp-server --http 10.89.0.2:3000 --trust-proxy-auth \
|
|
706
|
+
--enable-attachment-urls \
|
|
707
|
+
--attachment-port 3001 --attachment-host 10.89.1.2
|
|
708
|
+
MS365_MCP_ATTACHMENT_URL_BASE=http://m365-mcp:3001 # note: the attachment port
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
- `GET /attachment` on **3001** works; on 3000 it is **404** — the MCP app never mounts it.
|
|
712
|
+
- `/mcp` on **3001** is **404**, as is everything else: the second app has the attachment
|
|
713
|
+
route and nothing more. No OAuth router, no body parsers, no CORS, no health check.
|
|
714
|
+
- The 60 req/min limiter that guards the route follows it onto the new listener.
|
|
715
|
+
- `trust proxy` is **off** on the attachment listener (and `MS365_MCP_TRUST_PROXY_HOPS` is
|
|
716
|
+
not read for it), unlike the MCP listener, which trusts one hop. This port is meant to be
|
|
717
|
+
dialled directly on a container network; honouring `X-Forwarded-For` on the server's one
|
|
718
|
+
uncredentialed surface would let a caller choose its own rate-limit bucket.
|
|
719
|
+
|
|
720
|
+
The flag requires `--enable-attachment-urls` and refuses to start without it — on its own
|
|
721
|
+
it would open a port with nothing on it while the operator believed the surfaces were
|
|
722
|
+
separated. In stdio mode it warns and is ignored, like the flag it depends on.
|
|
723
|
+
`--attachment-host` likewise requires `--attachment-port`: alone it would name an interface
|
|
724
|
+
for a listener that does not exist.
|
|
725
|
+
|
|
726
|
+
#### Two ports are not two surfaces unless they bind two interfaces
|
|
727
|
+
|
|
728
|
+
**This is the part that decides whether any of the above is worth anything.** Read it
|
|
729
|
+
before you deploy the split.
|
|
730
|
+
|
|
731
|
+
`--attachment-port` on its own separates the two surfaces _inside the process_. It does not
|
|
732
|
+
separate them _on the network_. Without `--attachment-host` the attachment listener inherits
|
|
733
|
+
whatever host `--http` bound — and `--http 3000`, the common form, names no host at all, so
|
|
734
|
+
Node binds the wildcard and **both** ports answer on **every** interface:
|
|
735
|
+
|
|
736
|
+
```
|
|
737
|
+
ms-365-mcp-server --http 3000 --trust-proxy-auth \
|
|
738
|
+
--enable-attachment-urls --attachment-port 3001 # NOT isolated
|
|
739
|
+
```
|
|
740
|
+
|
|
741
|
+
Container networks grant a peer every port on a container, not one port. Put a
|
|
742
|
+
document-conversion sidecar on a shared bridge so it can fetch `/attachment` on 3001, and
|
|
743
|
+
that same sidecar can dial `:3000/mcp` — which under `--trust-proxy-auth` reads no
|
|
744
|
+
`Authorization` header at all and hands back the full tool catalogue. Nothing fails, nothing
|
|
745
|
+
is logged as an error, and the config looks exactly like the isolated one.
|
|
746
|
+
|
|
747
|
+
To make it real, give the two listeners **different addresses**, and put only the attachment
|
|
748
|
+
address on the network the fetcher is on:
|
|
749
|
+
|
|
750
|
+
```yaml
|
|
751
|
+
# docker compose — the MCP port on the agent's own bridge, the attachment port on the
|
|
752
|
+
# bridge shared with the converter. The converter can reach 3001 and cannot route to 3000.
|
|
753
|
+
services:
|
|
754
|
+
m365-mcp:
|
|
755
|
+
networks: { agent-net: { ipv4_address: 10.89.0.2 }, convert-net: { ipv4_address: 10.89.1.2 } }
|
|
756
|
+
command: >
|
|
757
|
+
--http 10.89.0.2:3000 --trust-proxy-auth
|
|
758
|
+
--enable-attachment-urls
|
|
759
|
+
--attachment-port 3001 --attachment-host 10.89.1.2
|
|
760
|
+
docglean:
|
|
761
|
+
networks: [convert-net]
|
|
762
|
+
```
|
|
763
|
+
|
|
764
|
+
The MCP port is then unreachable from `convert-net` **by binding** — there is no socket
|
|
765
|
+
listening on that interface — rather than by a firewall rule that has to keep matching.
|
|
766
|
+
|
|
767
|
+
The server warns at startup if you run `--trust-proxy-auth` with `--attachment-port` while
|
|
768
|
+
both listeners still answer on a common interface (either sharing an address, or either one
|
|
769
|
+
on the wildcard). Both bound addresses are logged, read back from the socket rather than
|
|
770
|
+
from the flags, so `Server listening on …` and `Attachment listener on …` can be compared
|
|
771
|
+
directly.
|
|
772
|
+
|
|
773
|
+
`--attachment-host` takes a bare IPv4 address, IPv6 address (bracketed `[::1]` or bare
|
|
774
|
+
`::1`) or hostname. It is refused rather than coerced — `--attachment-host 10.0.0.5:3001`
|
|
775
|
+
is an error naming `--attachment-port`, not a bind to something else. Note that
|
|
776
|
+
`MS365_MCP_ATTACHMENT_URL_BASE` still must not be an IPv6 literal (the URL signature covers
|
|
777
|
+
the host and the two implementations normalise IPv6 differently); if you bind the listener
|
|
778
|
+
to an IPv6 address, name it in the base by hostname.
|
|
779
|
+
|
|
780
|
+
Point `MS365_MCP_ATTACHMENT_URL_BASE` at the attachment port. The server cannot check this
|
|
781
|
+
for you: the base is usually a container name on a network this process cannot resolve, so
|
|
782
|
+
a wrong port here shows up as a fetch failure in the sidecar, not an error here. Both the
|
|
783
|
+
base and the bound port are logged at startup, one line apart, for exactly that comparison.
|
|
784
|
+
|
|
785
|
+
### The signature, and who checks what
|
|
786
|
+
|
|
787
|
+
`dgk`/`dgx`/`dgs` are **not** checked by this server on redemption, and that is deliberate.
|
|
788
|
+
They exist for the fetcher: a document-conversion sidecar that refuses to dial a private
|
|
789
|
+
address unless the URL carries a valid HMAC from an origin it has been configured to trust.
|
|
790
|
+
What authorises redemption _here_ is the ticket. Verifying the signature on the way back in
|
|
791
|
+
would prove only that we minted the URL — which the ticket already proves — while coupling
|
|
792
|
+
redemption to the sidecar's clock and to the key surviving a restart.
|
|
793
|
+
|
|
794
|
+
The wire format is [docglean-mcp](https://github.com/msoukhomlinov/docglean-mcp)'s
|
|
795
|
+
`signing.py` (`canonical_string`), and `src/lib/url-signing.ts` is a port of it. The
|
|
796
|
+
canonical string is `\n`-joined: `v1`, lowercased scheme, lowercased host, the port always
|
|
797
|
+
explicit, the path, the remaining query with `dgk`/`dgx`/`dgs` removed and the rest sorted
|
|
798
|
+
and re-encoded, and the expiry. The test vectors in
|
|
799
|
+
`test/attachment-url-signing.test.ts` were verified against the Python implementation byte
|
|
800
|
+
for byte — three places where the obvious JavaScript disagrees with Python (`!*'()`
|
|
801
|
+
escaping, `+` decoding as a space, and code-point vs UTF-16 sort order) are why that check
|
|
802
|
+
exists rather than being assumed.
|
|
803
|
+
|
|
804
|
+
The ticket travels in the **query, not the path**, because the verifying sidecar keeps a
|
|
805
|
+
fetched URL's path in its error messages and strips the query.
|
|
806
|
+
|
|
807
|
+
### Not available in OAuth/OBO mode
|
|
808
|
+
|
|
809
|
+
Identity there arrives per request on the caller's `Authorization` header, and a ticket is
|
|
810
|
+
redeemed later by a fetcher that sends none. Minting refuses with an explanation rather
|
|
811
|
+
than producing a URL that always fails.
|
|
812
|
+
|
|
641
813
|
## Token Storage
|
|
642
814
|
|
|
643
815
|
Authentication tokens are stored in an encrypted file (AES-256-GCM). Only the 32-byte encryption key goes to the OS credential store via keytar.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { Readable } from "node:stream";
|
|
2
|
+
import { pipeline } from "node:stream/promises";
|
|
3
|
+
import logger from "./logger.js";
|
|
4
|
+
import {
|
|
5
|
+
isPlainGraphPath,
|
|
6
|
+
TICKET_PARAM
|
|
7
|
+
} from "./lib/attachment-tickets.js";
|
|
8
|
+
const NOT_FOUND_BODY = "Not found";
|
|
9
|
+
function forceAttachment(header) {
|
|
10
|
+
if (!header) return "attachment";
|
|
11
|
+
const params = [];
|
|
12
|
+
let current = "";
|
|
13
|
+
let quoted = false;
|
|
14
|
+
for (let i = 0; i < header.length; i += 1) {
|
|
15
|
+
const char = header[i];
|
|
16
|
+
if (quoted && char === "\\" && i + 1 < header.length) {
|
|
17
|
+
current += char + header[i + 1];
|
|
18
|
+
i += 1;
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (char === '"') quoted = !quoted;
|
|
22
|
+
else if (char === ";" && !quoted) {
|
|
23
|
+
params.push(current);
|
|
24
|
+
current = "";
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
current += char;
|
|
28
|
+
}
|
|
29
|
+
params.push(current);
|
|
30
|
+
let filename;
|
|
31
|
+
let extended;
|
|
32
|
+
for (const param of params.slice(1)) {
|
|
33
|
+
const eq = param.indexOf("=");
|
|
34
|
+
if (eq === -1) continue;
|
|
35
|
+
const name = param.slice(0, eq).trim().toLowerCase();
|
|
36
|
+
const value = Array.from(param.slice(eq + 1).trim()).filter((char) => {
|
|
37
|
+
const code = char.charCodeAt(0);
|
|
38
|
+
return code > 31 && code !== 127;
|
|
39
|
+
}).join("");
|
|
40
|
+
if (!value) continue;
|
|
41
|
+
if (name === "filename*") extended = value;
|
|
42
|
+
else if (name === "filename") filename = value;
|
|
43
|
+
}
|
|
44
|
+
if (extended) return `attachment; filename*=${extended}`;
|
|
45
|
+
if (filename) return `attachment; filename=${filename}`;
|
|
46
|
+
return "attachment";
|
|
47
|
+
}
|
|
48
|
+
function refuse(res) {
|
|
49
|
+
res.status(404).type("text/plain").send(NOT_FOUND_BODY);
|
|
50
|
+
}
|
|
51
|
+
function createAttachmentHandler(deps) {
|
|
52
|
+
return async (req, res) => {
|
|
53
|
+
if (req.method !== "GET") {
|
|
54
|
+
res.setHeader("allow", "GET");
|
|
55
|
+
res.status(405).type("text/plain").send("Method not allowed");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const raw = req.query[TICKET_PARAM];
|
|
59
|
+
if (typeof raw !== "string" || raw.length === 0) {
|
|
60
|
+
refuse(res);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const ticket = deps.store.redeem(raw);
|
|
64
|
+
if (!ticket) {
|
|
65
|
+
refuse(res);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (!isPlainGraphPath(ticket.target)) {
|
|
69
|
+
logger.error("Attachment redemption refused: ticket target is not a plain Graph path");
|
|
70
|
+
refuse(res);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const graphClient = deps.getGraphClient();
|
|
74
|
+
if (!graphClient) {
|
|
75
|
+
logger.error("Attachment redemption failed: Graph client is not initialised");
|
|
76
|
+
res.status(503).type("text/plain").send("Service unavailable");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
let stream;
|
|
80
|
+
try {
|
|
81
|
+
let accessToken;
|
|
82
|
+
if (!deps.authManager.isOAuthModeEnabled()) {
|
|
83
|
+
accessToken = await deps.authManager.getTokenForAccount(ticket.accountName);
|
|
84
|
+
}
|
|
85
|
+
stream = await graphClient.downloadStream(ticket.target, { accessToken });
|
|
86
|
+
} catch (error) {
|
|
87
|
+
logger.error(
|
|
88
|
+
`Attachment redemption failed for ${ticket.target}: ${error.message}`
|
|
89
|
+
);
|
|
90
|
+
res.status(502).type("text/plain").send("Upstream fetch failed");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
res.status(200);
|
|
94
|
+
res.setHeader("content-type", stream.contentType);
|
|
95
|
+
if (stream.contentLength !== null) {
|
|
96
|
+
res.setHeader("content-length", String(stream.contentLength));
|
|
97
|
+
}
|
|
98
|
+
res.setHeader("content-disposition", forceAttachment(stream.contentDisposition));
|
|
99
|
+
res.setHeader("cache-control", "no-store");
|
|
100
|
+
res.setHeader("x-content-type-options", "nosniff");
|
|
101
|
+
try {
|
|
102
|
+
await pipeline(Readable.fromWeb(stream.body), res);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
logger.error(`Attachment stream aborted for ${ticket.target}: ${error.message}`);
|
|
105
|
+
res.destroy();
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
export {
|
|
110
|
+
createAttachmentHandler,
|
|
111
|
+
forceAttachment
|
|
112
|
+
};
|
package/dist/cli.js
CHANGED
|
@@ -27,6 +27,15 @@ program.name("ms-365-mcp-server").description("Microsoft 365 MCP Server").versio
|
|
|
27
27
|
).option(
|
|
28
28
|
"--enable-auth-tools",
|
|
29
29
|
"Enable login/logout tools when using HTTP mode (disabled by default in HTTP mode)"
|
|
30
|
+
).option(
|
|
31
|
+
"--enable-attachment-urls",
|
|
32
|
+
"HTTP mode only. Let get-download-url mint a short-TTL, single-use URL served by this server for Graph byte resources that expose no pre-authenticated URL of their own (mail and event attachments, meeting recordings, other $value endpoints). Requires MS365_MCP_ATTACHMENT_URL_BASE and MS365_MCP_ATTACHMENT_URL_KEY (or _KEY_FILE)"
|
|
33
|
+
).option(
|
|
34
|
+
"--attachment-port <port>",
|
|
35
|
+
"HTTP mode only. Serve the attachment download route on its own listener on this port instead of on the MCP app, so a caller that can fetch attachments cannot also reach /mcp. Requires --enable-attachment-urls, and MS365_MCP_ATTACHMENT_URL_BASE must name this port. A separate port only isolates the two surfaces if they also bind separate interfaces \u2014 see --attachment-host. Equivalent env var: MS365_MCP_ATTACHMENT_PORT."
|
|
36
|
+
).option(
|
|
37
|
+
"--attachment-host <host>",
|
|
38
|
+
"Interface the --attachment-port listener binds (IPv4, IPv6 or hostname). Defaults to whatever --http bound, which with a wildcard --http means both ports answer on every interface \u2014 so a peer allowed onto the network to fetch attachments can also reach /mcp. Bind this to the address the fetcher uses and --http to a different one to make that unreachable rather than merely un-advertised. Requires --attachment-port. Equivalent env var: MS365_MCP_ATTACHMENT_HOST."
|
|
30
39
|
).option(
|
|
31
40
|
"--enabled-tools <pattern>",
|
|
32
41
|
'Filter tools using regex pattern (e.g., "excel|contact" to enable Excel and Contact tools)'
|
|
@@ -127,6 +136,12 @@ function parseArgs() {
|
|
|
127
136
|
);
|
|
128
137
|
process.exit(1);
|
|
129
138
|
}
|
|
139
|
+
if (options.attachmentPort === void 0 && process.env.MS365_MCP_ATTACHMENT_PORT !== void 0) {
|
|
140
|
+
options.attachmentPort = process.env.MS365_MCP_ATTACHMENT_PORT;
|
|
141
|
+
}
|
|
142
|
+
if (options.attachmentHost === void 0 && process.env.MS365_MCP_ATTACHMENT_HOST !== void 0) {
|
|
143
|
+
options.attachmentHost = process.env.MS365_MCP_ATTACHMENT_HOST;
|
|
144
|
+
}
|
|
130
145
|
if (options.expectedUsername === void 0 && process.env.MS365_MCP_EXPECTED_USERNAME !== void 0) {
|
|
131
146
|
options.expectedUsername = process.env.MS365_MCP_EXPECTED_USERNAME;
|
|
132
147
|
}
|
package/dist/graph-client.js
CHANGED
|
@@ -167,6 +167,55 @@ class GraphClient {
|
|
|
167
167
|
* memory or hit V8's max string length. Creates the file with wx + 0o600 (never
|
|
168
168
|
* overwrites) and removes a partial file if the transfer fails.
|
|
169
169
|
*/
|
|
170
|
+
/**
|
|
171
|
+
* Fetch Graph binary content and hand back the undrained response stream.
|
|
172
|
+
*
|
|
173
|
+
* Same auth, same resilience and the same error mapping as `downloadToFile`,
|
|
174
|
+
* which is the reason this exists rather than the attachment route calling
|
|
175
|
+
* `fetch` for itself: token acquisition, the OBO/bearer context tokens, retry
|
|
176
|
+
* and the 403-scope special case are all in `performRequest`, which is
|
|
177
|
+
* private. Splitting them would give the route a second, quietly divergent
|
|
178
|
+
* copy of the auth path.
|
|
179
|
+
*
|
|
180
|
+
* The caller owns the body from here and MUST consume or cancel it -- an
|
|
181
|
+
* abandoned stream holds a socket open until the agent times out.
|
|
182
|
+
*/
|
|
183
|
+
async downloadStream(endpoint, options = {}) {
|
|
184
|
+
const contextTokens = getRequestTokens();
|
|
185
|
+
const accessToken = options.accessToken ?? contextTokens?.accessToken ?? await this.authManager.getToken();
|
|
186
|
+
if (!accessToken) {
|
|
187
|
+
throw new Error("No access token available");
|
|
188
|
+
}
|
|
189
|
+
const response = await this.performRequest(endpoint, accessToken, options);
|
|
190
|
+
if (response.status === 403) {
|
|
191
|
+
const errorText = await response.text();
|
|
192
|
+
if (errorText.includes("scope") || errorText.includes("permission")) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`Microsoft Graph API scope error: ${response.status} ${response.statusText} - ${errorText}. This tool requires organization mode. Please restart with --org-mode flag.`
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
throw new Error(
|
|
198
|
+
`Microsoft Graph API error: ${response.status} ${response.statusText} - ${errorText}`
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
if (!response.ok) {
|
|
202
|
+
throw new Error(
|
|
203
|
+
`Microsoft Graph API error: ${response.status} ${response.statusText} - ${await response.text()}`
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
if (!response.body) {
|
|
207
|
+
throw new Error("Microsoft Graph returned an empty response body");
|
|
208
|
+
}
|
|
209
|
+
const rawLength = response.headers.get("content-length");
|
|
210
|
+
const decoded = response.headers.get("content-encoding") !== null;
|
|
211
|
+
const declaredLength = !decoded && rawLength !== null && /^\d+$/.test(rawLength.trim());
|
|
212
|
+
return {
|
|
213
|
+
body: response.body,
|
|
214
|
+
contentType: response.headers.get("content-type") || "application/octet-stream",
|
|
215
|
+
contentLength: declaredLength ? Number(rawLength) : null,
|
|
216
|
+
contentDisposition: response.headers.get("content-disposition")
|
|
217
|
+
};
|
|
218
|
+
}
|
|
170
219
|
async downloadToFile(endpoint, destinationPath, options = {}) {
|
|
171
220
|
const fileHandle = await open(destinationPath, "wx", 384);
|
|
172
221
|
let completed = false;
|
package/dist/graph-tools.js
CHANGED
|
@@ -4,6 +4,12 @@ import logger from "./logger.js";
|
|
|
4
4
|
import { auditLog, getUserIdentityForAudit } from "./audit-log.js";
|
|
5
5
|
import { isDestructiveOperation } from "./lib/destructive-ops.js";
|
|
6
6
|
import { describePathParam } from "./lib/path-params.js";
|
|
7
|
+
import { getAttachmentMinting } from "./lib/attachment-minting.js";
|
|
8
|
+
import {
|
|
9
|
+
buildAttachmentUrl,
|
|
10
|
+
isPlainGraphPath,
|
|
11
|
+
TicketStoreFullError
|
|
12
|
+
} from "./lib/attachment-tickets.js";
|
|
7
13
|
import {
|
|
8
14
|
anyFieldPresent,
|
|
9
15
|
isTransportEnvelope,
|
|
@@ -475,6 +481,68 @@ async function checkAccountParamInBearerMode(accountParam, authManager) {
|
|
|
475
481
|
if (bearerIdentity && bearerIdentity.toLowerCase() === accountParam.toLowerCase()) return null;
|
|
476
482
|
return `The 'account' parameter is not supported in HTTP/OAuth mode: every request uses the identity of the connecting client's bearer token` + (bearerIdentity ? ` ('${bearerIdentity}')` : "") + `, so account switching is not possible. To act as '${accountParam}', reconnect the MCP client authenticated as that account, or run the server in stdio mode (or HTTP with --trust-proxy-auth) where cached accounts are available.`;
|
|
477
483
|
}
|
|
484
|
+
async function mintDownloadUrl(target, accountParam, authManager) {
|
|
485
|
+
const minting = getAttachmentMinting();
|
|
486
|
+
if (!minting) return null;
|
|
487
|
+
if (authManager?.isOAuthModeEnabled() || getRequestTokens()) {
|
|
488
|
+
return {
|
|
489
|
+
content: [
|
|
490
|
+
{
|
|
491
|
+
type: "text",
|
|
492
|
+
text: JSON.stringify({
|
|
493
|
+
error: "Server-minted download URLs are unavailable when Graph identity comes from the request (OAuth, OBO, or bearer mode): the URL is redeemed later without an Authorization header, so the bytes would be fetched as a different identity than the one that asked for them. Use download-bytes."
|
|
494
|
+
})
|
|
495
|
+
}
|
|
496
|
+
],
|
|
497
|
+
isError: true
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
const accountModeError = await checkAccountParamInBearerMode(accountParam, authManager);
|
|
501
|
+
if (accountModeError) {
|
|
502
|
+
return {
|
|
503
|
+
content: [{ type: "text", text: JSON.stringify({ error: accountModeError }) }],
|
|
504
|
+
isError: true
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
if (!isPlainGraphPath(target)) {
|
|
508
|
+
return {
|
|
509
|
+
content: [
|
|
510
|
+
{
|
|
511
|
+
type: "text",
|
|
512
|
+
text: JSON.stringify({
|
|
513
|
+
error: 'target must be a plain relative Graph path: no fragment, no query, no "." or ".." segments, and no percent-encoded separators.'
|
|
514
|
+
})
|
|
515
|
+
}
|
|
516
|
+
],
|
|
517
|
+
isError: true
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
let ticket;
|
|
521
|
+
try {
|
|
522
|
+
ticket = minting.store.mint(target, accountParam);
|
|
523
|
+
} catch (error) {
|
|
524
|
+
if (error instanceof TicketStoreFullError) {
|
|
525
|
+
return {
|
|
526
|
+
content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
|
|
527
|
+
isError: true
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
throw error;
|
|
531
|
+
}
|
|
532
|
+
return {
|
|
533
|
+
content: [
|
|
534
|
+
{
|
|
535
|
+
type: "text",
|
|
536
|
+
text: JSON.stringify({
|
|
537
|
+
downloadUrl: buildAttachmentUrl(minting.config, ticket.id),
|
|
538
|
+
expiresAt: new Date(ticket.expiresAtMs).toISOString(),
|
|
539
|
+
singleUse: true,
|
|
540
|
+
note: "Served by this server, not by Microsoft Graph. Valid for one fetch until it expires."
|
|
541
|
+
})
|
|
542
|
+
}
|
|
543
|
+
]
|
|
544
|
+
};
|
|
545
|
+
}
|
|
478
546
|
const UTILITY_TOOLS = [
|
|
479
547
|
{
|
|
480
548
|
name: "parse-teams-url",
|
|
@@ -719,7 +787,7 @@ const UTILITY_TOOLS = [
|
|
|
719
787
|
method: "GET",
|
|
720
788
|
path: "tool:get-download-url",
|
|
721
789
|
searchKeywords: "download file download drive file download onedrive file sharepoint file download large drive file large sharepoint file large file out-of-band download pre-authenticated url",
|
|
722
|
-
description: "Resolve a short-lived, pre-authenticated download URL for Microsoft Graph binary content that exposes one (drive/SharePoint file content). The returned URL streams the bytes with NO Authorization header, so the client can fetch it straight to disk (e.g. curl) without round-tripping base64 through the agent context. Prefer this over download-bytes for any file above a few KB or any bulk download. Returns { downloadUrl, name?, size?, contentType? }.
|
|
790
|
+
description: "Resolve a short-lived, pre-authenticated download URL for Microsoft Graph binary content that exposes one (drive/SharePoint file content). The returned URL streams the bytes with NO Authorization header, so the client can fetch it straight to disk (e.g. curl) without round-tripping base64 through the agent context. Prefer this over download-bytes for any file above a few KB or any bulk download. Returns { downloadUrl, name?, size?, contentType? }. Mail file attachments (/messages/{id}/attachments/{id}/$value), meeting recordings and other $value byte endpoints have no pre-authenticated URL from Graph itself, but call this tool for them anyway: a server running with --enable-attachment-urls mints its own single-use URL for them, and one without it answers with the reason and points at download-bytes.",
|
|
723
791
|
readOnlyHint: true,
|
|
724
792
|
openWorldHint: true,
|
|
725
793
|
buildSchema: (ctx) => {
|
|
@@ -778,6 +846,8 @@ const UTILITY_TOOLS = [
|
|
|
778
846
|
}
|
|
779
847
|
const pathPart = target.replace(/\/+$/, "");
|
|
780
848
|
if (/^(\/me|\/users\/[^/]+)\/messages\/[^/]+\/attachments\//.test(pathPart) || /^(\/me|\/users\/[^/]+)\/events\/[^/]+\/attachments\//.test(pathPart) || /^\/groups\/[^/]+\/messages\/[^/]+\/attachments\//.test(pathPart) || /^\/groups\/[^/]+\/events\/[^/]+\/attachments\//.test(pathPart)) {
|
|
849
|
+
const minted = await mintDownloadUrl(pathPart, accountParam, authManager);
|
|
850
|
+
if (minted) return minted;
|
|
781
851
|
return {
|
|
782
852
|
content: [
|
|
783
853
|
{
|
|
@@ -793,6 +863,8 @@ const UTILITY_TOOLS = [
|
|
|
793
863
|
if (/^(\/me|\/users\/[^/]+)\/onlineMeetings\/[^/]+\/recordings\/[^/]+(?:\/content)?$/.test(
|
|
794
864
|
pathPart
|
|
795
865
|
) || /^\/communications\/calls\/[^/]+\/recordings\/[^/]+(?:\/content)?$/.test(pathPart)) {
|
|
866
|
+
const minted = await mintDownloadUrl(pathPart, accountParam, authManager);
|
|
867
|
+
if (minted) return minted;
|
|
796
868
|
return {
|
|
797
869
|
content: [
|
|
798
870
|
{
|
|
@@ -806,6 +878,8 @@ const UTILITY_TOOLS = [
|
|
|
806
878
|
};
|
|
807
879
|
}
|
|
808
880
|
if (pathPart.endsWith("/$value")) {
|
|
881
|
+
const minted = await mintDownloadUrl(pathPart, accountParam, authManager);
|
|
882
|
+
if (minted) return minted;
|
|
809
883
|
return {
|
|
810
884
|
content: [
|
|
811
885
|
{
|
|
@@ -875,6 +949,8 @@ const UTILITY_TOOLS = [
|
|
|
875
949
|
}
|
|
876
950
|
const downloadUrl = item?.["@microsoft.graph.downloadUrl"];
|
|
877
951
|
if (!downloadUrl) {
|
|
952
|
+
const minted = await mintDownloadUrl(`${itemPath}/content`, accountParam, authManager);
|
|
953
|
+
if (minted) return minted;
|
|
878
954
|
return {
|
|
879
955
|
content: [
|
|
880
956
|
{
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
let current = null;
|
|
2
|
+
function configureAttachmentMinting(minting) {
|
|
3
|
+
current = minting;
|
|
4
|
+
}
|
|
5
|
+
function getAttachmentMinting() {
|
|
6
|
+
return current;
|
|
7
|
+
}
|
|
8
|
+
function resetAttachmentMinting() {
|
|
9
|
+
current = null;
|
|
10
|
+
}
|
|
11
|
+
export {
|
|
12
|
+
configureAttachmentMinting,
|
|
13
|
+
getAttachmentMinting,
|
|
14
|
+
resetAttachmentMinting
|
|
15
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { ATTACHMENT_ROUTE } from "./attachment-url-config.js";
|
|
3
|
+
import { signUrl } from "./url-signing.js";
|
|
4
|
+
const TICKET_PARAM = "t";
|
|
5
|
+
function buildAttachmentUrl(config, ticketId, nowMs = Date.now()) {
|
|
6
|
+
const url = new URL(ATTACHMENT_ROUTE, config.base);
|
|
7
|
+
url.searchParams.set(TICKET_PARAM, ticketId);
|
|
8
|
+
return signUrl(
|
|
9
|
+
url.toString(),
|
|
10
|
+
{ key: config.key, keyId: config.keyId, ttlSeconds: config.ttlSeconds },
|
|
11
|
+
nowMs
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
const PROBE_ORIGIN = "https://graph.invalid";
|
|
15
|
+
const PROBE_PREFIX = "/v1.0";
|
|
16
|
+
function isPlainGraphPath(target) {
|
|
17
|
+
if (!target.startsWith("/")) return false;
|
|
18
|
+
if (target.includes("//")) return false;
|
|
19
|
+
let resolved;
|
|
20
|
+
try {
|
|
21
|
+
resolved = new URL(PROBE_ORIGIN + PROBE_PREFIX + target);
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
return resolved.origin === PROBE_ORIGIN && resolved.search === "" && resolved.hash === "" && resolved.pathname === PROBE_PREFIX + target;
|
|
26
|
+
}
|
|
27
|
+
const MAX_LIVE_TICKETS = 256;
|
|
28
|
+
const TICKET_BYTES = 32;
|
|
29
|
+
class TicketStoreFullError extends Error {
|
|
30
|
+
constructor(limit) {
|
|
31
|
+
super(`No ticket slots available (limit ${limit}); retry once outstanding tickets expire.`);
|
|
32
|
+
this.limit = limit;
|
|
33
|
+
this.name = "TicketStoreFullError";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
class AttachmentTicketStore {
|
|
37
|
+
constructor(ttlSeconds) {
|
|
38
|
+
this.ttlSeconds = ttlSeconds;
|
|
39
|
+
this.tickets = /* @__PURE__ */ new Map();
|
|
40
|
+
}
|
|
41
|
+
/** Drop every expired ticket. Called before each mint and each redemption. */
|
|
42
|
+
sweep(nowMs) {
|
|
43
|
+
for (const [id, ticket] of this.tickets) {
|
|
44
|
+
if (ticket.expiresAtMs <= nowMs) this.tickets.delete(id);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
mint(target, accountName, nowMs = Date.now()) {
|
|
48
|
+
this.sweep(nowMs);
|
|
49
|
+
if (this.tickets.size >= MAX_LIVE_TICKETS) {
|
|
50
|
+
throw new TicketStoreFullError(MAX_LIVE_TICKETS);
|
|
51
|
+
}
|
|
52
|
+
const id = randomBytes(TICKET_BYTES).toString("base64url");
|
|
53
|
+
const expiresAtMs = nowMs + this.ttlSeconds * 1e3;
|
|
54
|
+
this.tickets.set(id, { target, accountName, expiresAtMs });
|
|
55
|
+
return { id, expiresAtMs };
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Return the ticket and burn it, or undefined.
|
|
59
|
+
*
|
|
60
|
+
* One `undefined` for every failure -- unknown id, already redeemed, expired.
|
|
61
|
+
* The caller answers 404 to all three, so a probe cannot use the response to
|
|
62
|
+
* tell "never existed" from "already used", which would confirm a guessed id.
|
|
63
|
+
*
|
|
64
|
+
* The delete happens before the value is returned rather than in the caller,
|
|
65
|
+
* so an exception on the streaming path cannot leave a redeemed ticket live.
|
|
66
|
+
*/
|
|
67
|
+
redeem(id, nowMs = Date.now()) {
|
|
68
|
+
this.sweep(nowMs);
|
|
69
|
+
const ticket = this.tickets.get(id);
|
|
70
|
+
if (!ticket) return void 0;
|
|
71
|
+
this.tickets.delete(id);
|
|
72
|
+
return ticket;
|
|
73
|
+
}
|
|
74
|
+
/** Live ticket count, for tests and diagnostics. Never logged with ids. */
|
|
75
|
+
size(nowMs = Date.now()) {
|
|
76
|
+
this.sweep(nowMs);
|
|
77
|
+
return this.tickets.size;
|
|
78
|
+
}
|
|
79
|
+
clear() {
|
|
80
|
+
this.tickets.clear();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export {
|
|
84
|
+
AttachmentTicketStore,
|
|
85
|
+
TICKET_PARAM,
|
|
86
|
+
TicketStoreFullError,
|
|
87
|
+
buildAttachmentUrl,
|
|
88
|
+
isPlainGraphPath
|
|
89
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
const ATTACHMENT_ROUTE = "/attachment";
|
|
3
|
+
const DEFAULT_TTL_SECONDS = 120;
|
|
4
|
+
const MAX_TTL_SECONDS = 300;
|
|
5
|
+
class AttachmentUrlConfigError extends Error {
|
|
6
|
+
constructor(message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "AttachmentUrlConfigError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const PYTHON_WHITESPACE = " \n\v\f\r \x85\xA0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u2028\u2029\u202F\u205F\u3000";
|
|
12
|
+
function pythonStrip(value) {
|
|
13
|
+
let start = 0;
|
|
14
|
+
let end = value.length;
|
|
15
|
+
while (start < end && PYTHON_WHITESPACE.includes(value[start])) start += 1;
|
|
16
|
+
while (end > start && PYTHON_WHITESPACE.includes(value[end - 1])) end -= 1;
|
|
17
|
+
return value.slice(start, end);
|
|
18
|
+
}
|
|
19
|
+
function readKey(env) {
|
|
20
|
+
const keyFile = env.MS365_MCP_ATTACHMENT_URL_KEY_FILE;
|
|
21
|
+
if (keyFile) {
|
|
22
|
+
try {
|
|
23
|
+
return pythonStrip(readFileSync(keyFile, "utf8"));
|
|
24
|
+
} catch {
|
|
25
|
+
throw new AttachmentUrlConfigError(
|
|
26
|
+
`MS365_MCP_ATTACHMENT_URL_KEY_FILE could not be read: ${keyFile}`
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return env.MS365_MCP_ATTACHMENT_URL_KEY ?? "";
|
|
31
|
+
}
|
|
32
|
+
function loadAttachmentUrlConfig(enabled, env = process.env) {
|
|
33
|
+
if (!enabled) return null;
|
|
34
|
+
const base = env.MS365_MCP_ATTACHMENT_URL_BASE ?? "";
|
|
35
|
+
if (!base) {
|
|
36
|
+
throw new AttachmentUrlConfigError(
|
|
37
|
+
"--enable-attachment-urls requires MS365_MCP_ATTACHMENT_URL_BASE (the origin a document-conversion sidecar will fetch, e.g. http://m365-mcp:3000). This is deliberately not MS365_MCP_PUBLIC_URL: that one is browser-facing for OAuth, while this is reached server-to-server and is commonly a container address. With --attachment-port, name that port here, not the --http one: the route is not served on the MCP port at all in that mode."
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
let parsedBase;
|
|
41
|
+
try {
|
|
42
|
+
parsedBase = new URL(base);
|
|
43
|
+
} catch {
|
|
44
|
+
throw new AttachmentUrlConfigError(
|
|
45
|
+
`MS365_MCP_ATTACHMENT_URL_BASE is not a valid absolute URL: ${base}`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
if (parsedBase.protocol !== "http:" && parsedBase.protocol !== "https:") {
|
|
49
|
+
throw new AttachmentUrlConfigError(
|
|
50
|
+
`MS365_MCP_ATTACHMENT_URL_BASE must be http or https, got ${parsedBase.protocol}`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
if (parsedBase.hostname.startsWith("[")) {
|
|
54
|
+
throw new AttachmentUrlConfigError(
|
|
55
|
+
`MS365_MCP_ATTACHMENT_URL_BASE must not be an IPv6 literal: the URL signature covers the host, and this runtime and the verifying sidecar normalise IPv6 spellings differently, so every minted URL would be refused. Use a hostname (a container or service name) instead of ${parsedBase.hostname}.`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
if (parsedBase.search || parsedBase.hash) {
|
|
59
|
+
throw new AttachmentUrlConfigError(
|
|
60
|
+
"MS365_MCP_ATTACHMENT_URL_BASE must not carry a query string or fragment."
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
const key = readKey(env);
|
|
64
|
+
if (!key) {
|
|
65
|
+
throw new AttachmentUrlConfigError(
|
|
66
|
+
"--enable-attachment-urls requires MS365_MCP_ATTACHMENT_URL_KEY or MS365_MCP_ATTACHMENT_URL_KEY_FILE -- the HMAC key shared with the sidecar that will verify the minted URL."
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
for (let index = 0; index < key.length; index += 1) {
|
|
70
|
+
const code = key.charCodeAt(index);
|
|
71
|
+
if (code < 32 || code === 127) {
|
|
72
|
+
throw new AttachmentUrlConfigError(
|
|
73
|
+
`The attachment URL signing key contains a control character at offset ${index} (value not shown).`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const keyId = env.MS365_MCP_ATTACHMENT_URL_KEY_ID || "1";
|
|
78
|
+
const ttlRaw = env.MS365_MCP_ATTACHMENT_URL_TTL_S;
|
|
79
|
+
let ttlSeconds = DEFAULT_TTL_SECONDS;
|
|
80
|
+
if (ttlRaw !== void 0 && ttlRaw !== "") {
|
|
81
|
+
if (!/^\d+$/.test(ttlRaw)) {
|
|
82
|
+
throw new AttachmentUrlConfigError(
|
|
83
|
+
`MS365_MCP_ATTACHMENT_URL_TTL_S must be a positive integer, got ${JSON.stringify(ttlRaw)}`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
ttlSeconds = Number(ttlRaw);
|
|
87
|
+
if (ttlSeconds <= 0 || ttlSeconds > MAX_TTL_SECONDS) {
|
|
88
|
+
throw new AttachmentUrlConfigError(
|
|
89
|
+
`MS365_MCP_ATTACHMENT_URL_TTL_S must be between 1 and ${MAX_TTL_SECONDS}, got ${ttlSeconds}`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
base: parsedBase.origin,
|
|
95
|
+
key,
|
|
96
|
+
keyId,
|
|
97
|
+
ttlSeconds
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
export {
|
|
101
|
+
ATTACHMENT_ROUTE,
|
|
102
|
+
AttachmentUrlConfigError,
|
|
103
|
+
loadAttachmentUrlConfig
|
|
104
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
function quoteAll(value) {
|
|
3
|
+
return Array.from(new TextEncoder().encode(value)).map((byte) => {
|
|
4
|
+
const char = String.fromCharCode(byte);
|
|
5
|
+
if (/[A-Za-z0-9_.\-~]/.test(char)) return char;
|
|
6
|
+
return "%" + byte.toString(16).toUpperCase().padStart(2, "0");
|
|
7
|
+
}).join("");
|
|
8
|
+
}
|
|
9
|
+
function unquotePlus(value) {
|
|
10
|
+
const withSpaces = value.replace(/\+/g, " ");
|
|
11
|
+
const bytes = [];
|
|
12
|
+
for (let i = 0; i < withSpaces.length; i += 1) {
|
|
13
|
+
const char = withSpaces[i];
|
|
14
|
+
if (char === "%" && /^[0-9A-Fa-f]{2}$/.test(withSpaces.slice(i + 1, i + 3))) {
|
|
15
|
+
bytes.push(parseInt(withSpaces.slice(i + 1, i + 3), 16));
|
|
16
|
+
i += 2;
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
for (const byte of new TextEncoder().encode(char)) bytes.push(byte);
|
|
20
|
+
}
|
|
21
|
+
return new TextDecoder("utf-8", { fatal: false }).decode(new Uint8Array(bytes));
|
|
22
|
+
}
|
|
23
|
+
function compareByCodePoint(a, b) {
|
|
24
|
+
const left = Array.from(a);
|
|
25
|
+
const right = Array.from(b);
|
|
26
|
+
const shared = Math.min(left.length, right.length);
|
|
27
|
+
for (let i = 0; i < shared; i += 1) {
|
|
28
|
+
const diff = left[i].codePointAt(0) - right[i].codePointAt(0);
|
|
29
|
+
if (diff !== 0) return diff;
|
|
30
|
+
}
|
|
31
|
+
return left.length - right.length;
|
|
32
|
+
}
|
|
33
|
+
const RESERVED_PARAMS = /* @__PURE__ */ new Set(["dgk", "dgx", "dgs"]);
|
|
34
|
+
function parseQsl(query) {
|
|
35
|
+
const pairs = [];
|
|
36
|
+
for (const field of query.split("&")) {
|
|
37
|
+
if (!field) continue;
|
|
38
|
+
const eq = field.indexOf("=");
|
|
39
|
+
const rawName = eq === -1 ? field : field.slice(0, eq);
|
|
40
|
+
const rawValue = eq === -1 ? "" : field.slice(eq + 1);
|
|
41
|
+
pairs.push([unquotePlus(rawName), unquotePlus(rawValue)]);
|
|
42
|
+
}
|
|
43
|
+
return pairs;
|
|
44
|
+
}
|
|
45
|
+
function canonicalQuery(query) {
|
|
46
|
+
const pairs = parseQsl(query).filter(([name]) => !RESERVED_PARAMS.has(name));
|
|
47
|
+
pairs.sort((a, b) => compareByCodePoint(a[0], b[0]) || compareByCodePoint(a[1], b[1]));
|
|
48
|
+
return pairs.map(([name, value]) => `${quoteAll(name)}=${quoteAll(value)}`).join("&");
|
|
49
|
+
}
|
|
50
|
+
function canonicalString(url, expiry) {
|
|
51
|
+
const parsed = new URL(url);
|
|
52
|
+
const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
|
|
53
|
+
const host = parsed.hostname.toLowerCase().replace(/^\[(.+)\]$/, "$1");
|
|
54
|
+
const port = parsed.port === "" ? scheme === "https" ? 443 : 80 : Number(parsed.port);
|
|
55
|
+
const path = parsed.pathname === "" ? "/" : parsed.pathname;
|
|
56
|
+
const query = canonicalQuery(parsed.search.replace(/^\?/, ""));
|
|
57
|
+
return ["v1", scheme, host, String(port), path, query, expiry].join("\n");
|
|
58
|
+
}
|
|
59
|
+
function digest(key, message) {
|
|
60
|
+
return createHmac("sha256", Buffer.from(key, "utf8")).update(Buffer.from(message, "utf8")).digest("base64url");
|
|
61
|
+
}
|
|
62
|
+
function signUrl(url, config, nowMs = Date.now()) {
|
|
63
|
+
const expiry = String(Math.floor(nowMs / 1e3) + config.ttlSeconds);
|
|
64
|
+
const signature = digest(config.key, canonicalString(url, expiry));
|
|
65
|
+
const parsed = new URL(url);
|
|
66
|
+
const kept = parseQsl(parsed.search.replace(/^\?/, "")).filter(
|
|
67
|
+
([name]) => !RESERVED_PARAMS.has(name)
|
|
68
|
+
);
|
|
69
|
+
kept.push(["dgk", config.keyId], ["dgx", expiry], ["dgs", signature]);
|
|
70
|
+
parsed.search = kept.map(([name, value]) => `${quoteAll(name)}=${quoteAll(value)}`).join("&");
|
|
71
|
+
return parsed.toString();
|
|
72
|
+
}
|
|
73
|
+
export {
|
|
74
|
+
canonicalString,
|
|
75
|
+
compareByCodePoint,
|
|
76
|
+
digest,
|
|
77
|
+
parseQsl,
|
|
78
|
+
quoteAll,
|
|
79
|
+
signUrl,
|
|
80
|
+
unquotePlus
|
|
81
|
+
};
|
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/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.151.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",
|