@softeria/ms-365-mcp-server 0.159.1 → 0.160.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
@@ -776,6 +776,28 @@ credential.
776
776
  be minted is one `download-bytes` would fetch for the same caller on the same account. The
777
777
  ticket only moves those bytes out of the context window and into a direct transfer.
778
778
 
779
+ ### Uploading attachments the same way
780
+
781
+ The same ticket store works in the other direction. `get-upload-url` mints a URL for
782
+ attaching a file to a draft message or an event, so the bytes go straight from the caller
783
+ to Graph instead of passing as base64 `contentBytes` through the agent context:
784
+
785
+ ```
786
+ PUT /attachment?t=<ticket>&dgk=<key-id>&dgx=<expiry>&dgs=<signature>
787
+ Content-Length: <file size>
788
+
789
+ <raw file bytes>
790
+ ```
791
+
792
+ `target` is the item's attachments collection (`/me/messages/{id}/attachments` for a draft,
793
+ `/me/events/{id}/attachments` for an event); `name` and `contentType` are recorded on the
794
+ attachment. Files under 3 MB are attached inline; larger ones, up to Graph's 150 MB, stream
795
+ through an upload session in 3.75 MiB chunks, so the server never holds more than one chunk.
796
+ `Content-Length` is required. The ticket is single-use and burnt on first presentation
797
+ whatever the outcome, and the upload is attached as the identity that minted it, exactly as
798
+ downloads are fetched. An upload ticket presented to `GET` (or a download ticket to `PUT`)
799
+ is refused with the same 404 as any other bad ticket.
800
+
779
801
  ### Configuration
780
802
 
781
803
  ```
@@ -61,7 +61,7 @@ function createAttachmentHandler(deps) {
61
61
  return;
62
62
  }
63
63
  const ticket = deps.store.redeem(raw);
64
- if (!ticket) {
64
+ if (!ticket || ticket.purpose !== "download") {
65
65
  refuse(res);
66
66
  return;
67
67
  }
@@ -104,7 +104,146 @@ function createAttachmentHandler(deps) {
104
104
  }
105
105
  };
106
106
  }
107
+ const MAX_UPLOAD_BYTES = 150 * 1024 * 1024;
108
+ const INLINE_UPLOAD_LIMIT = 3 * 1024 * 1024;
109
+ const UPLOAD_CHUNK_BYTES = 320 * 1024 * 12;
110
+ async function putChunk(uploadUrl, chunk, start, total) {
111
+ const end = start + chunk.length - 1;
112
+ const response = await fetch(uploadUrl, {
113
+ method: "PUT",
114
+ headers: {
115
+ "Content-Type": "application/octet-stream",
116
+ "Content-Length": String(chunk.length),
117
+ "Content-Range": `bytes ${start}-${end}/${total}`
118
+ },
119
+ // A standalone ArrayBuffer: fetch's body typing wants a BufferSource, and a
120
+ // Buffer view over a pooled slab is neither the right type nor the right bytes.
121
+ body: chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength)
122
+ });
123
+ if (![200, 201, 202].includes(response.status)) {
124
+ throw new Error(`Upload session chunk ${start}-${end} answered HTTP ${response.status}`);
125
+ }
126
+ }
127
+ function createAttachmentUploadHandler(deps) {
128
+ return async (req, res) => {
129
+ if (req.method !== "PUT") {
130
+ res.setHeader("allow", "GET, PUT");
131
+ res.status(405).type("text/plain").send("Method not allowed");
132
+ return;
133
+ }
134
+ const raw = req.query[TICKET_PARAM];
135
+ if (typeof raw !== "string" || raw.length === 0) {
136
+ refuse(res);
137
+ return;
138
+ }
139
+ const ticket = deps.store.redeem(raw);
140
+ if (!ticket || ticket.purpose !== "upload") {
141
+ refuse(res);
142
+ return;
143
+ }
144
+ if (!isPlainGraphPath(ticket.target)) {
145
+ logger.error("Attachment upload refused: ticket target is not a plain Graph path");
146
+ refuse(res);
147
+ return;
148
+ }
149
+ const declared = Number(req.headers["content-length"]);
150
+ if (!Number.isInteger(declared) || declared < 0) {
151
+ res.status(411).type("text/plain").send("Content-Length required");
152
+ return;
153
+ }
154
+ if (declared === 0) {
155
+ res.status(400).type("text/plain").send("Empty upload");
156
+ return;
157
+ }
158
+ if (declared > MAX_UPLOAD_BYTES) {
159
+ res.status(413).type("text/plain").send(`Upload exceeds ${MAX_UPLOAD_BYTES} bytes`);
160
+ return;
161
+ }
162
+ const graphClient = deps.getGraphClient();
163
+ if (!graphClient) {
164
+ logger.error("Attachment upload failed: Graph client is not initialised");
165
+ res.status(503).type("text/plain").send("Service unavailable");
166
+ return;
167
+ }
168
+ let accessToken;
169
+ try {
170
+ accessToken = ticket.kind === "request-token" ? ticket.accessToken : await deps.authManager.getTokenForAccount(ticket.accountName);
171
+ if (!accessToken) throw new Error("No access token for this ticket");
172
+ } catch (error) {
173
+ logger.error(`Attachment upload failed for ${ticket.target}: ${error.message}`);
174
+ res.status(502).type("text/plain").send("Upstream upload failed");
175
+ return;
176
+ }
177
+ res.setHeader("cache-control", "no-store");
178
+ res.setHeader("x-content-type-options", "nosniff");
179
+ try {
180
+ if (declared < INLINE_UPLOAD_LIMIT) {
181
+ const chunks = [];
182
+ let received = 0;
183
+ for await (const chunk of req) {
184
+ received += chunk.length;
185
+ if (received > declared) throw new Error("Body exceeds Content-Length");
186
+ chunks.push(chunk);
187
+ }
188
+ if (received !== declared) throw new Error("Body shorter than Content-Length");
189
+ const created = await graphClient.makeRequest(ticket.target, {
190
+ method: "POST",
191
+ accessToken,
192
+ body: JSON.stringify({
193
+ "@odata.type": "#microsoft.graph.fileAttachment",
194
+ name: ticket.name,
195
+ contentType: ticket.contentType,
196
+ contentBytes: Buffer.concat(chunks).toString("base64")
197
+ })
198
+ });
199
+ res.status(201).json({ ok: true, name: ticket.name, size: declared, id: created?.id });
200
+ return;
201
+ }
202
+ const session = await graphClient.makeRequest(`${ticket.target}/createUploadSession`, {
203
+ method: "POST",
204
+ accessToken,
205
+ body: JSON.stringify({
206
+ AttachmentItem: {
207
+ attachmentType: "file",
208
+ name: ticket.name,
209
+ size: declared,
210
+ contentType: ticket.contentType
211
+ }
212
+ })
213
+ });
214
+ if (!session?.uploadUrl) throw new Error("Upload session returned no uploadUrl");
215
+ let pending = [];
216
+ let pendingBytes = 0;
217
+ let offset = 0;
218
+ for await (const piece of req) {
219
+ pending.push(piece);
220
+ pendingBytes += piece.length;
221
+ if (offset + pendingBytes > declared) throw new Error("Body exceeds Content-Length");
222
+ while (pendingBytes >= UPLOAD_CHUNK_BYTES) {
223
+ const joined = Buffer.concat(pending);
224
+ const chunk = joined.subarray(0, UPLOAD_CHUNK_BYTES);
225
+ await putChunk(session.uploadUrl, chunk, offset, declared);
226
+ offset += chunk.length;
227
+ const rest = joined.subarray(UPLOAD_CHUNK_BYTES);
228
+ pending = rest.length ? [Buffer.from(rest)] : [];
229
+ pendingBytes = rest.length;
230
+ }
231
+ }
232
+ if (pendingBytes > 0) {
233
+ await putChunk(session.uploadUrl, Buffer.concat(pending), offset, declared);
234
+ offset += pendingBytes;
235
+ }
236
+ if (offset !== declared) throw new Error("Body shorter than Content-Length");
237
+ res.status(201).json({ ok: true, name: ticket.name, size: declared });
238
+ } catch (error) {
239
+ logger.error(`Attachment upload failed for ${ticket.target}: ${error.message}`);
240
+ if (!res.headersSent) res.status(502).type("text/plain").send("Upstream upload failed");
241
+ }
242
+ };
243
+ }
107
244
  export {
245
+ MAX_UPLOAD_BYTES,
108
246
  createAttachmentHandler,
247
+ createAttachmentUploadHandler,
109
248
  forceAttachment
110
249
  };
@@ -15,6 +15,7 @@ import {
15
15
  import { api as betaApi } from "./generated/client-beta.js";
16
16
  import { api } from "./generated/client.js";
17
17
  import { getAttachmentMinting } from "./lib/attachment-minting.js";
18
+ import { MAX_UPLOAD_BYTES } from "./attachment-route.js";
18
19
  import {
19
20
  buildAttachmentUrl,
20
21
  isPlainGraphPath,
@@ -545,6 +546,64 @@ async function checkAccountParamInBearerMode(accountParam, authManager) {
545
546
  if (bearerIdentity && bearerIdentity.toLowerCase() === accountParam.toLowerCase()) return null;
546
547
  return `The 'account' parameter is not supported in HTTP/OAuth mode: every request uses the identity of the connecting client's bearer token` + (bearerIdentity ? ` ('${bearerIdentity}')` : "") + `, so account switching is not possible. To act as '${accountParam}', reconnect the MCP client authenticated as that account, or run the server in stdio mode (or HTTP with --trust-proxy-auth) where cached accounts are available.`;
547
548
  }
549
+ const UPLOAD_TARGET = /^(\/me|\/users\/[^/]+)\/(messages|events)\/[^/]+\/attachments$/;
550
+ const UPLOAD_CONTENT_TYPE = /^[\w.+-]+\/[\w.+-]+$/;
551
+ function uploadError(message) {
552
+ return { content: [{ type: "text", text: JSON.stringify({ error: message }) }], isError: true };
553
+ }
554
+ async function mintUploadUrl(target, name, contentType, accountParam, authManager) {
555
+ const minting = getAttachmentMinting();
556
+ if (!minting) {
557
+ return uploadError(
558
+ "This server is not running with --enable-attachment-urls, so no upload URL can be minted. Attach small files with add-mail-attachment (base64 contentBytes)."
559
+ );
560
+ }
561
+ if (!UPLOAD_TARGET.test(target) || !isPlainGraphPath(target)) {
562
+ return uploadError(
563
+ "target must be the attachments collection of a draft message or an event: /me/messages/{message-id}/attachments or /me/events/{event-id}/attachments."
564
+ );
565
+ }
566
+ const cleanName = Array.from(name.trim()).filter((char) => {
567
+ const code = char.charCodeAt(0);
568
+ return code > 31 && code !== 127;
569
+ }).join("").slice(0, 255);
570
+ if (!cleanName) return uploadError("name is required.");
571
+ if (!UPLOAD_CONTENT_TYPE.test(contentType)) {
572
+ return uploadError("contentType must be a MIME type such as application/pdf.");
573
+ }
574
+ const identityFromRequest = Boolean(authManager?.isOAuthModeEnabled() || getRequestTokens());
575
+ const requestToken = identityFromRequest ? getRequestTokens()?.accessToken ?? await authManager?.getToken().catch(() => null) ?? void 0 : void 0;
576
+ if (identityFromRequest && !requestToken) {
577
+ return uploadError(
578
+ "No Graph access token is available for this request, so no upload URL can be minted."
579
+ );
580
+ }
581
+ const accountModeError = await checkAccountParamInBearerMode(accountParam, authManager);
582
+ if (accountModeError) return uploadError(accountModeError);
583
+ let ticket;
584
+ try {
585
+ const upload = { name: cleanName, contentType };
586
+ ticket = requestToken ? minting.store.mintUploadWithToken(target, requestToken, upload) : minting.store.mintUpload(target, accountParam, upload);
587
+ } catch (error) {
588
+ if (error instanceof TicketStoreFullError) return uploadError(error.message);
589
+ throw error;
590
+ }
591
+ return {
592
+ content: [
593
+ {
594
+ type: "text",
595
+ text: JSON.stringify({
596
+ uploadUrl: buildAttachmentUrl(minting.config, ticket.id),
597
+ method: "PUT",
598
+ expiresAt: new Date(ticket.expiresAtMs).toISOString(),
599
+ singleUse: true,
600
+ maxBytes: MAX_UPLOAD_BYTES,
601
+ note: 'PUT the raw file bytes with a Content-Length (e.g. curl -T <file> "<uploadUrl>"). Served by this server; valid for one upload until it expires.'
602
+ })
603
+ }
604
+ ]
605
+ };
606
+ }
548
607
  async function mintDownloadUrl(target, accountParam, authManager) {
549
608
  const minting = getAttachmentMinting();
550
609
  if (!minting) return null;
@@ -610,6 +669,50 @@ async function mintDownloadUrl(target, accountParam, authManager) {
610
669
  };
611
670
  }
612
671
  const UTILITY_TOOLS = [
672
+ {
673
+ name: "get-upload-url",
674
+ method: "PUT",
675
+ path: "tool:get-upload-url",
676
+ searchKeywords: "upload attachment attach file draft email attach file event large attachment send file out-of-band upload url",
677
+ description: 'Mint a short-lived, single-use URL that this server serves for uploading a file as an attachment to a draft message or an event, so the bytes go straight from the caller to Microsoft Graph instead of passing as base64 through the agent context. target is the attachments collection of the item: /me/messages/{message-id}/attachments (a draft; create it first) or /me/events/{event-id}/attachments. PUT the raw file bytes to the returned uploadUrl with a Content-Length (e.g. curl -T file "<uploadUrl>"); the response confirms the attachment. Files under 3 MB are attached directly, larger ones (up to 150 MB) through a Graph upload session. Requires --enable-attachment-urls. Returns { uploadUrl, method, expiresAt, singleUse, maxBytes }.',
678
+ readOnlyHint: false,
679
+ openWorldHint: true,
680
+ buildSchema: (ctx) => {
681
+ const schema = {
682
+ target: z.string().describe(
683
+ "Attachments collection of a draft message or an event, e.g. /me/messages/{message-id}/attachments or /me/events/{event-id}/attachments."
684
+ ),
685
+ name: z.string().describe("Attachment file name as the recipient will see it, e.g. report.pdf"),
686
+ contentType: z.string().optional().describe(
687
+ "MIME type of the file, e.g. application/pdf. Defaults to application/octet-stream."
688
+ )
689
+ };
690
+ if (ctx.multiAccount) {
691
+ schema["account"] = z.string().optional().describe(
692
+ "Account to use when multiple Microsoft accounts are configured. Required when multiple accounts exist (see list-accounts)."
693
+ );
694
+ }
695
+ return schema;
696
+ },
697
+ execute: async (params, { authManager }) => {
698
+ const target = params.target;
699
+ const name = params.name;
700
+ if (typeof target !== "string" || target.length === 0) {
701
+ return uploadError("target is required and must be a non-empty string.");
702
+ }
703
+ if (typeof name !== "string" || name.length === 0) {
704
+ return uploadError("name is required and must be a non-empty string.");
705
+ }
706
+ const contentType = typeof params.contentType === "string" && params.contentType.length > 0 ? params.contentType : "application/octet-stream";
707
+ return mintUploadUrl(
708
+ target,
709
+ name,
710
+ contentType,
711
+ params.account,
712
+ authManager
713
+ );
714
+ }
715
+ },
613
716
  {
614
717
  name: "parse-teams-url",
615
718
  method: "POST",
@@ -53,14 +53,14 @@ class AttachmentTicketStore {
53
53
  if (ticket.expiresAtMs <= nowMs) this.tickets.delete(id);
54
54
  }
55
55
  }
56
- add(target, identity, nowMs) {
56
+ add(target, identity, nowMs, purpose = { purpose: "download" }) {
57
57
  this.sweep(nowMs);
58
58
  if (this.tickets.size >= MAX_LIVE_TICKETS) {
59
59
  throw new TicketStoreFullError(MAX_LIVE_TICKETS);
60
60
  }
61
61
  const id = randomBytes(TICKET_BYTES).toString("base64url");
62
62
  const expiresAtMs = nowMs + this.ttlSeconds * 1e3;
63
- this.tickets.set(id, { ...identity, target, expiresAtMs });
63
+ this.tickets.set(id, { ...identity, ...purpose, target, expiresAtMs });
64
64
  return { id, expiresAtMs };
65
65
  }
66
66
  /** Mint a ticket redeemed with this server's own token for `accountName`. */
@@ -72,6 +72,21 @@ class AttachmentTicketStore {
72
72
  if (!accessToken) throw new Error("A request-token ticket needs a token");
73
73
  return this.add(target, { kind: "request-token", accessToken }, nowMs);
74
74
  }
75
+ /** Mint an upload ticket attached as this server's own token for `accountName`. */
76
+ mintUpload(target, accountName, upload, nowMs = Date.now()) {
77
+ return this.add(target, { kind: "server-account", accountName }, nowMs, {
78
+ purpose: "upload",
79
+ ...upload
80
+ });
81
+ }
82
+ /** Mint an upload ticket attached with `accessToken` and nothing else. */
83
+ mintUploadWithToken(target, accessToken, upload, nowMs = Date.now()) {
84
+ if (!accessToken) throw new Error("A request-token ticket needs a token");
85
+ return this.add(target, { kind: "request-token", accessToken }, nowMs, {
86
+ purpose: "upload",
87
+ ...upload
88
+ });
89
+ }
75
90
  /**
76
91
  * Return the ticket and burn it, or undefined.
77
92
  *
package/dist/server.js CHANGED
@@ -8,7 +8,7 @@ import rateLimit from "express-rate-limit";
8
8
  import helmet from "helmet";
9
9
  import crypto from "node:crypto";
10
10
  import { isIP, isIPv6 } from "node:net";
11
- import { createAttachmentHandler } from "./attachment-route.js";
11
+ import { createAttachmentHandler, createAttachmentUploadHandler } from "./attachment-route.js";
12
12
  import { registerAuthTools } from "./auth-tools.js";
13
13
  import {
14
14
  buildScopesFromEndpoints,
@@ -722,14 +722,13 @@ class MicrosoftGraphServer {
722
722
  })
723
723
  );
724
724
  }
725
- attachmentApp.get(
726
- ATTACHMENT_ROUTE,
727
- createAttachmentHandler({
728
- store: ticketStore,
729
- getGraphClient: () => this.graphClient,
730
- authManager: this.authManager
731
- })
732
- );
725
+ const attachmentDeps = {
726
+ store: ticketStore,
727
+ getGraphClient: () => this.graphClient,
728
+ authManager: this.authManager
729
+ };
730
+ attachmentApp.get(ATTACHMENT_ROUTE, createAttachmentHandler(attachmentDeps));
731
+ attachmentApp.put(ATTACHMENT_ROUTE, createAttachmentUploadHandler(attachmentDeps));
733
732
  logger.info(
734
733
  ` - Attachment URLs: ${attachmentConfig.base}${ATTACHMENT_ROUTE} (ttl ${attachmentConfig.ttlSeconds}s, key id ${attachmentConfig.keyId})`
735
734
  );
@@ -62,6 +62,7 @@ const PRESET_META = {
62
62
  const UNIVERSAL_UTILITY_TOOLS = ["download-bytes", "download-bytes-to-file"];
63
63
  const SCOPED_UTILITY_TOOLS = {
64
64
  "get-download-url": ["files", "onedrive", "personal", "work", "search"],
65
+ "get-upload-url": ["mail", "calendar", "outlook", "personal", "work"],
65
66
  "parse-teams-url": ["teams", "teams-write", "work"]
66
67
  };
67
68
  for (const [tool, presets] of Object.entries(SCOPED_UTILITY_TOOLS)) {
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.159.1",
4
+ "version": "0.160.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",