@softeria/ms-365-mcp-server 0.129.0 → 0.129.1

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.
@@ -62,7 +62,7 @@ function makeConfig(overrides = {}) {
62
62
  ...overrides
63
63
  };
64
64
  }
65
- function createMockGraphClient(responses) {
65
+ function createMockGraphClient(responses, outputFormat = "json") {
66
66
  const responseQueue = [...responses || []];
67
67
  return {
68
68
  graphRequest: vi.fn().mockImplementation(async () => {
@@ -72,7 +72,12 @@ function createMockGraphClient(responses) {
72
72
  return {
73
73
  content: [{ type: "text", text: JSON.stringify({ value: [] }) }]
74
74
  };
75
- })
75
+ }),
76
+ // Fake serialize: prefix the JSON in toon mode so a test can tell the merged
77
+ // body went through serialize() and not a plain JSON.stringify.
78
+ serialize: vi.fn().mockImplementation(
79
+ (data) => outputFormat === "toon" ? `TOON:${JSON.stringify(data)}` : JSON.stringify(data)
80
+ )
76
81
  };
77
82
  }
78
83
  async function loadModule() {
@@ -161,6 +166,61 @@ describe("graph-tools", () => {
161
166
  expect(parsed.value.map((v) => v.id)).toEqual(["1", "2", "3"]);
162
167
  expect(parsed["@odata.nextLink"]).toBeUndefined();
163
168
  });
169
+ it("merges all pages under --toon and encodes the combined result once (#560)", async () => {
170
+ const endpoint = makeEndpoint();
171
+ const config = makeConfig();
172
+ mockEndpoints.push(endpoint);
173
+ mockEndpointsJson = [config];
174
+ const graphClient = createMockGraphClient(
175
+ [
176
+ {
177
+ content: [
178
+ {
179
+ type: "text",
180
+ text: JSON.stringify({
181
+ value: [{ id: "1" }, { id: "2" }],
182
+ "@odata.nextLink": "https://graph.microsoft.com/v1.0/me/messages?$skip=2"
183
+ })
184
+ }
185
+ ]
186
+ },
187
+ {
188
+ content: [{ type: "text", text: JSON.stringify({ value: [{ id: "3" }] }) }]
189
+ }
190
+ ],
191
+ "toon"
192
+ );
193
+ const server = createMockServer();
194
+ const { registerGraphTools } = await loadModule();
195
+ registerGraphTools(server, graphClient);
196
+ const tool = server.tools.get("test-tool");
197
+ const result = await tool.handler({ fetchAllPages: true });
198
+ for (const call of graphClient.graphRequest.mock.calls) {
199
+ expect(call[1]?.forceJsonOutput).toBe(true);
200
+ }
201
+ expect(graphClient.serialize).toHaveBeenCalledTimes(1);
202
+ expect(result.content[0].text.startsWith("TOON:")).toBe(true);
203
+ const parsed = JSON.parse(result.content[0].text.slice("TOON:".length));
204
+ expect(parsed.value.map((v) => v.id)).toEqual(["1", "2", "3"]);
205
+ expect(parsed["@odata.nextLink"]).toBeUndefined();
206
+ });
207
+ it("does not inject value:[] when fetchAllPages hits a single-object (non-collection) GET", async () => {
208
+ const endpoint = makeEndpoint();
209
+ const config = makeConfig();
210
+ mockEndpoints.push(endpoint);
211
+ mockEndpointsJson = [config];
212
+ const graphClient = createMockGraphClient([
213
+ { content: [{ type: "text", text: JSON.stringify({ id: "abc", displayName: "Solo" }) }] }
214
+ ]);
215
+ const server = createMockServer();
216
+ const { registerGraphTools } = await loadModule();
217
+ registerGraphTools(server, graphClient);
218
+ const result = await server.tools.get("test-tool").handler({ fetchAllPages: true });
219
+ const parsed = JSON.parse(result.content[0].text);
220
+ expect(parsed).toEqual({ id: "abc", displayName: "Solo" });
221
+ expect(parsed.value).toBeUndefined();
222
+ expect(graphClient.graphRequest).toHaveBeenCalledTimes(1);
223
+ });
164
224
  it("should stop at 100 page limit", async () => {
165
225
  const endpoint = makeEndpoint();
166
226
  const config = makeConfig();
@@ -712,6 +772,27 @@ describe("graph-tools", () => {
712
772
  expect(payload.size).toBe(12727);
713
773
  expect(payload.contentType).toBe("application/pdf");
714
774
  });
775
+ it("forces a JSON body on the metadata request so it works under --toon (#560)", async () => {
776
+ mockEndpoints.length = 0;
777
+ mockEndpointsJson = [];
778
+ const graphClient = {
779
+ graphRequest: vi.fn().mockResolvedValue({
780
+ content: [
781
+ {
782
+ type: "text",
783
+ text: JSON.stringify({ "@microsoft.graph.downloadUrl": "https://dl.example/x" })
784
+ }
785
+ ]
786
+ })
787
+ };
788
+ const server = createMockServer();
789
+ const { registerGraphTools } = await loadModule();
790
+ registerGraphTools(server, graphClient);
791
+ const result = await server.tools.get("get-download-url").handler({ target: "/drives/d1/items/item1/content" });
792
+ const [, opts] = graphClient.graphRequest.mock.calls[0];
793
+ expect(opts?.forceJsonOutput).toBe(true);
794
+ expect(JSON.parse(result.content[0].text).downloadUrl).toBe("https://dl.example/x");
795
+ });
715
796
  it("rejects query-shaped targets instead of silently changing request semantics", async () => {
716
797
  mockEndpoints.length = 0;
717
798
  mockEndpointsJson = [];
@@ -133,11 +133,25 @@ class GraphClient {
133
133
  }
134
134
  return JSON.stringify(data, null, pretty ? 2 : void 0);
135
135
  }
136
+ /**
137
+ * Encode a value in the configured format (json/toon). The fetchAllPages merge
138
+ * uses this to encode the combined result once, after parsing pages as JSON (#560).
139
+ * Compact by default like JSON.stringify, so JSON-mode output is byte-identical.
140
+ */
141
+ serialize(data, pretty = false) {
142
+ return this.serializeData(data, this.outputFormat, pretty);
143
+ }
136
144
  async graphRequest(endpoint, options = {}) {
137
145
  try {
138
146
  logger.info(`Calling ${endpoint} with options: ${JSON.stringify(options)}`);
139
147
  const result = await this.makeRequest(endpoint, options);
140
- return this.formatJsonResponse(result, options.rawResponse, options.excludeResponse);
148
+ const outputFormat = options.forceJsonOutput ? "json" : this.outputFormat;
149
+ return this.formatJsonResponse(
150
+ result,
151
+ options.rawResponse,
152
+ options.excludeResponse,
153
+ outputFormat
154
+ );
141
155
  } catch (error) {
142
156
  logger.error(`Error in Graph API request: ${error}`);
143
157
  return {
@@ -146,10 +160,10 @@ class GraphClient {
146
160
  };
147
161
  }
148
162
  }
149
- formatJsonResponse(data, rawResponse = false, excludeResponse = false) {
163
+ formatJsonResponse(data, rawResponse = false, excludeResponse = false, outputFormat = this.outputFormat) {
150
164
  if (excludeResponse) {
151
165
  return {
152
- content: [{ type: "text", text: this.serializeData({ success: true }, this.outputFormat) }]
166
+ content: [{ type: "text", text: this.serializeData({ success: true }, outputFormat) }]
153
167
  };
154
168
  }
155
169
  if (data && typeof data === "object" && "_headers" in data) {
@@ -163,17 +177,13 @@ class GraphClient {
163
177
  }
164
178
  if (rawResponse) {
165
179
  return {
166
- content: [
167
- { type: "text", text: this.serializeData(responseData.data, this.outputFormat) }
168
- ],
180
+ content: [{ type: "text", text: this.serializeData(responseData.data, outputFormat) }],
169
181
  _meta: meta
170
182
  };
171
183
  }
172
184
  if (responseData.data === null || responseData.data === void 0) {
173
185
  return {
174
- content: [
175
- { type: "text", text: this.serializeData({ success: true }, this.outputFormat) }
176
- ],
186
+ content: [{ type: "text", text: this.serializeData({ success: true }, outputFormat) }],
177
187
  _meta: meta
178
188
  };
179
189
  }
@@ -191,19 +201,19 @@ class GraphClient {
191
201
  removeODataProps2(responseData.data);
192
202
  return {
193
203
  content: [
194
- { type: "text", text: this.serializeData(responseData.data, this.outputFormat, true) }
204
+ { type: "text", text: this.serializeData(responseData.data, outputFormat, true) }
195
205
  ],
196
206
  _meta: meta
197
207
  };
198
208
  }
199
209
  if (rawResponse) {
200
210
  return {
201
- content: [{ type: "text", text: this.serializeData(data, this.outputFormat) }]
211
+ content: [{ type: "text", text: this.serializeData(data, outputFormat) }]
202
212
  };
203
213
  }
204
214
  if (data === null || data === void 0) {
205
215
  return {
206
- content: [{ type: "text", text: this.serializeData({ success: true }, this.outputFormat) }]
216
+ content: [{ type: "text", text: this.serializeData({ success: true }, outputFormat) }]
207
217
  };
208
218
  }
209
219
  const removeODataProps = (obj) => {
@@ -219,7 +229,7 @@ class GraphClient {
219
229
  };
220
230
  removeODataProps(data);
221
231
  return {
222
- content: [{ type: "text", text: this.serializeData(data, this.outputFormat, true) }]
232
+ content: [{ type: "text", text: this.serializeData(data, outputFormat, true) }]
223
233
  };
224
234
  }
225
235
  }
@@ -328,7 +328,10 @@ const UTILITY_TOOLS = [
328
328
  accountAccessToken = await authManager.getTokenForAccount(accountParam);
329
329
  }
330
330
  const response = await graphClient.graphRequest(itemPath, {
331
- accessToken: accountAccessToken
331
+ accessToken: accountAccessToken,
332
+ // We JSON.parse the metadata below, so force JSON - under --toon it'd be
333
+ // TOON and the parse would fail, masking a real item as "no download url".
334
+ forceJsonOutput: true
332
335
  });
333
336
  if (response?.isError) {
334
337
  return response;
@@ -612,7 +615,6 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
612
615
  logger.info(
613
616
  `Making graph request to ${path2} with options: ${JSON.stringify(safeOptions)}${_redacted ? " [accessToken=REDACTED]" : ""}`
614
617
  );
615
- let response = await graphClient.graphRequest(path2, options);
616
618
  const fetchAllPages = params.fetchAllPages === true;
617
619
  const paginationEnabled = paginationAllowed();
618
620
  if (fetchAllPages && !paginationEnabled) {
@@ -620,58 +622,69 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
620
622
  "fetchAllPages requested but MS365_MCP_ALLOW_PAGINATION is disabled; returning first page only"
621
623
  );
622
624
  }
623
- if (fetchAllPages && paginationEnabled && response?.content?.[0]?.text) {
625
+ const mergePages = fetchAllPages && paginationEnabled;
626
+ if (mergePages) {
627
+ options.forceJsonOutput = true;
628
+ }
629
+ let response = await graphClient.graphRequest(path2, options);
630
+ if (mergePages && response?.content?.[0]?.text) {
631
+ let combinedResponse;
624
632
  try {
625
- let combinedResponse = JSON.parse(response.content[0].text);
626
- let allItems = combinedResponse.value || [];
627
- let nextLink = combinedResponse["@odata.nextLink"];
628
- let pageCount = 1;
629
- const maxPages = positiveIntFromEnv("MS365_MCP_MAX_PAGES", DEFAULT_MAX_PAGES);
630
- const maxItems = positiveIntFromEnv("MS365_MCP_MAX_ITEMS", DEFAULT_MAX_ITEMS);
631
- let deltaLink = combinedResponse["@odata.deltaLink"];
632
- while (nextLink && pageCount < maxPages && allItems.length < maxItems) {
633
- logger.info(`Fetching page ${pageCount + 1} from: ${nextLink}`);
634
- const url = new URL(nextLink);
635
- const nextPath = url.pathname.replace(/^\/(v1\.0|beta)/, "") + url.search;
636
- const nextOptions = { ...options };
637
- const nextResponse = await graphClient.graphRequest(nextPath, nextOptions);
638
- if (nextResponse?.content?.[0]?.text) {
639
- const nextJsonResponse = JSON.parse(nextResponse.content[0].text);
640
- if (nextJsonResponse.value && Array.isArray(nextJsonResponse.value)) {
641
- allItems = allItems.concat(nextJsonResponse.value);
642
- }
643
- nextLink = nextJsonResponse["@odata.nextLink"];
644
- if (nextJsonResponse["@odata.deltaLink"]) {
645
- deltaLink = nextJsonResponse["@odata.deltaLink"];
633
+ combinedResponse = JSON.parse(response.content[0].text);
634
+ const firstValue = combinedResponse.value;
635
+ if (Array.isArray(firstValue)) {
636
+ let allItems = firstValue;
637
+ let nextLink = combinedResponse["@odata.nextLink"];
638
+ let pageCount = 1;
639
+ const maxPages = positiveIntFromEnv("MS365_MCP_MAX_PAGES", DEFAULT_MAX_PAGES);
640
+ const maxItems = positiveIntFromEnv("MS365_MCP_MAX_ITEMS", DEFAULT_MAX_ITEMS);
641
+ let deltaLink = combinedResponse["@odata.deltaLink"];
642
+ while (nextLink && pageCount < maxPages && allItems.length < maxItems) {
643
+ logger.info(`Fetching page ${pageCount + 1} from: ${nextLink}`);
644
+ const url = new URL(nextLink);
645
+ const nextPath = url.pathname.replace(/^\/(v1\.0|beta)/, "") + url.search;
646
+ const nextOptions = { ...options };
647
+ const nextResponse = await graphClient.graphRequest(nextPath, nextOptions);
648
+ if (nextResponse?.content?.[0]?.text) {
649
+ const nextJsonResponse = JSON.parse(nextResponse.content[0].text);
650
+ if (Array.isArray(nextJsonResponse.value)) {
651
+ allItems = allItems.concat(nextJsonResponse.value);
652
+ }
653
+ nextLink = nextJsonResponse["@odata.nextLink"];
654
+ if (nextJsonResponse["@odata.deltaLink"]) {
655
+ deltaLink = nextJsonResponse["@odata.deltaLink"];
656
+ }
657
+ pageCount++;
658
+ } else {
659
+ break;
646
660
  }
647
- pageCount++;
648
- } else {
649
- break;
650
661
  }
651
- }
652
- if (pageCount >= maxPages) {
653
- logger.warn(`Reached maximum page limit (${maxPages}) for pagination`);
654
- }
655
- if (allItems.length >= maxItems) {
656
- logger.warn(
657
- `Reached maximum item limit (${maxItems}) for pagination \u2014 truncated at ${allItems.length} items`
662
+ if (pageCount >= maxPages) {
663
+ logger.warn(`Reached maximum page limit (${maxPages}) for pagination`);
664
+ }
665
+ if (allItems.length >= maxItems) {
666
+ logger.warn(
667
+ `Reached maximum item limit (${maxItems}) for pagination \u2014 truncated at ${allItems.length} items`
668
+ );
669
+ }
670
+ combinedResponse.value = allItems;
671
+ if (combinedResponse["@odata.count"]) {
672
+ combinedResponse["@odata.count"] = allItems.length;
673
+ }
674
+ delete combinedResponse["@odata.nextLink"];
675
+ if (deltaLink) {
676
+ combinedResponse["@odata.deltaLink"] = deltaLink;
677
+ }
678
+ logger.info(
679
+ `Pagination complete: collected ${allItems.length} items across ${pageCount} pages`
658
680
  );
659
681
  }
660
- combinedResponse.value = allItems;
661
- if (combinedResponse["@odata.count"]) {
662
- combinedResponse["@odata.count"] = allItems.length;
663
- }
664
- delete combinedResponse["@odata.nextLink"];
665
- if (deltaLink) {
666
- combinedResponse["@odata.deltaLink"] = deltaLink;
667
- }
668
- response.content[0].text = JSON.stringify(combinedResponse);
669
- logger.info(
670
- `Pagination complete: collected ${allItems.length} items across ${pageCount} pages`
671
- );
672
682
  } catch (e) {
673
683
  logger.error(`Error during pagination: ${e}`);
674
684
  }
685
+ if (combinedResponse !== void 0) {
686
+ response.content[0].text = graphClient.serialize(combinedResponse);
687
+ }
675
688
  }
676
689
  if (response?.content?.[0]?.text) {
677
690
  const responseText = response.content[0].text;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softeria/ms-365-mcp-server",
3
- "version": "0.129.0",
3
+ "version": "0.129.1",
4
4
  "description": " A Model Context Protocol (MCP) server for interacting with Microsoft 365 and Office services through the Graph API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",