@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.
- package/dist/__tests__/graph-tools.test.js +83 -2
- package/dist/graph-client.js +23 -13
- package/dist/graph-tools.js +59 -46
- package/package.json +1 -1
|
@@ -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 = [];
|
package/dist/graph-client.js
CHANGED
|
@@ -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
|
-
|
|
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 },
|
|
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,
|
|
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,
|
|
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 },
|
|
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,
|
|
232
|
+
content: [{ type: "text", text: this.serializeData(data, outputFormat, true) }]
|
|
223
233
|
};
|
|
224
234
|
}
|
|
225
235
|
}
|
package/dist/graph-tools.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
const
|
|
640
|
-
if (
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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.
|
|
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",
|