@softeria/ms-365-mcp-server 0.157.2 → 0.158.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 +74 -0
- package/dist/__tests__/graph-tools.test.js +407 -120
- package/dist/cli.js +13 -1
- package/dist/generated/client-beta.js +1 -0
- package/dist/generated/client.js +10 -1
- package/dist/graph-tools.js +250 -55
- package/dist/lib/attachment-tickets.js +11 -1
- package/dist/lib/select-projection.js +9 -8
- package/dist/lib/user-field-policy.js +69 -0
- package/dist/server.js +32 -43
- package/package.json +1 -1
package/dist/generated/client.js
CHANGED
|
@@ -2688,7 +2688,16 @@ const microsoft_graph_subscription = z.object({
|
|
|
2688
2688
|
).nullish(),
|
|
2689
2689
|
resource: z.string().describe(
|
|
2690
2690
|
"Required. Specifies the resource that is monitored for changes. Don't include the base URL (https://graph.microsoft.com/v1.0/). See the possible resource path values for each supported resource."
|
|
2691
|
-
).optional()
|
|
2691
|
+
).optional(),
|
|
2692
|
+
vapidPublicKey: z.string().describe(
|
|
2693
|
+
"Optional. The application server's VAPID public key, base64url-encoded (P-256 uncompressed point, 65 bytes pre-encoding). Obtained by calling the getVapidPublicKey function on the subscription collection. The browser passes this value to PushManager.subscribe({ applicationServerKey: vapidPublicKey }) to bind the push subscription to this server identity. Required when notificationUrl targets a known Web Push service origin (for example, *.push.apple.com, fcm.googleapis.com, updates.push.services.mozilla.com); rejected with 400 Bad Request if supplied on a standard webhook subscription. For more information, see RFC 8292."
|
|
2694
|
+
).nullish(),
|
|
2695
|
+
webPushEncryptionP256dhPublicKey: z.string().describe(
|
|
2696
|
+
"Optional. The subscriber's ECDH public key, base64url-encoded (P-256 uncompressed point, 65 bytes pre-encoding). Obtained from the browser via PushSubscription.getKey('p256dh'). Used as the peer public key during ECDH key agreement to derive the per-message content encryption key for RFC 8291 payload encryption. Required when notificationUrl targets a known Web Push service origin; rejected with 400 Bad Request if supplied on a standard webhook subscription. For more information, see RFC 8291 Section 3."
|
|
2697
|
+
).nullish(),
|
|
2698
|
+
webPushEncryptionSecret: z.string().describe(
|
|
2699
|
+
"Optional. The subscriber's auth secret, base64url-encoded (16 bytes pre-encoding). Obtained from the browser via PushSubscription.getKey('auth'). Used as the HMAC-SHA-256 salt for the HKDF combine step that derives key material for RFC 8291 payload encryption. Write-only: this value is never returned in GET responses (returned as null). Treat as a secret. Required when notificationUrl targets a known Web Push service origin; rejected with 400 Bad Request if supplied on a standard webhook subscription. For more information, see RFC 8291 Section 3."
|
|
2700
|
+
).nullish()
|
|
2692
2701
|
}).passthrough();
|
|
2693
2702
|
const microsoft_graph_list = z.lazy(
|
|
2694
2703
|
() => z.object({
|
package/dist/graph-tools.js
CHANGED
|
@@ -1,58 +1,67 @@
|
|
|
1
1
|
import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
2
2
|
import { randomUUID } from "crypto";
|
|
3
|
-
import
|
|
3
|
+
import { readFileSync } from "fs";
|
|
4
|
+
import { access } from "fs/promises";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
7
|
+
import { z } from "zod";
|
|
4
8
|
import { auditLog, getUserIdentityForAudit } from "./audit-log.js";
|
|
5
|
-
import {
|
|
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";
|
|
13
|
-
import {
|
|
14
|
-
anyFieldPresent,
|
|
15
|
-
isTransportEnvelope,
|
|
16
|
-
parseSelectFields,
|
|
17
|
-
projectSelectedFields
|
|
18
|
-
} from "./lib/select-projection.js";
|
|
9
|
+
import { deriveTargetResource } from "./audit-target-resource.js";
|
|
19
10
|
import {
|
|
20
11
|
getEndpointScopeGroups,
|
|
21
12
|
getMissingAllowedScopesForGroups,
|
|
22
13
|
parseAllowedScopes
|
|
23
14
|
} from "./auth.js";
|
|
24
|
-
import { api } from "./generated/client.js";
|
|
25
15
|
import { api as betaApi } from "./generated/client-beta.js";
|
|
26
|
-
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
import { parseTeamsUrl } from "./lib/teams-url-parser.js";
|
|
16
|
+
import { api } from "./generated/client.js";
|
|
17
|
+
import { getAttachmentMinting } from "./lib/attachment-minting.js";
|
|
18
|
+
import {
|
|
19
|
+
buildAttachmentUrl,
|
|
20
|
+
isPlainGraphPath,
|
|
21
|
+
isUnalteredGraphPath,
|
|
22
|
+
TicketStoreFullError
|
|
23
|
+
} from "./lib/attachment-tickets.js";
|
|
35
24
|
import { buildBM25Index, scoreQuery, tokenize } from "./lib/bm25.js";
|
|
36
|
-
import {
|
|
37
|
-
import { describeToolSchema, describeUtilityToolSchema } from "./lib/tool-schema.js";
|
|
38
|
-
import { queryParameterSchema } from "./lib/query-parameter-schema.js";
|
|
25
|
+
import { isDestructiveOperation } from "./lib/destructive-ops.js";
|
|
39
26
|
import {
|
|
40
|
-
TOP_UNSUPPORTED_DELTA_TOOLS,
|
|
41
|
-
shouldOmitTopParam,
|
|
42
|
-
paginationAllowed,
|
|
43
|
-
positiveIntFromEnv,
|
|
44
|
-
DEFAULT_MAX_PAGES,
|
|
45
|
-
getMaxPages,
|
|
46
|
-
isFetchAllPagesApplicable,
|
|
47
27
|
CONFIRM_PARAM_DESCRIPTION,
|
|
48
|
-
|
|
28
|
+
DEFAULT_MAX_PAGES,
|
|
49
29
|
EXPAND_EXTENDED_PROPERTIES_PARAM_DESCRIPTION,
|
|
50
30
|
getAcceptParamDescription,
|
|
51
31
|
getAccountParamDescription,
|
|
52
32
|
getFetchAllPagesParamDescription,
|
|
33
|
+
getMaxPages,
|
|
34
|
+
isFetchAllPagesApplicable,
|
|
35
|
+
isSkiptokenApplicable,
|
|
36
|
+
paginationAllowed,
|
|
37
|
+
positiveIntFromEnv,
|
|
38
|
+
shouldOmitTopParam,
|
|
53
39
|
SKIPTOKEN_PARAM_DESCRIPTION,
|
|
54
|
-
|
|
40
|
+
TIMEZONE_PARAM_DESCRIPTION,
|
|
41
|
+
TOP_UNSUPPORTED_DELTA_TOOLS
|
|
55
42
|
} from "./lib/param-descriptions.js";
|
|
43
|
+
import { describePathParam } from "./lib/path-params.js";
|
|
44
|
+
import { queryParameterSchema } from "./lib/query-parameter-schema.js";
|
|
45
|
+
import {
|
|
46
|
+
anyFieldPresent,
|
|
47
|
+
isTransportEnvelope,
|
|
48
|
+
parseSelectFields,
|
|
49
|
+
projectSelectedFields
|
|
50
|
+
} from "./lib/select-projection.js";
|
|
51
|
+
import { parseTeamsUrl } from "./lib/teams-url-parser.js";
|
|
52
|
+
import { describeToolSchema, describeUtilityToolSchema } from "./lib/tool-schema.js";
|
|
53
|
+
import {
|
|
54
|
+
restrictUserFieldQuery,
|
|
55
|
+
restrictUserFieldUrl,
|
|
56
|
+
shouldStripUserFieldExpand,
|
|
57
|
+
stripUserFieldExpandFromUrl,
|
|
58
|
+
targetsUserProfile,
|
|
59
|
+
userFieldEnforcement
|
|
60
|
+
} from "./lib/user-field-policy.js";
|
|
61
|
+
import logger from "./logger.js";
|
|
62
|
+
import { getRequestTokens } from "./request-context.js";
|
|
63
|
+
import { TOOL_CATEGORIES } from "./tool-categories.js";
|
|
64
|
+
const allEndpoints = [...api.endpoints, ...betaApi.endpoints];
|
|
56
65
|
const __filename = fileURLToPath(import.meta.url);
|
|
57
66
|
const __dirname = path.dirname(__filename);
|
|
58
67
|
const endpointsData = JSON.parse(
|
|
@@ -81,6 +90,16 @@ function clampTopQueryParam(queryParams) {
|
|
|
81
90
|
logger.info(`Clamping $top from ${requested} to ${cap} (MS365_MCP_MAX_TOP)`);
|
|
82
91
|
queryParams["$top"] = String(cap);
|
|
83
92
|
}
|
|
93
|
+
function parseUserFields(value) {
|
|
94
|
+
if (value === void 0) return void 0;
|
|
95
|
+
const fields = value.split(",").map((field) => field.trim()).filter(Boolean);
|
|
96
|
+
if (fields.length === 0) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
"User field allowlist was configured but names no fields. Provide one or more comma-separated Graph fields, or omit --user-fields / MS365_MCP_USER_FIELDS."
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return fields;
|
|
102
|
+
}
|
|
84
103
|
const OUTLOOK_MAIL_PATH = /^\/(?:me|users\/[^/]+)(?:\/(?:mailFolders|childFolders)\/[^/]+)*\/messages(?:\/delta\(\))?$/i;
|
|
85
104
|
function isOutlookMailPath(path2) {
|
|
86
105
|
return OUTLOOK_MAIL_PATH.test(path2);
|
|
@@ -640,7 +659,7 @@ const UTILITY_TOOLS = [
|
|
|
640
659
|
}
|
|
641
660
|
return schema;
|
|
642
661
|
},
|
|
643
|
-
execute: async (params, { graphClient, authManager }) => {
|
|
662
|
+
execute: async (params, { graphClient, authManager, userFields }) => {
|
|
644
663
|
const target = params.target;
|
|
645
664
|
const accountParam = params.account;
|
|
646
665
|
if (typeof target !== "string" || target.length === 0) {
|
|
@@ -667,6 +686,10 @@ const UTILITY_TOOLS = [
|
|
|
667
686
|
isError: true
|
|
668
687
|
};
|
|
669
688
|
}
|
|
689
|
+
const alteredTarget = unalteredTargetError(target);
|
|
690
|
+
if (alteredTarget) return alteredTarget;
|
|
691
|
+
const restrictedTarget = userProfilePassthroughError(target, userFields);
|
|
692
|
+
if (restrictedTarget) return restrictedTarget;
|
|
670
693
|
try {
|
|
671
694
|
const accountModeError = await checkAccountParamInBearerMode(accountParam, authManager);
|
|
672
695
|
if (accountModeError) {
|
|
@@ -721,7 +744,7 @@ const UTILITY_TOOLS = [
|
|
|
721
744
|
}
|
|
722
745
|
return schema;
|
|
723
746
|
},
|
|
724
|
-
execute: async (params, { graphClient, authManager }) => {
|
|
747
|
+
execute: async (params, { graphClient, authManager, userFields }) => {
|
|
725
748
|
const target = params.target;
|
|
726
749
|
const outputPath = params.outputPath;
|
|
727
750
|
const accountParam = params.account;
|
|
@@ -762,6 +785,10 @@ const UTILITY_TOOLS = [
|
|
|
762
785
|
isError: true
|
|
763
786
|
};
|
|
764
787
|
}
|
|
788
|
+
const alteredTarget = unalteredTargetError(target);
|
|
789
|
+
if (alteredTarget) return alteredTarget;
|
|
790
|
+
const restrictedTarget = userProfilePassthroughError(target, userFields);
|
|
791
|
+
if (restrictedTarget) return restrictedTarget;
|
|
765
792
|
if (!path.isAbsolute(outputPath)) {
|
|
766
793
|
return {
|
|
767
794
|
content: [
|
|
@@ -858,7 +885,7 @@ const UTILITY_TOOLS = [
|
|
|
858
885
|
}
|
|
859
886
|
return schema;
|
|
860
887
|
},
|
|
861
|
-
execute: async (params, { graphClient, authManager }) => {
|
|
888
|
+
execute: async (params, { graphClient, authManager, userFields }) => {
|
|
862
889
|
const target = params.target;
|
|
863
890
|
const accountParam = params.account;
|
|
864
891
|
if (typeof target !== "string" || target.length === 0) {
|
|
@@ -885,6 +912,10 @@ const UTILITY_TOOLS = [
|
|
|
885
912
|
isError: true
|
|
886
913
|
};
|
|
887
914
|
}
|
|
915
|
+
const alteredTarget = unalteredTargetError(target);
|
|
916
|
+
if (alteredTarget) return alteredTarget;
|
|
917
|
+
const restrictedTarget = userProfilePassthroughError(target, userFields);
|
|
918
|
+
if (restrictedTarget) return restrictedTarget;
|
|
888
919
|
const queryIdx = target.indexOf("?");
|
|
889
920
|
if (queryIdx >= 0) {
|
|
890
921
|
return {
|
|
@@ -1096,6 +1127,69 @@ const READ_ONLY_BODY_FIELDS = /* @__PURE__ */ new Set([
|
|
|
1096
1127
|
function isPlainObject(value) {
|
|
1097
1128
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1098
1129
|
}
|
|
1130
|
+
function restrictBatchSubrequests(body, allowlist) {
|
|
1131
|
+
const restricted = /* @__PURE__ */ new Map();
|
|
1132
|
+
let changed = false;
|
|
1133
|
+
let parsed = body;
|
|
1134
|
+
const wasString = typeof body === "string";
|
|
1135
|
+
if (wasString) {
|
|
1136
|
+
try {
|
|
1137
|
+
parsed = JSON.parse(body);
|
|
1138
|
+
} catch {
|
|
1139
|
+
return { body, restricted };
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
if (!isPlainObject(parsed) || !Array.isArray(parsed.requests)) return { body, restricted };
|
|
1143
|
+
parsed.requests.forEach((request, index) => {
|
|
1144
|
+
if (!isPlainObject(request) || typeof request.url !== "string") return;
|
|
1145
|
+
const method = typeof request.method === "string" ? request.method.toUpperCase() : "GET";
|
|
1146
|
+
const enforcement = userFieldEnforcement(request.url);
|
|
1147
|
+
if (method !== "GET") return;
|
|
1148
|
+
if (enforcement !== "none") {
|
|
1149
|
+
const { url, fields } = restrictUserFieldUrl(request.url, allowlist, enforcement);
|
|
1150
|
+
request.url = url;
|
|
1151
|
+
restricted.set(String(request.id ?? index), fields);
|
|
1152
|
+
changed = true;
|
|
1153
|
+
logger.info(`Restricting batch subrequest ${String(request.id ?? index)} to: ${fields}`);
|
|
1154
|
+
} else if (shouldStripUserFieldExpand(request.url)) {
|
|
1155
|
+
const url = stripUserFieldExpandFromUrl(request.url);
|
|
1156
|
+
if (url !== request.url) {
|
|
1157
|
+
request.url = url;
|
|
1158
|
+
changed = true;
|
|
1159
|
+
logger.info(`Removing $expand from batch subrequest ${String(request.id ?? index)}`);
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
});
|
|
1163
|
+
if (!changed) return { body, restricted };
|
|
1164
|
+
return { body: wasString ? JSON.stringify(parsed) : parsed, restricted };
|
|
1165
|
+
}
|
|
1166
|
+
function projectBatchSubresponses(data, restricted) {
|
|
1167
|
+
if (!isPlainObject(data) || !Array.isArray(data.responses)) return data;
|
|
1168
|
+
return {
|
|
1169
|
+
...data,
|
|
1170
|
+
responses: data.responses.map((subresponse) => {
|
|
1171
|
+
if (!isPlainObject(subresponse)) return subresponse;
|
|
1172
|
+
const fields = restricted.get(String(subresponse.id));
|
|
1173
|
+
if (!fields) return subresponse;
|
|
1174
|
+
return { ...subresponse, body: projectSelectedFields(subresponse.body, fields, true) };
|
|
1175
|
+
})
|
|
1176
|
+
};
|
|
1177
|
+
}
|
|
1178
|
+
function userProfilePassthroughError(target, userFields) {
|
|
1179
|
+
if (userFields === void 0 || !targetsUserProfile(target)) return void 0;
|
|
1180
|
+
return {
|
|
1181
|
+
content: [
|
|
1182
|
+
{
|
|
1183
|
+
type: "text",
|
|
1184
|
+
text: JSON.stringify({
|
|
1185
|
+
error: "user_fields_restricted",
|
|
1186
|
+
message: "This deployment restricts which user profile fields may be returned, and this tool returns the response bytes verbatim. Use list-users, which applies the restriction."
|
|
1187
|
+
})
|
|
1188
|
+
}
|
|
1189
|
+
],
|
|
1190
|
+
isError: true
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1099
1193
|
function hasOwn(obj, key) {
|
|
1100
1194
|
return Object.prototype.hasOwnProperty.call(obj, key);
|
|
1101
1195
|
}
|
|
@@ -1106,7 +1200,37 @@ function describeParamsForLog(params) {
|
|
|
1106
1200
|
return `[unserializable: ${error instanceof Error ? error.name : "unknown error"}]`;
|
|
1107
1201
|
}
|
|
1108
1202
|
}
|
|
1109
|
-
|
|
1203
|
+
function escapeIfStringLiteral(template, names, value) {
|
|
1204
|
+
const quoted = names.some((n) => template.includes(`'{${n}}'`) || template.includes(`':${n}'`));
|
|
1205
|
+
return quoted ? String(value).replace(/'/g, "''") : String(value);
|
|
1206
|
+
}
|
|
1207
|
+
function rawValueAddsSegments(template, names, value) {
|
|
1208
|
+
const isTail = names.some((n) => template.endsWith(`/{${n}}`) || template.endsWith(`/:${n}`));
|
|
1209
|
+
return isTail ? value.includes(":") : value.includes("/");
|
|
1210
|
+
}
|
|
1211
|
+
function invalidPathParameter(message) {
|
|
1212
|
+
return {
|
|
1213
|
+
content: [
|
|
1214
|
+
{ type: "text", text: JSON.stringify({ error: "invalid_path_parameter", message }) }
|
|
1215
|
+
],
|
|
1216
|
+
isError: true
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
1219
|
+
function unalteredTargetError(target) {
|
|
1220
|
+
if (isUnalteredGraphPath(target.split("?")[0])) return void 0;
|
|
1221
|
+
return {
|
|
1222
|
+
content: [
|
|
1223
|
+
{
|
|
1224
|
+
type: "text",
|
|
1225
|
+
text: JSON.stringify({
|
|
1226
|
+
error: 'target must not contain "." or ".." segments, a fragment or a backslash.'
|
|
1227
|
+
})
|
|
1228
|
+
}
|
|
1229
|
+
],
|
|
1230
|
+
isError: true
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
async function executeGraphTool(tool, config, graphClient, params, authManager, userFields) {
|
|
1110
1234
|
logger.info(`Tool ${tool.alias} called with params: ${describeParamsForLog(params)}`);
|
|
1111
1235
|
if (isConfirmGateEnabled() && isDestructiveOperation(tool.method, config) && params.confirm !== true) {
|
|
1112
1236
|
logger.warn(
|
|
@@ -1227,7 +1351,16 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
|
|
|
1227
1351
|
switch (paramDef.type) {
|
|
1228
1352
|
case "Path": {
|
|
1229
1353
|
const shouldSkipEncoding = config?.skipEncoding?.includes(paramName) ?? false;
|
|
1230
|
-
const
|
|
1354
|
+
const names = [paramName, camelCaseParamName];
|
|
1355
|
+
if (shouldSkipEncoding && rawValueAddsSegments(tool.path, names, `${paramValue}`)) {
|
|
1356
|
+
return invalidPathParameter(
|
|
1357
|
+
`'${paramName}' cannot add path segments to this endpoint: no "/" in a value, and no ":" in a site path.`
|
|
1358
|
+
);
|
|
1359
|
+
}
|
|
1360
|
+
const encodedValue = shouldSkipEncoding ? paramValue : encodeURIComponent(escapeIfStringLiteral(tool.path, names, paramValue)).replace(
|
|
1361
|
+
/%3D/g,
|
|
1362
|
+
"="
|
|
1363
|
+
);
|
|
1231
1364
|
path2 = path2.replace(`{${paramName}}`, encodedValue).replace(`:${paramName}`, encodedValue).replace(`{${camelCaseParamName}}`, encodedValue).replace(`:${camelCaseParamName}`, encodedValue);
|
|
1232
1365
|
break;
|
|
1233
1366
|
}
|
|
@@ -1265,7 +1398,9 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
|
|
|
1265
1398
|
body = paramValue;
|
|
1266
1399
|
logger.info(`Set body param: ${JSON.stringify(body)}`);
|
|
1267
1400
|
} else if (path2.includes(`:${paramName}`) || path2.includes(`{${paramName}}`) || path2.includes(`:${camelCaseParamName}`) || path2.includes(`{${camelCaseParamName}}`)) {
|
|
1268
|
-
const encodedValue = encodeURIComponent(
|
|
1401
|
+
const encodedValue = encodeURIComponent(
|
|
1402
|
+
escapeIfStringLiteral(tool.path, [paramName, camelCaseParamName], paramValue)
|
|
1403
|
+
).replace(/%3D/g, "=");
|
|
1269
1404
|
path2 = path2.replace(`{${paramName}}`, encodedValue).replace(`:${paramName}`, encodedValue).replace(`{${camelCaseParamName}}`, encodedValue).replace(`:${camelCaseParamName}`, encodedValue);
|
|
1270
1405
|
logger.info(`Path param fallback: replaced :${camelCaseParamName} with encoded value`);
|
|
1271
1406
|
} else if (paramName.toLowerCase() === "accept" && config?.acceptType) {
|
|
@@ -1283,6 +1418,11 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
|
|
|
1283
1418
|
logger.warn(`Dropping unrecognized parameter '${paramName}' for tool ${tool.alias}`);
|
|
1284
1419
|
}
|
|
1285
1420
|
}
|
|
1421
|
+
if (!isUnalteredGraphPath(path2)) {
|
|
1422
|
+
return invalidPathParameter(
|
|
1423
|
+
'A path parameter would send this request to a different endpoint. Path parameters cannot be "." or "..", or contain "/../", "?", "#" or a backslash. Percent-encode "?" and "#" that are part of a name.'
|
|
1424
|
+
);
|
|
1425
|
+
}
|
|
1286
1426
|
if (isPlainObject(body) && bodyShape != null && hasOwn(bodyShape, "body") && !Object.keys(body).some((k) => k.toLowerCase() === "body")) {
|
|
1287
1427
|
const nestedShape = bodySchemaShape(bodyShape.body);
|
|
1288
1428
|
if (nestedShape != null) {
|
|
@@ -1364,8 +1504,26 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
|
|
|
1364
1504
|
logger.info(`Setting custom Accept: ${config.acceptType}`);
|
|
1365
1505
|
}
|
|
1366
1506
|
const requestedSelect = parseSelectFields(queryParams["$select"]);
|
|
1507
|
+
const enforcement = userFields === void 0 ? "none" : userFieldEnforcement(path2);
|
|
1508
|
+
const isUserFieldBoundary = enforcement !== "none";
|
|
1509
|
+
const boundaryFields = isUserFieldBoundary && userFields !== void 0 ? restrictUserFieldQuery(queryParams, userFields, enforcement) : [];
|
|
1510
|
+
if (userFields !== void 0 && shouldStripUserFieldExpand(path2)) {
|
|
1511
|
+
delete queryParams["$expand"];
|
|
1512
|
+
}
|
|
1513
|
+
if (isUserFieldBoundary) {
|
|
1514
|
+
logger.info(
|
|
1515
|
+
`Restricting ${tool.alias} to configured user fields (${enforcement}): ${boundaryFields}`
|
|
1516
|
+
);
|
|
1517
|
+
}
|
|
1518
|
+
let batchRestricted = /* @__PURE__ */ new Map();
|
|
1519
|
+
if (userFields !== void 0) {
|
|
1520
|
+
const restrictedBatch = restrictBatchSubrequests(body, userFields);
|
|
1521
|
+
body = restrictedBatch.body;
|
|
1522
|
+
batchRestricted = restrictedBatch.restricted;
|
|
1523
|
+
}
|
|
1524
|
+
const projectionSelect = isUserFieldBoundary ? boundaryFields : requestedSelect;
|
|
1367
1525
|
const keepFields = [
|
|
1368
|
-
.../* @__PURE__ */ new Set([...
|
|
1526
|
+
.../* @__PURE__ */ new Set([...projectionSelect, ...parseSelectFields(queryParams["$expand"])])
|
|
1369
1527
|
];
|
|
1370
1528
|
if (Object.keys(queryParams).length > 0) {
|
|
1371
1529
|
const queryString = Object.entries(queryParams).map(([key, value]) => `${key}=${encodeURIComponent(value).replace(/%2C/gi, ",")}`).join("&");
|
|
@@ -1430,8 +1588,9 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
|
|
|
1430
1588
|
);
|
|
1431
1589
|
}
|
|
1432
1590
|
const mergePages = fetchAllPages && paginationEnabled;
|
|
1433
|
-
const willProject = requestedSelect.length > 0 && params.excludeResponse !== true;
|
|
1434
|
-
|
|
1591
|
+
const willProject = (requestedSelect.length > 0 || isUserFieldBoundary) && params.excludeResponse !== true;
|
|
1592
|
+
const willProjectBatch = batchRestricted.size > 0 && params.excludeResponse !== true;
|
|
1593
|
+
if (mergePages || willProject || willProjectBatch) {
|
|
1435
1594
|
options.forceJsonOutput = true;
|
|
1436
1595
|
}
|
|
1437
1596
|
let response = await graphClient.graphRequest(path2, options);
|
|
@@ -1442,13 +1601,13 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
|
|
|
1442
1601
|
logger.info("Skipping $select projection: body is a transport envelope, not a resource");
|
|
1443
1602
|
return body2;
|
|
1444
1603
|
}
|
|
1445
|
-
if (!anyFieldPresent(body2,
|
|
1604
|
+
if (!isUserFieldBoundary && !anyFieldPresent(body2, projectionSelect)) {
|
|
1446
1605
|
logger.warn(
|
|
1447
|
-
`None of the requested $select fields (${
|
|
1606
|
+
`None of the requested $select fields (${projectionSelect.join(",")}) appear in the response; returning it untrimmed`
|
|
1448
1607
|
);
|
|
1449
1608
|
return body2;
|
|
1450
1609
|
}
|
|
1451
|
-
return projectSelectedFields(body2, keepFields);
|
|
1610
|
+
return projectSelectedFields(body2, keepFields, isUserFieldBoundary);
|
|
1452
1611
|
};
|
|
1453
1612
|
if (mergePages && response?.content?.[0]?.text) {
|
|
1454
1613
|
let combinedResponse;
|
|
@@ -1535,6 +1694,15 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
|
|
|
1535
1694
|
} catch {
|
|
1536
1695
|
}
|
|
1537
1696
|
}
|
|
1697
|
+
if (willProjectBatch && !response?.isError && response?.content?.[0]?.text) {
|
|
1698
|
+
try {
|
|
1699
|
+
const parsed = JSON.parse(response.content[0].text);
|
|
1700
|
+
response.content[0].text = graphClient.serialize(
|
|
1701
|
+
projectBatchSubresponses(parsed, batchRestricted)
|
|
1702
|
+
);
|
|
1703
|
+
} catch {
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1538
1706
|
if (response?.content?.[0]?.text) {
|
|
1539
1707
|
const responseText = response.content[0].text;
|
|
1540
1708
|
logger.info(`Response size: ${responseText.length} characters`);
|
|
@@ -1599,7 +1767,19 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
|
|
|
1599
1767
|
};
|
|
1600
1768
|
}
|
|
1601
1769
|
}
|
|
1602
|
-
function registerGraphTools(server, graphClient,
|
|
1770
|
+
function registerGraphTools(server, graphClient, options = {}) {
|
|
1771
|
+
const {
|
|
1772
|
+
readOnly = false,
|
|
1773
|
+
enabledTools: enabledToolsPattern,
|
|
1774
|
+
orgMode = false,
|
|
1775
|
+
authManager,
|
|
1776
|
+
multiAccount = false,
|
|
1777
|
+
accountNames = [],
|
|
1778
|
+
allowedScopes: allowedScopesValue,
|
|
1779
|
+
httpMode = false,
|
|
1780
|
+
userFields: userFieldsValue
|
|
1781
|
+
} = options;
|
|
1782
|
+
const userFields = parseUserFields(userFieldsValue);
|
|
1603
1783
|
let enabledToolsRegex;
|
|
1604
1784
|
if (enabledToolsPattern) {
|
|
1605
1785
|
try {
|
|
@@ -1718,7 +1898,7 @@ function registerGraphTools(server, graphClient, readOnly = false, enabledToolsP
|
|
|
1718
1898
|
// All tools call Microsoft Graph API
|
|
1719
1899
|
}
|
|
1720
1900
|
},
|
|
1721
|
-
async (params) => executeGraphTool(tool, endpointConfig, graphClient, params, authManager)
|
|
1901
|
+
async (params) => executeGraphTool(tool, endpointConfig, graphClient, params, authManager, userFields)
|
|
1722
1902
|
);
|
|
1723
1903
|
registeredCount++;
|
|
1724
1904
|
} catch (error) {
|
|
@@ -1738,7 +1918,8 @@ function registerGraphTools(server, graphClient, readOnly = false, enabledToolsP
|
|
|
1738
1918
|
graphClient,
|
|
1739
1919
|
authManager,
|
|
1740
1920
|
multiAccount,
|
|
1741
|
-
accountNames
|
|
1921
|
+
accountNames,
|
|
1922
|
+
userFields
|
|
1742
1923
|
};
|
|
1743
1924
|
for (const utility of UTILITY_TOOLS) {
|
|
1744
1925
|
if (readOnly && !utility.readOnlyHint) continue;
|
|
@@ -1859,7 +2040,19 @@ function scoreDiscoveryQuery(query, index) {
|
|
|
1859
2040
|
ranked.sort((a, b) => b.score - a.score);
|
|
1860
2041
|
return ranked;
|
|
1861
2042
|
}
|
|
1862
|
-
function registerDiscoveryTools(server, graphClient,
|
|
2043
|
+
function registerDiscoveryTools(server, graphClient, options = {}) {
|
|
2044
|
+
const {
|
|
2045
|
+
readOnly = false,
|
|
2046
|
+
enabledTools,
|
|
2047
|
+
orgMode = false,
|
|
2048
|
+
authManager,
|
|
2049
|
+
multiAccount = false,
|
|
2050
|
+
accountNames = [],
|
|
2051
|
+
allowedScopes: allowedScopesValue,
|
|
2052
|
+
httpMode = false,
|
|
2053
|
+
userFields: userFieldsValue
|
|
2054
|
+
} = options;
|
|
2055
|
+
const userFields = parseUserFields(userFieldsValue);
|
|
1863
2056
|
let enabledToolsRegex;
|
|
1864
2057
|
if (enabledTools) {
|
|
1865
2058
|
try {
|
|
@@ -1906,7 +2099,8 @@ function registerDiscoveryTools(server, graphClient, readOnly = false, orgMode =
|
|
|
1906
2099
|
graphClient,
|
|
1907
2100
|
authManager,
|
|
1908
2101
|
multiAccount,
|
|
1909
|
-
accountNames
|
|
2102
|
+
accountNames,
|
|
2103
|
+
userFields
|
|
1910
2104
|
};
|
|
1911
2105
|
const utilityByName = new Map(utilityTools.map((u) => [u.name, u]));
|
|
1912
2106
|
const categoryNames = Object.keys(TOOL_CATEGORIES).join(", ");
|
|
@@ -2047,7 +2241,8 @@ function registerDiscoveryTools(server, graphClient, readOnly = false, orgMode =
|
|
|
2047
2241
|
toolData.config,
|
|
2048
2242
|
graphClient,
|
|
2049
2243
|
parameters,
|
|
2050
|
-
authManager
|
|
2244
|
+
authManager,
|
|
2245
|
+
userFields
|
|
2051
2246
|
);
|
|
2052
2247
|
}
|
|
2053
2248
|
const utility = utilityByName.get(tool_name);
|
|
@@ -24,6 +24,15 @@ function isPlainGraphPath(target) {
|
|
|
24
24
|
}
|
|
25
25
|
return resolved.origin === PROBE_ORIGIN && resolved.search === "" && resolved.hash === "" && resolved.pathname === PROBE_PREFIX + target;
|
|
26
26
|
}
|
|
27
|
+
function isUnalteredGraphPath(path) {
|
|
28
|
+
const decode = (s) => decodeURIComponent(s.replace(/%(?![0-9a-f]{2})/gi, "%25"));
|
|
29
|
+
try {
|
|
30
|
+
const resolved = new URL(PROBE_ORIGIN + PROBE_PREFIX + path);
|
|
31
|
+
return resolved.origin === PROBE_ORIGIN && resolved.search === "" && resolved.hash === "" && decode(resolved.pathname) === decode(PROBE_PREFIX + path);
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
27
36
|
const MAX_LIVE_TICKETS = 256;
|
|
28
37
|
const TICKET_BYTES = 32;
|
|
29
38
|
class TicketStoreFullError extends Error {
|
|
@@ -94,5 +103,6 @@ export {
|
|
|
94
103
|
TICKET_PARAM,
|
|
95
104
|
TicketStoreFullError,
|
|
96
105
|
buildAttachmentUrl,
|
|
97
|
-
isPlainGraphPath
|
|
106
|
+
isPlainGraphPath,
|
|
107
|
+
isUnalteredGraphPath
|
|
98
108
|
};
|
|
@@ -23,8 +23,9 @@ function parseSelectFields(value) {
|
|
|
23
23
|
)
|
|
24
24
|
];
|
|
25
25
|
}
|
|
26
|
-
function isAlwaysKept(key) {
|
|
27
|
-
|
|
26
|
+
function isAlwaysKept(key, strict = false) {
|
|
27
|
+
if (key.startsWith("@") || key.startsWith("_")) return true;
|
|
28
|
+
return !strict && key.toLowerCase() === "id";
|
|
28
29
|
}
|
|
29
30
|
const TRANSPORT_OK_MESSAGE = "OK!";
|
|
30
31
|
function isTransportEnvelope(data) {
|
|
@@ -50,28 +51,28 @@ function anyFieldPresent(data, fields) {
|
|
|
50
51
|
if (objects.length === 0) return true;
|
|
51
52
|
return objects.some((item) => Object.keys(item).some((key) => wanted.has(key.toLowerCase())));
|
|
52
53
|
}
|
|
53
|
-
function projectObject(item, wanted) {
|
|
54
|
+
function projectObject(item, wanted, strict) {
|
|
54
55
|
if (!isPlainObject(item)) return item;
|
|
55
56
|
const projected = {};
|
|
56
57
|
for (const [key, value] of Object.entries(item)) {
|
|
57
|
-
if (isAlwaysKept(key) || wanted.has(key.toLowerCase())) {
|
|
58
|
+
if (isAlwaysKept(key, strict) || wanted.has(key.toLowerCase())) {
|
|
58
59
|
projected[key] = value;
|
|
59
60
|
}
|
|
60
61
|
}
|
|
61
62
|
return projected;
|
|
62
63
|
}
|
|
63
|
-
function projectSelectedFields(data, fields) {
|
|
64
|
+
function projectSelectedFields(data, fields, strict = false) {
|
|
64
65
|
if (fields.length === 0) return data;
|
|
65
66
|
if (!data || typeof data !== "object") return data;
|
|
66
67
|
const wanted = new Set(fields.map((field) => field.toLowerCase()));
|
|
67
68
|
if (Array.isArray(data)) {
|
|
68
|
-
return data.map((item) => projectObject(item, wanted));
|
|
69
|
+
return data.map((item) => projectObject(item, wanted, strict));
|
|
69
70
|
}
|
|
70
71
|
const body = data;
|
|
71
72
|
if (Array.isArray(body.value)) {
|
|
72
|
-
return { ...body, value: body.value.map((item) => projectObject(item, wanted)) };
|
|
73
|
+
return { ...body, value: body.value.map((item) => projectObject(item, wanted, strict)) };
|
|
73
74
|
}
|
|
74
|
-
return projectObject(body, wanted);
|
|
75
|
+
return projectObject(body, wanted, strict);
|
|
75
76
|
}
|
|
76
77
|
export {
|
|
77
78
|
TRANSPORT_OK_MESSAGE,
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { parseSelectFields } from "./select-projection.js";
|
|
2
|
+
const USER_ENTITY_PATH = /^\/users(?:\/[^/]+)?\/?$/i;
|
|
3
|
+
const DIRECTORY_NAVIGATION_PATH = /^(?:\/me|\/users\/[^/]+)\/(?:manager|directReports)\/?$|^\/groups\/[^/]+\/(?:members|owners)\/?$/i;
|
|
4
|
+
const EXPAND_USER_PATH = /^\/me\/?$|^\/groups(?:\/[^/]+)?\/?$/i;
|
|
5
|
+
function normalizePath(path) {
|
|
6
|
+
const withoutQuery = path.split("?")[0];
|
|
7
|
+
return withoutQuery.startsWith("/") ? withoutQuery : `/${withoutQuery}`;
|
|
8
|
+
}
|
|
9
|
+
function userFieldEnforcement(path) {
|
|
10
|
+
const normalized = normalizePath(path);
|
|
11
|
+
if (USER_ENTITY_PATH.test(normalized)) return "request-and-response";
|
|
12
|
+
if (DIRECTORY_NAVIGATION_PATH.test(normalized)) return "response-only";
|
|
13
|
+
return "none";
|
|
14
|
+
}
|
|
15
|
+
function targetsUserProfile(path) {
|
|
16
|
+
return userFieldEnforcement(path) !== "none";
|
|
17
|
+
}
|
|
18
|
+
function shouldStripUserFieldExpand(path) {
|
|
19
|
+
const normalized = normalizePath(path);
|
|
20
|
+
return userFieldEnforcement(normalized) !== "none" || EXPAND_USER_PATH.test(normalized);
|
|
21
|
+
}
|
|
22
|
+
function stripUserFieldExpandFromUrl(url) {
|
|
23
|
+
const [path, query = ""] = url.split("?");
|
|
24
|
+
if (!shouldStripUserFieldExpand(path) || query === "") return url;
|
|
25
|
+
const params = new URLSearchParams(query);
|
|
26
|
+
if (!params.has("$expand")) return url;
|
|
27
|
+
params.delete("$expand");
|
|
28
|
+
const rebuilt = params.toString();
|
|
29
|
+
if (rebuilt === "") return path;
|
|
30
|
+
return `${path}?${rebuilt.replace(/%24/gi, "$").replace(/%2C/gi, ",")}`;
|
|
31
|
+
}
|
|
32
|
+
function effectiveUserFields(requestedSelect, allowlist) {
|
|
33
|
+
const allowed = requestedSelect.filter(
|
|
34
|
+
(field) => allowlist.some((entry) => entry.toLowerCase() === field.toLowerCase())
|
|
35
|
+
);
|
|
36
|
+
return allowed.length > 0 ? allowed : allowlist;
|
|
37
|
+
}
|
|
38
|
+
function restrictUserFieldQuery(queryParams, allowlist, enforcement) {
|
|
39
|
+
const fields = effectiveUserFields(parseSelectFields(queryParams["$select"]), allowlist);
|
|
40
|
+
if (enforcement === "request-and-response") {
|
|
41
|
+
queryParams["$select"] = fields.join(",");
|
|
42
|
+
}
|
|
43
|
+
delete queryParams["$expand"];
|
|
44
|
+
return fields;
|
|
45
|
+
}
|
|
46
|
+
function restrictUserFieldUrl(url, allowlist, enforcement) {
|
|
47
|
+
const [path, query = ""] = url.split("?");
|
|
48
|
+
const params = new URLSearchParams(query);
|
|
49
|
+
const fields = effectiveUserFields(
|
|
50
|
+
parseSelectFields(params.get("$select") ?? void 0),
|
|
51
|
+
allowlist
|
|
52
|
+
);
|
|
53
|
+
if (enforcement === "request-and-response") {
|
|
54
|
+
params.set("$select", fields.join(","));
|
|
55
|
+
}
|
|
56
|
+
params.delete("$expand");
|
|
57
|
+
const rebuilt = params.toString();
|
|
58
|
+
if (rebuilt === "") return { url: path, fields };
|
|
59
|
+
return { url: `${path}?${rebuilt.replace(/%24/gi, "$").replace(/%2C/gi, ",")}`, fields };
|
|
60
|
+
}
|
|
61
|
+
export {
|
|
62
|
+
effectiveUserFields,
|
|
63
|
+
restrictUserFieldQuery,
|
|
64
|
+
restrictUserFieldUrl,
|
|
65
|
+
shouldStripUserFieldExpand,
|
|
66
|
+
stripUserFieldExpandFromUrl,
|
|
67
|
+
targetsUserProfile,
|
|
68
|
+
userFieldEnforcement
|
|
69
|
+
};
|