@softeria/ms-365-mcp-server 0.121.0 → 0.123.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
@@ -150,6 +150,18 @@ Scope coverage is hierarchy-aware: for example, `Mail.ReadWrite` covers tools th
150
150
 
151
151
  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.
152
152
 
153
+ ### Requesting extra scopes
154
+
155
+ `--allowed-scopes` only ever _narrows_ the token request. To request a Graph scope that no bundled tool needs — for example to drive an endpoint via `graph-batch` — use `--extra-scopes` (or `MS365_MCP_EXTRA_SCOPES`). These scopes are appended verbatim to the token request, on top of the tool-derived scopes.
156
+
157
+ ```bash
158
+ npx @softeria/ms-365-mcp-server \
159
+ --org-mode \
160
+ --extra-scopes 'CopilotPackages.ReadWrite.All'
161
+ ```
162
+
163
+ This is for use with your own Azure app registration (`MS365_MCP_CLIENT_ID` / `MS365_MCP_CLIENT_SECRET`): the default Softeria app only declares a lean, fixed permission set, so request additional scopes against an app you control (your tenant admin consents to them there). CLI value takes precedence over the env var; an empty value fails at startup.
164
+
153
165
  ## Organization/Work Mode
154
166
 
155
167
  To access work/school features (Teams, SharePoint, etc.), enable organization mode using any of these flags:
@@ -542,6 +554,7 @@ The following options can be used when running ms-365-mcp-server directly from t
542
554
  --force-work-scopes Backwards compatibility alias for --org-mode (deprecated)
543
555
  --cloud <type> Microsoft cloud environment: global (default) or china (21Vianet)
544
556
  --allowed-scopes <scopes> Limit exposed tools to Graph scopes covered by this allowlist
557
+ --extra-scopes <scopes> Append additional Graph scopes to the token request (for use with your own app registration + graph-batch)
545
558
  --expected-username <username> Require local MSAL auth to use this account username
546
559
  --expected-home-account-id <id> Require local MSAL auth to use this exact homeAccountId
547
560
  ```
@@ -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
@@ -218,7 +218,12 @@ function buildAllowedScopeDiagnostics(options = {}) {
218
218
  };
219
219
  }
220
220
  function resolveAuthScopes(options = {}) {
221
- return buildAllowedScopeDiagnostics(options).effectivePermissions;
221
+ const toolScopes = buildAllowedScopeDiagnostics(options).effectivePermissions;
222
+ const extraScopes = parseAllowedScopes(options.extraScopes);
223
+ if (!extraScopes || extraScopes.length === 0) {
224
+ return toolScopes;
225
+ }
226
+ return Array.from(/* @__PURE__ */ new Set([...toolScopes, ...extraScopes]));
222
227
  }
223
228
  function buildScopeDiagnostics(toolScopes, allowedScopesInput) {
224
229
  const toolPermissions = [...toolScopes].sort((a, b) => a.localeCompare(b));
package/dist/cli.js CHANGED
@@ -26,6 +26,9 @@ program.name("ms-365-mcp-server").description("Microsoft 365 MCP Server").versio
26
26
  ).option(
27
27
  "--allowed-scopes <scopes>",
28
28
  "Limit exposed tools to Graph scopes covered by this whitespace-separated allowlist"
29
+ ).option(
30
+ "--extra-scopes <scopes>",
31
+ "Append additional Graph scopes (whitespace-separated) to the token request, beyond those derived from enabled tools. Use with your own app registration (MS365_MCP_CLIENT_ID/SECRET) to request scopes the default app does not declare, then call the endpoints via graph-batch."
29
32
  ).option(
30
33
  "--preset <names>",
31
34
  "Use preset tool categories (comma-separated). Available: mail, calendar, files, personal, work, excel, contacts, tasks, onenote, search, users, all"
@@ -97,6 +100,15 @@ function parseArgs() {
97
100
  );
98
101
  process.exit(1);
99
102
  }
103
+ if (options.extraScopes === void 0 && process.env.MS365_MCP_EXTRA_SCOPES !== void 0) {
104
+ options.extraScopes = process.env.MS365_MCP_EXTRA_SCOPES;
105
+ }
106
+ if (options.extraScopes !== void 0 && options.extraScopes.trim() === "") {
107
+ console.error(
108
+ "Error: --extra-scopes / MS365_MCP_EXTRA_SCOPES was provided but is empty. Provide one or more whitespace-separated scopes, or omit it."
109
+ );
110
+ process.exit(1);
111
+ }
100
112
  if (options.expectedUsername === void 0 && process.env.MS365_MCP_EXPECTED_USERNAME !== void 0) {
101
113
  options.expectedUsername = process.env.MS365_MCP_EXPECTED_USERNAME;
102
114
  }
@@ -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); needs delegated Files.Read.All + Sites.Read.All (SharePoint/OneDrive) or ExternalItem.Read.All (Copilot connectors)."
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",
@@ -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) {
@@ -693,6 +606,7 @@ function registerGraphTools(server, graphClient, readOnly = false, enabledToolsP
693
606
 
694
607
  \u{1F4A1} TIP: ${endpointConfig.llmTip}`;
695
608
  }
609
+ const isReadOnlyTool = tool.method.toUpperCase() === "GET" || endpointConfig?.readOnly === true;
696
610
  try {
697
611
  server.tool(
698
612
  tool.alias,
@@ -700,8 +614,8 @@ function registerGraphTools(server, graphClient, readOnly = false, enabledToolsP
700
614
  paramSchema,
701
615
  {
702
616
  title: tool.alias,
703
- readOnlyHint: tool.method.toUpperCase() === "GET",
704
- destructiveHint: ["POST", "PATCH", "DELETE"].includes(tool.method.toUpperCase()),
617
+ readOnlyHint: isReadOnlyTool,
618
+ destructiveHint: !isReadOnlyTool && ["POST", "PATCH", "DELETE"].includes(tool.method.toUpperCase()),
705
619
  openWorldHint: true
706
620
  // All tools call Microsoft Graph API
707
621
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softeria/ms-365-mcp-server",
3
- "version": "0.121.0",
3
+ "version": "0.123.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); needs delegated Files.Read.All + Sites.Read.All (SharePoint/OneDrive) or ExternalItem.Read.All (Copilot connectors)."
1786
+ },
1745
1787
  {
1746
1788
  "pathPattern": "/me/onlineMeetings",
1747
1789
  "method": "get",