@softeria/ms-365-mcp-server 0.165.0 → 0.166.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
@@ -735,6 +735,7 @@ Environment variables:
735
735
  - `MS365_MCP_BLOB_OUTPUT=true|1`: File download tools (`download-bytes`, `get-mail-message-mime`, `get-shared-mailbox-message-mime`, `get-mail-attachment-content`, `get-shared-mailbox-attachment-content`) answer a short JSON text block (`name`, `contentType`, `size`) and an MCP `EmbeddedResource` with `BlobResourceContents` (`uri`, `mimeType`, `blob`) instead of base64 JSON in text. The `uri`'s last segment is the optional `fileName` argument. Default: off. Alternative to the --blob-output flag.
736
736
  - `MS365_MCP_BLOB_MAX_BYTES=<n>`: With blob output on, the largest file a download tool reads (positive integer, default `5242880`). The server stops reading past it and answers an error naming the limit.
737
737
  - `MS365_MCP_BODY_FORMAT=html`: Return email bodies as HTML instead of plain text (default: text)
738
+ - `MS365_MCP_OBO_ALLOWED_TENANTS=<ids>` / `MS365_MCP_OBO_ALLOWED_USERS=<ids>`: With `--obo`, only callers whose token `tid` (tenant id) / `oid` (user object id) is listed may use the server; others get 403 before any On-Behalf-Of exchange. Comma or space separated; unset = everyone. The claims are read without verifying the signature, which Entra verifies during the exchange, so a token that lies about them still gets no Graph token. A token Entra rejects at the exchange (bad signature, expired, wrong audience, consent needed) gets 401 with a `WWW-Authenticate` challenge so the client signs in again
738
739
  - `MS365_MCP_MESSAGE_SIGNOFF_PREFIX=<text>`: Signoff prepended to outgoing messages so recipients can tell they were agent-sent, e.g. `🤖`. Default: none. CLI equivalent: `--message-signoff-prefix <text>` (see Message Signoff below)
739
740
  - `MS365_MCP_MESSAGE_SIGNOFF_SUFFIX=<text>`: Signoff appended to outgoing messages. Default: none. CLI equivalent: `--message-signoff-suffix <text>`. `--no-message-signoff` disables both (see Message Signoff below)
740
741
  - `MS365_MCP_RATE_LIMIT_DISABLED=true|1`: Disable per-IP rate limiting in HTTP mode (default: enabled — 30 req/min on `/authorize`, `/token`, `/register`; 120 req/min on `/mcp`)
@@ -209,6 +209,7 @@ async function refreshAccessToken(refreshToken, clientId, clientSecret, tenantId
209
209
  }
210
210
  export {
211
211
  OAuthUpstreamError,
212
+ buildWwwAuthenticate,
212
213
  exchangeCodeForToken,
213
214
  microsoftBearerTokenAuthMiddleware,
214
215
  refreshAccessToken,
@@ -0,0 +1,50 @@
1
+ const splitList = (raw) => (raw ?? "").split(/[\s,]+/).map((s) => s.trim().toLowerCase()).filter(Boolean);
2
+ function readOboAllowlist(env = process.env) {
3
+ return {
4
+ tenants: new Set(splitList(env.MS365_MCP_OBO_ALLOWED_TENANTS)),
5
+ users: new Set(splitList(env.MS365_MCP_OBO_ALLOWED_USERS))
6
+ };
7
+ }
8
+ function readClaims(token) {
9
+ const parts = token.split(".");
10
+ if (parts.length !== 3) return void 0;
11
+ try {
12
+ const claims = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf-8"));
13
+ return claims && typeof claims === "object" ? claims : void 0;
14
+ } catch {
15
+ return void 0;
16
+ }
17
+ }
18
+ function oboAllowlistRefusal(token, allow) {
19
+ if (allow.tenants.size === 0 && allow.users.size === 0) return void 0;
20
+ const claims = readClaims(token);
21
+ if (!claims) return "The access token is not a JWT, so its tenant and user cannot be checked.";
22
+ const tid = typeof claims.tid === "string" ? claims.tid.toLowerCase() : "";
23
+ const oid = typeof claims.oid === "string" ? claims.oid.toLowerCase() : "";
24
+ if (allow.tenants.size > 0 && !allow.tenants.has(tid)) {
25
+ return "The caller's tenant is not allowed on this server.";
26
+ }
27
+ if (allow.users.size > 0 && !allow.users.has(oid)) {
28
+ return "The caller is not allowed on this server.";
29
+ }
30
+ return void 0;
31
+ }
32
+ const ASSERTION_ERROR_CODES = /* @__PURE__ */ new Set([
33
+ "invalid_grant",
34
+ "interaction_required",
35
+ "consent_required",
36
+ "login_required"
37
+ ]);
38
+ const ASSERTION_AADSTS = /\bAADSTS(50013|500133|50027|50173|700082|65001|50076|50079)\b/;
39
+ function isRejectedAssertion(error) {
40
+ if (!error || typeof error !== "object") return false;
41
+ const e = error;
42
+ if (typeof e.errorCode === "string" && ASSERTION_ERROR_CODES.has(e.errorCode)) return true;
43
+ const text = `${typeof e.errorMessage === "string" ? e.errorMessage : ""} ${typeof e.message === "string" ? e.message : ""}`;
44
+ return ASSERTION_AADSTS.test(text);
45
+ }
46
+ export {
47
+ isRejectedAssertion,
48
+ oboAllowlistRefusal,
49
+ readOboAllowlist
50
+ };
package/dist/server.js CHANGED
@@ -25,6 +25,7 @@ import { ATTACHMENT_ROUTE, loadAttachmentUrlConfig } from "./lib/attachment-url-
25
25
  import {
26
26
  exchangeCodeForToken,
27
27
  microsoftBearerTokenAuthMiddleware,
28
+ buildWwwAuthenticate,
28
29
  OAuthUpstreamError,
29
30
  refreshAccessToken,
30
31
  toOAuthErrorResponse
@@ -35,6 +36,7 @@ import { buildMcpServerInstructions } from "./mcp-instructions.js";
35
36
  import { installToolSchemaRefNormalization } from "./normalize-tool-schema.js";
36
37
  import { MicrosoftOAuthProvider } from "./oauth-provider.js";
37
38
  import OboClient from "./obo-client.js";
39
+ import { isRejectedAssertion, oboAllowlistRefusal, readOboAllowlist } from "./lib/obo-access.js";
38
40
  import { requestContext } from "./request-context.js";
39
41
  import { getSecrets } from "./secrets.js";
40
42
  const LOOPBACK_HOSTNAMES = ["localhost", "127.0.0.1", "[::1]"];
@@ -636,6 +638,12 @@ class MicrosoftGraphServer {
636
638
  issuerUrl: new URL(publicBase ?? `http://localhost:${port}`)
637
639
  })
638
640
  );
641
+ const oboAllowlist = this.oboClient ? readOboAllowlist() : null;
642
+ if (oboAllowlist && (oboAllowlist.tenants.size > 0 || oboAllowlist.users.size > 0)) {
643
+ logger.info(
644
+ `OBO allowlist: ${oboAllowlist.tenants.size} tenant(s), ${oboAllowlist.users.size} user(s)`
645
+ );
646
+ }
639
647
  const mcpAuth = microsoftBearerTokenAuthMiddleware({
640
648
  trustProxyAuth: this.options.trustProxyAuth,
641
649
  allowUnauthenticatedDiscovery: this.options.allowUnauthenticatedDiscovery,
@@ -674,7 +682,23 @@ class MicrosoftGraphServer {
674
682
  if (req.microsoftAuth) {
675
683
  let accessToken = req.microsoftAuth.accessToken;
676
684
  if (this.oboClient) {
677
- accessToken = await this.oboClient.exchangeToken(accessToken);
685
+ const refusal = oboAllowlist && oboAllowlistRefusal(accessToken, oboAllowlist);
686
+ if (refusal) {
687
+ logger.warn(`OBO caller refused by the allowlist: ${refusal}`);
688
+ res.status(403).json({ error: "access_denied", error_description: refusal });
689
+ return;
690
+ }
691
+ try {
692
+ accessToken = await this.oboClient.exchangeToken(accessToken);
693
+ } catch (error) {
694
+ if (!isRejectedAssertion(error)) throw error;
695
+ const description = "The access token was rejected; sign in again";
696
+ res.status(401).set(
697
+ "WWW-Authenticate",
698
+ buildWwwAuthenticate(req, "invalid_token", description, publicBase)
699
+ ).json({ error: "invalid_token", error_description: description });
700
+ return;
701
+ }
678
702
  }
679
703
  await requestContext.run({ accessToken }, handler);
680
704
  } else {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@softeria/ms-365-mcp-server",
3
3
  "mcpName": "io.github.Softeria/ms-365-mcp-server",
4
- "version": "0.165.0",
4
+ "version": "0.166.0",
5
5
  "description": " A Model Context Protocol (MCP) server for interacting with Microsoft 365 and Office services through the Graph API",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",