@softeria/ms-365-mcp-server 0.118.2 → 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 +1 -1
- package/dist/__tests__/graph-tools.test.js +114 -0
- package/dist/cli.js +6 -0
- package/dist/graph-tools.js +87 -0
- package/dist/lib/microsoft-auth.js +20 -0
- package/dist/server.js +2 -1
- package/docs/deployment.md +1 -1
- package/package.json +1 -1
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,
|
|
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
|
});
|
package/dist/cli.js
CHANGED
|
@@ -50,6 +50,9 @@ program.name("ms-365-mcp-server").description("Microsoft 365 MCP Server").versio
|
|
|
50
50
|
).option(
|
|
51
51
|
"--trust-proxy-auth",
|
|
52
52
|
"In HTTP mode, skip the built-in Bearer-token check on /mcp and ignore any forwarded Authorization header. All callers share the locally cached MSAL identity (same path stdio mode uses). Use only when an upstream reverse proxy has already authenticated the caller."
|
|
53
|
+
).option(
|
|
54
|
+
"--allow-unauthenticated-discovery",
|
|
55
|
+
"In HTTP mode, allow MCP discovery requests (initialize, tools/list, prompts/list, resources/list, ping) without a bearer token, so a gateway can enumerate the tool catalog before any user has authenticated. Non-discovery requests (e.g. tools/call) still require a token. Off by default."
|
|
53
56
|
).addOption(
|
|
54
57
|
// DEPRECATED: kept only so existing deployments that set --base-url or
|
|
55
58
|
// MS365_MCP_BASE_URL do not crash at startup. Use --public-url /
|
|
@@ -155,6 +158,9 @@ function parseArgs() {
|
|
|
155
158
|
if (process.env.MS365_MCP_TRUST_PROXY_AUTH === "true" || process.env.MS365_MCP_TRUST_PROXY_AUTH === "1") {
|
|
156
159
|
options.trustProxyAuth = true;
|
|
157
160
|
}
|
|
161
|
+
if (process.env.MS365_MCP_ALLOW_UNAUTHENTICATED_DISCOVERY === "true" || process.env.MS365_MCP_ALLOW_UNAUTHENTICATED_DISCOVERY === "1") {
|
|
162
|
+
options.allowUnauthenticatedDiscovery = true;
|
|
163
|
+
}
|
|
158
164
|
if (options.cloud) {
|
|
159
165
|
process.env.MS365_MCP_CLOUD_TYPE = options.cloud;
|
|
160
166
|
}
|
package/dist/graph-tools.js
CHANGED
|
@@ -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) {
|
|
@@ -17,6 +17,22 @@ function isJwtExpired(token) {
|
|
|
17
17
|
return false;
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
+
const DISCOVERY_METHODS = /* @__PURE__ */ new Set([
|
|
21
|
+
"initialize",
|
|
22
|
+
"notifications/initialized",
|
|
23
|
+
"tools/list",
|
|
24
|
+
"prompts/list",
|
|
25
|
+
"resources/list",
|
|
26
|
+
"ping"
|
|
27
|
+
]);
|
|
28
|
+
function isDiscoveryRequest(req) {
|
|
29
|
+
if (req.method !== "POST" || !req.body) return false;
|
|
30
|
+
const body = req.body;
|
|
31
|
+
if (Array.isArray(body)) {
|
|
32
|
+
return body.every((item) => DISCOVERY_METHODS.has(item?.method));
|
|
33
|
+
}
|
|
34
|
+
return DISCOVERY_METHODS.has(body?.method);
|
|
35
|
+
}
|
|
20
36
|
const microsoftBearerTokenAuthMiddleware = (opts = {}) => (req, res, next) => {
|
|
21
37
|
if (opts.trustProxyAuth) {
|
|
22
38
|
next();
|
|
@@ -24,6 +40,10 @@ const microsoftBearerTokenAuthMiddleware = (opts = {}) => (req, res, next) => {
|
|
|
24
40
|
}
|
|
25
41
|
const authHeader = req.headers.authorization;
|
|
26
42
|
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
43
|
+
if (opts.allowUnauthenticatedDiscovery && isDiscoveryRequest(req)) {
|
|
44
|
+
next();
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
27
47
|
res.status(401).set(
|
|
28
48
|
"WWW-Authenticate",
|
|
29
49
|
buildWwwAuthenticate(req, "invalid_token", "Missing or malformed Authorization header")
|
package/dist/server.js
CHANGED
|
@@ -469,7 +469,8 @@ class MicrosoftGraphServer {
|
|
|
469
469
|
})
|
|
470
470
|
);
|
|
471
471
|
const mcpAuth = microsoftBearerTokenAuthMiddleware({
|
|
472
|
-
trustProxyAuth: this.options.trustProxyAuth
|
|
472
|
+
trustProxyAuth: this.options.trustProxyAuth,
|
|
473
|
+
allowUnauthenticatedDiscovery: this.options.allowUnauthenticatedDiscovery
|
|
473
474
|
});
|
|
474
475
|
app.get(
|
|
475
476
|
"/mcp",
|
package/docs/deployment.md
CHANGED
|
@@ -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.
|
|
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",
|