@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.
@@ -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({
@@ -1,58 +1,67 @@
1
1
  import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
2
2
  import { randomUUID } from "crypto";
3
- import logger from "./logger.js";
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 { isDestructiveOperation } from "./lib/destructive-ops.js";
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
- const allEndpoints = [...api.endpoints, ...betaApi.endpoints];
27
- import { z } from "zod";
28
- import { readFileSync } from "fs";
29
- import { access } from "fs/promises";
30
- import path from "path";
31
- import { fileURLToPath } from "url";
32
- import { TOOL_CATEGORIES } from "./tool-categories.js";
33
- import { getRequestTokens } from "./request-context.js";
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 { deriveTargetResource } from "./audit-target-resource.js";
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
- TIMEZONE_PARAM_DESCRIPTION,
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
- isSkiptokenApplicable
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
- async function executeGraphTool(tool, config, graphClient, params, authManager) {
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 encodedValue = shouldSkipEncoding ? paramValue : encodeURIComponent(paramValue).replace(/%3D/g, "=");
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(paramValue).replace(/%3D/g, "=");
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([...requestedSelect, ...parseSelectFields(queryParams["$expand"])])
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
- if (mergePages || willProject) {
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, requestedSelect)) {
1604
+ if (!isUserFieldBoundary && !anyFieldPresent(body2, projectionSelect)) {
1446
1605
  logger.warn(
1447
- `None of the requested $select fields (${requestedSelect.join(",")}) appear in the response; returning it untrimmed`
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, readOnly = false, enabledToolsPattern, orgMode = false, authManager, multiAccount = false, accountNames = [], allowedScopesValue, httpMode = false) {
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, readOnly = false, orgMode = false, authManager, multiAccount = false, accountNames = [], enabledTools, allowedScopesValue, httpMode = false) {
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
- return key.toLowerCase() === "id" || key.startsWith("@") || key.startsWith("_");
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
+ };