@softeria/ms-365-mcp-server 0.119.0 → 0.120.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
@@ -285,7 +285,7 @@ Open WebUI supports MCP servers via HTTP transport with OAuth 2.1.
285
285
 
286
286
  3. Click **Register Client**.
287
287
 
288
- > **Note**: Dynamic client registration is enabled by default in HTTP mode. Use `--no-dynamic-registration` to disable it. If using a custom Azure Entra app, add your redirect URI under "Mobile and desktop applications" platform (not "Single-page application").
288
+ > **Note**: Dynamic client registration is enabled by default in HTTP mode. Use `--no-dynamic-registration` to disable it. If using a custom Azure Entra app, the platform type for your redirect URI depends on whether the app has a client secret: with a secret use "Web", without one use "Mobile and desktop applications" (never "Single-page application").
289
289
 
290
290
  **Quick test setup** using the default Azure app (ID `ms-365` and `localhost:8080` are pre-configured):
291
291
 
@@ -914,4 +914,118 @@ 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
+ });
917
1031
  });
@@ -169,6 +169,93 @@ 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
+ }
172
259
  }
173
260
  ];
174
261
  function registerUtilityToolWithMcp(server, utility, ctx) {
@@ -127,7 +127,7 @@ When deploying for an organization, create a dedicated app registration instead
127
127
  - Claude (claude.ai, Desktop, Cowork): `https://claude.ai/api/mcp/auth_callback`
128
128
  - Other clients: check the `redirect_uri` query parameter your client sends to the server's `/authorize` endpoint (visible in the server logs)
129
129
 
130
- > **Common pitfall**: registering `https://your-server-domain/callback` here breaks sign-in with `AADSTS50011` (redirect URI mismatch) after the user authenticates. The server has no callback endpoint of its own; the authorization code always goes to the MCP client.
130
+ > **Common pitfall**: registering `https://your-server-domain/callback` here breaks sign-in with `AADSTS50011` (redirect URI mismatch) after the user authenticates. The server has no callback endpoint of its own; the authorization code always goes to the MCP client. Note that platform type **Web** applies because this setup uses a client secret; an app without a secret must register the redirect URI under "Mobile and desktop applications" instead.
131
131
 
132
132
  2. **Add API permissions** > Microsoft Graph > Delegated permissions
133
133
  Run `npx @softeria/ms-365-mcp-server --org-mode --list-permissions` to print the exact list of permissions required for your enabled tools.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softeria/ms-365-mcp-server",
3
- "version": "0.119.0",
3
+ "version": "0.120.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",