@softeria/ms-365-mcp-server 0.150.3 → 0.152.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 +172 -0
- package/dist/__tests__/graph-client.test.js +46 -0
- package/dist/__tests__/graph-tools.test.js +166 -0
- package/dist/attachment-route.js +112 -0
- package/dist/cli.js +15 -0
- package/dist/graph-client.js +59 -0
- package/dist/graph-tools.js +101 -2
- package/dist/lib/attachment-minting.js +15 -0
- package/dist/lib/attachment-tickets.js +89 -0
- package/dist/lib/attachment-url-config.js +104 -0
- package/dist/lib/url-signing.js +81 -0
- package/dist/server.js +249 -17
- package/docs/deployment.md +1 -1
- package/package.json +1 -1
package/dist/graph-tools.js
CHANGED
|
@@ -4,6 +4,12 @@ import logger from "./logger.js";
|
|
|
4
4
|
import { auditLog, getUserIdentityForAudit } from "./audit-log.js";
|
|
5
5
|
import { isDestructiveOperation } from "./lib/destructive-ops.js";
|
|
6
6
|
import { describePathParam } from "./lib/path-params.js";
|
|
7
|
+
import { getAttachmentMinting } from "./lib/attachment-minting.js";
|
|
8
|
+
import {
|
|
9
|
+
buildAttachmentUrl,
|
|
10
|
+
isPlainGraphPath,
|
|
11
|
+
TicketStoreFullError
|
|
12
|
+
} from "./lib/attachment-tickets.js";
|
|
7
13
|
import {
|
|
8
14
|
anyFieldPresent,
|
|
9
15
|
isTransportEnvelope,
|
|
@@ -250,7 +256,13 @@ function graphResponseAuditFields(response) {
|
|
|
250
256
|
const graphBatchErrorCodeCounts = auditStringNumberMap(
|
|
251
257
|
response._meta?.graph_batch_error_code_counts
|
|
252
258
|
);
|
|
259
|
+
const resultCount = auditNonNegativeInteger(response._meta?.result_count);
|
|
260
|
+
const responseBytes = auditNonNegativeInteger(response._meta?.response_bytes);
|
|
261
|
+
const resultHasMore = typeof response._meta?.result_has_more === "boolean" ? response._meta.result_has_more : void 0;
|
|
253
262
|
return {
|
|
263
|
+
...resultCount !== void 0 ? { result_count: resultCount } : {},
|
|
264
|
+
...resultHasMore !== void 0 ? { result_has_more: resultHasMore } : {},
|
|
265
|
+
...responseBytes !== void 0 ? { response_bytes: responseBytes } : {},
|
|
254
266
|
...httpStatus !== void 0 ? { http_status: httpStatus } : {},
|
|
255
267
|
...errorCode !== void 0 ? { error_code: errorCode } : {},
|
|
256
268
|
...graphBatchSubrequestCount !== void 0 ? { graph_batch_subrequest_count: graphBatchSubrequestCount } : {},
|
|
@@ -475,6 +487,68 @@ async function checkAccountParamInBearerMode(accountParam, authManager) {
|
|
|
475
487
|
if (bearerIdentity && bearerIdentity.toLowerCase() === accountParam.toLowerCase()) return null;
|
|
476
488
|
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.`;
|
|
477
489
|
}
|
|
490
|
+
async function mintDownloadUrl(target, accountParam, authManager) {
|
|
491
|
+
const minting = getAttachmentMinting();
|
|
492
|
+
if (!minting) return null;
|
|
493
|
+
if (authManager?.isOAuthModeEnabled() || getRequestTokens()) {
|
|
494
|
+
return {
|
|
495
|
+
content: [
|
|
496
|
+
{
|
|
497
|
+
type: "text",
|
|
498
|
+
text: JSON.stringify({
|
|
499
|
+
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."
|
|
500
|
+
})
|
|
501
|
+
}
|
|
502
|
+
],
|
|
503
|
+
isError: true
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
const accountModeError = await checkAccountParamInBearerMode(accountParam, authManager);
|
|
507
|
+
if (accountModeError) {
|
|
508
|
+
return {
|
|
509
|
+
content: [{ type: "text", text: JSON.stringify({ error: accountModeError }) }],
|
|
510
|
+
isError: true
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
if (!isPlainGraphPath(target)) {
|
|
514
|
+
return {
|
|
515
|
+
content: [
|
|
516
|
+
{
|
|
517
|
+
type: "text",
|
|
518
|
+
text: JSON.stringify({
|
|
519
|
+
error: 'target must be a plain relative Graph path: no fragment, no query, no "." or ".." segments, and no percent-encoded separators.'
|
|
520
|
+
})
|
|
521
|
+
}
|
|
522
|
+
],
|
|
523
|
+
isError: true
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
let ticket;
|
|
527
|
+
try {
|
|
528
|
+
ticket = minting.store.mint(target, accountParam);
|
|
529
|
+
} catch (error) {
|
|
530
|
+
if (error instanceof TicketStoreFullError) {
|
|
531
|
+
return {
|
|
532
|
+
content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
|
|
533
|
+
isError: true
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
throw error;
|
|
537
|
+
}
|
|
538
|
+
return {
|
|
539
|
+
content: [
|
|
540
|
+
{
|
|
541
|
+
type: "text",
|
|
542
|
+
text: JSON.stringify({
|
|
543
|
+
downloadUrl: buildAttachmentUrl(minting.config, ticket.id),
|
|
544
|
+
expiresAt: new Date(ticket.expiresAtMs).toISOString(),
|
|
545
|
+
singleUse: true,
|
|
546
|
+
note: "Served by this server, not by Microsoft Graph. Valid for one fetch until it expires."
|
|
547
|
+
})
|
|
548
|
+
}
|
|
549
|
+
]
|
|
550
|
+
};
|
|
551
|
+
}
|
|
478
552
|
const UTILITY_TOOLS = [
|
|
479
553
|
{
|
|
480
554
|
name: "parse-teams-url",
|
|
@@ -702,7 +776,14 @@ const UTILITY_TOOLS = [
|
|
|
702
776
|
})
|
|
703
777
|
}
|
|
704
778
|
],
|
|
705
|
-
|
|
779
|
+
// response_bytes must describe the file written, not this receipt.
|
|
780
|
+
// Streaming to disk means the payload never appears in the response,
|
|
781
|
+
// so without this the most extraction-shaped tool in the server would
|
|
782
|
+
// audit a 250MB download at the size of an error message.
|
|
783
|
+
_meta: {
|
|
784
|
+
...result.httpStatus !== void 0 ? { http_status: result.httpStatus } : {},
|
|
785
|
+
...typeof result.contentLength === "number" ? { response_bytes: result.contentLength } : {}
|
|
786
|
+
}
|
|
706
787
|
};
|
|
707
788
|
} catch (error) {
|
|
708
789
|
const metadata = thrownErrorAuditFields(error);
|
|
@@ -719,7 +800,7 @@ const UTILITY_TOOLS = [
|
|
|
719
800
|
method: "GET",
|
|
720
801
|
path: "tool:get-download-url",
|
|
721
802
|
searchKeywords: "download file download drive file download onedrive file sharepoint file download large drive file large sharepoint file large file out-of-band download pre-authenticated url",
|
|
722
|
-
description: "Resolve a short-lived, pre-authenticated download URL for Microsoft Graph binary content that exposes one (drive/SharePoint file content). The returned URL streams the bytes with NO Authorization header, so the client can fetch it straight to disk (e.g. curl) without round-tripping base64 through the agent context. Prefer this over download-bytes for any file above a few KB or any bulk download. Returns { downloadUrl, name?, size?, contentType? }.
|
|
803
|
+
description: "Resolve a short-lived, pre-authenticated download URL for Microsoft Graph binary content that exposes one (drive/SharePoint file content). The returned URL streams the bytes with NO Authorization header, so the client can fetch it straight to disk (e.g. curl) without round-tripping base64 through the agent context. Prefer this over download-bytes for any file above a few KB or any bulk download. Returns { downloadUrl, name?, size?, contentType? }. Mail file attachments (/messages/{id}/attachments/{id}/$value), meeting recordings and other $value byte endpoints have no pre-authenticated URL from Graph itself, but call this tool for them anyway: a server running with --enable-attachment-urls mints its own single-use URL for them, and one without it answers with the reason and points at download-bytes.",
|
|
723
804
|
readOnlyHint: true,
|
|
724
805
|
openWorldHint: true,
|
|
725
806
|
buildSchema: (ctx) => {
|
|
@@ -778,6 +859,8 @@ const UTILITY_TOOLS = [
|
|
|
778
859
|
}
|
|
779
860
|
const pathPart = target.replace(/\/+$/, "");
|
|
780
861
|
if (/^(\/me|\/users\/[^/]+)\/messages\/[^/]+\/attachments\//.test(pathPart) || /^(\/me|\/users\/[^/]+)\/events\/[^/]+\/attachments\//.test(pathPart) || /^\/groups\/[^/]+\/messages\/[^/]+\/attachments\//.test(pathPart) || /^\/groups\/[^/]+\/events\/[^/]+\/attachments\//.test(pathPart)) {
|
|
862
|
+
const minted = await mintDownloadUrl(pathPart, accountParam, authManager);
|
|
863
|
+
if (minted) return minted;
|
|
781
864
|
return {
|
|
782
865
|
content: [
|
|
783
866
|
{
|
|
@@ -793,6 +876,8 @@ const UTILITY_TOOLS = [
|
|
|
793
876
|
if (/^(\/me|\/users\/[^/]+)\/onlineMeetings\/[^/]+\/recordings\/[^/]+(?:\/content)?$/.test(
|
|
794
877
|
pathPart
|
|
795
878
|
) || /^\/communications\/calls\/[^/]+\/recordings\/[^/]+(?:\/content)?$/.test(pathPart)) {
|
|
879
|
+
const minted = await mintDownloadUrl(pathPart, accountParam, authManager);
|
|
880
|
+
if (minted) return minted;
|
|
796
881
|
return {
|
|
797
882
|
content: [
|
|
798
883
|
{
|
|
@@ -806,6 +891,8 @@ const UTILITY_TOOLS = [
|
|
|
806
891
|
};
|
|
807
892
|
}
|
|
808
893
|
if (pathPart.endsWith("/$value")) {
|
|
894
|
+
const minted = await mintDownloadUrl(pathPart, accountParam, authManager);
|
|
895
|
+
if (minted) return minted;
|
|
809
896
|
return {
|
|
810
897
|
content: [
|
|
811
898
|
{
|
|
@@ -875,6 +962,8 @@ const UTILITY_TOOLS = [
|
|
|
875
962
|
}
|
|
876
963
|
const downloadUrl = item?.["@microsoft.graph.downloadUrl"];
|
|
877
964
|
if (!downloadUrl) {
|
|
965
|
+
const minted = await mintDownloadUrl(`${itemPath}/content`, accountParam, authManager);
|
|
966
|
+
if (minted) return minted;
|
|
878
967
|
return {
|
|
879
968
|
content: [
|
|
880
969
|
{
|
|
@@ -1300,6 +1389,7 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
|
|
|
1300
1389
|
let allItems = firstValue;
|
|
1301
1390
|
let nextLink = combinedResponse["@odata.nextLink"];
|
|
1302
1391
|
let pageCount = 1;
|
|
1392
|
+
let totalResponseBytes = response._meta?.response_bytes;
|
|
1303
1393
|
const maxPages = positiveIntFromEnv("MS365_MCP_MAX_PAGES", DEFAULT_MAX_PAGES);
|
|
1304
1394
|
const maxItems = positiveIntFromEnv("MS365_MCP_MAX_ITEMS", DEFAULT_MAX_ITEMS);
|
|
1305
1395
|
let deltaLink = combinedResponse["@odata.deltaLink"];
|
|
@@ -1320,6 +1410,9 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
|
|
|
1320
1410
|
allItems = allItems.concat(nextJsonResponse.value);
|
|
1321
1411
|
}
|
|
1322
1412
|
nextLink = nextJsonResponse["@odata.nextLink"];
|
|
1413
|
+
if (typeof totalResponseBytes === "number" && typeof nextResponse._meta?.response_bytes === "number") {
|
|
1414
|
+
totalResponseBytes += nextResponse._meta.response_bytes;
|
|
1415
|
+
}
|
|
1323
1416
|
if (nextJsonResponse["@odata.deltaLink"]) {
|
|
1324
1417
|
deltaLink = nextJsonResponse["@odata.deltaLink"];
|
|
1325
1418
|
}
|
|
@@ -1342,6 +1435,12 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
|
|
|
1342
1435
|
combinedResponse["@odata.count"] = allItems.length;
|
|
1343
1436
|
}
|
|
1344
1437
|
delete combinedResponse["@odata.nextLink"];
|
|
1438
|
+
response._meta = {
|
|
1439
|
+
...response._meta,
|
|
1440
|
+
result_count: allItems.length,
|
|
1441
|
+
result_has_more: Boolean(nextLink),
|
|
1442
|
+
...totalResponseBytes !== void 0 ? { response_bytes: totalResponseBytes } : {}
|
|
1443
|
+
};
|
|
1345
1444
|
if (deltaLink) {
|
|
1346
1445
|
combinedResponse["@odata.deltaLink"] = deltaLink;
|
|
1347
1446
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
let current = null;
|
|
2
|
+
function configureAttachmentMinting(minting) {
|
|
3
|
+
current = minting;
|
|
4
|
+
}
|
|
5
|
+
function getAttachmentMinting() {
|
|
6
|
+
return current;
|
|
7
|
+
}
|
|
8
|
+
function resetAttachmentMinting() {
|
|
9
|
+
current = null;
|
|
10
|
+
}
|
|
11
|
+
export {
|
|
12
|
+
configureAttachmentMinting,
|
|
13
|
+
getAttachmentMinting,
|
|
14
|
+
resetAttachmentMinting
|
|
15
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { ATTACHMENT_ROUTE } from "./attachment-url-config.js";
|
|
3
|
+
import { signUrl } from "./url-signing.js";
|
|
4
|
+
const TICKET_PARAM = "t";
|
|
5
|
+
function buildAttachmentUrl(config, ticketId, nowMs = Date.now()) {
|
|
6
|
+
const url = new URL(ATTACHMENT_ROUTE, config.base);
|
|
7
|
+
url.searchParams.set(TICKET_PARAM, ticketId);
|
|
8
|
+
return signUrl(
|
|
9
|
+
url.toString(),
|
|
10
|
+
{ key: config.key, keyId: config.keyId, ttlSeconds: config.ttlSeconds },
|
|
11
|
+
nowMs
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
const PROBE_ORIGIN = "https://graph.invalid";
|
|
15
|
+
const PROBE_PREFIX = "/v1.0";
|
|
16
|
+
function isPlainGraphPath(target) {
|
|
17
|
+
if (!target.startsWith("/")) return false;
|
|
18
|
+
if (target.includes("//")) return false;
|
|
19
|
+
let resolved;
|
|
20
|
+
try {
|
|
21
|
+
resolved = new URL(PROBE_ORIGIN + PROBE_PREFIX + target);
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
return resolved.origin === PROBE_ORIGIN && resolved.search === "" && resolved.hash === "" && resolved.pathname === PROBE_PREFIX + target;
|
|
26
|
+
}
|
|
27
|
+
const MAX_LIVE_TICKETS = 256;
|
|
28
|
+
const TICKET_BYTES = 32;
|
|
29
|
+
class TicketStoreFullError extends Error {
|
|
30
|
+
constructor(limit) {
|
|
31
|
+
super(`No ticket slots available (limit ${limit}); retry once outstanding tickets expire.`);
|
|
32
|
+
this.limit = limit;
|
|
33
|
+
this.name = "TicketStoreFullError";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
class AttachmentTicketStore {
|
|
37
|
+
constructor(ttlSeconds) {
|
|
38
|
+
this.ttlSeconds = ttlSeconds;
|
|
39
|
+
this.tickets = /* @__PURE__ */ new Map();
|
|
40
|
+
}
|
|
41
|
+
/** Drop every expired ticket. Called before each mint and each redemption. */
|
|
42
|
+
sweep(nowMs) {
|
|
43
|
+
for (const [id, ticket] of this.tickets) {
|
|
44
|
+
if (ticket.expiresAtMs <= nowMs) this.tickets.delete(id);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
mint(target, accountName, nowMs = Date.now()) {
|
|
48
|
+
this.sweep(nowMs);
|
|
49
|
+
if (this.tickets.size >= MAX_LIVE_TICKETS) {
|
|
50
|
+
throw new TicketStoreFullError(MAX_LIVE_TICKETS);
|
|
51
|
+
}
|
|
52
|
+
const id = randomBytes(TICKET_BYTES).toString("base64url");
|
|
53
|
+
const expiresAtMs = nowMs + this.ttlSeconds * 1e3;
|
|
54
|
+
this.tickets.set(id, { target, accountName, expiresAtMs });
|
|
55
|
+
return { id, expiresAtMs };
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Return the ticket and burn it, or undefined.
|
|
59
|
+
*
|
|
60
|
+
* One `undefined` for every failure -- unknown id, already redeemed, expired.
|
|
61
|
+
* The caller answers 404 to all three, so a probe cannot use the response to
|
|
62
|
+
* tell "never existed" from "already used", which would confirm a guessed id.
|
|
63
|
+
*
|
|
64
|
+
* The delete happens before the value is returned rather than in the caller,
|
|
65
|
+
* so an exception on the streaming path cannot leave a redeemed ticket live.
|
|
66
|
+
*/
|
|
67
|
+
redeem(id, nowMs = Date.now()) {
|
|
68
|
+
this.sweep(nowMs);
|
|
69
|
+
const ticket = this.tickets.get(id);
|
|
70
|
+
if (!ticket) return void 0;
|
|
71
|
+
this.tickets.delete(id);
|
|
72
|
+
return ticket;
|
|
73
|
+
}
|
|
74
|
+
/** Live ticket count, for tests and diagnostics. Never logged with ids. */
|
|
75
|
+
size(nowMs = Date.now()) {
|
|
76
|
+
this.sweep(nowMs);
|
|
77
|
+
return this.tickets.size;
|
|
78
|
+
}
|
|
79
|
+
clear() {
|
|
80
|
+
this.tickets.clear();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export {
|
|
84
|
+
AttachmentTicketStore,
|
|
85
|
+
TICKET_PARAM,
|
|
86
|
+
TicketStoreFullError,
|
|
87
|
+
buildAttachmentUrl,
|
|
88
|
+
isPlainGraphPath
|
|
89
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
const ATTACHMENT_ROUTE = "/attachment";
|
|
3
|
+
const DEFAULT_TTL_SECONDS = 120;
|
|
4
|
+
const MAX_TTL_SECONDS = 300;
|
|
5
|
+
class AttachmentUrlConfigError extends Error {
|
|
6
|
+
constructor(message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "AttachmentUrlConfigError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const PYTHON_WHITESPACE = " \n\v\f\r \x85\xA0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u2028\u2029\u202F\u205F\u3000";
|
|
12
|
+
function pythonStrip(value) {
|
|
13
|
+
let start = 0;
|
|
14
|
+
let end = value.length;
|
|
15
|
+
while (start < end && PYTHON_WHITESPACE.includes(value[start])) start += 1;
|
|
16
|
+
while (end > start && PYTHON_WHITESPACE.includes(value[end - 1])) end -= 1;
|
|
17
|
+
return value.slice(start, end);
|
|
18
|
+
}
|
|
19
|
+
function readKey(env) {
|
|
20
|
+
const keyFile = env.MS365_MCP_ATTACHMENT_URL_KEY_FILE;
|
|
21
|
+
if (keyFile) {
|
|
22
|
+
try {
|
|
23
|
+
return pythonStrip(readFileSync(keyFile, "utf8"));
|
|
24
|
+
} catch {
|
|
25
|
+
throw new AttachmentUrlConfigError(
|
|
26
|
+
`MS365_MCP_ATTACHMENT_URL_KEY_FILE could not be read: ${keyFile}`
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return env.MS365_MCP_ATTACHMENT_URL_KEY ?? "";
|
|
31
|
+
}
|
|
32
|
+
function loadAttachmentUrlConfig(enabled, env = process.env) {
|
|
33
|
+
if (!enabled) return null;
|
|
34
|
+
const base = env.MS365_MCP_ATTACHMENT_URL_BASE ?? "";
|
|
35
|
+
if (!base) {
|
|
36
|
+
throw new AttachmentUrlConfigError(
|
|
37
|
+
"--enable-attachment-urls requires MS365_MCP_ATTACHMENT_URL_BASE (the origin a document-conversion sidecar will fetch, e.g. http://m365-mcp:3000). This is deliberately not MS365_MCP_PUBLIC_URL: that one is browser-facing for OAuth, while this is reached server-to-server and is commonly a container address. With --attachment-port, name that port here, not the --http one: the route is not served on the MCP port at all in that mode."
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
let parsedBase;
|
|
41
|
+
try {
|
|
42
|
+
parsedBase = new URL(base);
|
|
43
|
+
} catch {
|
|
44
|
+
throw new AttachmentUrlConfigError(
|
|
45
|
+
`MS365_MCP_ATTACHMENT_URL_BASE is not a valid absolute URL: ${base}`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
if (parsedBase.protocol !== "http:" && parsedBase.protocol !== "https:") {
|
|
49
|
+
throw new AttachmentUrlConfigError(
|
|
50
|
+
`MS365_MCP_ATTACHMENT_URL_BASE must be http or https, got ${parsedBase.protocol}`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
if (parsedBase.hostname.startsWith("[")) {
|
|
54
|
+
throw new AttachmentUrlConfigError(
|
|
55
|
+
`MS365_MCP_ATTACHMENT_URL_BASE must not be an IPv6 literal: the URL signature covers the host, and this runtime and the verifying sidecar normalise IPv6 spellings differently, so every minted URL would be refused. Use a hostname (a container or service name) instead of ${parsedBase.hostname}.`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
if (parsedBase.search || parsedBase.hash) {
|
|
59
|
+
throw new AttachmentUrlConfigError(
|
|
60
|
+
"MS365_MCP_ATTACHMENT_URL_BASE must not carry a query string or fragment."
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
const key = readKey(env);
|
|
64
|
+
if (!key) {
|
|
65
|
+
throw new AttachmentUrlConfigError(
|
|
66
|
+
"--enable-attachment-urls requires MS365_MCP_ATTACHMENT_URL_KEY or MS365_MCP_ATTACHMENT_URL_KEY_FILE -- the HMAC key shared with the sidecar that will verify the minted URL."
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
for (let index = 0; index < key.length; index += 1) {
|
|
70
|
+
const code = key.charCodeAt(index);
|
|
71
|
+
if (code < 32 || code === 127) {
|
|
72
|
+
throw new AttachmentUrlConfigError(
|
|
73
|
+
`The attachment URL signing key contains a control character at offset ${index} (value not shown).`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const keyId = env.MS365_MCP_ATTACHMENT_URL_KEY_ID || "1";
|
|
78
|
+
const ttlRaw = env.MS365_MCP_ATTACHMENT_URL_TTL_S;
|
|
79
|
+
let ttlSeconds = DEFAULT_TTL_SECONDS;
|
|
80
|
+
if (ttlRaw !== void 0 && ttlRaw !== "") {
|
|
81
|
+
if (!/^\d+$/.test(ttlRaw)) {
|
|
82
|
+
throw new AttachmentUrlConfigError(
|
|
83
|
+
`MS365_MCP_ATTACHMENT_URL_TTL_S must be a positive integer, got ${JSON.stringify(ttlRaw)}`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
ttlSeconds = Number(ttlRaw);
|
|
87
|
+
if (ttlSeconds <= 0 || ttlSeconds > MAX_TTL_SECONDS) {
|
|
88
|
+
throw new AttachmentUrlConfigError(
|
|
89
|
+
`MS365_MCP_ATTACHMENT_URL_TTL_S must be between 1 and ${MAX_TTL_SECONDS}, got ${ttlSeconds}`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
base: parsedBase.origin,
|
|
95
|
+
key,
|
|
96
|
+
keyId,
|
|
97
|
+
ttlSeconds
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
export {
|
|
101
|
+
ATTACHMENT_ROUTE,
|
|
102
|
+
AttachmentUrlConfigError,
|
|
103
|
+
loadAttachmentUrlConfig
|
|
104
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
function quoteAll(value) {
|
|
3
|
+
return Array.from(new TextEncoder().encode(value)).map((byte) => {
|
|
4
|
+
const char = String.fromCharCode(byte);
|
|
5
|
+
if (/[A-Za-z0-9_.\-~]/.test(char)) return char;
|
|
6
|
+
return "%" + byte.toString(16).toUpperCase().padStart(2, "0");
|
|
7
|
+
}).join("");
|
|
8
|
+
}
|
|
9
|
+
function unquotePlus(value) {
|
|
10
|
+
const withSpaces = value.replace(/\+/g, " ");
|
|
11
|
+
const bytes = [];
|
|
12
|
+
for (let i = 0; i < withSpaces.length; i += 1) {
|
|
13
|
+
const char = withSpaces[i];
|
|
14
|
+
if (char === "%" && /^[0-9A-Fa-f]{2}$/.test(withSpaces.slice(i + 1, i + 3))) {
|
|
15
|
+
bytes.push(parseInt(withSpaces.slice(i + 1, i + 3), 16));
|
|
16
|
+
i += 2;
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
for (const byte of new TextEncoder().encode(char)) bytes.push(byte);
|
|
20
|
+
}
|
|
21
|
+
return new TextDecoder("utf-8", { fatal: false }).decode(new Uint8Array(bytes));
|
|
22
|
+
}
|
|
23
|
+
function compareByCodePoint(a, b) {
|
|
24
|
+
const left = Array.from(a);
|
|
25
|
+
const right = Array.from(b);
|
|
26
|
+
const shared = Math.min(left.length, right.length);
|
|
27
|
+
for (let i = 0; i < shared; i += 1) {
|
|
28
|
+
const diff = left[i].codePointAt(0) - right[i].codePointAt(0);
|
|
29
|
+
if (diff !== 0) return diff;
|
|
30
|
+
}
|
|
31
|
+
return left.length - right.length;
|
|
32
|
+
}
|
|
33
|
+
const RESERVED_PARAMS = /* @__PURE__ */ new Set(["dgk", "dgx", "dgs"]);
|
|
34
|
+
function parseQsl(query) {
|
|
35
|
+
const pairs = [];
|
|
36
|
+
for (const field of query.split("&")) {
|
|
37
|
+
if (!field) continue;
|
|
38
|
+
const eq = field.indexOf("=");
|
|
39
|
+
const rawName = eq === -1 ? field : field.slice(0, eq);
|
|
40
|
+
const rawValue = eq === -1 ? "" : field.slice(eq + 1);
|
|
41
|
+
pairs.push([unquotePlus(rawName), unquotePlus(rawValue)]);
|
|
42
|
+
}
|
|
43
|
+
return pairs;
|
|
44
|
+
}
|
|
45
|
+
function canonicalQuery(query) {
|
|
46
|
+
const pairs = parseQsl(query).filter(([name]) => !RESERVED_PARAMS.has(name));
|
|
47
|
+
pairs.sort((a, b) => compareByCodePoint(a[0], b[0]) || compareByCodePoint(a[1], b[1]));
|
|
48
|
+
return pairs.map(([name, value]) => `${quoteAll(name)}=${quoteAll(value)}`).join("&");
|
|
49
|
+
}
|
|
50
|
+
function canonicalString(url, expiry) {
|
|
51
|
+
const parsed = new URL(url);
|
|
52
|
+
const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
|
|
53
|
+
const host = parsed.hostname.toLowerCase().replace(/^\[(.+)\]$/, "$1");
|
|
54
|
+
const port = parsed.port === "" ? scheme === "https" ? 443 : 80 : Number(parsed.port);
|
|
55
|
+
const path = parsed.pathname === "" ? "/" : parsed.pathname;
|
|
56
|
+
const query = canonicalQuery(parsed.search.replace(/^\?/, ""));
|
|
57
|
+
return ["v1", scheme, host, String(port), path, query, expiry].join("\n");
|
|
58
|
+
}
|
|
59
|
+
function digest(key, message) {
|
|
60
|
+
return createHmac("sha256", Buffer.from(key, "utf8")).update(Buffer.from(message, "utf8")).digest("base64url");
|
|
61
|
+
}
|
|
62
|
+
function signUrl(url, config, nowMs = Date.now()) {
|
|
63
|
+
const expiry = String(Math.floor(nowMs / 1e3) + config.ttlSeconds);
|
|
64
|
+
const signature = digest(config.key, canonicalString(url, expiry));
|
|
65
|
+
const parsed = new URL(url);
|
|
66
|
+
const kept = parseQsl(parsed.search.replace(/^\?/, "")).filter(
|
|
67
|
+
([name]) => !RESERVED_PARAMS.has(name)
|
|
68
|
+
);
|
|
69
|
+
kept.push(["dgk", config.keyId], ["dgx", expiry], ["dgs", signature]);
|
|
70
|
+
parsed.search = kept.map(([name, value]) => `${quoteAll(name)}=${quoteAll(value)}`).join("&");
|
|
71
|
+
return parsed.toString();
|
|
72
|
+
}
|
|
73
|
+
export {
|
|
74
|
+
canonicalString,
|
|
75
|
+
compareByCodePoint,
|
|
76
|
+
digest,
|
|
77
|
+
parseQsl,
|
|
78
|
+
quoteAll,
|
|
79
|
+
signUrl,
|
|
80
|
+
unquotePlus
|
|
81
|
+
};
|