@softeria/ms-365-mcp-server 0.150.1 → 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.
- package/dist/__tests__/graph-tools.test.js +192 -0
- package/dist/endpoints.json +12 -8
- package/dist/graph-tools.js +138 -0
- package/dist/lib/param-descriptions.js +1 -1
- package/package.json +2 -2
- package/src/endpoints.json +12 -8
|
@@ -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(() => {
|
package/dist/endpoints.json
CHANGED
|
@@ -414,7 +414,7 @@
|
|
|
414
414
|
"toolName": "create-calendar-event",
|
|
415
415
|
"presets": ["calendar", "outlook", "personal"],
|
|
416
416
|
"scopes": ["Calendars.ReadWrite"],
|
|
417
|
-
"descriptionOverride": "Create (schedule) a new calendar event — a meeting or appointment — on the user's calendar.
|
|
417
|
+
"descriptionOverride": "Create (schedule) a new calendar event — a meeting or appointment — on the user's calendar. Times use nested objects, not flat fields: start: {dateTime, timeZone}, end: {dateTime, timeZone}. Do NOT use startDateTime/startTimeZone. For one-off events, UTC is simplest (e.g. 3:30 PM AEDT = 04:30 UTC). For recurring events, use the organizer's own time zone name instead — Graph resolves DST against the zone in start.timeZone, so UTC drifts after DST changes. Get the zone from get-mailbox-settings, or validate one with list-supported-time-zones instead of guessing from memory. Set subject, location, body, and attendees; supports online meetings and recurrence.",
|
|
418
418
|
"llmTip": "CRITICAL: Do not try to guess the email address of the recipients. Use the list-users tool to find the email address of the recipients."
|
|
419
419
|
},
|
|
420
420
|
{
|
|
@@ -423,6 +423,7 @@
|
|
|
423
423
|
"toolName": "update-calendar-event",
|
|
424
424
|
"presets": ["calendar", "outlook", "personal"],
|
|
425
425
|
"scopes": ["Calendars.ReadWrite"],
|
|
426
|
+
"descriptionOverride": "Update an event on the default calendar. Requires eventId (the event's ID from get-calendar-view or list-calendar-events). Times use nested {dateTime, timeZone} objects. UTC is simplest for one-off events; for recurring events use the organizer's own time zone (from get-mailbox-settings or list-supported-time-zones) instead of UTC, since Graph resolves DST against that zone.",
|
|
426
427
|
"llmTip": "CRITICAL: Do not try to guess the email address of the recipients. Use the list-users tool to find the email address of the recipients. WARNING: Setting attendees replaces the entire attendee list — include all attendees, not just new ones."
|
|
427
428
|
},
|
|
428
429
|
{
|
|
@@ -484,6 +485,7 @@
|
|
|
484
485
|
"toolName": "create-specific-calendar-event",
|
|
485
486
|
"presets": ["calendar", "outlook", "personal"],
|
|
486
487
|
"scopes": ["Calendars.ReadWrite"],
|
|
488
|
+
"descriptionOverride": "Create a calendar event on a specific calendar. Requires calendarId (the target calendar's ID). Times use nested {dateTime, timeZone} objects — do NOT use startDateTime/startTimeZone. UTC is simplest for one-off events; for recurring events use the organizer's own time zone (from get-mailbox-settings or list-supported-time-zones) instead of UTC, since Graph resolves DST against that zone.",
|
|
487
489
|
"llmTip": "CRITICAL: Do not try to guess the email address of the recipients. Use the list-users tool to find the email address of the recipients."
|
|
488
490
|
},
|
|
489
491
|
{
|
|
@@ -492,6 +494,7 @@
|
|
|
492
494
|
"toolName": "update-specific-calendar-event",
|
|
493
495
|
"presets": ["calendar", "outlook", "personal"],
|
|
494
496
|
"scopes": ["Calendars.ReadWrite"],
|
|
497
|
+
"descriptionOverride": "Update a specific calendar event. Requires calendarId (from list-calendars) and eventId (from list-specific-calendar-events or get-specific-calendar-view for that same calendar). Times use nested {dateTime, timeZone} objects. UTC is simplest for one-off events; for recurring events use the organizer's own time zone (from get-mailbox-settings or list-supported-time-zones) instead of UTC, since Graph resolves DST against that zone.",
|
|
495
498
|
"llmTip": "CRITICAL: Do not try to guess the email address of the recipients. Use the list-users tool to find the email address of the recipients. WARNING: Setting attendees replaces the entire attendee list — include all attendees, not just new ones."
|
|
496
499
|
},
|
|
497
500
|
{
|
|
@@ -500,6 +503,7 @@
|
|
|
500
503
|
"toolName": "delete-specific-calendar-event",
|
|
501
504
|
"presets": ["calendar", "outlook", "personal"],
|
|
502
505
|
"scopes": ["Calendars.ReadWrite"],
|
|
506
|
+
"descriptionOverride": "Delete a specific calendar event. Requires calendarId (the target calendar's ID) and eventId (the event's own ID).",
|
|
503
507
|
"llmTip": "Deleting a seriesMaster deletes ALL occurrences. To cancel a single occurrence, use the specific instance ID."
|
|
504
508
|
},
|
|
505
509
|
{
|
|
@@ -880,7 +884,7 @@
|
|
|
880
884
|
"pathPattern": "/me/messages/{message-id}/$value",
|
|
881
885
|
"method": "get",
|
|
882
886
|
"toolName": "get-mail-message-mime",
|
|
883
|
-
"descriptionOverride": "Download the raw MIME source (RFC
|
|
887
|
+
"descriptionOverride": "Download the raw MIME source (RFC 5322 .eml content) of an Outlook email message by its message ID. Returns the complete original message including headers and encoded attachments.",
|
|
884
888
|
"presets": ["mail", "outlook", "personal"],
|
|
885
889
|
"scopes": ["Mail.Read"],
|
|
886
890
|
"acceptType": "text/plain",
|
|
@@ -1304,7 +1308,7 @@
|
|
|
1304
1308
|
"toolName": "list-planner-tasks",
|
|
1305
1309
|
"presets": ["tasks", "work"],
|
|
1306
1310
|
"scopes": ["Tasks.Read"],
|
|
1307
|
-
"llmTip": "Priority
|
|
1311
|
+
"llmTip": "Priority is 0-10 (lower = higher priority); Planner's own UI presets are 1=Urgent, 3=Important, 5=Medium, 9=Low."
|
|
1308
1312
|
},
|
|
1309
1313
|
{
|
|
1310
1314
|
"pathPattern": "/planner/plans/{plannerPlan-id}",
|
|
@@ -1319,7 +1323,7 @@
|
|
|
1319
1323
|
"toolName": "list-plan-tasks",
|
|
1320
1324
|
"presets": ["tasks", "work"],
|
|
1321
1325
|
"scopes": ["Tasks.Read"],
|
|
1322
|
-
"llmTip": "Priority
|
|
1326
|
+
"llmTip": "Priority is 0-10 (lower = higher priority); Planner's own UI presets are 1=Urgent, 3=Important, 5=Medium, 9=Low."
|
|
1323
1327
|
},
|
|
1324
1328
|
{
|
|
1325
1329
|
"pathPattern": "/planner/tasks/{plannerTask-id}",
|
|
@@ -1342,7 +1346,7 @@
|
|
|
1342
1346
|
"toolName": "update-planner-task",
|
|
1343
1347
|
"presets": ["tasks", "work"],
|
|
1344
1348
|
"scopes": ["Tasks.ReadWrite"],
|
|
1345
|
-
"llmTip": "CRITICAL: Requires If-Match header with the task's @odata.etag value, otherwise returns 412 Precondition Failed. Get the ETag from get-planner-task with includeHeaders=true. Priority
|
|
1349
|
+
"llmTip": "CRITICAL: Requires If-Match header with the task's @odata.etag value, otherwise returns 412 Precondition Failed. Get the ETag from get-planner-task with includeHeaders=true. Priority is 0-10 (lower = higher priority); Planner's own UI presets are 1=Urgent, 3=Important, 5=Medium, 9=Low."
|
|
1346
1350
|
},
|
|
1347
1351
|
{
|
|
1348
1352
|
"pathPattern": "/planner/tasks/{plannerTask-id}/details",
|
|
@@ -1529,7 +1533,7 @@
|
|
|
1529
1533
|
"toolName": "list-relevant-people",
|
|
1530
1534
|
"presets": ["users", "work"],
|
|
1531
1535
|
"workScopes": ["People.Read"],
|
|
1532
|
-
"llmTip": "Lists people most relevant to the current user, ordered by relevance. Based on communication patterns, collaboration, and business relationships. Each person has displayName,
|
|
1536
|
+
"llmTip": "Lists people most relevant to the current user, ordered by relevance. Based on communication patterns, collaboration, and business relationships. Each person has displayName, scoredEmailAddresses, jobTitle, department, officeLocation. Use $search to find specific people by name. Use $top to limit results."
|
|
1533
1537
|
},
|
|
1534
1538
|
{
|
|
1535
1539
|
"pathPattern": "/me/memberOf",
|
|
@@ -2097,7 +2101,7 @@
|
|
|
2097
2101
|
"toolName": "create-online-meeting",
|
|
2098
2102
|
"presets": ["teams", "work"],
|
|
2099
2103
|
"workScopes": ["OnlineMeetings.ReadWrite"],
|
|
2100
|
-
"llmTip": "Creates a new online meeting. Required body: { subject, startDateTime,
|
|
2104
|
+
"llmTip": "Creates a new online meeting. Required body: { subject, endDateTime }. startDateTime is not documented as required, but is commonly supplied. Optional: participants (with organizer and attendees), lobbyBypassSettings, isEntryExitAnnounced, allowedPresenters (everyone/organization/roleIsPresenter/organizer). Returns the created meeting with joinWebUrl and meeting ID."
|
|
2101
2105
|
},
|
|
2102
2106
|
{
|
|
2103
2107
|
"pathPattern": "/me/onlineMeetings/{onlineMeeting-id}",
|
|
@@ -2548,7 +2552,7 @@
|
|
|
2548
2552
|
"toolName": "list-supported-time-zones",
|
|
2549
2553
|
"presets": ["calendar", "mail", "outlook", "personal"],
|
|
2550
2554
|
"scopes": ["User.Read"],
|
|
2551
|
-
"llmTip": "Lists time zones the user's mailbox server supports. TimeZoneStandard path parameter must be one of: Windows (default — Windows time zone names like 'Pacific Standard Time'), or Iana (IANA / Olson names like 'America/Los_Angeles'). Note the PascalCase — the values are case-sensitive enums, not lowercase strings. Returns timeZoneInformation objects with alias and displayName. Use the result to validate or look up the value before calling update-mailbox-settings to change the user's preferred timeZone —
|
|
2555
|
+
"llmTip": "Lists time zones the user's mailbox server supports. TimeZoneStandard path parameter must be one of: Windows (default — Windows time zone names like 'Pacific Standard Time'), or Iana (IANA / Olson names like 'America/Los_Angeles'). Note the PascalCase — the values are case-sensitive enums, not lowercase strings. Returns timeZoneInformation objects with alias and displayName. Use the result to validate or look up the value before calling update-mailbox-settings to change the user's preferred timeZone, or before setting timeZone on a calendar event's start/end (especially for recurring events) — don't guess a time zone name from memory, look it up here."
|
|
2552
2556
|
},
|
|
2553
2557
|
{
|
|
2554
2558
|
"pathPattern": "/me/outlook/supportedLanguages()",
|
package/dist/graph-tools.js
CHANGED
|
@@ -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
|
|
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.
|
|
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",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@azure/msal-node": "^5.2.2",
|
|
39
39
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
40
|
-
"@toon-format/toon": "^
|
|
40
|
+
"@toon-format/toon": "^2.3.1",
|
|
41
41
|
"commander": "^11.1.0",
|
|
42
42
|
"dotenv": "^17.0.1",
|
|
43
43
|
"express": "^5.2.1",
|
package/src/endpoints.json
CHANGED
|
@@ -414,7 +414,7 @@
|
|
|
414
414
|
"toolName": "create-calendar-event",
|
|
415
415
|
"presets": ["calendar", "outlook", "personal"],
|
|
416
416
|
"scopes": ["Calendars.ReadWrite"],
|
|
417
|
-
"descriptionOverride": "Create (schedule) a new calendar event — a meeting or appointment — on the user's calendar.
|
|
417
|
+
"descriptionOverride": "Create (schedule) a new calendar event — a meeting or appointment — on the user's calendar. Times use nested objects, not flat fields: start: {dateTime, timeZone}, end: {dateTime, timeZone}. Do NOT use startDateTime/startTimeZone. For one-off events, UTC is simplest (e.g. 3:30 PM AEDT = 04:30 UTC). For recurring events, use the organizer's own time zone name instead — Graph resolves DST against the zone in start.timeZone, so UTC drifts after DST changes. Get the zone from get-mailbox-settings, or validate one with list-supported-time-zones instead of guessing from memory. Set subject, location, body, and attendees; supports online meetings and recurrence.",
|
|
418
418
|
"llmTip": "CRITICAL: Do not try to guess the email address of the recipients. Use the list-users tool to find the email address of the recipients."
|
|
419
419
|
},
|
|
420
420
|
{
|
|
@@ -423,6 +423,7 @@
|
|
|
423
423
|
"toolName": "update-calendar-event",
|
|
424
424
|
"presets": ["calendar", "outlook", "personal"],
|
|
425
425
|
"scopes": ["Calendars.ReadWrite"],
|
|
426
|
+
"descriptionOverride": "Update an event on the default calendar. Requires eventId (the event's ID from get-calendar-view or list-calendar-events). Times use nested {dateTime, timeZone} objects. UTC is simplest for one-off events; for recurring events use the organizer's own time zone (from get-mailbox-settings or list-supported-time-zones) instead of UTC, since Graph resolves DST against that zone.",
|
|
426
427
|
"llmTip": "CRITICAL: Do not try to guess the email address of the recipients. Use the list-users tool to find the email address of the recipients. WARNING: Setting attendees replaces the entire attendee list — include all attendees, not just new ones."
|
|
427
428
|
},
|
|
428
429
|
{
|
|
@@ -484,6 +485,7 @@
|
|
|
484
485
|
"toolName": "create-specific-calendar-event",
|
|
485
486
|
"presets": ["calendar", "outlook", "personal"],
|
|
486
487
|
"scopes": ["Calendars.ReadWrite"],
|
|
488
|
+
"descriptionOverride": "Create a calendar event on a specific calendar. Requires calendarId (the target calendar's ID). Times use nested {dateTime, timeZone} objects — do NOT use startDateTime/startTimeZone. UTC is simplest for one-off events; for recurring events use the organizer's own time zone (from get-mailbox-settings or list-supported-time-zones) instead of UTC, since Graph resolves DST against that zone.",
|
|
487
489
|
"llmTip": "CRITICAL: Do not try to guess the email address of the recipients. Use the list-users tool to find the email address of the recipients."
|
|
488
490
|
},
|
|
489
491
|
{
|
|
@@ -492,6 +494,7 @@
|
|
|
492
494
|
"toolName": "update-specific-calendar-event",
|
|
493
495
|
"presets": ["calendar", "outlook", "personal"],
|
|
494
496
|
"scopes": ["Calendars.ReadWrite"],
|
|
497
|
+
"descriptionOverride": "Update a specific calendar event. Requires calendarId (from list-calendars) and eventId (from list-specific-calendar-events or get-specific-calendar-view for that same calendar). Times use nested {dateTime, timeZone} objects. UTC is simplest for one-off events; for recurring events use the organizer's own time zone (from get-mailbox-settings or list-supported-time-zones) instead of UTC, since Graph resolves DST against that zone.",
|
|
495
498
|
"llmTip": "CRITICAL: Do not try to guess the email address of the recipients. Use the list-users tool to find the email address of the recipients. WARNING: Setting attendees replaces the entire attendee list — include all attendees, not just new ones."
|
|
496
499
|
},
|
|
497
500
|
{
|
|
@@ -500,6 +503,7 @@
|
|
|
500
503
|
"toolName": "delete-specific-calendar-event",
|
|
501
504
|
"presets": ["calendar", "outlook", "personal"],
|
|
502
505
|
"scopes": ["Calendars.ReadWrite"],
|
|
506
|
+
"descriptionOverride": "Delete a specific calendar event. Requires calendarId (the target calendar's ID) and eventId (the event's own ID).",
|
|
503
507
|
"llmTip": "Deleting a seriesMaster deletes ALL occurrences. To cancel a single occurrence, use the specific instance ID."
|
|
504
508
|
},
|
|
505
509
|
{
|
|
@@ -880,7 +884,7 @@
|
|
|
880
884
|
"pathPattern": "/me/messages/{message-id}/$value",
|
|
881
885
|
"method": "get",
|
|
882
886
|
"toolName": "get-mail-message-mime",
|
|
883
|
-
"descriptionOverride": "Download the raw MIME source (RFC
|
|
887
|
+
"descriptionOverride": "Download the raw MIME source (RFC 5322 .eml content) of an Outlook email message by its message ID. Returns the complete original message including headers and encoded attachments.",
|
|
884
888
|
"presets": ["mail", "outlook", "personal"],
|
|
885
889
|
"scopes": ["Mail.Read"],
|
|
886
890
|
"acceptType": "text/plain",
|
|
@@ -1304,7 +1308,7 @@
|
|
|
1304
1308
|
"toolName": "list-planner-tasks",
|
|
1305
1309
|
"presets": ["tasks", "work"],
|
|
1306
1310
|
"scopes": ["Tasks.Read"],
|
|
1307
|
-
"llmTip": "Priority
|
|
1311
|
+
"llmTip": "Priority is 0-10 (lower = higher priority); Planner's own UI presets are 1=Urgent, 3=Important, 5=Medium, 9=Low."
|
|
1308
1312
|
},
|
|
1309
1313
|
{
|
|
1310
1314
|
"pathPattern": "/planner/plans/{plannerPlan-id}",
|
|
@@ -1319,7 +1323,7 @@
|
|
|
1319
1323
|
"toolName": "list-plan-tasks",
|
|
1320
1324
|
"presets": ["tasks", "work"],
|
|
1321
1325
|
"scopes": ["Tasks.Read"],
|
|
1322
|
-
"llmTip": "Priority
|
|
1326
|
+
"llmTip": "Priority is 0-10 (lower = higher priority); Planner's own UI presets are 1=Urgent, 3=Important, 5=Medium, 9=Low."
|
|
1323
1327
|
},
|
|
1324
1328
|
{
|
|
1325
1329
|
"pathPattern": "/planner/tasks/{plannerTask-id}",
|
|
@@ -1342,7 +1346,7 @@
|
|
|
1342
1346
|
"toolName": "update-planner-task",
|
|
1343
1347
|
"presets": ["tasks", "work"],
|
|
1344
1348
|
"scopes": ["Tasks.ReadWrite"],
|
|
1345
|
-
"llmTip": "CRITICAL: Requires If-Match header with the task's @odata.etag value, otherwise returns 412 Precondition Failed. Get the ETag from get-planner-task with includeHeaders=true. Priority
|
|
1349
|
+
"llmTip": "CRITICAL: Requires If-Match header with the task's @odata.etag value, otherwise returns 412 Precondition Failed. Get the ETag from get-planner-task with includeHeaders=true. Priority is 0-10 (lower = higher priority); Planner's own UI presets are 1=Urgent, 3=Important, 5=Medium, 9=Low."
|
|
1346
1350
|
},
|
|
1347
1351
|
{
|
|
1348
1352
|
"pathPattern": "/planner/tasks/{plannerTask-id}/details",
|
|
@@ -1529,7 +1533,7 @@
|
|
|
1529
1533
|
"toolName": "list-relevant-people",
|
|
1530
1534
|
"presets": ["users", "work"],
|
|
1531
1535
|
"workScopes": ["People.Read"],
|
|
1532
|
-
"llmTip": "Lists people most relevant to the current user, ordered by relevance. Based on communication patterns, collaboration, and business relationships. Each person has displayName,
|
|
1536
|
+
"llmTip": "Lists people most relevant to the current user, ordered by relevance. Based on communication patterns, collaboration, and business relationships. Each person has displayName, scoredEmailAddresses, jobTitle, department, officeLocation. Use $search to find specific people by name. Use $top to limit results."
|
|
1533
1537
|
},
|
|
1534
1538
|
{
|
|
1535
1539
|
"pathPattern": "/me/memberOf",
|
|
@@ -2097,7 +2101,7 @@
|
|
|
2097
2101
|
"toolName": "create-online-meeting",
|
|
2098
2102
|
"presets": ["teams", "work"],
|
|
2099
2103
|
"workScopes": ["OnlineMeetings.ReadWrite"],
|
|
2100
|
-
"llmTip": "Creates a new online meeting. Required body: { subject, startDateTime,
|
|
2104
|
+
"llmTip": "Creates a new online meeting. Required body: { subject, endDateTime }. startDateTime is not documented as required, but is commonly supplied. Optional: participants (with organizer and attendees), lobbyBypassSettings, isEntryExitAnnounced, allowedPresenters (everyone/organization/roleIsPresenter/organizer). Returns the created meeting with joinWebUrl and meeting ID."
|
|
2101
2105
|
},
|
|
2102
2106
|
{
|
|
2103
2107
|
"pathPattern": "/me/onlineMeetings/{onlineMeeting-id}",
|
|
@@ -2548,7 +2552,7 @@
|
|
|
2548
2552
|
"toolName": "list-supported-time-zones",
|
|
2549
2553
|
"presets": ["calendar", "mail", "outlook", "personal"],
|
|
2550
2554
|
"scopes": ["User.Read"],
|
|
2551
|
-
"llmTip": "Lists time zones the user's mailbox server supports. TimeZoneStandard path parameter must be one of: Windows (default — Windows time zone names like 'Pacific Standard Time'), or Iana (IANA / Olson names like 'America/Los_Angeles'). Note the PascalCase — the values are case-sensitive enums, not lowercase strings. Returns timeZoneInformation objects with alias and displayName. Use the result to validate or look up the value before calling update-mailbox-settings to change the user's preferred timeZone —
|
|
2555
|
+
"llmTip": "Lists time zones the user's mailbox server supports. TimeZoneStandard path parameter must be one of: Windows (default — Windows time zone names like 'Pacific Standard Time'), or Iana (IANA / Olson names like 'America/Los_Angeles'). Note the PascalCase — the values are case-sensitive enums, not lowercase strings. Returns timeZoneInformation objects with alias and displayName. Use the result to validate or look up the value before calling update-mailbox-settings to change the user's preferred timeZone, or before setting timeZone on a calendar event's start/end (especially for recurring events) — don't guess a time zone name from memory, look it up here."
|
|
2552
2556
|
},
|
|
2553
2557
|
{
|
|
2554
2558
|
"pathPattern": "/me/outlook/supportedLanguages()",
|