@softeria/ms-365-mcp-server 0.149.1 → 0.150.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.
@@ -2822,6 +2822,197 @@ describe("graph-tools", () => {
2822
2822
  expect(server.tools.get("destructive-tool").schema).toHaveProperty("confirm");
2823
2823
  });
2824
2824
  });
2825
+ describe("$select projection", () => {
2826
+ const untrimmed = {
2827
+ value: [
2828
+ {
2829
+ id: "MSo",
2830
+ subject: "Standup",
2831
+ joinWebUrl: "https://teams/x",
2832
+ joinInformation: { content: "<div>invite</div>" },
2833
+ joinMeetingIdSettings: { passcode: "123456" }
2834
+ }
2835
+ ]
2836
+ };
2837
+ async function run(args, responses, outputFormat = "json") {
2838
+ mockEndpoints.push(makeEndpoint());
2839
+ mockEndpointsJson = [makeConfig()];
2840
+ const graphClient = createMockGraphClient(
2841
+ responses ?? [{ content: [{ type: "text", text: JSON.stringify(untrimmed) }] }],
2842
+ outputFormat
2843
+ );
2844
+ const server = createMockServer();
2845
+ const { registerGraphTools } = await loadModule();
2846
+ registerGraphTools(server, graphClient);
2847
+ const result = await server.tools.get("test-tool").handler(args);
2848
+ return { result, graphClient };
2849
+ }
2850
+ it("narrows the body to the requested fields when Graph ignored $select", async () => {
2851
+ const { result } = await run({ select: "id,subject,joinWebUrl" });
2852
+ expect(JSON.parse(result.content[0].text).value[0]).toEqual({
2853
+ id: "MSo",
2854
+ subject: "Standup",
2855
+ joinWebUrl: "https://teams/x"
2856
+ });
2857
+ });
2858
+ it("leaves the body untouched when no select was passed", async () => {
2859
+ const { result } = await run({});
2860
+ expect(JSON.parse(result.content[0].text)).toEqual(untrimmed);
2861
+ });
2862
+ it("forces JSON so a --toon client still gets a trimmed body", async () => {
2863
+ const { result, graphClient } = await run({ select: "id,subject" }, void 0, "toon");
2864
+ expect(graphClient.graphRequest).toHaveBeenCalledWith(
2865
+ expect.any(String),
2866
+ expect.objectContaining({ forceJsonOutput: true })
2867
+ );
2868
+ expect(result.content[0].text.startsWith("TOON:")).toBe(true);
2869
+ expect(result.content[0].text).not.toContain("joinInformation");
2870
+ });
2871
+ it("still projects when --toon and fetchAllPages are combined", async () => {
2872
+ const { result } = await run(
2873
+ { select: "id,subject", fetchAllPages: true },
2874
+ [
2875
+ {
2876
+ content: [
2877
+ {
2878
+ type: "text",
2879
+ text: JSON.stringify({
2880
+ value: [{ id: "1", subject: "a", joinInformation: { content: "huge" } }],
2881
+ "@odata.nextLink": "https://graph.microsoft.com/v1.0/me/messages?$skip=1"
2882
+ })
2883
+ }
2884
+ ]
2885
+ },
2886
+ {
2887
+ content: [
2888
+ {
2889
+ type: "text",
2890
+ text: JSON.stringify({
2891
+ value: [{ id: "2", subject: "b", joinInformation: { content: "huge" } }]
2892
+ })
2893
+ }
2894
+ ]
2895
+ }
2896
+ ],
2897
+ "toon"
2898
+ );
2899
+ expect(result.content[0].text.startsWith("TOON:")).toBe(true);
2900
+ expect(result.content[0].text).not.toContain("joinInformation");
2901
+ const merged = JSON.parse(result.content[0].text.slice("TOON:".length));
2902
+ expect(merged.value).toEqual([
2903
+ { id: "1", subject: "a" },
2904
+ { id: "2", subject: "b" }
2905
+ ]);
2906
+ });
2907
+ it("does not project a raw-text transport envelope", async () => {
2908
+ const envelope = { message: "OK!", rawResponse: "WEBVTT\n\n00:00:01.000 --> 00:00:02.000" };
2909
+ const { result } = await run({ select: "id" }, [
2910
+ { content: [{ type: "text", text: JSON.stringify(envelope) }] }
2911
+ ]);
2912
+ expect(JSON.parse(result.content[0].text)).toEqual(envelope);
2913
+ });
2914
+ it("does not project a binary transport envelope", async () => {
2915
+ const envelope = {
2916
+ message: "OK!",
2917
+ contentType: "video/mp4",
2918
+ encoding: "base64",
2919
+ contentLength: 3,
2920
+ contentBytes: "AAA"
2921
+ };
2922
+ const { result } = await run({ select: "id" }, [
2923
+ { content: [{ type: "text", text: JSON.stringify(envelope) }] }
2924
+ ]);
2925
+ expect(JSON.parse(result.content[0].text)).toEqual(envelope);
2926
+ });
2927
+ it("keeps an $expand-ed navigation property that was not selected", async () => {
2928
+ const { result } = await run({ select: "subject", expand: "attachments" }, [
2929
+ {
2930
+ content: [
2931
+ {
2932
+ type: "text",
2933
+ text: JSON.stringify({
2934
+ id: "1",
2935
+ subject: "a",
2936
+ attachments: [{ id: "att1" }],
2937
+ bodyPreview: "huge"
2938
+ })
2939
+ }
2940
+ ]
2941
+ }
2942
+ ]);
2943
+ const body = JSON.parse(result.content[0].text);
2944
+ expect(body.attachments).toEqual([{ id: "att1" }]);
2945
+ expect(body).not.toHaveProperty("bodyPreview");
2946
+ });
2947
+ it("returns the body untrimmed when no requested field is present", async () => {
2948
+ const body = { id: "1", joinWebUrl: "u", joinInformation: { content: "huge" } };
2949
+ const { result } = await run({ select: "joinUrl" }, [
2950
+ { content: [{ type: "text", text: JSON.stringify(body) }] }
2951
+ ]);
2952
+ expect(JSON.parse(result.content[0].text)).toEqual(body);
2953
+ });
2954
+ it("keeps the _etag that includeHeaders adds to a single resource", async () => {
2955
+ const { result } = await run({ select: "subject", includeHeaders: true }, [
2956
+ {
2957
+ content: [
2958
+ {
2959
+ type: "text",
2960
+ text: JSON.stringify({ id: "1", subject: "a", _etag: 'W/"1"', bodyPreview: "huge" })
2961
+ }
2962
+ ]
2963
+ }
2964
+ ]);
2965
+ expect(JSON.parse(result.content[0].text)).toEqual({
2966
+ id: "1",
2967
+ subject: "a",
2968
+ _etag: 'W/"1"'
2969
+ });
2970
+ });
2971
+ it("does not arm projection when excludeResponse was requested", async () => {
2972
+ const { result, graphClient } = await run({ select: "id", excludeResponse: true }, [
2973
+ { content: [{ type: "text", text: JSON.stringify({ success: true }) }] }
2974
+ ]);
2975
+ expect(graphClient.graphRequest).toHaveBeenCalledWith(
2976
+ expect.any(String),
2977
+ expect.not.objectContaining({ forceJsonOutput: true })
2978
+ );
2979
+ expect(JSON.parse(result.content[0].text)).toEqual({ success: true });
2980
+ });
2981
+ it("does not project an error body on the merge path", async () => {
2982
+ const errorBody = { error: "Microsoft Graph API error: 403 Forbidden", code: "accessDenied" };
2983
+ const { result } = await run({ select: "error", fetchAllPages: true }, [
2984
+ { content: [{ type: "text", text: JSON.stringify(errorBody) }], isError: true }
2985
+ ]);
2986
+ expect(JSON.parse(result.content[0].text)).toEqual(errorBody);
2987
+ });
2988
+ it("leaves an error from a later page alone", async () => {
2989
+ const errorBody = { error: "Microsoft Graph API error: 503 Service Unavailable" };
2990
+ const { result } = await run(
2991
+ { select: "error", fetchAllPages: true },
2992
+ [
2993
+ {
2994
+ content: [
2995
+ {
2996
+ type: "text",
2997
+ text: JSON.stringify({
2998
+ value: [{ id: "1", subject: "a" }],
2999
+ "@odata.nextLink": "https://graph.microsoft.com/v1.0/me/messages?$skip=1"
3000
+ })
3001
+ }
3002
+ ]
3003
+ },
3004
+ { content: [{ type: "text", text: JSON.stringify(errorBody) }], isError: true }
3005
+ ],
3006
+ "toon"
3007
+ );
3008
+ expect(result.content[0].text.startsWith("TOON:")).toBe(false);
3009
+ expect(JSON.parse(result.content[0].text)).toEqual(errorBody);
3010
+ });
3011
+ it("returns the body untrimmed when the only real field is a typo alongside id", async () => {
3012
+ const { result } = await run({ select: "id,joinUrl" });
3013
+ expect(JSON.parse(result.content[0].text)).toEqual(untrimmed);
3014
+ });
3015
+ });
2825
3016
  describe("isDestructiveOperation", () => {
2826
3017
  it("returns true for POST, PATCH, PUT, DELETE", async () => {
2827
3018
  const { isDestructiveOperation } = await loadModule();
@@ -2081,7 +2081,7 @@
2081
2081
  "toolName": "list-online-meetings",
2082
2082
  "presets": ["teams", "work"],
2083
2083
  "workScopes": ["OnlineMeetings.Read"],
2084
- "llmTip": "Look up a meeting with $filter=JoinWebUrl eq '{url}', using the joinUrl from an event's onlineMeeting property. This filter is required: the collection cannot be listed or paged, and $search/$orderby are not supported. Without it Graph returns 400 InvalidArgument ('One of the required parameters to lookup meeting by QueryOptions is null or empty'). Returns the meeting ID needed for transcript access."
2084
+ "llmTip": "Look up a meeting with $filter=JoinWebUrl eq '{url}', using the joinUrl from an event's onlineMeeting property. This filter is required: the collection cannot be listed or paged, and $search/$orderby are not supported. Without it Graph returns 400 InvalidArgument ('One of the required parameters to lookup meeting by QueryOptions is null or empty'). Returns the meeting ID needed for transcript access. The full meeting object is large and can include the invite HTML and the meeting passcode, so pass select=id,subject,joinWebUrl unless you need more."
2085
2085
  },
2086
2086
  {
2087
2087
  "pathPattern": "/me/onlineMeetings/{onlineMeeting-id}",
@@ -2089,7 +2089,7 @@
2089
2089
  "toolName": "get-online-meeting",
2090
2090
  "presets": ["teams", "work"],
2091
2091
  "workScopes": ["OnlineMeetings.Read"],
2092
- "llmTip": "Gets a specific online meeting by ID. Returns full details including subject, startDateTime, endDateTime, joinWebUrl, chatInfo (threadId for meeting chat), participants (organizer, attendees), lobbyBypassSettings, and videoTeleconferenceId. Use list-online-meetings first to find the meeting ID."
2092
+ "llmTip": "Gets a specific online meeting by ID. Returns full details including subject, startDateTime, endDateTime, joinWebUrl, chatInfo (threadId for meeting chat), participants (organizer, attendees), lobbyBypassSettings, and videoTeleconferenceId. Use list-online-meetings first to find the meeting ID. The full meeting object is large and can include the invite HTML and the meeting passcode, so pass select with the fields you came here for, e.g. select=id,chatInfo,participants."
2093
2093
  },
2094
2094
  {
2095
2095
  "pathPattern": "/me/onlineMeetings",
@@ -8,6 +8,7 @@ import {
8
8
  loadResilienceConfig
9
9
  } from "./lib/graph-resilience.js";
10
10
  import { applyMessageSignoffToRequest } from "./lib/message-signoff.js";
11
+ import { TRANSPORT_OK_MESSAGE } from "./lib/select-projection.js";
11
12
  import { open, stat, unlink } from "fs/promises";
12
13
  import { pipeline } from "stream/promises";
13
14
  function isBinaryContentType(contentType) {
@@ -118,7 +119,7 @@ class GraphClient {
118
119
  if (isBinaryResponse) {
119
120
  const buffer = Buffer.from(await response.arrayBuffer());
120
121
  result = {
121
- message: "OK!",
122
+ message: TRANSPORT_OK_MESSAGE,
122
123
  contentType: contentTypeHeader,
123
124
  encoding: "base64",
124
125
  contentLength: buffer.byteLength,
@@ -127,14 +128,14 @@ class GraphClient {
127
128
  } else {
128
129
  const text = await response.text();
129
130
  if (text === "") {
130
- result = { message: "OK!" };
131
+ result = { message: TRANSPORT_OK_MESSAGE };
131
132
  } else if (options.rawResponse) {
132
- result = { message: "OK!", rawResponse: text };
133
+ result = { message: TRANSPORT_OK_MESSAGE, rawResponse: text };
133
134
  } else {
134
135
  try {
135
136
  result = JSON.parse(text);
136
137
  } catch {
137
- result = { message: "OK!", rawResponse: text };
138
+ result = { message: TRANSPORT_OK_MESSAGE, rawResponse: text };
138
139
  }
139
140
  }
140
141
  }
@@ -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 {
8
+ anyFieldPresent,
9
+ isTransportEnvelope,
10
+ parseSelectFields,
11
+ projectSelectedFields
12
+ } from "./lib/select-projection.js";
7
13
  import {
8
14
  getEndpointScopeGroups,
9
15
  getMissingAllowedScopesForGroups,
@@ -1060,6 +1066,10 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
1060
1066
  headers["Accept"] = config.acceptType;
1061
1067
  logger.info(`Setting custom Accept: ${config.acceptType}`);
1062
1068
  }
1069
+ const requestedSelect = parseSelectFields(queryParams["$select"]);
1070
+ const keepFields = [
1071
+ .../* @__PURE__ */ new Set([...requestedSelect, ...parseSelectFields(queryParams["$expand"])])
1072
+ ];
1063
1073
  if (Object.keys(queryParams).length > 0) {
1064
1074
  const queryString = Object.entries(queryParams).map(([key, value]) => `${key}=${encodeURIComponent(value).replace(/%2C/gi, ",")}`).join("&");
1065
1075
  path2 = `${path2}${path2.includes("?") ? "&" : "?"}${queryString}`;
@@ -1123,10 +1133,26 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
1123
1133
  );
1124
1134
  }
1125
1135
  const mergePages = fetchAllPages && paginationEnabled;
1126
- if (mergePages) {
1136
+ const willProject = requestedSelect.length > 0 && params.excludeResponse !== true;
1137
+ if (mergePages || willProject) {
1127
1138
  options.forceJsonOutput = true;
1128
1139
  }
1129
1140
  let response = await graphClient.graphRequest(path2, options);
1141
+ const shouldProject = willProject && !response?.isError;
1142
+ let projectionHandled = false;
1143
+ const applyProjection = (body2) => {
1144
+ if (isTransportEnvelope(body2)) {
1145
+ logger.info("Skipping $select projection: body is a transport envelope, not a resource");
1146
+ return body2;
1147
+ }
1148
+ if (!anyFieldPresent(body2, requestedSelect)) {
1149
+ logger.warn(
1150
+ `None of the requested $select fields (${requestedSelect.join(",")}) appear in the response; returning it untrimmed`
1151
+ );
1152
+ return body2;
1153
+ }
1154
+ return projectSelectedFields(body2, keepFields);
1155
+ };
1130
1156
  if (mergePages && response?.content?.[0]?.text) {
1131
1157
  let combinedResponse;
1132
1158
  try {
@@ -1190,7 +1216,16 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
1190
1216
  logger.error(`Error during pagination: ${e}`);
1191
1217
  }
1192
1218
  if (combinedResponse !== void 0) {
1193
- response.content[0].text = graphClient.serialize(combinedResponse);
1219
+ const merged = shouldProject ? applyProjection(combinedResponse) : combinedResponse;
1220
+ projectionHandled = shouldProject;
1221
+ response.content[0].text = graphClient.serialize(merged);
1222
+ }
1223
+ }
1224
+ if (shouldProject && !projectionHandled && !response?.isError && response?.content?.[0]?.text) {
1225
+ try {
1226
+ const parsed = JSON.parse(response.content[0].text);
1227
+ response.content[0].text = graphClient.serialize(applyProjection(parsed));
1228
+ } catch {
1194
1229
  }
1195
1230
  }
1196
1231
  if (response?.content?.[0]?.text) {
@@ -114,6 +114,61 @@ function toOAuthErrorResponse(error) {
114
114
  }
115
115
  };
116
116
  }
117
+ const PUBLIC_CLIENT_SECRET_REJECTED = 700025;
118
+ function isPublicClientRejection(body) {
119
+ return Array.isArray(body?.error_codes) && body.error_codes.includes(PUBLIC_CLIENT_SECRET_REJECTED);
120
+ }
121
+ async function postTokenRequest(tokenUrl, params) {
122
+ const response = await fetch(tokenUrl, {
123
+ method: "POST",
124
+ headers: {
125
+ "Content-Type": "application/x-www-form-urlencoded"
126
+ },
127
+ body: params
128
+ });
129
+ if (response.ok) {
130
+ return { ok: true, json: await response.json() };
131
+ }
132
+ const raw = await response.text();
133
+ return { ok: false, status: response.status, raw, parsed: parseUpstreamOAuthError(raw) };
134
+ }
135
+ async function requestToken(tokenUrl, params, clientSecret, failureMessage) {
136
+ if (!clientSecret) {
137
+ return unwrapToken(await postTokenRequest(tokenUrl, params), failureMessage);
138
+ }
139
+ const withSecret = new URLSearchParams(params);
140
+ withSecret.append("client_secret", clientSecret);
141
+ let result = await postTokenRequest(tokenUrl, withSecret);
142
+ if (!result.ok && isPublicClientRejection(result.parsed)) {
143
+ logger.info(
144
+ "Upstream rejected client_secret as a public client (AADSTS700025) \u2014 retrying without it",
145
+ {
146
+ status: result.status,
147
+ error_codes: result.parsed?.error_codes,
148
+ correlation_id: result.parsed?.correlation_id
149
+ }
150
+ );
151
+ result = await postTokenRequest(tokenUrl, params);
152
+ }
153
+ return unwrapToken(result, failureMessage);
154
+ }
155
+ function unwrapToken(result, failureMessage) {
156
+ if (result.ok) {
157
+ return result.json;
158
+ }
159
+ if (result.parsed) {
160
+ logger.warn(`Token endpoint upstream OAuth error: ${result.parsed.error}`, {
161
+ status: result.status,
162
+ error: result.parsed.error,
163
+ suberror: result.parsed.suberror,
164
+ error_codes: result.parsed.error_codes,
165
+ correlation_id: result.parsed.correlation_id
166
+ });
167
+ throw new OAuthUpstreamError(result.status, result.raw, result.parsed);
168
+ }
169
+ logger.error(`${failureMessage}: ${result.raw}`);
170
+ throw new Error(`${failureMessage}: ${result.raw}`);
171
+ }
117
172
  async function exchangeCodeForToken(code, redirectUri, clientId, clientSecret, tenantId = "common", codeVerifier, cloudType = "global") {
118
173
  const cloudEndpoints = getCloudEndpoints(cloudType);
119
174
  const params = new URLSearchParams({
@@ -122,36 +177,15 @@ async function exchangeCodeForToken(code, redirectUri, clientId, clientSecret, t
122
177
  redirect_uri: redirectUri,
123
178
  client_id: clientId
124
179
  });
125
- if (clientSecret) {
126
- params.append("client_secret", clientSecret);
127
- }
128
180
  if (codeVerifier) {
129
181
  params.append("code_verifier", codeVerifier);
130
182
  }
131
- const response = await fetch(`${cloudEndpoints.authority}/${tenantId}/oauth2/v2.0/token`, {
132
- method: "POST",
133
- headers: {
134
- "Content-Type": "application/x-www-form-urlencoded"
135
- },
136
- body: params
137
- });
138
- if (!response.ok) {
139
- const raw = await response.text();
140
- const parsed = parseUpstreamOAuthError(raw);
141
- if (parsed) {
142
- logger.warn(`Token endpoint upstream OAuth error: ${parsed.error}`, {
143
- status: response.status,
144
- error: parsed.error,
145
- suberror: parsed.suberror,
146
- error_codes: parsed.error_codes,
147
- correlation_id: parsed.correlation_id
148
- });
149
- throw new OAuthUpstreamError(response.status, raw, parsed);
150
- }
151
- logger.error(`Failed to exchange code for token: ${raw}`);
152
- throw new Error(`Failed to exchange code for token: ${raw}`);
153
- }
154
- return response.json();
183
+ return requestToken(
184
+ `${cloudEndpoints.authority}/${tenantId}/oauth2/v2.0/token`,
185
+ params,
186
+ clientSecret,
187
+ "Failed to exchange code for token"
188
+ );
155
189
  }
156
190
  async function refreshAccessToken(refreshToken, clientId, clientSecret, tenantId = "common", cloudType = "global") {
157
191
  const cloudEndpoints = getCloudEndpoints(cloudType);
@@ -160,33 +194,12 @@ async function refreshAccessToken(refreshToken, clientId, clientSecret, tenantId
160
194
  refresh_token: refreshToken,
161
195
  client_id: clientId
162
196
  });
163
- if (clientSecret) {
164
- params.append("client_secret", clientSecret);
165
- }
166
- const response = await fetch(`${cloudEndpoints.authority}/${tenantId}/oauth2/v2.0/token`, {
167
- method: "POST",
168
- headers: {
169
- "Content-Type": "application/x-www-form-urlencoded"
170
- },
171
- body: params
172
- });
173
- if (!response.ok) {
174
- const raw = await response.text();
175
- const parsed = parseUpstreamOAuthError(raw);
176
- if (parsed) {
177
- logger.warn(`Token endpoint upstream OAuth error: ${parsed.error}`, {
178
- status: response.status,
179
- error: parsed.error,
180
- suberror: parsed.suberror,
181
- error_codes: parsed.error_codes,
182
- correlation_id: parsed.correlation_id
183
- });
184
- throw new OAuthUpstreamError(response.status, raw, parsed);
185
- }
186
- logger.error(`Failed to refresh token: ${raw}`);
187
- throw new Error(`Failed to refresh token: ${raw}`);
188
- }
189
- return response.json();
197
+ return requestToken(
198
+ `${cloudEndpoints.authority}/${tenantId}/oauth2/v2.0/token`,
199
+ params,
200
+ clientSecret,
201
+ "Failed to refresh token"
202
+ );
190
203
  }
191
204
  export {
192
205
  OAuthUpstreamError,
@@ -0,0 +1,82 @@
1
+ function splitTopLevel(value) {
2
+ const parts = [];
3
+ let depth = 0;
4
+ let current = "";
5
+ for (const char of value) {
6
+ if (char === "(") depth++;
7
+ else if (char === ")") depth = Math.max(0, depth - 1);
8
+ if (char === "," && depth === 0) {
9
+ parts.push(current);
10
+ current = "";
11
+ } else {
12
+ current += char;
13
+ }
14
+ }
15
+ parts.push(current);
16
+ return parts;
17
+ }
18
+ function parseSelectFields(value) {
19
+ if (!value) return [];
20
+ return [
21
+ ...new Set(
22
+ splitTopLevel(value).map((field) => field.trim().split(/[/()]/)[0].trim()).filter(Boolean)
23
+ )
24
+ ];
25
+ }
26
+ function isAlwaysKept(key) {
27
+ return key.toLowerCase() === "id" || key.startsWith("@") || key.startsWith("_");
28
+ }
29
+ const TRANSPORT_OK_MESSAGE = "OK!";
30
+ function isTransportEnvelope(data) {
31
+ if (!data || typeof data !== "object" || Array.isArray(data)) return false;
32
+ const body = data;
33
+ if (body.message === TRANSPORT_OK_MESSAGE) return true;
34
+ return body.success === true && Object.keys(body).length === 1;
35
+ }
36
+ function candidateObjects(data) {
37
+ if (Array.isArray(data)) return data.filter(isPlainObject);
38
+ if (!isPlainObject(data)) return [];
39
+ if (Array.isArray(data.value)) return data.value.filter(isPlainObject);
40
+ return [data];
41
+ }
42
+ function isPlainObject(value) {
43
+ return !!value && typeof value === "object" && !Array.isArray(value);
44
+ }
45
+ function anyFieldPresent(data, fields) {
46
+ const checkable = fields.filter((field) => !isAlwaysKept(field));
47
+ if (checkable.length === 0) return true;
48
+ const wanted = new Set(checkable.map((field) => field.toLowerCase()));
49
+ const objects = candidateObjects(data);
50
+ if (objects.length === 0) return true;
51
+ return objects.some((item) => Object.keys(item).some((key) => wanted.has(key.toLowerCase())));
52
+ }
53
+ function projectObject(item, wanted) {
54
+ if (!isPlainObject(item)) return item;
55
+ const projected = {};
56
+ for (const [key, value] of Object.entries(item)) {
57
+ if (isAlwaysKept(key) || wanted.has(key.toLowerCase())) {
58
+ projected[key] = value;
59
+ }
60
+ }
61
+ return projected;
62
+ }
63
+ function projectSelectedFields(data, fields) {
64
+ if (fields.length === 0) return data;
65
+ if (!data || typeof data !== "object") return data;
66
+ const wanted = new Set(fields.map((field) => field.toLowerCase()));
67
+ if (Array.isArray(data)) {
68
+ return data.map((item) => projectObject(item, wanted));
69
+ }
70
+ const body = data;
71
+ if (Array.isArray(body.value)) {
72
+ return { ...body, value: body.value.map((item) => projectObject(item, wanted)) };
73
+ }
74
+ return projectObject(body, wanted);
75
+ }
76
+ export {
77
+ TRANSPORT_OK_MESSAGE,
78
+ anyFieldPresent,
79
+ isTransportEnvelope,
80
+ parseSelectFields,
81
+ projectSelectedFields
82
+ };
package/dist/server.js CHANGED
@@ -45,6 +45,7 @@ function parseHttpOption(httpOption) {
45
45
  const port = parseInt(httpString) || 3e3;
46
46
  return { host: void 0, port };
47
47
  }
48
+ const PKCE_MAX_AGE_MS = 60 * 60 * 1e3;
48
49
  class MicrosoftGraphServer {
49
50
  constructor(authManager, options = {}) {
50
51
  this.version = "0.0.0";
@@ -318,17 +319,18 @@ class MicrosoftGraphServer {
318
319
  microsoftAuthUrl.searchParams.set(param, value);
319
320
  }
320
321
  });
321
- if (clientCodeChallenge && state) {
322
- const serverCodeVerifier = crypto.randomBytes(32).toString("base64url");
323
- const serverCodeChallenge = crypto.createHash("sha256").update(serverCodeVerifier).digest("base64url");
322
+ if (clientCodeChallenge) {
324
323
  const now = Date.now();
325
- const maxAge = 10 * 60 * 1e3;
326
- const maxEntries = 1e3;
327
324
  for (const [key, value] of this.pkceStore) {
328
- if (now - value.createdAt > maxAge) {
325
+ if (now - value.createdAt > PKCE_MAX_AGE_MS || value.clientCodeChallenge === clientCodeChallenge) {
329
326
  this.pkceStore.delete(key);
330
327
  }
331
328
  }
329
+ }
330
+ if (clientCodeChallenge && state) {
331
+ const serverCodeVerifier = crypto.randomBytes(32).toString("base64url");
332
+ const serverCodeChallenge = crypto.createHash("sha256").update(serverCodeVerifier).digest("base64url");
333
+ const maxEntries = 1e3;
332
334
  if (this.pkceStore.size >= maxEntries) {
333
335
  logger.warn(
334
336
  `PKCE store at capacity (${maxEntries} entries) \u2014 rejecting new authorization request`
@@ -341,7 +343,6 @@ class MicrosoftGraphServer {
341
343
  }
342
344
  this.pkceStore.set(state, {
343
345
  clientCodeChallenge,
344
- clientCodeChallengeMethod: clientCodeChallengeMethod || "S256",
345
346
  serverCodeVerifier,
346
347
  createdAt: Date.now()
347
348
  });
@@ -405,14 +406,15 @@ class MicrosoftGraphServer {
405
406
  tenantId,
406
407
  hasClientSecret: !!clientSecret
407
408
  });
408
- let serverCodeVerifier;
409
+ let matchedPkceState;
410
+ let matchedPkceEntry;
409
411
  if (body.code_verifier) {
410
412
  const clientVerifier = body.code_verifier;
411
413
  const clientChallengeComputed = crypto.createHash("sha256").update(clientVerifier).digest("base64url");
412
414
  for (const [state, pkceData] of this.pkceStore) {
413
415
  if (pkceData.clientCodeChallenge === clientChallengeComputed) {
414
- serverCodeVerifier = pkceData.serverCodeVerifier;
415
- this.pkceStore.delete(state);
416
+ matchedPkceState = state;
417
+ matchedPkceEntry = pkceData;
416
418
  logger.info("Two-leg PKCE: matched client verifier, using server verifier", {
417
419
  state: state.substring(0, 8) + "..."
418
420
  });
@@ -426,9 +428,12 @@ class MicrosoftGraphServer {
426
428
  clientId,
427
429
  clientSecret,
428
430
  tenantId,
429
- serverCodeVerifier || body.code_verifier,
431
+ matchedPkceEntry?.serverCodeVerifier || body.code_verifier,
430
432
  this.secrets.cloudType
431
433
  );
434
+ if (matchedPkceState && this.pkceStore.get(matchedPkceState) === matchedPkceEntry) {
435
+ this.pkceStore.delete(matchedPkceState);
436
+ }
432
437
  res.json(result);
433
438
  } else if (body.grant_type === "refresh_token") {
434
439
  const tenantId = this.secrets?.tenantId || "common";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@softeria/ms-365-mcp-server",
3
3
  "mcpName": "io.github.Softeria/ms-365-mcp-server",
4
- "version": "0.149.1",
4
+ "version": "0.150.1",
5
5
  "description": " A Model Context Protocol (MCP) server for interacting with Microsoft 365 and Office services through the Graph API",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
@@ -2081,7 +2081,7 @@
2081
2081
  "toolName": "list-online-meetings",
2082
2082
  "presets": ["teams", "work"],
2083
2083
  "workScopes": ["OnlineMeetings.Read"],
2084
- "llmTip": "Look up a meeting with $filter=JoinWebUrl eq '{url}', using the joinUrl from an event's onlineMeeting property. This filter is required: the collection cannot be listed or paged, and $search/$orderby are not supported. Without it Graph returns 400 InvalidArgument ('One of the required parameters to lookup meeting by QueryOptions is null or empty'). Returns the meeting ID needed for transcript access."
2084
+ "llmTip": "Look up a meeting with $filter=JoinWebUrl eq '{url}', using the joinUrl from an event's onlineMeeting property. This filter is required: the collection cannot be listed or paged, and $search/$orderby are not supported. Without it Graph returns 400 InvalidArgument ('One of the required parameters to lookup meeting by QueryOptions is null or empty'). Returns the meeting ID needed for transcript access. The full meeting object is large and can include the invite HTML and the meeting passcode, so pass select=id,subject,joinWebUrl unless you need more."
2085
2085
  },
2086
2086
  {
2087
2087
  "pathPattern": "/me/onlineMeetings/{onlineMeeting-id}",
@@ -2089,7 +2089,7 @@
2089
2089
  "toolName": "get-online-meeting",
2090
2090
  "presets": ["teams", "work"],
2091
2091
  "workScopes": ["OnlineMeetings.Read"],
2092
- "llmTip": "Gets a specific online meeting by ID. Returns full details including subject, startDateTime, endDateTime, joinWebUrl, chatInfo (threadId for meeting chat), participants (organizer, attendees), lobbyBypassSettings, and videoTeleconferenceId. Use list-online-meetings first to find the meeting ID."
2092
+ "llmTip": "Gets a specific online meeting by ID. Returns full details including subject, startDateTime, endDateTime, joinWebUrl, chatInfo (threadId for meeting chat), participants (organizer, attendees), lobbyBypassSettings, and videoTeleconferenceId. Use list-online-meetings first to find the meeting ID. The full meeting object is large and can include the invite HTML and the meeting passcode, so pass select with the fields you came here for, e.g. select=id,chatInfo,participants."
2093
2093
  },
2094
2094
  {
2095
2095
  "pathPattern": "/me/onlineMeetings",