@softeria/ms-365-mcp-server 0.134.5 → 0.136.0

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/README.md CHANGED
@@ -149,6 +149,21 @@ CLI value takes precedence over `MS365_MCP_ALLOWED_SCOPES`; if neither is set, t
149
149
 
150
150
  Scope coverage is hierarchy-aware: for example, `Mail.ReadWrite` covers tools that require `Mail.Read`, and `Files.ReadWrite.All` covers tools that require `Files.Read`.
151
151
 
152
+ SharePoint supports two enterprise permission models:
153
+
154
+ - Broad tenant scopes such as `Sites.Read.All`, `Sites.ReadWrite.All`, and `Sites.Manage.All`.
155
+ - Microsoft Graph `Sites.Selected`, where SharePoint site access is granted to the app on specific site collections and Graph evaluates the signed-in user's own permissions at request time.
156
+
157
+ The default org-mode behavior continues to request the broad SharePoint scopes used by existing deployments. Enterprises that want selected-site SharePoint access can set an allowlist containing `Sites.Selected` instead of broad `Sites.*.All` scopes. Direct site/list/item tools that target an explicit SharePoint site can run with `Sites.Selected`; tenant-wide SharePoint discovery and search tools still require broad SharePoint scopes.
158
+
159
+ ```bash
160
+ npx @softeria/ms-365-mcp-server \
161
+ --org-mode \
162
+ --read-only \
163
+ --enabled-tools 'sharepoint|site|drive|planner' \
164
+ --allowed-scopes 'User.Read Files.Read Notes.Read Tasks.Read Sites.Selected'
165
+ ```
166
+
152
167
  In HTTP mode, OAuth discovery advertises the effective filtered permissions so clients request the same consent surface. On-Behalf-Of mode (`--obo`) still advertises `api://<clientId>/access_as_user` for protected-resource metadata; `--allowed-scopes` does not override OBO.
153
168
 
154
169
  ### Requesting extra scopes
@@ -89,6 +89,10 @@ async function loadModule() {
89
89
  const mod = await import("../graph-tools.js");
90
90
  return mod;
91
91
  }
92
+ async function spyOnAuditLogger() {
93
+ const { __testing } = await import("../audit-log.js");
94
+ return vi.spyOn(__testing.auditLogger, "info").mockImplementation(() => __testing.auditLogger);
95
+ }
92
96
  function createMockServer() {
93
97
  const tools = /* @__PURE__ */ new Map();
94
98
  return {
@@ -139,6 +143,234 @@ describe("graph-tools", () => {
139
143
  expect(url).toContain("$count=true");
140
144
  });
141
145
  });
146
+ describe("audit target resources", () => {
147
+ it("adds target_resource to generated Graph tool audit events", async () => {
148
+ const endpoint = makeEndpoint({
149
+ alias: "get-drive-item",
150
+ path: "/drives/:driveId/items/:driveItemId",
151
+ parameters: [
152
+ { name: "driveId", type: "Path", schema: z.string() },
153
+ { name: "driveItemId", type: "Path", schema: z.string() }
154
+ ]
155
+ });
156
+ const config = makeConfig({
157
+ toolName: "get-drive-item",
158
+ pathPattern: "/drives/{drive-id}/items/{driveItem-id}",
159
+ scopes: ["Files.Read"]
160
+ });
161
+ mockEndpoints.push(endpoint);
162
+ mockEndpointsJson = [config];
163
+ const graphClient = createMockGraphClient([
164
+ { content: [{ type: "text", text: JSON.stringify({ id: "item-2" }) }] }
165
+ ]);
166
+ const server = createMockServer();
167
+ const { registerGraphTools } = await loadModule();
168
+ registerGraphTools(server, graphClient);
169
+ const auditSpy = await spyOnAuditLogger();
170
+ await server.tools.get("get-drive-item").handler({
171
+ driveId: "drive-1",
172
+ driveItemId: "item-2"
173
+ });
174
+ expect(auditSpy).toHaveBeenCalledWith(
175
+ expect.objectContaining({
176
+ event: "tool.call",
177
+ tool: "get-drive-item",
178
+ status: "success",
179
+ target_resource: {
180
+ type: "drive_item",
181
+ id: "/drives/drive-1/items/item-2"
182
+ }
183
+ })
184
+ );
185
+ auditSpy.mockRestore();
186
+ });
187
+ it("adds target_resource to failed generated Graph tool audit events", async () => {
188
+ const endpoint = makeEndpoint({
189
+ alias: "get-drive-item",
190
+ path: "/drives/:driveId/items/:driveItemId",
191
+ parameters: [
192
+ { name: "driveId", type: "Path", schema: z.string() },
193
+ { name: "driveItemId", type: "Path", schema: z.string() }
194
+ ]
195
+ });
196
+ const config = makeConfig({
197
+ toolName: "get-drive-item",
198
+ pathPattern: "/drives/{drive-id}/items/{driveItem-id}",
199
+ scopes: ["Files.Read"]
200
+ });
201
+ mockEndpoints.push(endpoint);
202
+ mockEndpointsJson = [config];
203
+ const graphClient = createMockGraphClient();
204
+ graphClient.graphRequest.mockRejectedValueOnce(
205
+ Object.assign(new Error("Forbidden"), { status: 403 })
206
+ );
207
+ const server = createMockServer();
208
+ const { registerGraphTools } = await loadModule();
209
+ registerGraphTools(
210
+ server,
211
+ graphClient
212
+ );
213
+ const auditSpy = await spyOnAuditLogger();
214
+ const result = await server.tools.get("get-drive-item").handler({
215
+ driveId: "drive-1",
216
+ driveItemId: "item-2"
217
+ });
218
+ expect(result.isError).toBe(true);
219
+ expect(auditSpy).toHaveBeenCalledWith(
220
+ expect.objectContaining({
221
+ event: "tool.call",
222
+ tool: "get-drive-item",
223
+ status: "error",
224
+ error_code: 403,
225
+ target_resource: {
226
+ type: "drive_item",
227
+ id: "/drives/drive-1/items/item-2"
228
+ }
229
+ })
230
+ );
231
+ auditSpy.mockRestore();
232
+ });
233
+ it("derives target_resource from generic ID path parameters", async () => {
234
+ const endpoint = makeEndpoint({
235
+ alias: "get-mail-message",
236
+ path: "/me/messages/:messageId",
237
+ parameters: [{ name: "messageId", type: "Path", schema: z.string() }]
238
+ });
239
+ const config = makeConfig({
240
+ toolName: "get-mail-message",
241
+ pathPattern: "/me/messages/{message-id}"
242
+ });
243
+ mockEndpoints.push(endpoint);
244
+ mockEndpointsJson = [config];
245
+ const graphClient = createMockGraphClient([
246
+ { content: [{ type: "text", text: JSON.stringify({ id: "message-1" }) }] }
247
+ ]);
248
+ const server = createMockServer();
249
+ const { registerGraphTools } = await loadModule();
250
+ registerGraphTools(server, graphClient);
251
+ const auditSpy = await spyOnAuditLogger();
252
+ await server.tools.get("get-mail-message").handler({
253
+ messageId: "message-1"
254
+ });
255
+ expect(auditSpy).toHaveBeenCalledWith(
256
+ expect.objectContaining({
257
+ event: "tool.call",
258
+ tool: "get-mail-message",
259
+ status: "success",
260
+ target_resource: {
261
+ type: "message",
262
+ id: "/me/messages/message-1"
263
+ }
264
+ })
265
+ );
266
+ auditSpy.mockRestore();
267
+ });
268
+ it("omits target_resource when an ID path parameter is missing", async () => {
269
+ const endpoint = makeEndpoint({
270
+ alias: "get-drive-item",
271
+ path: "/drives/:driveId/items/:driveItemId",
272
+ parameters: [
273
+ { name: "driveId", type: "Path", schema: z.string() },
274
+ { name: "driveItemId", type: "Path", schema: z.string() }
275
+ ]
276
+ });
277
+ const config = makeConfig({
278
+ toolName: "get-drive-item",
279
+ pathPattern: "/drives/{drive-id}/items/{driveItem-id}",
280
+ scopes: ["Files.Read"]
281
+ });
282
+ mockEndpoints.push(endpoint);
283
+ mockEndpointsJson = [config];
284
+ const graphClient = createMockGraphClient([
285
+ { content: [{ type: "text", text: JSON.stringify({ id: "item-2" }) }] }
286
+ ]);
287
+ const server = createMockServer();
288
+ const { registerGraphTools } = await loadModule();
289
+ registerGraphTools(server, graphClient);
290
+ const auditSpy = await spyOnAuditLogger();
291
+ await server.tools.get("get-drive-item").handler({
292
+ driveId: "drive-1"
293
+ });
294
+ const [payload] = auditSpy.mock.calls[0];
295
+ expect(payload).toMatchObject({
296
+ event: "tool.call",
297
+ tool: "get-drive-item",
298
+ status: "success"
299
+ });
300
+ expect(payload).not.toHaveProperty("target_resource");
301
+ auditSpy.mockRestore();
302
+ });
303
+ it("omits SharePoint path parameters from target_resource", async () => {
304
+ const endpoint = makeEndpoint({
305
+ alias: "get-sharepoint-site-by-path",
306
+ path: "/sites/:siteId/getByPath(path=':path')",
307
+ parameters: [
308
+ { name: "siteId", type: "Path", schema: z.string() },
309
+ { name: "path", type: "Path", schema: z.string() }
310
+ ]
311
+ });
312
+ const config = makeConfig({
313
+ toolName: "get-sharepoint-site-by-path",
314
+ pathPattern: "/sites/{site-id}:/{path}",
315
+ scopes: [["Sites.Read.All"], ["Sites.Selected"]]
316
+ });
317
+ mockEndpoints.push(endpoint);
318
+ mockEndpointsJson = [config];
319
+ const graphClient = createMockGraphClient([
320
+ { content: [{ type: "text", text: JSON.stringify({ id: "site-1" }) }] }
321
+ ]);
322
+ const server = createMockServer();
323
+ const { registerGraphTools } = await loadModule();
324
+ registerGraphTools(server, graphClient);
325
+ const auditSpy = await spyOnAuditLogger();
326
+ await server.tools.get("get-sharepoint-site-by-path").handler({
327
+ siteId: "contoso.sharepoint.com",
328
+ path: "/sites/Finance"
329
+ });
330
+ expect(auditSpy).toHaveBeenCalledWith(
331
+ expect.objectContaining({
332
+ event: "tool.call",
333
+ tool: "get-sharepoint-site-by-path",
334
+ status: "success",
335
+ target_resource: {
336
+ type: "site",
337
+ id: "/sites/contoso.sharepoint.com"
338
+ }
339
+ })
340
+ );
341
+ const [payload] = auditSpy.mock.calls[0];
342
+ expect(JSON.stringify(payload)).not.toContain("Finance");
343
+ auditSpy.mockRestore();
344
+ });
345
+ it("omits target_resource for generated broad list/search audit events", async () => {
346
+ const endpoint = makeEndpoint({
347
+ alias: "list-mail-messages",
348
+ path: "/me/messages"
349
+ });
350
+ const config = makeConfig({
351
+ toolName: "list-mail-messages",
352
+ pathPattern: "/me/messages"
353
+ });
354
+ mockEndpoints.push(endpoint);
355
+ mockEndpointsJson = [config];
356
+ const graphClient = createMockGraphClient([
357
+ { content: [{ type: "text", text: JSON.stringify({ value: [] }) }] }
358
+ ]);
359
+ const server = createMockServer();
360
+ const { registerGraphTools } = await loadModule();
361
+ registerGraphTools(server, graphClient);
362
+ const auditSpy = await spyOnAuditLogger();
363
+ await server.tools.get("list-mail-messages").handler({ search: "budget" });
364
+ const [payload] = auditSpy.mock.calls[0];
365
+ expect(payload).toMatchObject({
366
+ event: "tool.call",
367
+ tool: "list-mail-messages",
368
+ status: "success"
369
+ });
370
+ expect(payload).not.toHaveProperty("target_resource");
371
+ auditSpy.mockRestore();
372
+ });
373
+ });
142
374
  describe("fetchAllPages pagination", () => {
143
375
  it("should follow @odata.nextLink and combine results", async () => {
144
376
  const endpoint = makeEndpoint();
@@ -0,0 +1,75 @@
1
+ const CONTROL_PARAM_NAMES = /* @__PURE__ */ new Set([
2
+ "account",
3
+ "confirm",
4
+ "fetchAllPages",
5
+ "includeHeaders",
6
+ "excludeResponse",
7
+ "timezone",
8
+ "expandExtendedProperties"
9
+ ]);
10
+ function toCamelCase(name) {
11
+ return name.replace(/-([a-zA-Z])/g, (_, c) => c.toUpperCase());
12
+ }
13
+ function toKebabCase(name) {
14
+ return name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
15
+ }
16
+ function toSnakeCase(name) {
17
+ return name.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[-\s]+/g, "_").replace(/_+/g, "_").replace(/^_|_$/g, "").toLowerCase();
18
+ }
19
+ function valueForPlaceholder(placeholderName, params) {
20
+ const candidates = [placeholderName, toCamelCase(placeholderName), toKebabCase(placeholderName)];
21
+ for (const candidate of candidates) {
22
+ if (CONTROL_PARAM_NAMES.has(candidate)) continue;
23
+ const value = params[candidate];
24
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
25
+ return encodeURIComponent(String(value)).replace(/%3D/g, "=");
26
+ }
27
+ }
28
+ return void 0;
29
+ }
30
+ function resolveGraphPathForAudit(pathPattern, params = {}) {
31
+ if (!pathPattern) return void 0;
32
+ let resolvedPath = pathPattern;
33
+ resolvedPath = resolvedPath.replace(/:([A-Za-z][A-Za-z0-9-]*)/g, (match, name) => {
34
+ return valueForPlaceholder(name, params) ?? match;
35
+ });
36
+ resolvedPath = resolvedPath.replace(/\{([^}]+)\}/g, (match, name) => {
37
+ return valueForPlaceholder(name, params) ?? match;
38
+ });
39
+ if (/:([A-Za-z][A-Za-z0-9-]*)|\{[^}]+\}/.test(resolvedPath)) {
40
+ return void 0;
41
+ }
42
+ return resolvedPath.startsWith("/") ? resolvedPath : `/${resolvedPath}`;
43
+ }
44
+ function idPlaceholderBase(name) {
45
+ const base = name.replace(/[-_]?id\d*$/i, "");
46
+ if (base === name || base.length === 0) return void 0;
47
+ return base;
48
+ }
49
+ function deriveTargetResource(input) {
50
+ const pathPattern = input.pathPattern;
51
+ if (!pathPattern) return void 0;
52
+ const placeholderPattern = /\{([^}]+)\}|:([A-Za-z][A-Za-z0-9-]*)/g;
53
+ let lastTarget;
54
+ for (const match of pathPattern.matchAll(placeholderPattern)) {
55
+ const name = match[1] ?? match[2];
56
+ const base = idPlaceholderBase(name);
57
+ if (!base) continue;
58
+ lastTarget = {
59
+ base,
60
+ end: match.index + match[0].length
61
+ };
62
+ }
63
+ if (!lastTarget) return void 0;
64
+ const targetPattern = pathPattern.slice(0, lastTarget.end);
65
+ const id = resolveGraphPathForAudit(targetPattern, input.params ?? {});
66
+ if (!id) return void 0;
67
+ return {
68
+ type: toSnakeCase(lastTarget.base),
69
+ id
70
+ };
71
+ }
72
+ export {
73
+ deriveTargetResource,
74
+ resolveGraphPathForAudit
75
+ };
@@ -1811,56 +1811,56 @@
1811
1811
  "method": "get",
1812
1812
  "toolName": "get-sharepoint-site",
1813
1813
  "presets": ["work"],
1814
- "workScopes": ["Sites.Read.All"]
1814
+ "workScopes": [["Sites.Read.All"], ["Sites.Selected"]]
1815
1815
  },
1816
1816
  {
1817
1817
  "pathPattern": "/sites/{site-id}/drives",
1818
1818
  "method": "get",
1819
1819
  "toolName": "list-sharepoint-site-drives",
1820
1820
  "presets": ["work"],
1821
- "workScopes": ["Sites.Read.All"]
1821
+ "workScopes": [["Sites.Read.All"], ["Sites.Selected"]]
1822
1822
  },
1823
1823
  {
1824
1824
  "pathPattern": "/sites/{site-id}/drives/{drive-id}",
1825
1825
  "method": "get",
1826
1826
  "toolName": "get-sharepoint-site-drive-by-id",
1827
1827
  "presets": ["work"],
1828
- "workScopes": ["Sites.Read.All"]
1828
+ "workScopes": [["Sites.Read.All"], ["Sites.Selected"]]
1829
1829
  },
1830
1830
  {
1831
1831
  "pathPattern": "/sites/{site-id}/items",
1832
1832
  "method": "get",
1833
1833
  "toolName": "list-sharepoint-site-items",
1834
1834
  "presets": ["work"],
1835
- "workScopes": ["Sites.Read.All"]
1835
+ "workScopes": [["Sites.Read.All"], ["Sites.Selected"]]
1836
1836
  },
1837
1837
  {
1838
1838
  "pathPattern": "/sites/{site-id}/items/{baseItem-id}",
1839
1839
  "method": "get",
1840
1840
  "toolName": "get-sharepoint-site-item",
1841
1841
  "presets": ["work"],
1842
- "workScopes": ["Sites.Read.All"]
1842
+ "workScopes": [["Sites.Read.All"], ["Sites.Selected"]]
1843
1843
  },
1844
1844
  {
1845
1845
  "pathPattern": "/sites/{site-id}/lists",
1846
1846
  "method": "get",
1847
1847
  "toolName": "list-sharepoint-site-lists",
1848
1848
  "presets": ["work"],
1849
- "workScopes": ["Sites.Read.All"]
1849
+ "workScopes": [["Sites.Read.All"], ["Sites.Selected"]]
1850
1850
  },
1851
1851
  {
1852
1852
  "pathPattern": "/sites/{site-id}/lists/{list-id}",
1853
1853
  "method": "get",
1854
1854
  "toolName": "get-sharepoint-site-list",
1855
1855
  "presets": ["work"],
1856
- "workScopes": ["Sites.Read.All"]
1856
+ "workScopes": [["Sites.Read.All"], ["Sites.Selected"]]
1857
1857
  },
1858
1858
  {
1859
1859
  "pathPattern": "/sites/{site-id}/lists/{list-id}/items",
1860
1860
  "method": "get",
1861
1861
  "toolName": "list-sharepoint-site-list-items",
1862
1862
  "presets": ["work"],
1863
- "workScopes": ["Sites.Read.All"],
1863
+ "workScopes": [["Sites.Read.All"], ["Sites.Selected"]],
1864
1864
  "llmTip": "Add $expand=fields to include actual column values. Without it, only metadata is returned."
1865
1865
  },
1866
1866
  {
@@ -1868,7 +1868,7 @@
1868
1868
  "method": "get",
1869
1869
  "toolName": "get-sharepoint-site-list-item",
1870
1870
  "presets": ["work"],
1871
- "workScopes": ["Sites.Read.All"],
1871
+ "workScopes": [["Sites.Read.All"], ["Sites.Selected"]],
1872
1872
  "llmTip": "Add $expand=fields to include actual column values. Without it, only metadata is returned."
1873
1873
  },
1874
1874
  {
@@ -1876,7 +1876,7 @@
1876
1876
  "method": "post",
1877
1877
  "toolName": "create-sharepoint-list-item",
1878
1878
  "presets": ["work"],
1879
- "workScopes": ["Sites.ReadWrite.All"],
1879
+ "workScopes": [["Sites.ReadWrite.All"], ["Sites.Selected"]],
1880
1880
  "llmTip": "Creates a new item in a SharePoint list. Body: { fields: { Title: 'Item name', ColumnName: 'value', ... } }. Use list-sharepoint-site-lists to find the list ID and get-sharepoint-site-list to discover available columns."
1881
1881
  },
1882
1882
  {
@@ -1884,7 +1884,7 @@
1884
1884
  "method": "patch",
1885
1885
  "toolName": "update-sharepoint-list-item",
1886
1886
  "presets": ["work"],
1887
- "workScopes": ["Sites.ReadWrite.All"],
1887
+ "workScopes": [["Sites.ReadWrite.All"], ["Sites.Selected"]],
1888
1888
  "llmTip": "Updates fields on an existing list item. Body: { fields: { ColumnName: 'new value' } }. Send only the fields you want to change. Use $expand=fields on get-sharepoint-site-list-item to see current values first."
1889
1889
  },
1890
1890
  {
@@ -1892,7 +1892,7 @@
1892
1892
  "method": "delete",
1893
1893
  "toolName": "delete-sharepoint-list-item",
1894
1894
  "presets": ["work"],
1895
- "workScopes": ["Sites.ReadWrite.All"],
1895
+ "workScopes": [["Sites.ReadWrite.All"], ["Sites.Selected"]],
1896
1896
  "llmTip": "Deletes a list item permanently. This cannot be undone — the item is moved to the site recycle bin."
1897
1897
  },
1898
1898
  {
@@ -1900,7 +1900,7 @@
1900
1900
  "method": "post",
1901
1901
  "toolName": "create-sharepoint-list",
1902
1902
  "presets": ["work"],
1903
- "workScopes": ["Sites.Manage.All"],
1903
+ "workScopes": [["Sites.Manage.All"], ["Sites.Selected"]],
1904
1904
  "llmTip": "Creates a new SharePoint list in a site. Body: { displayName: 'My List', description: 'Optional', list: { template: 'genericList' }, columns: [ { name: 'Status', text: {} }, { name: 'Due', dateTime: {} } ] }. Templates include genericList, documentLibrary, tasks, calendar, contacts, links, announcements, survey. Columns can be defined inline at creation; otherwise add them later via create-sharepoint-list-column. Use search-sharepoint-sites or get-sharepoint-site-by-path to find the site ID first."
1905
1905
  },
1906
1906
  {
@@ -1908,7 +1908,7 @@
1908
1908
  "method": "get",
1909
1909
  "toolName": "list-sharepoint-list-columns",
1910
1910
  "presets": ["work"],
1911
- "workScopes": ["Sites.Read.All"],
1911
+ "workScopes": [["Sites.Read.All"], ["Sites.Selected"]],
1912
1912
  "llmTip": "Lists column definitions for a SharePoint list. Returns each column's id, name, displayName, description, type indicator (text, number, choice, dateTime, person, lookup, boolean, calculated, hyperlinkOrPicture, etc.), required, indexed, hidden, readOnly. Use this to discover the schema before creating or updating list items."
1913
1913
  },
1914
1914
  {
@@ -1916,7 +1916,7 @@
1916
1916
  "method": "post",
1917
1917
  "toolName": "create-sharepoint-list-column",
1918
1918
  "presets": ["work"],
1919
- "workScopes": ["Sites.Manage.All"],
1919
+ "workScopes": [["Sites.Manage.All"], ["Sites.Selected"]],
1920
1920
  "llmTip": "Creates a new column on a SharePoint list. Body must include name and exactly one column type property: { name: 'Priority', text: {} } or { name: 'DueDate', dateTime: { format: 'dateOnly' } } or { name: 'Status', choice: { choices: ['Open','In Progress','Done'] } }. Other types: number, boolean, currency, hyperlinkOrPicture, personOrGroup, lookup, calculated. Optional: displayName, description, required, indexed, enforceUniqueValues."
1921
1921
  },
1922
1922
  {
@@ -1924,7 +1924,7 @@
1924
1924
  "method": "get",
1925
1925
  "toolName": "get-sharepoint-list-column",
1926
1926
  "presets": ["work"],
1927
- "workScopes": ["Sites.Read.All"],
1927
+ "workScopes": [["Sites.Read.All"], ["Sites.Selected"]],
1928
1928
  "llmTip": "Gets a specific column definition by ID, including its full type configuration (choices for choice columns, format for dateTime, etc.). Use list-sharepoint-list-columns first to find the column ID."
1929
1929
  },
1930
1930
  {
@@ -1932,7 +1932,7 @@
1932
1932
  "method": "patch",
1933
1933
  "toolName": "update-sharepoint-list-column",
1934
1934
  "presets": ["work"],
1935
- "workScopes": ["Sites.Manage.All"],
1935
+ "workScopes": [["Sites.Manage.All"], ["Sites.Selected"]],
1936
1936
  "llmTip": "Updates a column definition. Body: { displayName: 'New name', description: 'New description', required: true, ... }. The column type itself (text, choice, etc.) cannot be changed — only its metadata and per-type options (e.g. choices array for a choice column). Send only the fields you want to change."
1937
1937
  },
1938
1938
  {
@@ -1940,7 +1940,7 @@
1940
1940
  "method": "delete",
1941
1941
  "toolName": "delete-sharepoint-list-column",
1942
1942
  "presets": ["work"],
1943
- "workScopes": ["Sites.Manage.All"],
1943
+ "workScopes": [["Sites.Manage.All"], ["Sites.Selected"]],
1944
1944
  "llmTip": "Deletes a column from a SharePoint list. This is irreversible — all data stored in this column across every list item is lost. Confirm with the user before calling. Cannot delete built-in columns (Title, Created, Modified, etc.)."
1945
1945
  },
1946
1946
  {
@@ -1948,7 +1948,7 @@
1948
1948
  "method": "get",
1949
1949
  "toolName": "get-sharepoint-site-by-path",
1950
1950
  "presets": ["work"],
1951
- "workScopes": ["Sites.Read.All"],
1951
+ "workScopes": [["Sites.Read.All"], ["Sites.Selected"]],
1952
1952
  "skipEncoding": ["path"],
1953
1953
  "llmTip": "Resolve a SharePoint site from its server-relative URL. site-id is the hostname (contoso.sharepoint.com) and path is the site path without a leading slash (sites/marketing, teams/hr). Returns the site object whose id feeds list-sharepoint-site-drives."
1954
1954
  },
@@ -20,6 +20,7 @@ import { TOOL_CATEGORIES } from "./tool-categories.js";
20
20
  import { getRequestTokens } from "./request-context.js";
21
21
  import { parseTeamsUrl } from "./lib/teams-url-parser.js";
22
22
  import { buildBM25Index, scoreQuery, tokenize } from "./lib/bm25.js";
23
+ import { deriveTargetResource } from "./audit-target-resource.js";
23
24
  import { describeToolSchema, describeUtilityToolSchema } from "./lib/tool-schema.js";
24
25
  import {
25
26
  TOP_UNSUPPORTED_DELTA_TOOLS,
@@ -598,6 +599,7 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
598
599
  const startTime = Date.now();
599
600
  const upn = getUserIdentityForAudit(getRequestTokens()?.accessToken);
600
601
  const httpMethod = tool.method.toUpperCase();
602
+ let targetResource;
601
603
  try {
602
604
  const accountParam = params.account;
603
605
  const accountModeError = await checkAccountParamInBearerMode(accountParam, authManager);
@@ -815,6 +817,10 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
815
817
  if (accountAccessToken) {
816
818
  options.accessToken = accountAccessToken;
817
819
  }
820
+ targetResource = deriveTargetResource({
821
+ pathPattern: config?.pathPattern ?? tool.path,
822
+ params
823
+ });
818
824
  const { accessToken: _redacted, ...safeOptions } = options;
819
825
  logger.info(
820
826
  `Making graph request to ${path2} with options: ${JSON.stringify(safeOptions)}${_redacted ? " [accessToken=REDACTED]" : ""}`
@@ -915,7 +921,8 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
915
921
  tool: tool.alias,
916
922
  http_method: httpMethod,
917
923
  status: response.isError ? "error" : "success",
918
- duration_ms: Date.now() - startTime
924
+ duration_ms: Date.now() - startTime,
925
+ ...targetResource ? { target_resource: targetResource } : {}
919
926
  });
920
927
  return {
921
928
  content,
@@ -933,6 +940,7 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
933
940
  http_method: httpMethod,
934
941
  status: "error",
935
942
  duration_ms: Date.now() - startTime,
943
+ ...targetResource ? { target_resource: targetResource } : {},
936
944
  error_type: err?.name || "Error",
937
945
  error_code: err?.status ?? err?.code
938
946
  });
@@ -213,7 +213,7 @@ The client automatically discovers OAuth endpoints and opens a browser for authe
213
213
  - **Tool filtering**: use `--enabled-tools <regex>` or `--preset <names>` to restrict available tools
214
214
  - **CORS**: configure `MS365_MCP_CORS_ORIGIN` to restrict allowed origins (defaults to `http://localhost:3000`); set explicitly when clients run on a different origin
215
215
  - **Disable Dynamic Client Registration**: when only a known client talks to the server, set `MS365_MCP_DISABLE_DCR=true` (or pass `--no-dynamic-registration`) to close the anonymous `/register` endpoint
216
- - **Structured audit log**: enabled by default. Every tool invocation emits one JSON line on stdout (captured by the container platform's log collector) and to `~/.ms-365-mcp-server/logs/audit.log` (mode `0o600`) with `{ event, request_id, user_principal_name, tool, http_method, status, duration_ms, error_type?, error_code? }`. The schema is intentionally narrow — tool parameters and Graph response bodies are NEVER recorded, and error messages are reduced to `error_type` / `error_code` so upstream library errors do not leak token fragments or query-string PII. Forms the "who accessed what, when" trail required for GDPR / HIPAA / PIPEDA / SOC 2 audit. Opt-out: `MS365_MCP_AUDIT_LOG=false`
216
+ - **Structured audit log**: enabled by default. Every tool invocation emits one JSON line on stderr (captured by the container platform's log collector) and to `~/.ms-365-mcp-server/logs/audit.log` (mode `0o600`) with `{ event, request_id, user_principal_name, tool, http_method, status, duration_ms, target_resource?, error_type?, error_code? }`. When an audited generated Microsoft Graph tool targets a derivable resource through an ID-like path parameter such as `{message-id}` or `{driveItem-id}`, `target_resource` is `{ type, id }`, where `id` is the Graph path up to that resource ID. Later path parameters such as `{path}`, query values, tool parameters, returned content, and Graph response bodies are NEVER recorded, and error messages are reduced to `error_type` / `error_code` so upstream library errors do not leak token fragments or query-string PII. Forms the "who accessed what, when" trail required for GDPR / HIPAA / PIPEDA / SOC 2 audit. Opt-out: `MS365_MCP_AUDIT_LOG=false`
217
217
  - **Graph resilience**: every call to Microsoft Graph is wrapped with a fetch timeout (default 100 s via `MS365_MCP_GRAPH_TIMEOUT_MS`), retry-with-backoff on 429 / 503 / 504 / network errors (default 3 retries, full-jitter exponential backoff, honours `Retry-After`; 503 / 504 / network errors only retried for idempotent methods, 429 retried on all methods), and a process-wide circuit breaker that opens after 5 consecutive failures and cools down for 30 s (`MS365_MCP_GRAPH_CIRCUIT_THRESHOLD` / `MS365_MCP_GRAPH_CIRCUIT_COOLDOWN_MS`). Disable the breaker for trusted automation: `MS365_MCP_GRAPH_CIRCUIT_DISABLED=true`
218
218
  - **Confirm gate on destructive tools**: opt-in, **off by default**. Enable with `MS365_MCP_REQUIRE_CONFIRM=true`. When on, destructive tools (POST except `readOnly`, PATCH, PUT, DELETE — `delete-mail-message`, `send-mail`, `update-event`, etc.) return `{ "error": "confirmation_required" }` until the caller re-invokes them with `"confirm": true`. Mitigates accidental writes when an LLM misroutes a request or follows an injected instruction. Shipped opt-in so it is a non-breaking, additive layer that can coexist with client-side elicitation prompts (MCP Elicitation API) where the client supports them.
219
219
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softeria/ms-365-mcp-server",
3
- "version": "0.134.5",
3
+ "version": "0.136.0",
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",
@@ -1811,56 +1811,56 @@
1811
1811
  "method": "get",
1812
1812
  "toolName": "get-sharepoint-site",
1813
1813
  "presets": ["work"],
1814
- "workScopes": ["Sites.Read.All"]
1814
+ "workScopes": [["Sites.Read.All"], ["Sites.Selected"]]
1815
1815
  },
1816
1816
  {
1817
1817
  "pathPattern": "/sites/{site-id}/drives",
1818
1818
  "method": "get",
1819
1819
  "toolName": "list-sharepoint-site-drives",
1820
1820
  "presets": ["work"],
1821
- "workScopes": ["Sites.Read.All"]
1821
+ "workScopes": [["Sites.Read.All"], ["Sites.Selected"]]
1822
1822
  },
1823
1823
  {
1824
1824
  "pathPattern": "/sites/{site-id}/drives/{drive-id}",
1825
1825
  "method": "get",
1826
1826
  "toolName": "get-sharepoint-site-drive-by-id",
1827
1827
  "presets": ["work"],
1828
- "workScopes": ["Sites.Read.All"]
1828
+ "workScopes": [["Sites.Read.All"], ["Sites.Selected"]]
1829
1829
  },
1830
1830
  {
1831
1831
  "pathPattern": "/sites/{site-id}/items",
1832
1832
  "method": "get",
1833
1833
  "toolName": "list-sharepoint-site-items",
1834
1834
  "presets": ["work"],
1835
- "workScopes": ["Sites.Read.All"]
1835
+ "workScopes": [["Sites.Read.All"], ["Sites.Selected"]]
1836
1836
  },
1837
1837
  {
1838
1838
  "pathPattern": "/sites/{site-id}/items/{baseItem-id}",
1839
1839
  "method": "get",
1840
1840
  "toolName": "get-sharepoint-site-item",
1841
1841
  "presets": ["work"],
1842
- "workScopes": ["Sites.Read.All"]
1842
+ "workScopes": [["Sites.Read.All"], ["Sites.Selected"]]
1843
1843
  },
1844
1844
  {
1845
1845
  "pathPattern": "/sites/{site-id}/lists",
1846
1846
  "method": "get",
1847
1847
  "toolName": "list-sharepoint-site-lists",
1848
1848
  "presets": ["work"],
1849
- "workScopes": ["Sites.Read.All"]
1849
+ "workScopes": [["Sites.Read.All"], ["Sites.Selected"]]
1850
1850
  },
1851
1851
  {
1852
1852
  "pathPattern": "/sites/{site-id}/lists/{list-id}",
1853
1853
  "method": "get",
1854
1854
  "toolName": "get-sharepoint-site-list",
1855
1855
  "presets": ["work"],
1856
- "workScopes": ["Sites.Read.All"]
1856
+ "workScopes": [["Sites.Read.All"], ["Sites.Selected"]]
1857
1857
  },
1858
1858
  {
1859
1859
  "pathPattern": "/sites/{site-id}/lists/{list-id}/items",
1860
1860
  "method": "get",
1861
1861
  "toolName": "list-sharepoint-site-list-items",
1862
1862
  "presets": ["work"],
1863
- "workScopes": ["Sites.Read.All"],
1863
+ "workScopes": [["Sites.Read.All"], ["Sites.Selected"]],
1864
1864
  "llmTip": "Add $expand=fields to include actual column values. Without it, only metadata is returned."
1865
1865
  },
1866
1866
  {
@@ -1868,7 +1868,7 @@
1868
1868
  "method": "get",
1869
1869
  "toolName": "get-sharepoint-site-list-item",
1870
1870
  "presets": ["work"],
1871
- "workScopes": ["Sites.Read.All"],
1871
+ "workScopes": [["Sites.Read.All"], ["Sites.Selected"]],
1872
1872
  "llmTip": "Add $expand=fields to include actual column values. Without it, only metadata is returned."
1873
1873
  },
1874
1874
  {
@@ -1876,7 +1876,7 @@
1876
1876
  "method": "post",
1877
1877
  "toolName": "create-sharepoint-list-item",
1878
1878
  "presets": ["work"],
1879
- "workScopes": ["Sites.ReadWrite.All"],
1879
+ "workScopes": [["Sites.ReadWrite.All"], ["Sites.Selected"]],
1880
1880
  "llmTip": "Creates a new item in a SharePoint list. Body: { fields: { Title: 'Item name', ColumnName: 'value', ... } }. Use list-sharepoint-site-lists to find the list ID and get-sharepoint-site-list to discover available columns."
1881
1881
  },
1882
1882
  {
@@ -1884,7 +1884,7 @@
1884
1884
  "method": "patch",
1885
1885
  "toolName": "update-sharepoint-list-item",
1886
1886
  "presets": ["work"],
1887
- "workScopes": ["Sites.ReadWrite.All"],
1887
+ "workScopes": [["Sites.ReadWrite.All"], ["Sites.Selected"]],
1888
1888
  "llmTip": "Updates fields on an existing list item. Body: { fields: { ColumnName: 'new value' } }. Send only the fields you want to change. Use $expand=fields on get-sharepoint-site-list-item to see current values first."
1889
1889
  },
1890
1890
  {
@@ -1892,7 +1892,7 @@
1892
1892
  "method": "delete",
1893
1893
  "toolName": "delete-sharepoint-list-item",
1894
1894
  "presets": ["work"],
1895
- "workScopes": ["Sites.ReadWrite.All"],
1895
+ "workScopes": [["Sites.ReadWrite.All"], ["Sites.Selected"]],
1896
1896
  "llmTip": "Deletes a list item permanently. This cannot be undone — the item is moved to the site recycle bin."
1897
1897
  },
1898
1898
  {
@@ -1900,7 +1900,7 @@
1900
1900
  "method": "post",
1901
1901
  "toolName": "create-sharepoint-list",
1902
1902
  "presets": ["work"],
1903
- "workScopes": ["Sites.Manage.All"],
1903
+ "workScopes": [["Sites.Manage.All"], ["Sites.Selected"]],
1904
1904
  "llmTip": "Creates a new SharePoint list in a site. Body: { displayName: 'My List', description: 'Optional', list: { template: 'genericList' }, columns: [ { name: 'Status', text: {} }, { name: 'Due', dateTime: {} } ] }. Templates include genericList, documentLibrary, tasks, calendar, contacts, links, announcements, survey. Columns can be defined inline at creation; otherwise add them later via create-sharepoint-list-column. Use search-sharepoint-sites or get-sharepoint-site-by-path to find the site ID first."
1905
1905
  },
1906
1906
  {
@@ -1908,7 +1908,7 @@
1908
1908
  "method": "get",
1909
1909
  "toolName": "list-sharepoint-list-columns",
1910
1910
  "presets": ["work"],
1911
- "workScopes": ["Sites.Read.All"],
1911
+ "workScopes": [["Sites.Read.All"], ["Sites.Selected"]],
1912
1912
  "llmTip": "Lists column definitions for a SharePoint list. Returns each column's id, name, displayName, description, type indicator (text, number, choice, dateTime, person, lookup, boolean, calculated, hyperlinkOrPicture, etc.), required, indexed, hidden, readOnly. Use this to discover the schema before creating or updating list items."
1913
1913
  },
1914
1914
  {
@@ -1916,7 +1916,7 @@
1916
1916
  "method": "post",
1917
1917
  "toolName": "create-sharepoint-list-column",
1918
1918
  "presets": ["work"],
1919
- "workScopes": ["Sites.Manage.All"],
1919
+ "workScopes": [["Sites.Manage.All"], ["Sites.Selected"]],
1920
1920
  "llmTip": "Creates a new column on a SharePoint list. Body must include name and exactly one column type property: { name: 'Priority', text: {} } or { name: 'DueDate', dateTime: { format: 'dateOnly' } } or { name: 'Status', choice: { choices: ['Open','In Progress','Done'] } }. Other types: number, boolean, currency, hyperlinkOrPicture, personOrGroup, lookup, calculated. Optional: displayName, description, required, indexed, enforceUniqueValues."
1921
1921
  },
1922
1922
  {
@@ -1924,7 +1924,7 @@
1924
1924
  "method": "get",
1925
1925
  "toolName": "get-sharepoint-list-column",
1926
1926
  "presets": ["work"],
1927
- "workScopes": ["Sites.Read.All"],
1927
+ "workScopes": [["Sites.Read.All"], ["Sites.Selected"]],
1928
1928
  "llmTip": "Gets a specific column definition by ID, including its full type configuration (choices for choice columns, format for dateTime, etc.). Use list-sharepoint-list-columns first to find the column ID."
1929
1929
  },
1930
1930
  {
@@ -1932,7 +1932,7 @@
1932
1932
  "method": "patch",
1933
1933
  "toolName": "update-sharepoint-list-column",
1934
1934
  "presets": ["work"],
1935
- "workScopes": ["Sites.Manage.All"],
1935
+ "workScopes": [["Sites.Manage.All"], ["Sites.Selected"]],
1936
1936
  "llmTip": "Updates a column definition. Body: { displayName: 'New name', description: 'New description', required: true, ... }. The column type itself (text, choice, etc.) cannot be changed — only its metadata and per-type options (e.g. choices array for a choice column). Send only the fields you want to change."
1937
1937
  },
1938
1938
  {
@@ -1940,7 +1940,7 @@
1940
1940
  "method": "delete",
1941
1941
  "toolName": "delete-sharepoint-list-column",
1942
1942
  "presets": ["work"],
1943
- "workScopes": ["Sites.Manage.All"],
1943
+ "workScopes": [["Sites.Manage.All"], ["Sites.Selected"]],
1944
1944
  "llmTip": "Deletes a column from a SharePoint list. This is irreversible — all data stored in this column across every list item is lost. Confirm with the user before calling. Cannot delete built-in columns (Title, Created, Modified, etc.)."
1945
1945
  },
1946
1946
  {
@@ -1948,7 +1948,7 @@
1948
1948
  "method": "get",
1949
1949
  "toolName": "get-sharepoint-site-by-path",
1950
1950
  "presets": ["work"],
1951
- "workScopes": ["Sites.Read.All"],
1951
+ "workScopes": [["Sites.Read.All"], ["Sites.Selected"]],
1952
1952
  "skipEncoding": ["path"],
1953
1953
  "llmTip": "Resolve a SharePoint site from its server-relative URL. site-id is the hostname (contoso.sharepoint.com) and path is the site path without a leading slash (sites/marketing, teams/hr). Returns the site object whose id feeds list-sharepoint-site-drives."
1954
1954
  },