@softeria/ms-365-mcp-server 0.166.1 → 0.167.0

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.
@@ -4190,4 +4190,148 @@ describe("graph-tools", () => {
4190
4190
  expect(isDestructiveOperation("DELETE", { readOnly: true })).toBe(true);
4191
4191
  });
4192
4192
  });
4193
+ describe("filterMembersByName", () => {
4194
+ const body = {
4195
+ value: [
4196
+ { id: "1", displayName: "Jane Doe", email: "jane@contoso.com" },
4197
+ { id: "2", displayName: "John Smith", email: "jsmith@contoso.com" },
4198
+ { id: "3", displayName: null, email: null }
4199
+ ],
4200
+ "@odata.count": 3
4201
+ };
4202
+ const ids = (r) => r.value.map((m) => m.id);
4203
+ it("matches displayName case-insensitively", async () => {
4204
+ const { filterMembersByName } = await loadModule();
4205
+ expect(ids(filterMembersByName(body, "JANE"))).toEqual(["1"]);
4206
+ });
4207
+ it("matches email", async () => {
4208
+ const { filterMembersByName } = await loadModule();
4209
+ expect(ids(filterMembersByName(body, "jsmith@"))).toEqual(["2"]);
4210
+ });
4211
+ it("returns an empty value array when nothing matches", async () => {
4212
+ const { filterMembersByName } = await loadModule();
4213
+ expect(ids(filterMembersByName(body, "zzz"))).toEqual([]);
4214
+ });
4215
+ it("returns everything for an empty or whitespace-only name", async () => {
4216
+ const { filterMembersByName } = await loadModule();
4217
+ expect(ids(filterMembersByName(body, " "))).toEqual(["1", "2", "3"]);
4218
+ });
4219
+ it("does not match or throw on null fields", async () => {
4220
+ const { filterMembersByName } = await loadModule();
4221
+ expect(ids(filterMembersByName(body, "null"))).toEqual([]);
4222
+ });
4223
+ it("keeps other top-level fields", async () => {
4224
+ const { filterMembersByName } = await loadModule();
4225
+ expect(filterMembersByName(body, "jane")["@odata.count"]).toBe(3);
4226
+ });
4227
+ it("returns non-collection input unchanged", async () => {
4228
+ const { filterMembersByName } = await loadModule();
4229
+ expect(filterMembersByName("plain", "x")).toBe("plain");
4230
+ expect(filterMembersByName({ id: "1" }, "x")).toEqual({ id: "1" });
4231
+ });
4232
+ });
4233
+ describe("member name filter (tool wiring)", () => {
4234
+ const membersBody = {
4235
+ value: [
4236
+ { id: "1", displayName: "Jane Doe", email: "jane@contoso.com" },
4237
+ { id: "2", displayName: "John Smith", email: "jsmith@contoso.com" },
4238
+ { id: "3", displayName: null, email: null }
4239
+ ],
4240
+ "@odata.count": 3
4241
+ };
4242
+ const page = (b) => ({ content: [{ type: "text", text: JSON.stringify(b) }] });
4243
+ const ids = (result) => JSON.parse(result.content[0].text).value.map((m) => m.id);
4244
+ async function run(args, responses = [page(membersBody)], alias = "list-team-members", path = "/teams/{team-id}/members", outputFormat = "json") {
4245
+ mockEndpoints.push(makeEndpoint({ alias, path }));
4246
+ mockEndpointsJson = [makeConfig({ toolName: alias, pathPattern: path })];
4247
+ const graphClient = createMockGraphClient(responses, outputFormat);
4248
+ const server = createMockServer();
4249
+ const { registerGraphTools } = await loadModule();
4250
+ registerGraphTools(server, graphClient);
4251
+ const result = await server.tools.get(alias).handler({ "team-id": "t1", ...args });
4252
+ return { result, graphClient };
4253
+ }
4254
+ it("filters list-team-members by name", async () => {
4255
+ const { result } = await run({ name: "jane" });
4256
+ expect(ids(result)).toEqual(["1"]);
4257
+ });
4258
+ it("filters list-chat-members by email", async () => {
4259
+ const { result } = await run(
4260
+ { "chat-id": "c1", name: "jsmith" },
4261
+ [page(membersBody)],
4262
+ "list-chat-members",
4263
+ "/chats/{chat-id}/members"
4264
+ );
4265
+ expect(ids(result)).toEqual(["2"]);
4266
+ });
4267
+ it("does not send name to Graph", async () => {
4268
+ const { graphClient } = await run({ name: "jane" });
4269
+ expect(graphClient.graphRequest.mock.calls[0][0]).not.toContain("name");
4270
+ });
4271
+ it("returns the full list for a whitespace-only name", async () => {
4272
+ const { result } = await run({ name: " " });
4273
+ expect(ids(result)).toEqual(["1", "2", "3"]);
4274
+ });
4275
+ it("leaves an error response untouched", async () => {
4276
+ const err = { content: [{ type: "text", text: '{"error":"nope"}' }], isError: true };
4277
+ const { result } = await run({ name: "jane" }, [err]);
4278
+ expect(result.content[0].text).toBe('{"error":"nope"}');
4279
+ });
4280
+ it("keeps @odata.count as the unfiltered total", async () => {
4281
+ const { result } = await run({ name: "jane" });
4282
+ expect(JSON.parse(result.content[0].text)["@odata.count"]).toBe(3);
4283
+ });
4284
+ it("filters after fetchAllPages merges the pages", async () => {
4285
+ const { result } = await run({ name: "ann", fetchAllPages: true }, [
4286
+ page({
4287
+ value: [{ id: "1", displayName: "Ann", email: "a@x.com" }],
4288
+ "@odata.nextLink": "https://graph.microsoft.com/v1.0/teams/t1/members?$skiptoken=x"
4289
+ }),
4290
+ page({
4291
+ value: [
4292
+ { id: "2", displayName: "Bob", email: "b@x.com" },
4293
+ { id: "3", displayName: "Annette", email: "n@x.com" }
4294
+ ]
4295
+ })
4296
+ ]);
4297
+ expect(ids(result)).toEqual(["1", "3"]);
4298
+ });
4299
+ it("matches only fields that survive $select projection", async () => {
4300
+ const { result } = await run({ select: "id,displayName", name: "contoso" });
4301
+ expect(ids(result)).toEqual([]);
4302
+ });
4303
+ describe("under --toon", () => {
4304
+ const parseToon = (result) => JSON.parse(result.content[0].text.replace(/^TOON:/, ""));
4305
+ const toon = (args, responses = [page(membersBody)]) => run(args, responses, "list-team-members", "/teams/{team-id}/members", "toon");
4306
+ it("filters when no projection or merge applies", async () => {
4307
+ const { result } = await toon({ name: "jane" });
4308
+ expect(parseToon(result).value.map((m) => m.id)).toEqual(["1"]);
4309
+ });
4310
+ it("filters when $select projects the body", async () => {
4311
+ const { result } = await toon({ select: "id,displayName", name: "jane" });
4312
+ expect(parseToon(result).value).toEqual([{ id: "1", displayName: "Jane Doe" }]);
4313
+ });
4314
+ it("filters after fetchAllPages merges the pages", async () => {
4315
+ const { result } = await toon({ name: "ann", fetchAllPages: true }, [
4316
+ page({
4317
+ value: [{ id: "1", displayName: "Ann", email: "a@x.com" }],
4318
+ "@odata.nextLink": "https://graph.microsoft.com/v1.0/teams/t1/members?$skiptoken=x"
4319
+ }),
4320
+ page({
4321
+ value: [
4322
+ { id: "2", displayName: "Bob", email: "b@x.com" },
4323
+ { id: "3", displayName: "Annette", email: "n@x.com" }
4324
+ ]
4325
+ })
4326
+ ]);
4327
+ expect(parseToon(result).value.map((m) => m.id)).toEqual(["1", "3"]);
4328
+ });
4329
+ });
4330
+ it("still matches a projected field", async () => {
4331
+ const { result } = await run({ select: "id,displayName", name: "jane" });
4332
+ expect(JSON.parse(result.content[0].text).value).toEqual([
4333
+ { id: "1", displayName: "Jane Doe" }
4334
+ ]);
4335
+ });
4336
+ });
4193
4337
  });
@@ -1737,7 +1737,7 @@
1737
1737
  "toolName": "list-chat-members",
1738
1738
  "presets": ["teams", "work"],
1739
1739
  "workScopes": ["ChatMember.Read"],
1740
- "llmTip": "Lists members of a chat. Each member has displayName, email, roles (owner/guest), and visibleHistoryStartDateTime. For meeting chats, use the chatInfo.threadId from get-online-meeting as the chat-id to see who participated in the meeting chat."
1740
+ "llmTip": "Lists members of a chat. Each member has displayName, email, roles (owner/guest), and visibleHistoryStartDateTime. For meeting chats, use the chatInfo.threadId from get-online-meeting as the chat-id to see who participated in the meeting chat. Pass name to keep only members whose displayName or email contains that text (case-insensitive); use the returned userId in an @mention."
1741
1741
  },
1742
1742
  {
1743
1743
  "pathPattern": "/chats/{chat-id}/messages",
@@ -1958,7 +1958,8 @@
1958
1958
  "method": "get",
1959
1959
  "toolName": "list-team-members",
1960
1960
  "presets": ["teams", "work"],
1961
- "workScopes": ["TeamMember.Read.All"]
1961
+ "workScopes": ["TeamMember.Read.All"],
1962
+ "llmTip": "Lists members of a team. Each member has displayName, email, roles and userId. Pass name to keep only members whose displayName or email contains that text (case-insensitive); use the returned userId in an @mention."
1962
1963
  },
1963
1964
  {
1964
1965
  "pathPattern": "/teams/{team-id}/channels/{channel-id}/filesFolder",
@@ -203,6 +203,17 @@ function rewriteMailSearchQuotes(expr) {
203
203
  }
204
204
  return balanceTrailingSlashes(out.trim());
205
205
  }
206
+ const MEMBER_NAME_FILTER_TOOLS = /* @__PURE__ */ new Set(["list-team-members", "list-chat-members"]);
207
+ const MEMBER_NAME_PARAM_DESCRIPTION = "Case-insensitive text to match against each member's displayName and email. Applied after $select projection, so only fields you can see are matched. Without fetchAllPages only the fetched page is filtered, and @odata.count still reports the unfiltered total.";
208
+ function filterMembersByName(parsed, name) {
209
+ const needle = name.trim().toLowerCase();
210
+ if (needle === "" || !isPlainObject(parsed) || !Array.isArray(parsed.value)) return parsed;
211
+ const matches = (member) => isPlainObject(member) && ["displayName", "email"].some((field) => {
212
+ const value = member[field];
213
+ return typeof value === "string" && value.toLowerCase().includes(needle);
214
+ });
215
+ return { ...parsed, value: parsed.value.filter(matches) };
216
+ }
206
217
  function normalizeSearchQueryParam(queryParams, path2, toolAlias) {
207
218
  if (!isOutlookMailPath(path2)) return;
208
219
  const raw = queryParams["$search"];
@@ -1541,8 +1552,10 @@ async function executeGraphTool(tool, config, graphClient, params, authManager,
1541
1552
  parameterDefinitions.find((p) => p.type === "Body")?.schema
1542
1553
  );
1543
1554
  const strayBodyFields = {};
1555
+ const memberNameFilter = MEMBER_NAME_FILTER_TOOLS.has(tool.alias) && typeof params.name === "string" ? params.name : void 0;
1544
1556
  for (const [paramName, paramValue] of Object.entries(params)) {
1545
1557
  if (paramName === "fileName" && config?.blobOutput) continue;
1558
+ if (paramName === "name" && MEMBER_NAME_FILTER_TOOLS.has(tool.alias)) continue;
1546
1559
  if ([
1547
1560
  "account",
1548
1561
  "confirm",
@@ -1848,12 +1861,23 @@ async function executeGraphTool(tool, config, graphClient, params, authManager,
1848
1861
  const mergePages = fetchAllPages && paginationEnabled;
1849
1862
  const willProject = (requestedSelect.length > 0 || isUserFieldBoundary) && params.excludeResponse !== true;
1850
1863
  const willProjectBatch = batchRestricted.size > 0 && params.excludeResponse !== true;
1851
- if (mergePages || willProject || willProjectBatch) {
1864
+ const willFilterMembers = memberNameFilter !== void 0 && memberNameFilter.trim() !== "" && params.excludeResponse !== true;
1865
+ if (mergePages || willProject || willProjectBatch || willFilterMembers) {
1852
1866
  options.forceJsonOutput = true;
1853
1867
  }
1854
1868
  let response = await graphClient.graphRequest(path2, options);
1855
1869
  const shouldProject = willProject && !response?.isError;
1856
1870
  let projectionHandled = false;
1871
+ let memberFilterHandled = false;
1872
+ const applyMemberFilter = (body2) => {
1873
+ if (!willFilterMembers) return body2;
1874
+ memberFilterHandled = true;
1875
+ const filtered = filterMembersByName(body2, memberNameFilter);
1876
+ if (isPlainObject(filtered) && Array.isArray(filtered.value) && response._meta) {
1877
+ response._meta = { ...response._meta, result_count: filtered.value.length };
1878
+ }
1879
+ return filtered;
1880
+ };
1857
1881
  const applyProjection = (body2) => {
1858
1882
  if (isTransportEnvelope(body2)) {
1859
1883
  logger.info("Skipping $select projection: body is a transport envelope, not a resource");
@@ -1948,7 +1972,9 @@ async function executeGraphTool(tool, config, graphClient, params, authManager,
1948
1972
  logger.error(`Error during pagination: ${e}`);
1949
1973
  }
1950
1974
  if (combinedResponse !== void 0) {
1951
- const merged = shouldProject ? applyProjection(combinedResponse) : combinedResponse;
1975
+ const merged = applyMemberFilter(
1976
+ shouldProject ? applyProjection(combinedResponse) : combinedResponse
1977
+ );
1952
1978
  projectionHandled = shouldProject;
1953
1979
  response.content[0].text = graphClient.serialize(merged);
1954
1980
  }
@@ -1956,7 +1982,9 @@ async function executeGraphTool(tool, config, graphClient, params, authManager,
1956
1982
  if (shouldProject && !projectionHandled && !response?.isError && response?.content?.[0]?.text) {
1957
1983
  try {
1958
1984
  const parsed = JSON.parse(response.content[0].text);
1959
- response.content[0].text = graphClient.serialize(applyProjection(parsed));
1985
+ response.content[0].text = graphClient.serialize(
1986
+ applyMemberFilter(applyProjection(parsed))
1987
+ );
1960
1988
  } catch {
1961
1989
  }
1962
1990
  }
@@ -1969,6 +1997,14 @@ async function executeGraphTool(tool, config, graphClient, params, authManager,
1969
1997
  } catch {
1970
1998
  }
1971
1999
  }
2000
+ if (willFilterMembers && !memberFilterHandled && !response?.isError && response?.content?.[0]?.text) {
2001
+ try {
2002
+ response.content[0].text = graphClient.serialize(
2003
+ applyMemberFilter(JSON.parse(response.content[0].text))
2004
+ );
2005
+ } catch {
2006
+ }
2007
+ }
1972
2008
  if (response?.content?.[0]?.text) {
1973
2009
  const responseText = response.content[0].text;
1974
2010
  logger.info(`Response size: ${responseText.length} characters`);
@@ -2125,6 +2161,9 @@ function registerGraphTools(server, graphClient, options = {}) {
2125
2161
  const maxPages = getMaxPages();
2126
2162
  paramSchema["fetchAllPages"] = z.boolean().describe(getFetchAllPagesParamDescription(maxPages, tool.alias)).optional();
2127
2163
  }
2164
+ if (MEMBER_NAME_FILTER_TOOLS.has(tool.alias)) {
2165
+ paramSchema["name"] = z.string().describe(MEMBER_NAME_PARAM_DESCRIPTION).optional();
2166
+ }
2128
2167
  if (isSkiptokenApplicable(tool, Object.keys(paramSchema))) {
2129
2168
  paramSchema["skiptoken"] = z.string().describe(SKIPTOKEN_PARAM_DESCRIPTION).optional();
2130
2169
  }
@@ -2545,6 +2584,7 @@ export {
2545
2584
  UTILITY_TOOLS,
2546
2585
  buildDiscoverySearchIndex,
2547
2586
  buildToolsRegistry,
2587
+ filterMembersByName,
2548
2588
  isDestructiveOperation,
2549
2589
  registerDiscoveryTools,
2550
2590
  registerGraphTools,
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.166.1",
4
+ "version": "0.167.0",
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",
@@ -1737,7 +1737,7 @@
1737
1737
  "toolName": "list-chat-members",
1738
1738
  "presets": ["teams", "work"],
1739
1739
  "workScopes": ["ChatMember.Read"],
1740
- "llmTip": "Lists members of a chat. Each member has displayName, email, roles (owner/guest), and visibleHistoryStartDateTime. For meeting chats, use the chatInfo.threadId from get-online-meeting as the chat-id to see who participated in the meeting chat."
1740
+ "llmTip": "Lists members of a chat. Each member has displayName, email, roles (owner/guest), and visibleHistoryStartDateTime. For meeting chats, use the chatInfo.threadId from get-online-meeting as the chat-id to see who participated in the meeting chat. Pass name to keep only members whose displayName or email contains that text (case-insensitive); use the returned userId in an @mention."
1741
1741
  },
1742
1742
  {
1743
1743
  "pathPattern": "/chats/{chat-id}/messages",
@@ -1958,7 +1958,8 @@
1958
1958
  "method": "get",
1959
1959
  "toolName": "list-team-members",
1960
1960
  "presets": ["teams", "work"],
1961
- "workScopes": ["TeamMember.Read.All"]
1961
+ "workScopes": ["TeamMember.Read.All"],
1962
+ "llmTip": "Lists members of a team. Each member has displayName, email, roles and userId. Pass name to keep only members whose displayName or email contains that text (case-insensitive); use the returned userId in an @mention."
1962
1963
  },
1963
1964
  {
1964
1965
  "pathPattern": "/teams/{team-id}/channels/{channel-id}/filesFolder",