@softeria/ms-365-mcp-server 0.152.0 → 0.153.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.
@@ -1068,6 +1068,115 @@ describe("graph-tools", () => {
1068
1068
  expect(payload).not.toHaveProperty("target_resource");
1069
1069
  });
1070
1070
  });
1071
+ describe("skiptoken cursor", () => {
1072
+ const prevAllowPagination = process.env.MS365_MCP_ALLOW_PAGINATION;
1073
+ afterEach(() => {
1074
+ if (prevAllowPagination === void 0) delete process.env.MS365_MCP_ALLOW_PAGINATION;
1075
+ else process.env.MS365_MCP_ALLOW_PAGINATION = prevAllowPagination;
1076
+ });
1077
+ const callWith = async (args) => {
1078
+ mockEndpoints.push(makeEndpoint());
1079
+ mockEndpointsJson = [makeConfig()];
1080
+ const graphClient = createMockGraphClient();
1081
+ const server = createMockServer();
1082
+ const { registerGraphTools } = await loadModule();
1083
+ registerGraphTools(server, graphClient);
1084
+ await server.tools.get("test-tool").handler(args);
1085
+ return graphClient.graphRequest.mock.calls[0][0];
1086
+ };
1087
+ const singleObjectEndpoint = () => makeEndpoint({
1088
+ alias: "get-thing",
1089
+ path: "/me/thing",
1090
+ parameters: [
1091
+ { name: "select", type: "Query", schema: z.string().optional() },
1092
+ { name: "expand", type: "Query", schema: z.string().optional() }
1093
+ ]
1094
+ });
1095
+ const registerAnd = async (endpoint, config) => {
1096
+ mockEndpoints.push(endpoint);
1097
+ mockEndpointsJson = [config];
1098
+ const server = createMockServer();
1099
+ const { registerGraphTools } = await loadModule();
1100
+ registerGraphTools(server, createMockGraphClient());
1101
+ return server;
1102
+ };
1103
+ it("omits skiptoken on single-object GETs", async () => {
1104
+ const server = await registerAnd(
1105
+ singleObjectEndpoint(),
1106
+ makeConfig({ toolName: "get-thing", pathPattern: "/me/thing" })
1107
+ );
1108
+ expect(server.tools.get("get-thing").schema.skiptoken).toBeUndefined();
1109
+ });
1110
+ it("keeps skiptoken on delta tools that have $top stripped", async () => {
1111
+ const endpoint = makeEndpoint({
1112
+ alias: "list-calendar-events-delta",
1113
+ path: "/me/calendarView/delta"
1114
+ });
1115
+ const server = await registerAnd(
1116
+ endpoint,
1117
+ makeConfig({
1118
+ toolName: "list-calendar-events-delta",
1119
+ pathPattern: "/me/calendarView/delta"
1120
+ })
1121
+ );
1122
+ const schema = server.tools.get("list-calendar-events-delta").schema;
1123
+ expect(schema.top).toBeUndefined();
1124
+ expect(schema.skiptoken).toBeDefined();
1125
+ });
1126
+ it("advertises skiptoken on GET list tools", async () => {
1127
+ mockEndpoints.push(makeEndpoint());
1128
+ mockEndpointsJson = [makeConfig()];
1129
+ const server = createMockServer();
1130
+ const { registerGraphTools } = await loadModule();
1131
+ registerGraphTools(server, createMockGraphClient());
1132
+ expect(server.tools.get("test-tool").schema.skiptoken).toBeDefined();
1133
+ });
1134
+ it("still advertises skiptoken when MS365_MCP_ALLOW_PAGINATION is disabled", async () => {
1135
+ process.env.MS365_MCP_ALLOW_PAGINATION = "0";
1136
+ mockEndpoints.push(makeEndpoint());
1137
+ mockEndpointsJson = [makeConfig()];
1138
+ const server = createMockServer();
1139
+ const { registerGraphTools } = await loadModule();
1140
+ registerGraphTools(server, createMockGraphClient());
1141
+ expect(server.tools.get("test-tool").schema.fetchAllPages).toBeUndefined();
1142
+ expect(server.tools.get("test-tool").schema.skiptoken).toBeDefined();
1143
+ });
1144
+ it("forwards a bare token as $skiptoken", async () => {
1145
+ const path = await callWith({ skiptoken: "abc123" });
1146
+ expect(path).toContain("$skiptoken=abc123");
1147
+ });
1148
+ it("does not double-encode a token copied from @odata.nextLink", async () => {
1149
+ const path = await callWith({ skiptoken: "eyJhIjoxfQ%3d%3d" });
1150
+ expect(path).toContain("$skiptoken=eyJhIjoxfQ%3D%3D");
1151
+ expect(path).not.toContain("%253");
1152
+ });
1153
+ it("extracts the token when handed a whole nextLink URL", async () => {
1154
+ const path = await callWith({
1155
+ skiptoken: "https://graph.microsoft.com/v1.0/me/chats?$top=5&$filter=x&$skiptoken=tok123"
1156
+ });
1157
+ expect(path).toContain("$skiptoken=tok123");
1158
+ expect(path).not.toContain("graph.microsoft.com");
1159
+ });
1160
+ it("keeps only the cursor when the nextLink has params after it", async () => {
1161
+ const path = await callWith({ skiptoken: "$skiptoken=tok123&$top=5" });
1162
+ expect(path).toContain("$skiptoken=tok123");
1163
+ expect(path).not.toContain("tok123&");
1164
+ });
1165
+ it("accepts the $-prefixed param name", async () => {
1166
+ const path = await callWith({ $skiptoken: "abc123" });
1167
+ expect(path).toContain("$skiptoken=abc123");
1168
+ });
1169
+ it("omits $skiptoken entirely when blank", async () => {
1170
+ const path = await callWith({ skiptoken: " " });
1171
+ expect(path).not.toContain("skiptoken");
1172
+ });
1173
+ it("sends the cursor alongside the original query options", async () => {
1174
+ const path = await callWith({ filter: "chatType eq 'oneOnOne'", top: 5, skiptoken: "tok" });
1175
+ expect(path).toContain("$filter=");
1176
+ expect(path).toContain("$top=5");
1177
+ expect(path).toContain("$skiptoken=tok");
1178
+ });
1179
+ });
1071
1180
  describe("fetchAllPages pagination", () => {
1072
1181
  it("should follow @odata.nextLink and combine results", async () => {
1073
1182
  const endpoint = makeEndpoint();
@@ -56,7 +56,10 @@ import {
56
56
  EXPAND_EXTENDED_PROPERTIES_PARAM_DESCRIPTION,
57
57
  getAcceptParamDescription,
58
58
  getAccountParamDescription,
59
- getFetchAllPagesParamDescription
59
+ getFetchAllPagesParamDescription,
60
+ SKIPTOKEN_PARAM_DESCRIPTION,
61
+ isSkiptokenApplicable,
62
+ normalizeSkiptoken
60
63
  } from "./lib/param-descriptions.js";
61
64
  const __filename = fileURLToPath(import.meta.url);
62
65
  const __dirname = path.dirname(__filename);
@@ -1143,6 +1146,7 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
1143
1146
  "expand",
1144
1147
  "orderby",
1145
1148
  "skip",
1149
+ "skiptoken",
1146
1150
  "top",
1147
1151
  "count",
1148
1152
  "search",
@@ -1261,6 +1265,11 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
1261
1265
  if (TOP_UNSUPPORTED_DELTA_TOOLS.has(tool.alias)) {
1262
1266
  delete queryParams["$top"];
1263
1267
  }
1268
+ if (queryParams["$skiptoken"]) {
1269
+ const cursor = normalizeSkiptoken(queryParams["$skiptoken"]);
1270
+ if (cursor) queryParams["$skiptoken"] = cursor;
1271
+ else delete queryParams["$skiptoken"];
1272
+ }
1264
1273
  clampTopQueryParam(queryParams);
1265
1274
  const searchError = normalizeSearchQueryParam(queryParams, tool.path, tool.alias);
1266
1275
  if (searchError) return searchError;
@@ -1600,6 +1609,9 @@ function registerGraphTools(server, graphClient, readOnly = false, enabledToolsP
1600
1609
  const maxPages = getMaxPages();
1601
1610
  paramSchema["fetchAllPages"] = z.boolean().describe(getFetchAllPagesParamDescription(maxPages)).optional();
1602
1611
  }
1612
+ if (isSkiptokenApplicable(tool, Object.keys(paramSchema))) {
1613
+ paramSchema["skiptoken"] = z.string().describe(SKIPTOKEN_PARAM_DESCRIPTION).optional();
1614
+ }
1603
1615
  if (paramSchema["filter"] !== void 0 || paramSchema["$filter"] !== void 0) {
1604
1616
  const key = paramSchema["$filter"] !== void 0 ? "$filter" : "filter";
1605
1617
  paramSchema[key] = z.string().describe(FILTER_PARAM_DESCRIPTION).optional();
@@ -35,6 +35,31 @@ const EXPAND_PARAM_DESCRIPTION = 'Navigation properties to inline, e.g. attachme
35
35
  const ORDERBY_PARAM_DESCRIPTION = "Sort expression, e.g. receivedDateTime desc";
36
36
  const TOP_PARAM_DESCRIPTION = "Page size (Graph $top). Start small (e.g. 5\u201315) so responses fit the model context; raise only if needed. Use $select to return fewer fields per item. For more rows, use @odata.nextLink from the response instead of a very large $top.";
37
37
  const SKIP_PARAM_DESCRIPTION = "Items to skip for pagination. Not supported with $search.";
38
+ const SKIPTOKEN_PARAM_DESCRIPTION = "Opaque cursor for the next page, taken from the previous response. Copy the $skiptoken value out of @odata.nextLink (or paste the whole nextLink URL \u2014 the token is extracted from it) and resend it with the SAME $filter/$select/$expand/$top as the first call. Use this to walk pages one at a time instead of fetchAllPages. Omit it for the first page.";
39
+ const COLLECTION_QUERY_PARAMS = /* @__PURE__ */ new Set(["top", "filter", "orderby", "count"]);
40
+ function isSkiptokenApplicable(tool, paramNames) {
41
+ if (tool.method.toUpperCase() !== "GET") return false;
42
+ for (const name of paramNames) {
43
+ if (COLLECTION_QUERY_PARAMS.has(name.replace(/^\$/, "").toLowerCase())) return true;
44
+ }
45
+ return false;
46
+ }
47
+ function normalizeSkiptoken(raw) {
48
+ let token = raw.trim();
49
+ const marker = token.match(/(?:\$|%24)skiptoken=/i);
50
+ if (marker?.index !== void 0) {
51
+ token = token.slice(marker.index + marker[0].length);
52
+ token = token.split("&")[0];
53
+ }
54
+ if (token.includes("%")) {
55
+ try {
56
+ token = decodeURIComponent(token);
57
+ } catch {
58
+ logger.warn("skiptoken looks percent-encoded but could not be decoded; sending as-is");
59
+ }
60
+ }
61
+ return token;
62
+ }
38
63
  const COUNT_PARAM_DESCRIPTION = "Set true to enable advanced query mode (ConsistencyLevel: eventual). Required for complex $filter on flag/flagStatus or contains().";
39
64
  const CONFIRM_PARAM_DESCRIPTION = 'For destructive operations when the confirm gate is enabled (MS365_MCP_REQUIRE_CONFIRM=true; off by default). Set to true only after the user has explicitly approved this action. When the gate is on, calls without confirm: true return { error: "confirmation_required" } without touching user data.';
40
65
  const TIMEZONE_PARAM_DESCRIPTION = 'IANA timezone name (e.g., "America/New_York", "Europe/London", "Asia/Tokyo") for calendar event times. If not specified, times are returned in UTC.';
@@ -81,6 +106,7 @@ export {
81
106
  ORDERBY_PARAM_DESCRIPTION,
82
107
  SEARCH_PARAM_DESCRIPTION,
83
108
  SELECT_PARAM_DESCRIPTION,
109
+ SKIPTOKEN_PARAM_DESCRIPTION,
84
110
  SKIP_PARAM_DESCRIPTION,
85
111
  TIMEZONE_PARAM_DESCRIPTION,
86
112
  TOP_PARAM_DESCRIPTION,
@@ -91,6 +117,8 @@ export {
91
117
  getMaxPages,
92
118
  getODataParamDescription,
93
119
  isFetchAllPagesApplicable,
120
+ isSkiptokenApplicable,
121
+ normalizeSkiptoken,
94
122
  paginationAllowed,
95
123
  positiveIntFromEnv,
96
124
  shouldOmitTopParam
@@ -4,6 +4,8 @@ import {
4
4
  getODataParamDescription,
5
5
  shouldOmitTopParam,
6
6
  isFetchAllPagesApplicable,
7
+ isSkiptokenApplicable,
8
+ SKIPTOKEN_PARAM_DESCRIPTION,
7
9
  getMaxPages,
8
10
  getFetchAllPagesParamDescription,
9
11
  getAccountParamDescription,
@@ -57,6 +59,18 @@ function describeToolSchema(tool, config, ctx = {}) {
57
59
  schema: { type: "boolean" }
58
60
  });
59
61
  }
62
+ if (isSkiptokenApplicable(
63
+ { method: tool.method },
64
+ params.map((p) => p.name)
65
+ )) {
66
+ params.push({
67
+ name: "skiptoken",
68
+ in: "Query",
69
+ required: false,
70
+ description: SKIPTOKEN_PARAM_DESCRIPTION,
71
+ schema: { type: "string" }
72
+ });
73
+ }
60
74
  if (ctx.multiAccount) {
61
75
  params.push({
62
76
  name: "account",
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.152.0",
4
+ "version": "0.153.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",