@softeria/ms-365-mcp-server 0.154.1 → 0.154.3
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/Dockerfile.ghcr +29 -0
- package/dist/__tests__/graph-client.test.js +115 -0
- package/dist/__tests__/graph-tools.test.js +2 -0
- package/dist/graph-client.js +23 -16
- package/dist/graph-tools.js +2 -1
- package/dist/lib/batch-content-type.js +52 -0
- package/docs/deployment.md +19 -1
- package/package.json +1 -1
package/Dockerfile.ghcr
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# The image published to ghcr.io/softeria/ms-365-mcp-server. It installs the released npm
|
|
2
|
+
# package rather than building from source, so a version tag holds exactly what npm serves
|
|
3
|
+
# for that version. `generate` downloads Microsoft's live spec, so a source build of the
|
|
4
|
+
# same version can drift from it.
|
|
5
|
+
#
|
|
6
|
+
# Separate from Dockerfile because the legacy (non-BuildKit) builder runs every stage in a
|
|
7
|
+
# file whatever --target says, which would break the source build documented in
|
|
8
|
+
# docs/deployment.md.
|
|
9
|
+
FROM node:24-alpine
|
|
10
|
+
|
|
11
|
+
LABEL org.opencontainers.image.source="https://github.com/Softeria/ms-365-mcp-server"
|
|
12
|
+
|
|
13
|
+
# No default. npm resolves an empty spec to latest, which would stamp whatever is newest
|
|
14
|
+
# with the tag the workflow passes.
|
|
15
|
+
ARG VERSION
|
|
16
|
+
|
|
17
|
+
ENV NODE_ENV=production
|
|
18
|
+
|
|
19
|
+
RUN test -n "$VERSION" || { echo 'VERSION build arg is required'; exit 1; }
|
|
20
|
+
|
|
21
|
+
# --ignore-scripts keeps keytar (optional, native, no musl prebuild) from attempting a
|
|
22
|
+
# node-gyp build that cannot succeed here. --omit=optional would drop @azure/identity and
|
|
23
|
+
# @azure/keyvault-secrets with it, which Key Vault mode needs.
|
|
24
|
+
RUN npm i -g --ignore-scripts "@softeria/ms-365-mcp-server@${VERSION}" && \
|
|
25
|
+
npm cache clean --force
|
|
26
|
+
|
|
27
|
+
USER node
|
|
28
|
+
|
|
29
|
+
ENTRYPOINT ["ms-365-mcp-server"]
|
|
@@ -88,6 +88,121 @@ describe("GraphClient audit metadata", () => {
|
|
|
88
88
|
const result = await createGraphClient().graphRequest("/me/messages/m1/$value");
|
|
89
89
|
expect(result._meta).toMatchObject({ response_bytes: raw.byteLength });
|
|
90
90
|
});
|
|
91
|
+
describe("forceBinary", () => {
|
|
92
|
+
const doc = Buffer.from([
|
|
93
|
+
208,
|
|
94
|
+
207,
|
|
95
|
+
17,
|
|
96
|
+
224,
|
|
97
|
+
161,
|
|
98
|
+
177,
|
|
99
|
+
26,
|
|
100
|
+
225,
|
|
101
|
+
255,
|
|
102
|
+
254,
|
|
103
|
+
0,
|
|
104
|
+
65
|
|
105
|
+
]);
|
|
106
|
+
it("returns base64 bytes for a content type outside the binary allowlist", async () => {
|
|
107
|
+
fetchWithResilienceMock.mockResolvedValue(
|
|
108
|
+
new Response(doc, { status: 200, headers: { "content-type": "application/msword" } })
|
|
109
|
+
);
|
|
110
|
+
const result = await createGraphClient().graphRequest(
|
|
111
|
+
"/me/messages/m1/attachments/a1/$value",
|
|
112
|
+
{
|
|
113
|
+
rawResponse: true,
|
|
114
|
+
forceBinary: true
|
|
115
|
+
}
|
|
116
|
+
);
|
|
117
|
+
const payload = JSON.parse(result.content[0].text);
|
|
118
|
+
expect(payload).toMatchObject({
|
|
119
|
+
contentType: "application/msword",
|
|
120
|
+
encoding: "base64",
|
|
121
|
+
contentLength: doc.byteLength
|
|
122
|
+
});
|
|
123
|
+
expect(Buffer.from(payload.contentBytes, "base64").equals(doc)).toBe(true);
|
|
124
|
+
expect(payload).not.toHaveProperty("rawResponse");
|
|
125
|
+
expect(result._meta).toMatchObject({ response_bytes: doc.byteLength });
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
describe("never returns lossy text", () => {
|
|
129
|
+
const cases = [
|
|
130
|
+
{
|
|
131
|
+
name: "Word 97-2003 document",
|
|
132
|
+
contentType: "application/msword",
|
|
133
|
+
body: Buffer.from([208, 207, 17, 224, 161, 177, 26, 225, 255, 254, 0, 65])
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: "RTF with Windows-1252 quotes",
|
|
137
|
+
contentType: "application/rtf",
|
|
138
|
+
body: Buffer.from("{\\rtf1\\ansi \x93quoted\x94}", "latin1")
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: "8-bit MIME message",
|
|
142
|
+
contentType: "message/rfc822",
|
|
143
|
+
body: Buffer.from("Subject: caf\xE9\r\n\r\nna\xEFve body\r\n", "latin1")
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
name: "text/plain in Windows-1252",
|
|
147
|
+
contentType: "text/plain; charset=windows-1252",
|
|
148
|
+
body: Buffer.from("caf\xE9 \x96 dash", "latin1")
|
|
149
|
+
}
|
|
150
|
+
];
|
|
151
|
+
for (const c of cases) {
|
|
152
|
+
it(`returns ${c.name} (${c.contentType}) as byte-identical base64`, async () => {
|
|
153
|
+
fetchWithResilienceMock.mockResolvedValue(
|
|
154
|
+
new Response(new Uint8Array(c.body), {
|
|
155
|
+
status: 200,
|
|
156
|
+
headers: { "content-type": c.contentType }
|
|
157
|
+
})
|
|
158
|
+
);
|
|
159
|
+
const result = await createGraphClient().graphRequest(
|
|
160
|
+
"/me/messages/m1/attachments/a1/$value",
|
|
161
|
+
{
|
|
162
|
+
rawResponse: true
|
|
163
|
+
}
|
|
164
|
+
);
|
|
165
|
+
const payload = JSON.parse(result.content[0].text);
|
|
166
|
+
expect(payload).not.toHaveProperty("rawResponse");
|
|
167
|
+
expect(payload).toMatchObject({ encoding: "base64", contentLength: c.body.byteLength });
|
|
168
|
+
expect(Buffer.from(payload.contentBytes, "base64").equals(c.body)).toBe(true);
|
|
169
|
+
expect(result._meta).toMatchObject({ response_bytes: c.body.byteLength });
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
it("still returns valid UTF-8 text as text, byte for byte, BOM included", async () => {
|
|
173
|
+
const body = Buffer.concat([
|
|
174
|
+
Buffer.from([239, 187, 191]),
|
|
175
|
+
Buffer.from("h\xE9llo\r\nw\xF6rld\n", "utf8")
|
|
176
|
+
]);
|
|
177
|
+
fetchWithResilienceMock.mockResolvedValue(
|
|
178
|
+
new Response(body, {
|
|
179
|
+
status: 200,
|
|
180
|
+
headers: { "content-type": "text/plain; charset=utf-8" }
|
|
181
|
+
})
|
|
182
|
+
);
|
|
183
|
+
const result = await createGraphClient().graphRequest(
|
|
184
|
+
"/me/messages/m1/attachments/a1/$value",
|
|
185
|
+
{
|
|
186
|
+
rawResponse: true
|
|
187
|
+
}
|
|
188
|
+
);
|
|
189
|
+
const payload = JSON.parse(result.content[0].text);
|
|
190
|
+
expect(payload).not.toHaveProperty("contentBytes");
|
|
191
|
+
expect(Buffer.from(payload.rawResponse, "utf8").equals(body)).toBe(true);
|
|
192
|
+
expect(result._meta).toMatchObject({ response_bytes: body.byteLength });
|
|
193
|
+
});
|
|
194
|
+
it("still parses JSON, even with a leading byte-order mark", async () => {
|
|
195
|
+
const body = Buffer.concat([
|
|
196
|
+
Buffer.from([239, 187, 191]),
|
|
197
|
+
Buffer.from('{"id":"u1"}', "utf8")
|
|
198
|
+
]);
|
|
199
|
+
fetchWithResilienceMock.mockResolvedValue(
|
|
200
|
+
new Response(body, { status: 200, headers: { "content-type": "application/json" } })
|
|
201
|
+
);
|
|
202
|
+
const result = await createGraphClient().graphRequest("/me");
|
|
203
|
+
expect(JSON.parse(result.content[0].text)).toEqual({ id: "u1" });
|
|
204
|
+
});
|
|
205
|
+
});
|
|
91
206
|
it("preserves HTTP status metadata when response headers are requested", async () => {
|
|
92
207
|
fetchWithResilienceMock.mockResolvedValue(
|
|
93
208
|
new Response(JSON.stringify({ id: "task-1" }), {
|
|
@@ -2187,6 +2187,8 @@ describe("graph-tools", () => {
|
|
|
2187
2187
|
const [path, options] = graphClient.graphRequest.mock.calls[0];
|
|
2188
2188
|
expect(path).toBe("/me/photo/$value");
|
|
2189
2189
|
expect(options.accessToken).toBeUndefined();
|
|
2190
|
+
expect(options.forceBinary).toBe(true);
|
|
2191
|
+
expect(options.rawResponse).toBe(true);
|
|
2190
2192
|
});
|
|
2191
2193
|
it("rejects absolute URLs (Graph paths only)", async () => {
|
|
2192
2194
|
mockEndpoints.length = 0;
|
package/dist/graph-client.js
CHANGED
|
@@ -7,10 +7,12 @@ import {
|
|
|
7
7
|
getSharedBreaker,
|
|
8
8
|
loadResilienceConfig
|
|
9
9
|
} from "./lib/graph-resilience.js";
|
|
10
|
+
import { applyBatchContentType } from "./lib/batch-content-type.js";
|
|
10
11
|
import { applyMessageSignoffToRequest } from "./lib/message-signoff.js";
|
|
11
12
|
import { TRANSPORT_OK_MESSAGE } from "./lib/select-projection.js";
|
|
12
13
|
import { open, stat, unlink } from "fs/promises";
|
|
13
14
|
import { pipeline } from "stream/promises";
|
|
15
|
+
const UTF8_STRICT = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
14
16
|
function isBinaryContentType(contentType) {
|
|
15
17
|
if (!contentType) return false;
|
|
16
18
|
const lower = contentType.toLowerCase().split(";")[0].trim();
|
|
@@ -120,11 +122,20 @@ class GraphClient {
|
|
|
120
122
|
throw await this.createGraphApiError(response);
|
|
121
123
|
}
|
|
122
124
|
const contentTypeHeader = response.headers?.get?.("content-type") || "";
|
|
123
|
-
const isBinaryResponse = isBinaryContentType(contentTypeHeader);
|
|
125
|
+
const isBinaryResponse = options.forceBinary === true || isBinaryContentType(contentTypeHeader);
|
|
124
126
|
let metadata = { http_status: response.status };
|
|
125
127
|
let result;
|
|
126
|
-
|
|
127
|
-
|
|
128
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
129
|
+
metadata = { ...metadata, response_bytes: buffer.byteLength };
|
|
130
|
+
let text;
|
|
131
|
+
if (!isBinaryResponse) {
|
|
132
|
+
try {
|
|
133
|
+
text = UTF8_STRICT.decode(buffer);
|
|
134
|
+
} catch {
|
|
135
|
+
text = void 0;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (text === void 0) {
|
|
128
139
|
result = {
|
|
129
140
|
message: TRANSPORT_OK_MESSAGE,
|
|
130
141
|
contentType: contentTypeHeader,
|
|
@@ -132,20 +143,15 @@ class GraphClient {
|
|
|
132
143
|
contentLength: buffer.byteLength,
|
|
133
144
|
contentBytes: buffer.toString("base64")
|
|
134
145
|
};
|
|
135
|
-
|
|
146
|
+
} else if (text === "") {
|
|
147
|
+
result = { message: TRANSPORT_OK_MESSAGE };
|
|
148
|
+
} else if (options.rawResponse) {
|
|
149
|
+
result = { message: TRANSPORT_OK_MESSAGE, rawResponse: text };
|
|
136
150
|
} else {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
result = { message: TRANSPORT_OK_MESSAGE };
|
|
141
|
-
} else if (options.rawResponse) {
|
|
151
|
+
try {
|
|
152
|
+
result = JSON.parse(text.charCodeAt(0) === 65279 ? text.slice(1) : text);
|
|
153
|
+
} catch {
|
|
142
154
|
result = { message: TRANSPORT_OK_MESSAGE, rawResponse: text };
|
|
143
|
-
} else {
|
|
144
|
-
try {
|
|
145
|
-
result = JSON.parse(text);
|
|
146
|
-
} catch {
|
|
147
|
-
result = { message: TRANSPORT_OK_MESSAGE, rawResponse: text };
|
|
148
|
-
}
|
|
149
155
|
}
|
|
150
156
|
}
|
|
151
157
|
if (endpoint === "/$batch") {
|
|
@@ -292,7 +298,8 @@ class GraphClient {
|
|
|
292
298
|
const url = `${cloudEndpoints.graphApi}/${apiVersion}${endpoint}`;
|
|
293
299
|
logger.info(`[GRAPH CLIENT] Final URL being sent to Microsoft: ${url}`);
|
|
294
300
|
const method = options.method || "GET";
|
|
295
|
-
const
|
|
301
|
+
const signedBody = applyMessageSignoffToRequest(method, endpoint, options.body);
|
|
302
|
+
const body = applyBatchContentType(method, endpoint, signedBody);
|
|
296
303
|
const headers = {
|
|
297
304
|
Authorization: `Bearer ${accessToken}`,
|
|
298
305
|
"Content-Type": "application/json",
|
package/dist/graph-tools.js
CHANGED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import logger from "../logger.js";
|
|
2
|
+
const JSON_CONTENT_TYPE = "application/json";
|
|
3
|
+
const NEVER_HAS_BODY = /* @__PURE__ */ new Set(["GET", "DELETE"]);
|
|
4
|
+
function isPlainObject(value) {
|
|
5
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
function hasContentType(headers) {
|
|
8
|
+
return Object.keys(headers).some((key) => key.toLowerCase() === "content-type");
|
|
9
|
+
}
|
|
10
|
+
function fillSubRequest(entry) {
|
|
11
|
+
if (!isPlainObject(entry)) return entry;
|
|
12
|
+
if (typeof entry.method !== "string") return entry;
|
|
13
|
+
if (NEVER_HAS_BODY.has(entry.method.toUpperCase())) return entry;
|
|
14
|
+
if (!isPlainObject(entry.body)) return entry;
|
|
15
|
+
const headerKeys = Object.keys(entry).filter((key) => key.toLowerCase() === "headers");
|
|
16
|
+
if (headerKeys.length > 1) return entry;
|
|
17
|
+
const headersKey = headerKeys[0] ?? "headers";
|
|
18
|
+
const headers = entry[headersKey];
|
|
19
|
+
if (headers !== void 0 && !isPlainObject(headers)) return entry;
|
|
20
|
+
if (headers && hasContentType(headers)) return entry;
|
|
21
|
+
return { ...entry, [headersKey]: { ...headers, "Content-Type": JSON_CONTENT_TYPE } };
|
|
22
|
+
}
|
|
23
|
+
function normalizePath(path) {
|
|
24
|
+
let clean = path.split("?")[0].replace(/^\/(?:v1\.0|beta)(?=\/)/, "");
|
|
25
|
+
if (!clean.startsWith("/")) clean = `/${clean}`;
|
|
26
|
+
if (clean.length > 1 && clean.endsWith("/")) clean = clean.slice(0, -1);
|
|
27
|
+
return clean;
|
|
28
|
+
}
|
|
29
|
+
function subRequestLabel(entry, index) {
|
|
30
|
+
const id = isPlainObject(entry) ? entry.id : void 0;
|
|
31
|
+
return typeof id === "string" || typeof id === "number" ? String(id) : `index ${index}`;
|
|
32
|
+
}
|
|
33
|
+
function applyBatchContentType(method, path, body) {
|
|
34
|
+
if (typeof body !== "string") return body;
|
|
35
|
+
if (method.toUpperCase() !== "POST" || normalizePath(path) !== "/$batch") return body;
|
|
36
|
+
let payload;
|
|
37
|
+
try {
|
|
38
|
+
payload = JSON.parse(body);
|
|
39
|
+
} catch {
|
|
40
|
+
return body;
|
|
41
|
+
}
|
|
42
|
+
if (!isPlainObject(payload) || !Array.isArray(payload.requests)) return body;
|
|
43
|
+
const original = payload.requests;
|
|
44
|
+
const requests = original.map(fillSubRequest);
|
|
45
|
+
const filled = original.map((entry, index) => requests[index] === entry ? void 0 : subRequestLabel(entry, index)).filter((label) => label !== void 0);
|
|
46
|
+
if (filled.length === 0) return body;
|
|
47
|
+
logger.info(`Filled in missing $batch Content-Type for sub-request(s): ${filled.join(", ")}`);
|
|
48
|
+
return JSON.stringify({ ...payload, requests });
|
|
49
|
+
}
|
|
50
|
+
export {
|
|
51
|
+
applyBatchContentType
|
|
52
|
+
};
|
package/docs/deployment.md
CHANGED
|
@@ -28,7 +28,25 @@ In HTTP mode, `MS365_MCP_AUTH_CACHE_COMMAND` is skipped at startup and per Graph
|
|
|
28
28
|
|
|
29
29
|
## Docker
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
Released images are published to GitHub Container Registry:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
docker pull ghcr.io/softeria/ms-365-mcp-server:latest
|
|
35
|
+
# or pin a release
|
|
36
|
+
docker pull ghcr.io/softeria/ms-365-mcp-server:<version>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The image runs as a non-root user and takes the same arguments as the CLI:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
docker run -p 3000:3000 \
|
|
43
|
+
-e MS365_MCP_CLIENT_ID=your-client-id \
|
|
44
|
+
-e MS365_MCP_TENANT_ID=your-tenant-id \
|
|
45
|
+
ghcr.io/softeria/ms-365-mcp-server:latest \
|
|
46
|
+
--http 3000 --org-mode
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
A `Dockerfile` is also included, to build from source instead:
|
|
32
50
|
|
|
33
51
|
```bash
|
|
34
52
|
# Build the image
|
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.154.
|
|
4
|
+
"version": "0.154.3",
|
|
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",
|