@softeria/ms-365-mcp-server 0.153.0 → 0.154.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
@@ -154,7 +154,7 @@ SharePoint supports two enterprise permission models:
154
154
  - Broad tenant scopes such as `Sites.Read.All`, `Sites.ReadWrite.All`, and `Sites.Manage.All`.
155
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
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.
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, and the `/drives/{drive-id}/...` item tools (list, get, upload, folder, move/rename, copy, versions) for drives of a granted site, can run with `Sites.Selected`; tenant-wide SharePoint discovery and search tools still require broad SharePoint scopes.
158
158
 
159
159
  ```bash
160
160
  npx @softeria/ms-365-mcp-server \
@@ -1074,14 +1074,18 @@ describe("graph-tools", () => {
1074
1074
  if (prevAllowPagination === void 0) delete process.env.MS365_MCP_ALLOW_PAGINATION;
1075
1075
  else process.env.MS365_MCP_ALLOW_PAGINATION = prevAllowPagination;
1076
1076
  });
1077
- const callWith = async (args) => {
1077
+ const callForResult = async (args) => {
1078
1078
  mockEndpoints.push(makeEndpoint());
1079
1079
  mockEndpointsJson = [makeConfig()];
1080
1080
  const graphClient = createMockGraphClient();
1081
1081
  const server = createMockServer();
1082
1082
  const { registerGraphTools } = await loadModule();
1083
1083
  registerGraphTools(server, graphClient);
1084
- await server.tools.get("test-tool").handler(args);
1084
+ const result = await server.tools.get("test-tool").handler(args);
1085
+ return { result, graphClient };
1086
+ };
1087
+ const callWith = async (args) => {
1088
+ const { graphClient } = await callForResult(args);
1085
1089
  return graphClient.graphRequest.mock.calls[0][0];
1086
1090
  };
1087
1091
  const singleObjectEndpoint = () => makeEndpoint({
@@ -1166,10 +1170,78 @@ describe("graph-tools", () => {
1166
1170
  const path = await callWith({ $skiptoken: "abc123" });
1167
1171
  expect(path).toContain("$skiptoken=abc123");
1168
1172
  });
1169
- it("omits $skiptoken entirely when blank", async () => {
1170
- const path = await callWith({ skiptoken: " " });
1173
+ it.each(["", " "])("omits $skiptoken entirely when blank (%j)", async (blank) => {
1174
+ const path = await callWith({ skiptoken: blank });
1175
+ expect(path).not.toContain("skiptoken");
1176
+ });
1177
+ it("sends $skip when the nextLink pages with $skip", async () => {
1178
+ const path = await callWith({
1179
+ top: 10,
1180
+ skiptoken: "https://graph.microsoft.com/v1.0/me/messages?$top=10&$skip=10"
1181
+ });
1182
+ expect(path).toContain("$skip=10");
1171
1183
  expect(path).not.toContain("skiptoken");
1172
1184
  });
1185
+ it("ignores a cursor-looking value inside another query param", async () => {
1186
+ const path = await callWith({
1187
+ skiptoken: "https://graph.microsoft.com/v1.0/me/messages?$filter=subject eq '%24skip=100'&$skip=10"
1188
+ });
1189
+ expect(path).toContain("$skip=10");
1190
+ expect(path).not.toContain("100");
1191
+ });
1192
+ it("refuses a link with no cursor to page with", async () => {
1193
+ const { result, graphClient } = await callForResult({
1194
+ skiptoken: "https://graph.microsoft.com/v1.0/me/mailFolders/inbox/messages/delta()?$deltatoken=abc"
1195
+ });
1196
+ expect(result.isError).toBe(true);
1197
+ expect(JSON.parse(result.content[0].text).error).toBe("invalid_skiptoken");
1198
+ expect(graphClient.graphRequest).not.toHaveBeenCalled();
1199
+ });
1200
+ it("refuses a delta token= link without calling it a deltaLink", async () => {
1201
+ const { result, graphClient } = await callForResult({
1202
+ skiptoken: "https://graph.microsoft.com/v1.0/me/drive/delta(token=1230919asd190410jlka)"
1203
+ });
1204
+ const payload = JSON.parse(result.content[0].text);
1205
+ expect(payload.error).toBe("invalid_skiptoken");
1206
+ expect(payload.message).not.toContain("deltaLink");
1207
+ expect(payload.message).toContain("fetchAllPages");
1208
+ expect(graphClient.graphRequest).not.toHaveBeenCalled();
1209
+ });
1210
+ it("refuses a link whose $skiptoken carries no value", async () => {
1211
+ const { result } = await callForResult({
1212
+ skiptoken: "https://graph.microsoft.com/v1.0/me/chats?$skiptoken=&$top=5"
1213
+ });
1214
+ expect(result.isError).toBe(true);
1215
+ expect(JSON.parse(result.content[0].text).error).toBe("invalid_skiptoken");
1216
+ });
1217
+ it("accepts an encoded %24skiptoken marker", async () => {
1218
+ const path = await callWith({
1219
+ skiptoken: "https://graph.microsoft.com/v1.0/me/chats?%24top=5&%24skiptoken=tok123"
1220
+ });
1221
+ expect(path).toContain("$skiptoken=tok123");
1222
+ });
1223
+ it.each([
1224
+ ["https://graph.microsoft.com/v1.0/me/messages?$top=10&$skip=0", "$skip=0"],
1225
+ ["https://graph.microsoft.com/v1.0/me/messages?%24top=10&%24skip=10", "$skip=10"]
1226
+ ])("reads the $skip cursor out of %s", async (link, expected) => {
1227
+ const path = await callWith({ skiptoken: link });
1228
+ expect(path).toContain(expected);
1229
+ });
1230
+ it("refuses a $skip cursor that is not a plain number", async () => {
1231
+ const { result } = await callForResult({
1232
+ skiptoken: "https://graph.microsoft.com/v1.0/me/messages?$skip=10junk"
1233
+ });
1234
+ expect(JSON.parse(result.content[0].text).error).toBe("invalid_skiptoken");
1235
+ });
1236
+ it("offers no fetchAllPages in the refusal when pagination is disabled", async () => {
1237
+ process.env.MS365_MCP_ALLOW_PAGINATION = "0";
1238
+ const { result } = await callForResult({
1239
+ skiptoken: "https://graph.microsoft.com/v1.0/me/drive/delta(token=1230919asd190410jlka)"
1240
+ });
1241
+ const payload = JSON.parse(result.content[0].text);
1242
+ expect(payload.error).toBe("invalid_skiptoken");
1243
+ expect(payload.message).not.toContain("fetchAllPages");
1244
+ });
1173
1245
  it("sends the cursor alongside the original query options", async () => {
1174
1246
  const path = await callWith({ filter: "chatType eq 'oneOnOne'", top: 5, skiptoken: "tok" });
1175
1247
  expect(path).toContain("$filter=");
@@ -621,14 +621,14 @@
621
621
  "method": "get",
622
622
  "toolName": "get-drive-root-item",
623
623
  "presets": ["files", "onedrive", "personal"],
624
- "scopes": ["Files.Read"]
624
+ "scopes": [["Files.Read"], ["Sites.Selected"]]
625
625
  },
626
626
  {
627
627
  "pathPattern": "/drives/{drive-id}/items/{driveItem-id}/children",
628
628
  "method": "get",
629
629
  "toolName": "list-folder-files",
630
630
  "presets": ["files", "onedrive", "personal"],
631
- "scopes": ["Files.Read"]
631
+ "scopes": [["Files.Read"], ["Sites.Selected"]]
632
632
  },
633
633
  {
634
634
  "pathPattern": "/drives/{drive-id}/items/{driveItem-id}",
@@ -642,7 +642,7 @@
642
642
  "method": "put",
643
643
  "toolName": "upload-file-content",
644
644
  "presets": ["files", "onedrive", "personal"],
645
- "scopes": ["Files.ReadWrite"],
645
+ "scopes": [["Files.ReadWrite"], ["Sites.Selected"]],
646
646
  "llmTip": "Body is a base64-encoded string of the file bytes; the server decodes it before PUT. Graph accepts up to 250MB here, but the whole string travels as a tool argument and a truncated one decodes to a truncated file with no error, so use create-upload-session rather than emitting a large base64 string. For new files use path format: /items/root:/path/to/file.txt:/content. Overwrites existing files without warning."
647
647
  },
648
648
  {
@@ -650,7 +650,7 @@
650
650
  "method": "post",
651
651
  "toolName": "create-upload-session",
652
652
  "presets": ["files", "onedrive", "personal"],
653
- "scopes": ["Files.ReadWrite"],
653
+ "scopes": [["Files.ReadWrite"], ["Sites.Selected"]],
654
654
  "llmTip": "For large file uploads (no size limit, and no minimum unlike the Outlook attachment session). Returns a pre-authenticated uploadUrl; the caller PUTs the bytes there itself. This server does not perform the PUT. For new files use path: /items/{parentId}:/{fileName}:/createUploadSession. Body (optional): { item: { '@microsoft.graph.conflictBehavior': 'rename' } }."
655
655
  },
656
656
  {
@@ -658,7 +658,7 @@
658
658
  "method": "get",
659
659
  "toolName": "get-drive-item",
660
660
  "presets": ["files", "onedrive", "personal"],
661
- "scopes": ["Files.Read"],
661
+ "scopes": [["Files.Read"], ["Sites.Selected"]],
662
662
  "llmTip": "Gets metadata for a file or folder: name, size, lastModifiedDateTime, createdBy, webUrl, file (mimeType, hashes), folder (childCount), parentReference, and @microsoft.graph.downloadUrl. For large drive/SharePoint files, call get-download-url with target=/drives/{drive-id}/items/{driveItem-id}/content to fetch out-of-band with no Authorization header. For small files where base64 in the tool response is acceptable, call download-bytes with the same /content target."
663
663
  },
664
664
  {
@@ -666,7 +666,7 @@
666
666
  "method": "patch",
667
667
  "toolName": "move-rename-onedrive-item",
668
668
  "presets": ["files", "onedrive", "personal"],
669
- "scopes": ["Files.ReadWrite"],
669
+ "scopes": [["Files.ReadWrite"], ["Sites.Selected"]],
670
670
  "llmTip": "Move and/or rename a file or folder. To move, provide parentReference with the target folder's id. To rename, provide a new name. Both can be done in a single request."
671
671
  },
672
672
  {
@@ -674,7 +674,7 @@
674
674
  "method": "post",
675
675
  "toolName": "create-onedrive-folder",
676
676
  "presets": ["files", "onedrive", "personal"],
677
- "scopes": ["Files.ReadWrite"],
677
+ "scopes": [["Files.ReadWrite"], ["Sites.Selected"]],
678
678
  "llmTip": "Creates a new folder inside the specified drive item. Body must include name (string) and folder ({}) fields. Use @microsoft.graph.conflictBehavior to control behavior on name conflict: 'rename' (default), 'replace', or 'fail'."
679
679
  },
680
680
  {
@@ -698,7 +698,7 @@
698
698
  "method": "post",
699
699
  "toolName": "copy-drive-item",
700
700
  "presets": ["files", "onedrive", "personal"],
701
- "scopes": ["Files.ReadWrite"],
701
+ "scopes": [["Files.ReadWrite"], ["Sites.Selected"]],
702
702
  "llmTip": "Asynchronously copy a file or folder to a new location and/or name. Body: { parentReference: { driveId: '...', id: '...' }, name?: 'New Name.xlsx' }. Returns 202 Accepted with a Location header pointing at a monitor URL for the async job. Ideal for duplicating templates (e.g. clone an Armhr Census Template per prospect), bulk file provisioning, or preserving an immutable snapshot of a working file."
703
703
  },
704
704
  {
@@ -738,7 +738,7 @@
738
738
  "method": "get",
739
739
  "toolName": "list-drive-item-versions",
740
740
  "presets": ["files", "onedrive", "personal"],
741
- "scopes": ["Files.Read"],
741
+ "scopes": [["Files.Read"], ["Sites.Selected"]],
742
742
  "llmTip": "Lists version history of a file. Each version has id, lastModifiedDateTime, lastModifiedBy, and size. Use the version id with /versions/{id}/content to download a specific version."
743
743
  },
744
744
  {
@@ -58,8 +58,7 @@ import {
58
58
  getAccountParamDescription,
59
59
  getFetchAllPagesParamDescription,
60
60
  SKIPTOKEN_PARAM_DESCRIPTION,
61
- isSkiptokenApplicable,
62
- normalizeSkiptoken
61
+ isSkiptokenApplicable
63
62
  } from "./lib/param-descriptions.js";
64
63
  const __filename = fileURLToPath(import.meta.url);
65
64
  const __dirname = path.dirname(__filename);
@@ -225,6 +224,50 @@ function normalizeSearchQueryParam(queryParams, path2, toolAlias) {
225
224
  queryParams["$search"] = normalized;
226
225
  }
227
226
  }
227
+ function normalizeSkiptokenQueryParam(queryParams, toolAlias) {
228
+ const raw = queryParams["$skiptoken"];
229
+ if (raw === void 0) return;
230
+ delete queryParams["$skiptoken"];
231
+ let token = raw.trim();
232
+ if (token === "") return;
233
+ const cursorlessLink = () => {
234
+ logger.warn(`Refusing ${toolAlias}: 'skiptoken' has no $skiptoken or $skip to page with`);
235
+ return {
236
+ content: [
237
+ {
238
+ type: "text",
239
+ text: JSON.stringify({
240
+ error: "invalid_skiptoken",
241
+ tool: toolAlias,
242
+ message: "The value passed as skiptoken has no $skiptoken or $skip to page with. The drive and sites delta tools page with a token= link, which skiptoken cannot resend." + (paginationAllowed() ? " Remove skiptoken and retry with fetchAllPages set to true to follow the link, or remove it for the first page." : " Remove skiptoken and retry for the first page.")
243
+ })
244
+ }
245
+ ],
246
+ isError: true
247
+ };
248
+ };
249
+ const marker = token.match(/(?:^|[?&])(?:\$|%24)skiptoken=/i);
250
+ if (marker?.index !== void 0) {
251
+ token = token.slice(marker.index + marker[0].length).split("&")[0];
252
+ if (token === "") return cursorlessLink();
253
+ } else if (/:\/\/|\?|^(?:\$|%24)\w+=/.test(token)) {
254
+ const skip = token.match(/(?:^|[?&])(?:\$|%24)skip=(\d+)(?=&|$)/i)?.[1];
255
+ if (skip === void 0) return cursorlessLink();
256
+ logger.info(
257
+ `Auto-corrected parameter 'skiptoken': link pages with $skip, sending $skip=${skip}`
258
+ );
259
+ queryParams["$skip"] = skip;
260
+ return;
261
+ }
262
+ if (token.includes("%")) {
263
+ try {
264
+ token = decodeURIComponent(token);
265
+ } catch {
266
+ logger.warn("skiptoken looks percent-encoded but could not be decoded; sending as-is");
267
+ }
268
+ }
269
+ queryParams["$skiptoken"] = token;
270
+ }
228
271
  const DEFAULT_MAX_ITEMS = 1e4;
229
272
  function isConfirmGateEnabled() {
230
273
  return process.env.MS365_MCP_REQUIRE_CONFIRM === "true";
@@ -1265,11 +1308,8 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
1265
1308
  if (TOP_UNSUPPORTED_DELTA_TOOLS.has(tool.alias)) {
1266
1309
  delete queryParams["$top"];
1267
1310
  }
1268
- if (queryParams["$skiptoken"]) {
1269
- const cursor = normalizeSkiptoken(queryParams["$skiptoken"]);
1270
- if (cursor) queryParams["$skiptoken"] = cursor;
1271
- else delete queryParams["$skiptoken"];
1272
- }
1311
+ const skiptokenError = normalizeSkiptokenQueryParam(queryParams, tool.alias);
1312
+ if (skiptokenError) return skiptokenError;
1273
1313
  clampTopQueryParam(queryParams);
1274
1314
  const searchError = normalizeSearchQueryParam(queryParams, tool.path, tool.alias);
1275
1315
  if (searchError) return searchError;
@@ -35,7 +35,7 @@ const EXPAND_PARAM_DESCRIPTION = 'Navigation properties to inline, e.g. attachme
35
35
  const ORDERBY_PARAM_DESCRIPTION = "Sort expression, e.g. receivedDateTime desc";
36
36
  const TOP_PARAM_DESCRIPTION = "Page size (Graph $top). Start small (e.g. 5\u201315) so responses fit the model context; raise only if needed. Use $select to return fewer fields per item. For more rows, use @odata.nextLink from the response instead of a very large $top.";
37
37
  const SKIP_PARAM_DESCRIPTION = "Items to skip for pagination. Not supported with $search.";
38
- const SKIPTOKEN_PARAM_DESCRIPTION = "Opaque cursor for the next page, taken from the previous response. Copy the $skiptoken value out of @odata.nextLink (or paste the whole nextLink URL \u2014 the token is extracted from it) and resend it with the SAME $filter/$select/$expand/$top as the first call. Use this to walk pages one at a time instead of fetchAllPages. Omit it for the first page.";
38
+ const SKIPTOKEN_PARAM_DESCRIPTION = "Next page: the @odata.nextLink from the previous response. Keep the other arguments the same.";
39
39
  const COLLECTION_QUERY_PARAMS = /* @__PURE__ */ new Set(["top", "filter", "orderby", "count"]);
40
40
  function isSkiptokenApplicable(tool, paramNames) {
41
41
  if (tool.method.toUpperCase() !== "GET") return false;
@@ -44,22 +44,6 @@ function isSkiptokenApplicable(tool, paramNames) {
44
44
  }
45
45
  return false;
46
46
  }
47
- function normalizeSkiptoken(raw) {
48
- let token = raw.trim();
49
- const marker = token.match(/(?:\$|%24)skiptoken=/i);
50
- if (marker?.index !== void 0) {
51
- token = token.slice(marker.index + marker[0].length);
52
- token = token.split("&")[0];
53
- }
54
- if (token.includes("%")) {
55
- try {
56
- token = decodeURIComponent(token);
57
- } catch {
58
- logger.warn("skiptoken looks percent-encoded but could not be decoded; sending as-is");
59
- }
60
- }
61
- return token;
62
- }
63
47
  const COUNT_PARAM_DESCRIPTION = "Set true to enable advanced query mode (ConsistencyLevel: eventual). Required for complex $filter on flag/flagStatus or contains().";
64
48
  const CONFIRM_PARAM_DESCRIPTION = 'For destructive operations when the confirm gate is enabled (MS365_MCP_REQUIRE_CONFIRM=true; off by default). Set to true only after the user has explicitly approved this action. When the gate is on, calls without confirm: true return { error: "confirmation_required" } without touching user data.';
65
49
  const TIMEZONE_PARAM_DESCRIPTION = 'IANA timezone name (e.g., "America/New_York", "Europe/London", "Asia/Tokyo") for calendar event times. If not specified, times are returned in UTC.';
@@ -118,7 +102,6 @@ export {
118
102
  getODataParamDescription,
119
103
  isFetchAllPagesApplicable,
120
104
  isSkiptokenApplicable,
121
- normalizeSkiptoken,
122
105
  paginationAllowed,
123
106
  positiveIntFromEnv,
124
107
  shouldOmitTopParam
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.153.0",
4
+ "version": "0.154.0",
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",
@@ -621,14 +621,14 @@
621
621
  "method": "get",
622
622
  "toolName": "get-drive-root-item",
623
623
  "presets": ["files", "onedrive", "personal"],
624
- "scopes": ["Files.Read"]
624
+ "scopes": [["Files.Read"], ["Sites.Selected"]]
625
625
  },
626
626
  {
627
627
  "pathPattern": "/drives/{drive-id}/items/{driveItem-id}/children",
628
628
  "method": "get",
629
629
  "toolName": "list-folder-files",
630
630
  "presets": ["files", "onedrive", "personal"],
631
- "scopes": ["Files.Read"]
631
+ "scopes": [["Files.Read"], ["Sites.Selected"]]
632
632
  },
633
633
  {
634
634
  "pathPattern": "/drives/{drive-id}/items/{driveItem-id}",
@@ -642,7 +642,7 @@
642
642
  "method": "put",
643
643
  "toolName": "upload-file-content",
644
644
  "presets": ["files", "onedrive", "personal"],
645
- "scopes": ["Files.ReadWrite"],
645
+ "scopes": [["Files.ReadWrite"], ["Sites.Selected"]],
646
646
  "llmTip": "Body is a base64-encoded string of the file bytes; the server decodes it before PUT. Graph accepts up to 250MB here, but the whole string travels as a tool argument and a truncated one decodes to a truncated file with no error, so use create-upload-session rather than emitting a large base64 string. For new files use path format: /items/root:/path/to/file.txt:/content. Overwrites existing files without warning."
647
647
  },
648
648
  {
@@ -650,7 +650,7 @@
650
650
  "method": "post",
651
651
  "toolName": "create-upload-session",
652
652
  "presets": ["files", "onedrive", "personal"],
653
- "scopes": ["Files.ReadWrite"],
653
+ "scopes": [["Files.ReadWrite"], ["Sites.Selected"]],
654
654
  "llmTip": "For large file uploads (no size limit, and no minimum unlike the Outlook attachment session). Returns a pre-authenticated uploadUrl; the caller PUTs the bytes there itself. This server does not perform the PUT. For new files use path: /items/{parentId}:/{fileName}:/createUploadSession. Body (optional): { item: { '@microsoft.graph.conflictBehavior': 'rename' } }."
655
655
  },
656
656
  {
@@ -658,7 +658,7 @@
658
658
  "method": "get",
659
659
  "toolName": "get-drive-item",
660
660
  "presets": ["files", "onedrive", "personal"],
661
- "scopes": ["Files.Read"],
661
+ "scopes": [["Files.Read"], ["Sites.Selected"]],
662
662
  "llmTip": "Gets metadata for a file or folder: name, size, lastModifiedDateTime, createdBy, webUrl, file (mimeType, hashes), folder (childCount), parentReference, and @microsoft.graph.downloadUrl. For large drive/SharePoint files, call get-download-url with target=/drives/{drive-id}/items/{driveItem-id}/content to fetch out-of-band with no Authorization header. For small files where base64 in the tool response is acceptable, call download-bytes with the same /content target."
663
663
  },
664
664
  {
@@ -666,7 +666,7 @@
666
666
  "method": "patch",
667
667
  "toolName": "move-rename-onedrive-item",
668
668
  "presets": ["files", "onedrive", "personal"],
669
- "scopes": ["Files.ReadWrite"],
669
+ "scopes": [["Files.ReadWrite"], ["Sites.Selected"]],
670
670
  "llmTip": "Move and/or rename a file or folder. To move, provide parentReference with the target folder's id. To rename, provide a new name. Both can be done in a single request."
671
671
  },
672
672
  {
@@ -674,7 +674,7 @@
674
674
  "method": "post",
675
675
  "toolName": "create-onedrive-folder",
676
676
  "presets": ["files", "onedrive", "personal"],
677
- "scopes": ["Files.ReadWrite"],
677
+ "scopes": [["Files.ReadWrite"], ["Sites.Selected"]],
678
678
  "llmTip": "Creates a new folder inside the specified drive item. Body must include name (string) and folder ({}) fields. Use @microsoft.graph.conflictBehavior to control behavior on name conflict: 'rename' (default), 'replace', or 'fail'."
679
679
  },
680
680
  {
@@ -698,7 +698,7 @@
698
698
  "method": "post",
699
699
  "toolName": "copy-drive-item",
700
700
  "presets": ["files", "onedrive", "personal"],
701
- "scopes": ["Files.ReadWrite"],
701
+ "scopes": [["Files.ReadWrite"], ["Sites.Selected"]],
702
702
  "llmTip": "Asynchronously copy a file or folder to a new location and/or name. Body: { parentReference: { driveId: '...', id: '...' }, name?: 'New Name.xlsx' }. Returns 202 Accepted with a Location header pointing at a monitor URL for the async job. Ideal for duplicating templates (e.g. clone an Armhr Census Template per prospect), bulk file provisioning, or preserving an immutable snapshot of a working file."
703
703
  },
704
704
  {
@@ -738,7 +738,7 @@
738
738
  "method": "get",
739
739
  "toolName": "list-drive-item-versions",
740
740
  "presets": ["files", "onedrive", "personal"],
741
- "scopes": ["Files.Read"],
741
+ "scopes": [["Files.Read"], ["Sites.Selected"]],
742
742
  "llmTip": "Lists version history of a file. Each version has id, lastModifiedDateTime, lastModifiedBy, and size. Use the version id with /versions/{id}/content to download a specific version."
743
743
  },
744
744
  {