@softeria/ms-365-mcp-server 0.87.1 → 0.88.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.
@@ -14,6 +14,46 @@ export function createAndSaveSimplifiedOpenAPI(endpointsFile, openapiFile, opena
14
14
  }
15
15
  }
16
16
 
17
+ // Synthesize operations that the Graph REST API supports but are missing from
18
+ // Microsoft's published OpenAPI metadata (e.g. PATCH on range(address='{address}')
19
+ // for cell-value writes — documented in Excel API but not in the OpenAPI spec).
20
+ for (const endpoint of endpoints) {
21
+ const pathSpec = openApiSpec.paths[endpoint.pathPattern];
22
+ const methodLower = endpoint.method.toLowerCase();
23
+ if (pathSpec && !pathSpec[methodLower]) {
24
+ pathSpec[methodLower] = {
25
+ tags: ['drives.driveItem'],
26
+ summary: endpoint.llmTip || `${endpoint.toolName} (synthesized)`,
27
+ description: endpoint.llmTip || `${endpoint.toolName} (synthesized)`,
28
+ operationId: endpoint.toolName,
29
+ requestBody:
30
+ methodLower === 'get' || methodLower === 'delete'
31
+ ? undefined
32
+ : {
33
+ description: 'Operation payload',
34
+ required: true,
35
+ content: {
36
+ 'application/json': {
37
+ schema: { type: 'object', additionalProperties: true },
38
+ },
39
+ },
40
+ },
41
+ responses: {
42
+ '2XX': {
43
+ description: 'Success',
44
+ content: {
45
+ 'application/json': {
46
+ schema: { type: 'object', additionalProperties: true },
47
+ },
48
+ },
49
+ },
50
+ '4XX': { $ref: '#/components/responses/error' },
51
+ '5XX': { $ref: '#/components/responses/error' },
52
+ },
53
+ };
54
+ }
55
+ }
56
+
17
57
  for (const [key, value] of Object.entries(openApiSpec.paths)) {
18
58
  const e = endpoints.filter((ep) => ep.pathPattern === key);
19
59
  if (e.length === 0) {
@@ -395,4 +395,50 @@ describe("graph-tools", () => {
395
395
  expect(tool.schema["timezone"]).toBeUndefined();
396
396
  });
397
397
  });
398
+ describe("outlook.body-content-type Prefer header", () => {
399
+ it('should set Prefer: outlook.body-content-type="text" on GET requests', async () => {
400
+ const endpoint = makeEndpoint({ method: "get" });
401
+ const config = makeConfig({ method: "get" });
402
+ mockEndpoints.push(endpoint);
403
+ mockEndpointsJson = [config];
404
+ const graphClient = createMockGraphClient([
405
+ { content: [{ type: "text", text: JSON.stringify({ value: [] }) }] }
406
+ ]);
407
+ const server = createMockServer();
408
+ const { registerGraphTools } = await loadModule();
409
+ registerGraphTools(server, graphClient);
410
+ await server.tools.get("test-tool").handler({});
411
+ const [, options] = graphClient.graphRequest.mock.calls[0];
412
+ expect(options.headers["Prefer"]).toContain('outlook.body-content-type="text"');
413
+ });
414
+ it("should NOT set Prefer: outlook.body-content-type on POST requests", async () => {
415
+ const endpoint = makeEndpoint({
416
+ alias: "create-reply-draft",
417
+ method: "post",
418
+ path: "/me/messages/:messageId/createReply",
419
+ parameters: [
420
+ { name: "messageId", type: "Path", schema: z.string() },
421
+ { name: "body", type: "Body", schema: z.any() }
422
+ ]
423
+ });
424
+ const config = makeConfig({
425
+ toolName: "create-reply-draft",
426
+ method: "post",
427
+ pathPattern: "/me/messages/{message-id}/createReply"
428
+ });
429
+ mockEndpoints.push(endpoint);
430
+ mockEndpointsJson = [config];
431
+ const graphClient = createMockGraphClient([{ content: [{ type: "text", text: "{}" }] }]);
432
+ const server = createMockServer();
433
+ const { registerGraphTools } = await loadModule();
434
+ registerGraphTools(server, graphClient);
435
+ await server.tools.get("create-reply-draft").handler({
436
+ messageId: "AAMk123",
437
+ body: { Message: { body: { contentType: "html", content: "<p>hi</p>" } } }
438
+ });
439
+ const [, options] = graphClient.graphRequest.mock.calls[0];
440
+ const prefer = options.headers["Prefer"];
441
+ expect(prefer === void 0 || !prefer.includes("outlook.body-content-type")).toBe(true);
442
+ });
443
+ });
398
444
  });
@@ -190,13 +190,15 @@
190
190
  "pathPattern": "/me/messages/{message-id}/createReply",
191
191
  "method": "post",
192
192
  "toolName": "create-reply-draft",
193
- "scopes": ["Mail.ReadWrite"]
193
+ "scopes": ["Mail.ReadWrite"],
194
+ "llmTip": "For HTML replies pass Message.body.contentType: 'html' with Message.body.content as HTML. Note: supplying Message.body replaces the whole draft body, so the original quoted history is not included. Specifying both 'comment' and Message.body returns 400. Signatures are added by the Outlook client only, not via Graph."
194
195
  },
195
196
  {
196
197
  "pathPattern": "/me/messages/{message-id}/createReplyAll",
197
198
  "method": "post",
198
199
  "toolName": "create-reply-all-draft",
199
- "scopes": ["Mail.ReadWrite"]
200
+ "scopes": ["Mail.ReadWrite"],
201
+ "llmTip": "For HTML replies pass Message.body.contentType: 'html' with Message.body.content as HTML. Note: supplying Message.body replaces the whole draft body, so the original quoted history is not included. Specifying both 'comment' and Message.body returns 400. Signatures are added by the Outlook client only, not via Graph."
200
202
  },
201
203
  {
202
204
  "pathPattern": "/me/messages/{message-id}/send",
@@ -556,6 +558,59 @@
556
558
  "scopes": ["Files.Read"],
557
559
  "skipEncoding": ["address"]
558
560
  },
561
+ {
562
+ "pathPattern": "/drives/{drive-id}/items/{driveItem-id}/workbook/worksheets/{workbookWorksheet-id}/range(address='{address}')",
563
+ "method": "patch",
564
+ "toolName": "update-excel-range",
565
+ "isExcelOp": true,
566
+ "scopes": ["Files.ReadWrite"],
567
+ "skipEncoding": ["address"],
568
+ "llmTip": "Set cell values, formulas, or number format on any range — does NOT require the worksheet to be a formal Excel table. Body: { values: [['v1','v2','v3']] } for a single row, or [['a','b'],['c','d']] for multi-row. Use this for append (target the next empty row's address, e.g. 'A172:H172'), update (target a single cell like 'H42'), or prepend-style edits (read existing, concatenate, write back). Number of inner-array values must match the column count of the address."
569
+ },
570
+ {
571
+ "pathPattern": "/drives/{drive-id}/items/{driveItem-id}/workbook/worksheets/{workbookWorksheet-id}/range(address='{address}')/insert",
572
+ "method": "post",
573
+ "toolName": "insert-excel-range",
574
+ "isExcelOp": true,
575
+ "scopes": ["Files.ReadWrite"],
576
+ "skipEncoding": ["address"],
577
+ "llmTip": "Insert blank cells at the given range, shifting existing content. Body: { shift: 'Down' } or { shift: 'Right' }. Use 'Down' to insert blank rows above existing data."
578
+ },
579
+ {
580
+ "pathPattern": "/drives/{drive-id}/items/{driveItem-id}/workbook/worksheets/{workbookWorksheet-id}/range(address='{address}')/delete",
581
+ "method": "post",
582
+ "toolName": "delete-excel-range",
583
+ "isExcelOp": true,
584
+ "scopes": ["Files.ReadWrite"],
585
+ "skipEncoding": ["address"],
586
+ "llmTip": "Delete cells at the given range, shifting remaining content. Body: { shift: 'Up' } or { shift: 'Left' }. Use 'Up' to delete entire rows."
587
+ },
588
+ {
589
+ "pathPattern": "/drives/{drive-id}/items/{driveItem-id}/workbook/tables/{workbookTable-id}/rows/itemAt(index={index})",
590
+ "method": "patch",
591
+ "toolName": "update-excel-table-row",
592
+ "isExcelOp": true,
593
+ "scopes": ["Files.ReadWrite"],
594
+ "skipEncoding": ["index"],
595
+ "llmTip": "Update a single row in a formal Excel table by zero-based row index. Body: { values: [[...]] } with one inner array matching the column count."
596
+ },
597
+ {
598
+ "pathPattern": "/drives/{drive-id}/items/{driveItem-id}/workbook/tables/{workbookTable-id}/rows/itemAt(index={index})",
599
+ "method": "delete",
600
+ "toolName": "delete-excel-table-row",
601
+ "isExcelOp": true,
602
+ "scopes": ["Files.ReadWrite"],
603
+ "skipEncoding": ["index"],
604
+ "llmTip": "Delete a single row from a formal Excel table by zero-based row index."
605
+ },
606
+ {
607
+ "pathPattern": "/drives/{drive-id}/items/{driveItem-id}/workbook/worksheets/{workbookWorksheet-id}/tables/add",
608
+ "method": "post",
609
+ "toolName": "create-excel-table",
610
+ "isExcelOp": true,
611
+ "scopes": ["Files.ReadWrite"],
612
+ "llmTip": "Convert a worksheet range into a formal Excel table. Body: { address: 'A1:H171', hasHeaders: true }. Required before using add-excel-table-rows / update-excel-table-row / delete-excel-table-row on a plain-cells sheet."
613
+ },
559
614
  {
560
615
  "pathPattern": "/drives/{drive-id}/items/{driveItem-id}/workbook/worksheets",
561
616
  "method": "get",
@@ -1358,6 +1358,7 @@ const microsoft_graph_workbookRange = z.object({
1358
1358
  sort: microsoft_graph_workbookRangeSort.optional(),
1359
1359
  worksheet: microsoft_graph_workbookWorksheet.optional()
1360
1360
  }).passthrough();
1361
+ const create_excel_table_Body = z.object({ address: z.string().nullable(), hasHeaders: z.boolean().default(false) }).partial().passthrough();
1361
1362
  const microsoft_graph_assignedLabel = z.object({
1362
1363
  displayName: z.string().describe("The display name of the label. Read-only.").nullish(),
1363
1364
  labelId: z.string().describe("The unique identifier of the label.").nullish()
@@ -4461,6 +4462,7 @@ const schemas = {
4461
4462
  microsoft_graph_workbookRangeFormat,
4462
4463
  microsoft_graph_workbookRangeSort,
4463
4464
  microsoft_graph_workbookRange,
4465
+ create_excel_table_Body,
4464
4466
  microsoft_graph_assignedLabel,
4465
4467
  microsoft_graph_licenseProcessingState,
4466
4468
  microsoft_graph_group,
@@ -5737,6 +5739,30 @@ Items with this property set should be removed from your local state.`,
5737
5739
  ],
5738
5740
  response: z.void()
5739
5741
  },
5742
+ {
5743
+ method: "patch",
5744
+ path: "/drives/:driveId/items/:driveItemId/workbook/tables/:workbookTableId/rows/itemAt(index=:index)",
5745
+ alias: "update-excel-table-row",
5746
+ description: `Update a single row in a formal Excel table by zero-based row index. Body: { values: [[...]] } with one inner array matching the column count.`,
5747
+ requestFormat: "json",
5748
+ parameters: [
5749
+ {
5750
+ name: "body",
5751
+ description: `Operation payload`,
5752
+ type: "Body",
5753
+ schema: z.object({}).partial().passthrough().passthrough()
5754
+ }
5755
+ ],
5756
+ response: z.void()
5757
+ },
5758
+ {
5759
+ method: "delete",
5760
+ path: "/drives/:driveId/items/:driveItemId/workbook/tables/:workbookTableId/rows/itemAt(index=:index)",
5761
+ alias: "delete-excel-table-row",
5762
+ description: `Delete a single row from a formal Excel table by zero-based row index.`,
5763
+ requestFormat: "json",
5764
+ response: z.void()
5765
+ },
5740
5766
  {
5741
5767
  method: "get",
5742
5768
  path: "/drives/:driveId/items/:driveItemId/workbook/worksheets",
@@ -5845,6 +5871,70 @@ Items with this property set should be removed from your local state.`,
5845
5871
  requestFormat: "json",
5846
5872
  response: z.void()
5847
5873
  },
5874
+ {
5875
+ method: "patch",
5876
+ path: `/drives/:driveId/items/:driveItemId/workbook/worksheets/:workbookWorksheetId/range(address=':address')`,
5877
+ alias: "update-excel-range",
5878
+ description: `Set cell values, formulas, or number format on any range \u2014 does NOT require the worksheet to be a formal Excel table. Body: { values: [['v1','v2','v3']] } for a single row, or [['a','b'],['c','d']] for multi-row. Use this for append (target the next empty row's address, e.g. 'A172:H172'), update (target a single cell like 'H42'), or prepend-style edits (read existing, concatenate, write back). Number of inner-array values must match the column count of the address.`,
5879
+ requestFormat: "json",
5880
+ parameters: [
5881
+ {
5882
+ name: "body",
5883
+ description: `Operation payload`,
5884
+ type: "Body",
5885
+ schema: z.object({}).partial().passthrough().passthrough()
5886
+ }
5887
+ ],
5888
+ response: z.void()
5889
+ },
5890
+ {
5891
+ method: "post",
5892
+ path: `/drives/:driveId/items/:driveItemId/workbook/worksheets/:workbookWorksheetId/range(address=':address')/delete`,
5893
+ alias: "delete-excel-range",
5894
+ description: `Invoke action delete`,
5895
+ requestFormat: "json",
5896
+ parameters: [
5897
+ {
5898
+ name: "body",
5899
+ description: `Action parameters`,
5900
+ type: "Body",
5901
+ schema: z.object({ shift: z.string() }).partial().passthrough()
5902
+ }
5903
+ ],
5904
+ response: z.void()
5905
+ },
5906
+ {
5907
+ method: "post",
5908
+ path: `/drives/:driveId/items/:driveItemId/workbook/worksheets/:workbookWorksheetId/range(address=':address')/insert`,
5909
+ alias: "insert-excel-range",
5910
+ description: `Invoke action insert`,
5911
+ requestFormat: "json",
5912
+ parameters: [
5913
+ {
5914
+ name: "body",
5915
+ description: `Action parameters`,
5916
+ type: "Body",
5917
+ schema: z.object({ shift: z.string() }).partial().passthrough()
5918
+ }
5919
+ ],
5920
+ response: z.void()
5921
+ },
5922
+ {
5923
+ method: "post",
5924
+ path: "/drives/:driveId/items/:driveItemId/workbook/worksheets/:workbookWorksheetId/tables/add",
5925
+ alias: "create-excel-table",
5926
+ description: `Create a new table. The range source address determines the worksheet under which the table will be added. If the table can't be added (for example, because the address is invalid, or the table would overlap with another table), an error is generated.`,
5927
+ requestFormat: "json",
5928
+ parameters: [
5929
+ {
5930
+ name: "body",
5931
+ description: `Action parameters`,
5932
+ type: "Body",
5933
+ schema: create_excel_table_Body
5934
+ }
5935
+ ],
5936
+ response: z.void()
5937
+ },
5848
5938
  {
5849
5939
  method: "get",
5850
5940
  path: "/drives/:driveId/root",
@@ -142,7 +142,7 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
142
142
  logger.info(`Setting timezone preference: outlook.timezone="${params.timezone}"`);
143
143
  }
144
144
  const bodyFormat = process.env.MS365_MCP_BODY_FORMAT || "text";
145
- if (bodyFormat !== "html") {
145
+ if (bodyFormat !== "html" && tool.method.toUpperCase() === "GET") {
146
146
  preferValues.push(`outlook.body-content-type="${bodyFormat}"`);
147
147
  }
148
148
  if (preferValues.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softeria/ms-365-mcp-server",
3
- "version": "0.87.1",
3
+ "version": "0.88.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",
@@ -190,13 +190,15 @@
190
190
  "pathPattern": "/me/messages/{message-id}/createReply",
191
191
  "method": "post",
192
192
  "toolName": "create-reply-draft",
193
- "scopes": ["Mail.ReadWrite"]
193
+ "scopes": ["Mail.ReadWrite"],
194
+ "llmTip": "For HTML replies pass Message.body.contentType: 'html' with Message.body.content as HTML. Note: supplying Message.body replaces the whole draft body, so the original quoted history is not included. Specifying both 'comment' and Message.body returns 400. Signatures are added by the Outlook client only, not via Graph."
194
195
  },
195
196
  {
196
197
  "pathPattern": "/me/messages/{message-id}/createReplyAll",
197
198
  "method": "post",
198
199
  "toolName": "create-reply-all-draft",
199
- "scopes": ["Mail.ReadWrite"]
200
+ "scopes": ["Mail.ReadWrite"],
201
+ "llmTip": "For HTML replies pass Message.body.contentType: 'html' with Message.body.content as HTML. Note: supplying Message.body replaces the whole draft body, so the original quoted history is not included. Specifying both 'comment' and Message.body returns 400. Signatures are added by the Outlook client only, not via Graph."
200
202
  },
201
203
  {
202
204
  "pathPattern": "/me/messages/{message-id}/send",
@@ -556,6 +558,59 @@
556
558
  "scopes": ["Files.Read"],
557
559
  "skipEncoding": ["address"]
558
560
  },
561
+ {
562
+ "pathPattern": "/drives/{drive-id}/items/{driveItem-id}/workbook/worksheets/{workbookWorksheet-id}/range(address='{address}')",
563
+ "method": "patch",
564
+ "toolName": "update-excel-range",
565
+ "isExcelOp": true,
566
+ "scopes": ["Files.ReadWrite"],
567
+ "skipEncoding": ["address"],
568
+ "llmTip": "Set cell values, formulas, or number format on any range — does NOT require the worksheet to be a formal Excel table. Body: { values: [['v1','v2','v3']] } for a single row, or [['a','b'],['c','d']] for multi-row. Use this for append (target the next empty row's address, e.g. 'A172:H172'), update (target a single cell like 'H42'), or prepend-style edits (read existing, concatenate, write back). Number of inner-array values must match the column count of the address."
569
+ },
570
+ {
571
+ "pathPattern": "/drives/{drive-id}/items/{driveItem-id}/workbook/worksheets/{workbookWorksheet-id}/range(address='{address}')/insert",
572
+ "method": "post",
573
+ "toolName": "insert-excel-range",
574
+ "isExcelOp": true,
575
+ "scopes": ["Files.ReadWrite"],
576
+ "skipEncoding": ["address"],
577
+ "llmTip": "Insert blank cells at the given range, shifting existing content. Body: { shift: 'Down' } or { shift: 'Right' }. Use 'Down' to insert blank rows above existing data."
578
+ },
579
+ {
580
+ "pathPattern": "/drives/{drive-id}/items/{driveItem-id}/workbook/worksheets/{workbookWorksheet-id}/range(address='{address}')/delete",
581
+ "method": "post",
582
+ "toolName": "delete-excel-range",
583
+ "isExcelOp": true,
584
+ "scopes": ["Files.ReadWrite"],
585
+ "skipEncoding": ["address"],
586
+ "llmTip": "Delete cells at the given range, shifting remaining content. Body: { shift: 'Up' } or { shift: 'Left' }. Use 'Up' to delete entire rows."
587
+ },
588
+ {
589
+ "pathPattern": "/drives/{drive-id}/items/{driveItem-id}/workbook/tables/{workbookTable-id}/rows/itemAt(index={index})",
590
+ "method": "patch",
591
+ "toolName": "update-excel-table-row",
592
+ "isExcelOp": true,
593
+ "scopes": ["Files.ReadWrite"],
594
+ "skipEncoding": ["index"],
595
+ "llmTip": "Update a single row in a formal Excel table by zero-based row index. Body: { values: [[...]] } with one inner array matching the column count."
596
+ },
597
+ {
598
+ "pathPattern": "/drives/{drive-id}/items/{driveItem-id}/workbook/tables/{workbookTable-id}/rows/itemAt(index={index})",
599
+ "method": "delete",
600
+ "toolName": "delete-excel-table-row",
601
+ "isExcelOp": true,
602
+ "scopes": ["Files.ReadWrite"],
603
+ "skipEncoding": ["index"],
604
+ "llmTip": "Delete a single row from a formal Excel table by zero-based row index."
605
+ },
606
+ {
607
+ "pathPattern": "/drives/{drive-id}/items/{driveItem-id}/workbook/worksheets/{workbookWorksheet-id}/tables/add",
608
+ "method": "post",
609
+ "toolName": "create-excel-table",
610
+ "isExcelOp": true,
611
+ "scopes": ["Files.ReadWrite"],
612
+ "llmTip": "Convert a worksheet range into a formal Excel table. Body: { address: 'A1:H171', hasHeaders: true }. Required before using add-excel-table-rows / update-excel-table-row / delete-excel-table-row on a plain-cells sheet."
613
+ },
559
614
  {
560
615
  "pathPattern": "/drives/{drive-id}/items/{driveItem-id}/workbook/worksheets",
561
616
  "method": "get",