@softeria/ms-365-mcp-server 0.150.2 → 0.150.3

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.
@@ -1219,6 +1219,198 @@ describe("graph-tools", () => {
1219
1219
  expect(schema["messageId"].description).toContain("not as 'id'");
1220
1220
  });
1221
1221
  });
1222
+ describe("$search quote normalization", () => {
1223
+ async function callSearch(search, path = "/me/messages") {
1224
+ const endpoint = makeEndpoint({ path });
1225
+ const config = makeConfig();
1226
+ mockEndpoints.push(endpoint);
1227
+ mockEndpointsJson = [config];
1228
+ const graphClient = createMockGraphClient([
1229
+ { content: [{ type: "text", text: JSON.stringify({ value: [] }) }] }
1230
+ ]);
1231
+ const server = createMockServer();
1232
+ const { registerGraphTools } = await loadModule();
1233
+ registerGraphTools(server, graphClient);
1234
+ const result = await server.tools.get("test-tool").handler({ search });
1235
+ return { result, graphClient };
1236
+ }
1237
+ async function callWithSearch(search, path = "/me/messages") {
1238
+ const { graphClient } = await callSearch(search, path);
1239
+ return graphClient.graphRequest.mock.calls[0][0];
1240
+ }
1241
+ it("wraps a bare KQL expression in one pair of double quotes", async () => {
1242
+ const url = await callWithSearch("from:john AND subject:meeting");
1243
+ expect(url).toContain(`$search=${encodeURIComponent('"from:john AND subject:meeting"')}`);
1244
+ });
1245
+ it("collapses per-term quoting into a single enclosing pair", async () => {
1246
+ const url = await callWithSearch('"from:john" AND subject:meeting');
1247
+ expect(url).toContain(`$search=${encodeURIComponent('"from:john AND subject:meeting"')}`);
1248
+ });
1249
+ it("leaves an already correctly quoted expression untouched", async () => {
1250
+ const url = await callWithSearch('"from:john AND subject:meeting"');
1251
+ expect(url).toContain(`$search=${encodeURIComponent('"from:john AND subject:meeting"')}`);
1252
+ });
1253
+ it("adds the enclosing pair around a property phrase", async () => {
1254
+ const url = await callWithSearch('subject:"quarterly report"');
1255
+ expect(url).toContain(`$search=${encodeURIComponent('"subject:\\"quarterly report\\""')}`);
1256
+ });
1257
+ it("keeps a standalone phrase grouped", async () => {
1258
+ const url = await callWithSearch('"quarterly report" AND from:john');
1259
+ expect(url).toContain(
1260
+ `$search=${encodeURIComponent('"\\"quarterly report\\" AND from:john"')}`
1261
+ );
1262
+ });
1263
+ it("leaves an already escaped phrase untouched", async () => {
1264
+ const query = '"subject:\\"quarterly report\\""';
1265
+ const url = await callWithSearch(query);
1266
+ expect(url).toContain(`$search=${encodeURIComponent(query)}`);
1267
+ });
1268
+ it("leaves already-wrapped free text as a multi-term search", async () => {
1269
+ const query = '"quarterly report"';
1270
+ const url = await callWithSearch(query);
1271
+ expect(url).toContain(`$search=${encodeURIComponent(query)}`);
1272
+ });
1273
+ it.each([
1274
+ ['"received>=2024-01-01" AND from:john', '"received>=2024-01-01 AND from:john"'],
1275
+ ['"size>1000" AND subject:meeting', '"size>1000 AND subject:meeting"'],
1276
+ ['"received<2024-01-01"', '"received<2024-01-01"']
1277
+ ])("undoes per-clause quoting on a comparison clause (%s)", async (query, expected) => {
1278
+ const url = await callWithSearch(query);
1279
+ expect(url).toContain(`$search=${encodeURIComponent(expected)}`);
1280
+ });
1281
+ it.each([
1282
+ ['"RE: quarterly report" AND from:john', '"\\"RE: quarterly report\\" AND from:john"'],
1283
+ ['"Q3: plan.pdf" AND subject:budget', '"\\"Q3: plan.pdf\\" AND subject:budget"']
1284
+ ])("keeps phrase quotes on a clause-shaped phrase (%s)", async (query, expected) => {
1285
+ const url = await callWithSearch(query);
1286
+ expect(url).toContain(`$search=${encodeURIComponent(expected)}`);
1287
+ });
1288
+ it.each([
1289
+ ['subject:"RE: quarterly report"', '"subject:\\"RE: quarterly report\\""'],
1290
+ ['attachment:"Q3: plan.pdf"', '"attachment:\\"Q3: plan.pdf\\""']
1291
+ ])("keeps phrase quotes when the phrase contains a colon (%s)", async (query, expected) => {
1292
+ const url = await callWithSearch(query);
1293
+ expect(url).toContain(`$search=${encodeURIComponent(expected)}`);
1294
+ });
1295
+ it.each([
1296
+ [
1297
+ '"from: the desk of the CEO" AND subject:report',
1298
+ '"\\"from: the desk of the CEO\\" AND subject:report"'
1299
+ ],
1300
+ [
1301
+ '"subject: quarterly report" AND from:john',
1302
+ '"\\"subject: quarterly report\\" AND from:john"'
1303
+ ]
1304
+ ])("keeps phrase quotes when a space follows the property (%s)", async (query, expected) => {
1305
+ const url = await callWithSearch(query);
1306
+ expect(url).toContain(`$search=${encodeURIComponent(expected)}`);
1307
+ });
1308
+ it("balances a trailing backslash so it cannot escape the closing quote", async () => {
1309
+ const url = await callWithSearch("from:john\\");
1310
+ expect(url).toContain(`$search=${encodeURIComponent('"from:john\\\\"')}`);
1311
+ });
1312
+ it.each([
1313
+ ['from:"john', '"from:\\"john\\""'],
1314
+ ['subject:"quarterly report', '"subject:\\"quarterly report\\""']
1315
+ ])("closes an unterminated quote rather than dropping it (%s)", async (query, expected) => {
1316
+ const url = await callWithSearch(query);
1317
+ expect(url).toContain(`$search=${encodeURIComponent(expected)}`);
1318
+ });
1319
+ it.each([
1320
+ [
1321
+ '"subject:quarterly report" AND from:john',
1322
+ '"subject:\\"quarterly report\\" AND from:john"'
1323
+ ],
1324
+ ['"from:john" AND "subject:the big report"', '"from:john AND subject:\\"the big report\\""']
1325
+ ])("moves quotes past the operator on a multi-word value (%s)", async (query, expected) => {
1326
+ const url = await callWithSearch(query);
1327
+ expect(url).toContain(`$search=${encodeURIComponent(expected)}`);
1328
+ });
1329
+ it.each([
1330
+ [
1331
+ '"from:john AND subject:meeting" OR from:jane',
1332
+ '"from:john AND subject:meeting OR from:jane"'
1333
+ ],
1334
+ [
1335
+ '"from:john OR from:jane" AND hasAttachments:true',
1336
+ '"from:john OR from:jane AND hasAttachments:true"'
1337
+ ]
1338
+ ])("unwraps a quoted group of clauses (%s)", async (query, expected) => {
1339
+ const url = await callWithSearch(query);
1340
+ expect(url).toContain(`$search=${encodeURIComponent(expected)}`);
1341
+ });
1342
+ it("recovers a dropped closing quote on the enclosing pair", async () => {
1343
+ const url = await callWithSearch('"from:john AND subject:meeting');
1344
+ expect(url).toContain(`$search=${encodeURIComponent('"from:john AND subject:meeting"')}`);
1345
+ });
1346
+ it("reads an escaped backslash before a closing quote", async () => {
1347
+ const url = await callWithSearch('from:"a\\\\"');
1348
+ expect(url).toContain(`$search=${encodeURIComponent('"from:\\"a\\\\\\""')}`);
1349
+ });
1350
+ const CORPUS = [
1351
+ "from:john AND subject:meeting",
1352
+ '"from:john" AND subject:meeting',
1353
+ 'subject:"quarterly report',
1354
+ '"subject:quarterly report" AND from:john',
1355
+ '"from:john AND subject:meeting" OR from:jane',
1356
+ '"from:john AND subject:meeting',
1357
+ "from:john\\",
1358
+ 'from:"a\\\\"',
1359
+ 'subject:"abc\\',
1360
+ '"quarterly report"'
1361
+ ];
1362
+ it.each(CORPUS)("emits a balanced escaped string (%s)", async (query) => {
1363
+ const url = await callWithSearch(query);
1364
+ const value = new URL(url, "https://graph.microsoft.com").searchParams.get("$search");
1365
+ expect(value.startsWith('"') && value.endsWith('"')).toBe(true);
1366
+ let unescaped = 0;
1367
+ for (let i = 0; i < value.length; i++) {
1368
+ if (value[i] === "\\") {
1369
+ i++;
1370
+ continue;
1371
+ }
1372
+ if (value[i] === '"') unescaped++;
1373
+ }
1374
+ expect(unescaped).toBe(2);
1375
+ });
1376
+ it.each(CORPUS)("normalizing twice is a no-op (%s)", async (query) => {
1377
+ const once = await callWithSearch(query);
1378
+ const search = new URL(once, "https://graph.microsoft.com").searchParams.get("$search");
1379
+ const twice = await callWithSearch(search);
1380
+ expect(twice).toContain(`$search=${encodeURIComponent(search)}`);
1381
+ });
1382
+ it.each([" ", " ", '"', '""""'])(
1383
+ "refuses an unsearchable $search value %j",
1384
+ async (query) => {
1385
+ const { result, graphClient } = await callSearch(query);
1386
+ expect(result.isError).toBe(true);
1387
+ expect(JSON.parse(result.content[0].text).error).toBe("invalid_search");
1388
+ expect(graphClient.graphRequest).not.toHaveBeenCalled();
1389
+ }
1390
+ );
1391
+ it.each([
1392
+ ["/users", '"displayName:john" OR "displayName:jane"'],
1393
+ // Mail-adjacent, but none of these take message KQL.
1394
+ ["/me/mailFolders", "foo OR bar"],
1395
+ ["/me/mailFolders/:mailFolderId/childFolders", "foo OR bar"],
1396
+ ["/me/mailFolders/:mailFolderId/messageRules", "foo OR bar"],
1397
+ ["/me/messages/:messageId/attachments", "foo OR bar"],
1398
+ ["/planner/tasks/:plannerTaskId/messages", "foo OR bar"],
1399
+ ["/chats/:chatId/messages", "foo OR bar"],
1400
+ ["/teams/:teamId/channels/:channelId/messages", "foo OR bar"]
1401
+ ])("does not touch $search on %s", async (path, query) => {
1402
+ const url = await callWithSearch(query, path);
1403
+ expect(url).toContain(`$search=${encodeURIComponent(query)}`);
1404
+ });
1405
+ it.each([
1406
+ "/me/mailFolders/:mailFolderId/messages",
1407
+ "/users/:userId/messages",
1408
+ "/me/mailFolders/:mailFolderId/childFolders/:childFolderId/messages"
1409
+ ])("still normalizes on %s", async (path) => {
1410
+ const url = await callWithSearch('"from:john" AND subject:meeting', path);
1411
+ expect(url).toContain(`$search=${encodeURIComponent('"from:john AND subject:meeting"')}`);
1412
+ });
1413
+ });
1222
1414
  describe("MS365_MCP_MAX_TOP", () => {
1223
1415
  const prevMaxTop = process.env.MS365_MCP_MAX_TOP;
1224
1416
  afterEach(() => {
@@ -80,6 +80,142 @@ function clampTopQueryParam(queryParams) {
80
80
  logger.info(`Clamping $top from ${requested} to ${cap} (MS365_MCP_MAX_TOP)`);
81
81
  queryParams["$top"] = String(cap);
82
82
  }
83
+ const OUTLOOK_MAIL_PATH = /^\/(?:me|users\/[^/]+)(?:\/(?:mailFolders|childFolders)\/[^/]+)*\/messages(?:\/delta\(\))?$/i;
84
+ function isOutlookMailPath(path2) {
85
+ return OUTLOOK_MAIL_PATH.test(path2);
86
+ }
87
+ function readQuotedSegment(expr, start) {
88
+ let j = start + 1;
89
+ let segment = "";
90
+ while (j < expr.length) {
91
+ if (expr[j] === "\\" && expr[j + 1] === "\\") {
92
+ segment += "\\\\";
93
+ j += 2;
94
+ continue;
95
+ }
96
+ if (expr[j] === "\\" && expr[j + 1] === '"') {
97
+ segment += '\\"';
98
+ j += 2;
99
+ continue;
100
+ }
101
+ if (expr[j] === '"') return { segment, end: j };
102
+ segment += expr[j];
103
+ j += 1;
104
+ }
105
+ return void 0;
106
+ }
107
+ const MAIL_SEARCH_PROPERTIES = /* @__PURE__ */ new Set([
108
+ "attachment",
109
+ "bcc",
110
+ "body",
111
+ "category",
112
+ "cc",
113
+ "from",
114
+ "hasattachment",
115
+ "hasattachments",
116
+ "importance",
117
+ "kind",
118
+ "participants",
119
+ "received",
120
+ "recipients",
121
+ "sent",
122
+ "size",
123
+ "subject",
124
+ "to"
125
+ ]);
126
+ const CLAUSE_HEAD = /^([A-Za-z]+)(?::|<=|>=|<>|=|<|>)\S/;
127
+ const BOOLEAN_JOIN = /\s(?:AND|OR|NOT)\s/;
128
+ function clauseHead(segment) {
129
+ const head = CLAUSE_HEAD.exec(segment);
130
+ return head && MAIL_SEARCH_PROPERTIES.has(head[1].toLowerCase()) ? head : null;
131
+ }
132
+ function balanceTrailingSlashes(text) {
133
+ const slashes = text.length - text.replace(/\\+$/, "").length;
134
+ return slashes % 2 === 1 ? `${text}\\` : text;
135
+ }
136
+ function classifyRun(segment, introducedByProperty) {
137
+ if (introducedByProperty) return "phrase";
138
+ const head = clauseHead(segment);
139
+ if (!head) return "phrase";
140
+ if (BOOLEAN_JOIN.test(segment) || !/\s/.test(segment)) return "clause";
141
+ return "restriction-value";
142
+ }
143
+ function rewriteMailSearchQuotes(expr) {
144
+ let out = "";
145
+ let i = 0;
146
+ while (i < expr.length) {
147
+ if (expr[i] === "\\" && expr[i + 1] === "\\") {
148
+ out += "\\\\";
149
+ i += 2;
150
+ continue;
151
+ }
152
+ if (expr[i] === "\\" && expr[i + 1] === '"') {
153
+ out += '\\"';
154
+ i += 2;
155
+ continue;
156
+ }
157
+ if (expr[i] !== '"') {
158
+ out += expr[i];
159
+ i += 1;
160
+ continue;
161
+ }
162
+ const run = readQuotedSegment(expr, i);
163
+ const segment = run ? run.segment : balanceTrailingSlashes(expr.slice(i + 1));
164
+ const introducedByProperty = i > 0 && expr[i - 1] === ":";
165
+ switch (classifyRun(segment, introducedByProperty)) {
166
+ case "clause":
167
+ out += segment;
168
+ break;
169
+ case "restriction-value": {
170
+ const head = clauseHead(segment);
171
+ const valueAt = head[0].length - 1;
172
+ out += `${segment.slice(0, valueAt)}\\"${segment.slice(valueAt)}\\"`;
173
+ break;
174
+ }
175
+ default:
176
+ out += `\\"${segment}\\"`;
177
+ }
178
+ if (!run) break;
179
+ i = run.end + 1;
180
+ }
181
+ return balanceTrailingSlashes(out.trim());
182
+ }
183
+ function normalizeSearchQueryParam(queryParams, path2, toolAlias) {
184
+ if (!isOutlookMailPath(path2)) return;
185
+ const raw = queryParams["$search"];
186
+ if (raw === void 0) return;
187
+ const trimmed = raw.trim();
188
+ const noSearchableText = () => {
189
+ logger.warn(`Refusing ${toolAlias}: '$search' has no searchable text`);
190
+ return {
191
+ content: [
192
+ {
193
+ type: "text",
194
+ text: JSON.stringify({
195
+ error: "invalid_search",
196
+ tool: toolAlias,
197
+ message: 'The $search parameter has no searchable text. Supply a KQL expression such as "from:john" or "subject:budget", or omit $search to list messages unfiltered.'
198
+ })
199
+ }
200
+ ],
201
+ isError: true
202
+ };
203
+ };
204
+ if (trimmed === "" || /^["'\s]+$/.test(trimmed)) return noSearchableText();
205
+ let expr = trimmed;
206
+ if (expr.startsWith('"')) {
207
+ const whole = readQuotedSegment(expr, 0);
208
+ if (!whole) expr = expr.slice(1);
209
+ else if (whole.end === expr.length - 1) expr = whole.segment;
210
+ }
211
+ const inner = rewriteMailSearchQuotes(expr);
212
+ if (inner === "") return noSearchableText();
213
+ const normalized = `"${inner}"`;
214
+ if (normalized !== raw) {
215
+ logger.info(`Auto-corrected parameter '$search': normalized KQL quoting to ${normalized}`);
216
+ queryParams["$search"] = normalized;
217
+ }
218
+ }
83
219
  const DEFAULT_MAX_ITEMS = 1e4;
84
220
  function isConfirmGateEnabled() {
85
221
  return process.env.MS365_MCP_REQUIRE_CONFIRM === "true";
@@ -1037,6 +1173,8 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
1037
1173
  delete queryParams["$top"];
1038
1174
  }
1039
1175
  clampTopQueryParam(queryParams);
1176
+ const searchError = normalizeSearchQueryParam(queryParams, tool.path, tool.alias);
1177
+ if (searchError) return searchError;
1040
1178
  const preferValues = [];
1041
1179
  if (config?.supportsTimezone && params.timezone) {
1042
1180
  preferValues.push(`outlook.timezone="${params.timezone}"`);
@@ -29,7 +29,7 @@ function isFetchAllPagesApplicable(tool) {
29
29
  return tool.method.toUpperCase() === "GET" && tool.path.includes("/") && paginationAllowed();
30
30
  }
31
31
  const FILTER_PARAM_DESCRIPTION = "OData filter expression. Add $count=true for advanced filters (flag/flagStatus, contains()). Cannot combine with $search.";
32
- const SEARCH_PARAM_DESCRIPTION = "KQL search query \u2014 wrap value in double quotes. Cannot combine with $filter.";
32
+ const SEARCH_PARAM_DESCRIPTION = "KQL search query in one pair of double quotes; directory (users/groups) instead quotes each clause with no outer pair. Cannot combine with $filter.";
33
33
  const SELECT_PARAM_DESCRIPTION = "Comma-separated fields to return, e.g. id,subject,from,receivedDateTime";
34
34
  const EXPAND_PARAM_DESCRIPTION = 'Navigation properties to inline, e.g. attachments on a message or event. Only navigation properties can be expanded: expanding a non-navigation property such as a message body fails with "Parsing OData Select and Expand failed", and an unsupported value may be ignored rather than reported. Request ordinary fields with $select instead.';
35
35
  const ORDERBY_PARAM_DESCRIPTION = "Sort expression, e.g. receivedDateTime desc";
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.150.2",
4
+ "version": "0.150.3",
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",