@softeria/ms-365-mcp-server 0.154.1 → 0.154.2

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.
@@ -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;
@@ -11,6 +11,7 @@ import { applyMessageSignoffToRequest } from "./lib/message-signoff.js";
11
11
  import { TRANSPORT_OK_MESSAGE } from "./lib/select-projection.js";
12
12
  import { open, stat, unlink } from "fs/promises";
13
13
  import { pipeline } from "stream/promises";
14
+ const UTF8_STRICT = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
14
15
  function isBinaryContentType(contentType) {
15
16
  if (!contentType) return false;
16
17
  const lower = contentType.toLowerCase().split(";")[0].trim();
@@ -120,11 +121,20 @@ class GraphClient {
120
121
  throw await this.createGraphApiError(response);
121
122
  }
122
123
  const contentTypeHeader = response.headers?.get?.("content-type") || "";
123
- const isBinaryResponse = isBinaryContentType(contentTypeHeader);
124
+ const isBinaryResponse = options.forceBinary === true || isBinaryContentType(contentTypeHeader);
124
125
  let metadata = { http_status: response.status };
125
126
  let result;
126
- if (isBinaryResponse) {
127
- const buffer = Buffer.from(await response.arrayBuffer());
127
+ const buffer = Buffer.from(await response.arrayBuffer());
128
+ metadata = { ...metadata, response_bytes: buffer.byteLength };
129
+ let text;
130
+ if (!isBinaryResponse) {
131
+ try {
132
+ text = UTF8_STRICT.decode(buffer);
133
+ } catch {
134
+ text = void 0;
135
+ }
136
+ }
137
+ if (text === void 0) {
128
138
  result = {
129
139
  message: TRANSPORT_OK_MESSAGE,
130
140
  contentType: contentTypeHeader,
@@ -132,20 +142,15 @@ class GraphClient {
132
142
  contentLength: buffer.byteLength,
133
143
  contentBytes: buffer.toString("base64")
134
144
  };
135
- metadata = { ...metadata, response_bytes: buffer.byteLength };
145
+ } else if (text === "") {
146
+ result = { message: TRANSPORT_OK_MESSAGE };
147
+ } else if (options.rawResponse) {
148
+ result = { message: TRANSPORT_OK_MESSAGE, rawResponse: text };
136
149
  } else {
137
- const text = await response.text();
138
- metadata = { ...metadata, response_bytes: Buffer.byteLength(text, "utf8") };
139
- if (text === "") {
140
- result = { message: TRANSPORT_OK_MESSAGE };
141
- } else if (options.rawResponse) {
150
+ try {
151
+ result = JSON.parse(text.charCodeAt(0) === 65279 ? text.slice(1) : text);
152
+ } catch {
142
153
  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
154
  }
150
155
  }
151
156
  if (endpoint === "/$batch") {
@@ -679,7 +679,8 @@ const UTILITY_TOOLS = [
679
679
  }
680
680
  return await graphClient.graphRequest(target, {
681
681
  accessToken: accountAccessToken,
682
- rawResponse: true
682
+ rawResponse: true,
683
+ forceBinary: true
683
684
  });
684
685
  } catch (error) {
685
686
  return {
@@ -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
- A `Dockerfile` is included for containerized deployments:
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.1",
4
+ "version": "0.154.2",
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",