@softeria/ms-365-mcp-server 0.164.0 → 0.165.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 +4 -0
- package/dist/__tests__/blob-output.test.js +106 -0
- package/dist/__tests__/graph-tools.test.js +22 -0
- package/dist/cli.js +6 -0
- package/dist/endpoints.json +32 -0
- package/dist/generated/client.js +53 -0
- package/dist/graph-client.js +59 -3
- package/dist/graph-tools.js +30 -7
- package/package.json +1 -1
- package/src/endpoints.json +32 -0
package/README.md
CHANGED
|
@@ -715,6 +715,8 @@ When running as an MCP server, the following options can be used:
|
|
|
715
715
|
--preset <names> Use preset tool categories (comma-separated). See "Tool Presets" section above
|
|
716
716
|
--list-presets List all available presets and exit
|
|
717
717
|
--toon (experimental) Enable TOON output format for 30-60% token reduction
|
|
718
|
+
--blob-output File download tools answer an MCP EmbeddedResource with the file instead of
|
|
719
|
+
base64 JSON in a text block (see MS365_MCP_BLOB_OUTPUT below)
|
|
718
720
|
--discovery Dynamic tool discovery: loads tools on demand to reduce initial token usage (see "Dynamic Tool Discovery" above)
|
|
719
721
|
--public-url <url> Public base URL for OAuth when behind a reverse proxy (see Open WebUI section and docs/deployment.md)
|
|
720
722
|
```
|
|
@@ -730,6 +732,8 @@ Environment variables:
|
|
|
730
732
|
- `MS365_MCP_MAX_PAGES=<n>`: Maximum number of pages followed when a tool is called with `fetchAllPages: true` (positive integer, default `100`). Bounds memory and latency for large result sets.
|
|
731
733
|
- `MS365_MCP_MAX_ITEMS=<n>`: Maximum number of items accumulated when `fetchAllPages: true` (positive integer, default `10000`). Pagination stops and the response is truncated once this many items are collected.
|
|
732
734
|
- `MS365_MCP_ALLOW_PAGINATION=0|false|no`: Disable multi-page following entirely. When set, the `fetchAllPages` parameter is not advertised on tools, and any request that still passes it returns only the first page (default: pagination enabled).
|
|
735
|
+
- `MS365_MCP_BLOB_OUTPUT=true|1`: File download tools (`download-bytes`, `get-mail-message-mime`, `get-shared-mailbox-message-mime`, `get-mail-attachment-content`, `get-shared-mailbox-attachment-content`) answer a short JSON text block (`name`, `contentType`, `size`) and an MCP `EmbeddedResource` with `BlobResourceContents` (`uri`, `mimeType`, `blob`) instead of base64 JSON in text. The `uri`'s last segment is the optional `fileName` argument. Default: off. Alternative to the --blob-output flag.
|
|
736
|
+
- `MS365_MCP_BLOB_MAX_BYTES=<n>`: With blob output on, the largest file a download tool reads (positive integer, default `5242880`). The server stops reading past it and answers an error naming the limit.
|
|
733
737
|
- `MS365_MCP_BODY_FORMAT=html`: Return email bodies as HTML instead of plain text (default: text)
|
|
734
738
|
- `MS365_MCP_MESSAGE_SIGNOFF_PREFIX=<text>`: Signoff prepended to outgoing messages so recipients can tell they were agent-sent, e.g. `🤖`. Default: none. CLI equivalent: `--message-signoff-prefix <text>` (see Message Signoff below)
|
|
735
739
|
- `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)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import http from "http";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import GraphClient from "../graph-client.js";
|
|
4
|
+
import { fetchWithResilience, loadResilienceConfig } from "../lib/graph-resilience.js";
|
|
5
|
+
vi.mock("../logger.js", () => ({
|
|
6
|
+
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }
|
|
7
|
+
}));
|
|
8
|
+
vi.mock("../lib/graph-resilience.js", async (importOriginal) => ({
|
|
9
|
+
...await importOriginal(),
|
|
10
|
+
fetchWithResilience: vi.fn()
|
|
11
|
+
}));
|
|
12
|
+
const fetchMock = vi.mocked(fetchWithResilience);
|
|
13
|
+
const client = () => new GraphClient({ getToken: vi.fn().mockResolvedValue("token") }, {
|
|
14
|
+
clientId: "c",
|
|
15
|
+
tenantId: "t",
|
|
16
|
+
cloudType: "global"
|
|
17
|
+
});
|
|
18
|
+
const pdf = Buffer.from("%PDF-1.4 test bytes \0\xFF", "latin1");
|
|
19
|
+
const opts = { forceBinary: true, maxBytes: 1024, blobOutput: { fileName: "a b.pdf" } };
|
|
20
|
+
describe("blob output", () => {
|
|
21
|
+
beforeEach(() => vi.clearAllMocks());
|
|
22
|
+
it("answers a text block and an EmbeddedResource with the exact bytes", async () => {
|
|
23
|
+
fetchMock.mockResolvedValue(
|
|
24
|
+
new Response(pdf, { status: 200, headers: { "content-type": "application/pdf" } })
|
|
25
|
+
);
|
|
26
|
+
const res = await client().graphRequest("/me/messages/m/attachments/a/$value", opts);
|
|
27
|
+
expect(JSON.parse(res.content[0].text)).toEqual({
|
|
28
|
+
name: "a b.pdf",
|
|
29
|
+
contentType: "application/pdf",
|
|
30
|
+
size: pdf.length
|
|
31
|
+
});
|
|
32
|
+
const item = res.content[1];
|
|
33
|
+
expect(item.type).toBe("resource");
|
|
34
|
+
expect(item.resource.uri).toBe("file:///a%20b.pdf");
|
|
35
|
+
expect(item.resource.mimeType).toBe("application/pdf");
|
|
36
|
+
expect(Buffer.from(item.resource.blob, "base64").equals(pdf)).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
it("keeps the old base64 JSON when blob output is not asked for", async () => {
|
|
39
|
+
fetchMock.mockResolvedValue(
|
|
40
|
+
new Response(pdf, { headers: { "content-type": "application/pdf" } })
|
|
41
|
+
);
|
|
42
|
+
const res = await client().graphRequest("/x/$value", { forceBinary: true });
|
|
43
|
+
expect(res.content).toHaveLength(1);
|
|
44
|
+
expect(JSON.parse(res.content[0].text).contentBytes).toBe(pdf.toString("base64"));
|
|
45
|
+
});
|
|
46
|
+
it("refuses a body over the cap by Content-Length without reading it", async () => {
|
|
47
|
+
fetchMock.mockResolvedValue(
|
|
48
|
+
new Response(Buffer.alloc(2048), { headers: { "content-length": "2048" } })
|
|
49
|
+
);
|
|
50
|
+
const res = await client().graphRequest("/x/$value", opts);
|
|
51
|
+
expect(res.isError).toBe(true);
|
|
52
|
+
expect(res.content[0].text).toContain("download limit of 1024 bytes");
|
|
53
|
+
});
|
|
54
|
+
it("stops reading a body over the cap when no length is declared", async () => {
|
|
55
|
+
let pulled = 0;
|
|
56
|
+
const body = new ReadableStream({
|
|
57
|
+
pull(controller) {
|
|
58
|
+
pulled += 512;
|
|
59
|
+
controller.enqueue(new Uint8Array(512));
|
|
60
|
+
if (pulled >= 1024 * 1024) controller.close();
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
fetchMock.mockResolvedValue(new Response(body));
|
|
64
|
+
const res = await client().graphRequest("/x/$value", opts);
|
|
65
|
+
expect(res.isError).toBe(true);
|
|
66
|
+
expect(res.content[0].text).toContain("download limit of 1024 bytes");
|
|
67
|
+
expect(pulled).toBeLessThan(16 * 1024);
|
|
68
|
+
});
|
|
69
|
+
it("accepts a body exactly at the cap", async () => {
|
|
70
|
+
fetchMock.mockResolvedValue(new Response(Buffer.alloc(1024)));
|
|
71
|
+
const res = await client().graphRequest("/x/$value", opts);
|
|
72
|
+
expect(res.isError).toBeUndefined();
|
|
73
|
+
expect(JSON.parse(res.content[0].text).size).toBe(1024);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
describe("redirects that leave the Graph origin", () => {
|
|
77
|
+
const servers = [];
|
|
78
|
+
const listen = (h) => new Promise((resolve) => {
|
|
79
|
+
const s = http.createServer(h).listen(0, "127.0.0.1", () => {
|
|
80
|
+
servers.push(s);
|
|
81
|
+
resolve(`http://127.0.0.1:${s.address().port}`);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
afterEach(() => servers.splice(0).forEach((s) => s.close()));
|
|
85
|
+
it("arrive without the Authorization header", async () => {
|
|
86
|
+
const real = await vi.importActual(
|
|
87
|
+
"../lib/graph-resilience.js"
|
|
88
|
+
);
|
|
89
|
+
let seen = "unset";
|
|
90
|
+
const other = await listen((req, res2) => {
|
|
91
|
+
seen = req.headers.authorization;
|
|
92
|
+
res2.end("bytes");
|
|
93
|
+
});
|
|
94
|
+
const graph = await listen((_req, res2) => {
|
|
95
|
+
res2.writeHead(302, { location: `${other}/file` }).end();
|
|
96
|
+
});
|
|
97
|
+
const res = await real.fetchWithResilience(
|
|
98
|
+
`${graph}/v1.0/x`,
|
|
99
|
+
{ headers: { Authorization: "Bearer secret" } },
|
|
100
|
+
loadResilienceConfig(),
|
|
101
|
+
real.getSharedBreaker()
|
|
102
|
+
);
|
|
103
|
+
expect(await res.text()).toBe("bytes");
|
|
104
|
+
expect(seen).toBeUndefined();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -2256,6 +2256,28 @@ describe("graph-tools", () => {
|
|
|
2256
2256
|
expect(options.accessToken).toBeUndefined();
|
|
2257
2257
|
expect(options.forceBinary).toBe(true);
|
|
2258
2258
|
expect(options.rawResponse).toBe(true);
|
|
2259
|
+
expect(options.blobOutput).toBeUndefined();
|
|
2260
|
+
expect(options.maxBytes).toBeUndefined();
|
|
2261
|
+
});
|
|
2262
|
+
it("asks for a capped blob resource when blob output is on", async () => {
|
|
2263
|
+
mockEndpoints.length = 0;
|
|
2264
|
+
mockEndpointsJson = [];
|
|
2265
|
+
process.env.MS365_MCP_BLOB_OUTPUT = "true";
|
|
2266
|
+
process.env.MS365_MCP_BLOB_MAX_BYTES = "1234";
|
|
2267
|
+
try {
|
|
2268
|
+
const graphClient = { graphRequest: vi.fn().mockResolvedValue({ content: [] }) };
|
|
2269
|
+
const server = createMockServer();
|
|
2270
|
+
const { registerGraphTools } = await loadModule();
|
|
2271
|
+
registerGraphTools(server, graphClient);
|
|
2272
|
+
await server.tools.get("download-bytes").handler({ target: "/me/messages/m1/attachments/a1/$value", fileName: "drawing.pdf" });
|
|
2273
|
+
const [, options] = graphClient.graphRequest.mock.calls[0];
|
|
2274
|
+
expect(options.forceBinary).toBe(true);
|
|
2275
|
+
expect(options.maxBytes).toBe(1234);
|
|
2276
|
+
expect(options.blobOutput).toEqual({ fileName: "drawing.pdf" });
|
|
2277
|
+
} finally {
|
|
2278
|
+
delete process.env.MS365_MCP_BLOB_OUTPUT;
|
|
2279
|
+
delete process.env.MS365_MCP_BLOB_MAX_BYTES;
|
|
2280
|
+
}
|
|
2259
2281
|
});
|
|
2260
2282
|
it("rejects absolute URLs (Graph paths only)", async () => {
|
|
2261
2283
|
mockEndpoints.length = 0;
|
package/dist/cli.js
CHANGED
|
@@ -76,6 +76,9 @@ program.name("ms-365-mcp-server").description("Microsoft 365 MCP Server").versio
|
|
|
76
76
|
).option(
|
|
77
77
|
"--http-local-file-tools",
|
|
78
78
|
"In HTTP mode, also register the local file tools: download-bytes-to-file, which writes files on the server as the server's user, and upload-attachment-from-file, which reads files from the MS365_MCP_UPLOAD_DIRS directories when that is set. Anyone who can reach the port can use them, so enable only on a single-user machine. Refused unless --http binds a loopback host with no --public-url and no --trust-proxy-auth."
|
|
79
|
+
).option(
|
|
80
|
+
"--blob-output",
|
|
81
|
+
"File download tools (get-mail-message-mime, get-shared-mailbox-message-mime, get-mail-attachment-content, get-shared-mailbox-attachment-content) answer an MCP EmbeddedResource with the file instead of base64 JSON in a text block. Off by default."
|
|
79
82
|
).option(
|
|
80
83
|
"--allow-unauthenticated-discovery",
|
|
81
84
|
"In HTTP mode, allow MCP discovery requests (initialize, tools/list, prompts/list, resources/list, ping) without a bearer token, so a gateway can enumerate the tool catalog before any user has authenticated. Non-discovery requests (e.g. tools/call) still require a token. Off by default."
|
|
@@ -98,6 +101,9 @@ function parseArgs() {
|
|
|
98
101
|
process.env.MS365_MCP_MESSAGE_SIGNOFF_SUFFIX = "";
|
|
99
102
|
process.env.MS365_MCP_MESSAGE_SIGNOFF_PREFIX = "";
|
|
100
103
|
}
|
|
104
|
+
if (options.blobOutput) {
|
|
105
|
+
process.env.MS365_MCP_BLOB_OUTPUT = "true";
|
|
106
|
+
}
|
|
101
107
|
assertSignoffMarkersVisible();
|
|
102
108
|
assertUploadDirsAbsolute();
|
|
103
109
|
if (options.listPresets) {
|
package/dist/endpoints.json
CHANGED
|
@@ -949,8 +949,40 @@
|
|
|
949
949
|
"presets": ["mail", "outlook", "personal"],
|
|
950
950
|
"scopes": ["Mail.Read"],
|
|
951
951
|
"acceptType": "text/plain",
|
|
952
|
+
"blobOutput": true,
|
|
952
953
|
"llmTip": "Download an email message as raw RFC 5322 MIME content (.eml format). Use this when archiving an email to disk preserving all original headers, body, and inline-encoded attachments. Returns the MIME stream as text. Find the message id with list-mail-messages first."
|
|
953
954
|
},
|
|
955
|
+
{
|
|
956
|
+
"pathPattern": "/users/{user-id}/messages/{message-id}/$value",
|
|
957
|
+
"method": "get",
|
|
958
|
+
"toolName": "get-shared-mailbox-message-mime",
|
|
959
|
+
"descriptionOverride": "Download the raw MIME source (RFC 5322 .eml content) of a message in another user's mailbox or a shared mailbox, by message ID. The user-id is the mailbox owner's ID or email address, and delegated access to that mailbox is required.",
|
|
960
|
+
"presets": ["work"],
|
|
961
|
+
"workScopes": ["Mail.Read.Shared"],
|
|
962
|
+
"acceptType": "text/plain",
|
|
963
|
+
"blobOutput": true,
|
|
964
|
+
"llmTip": "Download a message of a shared mailbox as an .eml. Find the message id with list-shared-mailbox-messages first. With the server's blob output on, the bytes come back as a file resource; pass fileName to name it."
|
|
965
|
+
},
|
|
966
|
+
{
|
|
967
|
+
"pathPattern": "/me/messages/{message-id}/attachments/{attachment-id}/$value",
|
|
968
|
+
"method": "get",
|
|
969
|
+
"toolName": "get-mail-attachment-content",
|
|
970
|
+
"descriptionOverride": "Download the raw bytes of a file attachment of an Outlook message in the signed-in user's mailbox, by message ID and attachment ID.",
|
|
971
|
+
"presets": ["mail", "outlook", "personal"],
|
|
972
|
+
"scopes": ["Mail.Read"],
|
|
973
|
+
"blobOutput": true,
|
|
974
|
+
"llmTip": "Find the attachment id with list-mail-attachments first. With the server's blob output on, the bytes come back as a file resource; pass fileName (the attachment's name) to name it. Files over the server's download limit are refused with an error naming the limit."
|
|
975
|
+
},
|
|
976
|
+
{
|
|
977
|
+
"pathPattern": "/users/{user-id}/messages/{message-id}/attachments/{attachment-id}/$value",
|
|
978
|
+
"method": "get",
|
|
979
|
+
"toolName": "get-shared-mailbox-attachment-content",
|
|
980
|
+
"descriptionOverride": "Download the raw bytes of a file attachment of a message in another user's mailbox or a shared mailbox, by message ID and attachment ID. The user-id is the mailbox owner's ID or email address, and delegated access to that mailbox is required.",
|
|
981
|
+
"presets": ["work"],
|
|
982
|
+
"workScopes": ["Mail.Read.Shared"],
|
|
983
|
+
"blobOutput": true,
|
|
984
|
+
"llmTip": "Find the attachment id with list-shared-mailbox-attachments first. With the server's blob output on, the bytes come back as a file resource; pass fileName (the attachment's name) to name it. Files over the server's download limit are refused with an error naming the limit."
|
|
985
|
+
},
|
|
954
986
|
{
|
|
955
987
|
"pathPattern": "/drives/{drive-id}/items/{driveItem-id}/workbook/worksheets/{workbookWorksheet-id}/range(address='{address}')",
|
|
956
988
|
"method": "patch",
|
package/dist/generated/client.js
CHANGED
|
@@ -10618,6 +10618,26 @@ resource.`,
|
|
|
10618
10618
|
],
|
|
10619
10619
|
response: z.void()
|
|
10620
10620
|
},
|
|
10621
|
+
{
|
|
10622
|
+
method: "get",
|
|
10623
|
+
path: "/me/messages/:messageId/attachments/:attachmentId/$value",
|
|
10624
|
+
alias: "get-mail-attachment-content",
|
|
10625
|
+
description: `Get mail attachment content.`,
|
|
10626
|
+
requestFormat: "json",
|
|
10627
|
+
parameters: [
|
|
10628
|
+
{
|
|
10629
|
+
name: "messageId",
|
|
10630
|
+
type: "Path",
|
|
10631
|
+
schema: z.string().describe("Path parameter: message-id")
|
|
10632
|
+
},
|
|
10633
|
+
{
|
|
10634
|
+
name: "attachmentId",
|
|
10635
|
+
type: "Path",
|
|
10636
|
+
schema: z.string().describe("Path parameter: attachment-id")
|
|
10637
|
+
}
|
|
10638
|
+
],
|
|
10639
|
+
response: z.void()
|
|
10640
|
+
},
|
|
10621
10641
|
{
|
|
10622
10642
|
method: "post",
|
|
10623
10643
|
path: "/me/messages/:messageId/attachments/createUploadSession",
|
|
@@ -15571,6 +15591,14 @@ Only the policyViolation property of a chatMessage can be updated in application
|
|
|
15571
15591
|
],
|
|
15572
15592
|
response: z.void()
|
|
15573
15593
|
},
|
|
15594
|
+
{
|
|
15595
|
+
method: "get",
|
|
15596
|
+
path: "/users/:userId/messages/:messageId/$value",
|
|
15597
|
+
alias: "get-shared-mailbox-message-mime",
|
|
15598
|
+
description: `The unique identifier for an entity. Read-only.`,
|
|
15599
|
+
requestFormat: "json",
|
|
15600
|
+
response: z.void()
|
|
15601
|
+
},
|
|
15574
15602
|
{
|
|
15575
15603
|
method: "get",
|
|
15576
15604
|
path: "/users/:userId/messages/:messageId/attachments",
|
|
@@ -15652,6 +15680,31 @@ Only the policyViolation property of a chatMessage can be updated in application
|
|
|
15652
15680
|
],
|
|
15653
15681
|
response: z.void()
|
|
15654
15682
|
},
|
|
15683
|
+
{
|
|
15684
|
+
method: "get",
|
|
15685
|
+
path: "/users/:userId/messages/:messageId/attachments/:attachmentId/$value",
|
|
15686
|
+
alias: "get-shared-mailbox-attachment-content",
|
|
15687
|
+
description: `Get shared mailbox attachment content.`,
|
|
15688
|
+
requestFormat: "json",
|
|
15689
|
+
parameters: [
|
|
15690
|
+
{
|
|
15691
|
+
name: "userId",
|
|
15692
|
+
type: "Path",
|
|
15693
|
+
schema: z.string().describe("Path parameter: user-id")
|
|
15694
|
+
},
|
|
15695
|
+
{
|
|
15696
|
+
name: "messageId",
|
|
15697
|
+
type: "Path",
|
|
15698
|
+
schema: z.string().describe("Path parameter: message-id")
|
|
15699
|
+
},
|
|
15700
|
+
{
|
|
15701
|
+
name: "attachmentId",
|
|
15702
|
+
type: "Path",
|
|
15703
|
+
schema: z.string().describe("Path parameter: attachment-id")
|
|
15704
|
+
}
|
|
15705
|
+
],
|
|
15706
|
+
response: z.void()
|
|
15707
|
+
},
|
|
15655
15708
|
{
|
|
15656
15709
|
method: "post",
|
|
15657
15710
|
path: "/users/:userId/messages/:messageId/createForward",
|
package/dist/graph-client.js
CHANGED
|
@@ -97,6 +97,58 @@ function extractBatchMetadata(data) {
|
|
|
97
97
|
...Object.keys(errorCodeCounts).length > 0 ? { graph_batch_error_code_counts: errorCodeCounts } : {}
|
|
98
98
|
};
|
|
99
99
|
}
|
|
100
|
+
const DEFAULT_BLOB_MAX_BYTES = 5 * 1024 * 1024;
|
|
101
|
+
function isBlobOutputEnabled() {
|
|
102
|
+
return /^(1|true)$/i.test(process.env.MS365_MCP_BLOB_OUTPUT ?? "");
|
|
103
|
+
}
|
|
104
|
+
function getBlobMaxBytes() {
|
|
105
|
+
const n = Number(process.env.MS365_MCP_BLOB_MAX_BYTES);
|
|
106
|
+
return Number.isInteger(n) && n > 0 ? n : DEFAULT_BLOB_MAX_BYTES;
|
|
107
|
+
}
|
|
108
|
+
async function readBody(response, maxBytes) {
|
|
109
|
+
if (maxBytes === void 0) return Buffer.from(await response.arrayBuffer());
|
|
110
|
+
const tooLarge = () => new Error(`The file is larger than the download limit of ${maxBytes} bytes.`);
|
|
111
|
+
const declared = Number(response.headers?.get?.("content-length"));
|
|
112
|
+
if (declared > maxBytes) {
|
|
113
|
+
await response.body?.cancel();
|
|
114
|
+
throw tooLarge();
|
|
115
|
+
}
|
|
116
|
+
const chunks = [];
|
|
117
|
+
let total = 0;
|
|
118
|
+
const reader = response.body?.getReader();
|
|
119
|
+
while (reader) {
|
|
120
|
+
const { done, value } = await reader.read();
|
|
121
|
+
if (done) break;
|
|
122
|
+
total += value.byteLength;
|
|
123
|
+
if (total > maxBytes) {
|
|
124
|
+
await reader.cancel();
|
|
125
|
+
throw tooLarge();
|
|
126
|
+
}
|
|
127
|
+
chunks.push(value);
|
|
128
|
+
}
|
|
129
|
+
return Buffer.concat(chunks);
|
|
130
|
+
}
|
|
131
|
+
function toBlobResponse(data, blob) {
|
|
132
|
+
const d = data;
|
|
133
|
+
if (!d || typeof d.contentBytes !== "string") return void 0;
|
|
134
|
+
const mimeType = d.contentType?.split(";")[0].trim() || "application/octet-stream";
|
|
135
|
+
const name = blob.fileName?.split("/").join("_").split(String.fromCharCode(92)).join("_") || "download";
|
|
136
|
+
const size = Buffer.byteLength(d.contentBytes, "base64");
|
|
137
|
+
return {
|
|
138
|
+
content: [
|
|
139
|
+
{ type: "text", text: JSON.stringify({ name, contentType: mimeType, size }) },
|
|
140
|
+
// shortcut: ContentItem is text-only; this is the one non-text item, passed through as is.
|
|
141
|
+
{
|
|
142
|
+
type: "resource",
|
|
143
|
+
resource: {
|
|
144
|
+
uri: `file:///${encodeURIComponent(name)}`,
|
|
145
|
+
mimeType,
|
|
146
|
+
blob: d.contentBytes
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
]
|
|
150
|
+
};
|
|
151
|
+
}
|
|
100
152
|
class GraphClient {
|
|
101
153
|
constructor(authManager, secrets, outputFormat = "json") {
|
|
102
154
|
this.outputFormat = "json";
|
|
@@ -125,7 +177,7 @@ class GraphClient {
|
|
|
125
177
|
const isBinaryResponse = options.forceBinary === true || isBinaryContentType(contentTypeHeader);
|
|
126
178
|
let metadata = { http_status: response.status };
|
|
127
179
|
let result;
|
|
128
|
-
const buffer =
|
|
180
|
+
const buffer = await readBody(response, options.maxBytes);
|
|
129
181
|
metadata = { ...metadata, response_bytes: buffer.byteLength };
|
|
130
182
|
let text;
|
|
131
183
|
if (!isBinaryResponse) {
|
|
@@ -343,8 +395,9 @@ class GraphClient {
|
|
|
343
395
|
);
|
|
344
396
|
const { data, metadata } = await this.makeRequestWithMetadata(endpoint, options);
|
|
345
397
|
const outputFormat = options.forceJsonOutput ? "json" : this.outputFormat;
|
|
398
|
+
const blobResponse = options.blobOutput && !options.excludeResponse ? toBlobResponse(data, options.blobOutput) : void 0;
|
|
346
399
|
return this.withResponseMetadata(
|
|
347
|
-
this.formatJsonResponse(data, options.rawResponse, options.excludeResponse, outputFormat),
|
|
400
|
+
blobResponse ?? this.formatJsonResponse(data, options.rawResponse, options.excludeResponse, outputFormat),
|
|
348
401
|
metadata
|
|
349
402
|
);
|
|
350
403
|
} catch (error) {
|
|
@@ -446,7 +499,10 @@ class GraphClient {
|
|
|
446
499
|
}
|
|
447
500
|
var graph_client_default = GraphClient;
|
|
448
501
|
export {
|
|
502
|
+
DEFAULT_BLOB_MAX_BYTES,
|
|
449
503
|
GraphApiError,
|
|
450
504
|
graph_client_default as default,
|
|
451
|
-
|
|
505
|
+
getBlobMaxBytes,
|
|
506
|
+
isBinaryContentType,
|
|
507
|
+
isBlobOutputEnabled
|
|
452
508
|
};
|
package/dist/graph-tools.js
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from "./auth.js";
|
|
15
15
|
import { api as betaApi } from "./generated/client-beta.js";
|
|
16
16
|
import { api } from "./generated/client.js";
|
|
17
|
+
import { getBlobMaxBytes, isBlobOutputEnabled } from "./graph-client.js";
|
|
17
18
|
import { getAttachmentMinting } from "./lib/attachment-minting.js";
|
|
18
19
|
import { MAX_UPLOAD_BYTES, uploadAttachment } from "./lib/attachment-upload.js";
|
|
19
20
|
import { getUploadDirs, openUploadFile } from "./lib/upload-dirs.js";
|
|
@@ -856,14 +857,17 @@ const UTILITY_TOOLS = [
|
|
|
856
857
|
name: "download-bytes",
|
|
857
858
|
method: "GET",
|
|
858
859
|
path: "tool:download-bytes",
|
|
859
|
-
description:
|
|
860
|
+
description: `Download binary content from Microsoft Graph and return it as base64. Single tool for any binary read: drive file content, mail attachment, profile photo, Teams hosted content, meeting recording. Returns { contentType, encoding: "base64", contentLength, contentBytes }. For large drive/SharePoint file content, prefer get-download-url, which returns a pre-authenticated URL to stream bytes out-of-band instead of base64 through the agent context. With the server's blob output on, the bytes come back as a file resource instead (capped by the server's download limit); pass fileName to name it.`,
|
|
860
861
|
readOnlyHint: true,
|
|
861
862
|
openWorldHint: true,
|
|
862
863
|
buildSchema: (ctx) => {
|
|
863
864
|
const schema = {
|
|
864
865
|
target: z.string().describe(
|
|
865
866
|
'Relative Microsoft Graph path starting with "/". Common paths: /drives/{drive-id}/items/{driveItem-id}/content (drive file content); /me/messages/{message-id}/attachments/{attachment-id}/$value (mail attachment, list-mail-attachments returns the IDs); /me/photo/$value or /users/{user-id}/photo/$value (profile photo); /chats/{chat-id}/messages/{chatMessage-id}/hostedContents/{chatMessageHostedContent-id}/$value (Teams chat hosted content, list-chat-message-hosted-contents returns the IDs); /teams/{team-id}/channels/{channel-id}/messages/{chatMessage-id}/hostedContents/{chatMessageHostedContent-id}/$value (Teams channel hosted content). For meeting recordings, use get-meeting-recording-content where available; Microsoft Graph returns authenticated recording bytes, not a pre-authenticated download URL.'
|
|
866
|
-
)
|
|
867
|
+
),
|
|
868
|
+
fileName: z.string().describe(
|
|
869
|
+
"Name of the file, used as the last segment of the returned resource URI when the server runs with blob output. Optional."
|
|
870
|
+
).optional()
|
|
867
871
|
};
|
|
868
872
|
if (ctx.multiAccount) {
|
|
869
873
|
schema["account"] = z.string().optional().describe(
|
|
@@ -915,10 +919,17 @@ const UTILITY_TOOLS = [
|
|
|
915
919
|
if (authManager && !authManager.isOAuthModeEnabled() && !getRequestTokens()) {
|
|
916
920
|
accountAccessToken = await authManager.getTokenForAccount(accountParam);
|
|
917
921
|
}
|
|
922
|
+
const blob = isBlobOutputEnabled() ? {
|
|
923
|
+
maxBytes: getBlobMaxBytes(),
|
|
924
|
+
blobOutput: {
|
|
925
|
+
fileName: typeof params.fileName === "string" ? params.fileName : void 0
|
|
926
|
+
}
|
|
927
|
+
} : {};
|
|
918
928
|
return await graphClient.graphRequest(target, {
|
|
919
929
|
accessToken: accountAccessToken,
|
|
920
930
|
rawResponse: true,
|
|
921
|
-
forceBinary: true
|
|
931
|
+
forceBinary: true,
|
|
932
|
+
...blob
|
|
922
933
|
});
|
|
923
934
|
} catch (error) {
|
|
924
935
|
return {
|
|
@@ -1531,6 +1542,7 @@ async function executeGraphTool(tool, config, graphClient, params, authManager,
|
|
|
1531
1542
|
);
|
|
1532
1543
|
const strayBodyFields = {};
|
|
1533
1544
|
for (const [paramName, paramValue] of Object.entries(params)) {
|
|
1545
|
+
if (paramName === "fileName" && config?.blobOutput) continue;
|
|
1534
1546
|
if ([
|
|
1535
1547
|
"account",
|
|
1536
1548
|
"confirm",
|
|
@@ -1772,6 +1784,13 @@ async function executeGraphTool(tool, config, graphClient, params, authManager,
|
|
|
1772
1784
|
method: tool.method.toUpperCase(),
|
|
1773
1785
|
headers
|
|
1774
1786
|
};
|
|
1787
|
+
if (config?.blobOutput && isBlobOutputEnabled()) {
|
|
1788
|
+
options.forceBinary = true;
|
|
1789
|
+
options.maxBytes = getBlobMaxBytes();
|
|
1790
|
+
options.blobOutput = {
|
|
1791
|
+
fileName: typeof params.fileName === "string" ? params.fileName : void 0
|
|
1792
|
+
};
|
|
1793
|
+
}
|
|
1775
1794
|
if (config?.apiVersion) {
|
|
1776
1795
|
options.apiVersion = config.apiVersion;
|
|
1777
1796
|
}
|
|
@@ -1964,10 +1983,9 @@ async function executeGraphTool(tool, config, graphClient, params, authManager,
|
|
|
1964
1983
|
} catch {
|
|
1965
1984
|
}
|
|
1966
1985
|
}
|
|
1967
|
-
const content = response.content.map(
|
|
1968
|
-
type: "text",
|
|
1969
|
-
|
|
1970
|
-
}));
|
|
1986
|
+
const content = response.content.map(
|
|
1987
|
+
(item) => item.type === "resource" ? item : { type: "text", text: item.text }
|
|
1988
|
+
);
|
|
1971
1989
|
auditLog({
|
|
1972
1990
|
event: "tool.call",
|
|
1973
1991
|
request_id: requestId,
|
|
@@ -2098,6 +2116,11 @@ function registerGraphTools(server, graphClient, options = {}) {
|
|
|
2098
2116
|
if (endpointConfig?.acceptType && paramSchema["Accept"] === void 0) {
|
|
2099
2117
|
paramSchema["Accept"] = z.string().describe(getAcceptParamDescription(endpointConfig.acceptType)).optional();
|
|
2100
2118
|
}
|
|
2119
|
+
if (endpointConfig?.blobOutput && paramSchema["fileName"] === void 0) {
|
|
2120
|
+
paramSchema["fileName"] = z.string().describe(
|
|
2121
|
+
"Name of the file, used as the last segment of the returned resource URI when the server runs with blob output. Optional."
|
|
2122
|
+
).optional();
|
|
2123
|
+
}
|
|
2101
2124
|
if (isFetchAllPagesApplicable(tool)) {
|
|
2102
2125
|
const maxPages = getMaxPages();
|
|
2103
2126
|
paramSchema["fetchAllPages"] = z.boolean().describe(getFetchAllPagesParamDescription(maxPages, tool.alias)).optional();
|
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.165.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",
|
package/src/endpoints.json
CHANGED
|
@@ -949,8 +949,40 @@
|
|
|
949
949
|
"presets": ["mail", "outlook", "personal"],
|
|
950
950
|
"scopes": ["Mail.Read"],
|
|
951
951
|
"acceptType": "text/plain",
|
|
952
|
+
"blobOutput": true,
|
|
952
953
|
"llmTip": "Download an email message as raw RFC 5322 MIME content (.eml format). Use this when archiving an email to disk preserving all original headers, body, and inline-encoded attachments. Returns the MIME stream as text. Find the message id with list-mail-messages first."
|
|
953
954
|
},
|
|
955
|
+
{
|
|
956
|
+
"pathPattern": "/users/{user-id}/messages/{message-id}/$value",
|
|
957
|
+
"method": "get",
|
|
958
|
+
"toolName": "get-shared-mailbox-message-mime",
|
|
959
|
+
"descriptionOverride": "Download the raw MIME source (RFC 5322 .eml content) of a message in another user's mailbox or a shared mailbox, by message ID. The user-id is the mailbox owner's ID or email address, and delegated access to that mailbox is required.",
|
|
960
|
+
"presets": ["work"],
|
|
961
|
+
"workScopes": ["Mail.Read.Shared"],
|
|
962
|
+
"acceptType": "text/plain",
|
|
963
|
+
"blobOutput": true,
|
|
964
|
+
"llmTip": "Download a message of a shared mailbox as an .eml. Find the message id with list-shared-mailbox-messages first. With the server's blob output on, the bytes come back as a file resource; pass fileName to name it."
|
|
965
|
+
},
|
|
966
|
+
{
|
|
967
|
+
"pathPattern": "/me/messages/{message-id}/attachments/{attachment-id}/$value",
|
|
968
|
+
"method": "get",
|
|
969
|
+
"toolName": "get-mail-attachment-content",
|
|
970
|
+
"descriptionOverride": "Download the raw bytes of a file attachment of an Outlook message in the signed-in user's mailbox, by message ID and attachment ID.",
|
|
971
|
+
"presets": ["mail", "outlook", "personal"],
|
|
972
|
+
"scopes": ["Mail.Read"],
|
|
973
|
+
"blobOutput": true,
|
|
974
|
+
"llmTip": "Find the attachment id with list-mail-attachments first. With the server's blob output on, the bytes come back as a file resource; pass fileName (the attachment's name) to name it. Files over the server's download limit are refused with an error naming the limit."
|
|
975
|
+
},
|
|
976
|
+
{
|
|
977
|
+
"pathPattern": "/users/{user-id}/messages/{message-id}/attachments/{attachment-id}/$value",
|
|
978
|
+
"method": "get",
|
|
979
|
+
"toolName": "get-shared-mailbox-attachment-content",
|
|
980
|
+
"descriptionOverride": "Download the raw bytes of a file attachment of a message in another user's mailbox or a shared mailbox, by message ID and attachment ID. The user-id is the mailbox owner's ID or email address, and delegated access to that mailbox is required.",
|
|
981
|
+
"presets": ["work"],
|
|
982
|
+
"workScopes": ["Mail.Read.Shared"],
|
|
983
|
+
"blobOutput": true,
|
|
984
|
+
"llmTip": "Find the attachment id with list-shared-mailbox-attachments first. With the server's blob output on, the bytes come back as a file resource; pass fileName (the attachment's name) to name it. Files over the server's download limit are refused with an error naming the limit."
|
|
985
|
+
},
|
|
954
986
|
{
|
|
955
987
|
"pathPattern": "/drives/{drive-id}/items/{driveItem-id}/workbook/worksheets/{workbookWorksheet-id}/range(address='{address}')",
|
|
956
988
|
"method": "patch",
|