@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/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.
|
|
@@ -42,6 +42,52 @@ describe("GraphClient audit metadata", () => {
|
|
|
42
42
|
expect(result._meta).toMatchObject({ http_status: 200 });
|
|
43
43
|
expect(JSON.parse(result.content[0].text)).toEqual({ id: "user-1" });
|
|
44
44
|
});
|
|
45
|
+
it("derives result volume from a collection response", async () => {
|
|
46
|
+
const body = JSON.stringify({
|
|
47
|
+
value: [{ id: "m1" }, { id: "m2" }, { id: "m3" }],
|
|
48
|
+
"@odata.nextLink": "https://graph.microsoft.com/v1.0/me/messages?$skip=3"
|
|
49
|
+
});
|
|
50
|
+
fetchWithResilienceMock.mockResolvedValue(
|
|
51
|
+
new Response(body, { status: 200, headers: { "content-type": "application/json" } })
|
|
52
|
+
);
|
|
53
|
+
const result = await createGraphClient().graphRequest("/me/messages");
|
|
54
|
+
expect(result._meta).toMatchObject({
|
|
55
|
+
result_count: 3,
|
|
56
|
+
result_has_more: true,
|
|
57
|
+
response_bytes: Buffer.byteLength(body, "utf8")
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
it("reports result_has_more false, not absent, for a complete collection", async () => {
|
|
61
|
+
fetchWithResilienceMock.mockResolvedValue(
|
|
62
|
+
new Response(JSON.stringify({ value: [{ id: "m1" }] }), {
|
|
63
|
+
status: 200,
|
|
64
|
+
headers: { "content-type": "application/json" }
|
|
65
|
+
})
|
|
66
|
+
);
|
|
67
|
+
const result = await createGraphClient().graphRequest("/me/messages");
|
|
68
|
+
expect(result._meta).toMatchObject({ result_count: 1, result_has_more: false });
|
|
69
|
+
});
|
|
70
|
+
it("omits result fields for a single-object response but still records size", async () => {
|
|
71
|
+
const body = JSON.stringify({ id: "user-1" });
|
|
72
|
+
fetchWithResilienceMock.mockResolvedValue(
|
|
73
|
+
new Response(body, { status: 200, headers: { "content-type": "application/json" } })
|
|
74
|
+
);
|
|
75
|
+
const result = await createGraphClient().graphRequest("/me");
|
|
76
|
+
expect(result._meta).toMatchObject({ response_bytes: Buffer.byteLength(body, "utf8") });
|
|
77
|
+
expect(result._meta).not.toHaveProperty("result_count");
|
|
78
|
+
expect(result._meta).not.toHaveProperty("result_has_more");
|
|
79
|
+
});
|
|
80
|
+
it("records the pre-base64 byte count for binary content", async () => {
|
|
81
|
+
const raw = Buffer.from("binary-attachment-payload-\xFF\xFE");
|
|
82
|
+
fetchWithResilienceMock.mockResolvedValue(
|
|
83
|
+
new Response(raw, {
|
|
84
|
+
status: 200,
|
|
85
|
+
headers: { "content-type": "application/octet-stream" }
|
|
86
|
+
})
|
|
87
|
+
);
|
|
88
|
+
const result = await createGraphClient().graphRequest("/me/messages/m1/$value");
|
|
89
|
+
expect(result._meta).toMatchObject({ response_bytes: raw.byteLength });
|
|
90
|
+
});
|
|
45
91
|
it("preserves HTTP status metadata when response headers are requested", async () => {
|
|
46
92
|
fetchWithResilienceMock.mockResolvedValue(
|
|
47
93
|
new Response(JSON.stringify({ id: "task-1" }), {
|
|
@@ -333,6 +333,172 @@ describe("graph-tools", () => {
|
|
|
333
333
|
);
|
|
334
334
|
});
|
|
335
335
|
});
|
|
336
|
+
describe("audit response volume", () => {
|
|
337
|
+
it("lifts result volume from _meta onto the audit event", async () => {
|
|
338
|
+
const endpoint = makeEndpoint({
|
|
339
|
+
method: "get",
|
|
340
|
+
path: "/me/messages",
|
|
341
|
+
alias: "list-mail-messages"
|
|
342
|
+
});
|
|
343
|
+
const config = makeConfig({
|
|
344
|
+
pathPattern: "/me/messages",
|
|
345
|
+
method: "get",
|
|
346
|
+
toolName: "list-mail-messages"
|
|
347
|
+
});
|
|
348
|
+
mockEndpoints.push(endpoint);
|
|
349
|
+
mockEndpointsJson = [config];
|
|
350
|
+
const graphClient = createMockGraphClient([
|
|
351
|
+
{
|
|
352
|
+
content: [{ type: "text", text: JSON.stringify({ value: [{ id: "m1" }] }) }],
|
|
353
|
+
_meta: {
|
|
354
|
+
http_status: 200,
|
|
355
|
+
result_count: 4821,
|
|
356
|
+
result_has_more: true,
|
|
357
|
+
response_bytes: 8412004
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
]);
|
|
361
|
+
const server = createMockServer();
|
|
362
|
+
const { registerGraphTools } = await loadModule();
|
|
363
|
+
registerGraphTools(
|
|
364
|
+
server,
|
|
365
|
+
graphClient
|
|
366
|
+
);
|
|
367
|
+
await server.tools.get("list-mail-messages").handler({});
|
|
368
|
+
expect(auditLogMock).toHaveBeenCalledWith(
|
|
369
|
+
expect.objectContaining({
|
|
370
|
+
tool: "list-mail-messages",
|
|
371
|
+
status: "success",
|
|
372
|
+
result_count: 4821,
|
|
373
|
+
result_has_more: true,
|
|
374
|
+
response_bytes: 8412004
|
|
375
|
+
})
|
|
376
|
+
);
|
|
377
|
+
});
|
|
378
|
+
it("keeps result_has_more when it is false rather than dropping it", async () => {
|
|
379
|
+
const endpoint = makeEndpoint({
|
|
380
|
+
method: "get",
|
|
381
|
+
path: "/me/messages",
|
|
382
|
+
alias: "list-mail-messages"
|
|
383
|
+
});
|
|
384
|
+
const config = makeConfig({
|
|
385
|
+
pathPattern: "/me/messages",
|
|
386
|
+
method: "get",
|
|
387
|
+
toolName: "list-mail-messages"
|
|
388
|
+
});
|
|
389
|
+
mockEndpoints.push(endpoint);
|
|
390
|
+
mockEndpointsJson = [config];
|
|
391
|
+
const graphClient = createMockGraphClient([
|
|
392
|
+
{
|
|
393
|
+
content: [{ type: "text", text: JSON.stringify({ value: [] }) }],
|
|
394
|
+
_meta: { http_status: 200, result_count: 0, result_has_more: false }
|
|
395
|
+
}
|
|
396
|
+
]);
|
|
397
|
+
const server = createMockServer();
|
|
398
|
+
const { registerGraphTools } = await loadModule();
|
|
399
|
+
registerGraphTools(
|
|
400
|
+
server,
|
|
401
|
+
graphClient
|
|
402
|
+
);
|
|
403
|
+
await server.tools.get("list-mail-messages").handler({});
|
|
404
|
+
const [payload] = auditLogMock.mock.calls[0];
|
|
405
|
+
expect(payload.result_count).toBe(0);
|
|
406
|
+
expect(payload.result_has_more).toBe(false);
|
|
407
|
+
});
|
|
408
|
+
it("restates count and bytes for the whole read when pages are merged", async () => {
|
|
409
|
+
mockEndpoints.push(makeEndpoint());
|
|
410
|
+
mockEndpointsJson = [makeConfig()];
|
|
411
|
+
const graphClient = createMockGraphClient([
|
|
412
|
+
{
|
|
413
|
+
content: [
|
|
414
|
+
{
|
|
415
|
+
type: "text",
|
|
416
|
+
text: JSON.stringify({
|
|
417
|
+
value: [{ id: "1" }, { id: "2" }],
|
|
418
|
+
"@odata.nextLink": "https://graph.microsoft.com/v1.0/me/messages?$skip=2"
|
|
419
|
+
})
|
|
420
|
+
}
|
|
421
|
+
],
|
|
422
|
+
_meta: { http_status: 200, result_count: 2, result_has_more: true, response_bytes: 1e3 }
|
|
423
|
+
},
|
|
424
|
+
{
|
|
425
|
+
content: [{ type: "text", text: JSON.stringify({ value: [{ id: "3" }] }) }],
|
|
426
|
+
_meta: { http_status: 200, result_count: 1, result_has_more: false, response_bytes: 700 }
|
|
427
|
+
}
|
|
428
|
+
]);
|
|
429
|
+
const server = createMockServer();
|
|
430
|
+
const { registerGraphTools } = await loadModule();
|
|
431
|
+
registerGraphTools(server, graphClient);
|
|
432
|
+
await server.tools.get("test-tool").handler({ fetchAllPages: true });
|
|
433
|
+
const [payload] = auditLogMock.mock.calls[0];
|
|
434
|
+
expect(payload.result_count).toBe(3);
|
|
435
|
+
expect(payload.result_has_more).toBe(false);
|
|
436
|
+
expect(payload.response_bytes).toBe(1700);
|
|
437
|
+
});
|
|
438
|
+
it("reports result_has_more when the merge loop stopped on a page cap", async () => {
|
|
439
|
+
const prevMaxPages = process.env.MS365_MCP_MAX_PAGES;
|
|
440
|
+
process.env.MS365_MCP_MAX_PAGES = "2";
|
|
441
|
+
try {
|
|
442
|
+
mockEndpoints.push(makeEndpoint());
|
|
443
|
+
mockEndpointsJson = [makeConfig()];
|
|
444
|
+
const graphClient = createMockGraphClient(
|
|
445
|
+
Array.from({ length: 5 }, (_, i) => ({
|
|
446
|
+
content: [
|
|
447
|
+
{
|
|
448
|
+
type: "text",
|
|
449
|
+
text: JSON.stringify({
|
|
450
|
+
value: [{ id: `item-${i}` }],
|
|
451
|
+
"@odata.nextLink": `https://graph.microsoft.com/v1.0/me/messages?$skip=${i + 1}`
|
|
452
|
+
})
|
|
453
|
+
}
|
|
454
|
+
],
|
|
455
|
+
_meta: { http_status: 200, result_count: 1, result_has_more: true, response_bytes: 50 }
|
|
456
|
+
}))
|
|
457
|
+
);
|
|
458
|
+
const server = createMockServer();
|
|
459
|
+
const { registerGraphTools } = await loadModule();
|
|
460
|
+
registerGraphTools(server, graphClient);
|
|
461
|
+
await server.tools.get("test-tool").handler({ fetchAllPages: true });
|
|
462
|
+
const [payload] = auditLogMock.mock.calls[0];
|
|
463
|
+
expect(payload.result_count).toBe(2);
|
|
464
|
+
expect(payload.result_has_more).toBe(true);
|
|
465
|
+
expect(payload.response_bytes).toBe(100);
|
|
466
|
+
} finally {
|
|
467
|
+
if (prevMaxPages === void 0) {
|
|
468
|
+
delete process.env.MS365_MCP_MAX_PAGES;
|
|
469
|
+
} else {
|
|
470
|
+
process.env.MS365_MCP_MAX_PAGES = prevMaxPages;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
it("omits the volume fields when the client supplied none", async () => {
|
|
475
|
+
const endpoint = makeEndpoint({ method: "get", path: "/me", alias: "get-current-user" });
|
|
476
|
+
const config = makeConfig({
|
|
477
|
+
pathPattern: "/me",
|
|
478
|
+
method: "get",
|
|
479
|
+
toolName: "get-current-user"
|
|
480
|
+
});
|
|
481
|
+
mockEndpoints.push(endpoint);
|
|
482
|
+
mockEndpointsJson = [config];
|
|
483
|
+
const graphClient = createMockGraphClient([
|
|
484
|
+
{
|
|
485
|
+
content: [{ type: "text", text: JSON.stringify({ id: "user-1" }) }],
|
|
486
|
+
_meta: { http_status: 200 }
|
|
487
|
+
}
|
|
488
|
+
]);
|
|
489
|
+
const server = createMockServer();
|
|
490
|
+
const { registerGraphTools } = await loadModule();
|
|
491
|
+
registerGraphTools(
|
|
492
|
+
server,
|
|
493
|
+
graphClient
|
|
494
|
+
);
|
|
495
|
+
await server.tools.get("get-current-user").handler({});
|
|
496
|
+
const [payload] = auditLogMock.mock.calls[0];
|
|
497
|
+
expect(payload).not.toHaveProperty("result_count");
|
|
498
|
+
expect(payload).not.toHaveProperty("result_has_more");
|
|
499
|
+
expect(payload).not.toHaveProperty("response_bytes");
|
|
500
|
+
});
|
|
501
|
+
});
|
|
336
502
|
describe("audit recipient metadata", () => {
|
|
337
503
|
const draftEndpoint = () => {
|
|
338
504
|
const endpoint = makeEndpoint({
|
|
@@ -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
|
@@ -66,6 +66,13 @@ function extractGraphErrorCodeFromBody(body) {
|
|
|
66
66
|
const code = isRecord(error) ? error.code : body.code;
|
|
67
67
|
return typeof code === "string" ? code : void 0;
|
|
68
68
|
}
|
|
69
|
+
function extractPayloadMetadata(data) {
|
|
70
|
+
if (!isRecord(data) || !Array.isArray(data.value)) return {};
|
|
71
|
+
return {
|
|
72
|
+
result_count: data.value.length,
|
|
73
|
+
result_has_more: typeof data["@odata.nextLink"] === "string"
|
|
74
|
+
};
|
|
75
|
+
}
|
|
69
76
|
function extractBatchMetadata(data) {
|
|
70
77
|
if (!isRecord(data) || !Array.isArray(data.responses)) return {};
|
|
71
78
|
const httpStatusCounts = {};
|
|
@@ -125,8 +132,10 @@ class GraphClient {
|
|
|
125
132
|
contentLength: buffer.byteLength,
|
|
126
133
|
contentBytes: buffer.toString("base64")
|
|
127
134
|
};
|
|
135
|
+
metadata = { ...metadata, response_bytes: buffer.byteLength };
|
|
128
136
|
} else {
|
|
129
137
|
const text = await response.text();
|
|
138
|
+
metadata = { ...metadata, response_bytes: Buffer.byteLength(text, "utf8") };
|
|
130
139
|
if (text === "") {
|
|
131
140
|
result = { message: TRANSPORT_OK_MESSAGE };
|
|
132
141
|
} else if (options.rawResponse) {
|
|
@@ -142,6 +151,7 @@ class GraphClient {
|
|
|
142
151
|
if (endpoint === "/$batch") {
|
|
143
152
|
metadata = { ...metadata, ...extractBatchMetadata(result) };
|
|
144
153
|
}
|
|
154
|
+
metadata = { ...metadata, ...extractPayloadMetadata(result) };
|
|
145
155
|
if (options.includeHeaders) {
|
|
146
156
|
const etag = response.headers.get("ETag") || response.headers.get("etag");
|
|
147
157
|
if (result && typeof result === "object" && !Array.isArray(result)) {
|
|
@@ -167,6 +177,55 @@ class GraphClient {
|
|
|
167
177
|
* memory or hit V8's max string length. Creates the file with wx + 0o600 (never
|
|
168
178
|
* overwrites) and removes a partial file if the transfer fails.
|
|
169
179
|
*/
|
|
180
|
+
/**
|
|
181
|
+
* Fetch Graph binary content and hand back the undrained response stream.
|
|
182
|
+
*
|
|
183
|
+
* Same auth, same resilience and the same error mapping as `downloadToFile`,
|
|
184
|
+
* which is the reason this exists rather than the attachment route calling
|
|
185
|
+
* `fetch` for itself: token acquisition, the OBO/bearer context tokens, retry
|
|
186
|
+
* and the 403-scope special case are all in `performRequest`, which is
|
|
187
|
+
* private. Splitting them would give the route a second, quietly divergent
|
|
188
|
+
* copy of the auth path.
|
|
189
|
+
*
|
|
190
|
+
* The caller owns the body from here and MUST consume or cancel it -- an
|
|
191
|
+
* abandoned stream holds a socket open until the agent times out.
|
|
192
|
+
*/
|
|
193
|
+
async downloadStream(endpoint, options = {}) {
|
|
194
|
+
const contextTokens = getRequestTokens();
|
|
195
|
+
const accessToken = options.accessToken ?? contextTokens?.accessToken ?? await this.authManager.getToken();
|
|
196
|
+
if (!accessToken) {
|
|
197
|
+
throw new Error("No access token available");
|
|
198
|
+
}
|
|
199
|
+
const response = await this.performRequest(endpoint, accessToken, options);
|
|
200
|
+
if (response.status === 403) {
|
|
201
|
+
const errorText = await response.text();
|
|
202
|
+
if (errorText.includes("scope") || errorText.includes("permission")) {
|
|
203
|
+
throw new Error(
|
|
204
|
+
`Microsoft Graph API scope error: ${response.status} ${response.statusText} - ${errorText}. This tool requires organization mode. Please restart with --org-mode flag.`
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
throw new Error(
|
|
208
|
+
`Microsoft Graph API error: ${response.status} ${response.statusText} - ${errorText}`
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
if (!response.ok) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
`Microsoft Graph API error: ${response.status} ${response.statusText} - ${await response.text()}`
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
if (!response.body) {
|
|
217
|
+
throw new Error("Microsoft Graph returned an empty response body");
|
|
218
|
+
}
|
|
219
|
+
const rawLength = response.headers.get("content-length");
|
|
220
|
+
const decoded = response.headers.get("content-encoding") !== null;
|
|
221
|
+
const declaredLength = !decoded && rawLength !== null && /^\d+$/.test(rawLength.trim());
|
|
222
|
+
return {
|
|
223
|
+
body: response.body,
|
|
224
|
+
contentType: response.headers.get("content-type") || "application/octet-stream",
|
|
225
|
+
contentLength: declaredLength ? Number(rawLength) : null,
|
|
226
|
+
contentDisposition: response.headers.get("content-disposition")
|
|
227
|
+
};
|
|
228
|
+
}
|
|
170
229
|
async downloadToFile(endpoint, destinationPath, options = {}) {
|
|
171
230
|
const fileHandle = await open(destinationPath, "wx", 384);
|
|
172
231
|
let completed = false;
|