@softeria/ms-365-mcp-server 0.156.2 → 0.157.1

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
@@ -691,8 +691,8 @@ GET /attachment?t=<ticket>&dgk=<key-id>&dgx=<expiry>&dgs=<signature>
691
691
  ```
692
692
 
693
693
  The ticket is 32 bytes of CSPRNG output, **single-use**, memory-only, and expires after
694
- `MS365_MCP_ATTACHMENT_URL_TTL_S` seconds. Redeeming it streams the Graph bytes with this
695
- server's own token; the fetcher sends no Authorization header and holds no Microsoft
694
+ `MS365_MCP_ATTACHMENT_URL_TTL_S` seconds. Redeeming it streams the Graph bytes as the
695
+ identity that minted it; the fetcher sends no Authorization header and holds no Microsoft
696
696
  credential.
697
697
 
698
698
  **This grants no authority the calling agent did not already have.** Every target that can
@@ -829,11 +829,26 @@ exists rather than being assumed.
829
829
  The ticket travels in the **query, not the path**, because the verifying sidecar keeps a
830
830
  fetched URL's path in its error messages and strips the query.
831
831
 
832
- ### Not available in OAuth/OBO mode
832
+ ### Whose identity the bytes are read as
833
833
 
834
- Identity there arrives per request on the caller's `Authorization` header, and a ticket is
835
- redeemed later by a fetcher that sends none. Minting refuses with an explanation rather
836
- than producing a URL that always fails.
834
+ Under `--trust-proxy-auth` the server reads with its own cached account, and redemption
835
+ looks that account up again.
836
+
837
+ In plain `--http` and `--obo`, identity arrives per request on the caller's
838
+ `Authorization` header, and a ticket is redeemed later by a fetcher that sends none. So
839
+ the ticket keeps the Graph token the minting request used (the exchanged one, under
840
+ `--obo`) and redemption reads with that token and nothing else. It stays in server memory
841
+ and is never part of the URL.
842
+
843
+ With `MS365_MCP_OAUTH_TOKEN` set, the ticket keeps that token instead, `--trust-proxy-auth`
844
+ or not.
845
+
846
+ A token kept this way cannot be refreshed. If it expires before the URL is fetched,
847
+ redemption answers 502. The agent can mint again once its client has a fresh token; an
848
+ expired `MS365_MCP_OAUTH_TOKEN` has to be replaced by the operator.
849
+
850
+ Tickets live in the memory of the process that minted them, so a URL has to be redeemed
851
+ on the same instance. Behind a load balancer that means one replica or sticky routing.
837
852
 
838
853
  ## Token Storage
839
854
 
@@ -78,10 +78,8 @@ function createAttachmentHandler(deps) {
78
78
  }
79
79
  let stream;
80
80
  try {
81
- let accessToken;
82
- if (!deps.authManager.isOAuthModeEnabled()) {
83
- accessToken = await deps.authManager.getTokenForAccount(ticket.accountName);
84
- }
81
+ const accessToken = ticket.kind === "request-token" ? ticket.accessToken : await deps.authManager.getTokenForAccount(ticket.accountName);
82
+ if (!accessToken) throw new Error("No access token for this ticket");
85
83
  stream = await graphClient.downloadStream(ticket.target, { accessToken });
86
84
  } catch (error) {
87
85
  logger.error(
@@ -529,13 +529,15 @@ async function checkAccountParamInBearerMode(accountParam, authManager) {
529
529
  async function mintDownloadUrl(target, accountParam, authManager) {
530
530
  const minting = getAttachmentMinting();
531
531
  if (!minting) return null;
532
- if (authManager?.isOAuthModeEnabled() || getRequestTokens()) {
532
+ const identityFromRequest = Boolean(authManager?.isOAuthModeEnabled() || getRequestTokens());
533
+ const requestToken = identityFromRequest ? getRequestTokens()?.accessToken ?? await authManager?.getToken().catch(() => null) ?? void 0 : void 0;
534
+ if (identityFromRequest && !requestToken) {
533
535
  return {
534
536
  content: [
535
537
  {
536
538
  type: "text",
537
539
  text: JSON.stringify({
538
- error: "Server-minted download URLs are unavailable when Graph identity comes from the request (OAuth, OBO, or bearer mode): the URL is redeemed later without an Authorization header, so the bytes would be fetched as a different identity than the one that asked for them. Use download-bytes."
540
+ error: "No Graph access token is available for this request, so no download URL can be minted. Use download-bytes."
539
541
  })
540
542
  }
541
543
  ],
@@ -564,7 +566,7 @@ async function mintDownloadUrl(target, accountParam, authManager) {
564
566
  }
565
567
  let ticket;
566
568
  try {
567
- ticket = minting.store.mint(target, accountParam);
569
+ ticket = requestToken ? minting.store.mintWithToken(target, requestToken) : minting.store.mint(target, accountParam);
568
570
  } catch (error) {
569
571
  if (error instanceof TicketStoreFullError) {
570
572
  return {
@@ -846,7 +848,7 @@ const UTILITY_TOOLS = [
846
848
  buildSchema: (ctx) => {
847
849
  const schema = {
848
850
  target: z.string().describe(
849
- 'Relative Microsoft Graph path starting with "/". Either a driveItem content path or the item path itself, e.g. /drives/{drive-id}/items/{driveItem-id}/content, /me/drive/items/{driveItem-id}/content, or /sites/{site-id}/drive/items/{driveItem-id}. A trailing /content is optional and is stripped automatically for drive items. Mail attachment $value paths and meeting recordings are not supported (Graph exposes no pre-authenticated URL for them).'
851
+ 'Relative Microsoft Graph path starting with "/". Either a driveItem content path or the item path itself, e.g. /drives/{drive-id}/items/{driveItem-id}/content, /me/drive/items/{driveItem-id}/content, or /sites/{site-id}/drive/items/{driveItem-id}. A trailing /content is optional and is stripped automatically for drive items. Mail attachment $value paths and meeting recordings are accepted on a server running with --enable-attachment-urls.'
850
852
  )
851
853
  };
852
854
  if (ctx.multiAccount) {
@@ -44,16 +44,25 @@ class AttachmentTicketStore {
44
44
  if (ticket.expiresAtMs <= nowMs) this.tickets.delete(id);
45
45
  }
46
46
  }
47
- mint(target, accountName, nowMs = Date.now()) {
47
+ add(target, identity, nowMs) {
48
48
  this.sweep(nowMs);
49
49
  if (this.tickets.size >= MAX_LIVE_TICKETS) {
50
50
  throw new TicketStoreFullError(MAX_LIVE_TICKETS);
51
51
  }
52
52
  const id = randomBytes(TICKET_BYTES).toString("base64url");
53
53
  const expiresAtMs = nowMs + this.ttlSeconds * 1e3;
54
- this.tickets.set(id, { target, accountName, expiresAtMs });
54
+ this.tickets.set(id, { ...identity, target, expiresAtMs });
55
55
  return { id, expiresAtMs };
56
56
  }
57
+ /** Mint a ticket redeemed with this server's own token for `accountName`. */
58
+ mint(target, accountName, nowMs = Date.now()) {
59
+ return this.add(target, { kind: "server-account", accountName }, nowMs);
60
+ }
61
+ /** Mint a ticket redeemed with `accessToken` and nothing else. */
62
+ mintWithToken(target, accessToken, nowMs = Date.now()) {
63
+ if (!accessToken) throw new Error("A request-token ticket needs a token");
64
+ return this.add(target, { kind: "request-token", accessToken }, nowMs);
65
+ }
57
66
  /**
58
67
  * Return the ticket and burn it, or undefined.
59
68
  *
@@ -169,7 +169,7 @@ function unwrapToken(result, failureMessage) {
169
169
  logger.error(`${failureMessage}: ${result.raw}`);
170
170
  throw new Error(`${failureMessage}: ${result.raw}`);
171
171
  }
172
- async function exchangeCodeForToken(code, redirectUri, clientId, clientSecret, tenantId = "common", codeVerifier, cloudType = "global") {
172
+ async function exchangeCodeForToken(code, redirectUri, clientId, clientSecret, tenantId = "common", codeVerifier, cloudType = "global", scope) {
173
173
  const cloudEndpoints = getCloudEndpoints(cloudType);
174
174
  const params = new URLSearchParams({
175
175
  grant_type: "authorization_code",
@@ -180,6 +180,9 @@ async function exchangeCodeForToken(code, redirectUri, clientId, clientSecret, t
180
180
  if (codeVerifier) {
181
181
  params.append("code_verifier", codeVerifier);
182
182
  }
183
+ if (scope) {
184
+ params.append("scope", scope);
185
+ }
183
186
  return requestToken(
184
187
  `${cloudEndpoints.authority}/${tenantId}/oauth2/v2.0/token`,
185
188
  params,
@@ -187,13 +190,16 @@ async function exchangeCodeForToken(code, redirectUri, clientId, clientSecret, t
187
190
  "Failed to exchange code for token"
188
191
  );
189
192
  }
190
- async function refreshAccessToken(refreshToken, clientId, clientSecret, tenantId = "common", cloudType = "global") {
193
+ async function refreshAccessToken(refreshToken, clientId, clientSecret, tenantId = "common", cloudType = "global", scope) {
191
194
  const cloudEndpoints = getCloudEndpoints(cloudType);
192
195
  const params = new URLSearchParams({
193
196
  grant_type: "refresh_token",
194
197
  refresh_token: refreshToken,
195
198
  client_id: clientId
196
199
  });
200
+ if (scope) {
201
+ params.append("scope", scope);
202
+ }
197
203
  return requestToken(
198
204
  `${cloudEndpoints.authority}/${tenantId}/oauth2/v2.0/token`,
199
205
  params,
@@ -6,7 +6,7 @@ function buildGeneralMcpInstructions(opts) {
6
6
  "When you need an organizational user or recipient address, resolve it with list-users (or another directory tool); do not invent SMTP addresses.",
7
7
  "Directory $search on collections such as /users or /groups requires ConsistencyLevel: eventual when the tool exposes that header.",
8
8
  "Teams chat and channel messages: prefer HTML contentType in the body; plain text is often mangled by Graph.",
9
- "Files / binary content: for large drive/SharePoint file content, prefer get-download-url to resolve a pre-authenticated URL for out-of-band download. Use download-bytes for authenticated byte reads such as mail attachments, profile photos, Teams hosted content, and meeting recordings. In stdio mode (or HTTP with --http-local-file-tools), download-bytes-to-file writes those same authenticated bytes straight to a local absolute path instead of returning base64 \u2014 the only out-of-band option for large mail attachments and meeting recordings, which get-download-url cannot handle. These tools take relative Microsoft Graph paths, not absolute URLs. For uploads, upload-file-content takes a base64 string body (Graph allows 250MB, but the whole string passes through the agent context and a truncated one is written without error); use create-upload-session for anything but small files."
9
+ "Files / binary content: for large drive/SharePoint file content, prefer get-download-url to resolve a pre-authenticated URL for out-of-band download. Use download-bytes for authenticated byte reads such as mail attachments, profile photos, Teams hosted content, and meeting recordings. In stdio mode (or HTTP with --http-local-file-tools), download-bytes-to-file writes those same authenticated bytes straight to a local absolute path instead of returning base64. For large mail attachments and meeting recordings that is one out-of-band option; get-download-url is the other, on a server running with --enable-attachment-urls. These tools take relative Microsoft Graph paths, not absolute URLs. For uploads, upload-file-content takes a base64 string body (Graph allows 250MB, but the whole string passes through the agent context and a truncated one is written without error); use create-upload-session for anything but small files."
10
10
  ];
11
11
  if (opts.readOnly) parts.push("This server is read-only; write operations are disabled.");
12
12
  if (opts.multiAccount)
package/dist/server.js CHANGED
@@ -217,6 +217,17 @@ class MicrosoftGraphServer {
217
217
  installToolSchemaRefNormalization(server);
218
218
  return server;
219
219
  }
220
+ /**
221
+ * Under --obo the token Entra issues to the MCP client is for this app
222
+ * itself (`<clientId>/access_as_user`). Personal Microsoft accounts refuse
223
+ * to redeem a code or refresh token for that audience unless the request
224
+ * names the scope (AADSTS70011), so both /token grants send it in OBO mode.
225
+ * Outside OBO the request is unchanged.
226
+ */
227
+ oboRedemptionScope() {
228
+ if (!this.options.obo || !this.secrets?.clientId) return void 0;
229
+ return `${this.secrets.clientId}/access_as_user offline_access`;
230
+ }
220
231
  async initialize(version) {
221
232
  this.secrets = await getSecrets();
222
233
  this.version = version;
@@ -582,7 +593,8 @@ class MicrosoftGraphServer {
582
593
  clientSecret,
583
594
  tenantId,
584
595
  matchedPkceEntry?.serverCodeVerifier || body.code_verifier,
585
- this.secrets.cloudType
596
+ this.secrets.cloudType,
597
+ this.oboRedemptionScope()
586
598
  );
587
599
  if (matchedPkceState && this.pkceStore.get(matchedPkceState) === matchedPkceEntry) {
588
600
  this.pkceStore.delete(matchedPkceState);
@@ -602,7 +614,8 @@ class MicrosoftGraphServer {
602
614
  clientId,
603
615
  clientSecret,
604
616
  tenantId,
605
- this.secrets.cloudType
617
+ this.secrets.cloudType,
618
+ this.oboRedemptionScope()
606
619
  );
607
620
  res.json(result);
608
621
  } else {
@@ -692,12 +705,6 @@ class MicrosoftGraphServer {
692
705
  }
693
706
  );
694
707
  const attachmentConfig = loadAttachmentUrlConfig(Boolean(this.options.enableAttachmentUrls));
695
- const mintingAlwaysRefused = this.authManager?.isOAuthModeEnabled() === true || !this.options.trustProxyAuth;
696
- if (attachmentConfig && mintingAlwaysRefused) {
697
- logger.warn(
698
- "--enable-attachment-urls is on, but this server takes its Graph identity from the request in plain --http mode, and minting is refused whenever it does (the URL is redeemed later with no Authorization header, so the bytes would be fetched as a different identity). get-download-url will keep answering with download-bytes. Server-minted URLs need --trust-proxy-auth, where the server uses its own token."
699
- );
700
- }
701
708
  let attachmentApp = null;
702
709
  if (attachmentConfig) {
703
710
  const ticketStore = new AttachmentTicketStore(attachmentConfig.ttlSeconds);
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.156.2",
4
+ "version": "0.157.1",
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",