@softeria/ms-365-mcp-server 0.150.2 → 0.151.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-tools.test.js +192 -0
- package/dist/attachment-route.js +112 -0
- package/dist/cli.js +15 -0
- package/dist/graph-client.js +49 -0
- package/dist/graph-tools.js +215 -1
- 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/param-descriptions.js +1 -1
- package/dist/lib/url-signing.js +81 -0
- package/dist/server.js +249 -17
- 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,
|
|
@@ -80,6 +86,142 @@ function clampTopQueryParam(queryParams) {
|
|
|
80
86
|
logger.info(`Clamping $top from ${requested} to ${cap} (MS365_MCP_MAX_TOP)`);
|
|
81
87
|
queryParams["$top"] = String(cap);
|
|
82
88
|
}
|
|
89
|
+
const OUTLOOK_MAIL_PATH = /^\/(?:me|users\/[^/]+)(?:\/(?:mailFolders|childFolders)\/[^/]+)*\/messages(?:\/delta\(\))?$/i;
|
|
90
|
+
function isOutlookMailPath(path2) {
|
|
91
|
+
return OUTLOOK_MAIL_PATH.test(path2);
|
|
92
|
+
}
|
|
93
|
+
function readQuotedSegment(expr, start) {
|
|
94
|
+
let j = start + 1;
|
|
95
|
+
let segment = "";
|
|
96
|
+
while (j < expr.length) {
|
|
97
|
+
if (expr[j] === "\\" && expr[j + 1] === "\\") {
|
|
98
|
+
segment += "\\\\";
|
|
99
|
+
j += 2;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (expr[j] === "\\" && expr[j + 1] === '"') {
|
|
103
|
+
segment += '\\"';
|
|
104
|
+
j += 2;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (expr[j] === '"') return { segment, end: j };
|
|
108
|
+
segment += expr[j];
|
|
109
|
+
j += 1;
|
|
110
|
+
}
|
|
111
|
+
return void 0;
|
|
112
|
+
}
|
|
113
|
+
const MAIL_SEARCH_PROPERTIES = /* @__PURE__ */ new Set([
|
|
114
|
+
"attachment",
|
|
115
|
+
"bcc",
|
|
116
|
+
"body",
|
|
117
|
+
"category",
|
|
118
|
+
"cc",
|
|
119
|
+
"from",
|
|
120
|
+
"hasattachment",
|
|
121
|
+
"hasattachments",
|
|
122
|
+
"importance",
|
|
123
|
+
"kind",
|
|
124
|
+
"participants",
|
|
125
|
+
"received",
|
|
126
|
+
"recipients",
|
|
127
|
+
"sent",
|
|
128
|
+
"size",
|
|
129
|
+
"subject",
|
|
130
|
+
"to"
|
|
131
|
+
]);
|
|
132
|
+
const CLAUSE_HEAD = /^([A-Za-z]+)(?::|<=|>=|<>|=|<|>)\S/;
|
|
133
|
+
const BOOLEAN_JOIN = /\s(?:AND|OR|NOT)\s/;
|
|
134
|
+
function clauseHead(segment) {
|
|
135
|
+
const head = CLAUSE_HEAD.exec(segment);
|
|
136
|
+
return head && MAIL_SEARCH_PROPERTIES.has(head[1].toLowerCase()) ? head : null;
|
|
137
|
+
}
|
|
138
|
+
function balanceTrailingSlashes(text) {
|
|
139
|
+
const slashes = text.length - text.replace(/\\+$/, "").length;
|
|
140
|
+
return slashes % 2 === 1 ? `${text}\\` : text;
|
|
141
|
+
}
|
|
142
|
+
function classifyRun(segment, introducedByProperty) {
|
|
143
|
+
if (introducedByProperty) return "phrase";
|
|
144
|
+
const head = clauseHead(segment);
|
|
145
|
+
if (!head) return "phrase";
|
|
146
|
+
if (BOOLEAN_JOIN.test(segment) || !/\s/.test(segment)) return "clause";
|
|
147
|
+
return "restriction-value";
|
|
148
|
+
}
|
|
149
|
+
function rewriteMailSearchQuotes(expr) {
|
|
150
|
+
let out = "";
|
|
151
|
+
let i = 0;
|
|
152
|
+
while (i < expr.length) {
|
|
153
|
+
if (expr[i] === "\\" && expr[i + 1] === "\\") {
|
|
154
|
+
out += "\\\\";
|
|
155
|
+
i += 2;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (expr[i] === "\\" && expr[i + 1] === '"') {
|
|
159
|
+
out += '\\"';
|
|
160
|
+
i += 2;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (expr[i] !== '"') {
|
|
164
|
+
out += expr[i];
|
|
165
|
+
i += 1;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const run = readQuotedSegment(expr, i);
|
|
169
|
+
const segment = run ? run.segment : balanceTrailingSlashes(expr.slice(i + 1));
|
|
170
|
+
const introducedByProperty = i > 0 && expr[i - 1] === ":";
|
|
171
|
+
switch (classifyRun(segment, introducedByProperty)) {
|
|
172
|
+
case "clause":
|
|
173
|
+
out += segment;
|
|
174
|
+
break;
|
|
175
|
+
case "restriction-value": {
|
|
176
|
+
const head = clauseHead(segment);
|
|
177
|
+
const valueAt = head[0].length - 1;
|
|
178
|
+
out += `${segment.slice(0, valueAt)}\\"${segment.slice(valueAt)}\\"`;
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
default:
|
|
182
|
+
out += `\\"${segment}\\"`;
|
|
183
|
+
}
|
|
184
|
+
if (!run) break;
|
|
185
|
+
i = run.end + 1;
|
|
186
|
+
}
|
|
187
|
+
return balanceTrailingSlashes(out.trim());
|
|
188
|
+
}
|
|
189
|
+
function normalizeSearchQueryParam(queryParams, path2, toolAlias) {
|
|
190
|
+
if (!isOutlookMailPath(path2)) return;
|
|
191
|
+
const raw = queryParams["$search"];
|
|
192
|
+
if (raw === void 0) return;
|
|
193
|
+
const trimmed = raw.trim();
|
|
194
|
+
const noSearchableText = () => {
|
|
195
|
+
logger.warn(`Refusing ${toolAlias}: '$search' has no searchable text`);
|
|
196
|
+
return {
|
|
197
|
+
content: [
|
|
198
|
+
{
|
|
199
|
+
type: "text",
|
|
200
|
+
text: JSON.stringify({
|
|
201
|
+
error: "invalid_search",
|
|
202
|
+
tool: toolAlias,
|
|
203
|
+
message: 'The $search parameter has no searchable text. Supply a KQL expression such as "from:john" or "subject:budget", or omit $search to list messages unfiltered.'
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
],
|
|
207
|
+
isError: true
|
|
208
|
+
};
|
|
209
|
+
};
|
|
210
|
+
if (trimmed === "" || /^["'\s]+$/.test(trimmed)) return noSearchableText();
|
|
211
|
+
let expr = trimmed;
|
|
212
|
+
if (expr.startsWith('"')) {
|
|
213
|
+
const whole = readQuotedSegment(expr, 0);
|
|
214
|
+
if (!whole) expr = expr.slice(1);
|
|
215
|
+
else if (whole.end === expr.length - 1) expr = whole.segment;
|
|
216
|
+
}
|
|
217
|
+
const inner = rewriteMailSearchQuotes(expr);
|
|
218
|
+
if (inner === "") return noSearchableText();
|
|
219
|
+
const normalized = `"${inner}"`;
|
|
220
|
+
if (normalized !== raw) {
|
|
221
|
+
logger.info(`Auto-corrected parameter '$search': normalized KQL quoting to ${normalized}`);
|
|
222
|
+
queryParams["$search"] = normalized;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
83
225
|
const DEFAULT_MAX_ITEMS = 1e4;
|
|
84
226
|
function isConfirmGateEnabled() {
|
|
85
227
|
return process.env.MS365_MCP_REQUIRE_CONFIRM === "true";
|
|
@@ -339,6 +481,68 @@ async function checkAccountParamInBearerMode(accountParam, authManager) {
|
|
|
339
481
|
if (bearerIdentity && bearerIdentity.toLowerCase() === accountParam.toLowerCase()) return null;
|
|
340
482
|
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.`;
|
|
341
483
|
}
|
|
484
|
+
async function mintDownloadUrl(target, accountParam, authManager) {
|
|
485
|
+
const minting = getAttachmentMinting();
|
|
486
|
+
if (!minting) return null;
|
|
487
|
+
if (authManager?.isOAuthModeEnabled() || getRequestTokens()) {
|
|
488
|
+
return {
|
|
489
|
+
content: [
|
|
490
|
+
{
|
|
491
|
+
type: "text",
|
|
492
|
+
text: JSON.stringify({
|
|
493
|
+
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."
|
|
494
|
+
})
|
|
495
|
+
}
|
|
496
|
+
],
|
|
497
|
+
isError: true
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
const accountModeError = await checkAccountParamInBearerMode(accountParam, authManager);
|
|
501
|
+
if (accountModeError) {
|
|
502
|
+
return {
|
|
503
|
+
content: [{ type: "text", text: JSON.stringify({ error: accountModeError }) }],
|
|
504
|
+
isError: true
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
if (!isPlainGraphPath(target)) {
|
|
508
|
+
return {
|
|
509
|
+
content: [
|
|
510
|
+
{
|
|
511
|
+
type: "text",
|
|
512
|
+
text: JSON.stringify({
|
|
513
|
+
error: 'target must be a plain relative Graph path: no fragment, no query, no "." or ".." segments, and no percent-encoded separators.'
|
|
514
|
+
})
|
|
515
|
+
}
|
|
516
|
+
],
|
|
517
|
+
isError: true
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
let ticket;
|
|
521
|
+
try {
|
|
522
|
+
ticket = minting.store.mint(target, accountParam);
|
|
523
|
+
} catch (error) {
|
|
524
|
+
if (error instanceof TicketStoreFullError) {
|
|
525
|
+
return {
|
|
526
|
+
content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
|
|
527
|
+
isError: true
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
throw error;
|
|
531
|
+
}
|
|
532
|
+
return {
|
|
533
|
+
content: [
|
|
534
|
+
{
|
|
535
|
+
type: "text",
|
|
536
|
+
text: JSON.stringify({
|
|
537
|
+
downloadUrl: buildAttachmentUrl(minting.config, ticket.id),
|
|
538
|
+
expiresAt: new Date(ticket.expiresAtMs).toISOString(),
|
|
539
|
+
singleUse: true,
|
|
540
|
+
note: "Served by this server, not by Microsoft Graph. Valid for one fetch until it expires."
|
|
541
|
+
})
|
|
542
|
+
}
|
|
543
|
+
]
|
|
544
|
+
};
|
|
545
|
+
}
|
|
342
546
|
const UTILITY_TOOLS = [
|
|
343
547
|
{
|
|
344
548
|
name: "parse-teams-url",
|
|
@@ -583,7 +787,7 @@ const UTILITY_TOOLS = [
|
|
|
583
787
|
method: "GET",
|
|
584
788
|
path: "tool:get-download-url",
|
|
585
789
|
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",
|
|
586
|
-
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? }.
|
|
790
|
+
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.",
|
|
587
791
|
readOnlyHint: true,
|
|
588
792
|
openWorldHint: true,
|
|
589
793
|
buildSchema: (ctx) => {
|
|
@@ -642,6 +846,8 @@ const UTILITY_TOOLS = [
|
|
|
642
846
|
}
|
|
643
847
|
const pathPart = target.replace(/\/+$/, "");
|
|
644
848
|
if (/^(\/me|\/users\/[^/]+)\/messages\/[^/]+\/attachments\//.test(pathPart) || /^(\/me|\/users\/[^/]+)\/events\/[^/]+\/attachments\//.test(pathPart) || /^\/groups\/[^/]+\/messages\/[^/]+\/attachments\//.test(pathPart) || /^\/groups\/[^/]+\/events\/[^/]+\/attachments\//.test(pathPart)) {
|
|
849
|
+
const minted = await mintDownloadUrl(pathPart, accountParam, authManager);
|
|
850
|
+
if (minted) return minted;
|
|
645
851
|
return {
|
|
646
852
|
content: [
|
|
647
853
|
{
|
|
@@ -657,6 +863,8 @@ const UTILITY_TOOLS = [
|
|
|
657
863
|
if (/^(\/me|\/users\/[^/]+)\/onlineMeetings\/[^/]+\/recordings\/[^/]+(?:\/content)?$/.test(
|
|
658
864
|
pathPart
|
|
659
865
|
) || /^\/communications\/calls\/[^/]+\/recordings\/[^/]+(?:\/content)?$/.test(pathPart)) {
|
|
866
|
+
const minted = await mintDownloadUrl(pathPart, accountParam, authManager);
|
|
867
|
+
if (minted) return minted;
|
|
660
868
|
return {
|
|
661
869
|
content: [
|
|
662
870
|
{
|
|
@@ -670,6 +878,8 @@ const UTILITY_TOOLS = [
|
|
|
670
878
|
};
|
|
671
879
|
}
|
|
672
880
|
if (pathPart.endsWith("/$value")) {
|
|
881
|
+
const minted = await mintDownloadUrl(pathPart, accountParam, authManager);
|
|
882
|
+
if (minted) return minted;
|
|
673
883
|
return {
|
|
674
884
|
content: [
|
|
675
885
|
{
|
|
@@ -739,6 +949,8 @@ const UTILITY_TOOLS = [
|
|
|
739
949
|
}
|
|
740
950
|
const downloadUrl = item?.["@microsoft.graph.downloadUrl"];
|
|
741
951
|
if (!downloadUrl) {
|
|
952
|
+
const minted = await mintDownloadUrl(`${itemPath}/content`, accountParam, authManager);
|
|
953
|
+
if (minted) return minted;
|
|
742
954
|
return {
|
|
743
955
|
content: [
|
|
744
956
|
{
|
|
@@ -1037,6 +1249,8 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
|
|
|
1037
1249
|
delete queryParams["$top"];
|
|
1038
1250
|
}
|
|
1039
1251
|
clampTopQueryParam(queryParams);
|
|
1252
|
+
const searchError = normalizeSearchQueryParam(queryParams, tool.path, tool.alias);
|
|
1253
|
+
if (searchError) return searchError;
|
|
1040
1254
|
const preferValues = [];
|
|
1041
1255
|
if (config?.supportsTimezone && params.timezone) {
|
|
1042
1256
|
preferValues.push(`outlook.timezone="${params.timezone}"`);
|
|
@@ -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
|
+
};
|
|
@@ -29,7 +29,7 @@ function isFetchAllPagesApplicable(tool) {
|
|
|
29
29
|
return tool.method.toUpperCase() === "GET" && tool.path.includes("/") && paginationAllowed();
|
|
30
30
|
}
|
|
31
31
|
const FILTER_PARAM_DESCRIPTION = "OData filter expression. Add $count=true for advanced filters (flag/flagStatus, contains()). Cannot combine with $search.";
|
|
32
|
-
const SEARCH_PARAM_DESCRIPTION = "KQL search query
|
|
32
|
+
const SEARCH_PARAM_DESCRIPTION = "KQL search query in one pair of double quotes; directory (users/groups) instead quotes each clause with no outer pair. Cannot combine with $filter.";
|
|
33
33
|
const SELECT_PARAM_DESCRIPTION = "Comma-separated fields to return, e.g. id,subject,from,receivedDateTime";
|
|
34
34
|
const EXPAND_PARAM_DESCRIPTION = 'Navigation properties to inline, e.g. attachments on a message or event. Only navigation properties can be expanded: expanding a non-navigation property such as a message body fails with "Parsing OData Select and Expand failed", and an unsupported value may be ignored rather than reported. Request ordinary fields with $select instead.';
|
|
35
35
|
const ORDERBY_PARAM_DESCRIPTION = "Sort expression, e.g. receivedDateTime desc";
|
|
@@ -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
|
+
};
|