@softeria/ms-365-mcp-server 0.154.0 → 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" }), {
@@ -1068,6 +1068,95 @@ describe("graph-tools", () => {
1068
1068
  expect(payload).not.toHaveProperty("target_resource");
1069
1069
  });
1070
1070
  });
1071
+ describe("shared query contract at execution", () => {
1072
+ it.each([{ skiptoken: 123 }, { $SKIPTOKEN: 123 }, { COUNT: "true" }, { $Count: "true" }])(
1073
+ "rejects invalid cursor and mixed-case query values: %j",
1074
+ async (params) => {
1075
+ mockEndpoints.push(makeEndpoint());
1076
+ mockEndpointsJson = [makeConfig()];
1077
+ const graphClient = createMockGraphClient();
1078
+ const server = createMockServer();
1079
+ const { registerGraphTools } = await loadModule();
1080
+ registerGraphTools(server, graphClient);
1081
+ const result = await server.tools.get("test-tool").handler(params);
1082
+ expect(result.isError).toBe(true);
1083
+ expect(graphClient.graphRequest).not.toHaveBeenCalled();
1084
+ }
1085
+ );
1086
+ it("accepts valid mixed-case query values and string cursors", async () => {
1087
+ mockEndpoints.push(makeEndpoint());
1088
+ mockEndpointsJson = [makeConfig()];
1089
+ const graphClient = createMockGraphClient();
1090
+ const server = createMockServer();
1091
+ const { registerGraphTools } = await loadModule();
1092
+ registerGraphTools(server, graphClient);
1093
+ await server.tools.get("test-tool").handler({ COUNT: true, $SKIPTOKEN: "next-token" });
1094
+ expect(graphClient.graphRequest).toHaveBeenCalledTimes(1);
1095
+ const request = JSON.stringify(graphClient.graphRequest.mock.calls[0]);
1096
+ expect(request).toContain("$count=true");
1097
+ expect(request).toContain("$skiptoken=next-token");
1098
+ });
1099
+ it.each(["list-calendar-events-delta", "list-calendar-view-delta"])(
1100
+ "ignores stale mixed-case top values for %s",
1101
+ async (alias) => {
1102
+ mockEndpoints.push(makeEndpoint({ alias }));
1103
+ mockEndpointsJson = [makeConfig({ toolName: alias })];
1104
+ const graphClient = createMockGraphClient();
1105
+ const server = createMockServer();
1106
+ const { registerGraphTools } = await loadModule();
1107
+ registerGraphTools(server, graphClient);
1108
+ for (const key of ["top", "$top", "TOP", "$TOP", "$Top"]) {
1109
+ await server.tools.get(alias).handler({ [key]: 10 });
1110
+ }
1111
+ expect(graphClient.graphRequest).toHaveBeenCalledTimes(5);
1112
+ for (const call of graphClient.graphRequest.mock.calls) {
1113
+ expect(JSON.stringify(call)).not.toContain("$top");
1114
+ }
1115
+ }
1116
+ );
1117
+ it.each(["list-joined-teams", "list-my-associated-teams"])(
1118
+ "rejects unsupported query options before Graph dispatch for %s",
1119
+ async (alias) => {
1120
+ mockEndpoints.push(makeEndpoint({ alias }));
1121
+ mockEndpointsJson = [makeConfig({ toolName: alias })];
1122
+ const graphClient = createMockGraphClient();
1123
+ const server = createMockServer();
1124
+ const { registerGraphTools } = await loadModule();
1125
+ registerGraphTools(server, graphClient);
1126
+ for (const key of ["top", "$top", "skiptoken", "$skiptoken", "select", "$select"]) {
1127
+ const value = key.replace("$", "") === "top" ? 100 : "next-token";
1128
+ const result = await server.tools.get(alias).handler({ [key]: value });
1129
+ expect(result.isError).toBe(true);
1130
+ }
1131
+ expect(graphClient.graphRequest).not.toHaveBeenCalled();
1132
+ }
1133
+ );
1134
+ it("rejects oversized chat pages before Graph dispatch, including dollar-prefixed input", async () => {
1135
+ mockEndpoints.push(makeEndpoint({ alias: "list-chats" }));
1136
+ mockEndpointsJson = [makeConfig({ toolName: "list-chats" })];
1137
+ const graphClient = createMockGraphClient();
1138
+ const server = createMockServer();
1139
+ const { registerGraphTools } = await loadModule();
1140
+ registerGraphTools(server, graphClient);
1141
+ for (const key of ["top", "$top"]) {
1142
+ const result = await server.tools.get("list-chats").handler({ [key]: 100 });
1143
+ expect(result.isError).toBe(true);
1144
+ }
1145
+ expect(graphClient.graphRequest).not.toHaveBeenCalled();
1146
+ });
1147
+ it("sends a calendar field array as comma-separated text in one Graph request", async () => {
1148
+ const alias = "list-calendar-events-delta";
1149
+ mockEndpoints.push(makeEndpoint({ alias }));
1150
+ mockEndpointsJson = [makeConfig({ toolName: alias })];
1151
+ const graphClient = createMockGraphClient();
1152
+ const server = createMockServer();
1153
+ const { registerGraphTools } = await loadModule();
1154
+ registerGraphTools(server, graphClient);
1155
+ await server.tools.get(alias).handler({ select: ["id", "subject"] });
1156
+ expect(graphClient.graphRequest).toHaveBeenCalledTimes(1);
1157
+ expect(JSON.stringify(graphClient.graphRequest.mock.calls[0])).toContain("id,subject");
1158
+ });
1159
+ });
1071
1160
  describe("skiptoken cursor", () => {
1072
1161
  const prevAllowPagination = process.env.MS365_MCP_ALLOW_PAGINATION;
1073
1162
  afterEach(() => {
@@ -2098,6 +2187,8 @@ describe("graph-tools", () => {
2098
2187
  const [path, options] = graphClient.graphRequest.mock.calls[0];
2099
2188
  expect(path).toBe("/me/photo/$value");
2100
2189
  expect(options.accessToken).toBeUndefined();
2190
+ expect(options.forceBinary).toBe(true);
2191
+ expect(options.rawResponse).toBe(true);
2101
2192
  });
2102
2193
  it("rejects absolute URLs (Graph paths only)", async () => {
2103
2194
  mockEndpoints.length = 0;
@@ -846,8 +846,8 @@ const microsoft_graph_driveItem = z.object({
846
846
  webUrl: z.string().describe(
847
847
  "URL that either displays the resource in the browser (for Office file formats), or is a direct link to the file (for other formats). Read-only."
848
848
  ).nullish(),
849
- createdByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
850
- lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
849
+ createdByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
850
+ lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
851
851
  audio: microsoft_graph_audio.optional(),
852
852
  bundle: microsoft_graph_bundle.optional(),
853
853
  cTag: z.string().describe(
@@ -2586,8 +2586,8 @@ const microsoft_graph_listItem = z.object({
2586
2586
  webUrl: z.string().describe(
2587
2587
  "URL that either displays the resource in the browser (for Office file formats), or is a direct link to the file (for other formats). Read-only."
2588
2588
  ).nullish(),
2589
- createdByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
2590
- lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
2589
+ createdByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
2590
+ lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
2591
2591
  contentType: microsoft_graph_contentTypeInfo.optional(),
2592
2592
  deleted: microsoft_graph_deleted.optional(),
2593
2593
  sharepointIds: microsoft_graph_sharepointIds.optional(),
@@ -2708,8 +2708,8 @@ const microsoft_graph_list = z.lazy(
2708
2708
  webUrl: z.string().describe(
2709
2709
  "URL that either displays the resource in the browser (for Office file formats), or is a direct link to the file (for other formats). Read-only."
2710
2710
  ).nullish(),
2711
- createdByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
2712
- lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
2711
+ createdByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
2712
+ lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
2713
2713
  displayName: z.string().describe("The displayable title of the list.").nullish(),
2714
2714
  list: microsoft_graph_listInfo.optional(),
2715
2715
  sharepointIds: microsoft_graph_sharepointIds.optional(),
@@ -2741,8 +2741,8 @@ const microsoft_graph_drive = z.lazy(
2741
2741
  webUrl: z.string().describe(
2742
2742
  "URL that either displays the resource in the browser (for Office file formats), or is a direct link to the file (for other formats). Read-only."
2743
2743
  ).nullish(),
2744
- createdByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
2745
- lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
2744
+ createdByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
2745
+ lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
2746
2746
  driveType: z.string().describe(
2747
2747
  "Describes the type of drive represented by this resource. OneDrive personal drives return personal. OneDrive for Business returns business. SharePoint document libraries return documentLibrary. Read-only."
2748
2748
  ).nullish(),
@@ -4558,8 +4558,8 @@ const microsoft_graph_baseItem = z.object({
4558
4558
  webUrl: z.string().describe(
4559
4559
  "URL that either displays the resource in the browser (for Office file formats), or is a direct link to the file (for other formats). Read-only."
4560
4560
  ).nullish(),
4561
- createdByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
4562
- lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional()
4561
+ createdByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
4562
+ lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional()
4563
4563
  }).passthrough();
4564
4564
  const microsoft_graph_site = z.object({
4565
4565
  id: z.string().describe("The unique identifier for an entity. Read-only.").optional(),
@@ -4580,8 +4580,8 @@ const microsoft_graph_site = z.object({
4580
4580
  webUrl: z.string().describe(
4581
4581
  "URL that either displays the resource in the browser (for Office file formats), or is a direct link to the file (for other formats). Read-only."
4582
4582
  ).nullish(),
4583
- createdByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
4584
- lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
4583
+ createdByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
4584
+ lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
4585
4585
  isPersonalSite: z.boolean().describe("Identifies whether the site is personal or not. Read-only.").nullish(),
4586
4586
  root: microsoft_graph_root.optional(),
4587
4587
  sharepointIds: microsoft_graph_sharepointIds.optional(),
@@ -5880,8 +5880,8 @@ const endpoints = makeApi([
5880
5880
  webUrl: z.string().describe(
5881
5881
  "URL that either displays the resource in the browser (for Office file formats), or is a direct link to the file (for other formats). Read-only."
5882
5882
  ).nullish(),
5883
- createdByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
5884
- lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
5883
+ createdByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
5884
+ lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
5885
5885
  audio: microsoft_graph_audio.optional(),
5886
5886
  bundle: microsoft_graph_bundle.optional(),
5887
5887
  cTag: z.string().describe(
@@ -5995,8 +5995,8 @@ const endpoints = makeApi([
5995
5995
  webUrl: z.string().describe(
5996
5996
  "URL that either displays the resource in the browser (for Office file formats), or is a direct link to the file (for other formats). Read-only."
5997
5997
  ).nullish(),
5998
- createdByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
5999
- lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
5998
+ createdByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
5999
+ lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
6000
6000
  audio: microsoft_graph_audio.optional(),
6001
6001
  bundle: microsoft_graph_bundle.optional(),
6002
6002
  cTag: z.string().describe(
@@ -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") {
@@ -35,6 +35,7 @@ import { parseTeamsUrl } from "./lib/teams-url-parser.js";
35
35
  import { buildBM25Index, scoreQuery, tokenize } from "./lib/bm25.js";
36
36
  import { deriveTargetResource } from "./audit-target-resource.js";
37
37
  import { describeToolSchema, describeUtilityToolSchema } from "./lib/tool-schema.js";
38
+ import { queryParameterSchema } from "./lib/query-parameter-schema.js";
38
39
  import {
39
40
  TOP_UNSUPPORTED_DELTA_TOOLS,
40
41
  shouldOmitTopParam,
@@ -43,14 +44,6 @@ import {
43
44
  DEFAULT_MAX_PAGES,
44
45
  getMaxPages,
45
46
  isFetchAllPagesApplicable,
46
- FILTER_PARAM_DESCRIPTION,
47
- SEARCH_PARAM_DESCRIPTION,
48
- SELECT_PARAM_DESCRIPTION,
49
- EXPAND_PARAM_DESCRIPTION,
50
- ORDERBY_PARAM_DESCRIPTION,
51
- TOP_PARAM_DESCRIPTION,
52
- SKIP_PARAM_DESCRIPTION,
53
- COUNT_PARAM_DESCRIPTION,
54
47
  CONFIRM_PARAM_DESCRIPTION,
55
48
  TIMEZONE_PARAM_DESCRIPTION,
56
49
  EXPAND_EXTENDED_PROPERTIES_PARAM_DESCRIPTION,
@@ -686,7 +679,8 @@ const UTILITY_TOOLS = [
686
679
  }
687
680
  return await graphClient.graphRequest(target, {
688
681
  accessToken: accountAccessToken,
689
- rawResponse: true
682
+ rawResponse: true,
683
+ forceBinary: true
690
684
  });
691
685
  } catch (error) {
692
686
  return {
@@ -1195,13 +1189,38 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
1195
1189
  "search",
1196
1190
  "format"
1197
1191
  ];
1198
- const normalizedParamName = paramName.startsWith("$") ? paramName.slice(1) : paramName;
1199
- const isOdataParam = odataParams.includes(normalizedParamName.toLowerCase());
1200
- const fixedParamName = isOdataParam ? `$${normalizedParamName.toLowerCase()}` : paramName;
1192
+ const bareParamName = paramName.startsWith("$") ? paramName.slice(1) : paramName;
1193
+ const isOdataParam = odataParams.includes(bareParamName.toLowerCase());
1194
+ const normalizedParamName = isOdataParam ? bareParamName.toLowerCase() : bareParamName;
1195
+ const fixedParamName = isOdataParam ? `$${normalizedParamName}` : paramName;
1201
1196
  const camelCaseParamName = paramName.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
1202
1197
  const paramDef = parameterDefinitions.find(
1203
- (p) => p.name === paramName || p.name === camelCaseParamName || isOdataParam && p.name === normalizedParamName
1198
+ (p) => p.name === paramName || p.name === camelCaseParamName || isOdataParam && p.name.replace(/^\$/, "").toLowerCase() === normalizedParamName
1204
1199
  );
1200
+ const isQuery = paramDef?.type === "Query" || isOdataParam;
1201
+ const ignoredDeltaTop = shouldOmitTopParam(tool.alias) && normalizedParamName === "top";
1202
+ if (isQuery && !ignoredDeltaTop && paramValue != null && paramValue !== "") {
1203
+ const schema = queryParameterSchema(
1204
+ tool.alias,
1205
+ normalizedParamName,
1206
+ paramDef?.schema ?? z.any()
1207
+ );
1208
+ if (!schema || !schema.safeParse(paramValue).success) {
1209
+ return {
1210
+ content: [
1211
+ {
1212
+ type: "text",
1213
+ text: JSON.stringify({
1214
+ error: "invalid_query_parameter",
1215
+ parameter: fixedParamName,
1216
+ message: schema ? "Value does not match the query contract. Check get-tool-schema." : "This endpoint does not support this query parameter."
1217
+ })
1218
+ }
1219
+ ],
1220
+ isError: true
1221
+ };
1222
+ }
1223
+ }
1205
1224
  if (paramDef) {
1206
1225
  switch (paramDef.type) {
1207
1226
  case "Path": {
@@ -1632,6 +1651,11 @@ function registerGraphTools(server, graphClient, readOnly = false, enabledToolsP
1632
1651
  const paramSchema = {};
1633
1652
  if (tool.parameters && tool.parameters.length > 0) {
1634
1653
  for (const param of tool.parameters) {
1654
+ if (param.type === "Query") {
1655
+ const schema = queryParameterSchema(tool.alias, param.name, param.schema || z.any());
1656
+ if (schema) paramSchema[param.name] = schema;
1657
+ continue;
1658
+ }
1635
1659
  paramSchema[param.name] = param.type === "Body" && param.schema ? lenientBodySchema(param.schema) : param.schema || z.any();
1636
1660
  }
1637
1661
  }
@@ -1652,41 +1676,6 @@ function registerGraphTools(server, graphClient, readOnly = false, enabledToolsP
1652
1676
  if (isSkiptokenApplicable(tool, Object.keys(paramSchema))) {
1653
1677
  paramSchema["skiptoken"] = z.string().describe(SKIPTOKEN_PARAM_DESCRIPTION).optional();
1654
1678
  }
1655
- if (paramSchema["filter"] !== void 0 || paramSchema["$filter"] !== void 0) {
1656
- const key = paramSchema["$filter"] !== void 0 ? "$filter" : "filter";
1657
- paramSchema[key] = z.string().describe(FILTER_PARAM_DESCRIPTION).optional();
1658
- }
1659
- if (paramSchema["search"] !== void 0 || paramSchema["$search"] !== void 0) {
1660
- const key = paramSchema["$search"] !== void 0 ? "$search" : "search";
1661
- paramSchema[key] = z.string().describe(SEARCH_PARAM_DESCRIPTION).optional();
1662
- }
1663
- if (paramSchema["select"] !== void 0 || paramSchema["$select"] !== void 0) {
1664
- const key = paramSchema["$select"] !== void 0 ? "$select" : "select";
1665
- paramSchema[key] = z.string().describe(SELECT_PARAM_DESCRIPTION).optional();
1666
- }
1667
- if (paramSchema["expand"] !== void 0 || paramSchema["$expand"] !== void 0) {
1668
- const key = paramSchema["$expand"] !== void 0 ? "$expand" : "expand";
1669
- paramSchema[key] = z.array(z.string()).describe(EXPAND_PARAM_DESCRIPTION).optional();
1670
- }
1671
- if (paramSchema["orderby"] !== void 0 || paramSchema["$orderby"] !== void 0) {
1672
- const key = paramSchema["$orderby"] !== void 0 ? "$orderby" : "orderby";
1673
- paramSchema[key] = z.string().describe(ORDERBY_PARAM_DESCRIPTION).optional();
1674
- }
1675
- if (shouldOmitTopParam(tool.alias)) {
1676
- delete paramSchema["top"];
1677
- delete paramSchema["$top"];
1678
- } else if (paramSchema["top"] !== void 0 || paramSchema["$top"] !== void 0) {
1679
- const key = paramSchema["$top"] !== void 0 ? "$top" : "top";
1680
- paramSchema[key] = z.number().describe(TOP_PARAM_DESCRIPTION).optional();
1681
- }
1682
- if (paramSchema["skip"] !== void 0 || paramSchema["$skip"] !== void 0) {
1683
- const key = paramSchema["$skip"] !== void 0 ? "$skip" : "skip";
1684
- paramSchema[key] = z.number().describe(SKIP_PARAM_DESCRIPTION).optional();
1685
- }
1686
- if (paramSchema["count"] !== void 0 || paramSchema["$count"] !== void 0) {
1687
- const countKey = paramSchema["$count"] !== void 0 ? "$count" : "count";
1688
- paramSchema[countKey] = z.boolean().describe(COUNT_PARAM_DESCRIPTION).optional();
1689
- }
1690
1679
  if (multiAccount) {
1691
1680
  paramSchema["account"] = z.string().describe(getAccountParamDescription(accountNames)).optional();
1692
1681
  }
@@ -0,0 +1,33 @@
1
+ import { z } from "zod";
2
+ import { getODataParamDescription, shouldOmitTopParam } from "./param-descriptions.js";
3
+ const TEAM_LIST_TOOLS = /* @__PURE__ */ new Set(["list-joined-teams", "list-my-associated-teams"]);
4
+ function queryParameterSchema(toolName, name, providerSchema) {
5
+ const bareName = name.replace(/^\$/, "").toLowerCase();
6
+ if (TEAM_LIST_TOOLS.has(toolName)) return void 0;
7
+ if (bareName === "top" && shouldOmitTopParam(toolName)) return void 0;
8
+ const source = providerSchema instanceof z.ZodOptional ? providerSchema.unwrap() : providerSchema;
9
+ let schema = source;
10
+ switch (bareName) {
11
+ case "select":
12
+ case "expand":
13
+ case "orderby":
14
+ schema = z.union([z.string(), z.array(z.string())]);
15
+ break;
16
+ case "filter":
17
+ case "search":
18
+ schema = z.string();
19
+ break;
20
+ case "skiptoken":
21
+ schema = z.string();
22
+ break;
23
+ case "top":
24
+ if (toolName === "list-chats") schema = source.and(z.number().max(50));
25
+ break;
26
+ }
27
+ if (providerSchema.isOptional()) schema = schema.optional();
28
+ const description = getODataParamDescription(bareName);
29
+ return description ? schema.describe(description) : schema;
30
+ }
31
+ export {
32
+ queryParameterSchema
33
+ };
@@ -1,8 +1,7 @@
1
1
  import { zodToJsonSchema } from "zod-to-json-schema";
2
+ import { queryParameterSchema } from "./query-parameter-schema.js";
2
3
  import { isDestructiveOperation } from "./destructive-ops.js";
3
4
  import {
4
- getODataParamDescription,
5
- shouldOmitTopParam,
6
5
  isFetchAllPagesApplicable,
7
6
  isSkiptokenApplicable,
8
7
  SKIPTOKEN_PARAM_DESCRIPTION,
@@ -22,24 +21,23 @@ function unwrapOptional(schema) {
22
21
  }
23
22
  return { inner: schema, optional: false };
24
23
  }
25
- function bareParamName(name) {
26
- return name.startsWith("$") ? name.slice(1) : name;
27
- }
28
24
  function describeToolSchema(tool, config, ctx = {}) {
29
- const omitTop = shouldOmitTopParam(tool.alias);
30
- const params = (tool.parameters ?? []).filter((p) => !(omitTop && bareParamName(p.name) === "top")).map((p) => {
31
- const { inner, optional } = unwrapOptional(p.schema);
25
+ const params = (tool.parameters ?? []).flatMap((p) => {
26
+ const effectiveSchema = p.type === "Query" ? queryParameterSchema(tool.alias, p.name, p.schema) : p.schema;
27
+ if (!effectiveSchema) return [];
28
+ const { inner, optional } = unwrapOptional(effectiveSchema);
32
29
  const isPath = p.type === "Path";
33
30
  const jsonSchema = zodToJsonSchema(inner, { target: "jsonSchema7", $refStrategy: "none" });
34
31
  const { $schema: _s, ...schema } = jsonSchema;
35
- const override = p.type === "Query" ? getODataParamDescription(bareParamName(p.name)) : void 0;
36
- return {
37
- name: p.name,
38
- in: p.type,
39
- required: isPath || !optional,
40
- description: override ?? p.description,
41
- schema
42
- };
32
+ return [
33
+ {
34
+ name: p.name,
35
+ in: p.type,
36
+ required: isPath || !optional,
37
+ description: effectiveSchema.description ?? p.description,
38
+ schema
39
+ }
40
+ ];
43
41
  });
44
42
  if (isDestructiveOperation(tool.method, config)) {
45
43
  params.push({
@@ -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.0",
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",