@softeria/ms-365-mcp-server 0.122.0 → 0.124.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.
@@ -17,11 +17,27 @@ export function createAndSaveSimplifiedOpenAPI(endpointsFile, openapiFile, opena
17
17
  }
18
18
  }
19
19
 
20
- // Synthesize operations on existing paths when the method is missing.
20
+ // Two cases handled here:
21
+ // 1. The method is missing entirely (Microsoft never published this operation):
22
+ // synthesize the whole operation from endpoints.json.
23
+ // 2. The method exists but the endpoint declares its own requestBodySchema
24
+ // (Microsoft published a deprecated/malformed/private-preview body our generator
25
+ // can't consume): override ONLY the request body and keep Microsoft's published
26
+ // responses/parameters intact, so we don't silently drop upstream fields or mask
27
+ // a future upstream fix. requestBodySchema is explicit opt-in per endpoint.
21
28
  for (const endpoint of endpoints) {
22
29
  const pathSpec = openApiSpec.paths[endpoint.pathPattern];
23
30
  const methodLower = endpoint.method.toLowerCase();
24
- if (pathSpec && !pathSpec[methodLower]) {
31
+ if (!pathSpec) continue;
32
+
33
+ const operationMissing = !pathSpec[methodLower];
34
+ const overrideBodyOnly =
35
+ !operationMissing &&
36
+ endpoint.requestBodySchema &&
37
+ methodLower !== 'get' &&
38
+ methodLower !== 'delete';
39
+
40
+ if (operationMissing) {
25
41
  const pathParamMatches = [...endpoint.pathPattern.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]);
26
42
  const synthesizedParameters = pathParamMatches.map((paramName) => ({
27
43
  name: paramName,
@@ -44,7 +60,14 @@ export function createAndSaveSimplifiedOpenAPI(endpointsFile, openapiFile, opena
44
60
  required: true,
45
61
  content: {
46
62
  'application/json': {
47
- schema: { type: 'object', additionalProperties: true },
63
+ // When an endpoint declares a typed body schema in endpoints.json
64
+ // (for APIs Microsoft hasn't published in its OpenAPI metadata),
65
+ // use it so the generated client gets a validated `body` param.
66
+ // Otherwise fall back to a permissive object.
67
+ schema: endpoint.requestBodySchema ?? {
68
+ type: 'object',
69
+ additionalProperties: true,
70
+ },
48
71
  },
49
72
  },
50
73
  },
@@ -61,6 +84,24 @@ export function createAndSaveSimplifiedOpenAPI(endpointsFile, openapiFile, opena
61
84
  '5XX': { $ref: '#/components/responses/error' },
62
85
  },
63
86
  };
87
+ } else if (overrideBodyOnly) {
88
+ // Surgical override: replace only the request body, leaving Microsoft's
89
+ // published responses and parameters for this operation untouched.
90
+ pathSpec[methodLower].requestBody = {
91
+ description: pathSpec[methodLower].requestBody?.description || 'Operation payload',
92
+ required: true,
93
+ content: {
94
+ 'application/json': {
95
+ schema: endpoint.requestBodySchema,
96
+ },
97
+ },
98
+ };
99
+ // These operations are often published as deprecated/private-preview, which
100
+ // openapi-zod-client skips by default. Declaring a requestBodySchema means we
101
+ // deliberately vouch for the endpoint, so clear the deprecation markers to keep
102
+ // it in the generated client.
103
+ delete pathSpec[methodLower].deprecated;
104
+ delete pathSpec[methodLower]['x-ms-deprecation'];
64
105
  }
65
106
  }
66
107
 
@@ -914,118 +914,4 @@ describe("graph-tools", () => {
914
914
  expect(server.tools.has("parse-teams-url")).toBe(true);
915
915
  });
916
916
  });
917
- describe("copilot-retrieve", () => {
918
- const retrievalResponse = {
919
- content: [
920
- {
921
- type: "text",
922
- text: JSON.stringify({
923
- retrievalHits: [
924
- {
925
- webUrl: "https://contoso.sharepoint.com/sites/HR/VPNAccess.docx",
926
- extracts: [{ text: "To configure the VPN...", relevanceScore: 0.83 }],
927
- resourceType: "listItem",
928
- resourceMetadata: { title: "VPN Access" }
929
- }
930
- ]
931
- })
932
- }
933
- ]
934
- };
935
- it("POSTs queryString + dataSource to /copilot/retrieval on v1.0 and returns the hits", async () => {
936
- mockEndpoints.length = 0;
937
- mockEndpointsJson = [];
938
- const graphClient = { graphRequest: vi.fn().mockResolvedValue(retrievalResponse) };
939
- const server = createMockServer();
940
- const { registerGraphTools } = await loadModule();
941
- registerGraphTools(server, graphClient);
942
- const tool = server.tools.get("copilot-retrieve");
943
- expect(tool).toBeDefined();
944
- const result = await tool.handler({
945
- queryString: "How to setup corporate VPN?",
946
- dataSource: "sharePoint"
947
- });
948
- expect(graphClient.graphRequest).toHaveBeenCalledTimes(1);
949
- const [endpoint, options] = graphClient.graphRequest.mock.calls[0];
950
- expect(endpoint).toBe("/copilot/retrieval");
951
- expect(options.method).toBe("POST");
952
- expect(options.apiVersion === void 0 || options.apiVersion === "v1.0").toBe(true);
953
- const body = JSON.parse(options.body);
954
- expect(body).toEqual({
955
- queryString: "How to setup corporate VPN?",
956
- dataSource: "sharePoint"
957
- });
958
- const payload = JSON.parse(result.content[0].text);
959
- expect(payload.retrievalHits[0].webUrl).toBe(
960
- "https://contoso.sharepoint.com/sites/HR/VPNAccess.docx"
961
- );
962
- });
963
- it("includes optional filterExpression, resourceMetadata, and maximumNumberOfResults only when provided", async () => {
964
- mockEndpoints.length = 0;
965
- mockEndpointsJson = [];
966
- const graphClient = { graphRequest: vi.fn().mockResolvedValue(retrievalResponse) };
967
- const server = createMockServer();
968
- const { registerGraphTools } = await loadModule();
969
- registerGraphTools(server, graphClient);
970
- const tool = server.tools.get("copilot-retrieve");
971
- await tool.handler({
972
- queryString: "corporate VPN",
973
- dataSource: "sharePoint",
974
- filterExpression: 'Author:"Megan Bowen"',
975
- resourceMetadata: ["title", "author"],
976
- maximumNumberOfResults: 5
977
- });
978
- const [, options] = graphClient.graphRequest.mock.calls[0];
979
- const body = JSON.parse(options.body);
980
- expect(body.filterExpression).toBe('Author:"Megan Bowen"');
981
- expect(body.resourceMetadata).toEqual(["title", "author"]);
982
- expect(body.maximumNumberOfResults).toBe(5);
983
- });
984
- it("requires a non-empty queryString", async () => {
985
- mockEndpoints.length = 0;
986
- mockEndpointsJson = [];
987
- const graphClient = { graphRequest: vi.fn() };
988
- const server = createMockServer();
989
- const { registerGraphTools } = await loadModule();
990
- registerGraphTools(server, graphClient);
991
- const tool = server.tools.get("copilot-retrieve");
992
- const result = await tool.handler({ queryString: " ", dataSource: "sharePoint" });
993
- expect(result.isError).toBe(true);
994
- expect(graphClient.graphRequest).not.toHaveBeenCalled();
995
- const payload = JSON.parse(result.content[0].text);
996
- expect(payload.error).toMatch(/queryString/);
997
- });
998
- it("rejects an invalid dataSource", async () => {
999
- mockEndpoints.length = 0;
1000
- mockEndpointsJson = [];
1001
- const graphClient = { graphRequest: vi.fn() };
1002
- const server = createMockServer();
1003
- const { registerGraphTools } = await loadModule();
1004
- registerGraphTools(server, graphClient);
1005
- const tool = server.tools.get("copilot-retrieve");
1006
- const result = await tool.handler({ queryString: "vpn", dataSource: "mailbox" });
1007
- expect(result.isError).toBe(true);
1008
- expect(graphClient.graphRequest).not.toHaveBeenCalled();
1009
- const payload = JSON.parse(result.content[0].text);
1010
- expect(payload.error).toMatch(/dataSource/);
1011
- });
1012
- it("rejects maximumNumberOfResults outside the 1-25 range", async () => {
1013
- mockEndpoints.length = 0;
1014
- mockEndpointsJson = [];
1015
- const graphClient = { graphRequest: vi.fn() };
1016
- const server = createMockServer();
1017
- const { registerGraphTools } = await loadModule();
1018
- registerGraphTools(server, graphClient);
1019
- const tool = server.tools.get("copilot-retrieve");
1020
- const result = await tool.handler({
1021
- queryString: "vpn",
1022
- dataSource: "sharePoint",
1023
- maximumNumberOfResults: 50
1024
- });
1025
- expect(result.isError).toBe(true);
1026
- expect(graphClient.graphRequest).not.toHaveBeenCalled();
1027
- const payload = JSON.parse(result.content[0].text);
1028
- expect(payload.error).toMatch(/maximumNumberOfResults/);
1029
- });
1030
- });
1031
917
  });
package/dist/auth.js CHANGED
@@ -49,13 +49,47 @@ function getEndpointRequiredScopes(endpoint, includeWorkAccountScopes = false) {
49
49
  return [];
50
50
  }
51
51
  const scopes = /* @__PURE__ */ new Set();
52
- if (endpoint.scopes && Array.isArray(endpoint.scopes)) {
53
- endpoint.scopes.forEach((scope) => scopes.add(scope));
52
+ getEndpointScopeGroups(endpoint, includeWorkAccountScopes).forEach(
53
+ (group) => group.forEach((scope) => scopes.add(scope))
54
+ );
55
+ return Array.from(scopes);
56
+ }
57
+ function toScopeGroups(value) {
58
+ if (!value || value.length === 0) {
59
+ return [];
54
60
  }
55
- if (includeWorkAccountScopes && endpoint.workScopes && Array.isArray(endpoint.workScopes)) {
56
- endpoint.workScopes.forEach((scope) => scopes.add(scope));
61
+ return Array.isArray(value[0]) ? value : [value];
62
+ }
63
+ function getEndpointScopeGroups(endpoint, includeWorkAccountScopes = false) {
64
+ if (!endpoint) {
65
+ return [];
57
66
  }
58
- return Array.from(scopes);
67
+ const groups = [...toScopeGroups(endpoint.scopes)];
68
+ if (includeWorkAccountScopes) {
69
+ groups.push(...toScopeGroups(endpoint.workScopes));
70
+ }
71
+ return groups;
72
+ }
73
+ function getEndpointLoginScopes(endpoint, includeWorkAccountScopes = false) {
74
+ const groups = getEndpointScopeGroups(endpoint, includeWorkAccountScopes);
75
+ return groups.length > 0 ? groups[0] : [];
76
+ }
77
+ function getMissingAllowedScopesForGroups(scopeGroups, allowedScopes) {
78
+ if (allowedScopes === void 0 || scopeGroups.length === 0) {
79
+ return [];
80
+ }
81
+ const coveredAllowedScopes = new Set(collapseScopeHierarchy(allowedScopes));
82
+ let closest;
83
+ for (const group of scopeGroups) {
84
+ const missing = group.filter((scope) => !coveredAllowedScopes.has(scope));
85
+ if (missing.length === 0) {
86
+ return [];
87
+ }
88
+ if (!closest || missing.length < closest.length) {
89
+ closest = missing;
90
+ }
91
+ }
92
+ return closest ?? [];
59
93
  }
60
94
  function collapseRedundantScopes(scopes) {
61
95
  const scopesSet = new Set(scopes);
@@ -91,7 +125,7 @@ function buildScopesFromEndpoints(includeWorkAccountScopes = false, enabledTools
91
125
  if (!includeWorkAccountScopes && !endpoint.scopes && endpoint.workScopes) {
92
126
  return;
93
127
  }
94
- getEndpointRequiredScopes(endpoint, includeWorkAccountScopes).forEach(
128
+ getEndpointLoginScopes(endpoint, includeWorkAccountScopes).forEach(
95
129
  (scope) => scopesSet.add(scope)
96
130
  );
97
131
  });
@@ -173,6 +207,7 @@ function buildAllowedScopeDiagnostics(options = {}) {
173
207
  }
174
208
  const normalToolScopes = /* @__PURE__ */ new Set();
175
209
  const effectiveToolScopes = /* @__PURE__ */ new Set();
210
+ const effectiveToolScopesAllGroups = /* @__PURE__ */ new Set();
176
211
  const disabledTools = [];
177
212
  for (const endpoint of endpoints.default) {
178
213
  if (!endpointMatchesNormalToolSurface(
@@ -183,18 +218,21 @@ function buildAllowedScopeDiagnostics(options = {}) {
183
218
  )) {
184
219
  continue;
185
220
  }
186
- const requiredScopes = getEndpointRequiredScopes(endpoint, Boolean(options.orgMode));
187
- requiredScopes.forEach((scope) => normalToolScopes.add(scope));
188
- const missingScopes = getMissingAllowedScopes(requiredScopes, allowedScopes);
221
+ const scopeGroups = getEndpointScopeGroups(endpoint, Boolean(options.orgMode));
222
+ const loginScopes = getEndpointLoginScopes(endpoint, Boolean(options.orgMode));
223
+ const allScopes = getEndpointRequiredScopes(endpoint, Boolean(options.orgMode));
224
+ loginScopes.forEach((scope) => normalToolScopes.add(scope));
225
+ const missingScopes = getMissingAllowedScopesForGroups(scopeGroups, allowedScopes);
189
226
  if (missingScopes.length > 0) {
190
227
  disabledTools.push({
191
228
  toolName: endpoint.toolName,
192
- requiredScopes: requiredScopes.sort((a, b) => a.localeCompare(b)),
229
+ requiredScopes: allScopes.sort((a, b) => a.localeCompare(b)),
193
230
  missingScopes: missingScopes.sort((a, b) => a.localeCompare(b))
194
231
  });
195
232
  continue;
196
233
  }
197
- requiredScopes.forEach((scope) => effectiveToolScopes.add(scope));
234
+ loginScopes.forEach((scope) => effectiveToolScopes.add(scope));
235
+ allScopes.forEach((scope) => effectiveToolScopesAllGroups.add(scope));
198
236
  }
199
237
  const toolPermissions = collapseRedundantScopes(Array.from(normalToolScopes)).sort(
200
238
  (a, b) => a.localeCompare(b)
@@ -206,7 +244,8 @@ function buildAllowedScopeDiagnostics(options = {}) {
206
244
  const missingAllowedScopesForTools = Array.from(
207
245
  new Set(disabledTools.flatMap((tool) => tool.missingScopes))
208
246
  ).sort((a, b) => a.localeCompare(b));
209
- const extraAllowedScopesNotUsedByTools = sortedAllowedScopes?.filter((scope) => !isScopeUsedByTools(scope, effectivePermissions)) ?? [];
247
+ const allEffectiveToolScopes = Array.from(effectiveToolScopesAllGroups);
248
+ const extraAllowedScopesNotUsedByTools = sortedAllowedScopes?.filter((scope) => !isScopeUsedByTools(scope, allEffectiveToolScopes)) ?? [];
210
249
  return {
211
250
  permissions: effectivePermissions,
212
251
  toolPermissions,
@@ -811,7 +850,9 @@ export {
811
850
  auth_default as default,
812
851
  describeAuthError,
813
852
  getEndpointRequiredScopes,
853
+ getEndpointScopeGroups,
814
854
  getMissingAllowedScopes,
855
+ getMissingAllowedScopesForGroups,
815
856
  getSelectedAccountPath,
816
857
  getTokenCachePath,
817
858
  parseAllowedScopes,
@@ -1742,6 +1742,48 @@
1742
1742
  "ChannelMessage.Read.All"
1743
1743
  ]
1744
1744
  },
1745
+ {
1746
+ "pathPattern": "/copilot/retrieval",
1747
+ "method": "post",
1748
+ "toolName": "copilot-retrieve",
1749
+ "presets": ["search", "work"],
1750
+ "readOnly": true,
1751
+ "workScopes": [["Files.Read.All", "Sites.Read.All"], ["ExternalItem.Read.All"]],
1752
+ "requestBodySchema": {
1753
+ "type": "object",
1754
+ "required": ["queryString", "dataSource"],
1755
+ "properties": {
1756
+ "queryString": {
1757
+ "type": "string",
1758
+ "minLength": 1,
1759
+ "maxLength": 1500,
1760
+ "pattern": "\\S",
1761
+ "description": "Natural-language query (a single sentence works best; max 1500 characters; must contain a non-whitespace character). Avoid spelling errors in context-rich keywords."
1762
+ },
1763
+ "dataSource": {
1764
+ "type": "string",
1765
+ "enum": ["sharePoint", "oneDriveBusiness", "externalItem"],
1766
+ "description": "Which source to retrieve from — one at a time (interleaved results are not supported). 'sharePoint', 'oneDriveBusiness', or 'externalItem' (Copilot connectors)."
1767
+ },
1768
+ "filterExpression": {
1769
+ "type": "string",
1770
+ "description": "Optional KQL expression to scope the retrieval before the query runs. Supported SharePoint/OneDrive properties: Author, FileExtension, Filename, FileType, InformationProtectionLabelId, LastModifiedTime, ModifiedBy, Path, SiteID, Title. Invalid KQL is ignored (query runs unscoped)."
1771
+ },
1772
+ "resourceMetadata": {
1773
+ "type": "array",
1774
+ "items": { "type": "string" },
1775
+ "description": "Optional list of retrievable metadata fields to return per hit (e.g. ['title', 'author']). By default no metadata is returned."
1776
+ },
1777
+ "maximumNumberOfResults": {
1778
+ "type": "integer",
1779
+ "minimum": 1,
1780
+ "maximum": 25,
1781
+ "description": "Optional cap on the number of results (1-25). Best practice: leave unset unless your LLM has strict token limits — results are unordered."
1782
+ }
1783
+ }
1784
+ },
1785
+ "llmTip": "Semantic/hybrid search over Microsoft 365 content via the Copilot Retrieval API. Grounds a natural-language query against the same hybrid index that powers Microsoft 365 Copilot and returns permission-trimmed text extracts with their source URLs — unlike search-query/search-onedrive-files/search-sharepoint-sites, which are lexical KQL. Prefer this for paraphrase/intent queries (e.g. resolving an informal project nickname, or matching 'packaging requirements' against text that never uses that phrase). Pass the request fields nested under a 'body' object: { queryString, dataSource, filterExpression?, resourceMetadata?, maximumNumberOfResults? }. Returns { retrievalHits: [{ webUrl, extracts: [{ text, relevanceScore }], resourceType, resourceMetadata, sensitivityLabel? }] }. Work/school accounts only (personal accounts are not supported). dataSource 'sharePoint'/'oneDriveBusiness' need delegated Files.Read.All + Sites.Read.All (requested by default); dataSource 'externalItem' (Copilot connectors) needs ExternalItem.Read.All, which is not requested by default - add it with --extra-scopes ExternalItem.Read.All."
1786
+ },
1745
1787
  {
1746
1788
  "pathPattern": "/me/onlineMeetings",
1747
1789
  "method": "get",
@@ -547,6 +547,52 @@ const microsoft_graph_presence = z.object({
547
547
  statusMessage: microsoft_graph_presenceStatusMessage.optional(),
548
548
  workLocation: microsoft_graph_userWorkLocation.optional()
549
549
  }).passthrough();
550
+ const copilot_retrieve_Body = z.object({
551
+ queryString: z.string().min(1).max(1500).regex(/\S/).describe(
552
+ "Natural-language query (a single sentence works best; max 1500 characters; must contain a non-whitespace character). Avoid spelling errors in context-rich keywords."
553
+ ),
554
+ dataSource: z.enum(["sharePoint", "oneDriveBusiness", "externalItem"]).describe(
555
+ "Which source to retrieve from \u2014 one at a time (interleaved results are not supported). 'sharePoint', 'oneDriveBusiness', or 'externalItem' (Copilot connectors)."
556
+ ),
557
+ filterExpression: z.string().describe(
558
+ "Optional KQL expression to scope the retrieval before the query runs. Supported SharePoint/OneDrive properties: Author, FileExtension, Filename, FileType, InformationProtectionLabelId, LastModifiedTime, ModifiedBy, Path, SiteID, Title. Invalid KQL is ignored (query runs unscoped)."
559
+ ).optional(),
560
+ resourceMetadata: z.array(z.string()).describe(
561
+ "Optional list of retrievable metadata fields to return per hit (e.g. ['title', 'author']). By default no metadata is returned."
562
+ ).optional(),
563
+ maximumNumberOfResults: z.number().int().gte(1).lte(25).describe(
564
+ "Optional cap on the number of results (1-25). Best practice: leave unset unless your LLM has strict token limits \u2014 results are unordered."
565
+ ).optional()
566
+ }).passthrough();
567
+ const microsoft_graph_retrievalExtract = z.object({
568
+ relevanceScore: z.number().describe("[Simplified from 3 options]").nullish(),
569
+ text: z.string().nullish()
570
+ }).passthrough();
571
+ const microsoft_graph_searchResourceMetadataDictionary = z.object({}).passthrough();
572
+ const microsoft_graph_retrievalEntityType = z.enum([
573
+ "site",
574
+ "list",
575
+ "listItem",
576
+ "drive",
577
+ "driveItem",
578
+ "externalItem",
579
+ "unknownFutureValue"
580
+ ]);
581
+ const microsoft_graph_sensitivityLabelInfo = z.object({
582
+ color: z.string().nullish(),
583
+ displayName: z.string().nullish(),
584
+ priority: z.number().gte(-2147483648).lte(2147483647).nullish(),
585
+ sensitivityLabelId: z.string().nullish(),
586
+ tooltip: z.string().nullish()
587
+ }).passthrough();
588
+ const microsoft_graph_retrievalHit = z.object({
589
+ extracts: z.array(microsoft_graph_retrievalExtract).optional(),
590
+ resourceMetadata: microsoft_graph_searchResourceMetadataDictionary.optional(),
591
+ resourceType: microsoft_graph_retrievalEntityType.optional(),
592
+ sensitivityLabel: microsoft_graph_sensitivityLabelInfo.optional(),
593
+ webUrl: z.string().nullish()
594
+ }).passthrough();
595
+ const microsoft_graph_retrievalResponse = z.object({ retrievalHits: z.array(microsoft_graph_retrievalHit).optional() }).passthrough();
550
596
  const microsoft_graph_geoCoordinates = z.object({
551
597
  altitude: z.number().describe(
552
598
  "Optional. The altitude (height), in feet, above sea level for the item. Read-only. [Simplified from 3 options]"
@@ -4818,6 +4864,13 @@ const schemas = {
4818
4864
  microsoft_graph_workLocationType,
4819
4865
  microsoft_graph_userWorkLocation,
4820
4866
  microsoft_graph_presence,
4867
+ copilot_retrieve_Body,
4868
+ microsoft_graph_retrievalExtract,
4869
+ microsoft_graph_searchResourceMetadataDictionary,
4870
+ microsoft_graph_retrievalEntityType,
4871
+ microsoft_graph_sensitivityLabelInfo,
4872
+ microsoft_graph_retrievalHit,
4873
+ microsoft_graph_retrievalResponse,
4821
4874
  microsoft_graph_geoCoordinates,
4822
4875
  microsoft_graph_sharepointIds,
4823
4876
  microsoft_graph_itemReference,
@@ -5683,6 +5736,22 @@ const endpoints = makeApi([
5683
5736
  ],
5684
5737
  response: z.void()
5685
5738
  },
5739
+ {
5740
+ method: "post",
5741
+ path: "/copilot/retrieval",
5742
+ alias: "copilot-retrieve",
5743
+ description: `Invoke action retrieval`,
5744
+ requestFormat: "json",
5745
+ parameters: [
5746
+ {
5747
+ name: "body",
5748
+ description: `Action parameters`,
5749
+ type: "Body",
5750
+ schema: copilot_retrieve_Body
5751
+ }
5752
+ ],
5753
+ response: z.void()
5754
+ },
5686
5755
  {
5687
5756
  method: "get",
5688
5757
  path: "/drives/:driveId/items/:driveItemId",
@@ -2,8 +2,8 @@ import { randomUUID } from "crypto";
2
2
  import logger from "./logger.js";
3
3
  import { auditLog, getUserIdentityForAudit } from "./audit-log.js";
4
4
  import {
5
- getEndpointRequiredScopes,
6
- getMissingAllowedScopes,
5
+ getEndpointScopeGroups,
6
+ getMissingAllowedScopesForGroups,
7
7
  parseAllowedScopes
8
8
  } from "./auth.js";
9
9
  import { api } from "./generated/client.js";
@@ -169,93 +169,6 @@ const UTILITY_TOOLS = [
169
169
  };
170
170
  }
171
171
  }
172
- },
173
- {
174
- name: "copilot-retrieve",
175
- method: "POST",
176
- path: "tool:copilot-retrieve",
177
- description: 'Semantic search over Microsoft 365 content via the Copilot Retrieval API (POST /copilot/retrieval). Grounds a natural-language query against the same hybrid index that powers Microsoft 365 Copilot and returns relevant, permission-trimmed text extracts with their source URLs \u2014 unlike search-query/search-onedrive-files/search-sharepoint-sites, which are lexical KQL. Prefer this for paraphrase/intent queries (e.g. resolving an informal project nickname, or matching "packaging requirements" against text that never uses that phrase). Returns { retrievalHits: [{ webUrl, extracts: [{ text, relevanceScore }], resourceType, resourceMetadata, sensitivityLabel? }] }. Read-only. Requires delegated Files.Read.All + Sites.Read.All (SharePoint/OneDrive) or ExternalItem.Read.All (Copilot connectors); application-only auth is not supported.',
178
- readOnlyHint: true,
179
- openWorldHint: true,
180
- buildSchema: (ctx) => {
181
- const schema = {
182
- queryString: z.string().min(1).max(1500).describe(
183
- "Natural-language query (a single sentence works best; max 1500 characters). Avoid spelling errors in context-rich keywords."
184
- ),
185
- dataSource: z.enum(["sharePoint", "oneDriveBusiness", "externalItem"]).describe(
186
- 'Which source to retrieve from \u2014 one at a time (interleaved results are not supported). "sharePoint", "oneDriveBusiness", or "externalItem" (Copilot connectors).'
187
- ),
188
- filterExpression: z.string().optional().describe(
189
- 'Optional KQL expression to scope the retrieval before the query runs. Supported SharePoint/OneDrive properties: Author, FileExtension, Filename, FileType, InformationProtectionLabelId, LastModifiedTime, ModifiedBy, Path, SiteID, Title. Example: Author:"Megan Bowen" OR Path:"https://contoso.sharepoint.com/sites/HR/". Invalid KQL is ignored (query runs unscoped).'
190
- ),
191
- resourceMetadata: z.array(z.string()).optional().describe(
192
- 'Optional list of retrievable metadata fields to return per hit (e.g. ["title", "author"]). By default no metadata is returned.'
193
- ),
194
- maximumNumberOfResults: z.number().int().min(1).max(25).optional().describe(
195
- "Optional cap on the number of results (1-25). Best practice: leave unset unless your LLM has strict token limits \u2014 results are unordered."
196
- )
197
- };
198
- if (ctx.multiAccount) {
199
- schema["account"] = z.string().optional().describe(
200
- "Account to use when multiple Microsoft accounts are configured. Required when multiple accounts exist (see list-accounts)."
201
- );
202
- }
203
- return schema;
204
- },
205
- execute: async (params, { graphClient, authManager }) => {
206
- const err = (message) => ({
207
- content: [{ type: "text", text: JSON.stringify({ error: message }) }],
208
- isError: true
209
- });
210
- const queryString = typeof params.queryString === "string" ? params.queryString : "";
211
- if (queryString.trim().length === 0) {
212
- return err("queryString is required and must be a non-empty string.");
213
- }
214
- if (queryString.length > 1500) {
215
- return err("queryString must be 1500 characters or fewer.");
216
- }
217
- const dataSource = params.dataSource;
218
- const allowedSources = ["sharePoint", "oneDriveBusiness", "externalItem"];
219
- if (typeof dataSource !== "string" || !allowedSources.includes(dataSource)) {
220
- return err(`dataSource is required and must be one of: ${allowedSources.join(", ")}.`);
221
- }
222
- const body = { queryString, dataSource };
223
- if (params.filterExpression !== void 0) {
224
- if (typeof params.filterExpression !== "string") {
225
- return err("filterExpression must be a string (KQL expression).");
226
- }
227
- body.filterExpression = params.filterExpression;
228
- }
229
- if (params.resourceMetadata !== void 0) {
230
- const rm = params.resourceMetadata;
231
- if (!Array.isArray(rm) || rm.some((x) => typeof x !== "string")) {
232
- return err("resourceMetadata must be an array of strings.");
233
- }
234
- body.resourceMetadata = rm;
235
- }
236
- if (params.maximumNumberOfResults !== void 0) {
237
- const n = params.maximumNumberOfResults;
238
- if (typeof n !== "number" || !Number.isInteger(n) || n < 1 || n > 25) {
239
- return err("maximumNumberOfResults must be an integer between 1 and 25.");
240
- }
241
- body.maximumNumberOfResults = n;
242
- }
243
- try {
244
- let accountAccessToken;
245
- if (authManager && !authManager.isOAuthModeEnabled() && !getRequestTokens()) {
246
- accountAccessToken = await authManager.getTokenForAccount(
247
- params.account
248
- );
249
- }
250
- return await graphClient.graphRequest("/copilot/retrieval", {
251
- method: "POST",
252
- body: JSON.stringify(body),
253
- accessToken: accountAccessToken
254
- });
255
- } catch (error) {
256
- return err(error.message);
257
- }
258
- }
259
172
  }
260
173
  ];
261
174
  function registerUtilityToolWithMcp(server, utility, ctx) {
@@ -609,8 +522,10 @@ function registerGraphTools(server, graphClient, readOnly = false, enabledToolsP
609
522
  skippedCount++;
610
523
  continue;
611
524
  }
612
- const requiredScopes = getEndpointRequiredScopes(endpointConfig, orgMode);
613
- const missingScopes = allowedScopes !== void 0 && !endpointConfig ? ["endpoint scope metadata"] : getMissingAllowedScopes(requiredScopes, allowedScopes);
525
+ const missingScopes = allowedScopes !== void 0 && !endpointConfig ? ["endpoint scope metadata"] : getMissingAllowedScopesForGroups(
526
+ getEndpointScopeGroups(endpointConfig, orgMode),
527
+ allowedScopes
528
+ );
614
529
  if (missingScopes.length > 0) {
615
530
  disabledByAllowedScopes.push({ toolName: tool.alias, missingScopes });
616
531
  skippedCount++;
@@ -693,6 +608,7 @@ function registerGraphTools(server, graphClient, readOnly = false, enabledToolsP
693
608
 
694
609
  \u{1F4A1} TIP: ${endpointConfig.llmTip}`;
695
610
  }
611
+ const isReadOnlyTool = tool.method.toUpperCase() === "GET" || endpointConfig?.readOnly === true;
696
612
  try {
697
613
  server.tool(
698
614
  tool.alias,
@@ -700,8 +616,8 @@ function registerGraphTools(server, graphClient, readOnly = false, enabledToolsP
700
616
  paramSchema,
701
617
  {
702
618
  title: tool.alias,
703
- readOnlyHint: tool.method.toUpperCase() === "GET",
704
- destructiveHint: ["POST", "PATCH", "DELETE"].includes(tool.method.toUpperCase()),
619
+ readOnlyHint: isReadOnlyTool,
620
+ destructiveHint: !isReadOnlyTool && ["POST", "PATCH", "DELETE"].includes(tool.method.toUpperCase()),
705
621
  openWorldHint: true
706
622
  // All tools call Microsoft Graph API
707
623
  },
@@ -760,8 +676,8 @@ function buildToolsRegistry(readOnly, orgMode, enabledToolsRegex, allowedScopesV
760
676
  if (enabledToolsRegex && !enabledToolsRegex.test(tool.alias)) {
761
677
  continue;
762
678
  }
763
- const missingScopes = allowedScopes !== void 0 && !endpointConfig ? ["endpoint scope metadata"] : getMissingAllowedScopes(
764
- getEndpointRequiredScopes(endpointConfig, orgMode),
679
+ const missingScopes = allowedScopes !== void 0 && !endpointConfig ? ["endpoint scope metadata"] : getMissingAllowedScopesForGroups(
680
+ getEndpointScopeGroups(endpointConfig, orgMode),
765
681
  allowedScopes
766
682
  );
767
683
  if (missingScopes.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softeria/ms-365-mcp-server",
3
- "version": "0.122.0",
3
+ "version": "0.124.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",
@@ -1742,6 +1742,48 @@
1742
1742
  "ChannelMessage.Read.All"
1743
1743
  ]
1744
1744
  },
1745
+ {
1746
+ "pathPattern": "/copilot/retrieval",
1747
+ "method": "post",
1748
+ "toolName": "copilot-retrieve",
1749
+ "presets": ["search", "work"],
1750
+ "readOnly": true,
1751
+ "workScopes": [["Files.Read.All", "Sites.Read.All"], ["ExternalItem.Read.All"]],
1752
+ "requestBodySchema": {
1753
+ "type": "object",
1754
+ "required": ["queryString", "dataSource"],
1755
+ "properties": {
1756
+ "queryString": {
1757
+ "type": "string",
1758
+ "minLength": 1,
1759
+ "maxLength": 1500,
1760
+ "pattern": "\\S",
1761
+ "description": "Natural-language query (a single sentence works best; max 1500 characters; must contain a non-whitespace character). Avoid spelling errors in context-rich keywords."
1762
+ },
1763
+ "dataSource": {
1764
+ "type": "string",
1765
+ "enum": ["sharePoint", "oneDriveBusiness", "externalItem"],
1766
+ "description": "Which source to retrieve from — one at a time (interleaved results are not supported). 'sharePoint', 'oneDriveBusiness', or 'externalItem' (Copilot connectors)."
1767
+ },
1768
+ "filterExpression": {
1769
+ "type": "string",
1770
+ "description": "Optional KQL expression to scope the retrieval before the query runs. Supported SharePoint/OneDrive properties: Author, FileExtension, Filename, FileType, InformationProtectionLabelId, LastModifiedTime, ModifiedBy, Path, SiteID, Title. Invalid KQL is ignored (query runs unscoped)."
1771
+ },
1772
+ "resourceMetadata": {
1773
+ "type": "array",
1774
+ "items": { "type": "string" },
1775
+ "description": "Optional list of retrievable metadata fields to return per hit (e.g. ['title', 'author']). By default no metadata is returned."
1776
+ },
1777
+ "maximumNumberOfResults": {
1778
+ "type": "integer",
1779
+ "minimum": 1,
1780
+ "maximum": 25,
1781
+ "description": "Optional cap on the number of results (1-25). Best practice: leave unset unless your LLM has strict token limits — results are unordered."
1782
+ }
1783
+ }
1784
+ },
1785
+ "llmTip": "Semantic/hybrid search over Microsoft 365 content via the Copilot Retrieval API. Grounds a natural-language query against the same hybrid index that powers Microsoft 365 Copilot and returns permission-trimmed text extracts with their source URLs — unlike search-query/search-onedrive-files/search-sharepoint-sites, which are lexical KQL. Prefer this for paraphrase/intent queries (e.g. resolving an informal project nickname, or matching 'packaging requirements' against text that never uses that phrase). Pass the request fields nested under a 'body' object: { queryString, dataSource, filterExpression?, resourceMetadata?, maximumNumberOfResults? }. Returns { retrievalHits: [{ webUrl, extracts: [{ text, relevanceScore }], resourceType, resourceMetadata, sensitivityLabel? }] }. Work/school accounts only (personal accounts are not supported). dataSource 'sharePoint'/'oneDriveBusiness' need delegated Files.Read.All + Sites.Read.All (requested by default); dataSource 'externalItem' (Copilot connectors) needs ExternalItem.Read.All, which is not requested by default - add it with --extra-scopes ExternalItem.Read.All."
1786
+ },
1745
1787
  {
1746
1788
  "pathPattern": "/me/onlineMeetings",
1747
1789
  "method": "get",