@softeria/ms-365-mcp-server 0.153.1 → 0.154.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/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 \
@@ -1068,6 +1068,95 @@ describe("graph-tools", () => {
1068
1068
  expect(payload).not.toHaveProperty("target_resource");
1069
1069
  });
1070
1070
  });
1071
+ describe("shared query contract at execution", () => {
1072
+ it.each([{ skiptoken: 123 }, { $SKIPTOKEN: 123 }, { COUNT: "true" }, { $Count: "true" }])(
1073
+ "rejects invalid cursor and mixed-case query values: %j",
1074
+ async (params) => {
1075
+ mockEndpoints.push(makeEndpoint());
1076
+ mockEndpointsJson = [makeConfig()];
1077
+ const graphClient = createMockGraphClient();
1078
+ const server = createMockServer();
1079
+ const { registerGraphTools } = await loadModule();
1080
+ registerGraphTools(server, graphClient);
1081
+ const result = await server.tools.get("test-tool").handler(params);
1082
+ expect(result.isError).toBe(true);
1083
+ expect(graphClient.graphRequest).not.toHaveBeenCalled();
1084
+ }
1085
+ );
1086
+ it("accepts valid mixed-case query values and string cursors", async () => {
1087
+ mockEndpoints.push(makeEndpoint());
1088
+ mockEndpointsJson = [makeConfig()];
1089
+ const graphClient = createMockGraphClient();
1090
+ const server = createMockServer();
1091
+ const { registerGraphTools } = await loadModule();
1092
+ registerGraphTools(server, graphClient);
1093
+ await server.tools.get("test-tool").handler({ COUNT: true, $SKIPTOKEN: "next-token" });
1094
+ expect(graphClient.graphRequest).toHaveBeenCalledTimes(1);
1095
+ const request = JSON.stringify(graphClient.graphRequest.mock.calls[0]);
1096
+ expect(request).toContain("$count=true");
1097
+ expect(request).toContain("$skiptoken=next-token");
1098
+ });
1099
+ it.each(["list-calendar-events-delta", "list-calendar-view-delta"])(
1100
+ "ignores stale mixed-case top values for %s",
1101
+ async (alias) => {
1102
+ mockEndpoints.push(makeEndpoint({ alias }));
1103
+ mockEndpointsJson = [makeConfig({ toolName: alias })];
1104
+ const graphClient = createMockGraphClient();
1105
+ const server = createMockServer();
1106
+ const { registerGraphTools } = await loadModule();
1107
+ registerGraphTools(server, graphClient);
1108
+ for (const key of ["top", "$top", "TOP", "$TOP", "$Top"]) {
1109
+ await server.tools.get(alias).handler({ [key]: 10 });
1110
+ }
1111
+ expect(graphClient.graphRequest).toHaveBeenCalledTimes(5);
1112
+ for (const call of graphClient.graphRequest.mock.calls) {
1113
+ expect(JSON.stringify(call)).not.toContain("$top");
1114
+ }
1115
+ }
1116
+ );
1117
+ it.each(["list-joined-teams", "list-my-associated-teams"])(
1118
+ "rejects unsupported query options before Graph dispatch for %s",
1119
+ async (alias) => {
1120
+ mockEndpoints.push(makeEndpoint({ alias }));
1121
+ mockEndpointsJson = [makeConfig({ toolName: alias })];
1122
+ const graphClient = createMockGraphClient();
1123
+ const server = createMockServer();
1124
+ const { registerGraphTools } = await loadModule();
1125
+ registerGraphTools(server, graphClient);
1126
+ for (const key of ["top", "$top", "skiptoken", "$skiptoken", "select", "$select"]) {
1127
+ const value = key.replace("$", "") === "top" ? 100 : "next-token";
1128
+ const result = await server.tools.get(alias).handler({ [key]: value });
1129
+ expect(result.isError).toBe(true);
1130
+ }
1131
+ expect(graphClient.graphRequest).not.toHaveBeenCalled();
1132
+ }
1133
+ );
1134
+ it("rejects oversized chat pages before Graph dispatch, including dollar-prefixed input", async () => {
1135
+ mockEndpoints.push(makeEndpoint({ alias: "list-chats" }));
1136
+ mockEndpointsJson = [makeConfig({ toolName: "list-chats" })];
1137
+ const graphClient = createMockGraphClient();
1138
+ const server = createMockServer();
1139
+ const { registerGraphTools } = await loadModule();
1140
+ registerGraphTools(server, graphClient);
1141
+ for (const key of ["top", "$top"]) {
1142
+ const result = await server.tools.get("list-chats").handler({ [key]: 100 });
1143
+ expect(result.isError).toBe(true);
1144
+ }
1145
+ expect(graphClient.graphRequest).not.toHaveBeenCalled();
1146
+ });
1147
+ it("sends a calendar field array as comma-separated text in one Graph request", async () => {
1148
+ const alias = "list-calendar-events-delta";
1149
+ mockEndpoints.push(makeEndpoint({ alias }));
1150
+ mockEndpointsJson = [makeConfig({ toolName: alias })];
1151
+ const graphClient = createMockGraphClient();
1152
+ const server = createMockServer();
1153
+ const { registerGraphTools } = await loadModule();
1154
+ registerGraphTools(server, graphClient);
1155
+ await server.tools.get(alias).handler({ select: ["id", "subject"] });
1156
+ expect(graphClient.graphRequest).toHaveBeenCalledTimes(1);
1157
+ expect(JSON.stringify(graphClient.graphRequest.mock.calls[0])).toContain("id,subject");
1158
+ });
1159
+ });
1071
1160
  describe("skiptoken cursor", () => {
1072
1161
  const prevAllowPagination = process.env.MS365_MCP_ALLOW_PAGINATION;
1073
1162
  afterEach(() => {
@@ -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
  {
@@ -846,8 +846,8 @@ const microsoft_graph_driveItem = z.object({
846
846
  webUrl: z.string().describe(
847
847
  "URL that either displays the resource in the browser (for Office file formats), or is a direct link to the file (for other formats). Read-only."
848
848
  ).nullish(),
849
- createdByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
850
- lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
849
+ createdByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
850
+ lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
851
851
  audio: microsoft_graph_audio.optional(),
852
852
  bundle: microsoft_graph_bundle.optional(),
853
853
  cTag: z.string().describe(
@@ -2586,8 +2586,8 @@ const microsoft_graph_listItem = z.object({
2586
2586
  webUrl: z.string().describe(
2587
2587
  "URL that either displays the resource in the browser (for Office file formats), or is a direct link to the file (for other formats). Read-only."
2588
2588
  ).nullish(),
2589
- createdByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
2590
- lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
2589
+ createdByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
2590
+ lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
2591
2591
  contentType: microsoft_graph_contentTypeInfo.optional(),
2592
2592
  deleted: microsoft_graph_deleted.optional(),
2593
2593
  sharepointIds: microsoft_graph_sharepointIds.optional(),
@@ -2708,8 +2708,8 @@ const microsoft_graph_list = z.lazy(
2708
2708
  webUrl: z.string().describe(
2709
2709
  "URL that either displays the resource in the browser (for Office file formats), or is a direct link to the file (for other formats). Read-only."
2710
2710
  ).nullish(),
2711
- createdByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
2712
- lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
2711
+ createdByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
2712
+ lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
2713
2713
  displayName: z.string().describe("The displayable title of the list.").nullish(),
2714
2714
  list: microsoft_graph_listInfo.optional(),
2715
2715
  sharepointIds: microsoft_graph_sharepointIds.optional(),
@@ -2741,8 +2741,8 @@ const microsoft_graph_drive = z.lazy(
2741
2741
  webUrl: z.string().describe(
2742
2742
  "URL that either displays the resource in the browser (for Office file formats), or is a direct link to the file (for other formats). Read-only."
2743
2743
  ).nullish(),
2744
- createdByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
2745
- lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
2744
+ createdByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
2745
+ lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
2746
2746
  driveType: z.string().describe(
2747
2747
  "Describes the type of drive represented by this resource. OneDrive personal drives return personal. OneDrive for Business returns business. SharePoint document libraries return documentLibrary. Read-only."
2748
2748
  ).nullish(),
@@ -4558,8 +4558,8 @@ const microsoft_graph_baseItem = z.object({
4558
4558
  webUrl: z.string().describe(
4559
4559
  "URL that either displays the resource in the browser (for Office file formats), or is a direct link to the file (for other formats). Read-only."
4560
4560
  ).nullish(),
4561
- createdByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
4562
- lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional()
4561
+ createdByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
4562
+ lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional()
4563
4563
  }).passthrough();
4564
4564
  const microsoft_graph_site = z.object({
4565
4565
  id: z.string().describe("The unique identifier for an entity. Read-only.").optional(),
@@ -4580,8 +4580,8 @@ const microsoft_graph_site = z.object({
4580
4580
  webUrl: z.string().describe(
4581
4581
  "URL that either displays the resource in the browser (for Office file formats), or is a direct link to the file (for other formats). Read-only."
4582
4582
  ).nullish(),
4583
- createdByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
4584
- lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
4583
+ createdByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
4584
+ lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
4585
4585
  isPersonalSite: z.boolean().describe("Identifies whether the site is personal or not. Read-only.").nullish(),
4586
4586
  root: microsoft_graph_root.optional(),
4587
4587
  sharepointIds: microsoft_graph_sharepointIds.optional(),
@@ -5880,8 +5880,8 @@ const endpoints = makeApi([
5880
5880
  webUrl: z.string().describe(
5881
5881
  "URL that either displays the resource in the browser (for Office file formats), or is a direct link to the file (for other formats). Read-only."
5882
5882
  ).nullish(),
5883
- createdByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
5884
- lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
5883
+ createdByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
5884
+ lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
5885
5885
  audio: microsoft_graph_audio.optional(),
5886
5886
  bundle: microsoft_graph_bundle.optional(),
5887
5887
  cTag: z.string().describe(
@@ -5995,8 +5995,8 @@ const endpoints = makeApi([
5995
5995
  webUrl: z.string().describe(
5996
5996
  "URL that either displays the resource in the browser (for Office file formats), or is a direct link to the file (for other formats). Read-only."
5997
5997
  ).nullish(),
5998
- createdByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
5999
- lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 137 properties to 25 most common ones]").optional(),
5998
+ createdByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
5999
+ lastModifiedByUser: microsoft_graph_user.describe("[Note: Simplified from 138 properties to 25 most common ones]").optional(),
6000
6000
  audio: microsoft_graph_audio.optional(),
6001
6001
  bundle: microsoft_graph_bundle.optional(),
6002
6002
  cTag: z.string().describe(
@@ -35,6 +35,7 @@ import { parseTeamsUrl } from "./lib/teams-url-parser.js";
35
35
  import { buildBM25Index, scoreQuery, tokenize } from "./lib/bm25.js";
36
36
  import { deriveTargetResource } from "./audit-target-resource.js";
37
37
  import { describeToolSchema, describeUtilityToolSchema } from "./lib/tool-schema.js";
38
+ import { queryParameterSchema } from "./lib/query-parameter-schema.js";
38
39
  import {
39
40
  TOP_UNSUPPORTED_DELTA_TOOLS,
40
41
  shouldOmitTopParam,
@@ -43,14 +44,6 @@ import {
43
44
  DEFAULT_MAX_PAGES,
44
45
  getMaxPages,
45
46
  isFetchAllPagesApplicable,
46
- FILTER_PARAM_DESCRIPTION,
47
- SEARCH_PARAM_DESCRIPTION,
48
- SELECT_PARAM_DESCRIPTION,
49
- EXPAND_PARAM_DESCRIPTION,
50
- ORDERBY_PARAM_DESCRIPTION,
51
- TOP_PARAM_DESCRIPTION,
52
- SKIP_PARAM_DESCRIPTION,
53
- COUNT_PARAM_DESCRIPTION,
54
47
  CONFIRM_PARAM_DESCRIPTION,
55
48
  TIMEZONE_PARAM_DESCRIPTION,
56
49
  EXPAND_EXTENDED_PROPERTIES_PARAM_DESCRIPTION,
@@ -1195,13 +1188,38 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
1195
1188
  "search",
1196
1189
  "format"
1197
1190
  ];
1198
- const normalizedParamName = paramName.startsWith("$") ? paramName.slice(1) : paramName;
1199
- const isOdataParam = odataParams.includes(normalizedParamName.toLowerCase());
1200
- const fixedParamName = isOdataParam ? `$${normalizedParamName.toLowerCase()}` : paramName;
1191
+ const bareParamName = paramName.startsWith("$") ? paramName.slice(1) : paramName;
1192
+ const isOdataParam = odataParams.includes(bareParamName.toLowerCase());
1193
+ const normalizedParamName = isOdataParam ? bareParamName.toLowerCase() : bareParamName;
1194
+ const fixedParamName = isOdataParam ? `$${normalizedParamName}` : paramName;
1201
1195
  const camelCaseParamName = paramName.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
1202
1196
  const paramDef = parameterDefinitions.find(
1203
- (p) => p.name === paramName || p.name === camelCaseParamName || isOdataParam && p.name === normalizedParamName
1197
+ (p) => p.name === paramName || p.name === camelCaseParamName || isOdataParam && p.name.replace(/^\$/, "").toLowerCase() === normalizedParamName
1204
1198
  );
1199
+ const isQuery = paramDef?.type === "Query" || isOdataParam;
1200
+ const ignoredDeltaTop = shouldOmitTopParam(tool.alias) && normalizedParamName === "top";
1201
+ if (isQuery && !ignoredDeltaTop && paramValue != null && paramValue !== "") {
1202
+ const schema = queryParameterSchema(
1203
+ tool.alias,
1204
+ normalizedParamName,
1205
+ paramDef?.schema ?? z.any()
1206
+ );
1207
+ if (!schema || !schema.safeParse(paramValue).success) {
1208
+ return {
1209
+ content: [
1210
+ {
1211
+ type: "text",
1212
+ text: JSON.stringify({
1213
+ error: "invalid_query_parameter",
1214
+ parameter: fixedParamName,
1215
+ message: schema ? "Value does not match the query contract. Check get-tool-schema." : "This endpoint does not support this query parameter."
1216
+ })
1217
+ }
1218
+ ],
1219
+ isError: true
1220
+ };
1221
+ }
1222
+ }
1205
1223
  if (paramDef) {
1206
1224
  switch (paramDef.type) {
1207
1225
  case "Path": {
@@ -1632,6 +1650,11 @@ function registerGraphTools(server, graphClient, readOnly = false, enabledToolsP
1632
1650
  const paramSchema = {};
1633
1651
  if (tool.parameters && tool.parameters.length > 0) {
1634
1652
  for (const param of tool.parameters) {
1653
+ if (param.type === "Query") {
1654
+ const schema = queryParameterSchema(tool.alias, param.name, param.schema || z.any());
1655
+ if (schema) paramSchema[param.name] = schema;
1656
+ continue;
1657
+ }
1635
1658
  paramSchema[param.name] = param.type === "Body" && param.schema ? lenientBodySchema(param.schema) : param.schema || z.any();
1636
1659
  }
1637
1660
  }
@@ -1652,41 +1675,6 @@ function registerGraphTools(server, graphClient, readOnly = false, enabledToolsP
1652
1675
  if (isSkiptokenApplicable(tool, Object.keys(paramSchema))) {
1653
1676
  paramSchema["skiptoken"] = z.string().describe(SKIPTOKEN_PARAM_DESCRIPTION).optional();
1654
1677
  }
1655
- if (paramSchema["filter"] !== void 0 || paramSchema["$filter"] !== void 0) {
1656
- const key = paramSchema["$filter"] !== void 0 ? "$filter" : "filter";
1657
- paramSchema[key] = z.string().describe(FILTER_PARAM_DESCRIPTION).optional();
1658
- }
1659
- if (paramSchema["search"] !== void 0 || paramSchema["$search"] !== void 0) {
1660
- const key = paramSchema["$search"] !== void 0 ? "$search" : "search";
1661
- paramSchema[key] = z.string().describe(SEARCH_PARAM_DESCRIPTION).optional();
1662
- }
1663
- if (paramSchema["select"] !== void 0 || paramSchema["$select"] !== void 0) {
1664
- const key = paramSchema["$select"] !== void 0 ? "$select" : "select";
1665
- paramSchema[key] = z.string().describe(SELECT_PARAM_DESCRIPTION).optional();
1666
- }
1667
- if (paramSchema["expand"] !== void 0 || paramSchema["$expand"] !== void 0) {
1668
- const key = paramSchema["$expand"] !== void 0 ? "$expand" : "expand";
1669
- paramSchema[key] = z.array(z.string()).describe(EXPAND_PARAM_DESCRIPTION).optional();
1670
- }
1671
- if (paramSchema["orderby"] !== void 0 || paramSchema["$orderby"] !== void 0) {
1672
- const key = paramSchema["$orderby"] !== void 0 ? "$orderby" : "orderby";
1673
- paramSchema[key] = z.string().describe(ORDERBY_PARAM_DESCRIPTION).optional();
1674
- }
1675
- if (shouldOmitTopParam(tool.alias)) {
1676
- delete paramSchema["top"];
1677
- delete paramSchema["$top"];
1678
- } else if (paramSchema["top"] !== void 0 || paramSchema["$top"] !== void 0) {
1679
- const key = paramSchema["$top"] !== void 0 ? "$top" : "top";
1680
- paramSchema[key] = z.number().describe(TOP_PARAM_DESCRIPTION).optional();
1681
- }
1682
- if (paramSchema["skip"] !== void 0 || paramSchema["$skip"] !== void 0) {
1683
- const key = paramSchema["$skip"] !== void 0 ? "$skip" : "skip";
1684
- paramSchema[key] = z.number().describe(SKIP_PARAM_DESCRIPTION).optional();
1685
- }
1686
- if (paramSchema["count"] !== void 0 || paramSchema["$count"] !== void 0) {
1687
- const countKey = paramSchema["$count"] !== void 0 ? "$count" : "count";
1688
- paramSchema[countKey] = z.boolean().describe(COUNT_PARAM_DESCRIPTION).optional();
1689
- }
1690
1678
  if (multiAccount) {
1691
1679
  paramSchema["account"] = z.string().describe(getAccountParamDescription(accountNames)).optional();
1692
1680
  }
@@ -0,0 +1,33 @@
1
+ import { z } from "zod";
2
+ import { getODataParamDescription, shouldOmitTopParam } from "./param-descriptions.js";
3
+ const TEAM_LIST_TOOLS = /* @__PURE__ */ new Set(["list-joined-teams", "list-my-associated-teams"]);
4
+ function queryParameterSchema(toolName, name, providerSchema) {
5
+ const bareName = name.replace(/^\$/, "").toLowerCase();
6
+ if (TEAM_LIST_TOOLS.has(toolName)) return void 0;
7
+ if (bareName === "top" && shouldOmitTopParam(toolName)) return void 0;
8
+ const source = providerSchema instanceof z.ZodOptional ? providerSchema.unwrap() : providerSchema;
9
+ let schema = source;
10
+ switch (bareName) {
11
+ case "select":
12
+ case "expand":
13
+ case "orderby":
14
+ schema = z.union([z.string(), z.array(z.string())]);
15
+ break;
16
+ case "filter":
17
+ case "search":
18
+ schema = z.string();
19
+ break;
20
+ case "skiptoken":
21
+ schema = z.string();
22
+ break;
23
+ case "top":
24
+ if (toolName === "list-chats") schema = source.and(z.number().max(50));
25
+ break;
26
+ }
27
+ if (providerSchema.isOptional()) schema = schema.optional();
28
+ const description = getODataParamDescription(bareName);
29
+ return description ? schema.describe(description) : schema;
30
+ }
31
+ export {
32
+ queryParameterSchema
33
+ };
@@ -1,8 +1,7 @@
1
1
  import { zodToJsonSchema } from "zod-to-json-schema";
2
+ import { queryParameterSchema } from "./query-parameter-schema.js";
2
3
  import { isDestructiveOperation } from "./destructive-ops.js";
3
4
  import {
4
- getODataParamDescription,
5
- shouldOmitTopParam,
6
5
  isFetchAllPagesApplicable,
7
6
  isSkiptokenApplicable,
8
7
  SKIPTOKEN_PARAM_DESCRIPTION,
@@ -22,24 +21,23 @@ function unwrapOptional(schema) {
22
21
  }
23
22
  return { inner: schema, optional: false };
24
23
  }
25
- function bareParamName(name) {
26
- return name.startsWith("$") ? name.slice(1) : name;
27
- }
28
24
  function describeToolSchema(tool, config, ctx = {}) {
29
- const omitTop = shouldOmitTopParam(tool.alias);
30
- const params = (tool.parameters ?? []).filter((p) => !(omitTop && bareParamName(p.name) === "top")).map((p) => {
31
- const { inner, optional } = unwrapOptional(p.schema);
25
+ const params = (tool.parameters ?? []).flatMap((p) => {
26
+ const effectiveSchema = p.type === "Query" ? queryParameterSchema(tool.alias, p.name, p.schema) : p.schema;
27
+ if (!effectiveSchema) return [];
28
+ const { inner, optional } = unwrapOptional(effectiveSchema);
32
29
  const isPath = p.type === "Path";
33
30
  const jsonSchema = zodToJsonSchema(inner, { target: "jsonSchema7", $refStrategy: "none" });
34
31
  const { $schema: _s, ...schema } = jsonSchema;
35
- const override = p.type === "Query" ? getODataParamDescription(bareParamName(p.name)) : void 0;
36
- return {
37
- name: p.name,
38
- in: p.type,
39
- required: isPath || !optional,
40
- description: override ?? p.description,
41
- schema
42
- };
32
+ return [
33
+ {
34
+ name: p.name,
35
+ in: p.type,
36
+ required: isPath || !optional,
37
+ description: effectiveSchema.description ?? p.description,
38
+ schema
39
+ }
40
+ ];
43
41
  });
44
42
  if (isDestructiveOperation(tool.method, config)) {
45
43
  params.push({
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.1",
4
+ "version": "0.154.1",
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
  {