loomiomcp 0.0.2 → 0.0.6

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/README.md CHANGED
@@ -1,11 +1,17 @@
1
1
  # loomiomcp
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/loomiomcp)](https://www.npmjs.com/package/loomiomcp)
4
+ [![CI](https://github.com/soil-dev/loomiomcp/actions/workflows/ci.yml/badge.svg)](https://github.com/soil-dev/loomiomcp/actions/workflows/ci.yml)
5
+ [![License: Apache-2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE)
6
+ [![Glama](https://glama.ai/mcp/servers/soil-dev/loomiomcp/badge)](https://glama.ai/mcp/servers/soil-dev/loomiomcp)
7
+
3
8
  Model Context Protocol server for [Loomio](https://www.loomio.com). Lets
4
9
  Claude (Desktop, Code, or web Projects via Custom Connector) read and
5
- write Loomio discussions, polls, comments, and group memberships in
6
- plain English. Targets Loomio's **b2** API — the canonical surface
7
- documented at [/help/api2](https://www.loomio.com/help/api2) and the
8
- namespace where the controllers actually live in the open-source repo.
10
+ write Loomio discussions, polls, comments, and group memberships — and
11
+ analyse member activity — in plain English. Targets Loomio's **b2** API
12
+ the canonical surface documented at
13
+ [/help/api2](https://www.loomio.com/help/api2) and the namespace where
14
+ the controllers actually live in the open-source repo.
9
15
 
10
16
  Tools (b2, per-user `?api_key=`):
11
17
 
@@ -16,11 +22,24 @@ Tools (b2, per-user `?api_key=`):
16
22
  - `list_polls(group_id, status?, limit?, offset?)` — list a group's polls
17
23
  - `create_poll(title, poll_type, …)` — start a new poll
18
24
  - `list_memberships(group_id, limit?, offset?)` — list a group's members with email
19
- addresses (caller must be a group admin)
25
+ addresses. Requires the connector's user to be a group **admin** (coordinator);
26
+ Loomio only returns the member list to admins. On a 403 the connector probes to
27
+ explain *why* — bot lacks the admin role vs. invalid key vs. not-a-member — and
28
+ points at `get_user_activity` / `list_events` for names/ids (email stays admin-only).
20
29
  - `list_groups({start_id?, end_id?, stop_after_consecutive_misses?})` — enumerate
21
30
  visible groups by probing `b2/polls` across an id range. Loomio has no
22
31
  api-key-authed list-groups endpoint; this is the workaround. Default scans
23
32
  are ~50–200 outbound calls; a single invocation is capped at 500 ids
33
+ - `list_events(discussion_id, limit?, offset?, kinds?)` — the event stream for one
34
+ discussion (comments, reactions, stances, outcomes, …) with `actor_id`, `kind`,
35
+ timestamps, and embedded `users` / `comments` / `polls`. With no `limit`/`offset`
36
+ it paginates up to a bounded cap and reports `scope.complete`; with either
37
+ pagination knob it returns that one page.
38
+ - `get_user_activity(user_id, group_ids, since?, until?)` — aggregate one user's
39
+ participation across groups (counts by kind / group / month, first/last activity).
40
+ The primary tool for any user-centric question; fans out server-side with a bounded
41
+ budget and reports completeness via `scope.complete`. If both `since` and `until`
42
+ are supplied, `until` must be later than `since`.
24
43
  - `manage_memberships({group_id, emails, remove_absent})` — add and (with
25
44
  `remove_absent: true`) **remove** members. See SECURITY.md before using
26
45
  `remove_absent`.
@@ -58,10 +77,10 @@ Only relevant if you operate a Loomio instance.
58
77
 
59
78
  ## Read-only mode
60
79
 
61
- Set `LOOMIO_MCP_READONLY=1` to register only the read tools
62
- (`get_*` / `list_*`). All write tools (`create_*`, `manage_*`) are
63
- skipped at server-init time. This is the mode the Cloud Run deployment
64
- runs in.
80
+ Set `LOOMIO_MCP_READONLY=1` to register only the 8 read tools
81
+ (`get_*` / `list_*` / `get_user_activity`). All write tools
82
+ (`create_*`, `manage_*`) are skipped at server-init time. This is the
83
+ mode the Cloud Run deployment runs in.
65
84
 
66
85
  ## Docs map
67
86
 
package/dist/http.js CHANGED
@@ -38,7 +38,7 @@ function logEvent(event, fields, opts = {}) {
38
38
  }
39
39
  function redactPath(path) {
40
40
  const noQuery = path.split("?")[0] ?? path;
41
- return noQuery.replace(/\/\d+(?:,\d+)*/g, "/:id");
41
+ return noQuery.replace(/\/\d+(?:,\d+)*/g, "/:id").replace(/\/(discussions|polls)\/[^/]+/g, "/$1/:id");
42
42
  }
43
43
  var requestContext = new AsyncLocalStorage();
44
44
  function withRequestContext(initial, fn) {
@@ -187,8 +187,15 @@ async function fetchWithTimeout(url, options) {
187
187
  async function throwForStatus(res) {
188
188
  if (res.status === 401 || res.status === 403) {
189
189
  const detail = await parseErrorBody(res);
190
+ const detailStr = String(detail ?? "").trim();
191
+ let pathHint = "the requested resource";
192
+ try {
193
+ pathHint = redactPath(new URL(res.url).pathname);
194
+ } catch {
195
+ }
196
+ const detailSuffix = detailStr && detailStr !== String(res.status) ? `: ${detailStr}` : "";
190
197
  throw new LoomioAuthError(
191
- `Loomio API returned ${res.status}: ${detail}. Check that LOOMIO_API_KEY is valid and has access to the requested resource.`,
198
+ `Loomio API returned ${res.status} for ${pathHint}${detailSuffix}. Loomio sends the same 403 whether the connector's key is invalid/expired OR the key's user simply lacks the role this resource requires (some endpoints \u2014 e.g. listing a group's members \u2014 require the group-admin/coordinator role). Verify the key, and that the bot user has the needed role on the target group.`,
192
199
  res.status
193
200
  );
194
201
  }
@@ -259,6 +266,21 @@ async function loomioGet(path, params) {
259
266
  start.cleanup();
260
267
  }
261
268
  }
269
+ async function loomioGetStatus(path, params) {
270
+ const url = buildUrl(B2_AUTH, path, params);
271
+ const start = await doFetch(url, { headers: baseHeaders() });
272
+ try {
273
+ return await consumeBody(start, async () => {
274
+ try {
275
+ await start.res.text();
276
+ } catch {
277
+ }
278
+ return start.res.status;
279
+ });
280
+ } finally {
281
+ start.cleanup();
282
+ }
283
+ }
262
284
  function encodeForm(body) {
263
285
  const u = new URLSearchParams();
264
286
  for (const [k, v] of Object.entries(body)) {
@@ -916,6 +938,9 @@ import { z as z2 } from "zod";
916
938
  var positiveId = z2.number().int().positive();
917
939
  var loomioKey = z2.string().regex(/^[A-Za-z0-9_-]+$/, "Loomio short keys may only contain letters, numbers, _ and -.");
918
940
  var idOrKey = z2.union([loomioKey, positiveId]);
941
+ var isoTimestamp = z2.string().refine((v) => !Number.isNaN(Date.parse(v)), {
942
+ message: "must be a parseable ISO-8601 timestamp (e.g. 2026-01-31 or 2026-01-31T00:00:00Z)."
943
+ }).optional();
919
944
  function encodePathSegment(value) {
920
945
  const raw = String(value);
921
946
  if (raw === "" || raw === "." || raw === "..") {
@@ -1060,19 +1085,75 @@ async function createPoll(input) {
1060
1085
 
1061
1086
  // src/tools/memberships.ts
1062
1087
  import { z as z5 } from "zod";
1088
+
1089
+ // src/loomio/access.ts
1090
+ async function classifyGroupForbidden(groupId) {
1091
+ const probeStatus = await loomioGetStatus("/b2/polls", {
1092
+ group_id: groupId,
1093
+ limit: 1,
1094
+ status: "all"
1095
+ });
1096
+ if (probeStatus === 200) return { kind: "not_admin" };
1097
+ if (probeStatus === 403) return { kind: "no_access" };
1098
+ return { kind: "inconclusive", probeStatus };
1099
+ }
1100
+ function adminRequiredError(opts) {
1101
+ const { groupId, resource, classification, fallback } = opts;
1102
+ const tail = fallback ? ` ${fallback}` : "";
1103
+ switch (classification.kind) {
1104
+ case "not_admin":
1105
+ return new LoomioAuthError(
1106
+ `Loomio denied access to ${resource} for group ${groupId} (HTTP 403). The connector's Loomio key is valid \u2014 it can read group ${groupId} \u2014 but this data is only visible to a group admin (coordinator), and the connector's bot user is a plain member. This is a Loomio permission boundary, not a connector or client bug, and it is often intentional: the bot is deliberately kept as a non-admin so it can't read everyone's email. If this data is genuinely needed, a coordinator of group ${groupId} can grant the bot the coordinator role (Loomio \u2192 the group \u2192 Members \u2192 the bot user \u2192 Make coordinator).${tail}`,
1107
+ 403
1108
+ );
1109
+ case "no_access":
1110
+ return new LoomioAuthError(
1111
+ `Loomio denied access to ${resource} for group ${groupId} (HTTP 403), and a follow-up check shows the connector can't read group ${groupId} at all. That means one of: the Loomio API key is invalid or expired, or the connector's bot user is not a member of group ${groupId}. Verify LOOMIO_API_KEY and that the bot has been added to the group.`,
1112
+ 403
1113
+ );
1114
+ default:
1115
+ return new LoomioAuthError(
1116
+ `Loomio denied access to ${resource} for group ${groupId} (HTTP 403). This endpoint typically requires the connector's bot user to be an admin (coordinator) of the group, but a follow-up access check was inconclusive (HTTP ${classification.probeStatus}), so the exact cause \u2014 missing admin role, an invalid key, or the bot not being a member \u2014 couldn't be confirmed. Verify the group id, the API key, and the bot's role on the group.${tail}`,
1117
+ 403
1118
+ );
1119
+ }
1120
+ }
1121
+
1122
+ // src/tools/memberships.ts
1123
+ var NON_ADMIN_FALLBACK = "Member display names, usernames, and ids (but not email addresses) are available without admin via get_user_activity / list_events, which read the group's event stream.";
1124
+ async function explainForbidden(groupId, original, resource, fallback) {
1125
+ try {
1126
+ const classification = await classifyGroupForbidden(groupId);
1127
+ return adminRequiredError({ groupId, resource, classification, fallback });
1128
+ } catch {
1129
+ return original;
1130
+ }
1131
+ }
1063
1132
  var listMembershipsSchema = z5.object({
1064
1133
  group_id: positiveId.describe(
1065
- "ID of the Loomio group whose memberships to list (required). Caller must be a group admin; the response includes member email addresses."
1134
+ "ID of the Loomio group whose memberships to list (required). The connector's bot user must be an admin (coordinator) of the group \u2014 Loomio only returns the member list, including email addresses, to group admins. For a non-admin bot this returns a clear 403 explaining the role requirement; names/usernames/ids (not emails) are still reachable via get_user_activity / list_events."
1066
1135
  ),
1067
1136
  limit: z5.number().int().min(1).max(200).optional().describe("Page size. Loomio defaults to 50."),
1068
1137
  offset: z5.number().int().min(0).optional().describe("Page offset. Defaults to 0.")
1069
1138
  });
1070
1139
  async function listMemberships(input) {
1071
- return loomioGet("/b2/memberships", {
1072
- group_id: input.group_id,
1073
- ...input.limit !== void 0 ? { limit: input.limit } : {},
1074
- ...input.offset !== void 0 ? { offset: input.offset } : {}
1075
- });
1140
+ try {
1141
+ return await loomioGet("/b2/memberships", {
1142
+ group_id: input.group_id,
1143
+ ...input.limit !== void 0 ? { limit: input.limit } : {},
1144
+ ...input.offset !== void 0 ? { offset: input.offset } : {}
1145
+ });
1146
+ } catch (err) {
1147
+ if (err instanceof LoomioAuthError && err.status === 403) {
1148
+ throw await explainForbidden(
1149
+ input.group_id,
1150
+ err,
1151
+ "the member list (names, emails, roles)",
1152
+ NON_ADMIN_FALLBACK
1153
+ );
1154
+ }
1155
+ throw err;
1156
+ }
1076
1157
  }
1077
1158
  var manageMembershipsSchema = z5.object({
1078
1159
  group_id: positiveId.describe(
@@ -1086,7 +1167,14 @@ var manageMembershipsSchema = z5.object({
1086
1167
  )
1087
1168
  });
1088
1169
  async function manageMemberships(input) {
1089
- return loomioPost("/b2/memberships", input);
1170
+ try {
1171
+ return await loomioPost("/b2/memberships", input);
1172
+ } catch (err) {
1173
+ if (err instanceof LoomioAuthError && err.status === 403) {
1174
+ throw await explainForbidden(input.group_id, err, "membership changes");
1175
+ }
1176
+ throw err;
1177
+ }
1090
1178
  }
1091
1179
 
1092
1180
  // src/tools/groups.ts
@@ -1197,34 +1285,79 @@ async function listGroups(input) {
1197
1285
 
1198
1286
  // src/tools/events.ts
1199
1287
  import { z as z7 } from "zod";
1288
+ var CONCURRENCY2 = 6;
1289
+ var EVENTS_PAGE_SIZE = 200;
1290
+ var MAX_EVENTS_PAGES = 10;
1291
+ var MAX_DISCUSSION_PAGES = 20;
1292
+ var MAX_SCAN_DISCUSSIONS = 500;
1200
1293
  var listEventsSchema = z7.object({
1201
1294
  discussion_id: positiveId.describe(
1202
1295
  "ID of the discussion whose event stream to fetch. Required \u2014 Loomio's v1/events endpoint silently returns empty without it."
1203
1296
  ),
1204
- limit: z7.number().int().min(1).max(200).optional().describe("Page size. Loomio defaults to 50; cap is 200."),
1205
- offset: z7.number().int().min(0).optional().describe("Page offset (Loomio's `from` parameter). Defaults to 0."),
1297
+ limit: z7.number().int().min(1).max(200).optional().describe(
1298
+ "Page size for a single-page fetch. Omit limit/offset to let the connector paginate the full discussion stream up to its bounded cap."
1299
+ ),
1300
+ offset: z7.number().int().min(0).optional().describe(
1301
+ "Page offset (Loomio's `from` parameter). When supplied, list_events returns exactly that page instead of auto-paginating."
1302
+ ),
1206
1303
  kinds: z7.array(z7.string().min(1)).optional().describe(
1207
1304
  "Optional filter to only these event kinds (applied client-side after fetch \u2014 Loomio doesn't filter `kind` server-side). Common kinds: new_discussion, new_comment, comment_edited, poll_created, stance_created, outcome_created, discussion_moved, discussion_closed, reaction."
1208
1305
  )
1209
1306
  });
1307
+ function mergeEventResponse(into, from) {
1308
+ into.events = [...into.events ?? [], ...from.events ?? []];
1309
+ into.comments = [...into.comments ?? [], ...from.comments ?? []];
1310
+ into.users = [...into.users ?? [], ...from.users ?? []];
1311
+ into.polls = [...into.polls ?? [], ...from.polls ?? []];
1312
+ into.parent_events = [...into.parent_events ?? [], ...from.parent_events ?? []];
1313
+ into.meta = from.meta ?? into.meta;
1314
+ }
1315
+ function filterKinds(resp, kinds) {
1316
+ if (!kinds?.length) return resp;
1317
+ const allowed = new Set(kinds);
1318
+ return {
1319
+ ...resp,
1320
+ events: (resp.events ?? []).filter((e) => allowed.has(e.kind))
1321
+ };
1322
+ }
1210
1323
  async function listEvents(input) {
1211
- const resp = await loomioGet("/v1/events", {
1212
- discussion_id: input.discussion_id,
1213
- ...input.limit !== void 0 ? { per: input.limit } : {},
1214
- ...input.offset !== void 0 ? { from: input.offset } : {}
1215
- });
1216
- if (input.kinds?.length) {
1217
- const allowed = new Set(input.kinds);
1218
- return {
1219
- ...resp,
1220
- events: (resp.events ?? []).filter((e) => allowed.has(e.kind))
1221
- };
1324
+ if (input.limit !== void 0 || input.offset !== void 0) {
1325
+ const resp = await loomioGet("/v1/events", {
1326
+ discussion_id: input.discussion_id,
1327
+ ...input.limit !== void 0 ? { per: input.limit } : {},
1328
+ ...input.offset !== void 0 ? { from: input.offset } : {}
1329
+ });
1330
+ return filterKinds(resp, input.kinds);
1222
1331
  }
1223
- return resp;
1332
+ const merged = {};
1333
+ let offset = 0;
1334
+ let pagesFetched = 0;
1335
+ let truncated = false;
1336
+ for (let page = 0; page < MAX_EVENTS_PAGES; page++) {
1337
+ const resp = await loomioGet("/v1/events", {
1338
+ discussion_id: input.discussion_id,
1339
+ per: EVENTS_PAGE_SIZE,
1340
+ from: offset
1341
+ });
1342
+ pagesFetched++;
1343
+ const evs = resp.events ?? [];
1344
+ mergeEventResponse(merged, resp);
1345
+ if (evs.length < EVENTS_PAGE_SIZE) break;
1346
+ offset += EVENTS_PAGE_SIZE;
1347
+ if (page === MAX_EVENTS_PAGES - 1) truncated = true;
1348
+ }
1349
+ return filterKinds(
1350
+ {
1351
+ ...merged,
1352
+ scope: {
1353
+ complete: !truncated,
1354
+ pages_fetched: pagesFetched,
1355
+ events_truncated: truncated
1356
+ }
1357
+ },
1358
+ input.kinds
1359
+ );
1224
1360
  }
1225
- var CONCURRENCY2 = 6;
1226
- var EVENTS_PAGE_SIZE = 200;
1227
- var MAX_EVENTS_PAGES = 10;
1228
1361
  var ACTIVITY_KINDS = /* @__PURE__ */ new Set([
1229
1362
  "new_discussion",
1230
1363
  "new_comment",
@@ -1240,13 +1373,27 @@ var getUserActivitySchema = z7.object({
1240
1373
  group_ids: z7.array(positiveId).min(1).max(50).describe(
1241
1374
  "Groups to scan. Required \u2014 pass the result of `list_groups` (or a subset of it) to make the cost explicit. ~1 outbound HTTP request per discussion in scope, plus one list_discussions call per group; ~100-300 calls is typical for a wide scan. Capped at 50 groups per call."
1242
1375
  ),
1243
- since: z7.string().optional().describe("ISO-8601 timestamp; ignore events before this time."),
1244
- until: z7.string().optional().describe("ISO-8601 timestamp; ignore events at or after this time.")
1376
+ since: isoTimestamp.describe(
1377
+ "ISO-8601 timestamp; ignore events before this time. Rejected if unparseable (so a typo can't silently widen the scan to all history)."
1378
+ ),
1379
+ until: isoTimestamp.describe(
1380
+ "ISO-8601 timestamp; ignore events at or after this time. Rejected if unparseable."
1381
+ )
1382
+ }).superRefine((input, ctx) => {
1383
+ if (!input.since || !input.until) return;
1384
+ if (Date.parse(input.until) <= Date.parse(input.since)) {
1385
+ ctx.addIssue({
1386
+ code: "custom",
1387
+ path: ["until"],
1388
+ message: "until must be later than since."
1389
+ });
1390
+ }
1245
1391
  });
1246
1392
  async function listDiscussionIdsForGroup(groupId) {
1247
1393
  const all = [];
1248
1394
  let offset = 0;
1249
- for (let page = 0; page < 20; page++) {
1395
+ let truncated = false;
1396
+ for (let page = 0; page < MAX_DISCUSSION_PAGES; page++) {
1250
1397
  const resp = await loomioGet("/b2/discussions", {
1251
1398
  group_id: groupId,
1252
1399
  status: "all",
@@ -1257,12 +1404,14 @@ async function listDiscussionIdsForGroup(groupId) {
1257
1404
  for (const d of ds) all.push({ id: d.id, group_id: groupId });
1258
1405
  if (ds.length < EVENTS_PAGE_SIZE) break;
1259
1406
  offset += EVENTS_PAGE_SIZE;
1407
+ if (page === MAX_DISCUSSION_PAGES - 1) truncated = true;
1260
1408
  }
1261
- return all;
1409
+ return { discussions: all, truncated };
1262
1410
  }
1263
1411
  async function fetchAllEventsForDiscussion(discussionId) {
1264
1412
  const all = [];
1265
1413
  let offset = 0;
1414
+ let truncated = false;
1266
1415
  for (let page = 0; page < MAX_EVENTS_PAGES; page++) {
1267
1416
  const resp = await loomioGet("/v1/events", {
1268
1417
  discussion_id: discussionId,
@@ -1273,10 +1422,11 @@ async function fetchAllEventsForDiscussion(discussionId) {
1273
1422
  for (const e of evs) all.push(e);
1274
1423
  if (evs.length < EVENTS_PAGE_SIZE) break;
1275
1424
  offset += EVENTS_PAGE_SIZE;
1425
+ if (page === MAX_EVENTS_PAGES - 1) truncated = true;
1276
1426
  }
1277
- return all;
1427
+ return { events: all, truncated };
1278
1428
  }
1279
- async function runWithConcurrency(items, worker, concurrency) {
1429
+ async function runWithConcurrency(items, worker, concurrency, recoverError = () => true) {
1280
1430
  const results = new Array(items.length).fill(null);
1281
1431
  let next = 0;
1282
1432
  async function take() {
@@ -1285,7 +1435,8 @@ async function runWithConcurrency(items, worker, concurrency) {
1285
1435
  if (i >= items.length) return;
1286
1436
  try {
1287
1437
  results[i] = await worker(items[i]);
1288
- } catch {
1438
+ } catch (err) {
1439
+ if (!recoverError(err)) throw err;
1289
1440
  results[i] = null;
1290
1441
  }
1291
1442
  }
@@ -1293,26 +1444,55 @@ async function runWithConcurrency(items, worker, concurrency) {
1293
1444
  await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, take));
1294
1445
  return results;
1295
1446
  }
1447
+ function isRecoverableScanError(err) {
1448
+ if (err instanceof LoomioAuthError) return err.status === 403;
1449
+ return err instanceof LoomioApiError;
1450
+ }
1451
+ function rememberRecentSample(samples, e) {
1452
+ samples.push({
1453
+ kind: e.kind,
1454
+ discussion_id: e.discussion_id,
1455
+ created_at: e.created_at,
1456
+ eventable_type: e.eventable_type,
1457
+ eventable_id: e.eventable_id
1458
+ });
1459
+ samples.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at));
1460
+ if (samples.length > 10) samples.length = 10;
1461
+ }
1296
1462
  async function getUserActivity(input) {
1297
1463
  const groupIds = [...new Set(input.group_ids)];
1298
1464
  const groupDiscussionLists = await runWithConcurrency(
1299
1465
  groupIds,
1300
1466
  (gid) => listDiscussionIdsForGroup(gid),
1301
- CONCURRENCY2
1467
+ CONCURRENCY2,
1468
+ isRecoverableScanError
1302
1469
  );
1470
+ const groupsFailed = [];
1471
+ const groupsTruncated = [];
1303
1472
  const discussions = [];
1304
- for (const list of groupDiscussionLists) {
1305
- if (list) discussions.push(...list);
1306
- }
1473
+ groupDiscussionLists.forEach((list, i) => {
1474
+ if (list) {
1475
+ discussions.push(...list.discussions);
1476
+ if (list.truncated) groupsTruncated.push(groupIds[i]);
1477
+ } else {
1478
+ groupsFailed.push(groupIds[i]);
1479
+ }
1480
+ });
1481
+ const discussionsCapped = discussions.length > MAX_SCAN_DISCUSSIONS;
1482
+ const scanDiscussions = discussionsCapped ? discussions.slice(0, MAX_SCAN_DISCUSSIONS) : discussions;
1307
1483
  const since = input.since ? Date.parse(input.since) : Number.NEGATIVE_INFINITY;
1308
1484
  const until = input.until ? Date.parse(input.until) : Number.POSITIVE_INFINITY;
1309
1485
  let eventsExamined = 0;
1486
+ let discussionsFailed = 0;
1487
+ let discussionsTruncated = 0;
1310
1488
  const counts = {
1311
1489
  total: 0,
1312
1490
  new_discussion: 0,
1313
1491
  new_comment: 0,
1492
+ comment_edited: 0,
1314
1493
  poll_created: 0,
1315
1494
  stance_created: 0,
1495
+ stance_updated: 0,
1316
1496
  outcome_created: 0,
1317
1497
  reaction: 0,
1318
1498
  other: 0
@@ -1323,12 +1503,20 @@ async function getUserActivity(input) {
1323
1503
  let lastActivity = null;
1324
1504
  const samples = [];
1325
1505
  const eventStreams = await runWithConcurrency(
1326
- discussions,
1327
- async (d) => ({ groupId: d.group_id, events: await fetchAllEventsForDiscussion(d.id) }),
1328
- CONCURRENCY2
1506
+ scanDiscussions,
1507
+ async (d) => {
1508
+ const { events, truncated } = await fetchAllEventsForDiscussion(d.id);
1509
+ return { groupId: d.group_id, events, truncated };
1510
+ },
1511
+ CONCURRENCY2,
1512
+ isRecoverableScanError
1329
1513
  );
1330
1514
  for (const s of eventStreams) {
1331
- if (!s) continue;
1515
+ if (!s) {
1516
+ discussionsFailed++;
1517
+ continue;
1518
+ }
1519
+ if (s.truncated) discussionsTruncated++;
1332
1520
  for (const e of s.events) {
1333
1521
  eventsExamined++;
1334
1522
  if (e.actor_id !== input.user_id) continue;
@@ -1346,25 +1534,24 @@ async function getUserActivity(input) {
1346
1534
  byMonth[month] = (byMonth[month] ?? 0) + 1;
1347
1535
  if (!firstActivity || e.created_at < firstActivity) firstActivity = e.created_at;
1348
1536
  if (!lastActivity || e.created_at > lastActivity) lastActivity = e.created_at;
1349
- if (samples.length < 10) {
1350
- samples.push({
1351
- kind: e.kind,
1352
- discussion_id: e.discussion_id,
1353
- created_at: e.created_at,
1354
- eventable_type: e.eventable_type,
1355
- eventable_id: e.eventable_id
1356
- });
1357
- }
1537
+ rememberRecentSample(samples, e);
1358
1538
  }
1359
1539
  }
1540
+ const complete = groupsFailed.length === 0 && discussionsFailed === 0 && discussionsTruncated === 0 && groupsTruncated.length === 0 && !discussionsCapped;
1360
1541
  return {
1361
1542
  user_id: input.user_id,
1362
1543
  scope: {
1363
1544
  group_ids: groupIds,
1364
- discussions_scanned: discussions.length,
1545
+ discussions_scanned: scanDiscussions.length,
1365
1546
  events_examined: eventsExamined,
1366
1547
  since: input.since ?? null,
1367
- until: input.until ?? null
1548
+ until: input.until ?? null,
1549
+ complete,
1550
+ groups_failed: groupsFailed,
1551
+ discussions_failed: discussionsFailed,
1552
+ discussions_truncated: discussionsTruncated,
1553
+ discussions_capped: discussionsCapped,
1554
+ groups_truncated: groupsTruncated
1368
1555
  },
1369
1556
  counts,
1370
1557
  by_group: byGroup,
@@ -1413,8 +1600,9 @@ function createLoomioMcpServer() {
1413
1600
  const b3Enabled = hasB3ApiKey();
1414
1601
  const server = new McpServer({
1415
1602
  name: "loomiomcp",
1416
- version: "0.1.0",
1417
- description: "MCP server for Loomio (loomio.com / self-hosted). Wraps Loomio's b2 public API: read and create discussions, polls, comments, plus list and manage group memberships. Read-only mode supported via LOOMIO_MCP_READONLY=1 (Cloud Run pattern). Optional b3 admin operations (deactivate / reactivate user) when LOOMIO_B3_API_KEY is set \u2014 instance operators only. Read tools annotated with readOnlyHint so MCP clients can auto-approve safe calls; destructive writes (manage_memberships with remove_absent, deactivate_user) carry destructiveHint.",
1603
+ // Keep in sync with package.json on each release.
1604
+ version: "0.0.6",
1605
+ description: "MCP server for Loomio (loomio.com / self-hosted). Wraps Loomio's b2 public API: read and create discussions, polls, and comments; list and manage group memberships; read per-discussion event streams; and aggregate a user's participation across groups. Read-only mode supported via LOOMIO_MCP_READONLY=1 (Cloud Run pattern). Optional b3 admin operations (deactivate / reactivate user) when LOOMIO_B3_API_KEY is set \u2014 instance operators only. Read tools annotated with readOnlyHint so MCP clients can auto-approve safe calls; destructive writes (manage_memberships with remove_absent, deactivate_user) carry destructiveHint.",
1418
1606
  websiteUrl: "https://github.com/soil-dev/loomiomcp",
1419
1607
  icons: ICONS
1420
1608
  });
@@ -1451,7 +1639,7 @@ function createLoomioMcpServer() {
1451
1639
  registerTool(
1452
1640
  server,
1453
1641
  "list_polls",
1454
- "List polls in a Loomio group, ordered by creation date (newest first). Required: `group_id`. Optional `status` filter \u2014 'active' (default), 'closed', 'all' (every kept poll); `limit` 1-200 (default 50); `offset` for pagination. Caller must be a group member. Use to answer 'what's up for vote in group X', 'show me past poll results', or before create_poll to check what's already proposed.",
1642
+ "List polls in a Loomio group, ordered by creation date (newest first). Required: `group_id`. Optional `status` filter \u2014 'active' (default), 'closed', 'all' (every kept poll); `limit` 1-200 (default 50); `offset` for pagination. Caller must be a group member. Use to answer 'what's up for vote in group X', 'show me past poll results', or before create_poll to check what's already proposed. FOR PER-USER PARTICIPATION QUESTIONS \u2014 'who voted', 'how often did X vote', 'compare members' turnout' \u2014 prefer `get_user_activity`. It returns participation directly (via the underlying events stream) and avoids the ambiguity between 'didn't vote' and 'abstained' that you can't tell apart from a `list_polls` response alone.",
1455
1643
  listPollsSchema,
1456
1644
  listPolls
1457
1645
  );
@@ -1467,7 +1655,7 @@ function createLoomioMcpServer() {
1467
1655
  registerTool(
1468
1656
  server,
1469
1657
  "list_memberships",
1470
- "List members of a Loomio group with their email addresses, roles, and join state. Required: `group_id`. Caller MUST be a group admin (non-admins get HTTP 403; the response is server-side scoped to include `include_email: true`). Optional `limit` 1-200 (default 50) and `offset` for pagination. Use to answer 'who's in group X', 'find a member by email', or \u2014 critically \u2014 BEFORE calling manage_memberships with remove_absent=true, since the diff between current and intended members is what makes that destructive call safe.",
1658
+ "List members of a Loomio group with their email addresses, roles, and join state. Required: `group_id`. Caller MUST be a group admin (non-admins get HTTP 403; the response is server-side scoped to include `include_email: true`). Optional `limit` 1-200 (default 50) and `offset` for pagination. Use to answer 'who's in group X', 'find a member by email', or \u2014 critically \u2014 BEFORE calling manage_memberships with remove_absent=true, since the diff between current and intended members is what makes that destructive call safe. Do NOT use this tool to construct a participation analysis (e.g. 'how active is each member', 'who voted in our polls') by combining its output with `list_polls`. That reconstruction is more expensive in round-trips AND ambiguous about abstain-vs-didn't-vote. Use `get_user_activity` per member instead \u2014 it answers participation directly from the event stream.",
1471
1659
  listMembershipsSchema,
1472
1660
  listMemberships
1473
1661
  );
@@ -1481,14 +1669,14 @@ function createLoomioMcpServer() {
1481
1669
  registerTool(
1482
1670
  server,
1483
1671
  "list_events",
1484
- "Fetch the full event stream for one discussion \u2014 every new_comment, poll_created, stance_created, outcome_created, reaction, discussion_moved, etc. \u2014 with actor_id, kind, parent_id, created_at, and pointers to the underlying eventable record. Required: `discussion_id`. Optional `limit` (1-200, default 50), `offset` (Loomio's `from` param), and `kinds` (client-side filter to specific event kinds). The response also embeds related `comments`, `users`, and `polls` arrays for in-place resolution. Use this to answer 'show me the reply tree for thread X', 'who participated in discussion Y', or as the building block for cross-discussion aggregations. Loomio's v1/events endpoint requires a discussion_id; there's no instance-wide or per-user index \u2014 for user-centric questions, use `get_user_activity`.",
1672
+ "Fetch the event stream for ONE discussion \u2014 new_comment, poll_created, stance_created, outcome_created, reaction, discussion_moved, etc. \u2014 with actor_id, kind, parent_id, created_at, and pointers to the underlying eventable record. Required: `discussion_id`. By default the connector paginates Loomio's v1/events endpoint up to a bounded cap and returns `scope.complete`; if you pass `limit` and/or `offset`, it returns exactly that one page. Optional `kinds` filters client-side after fetch. The response also embeds related `comments`, `users`, and `polls` arrays for in-place resolution. Use this to answer 'show me the reply tree for thread X', 'who participated in discussion Y', or as the building block for cross-discussion aggregations. Loomio's v1/events endpoint REQUIRES a discussion_id; there is no instance-wide, per-group, or per-user index. For user-centric questions across many discussions ('how active is X', 'compare members across groups'), use `get_user_activity` \u2014 do NOT loop `list_events` over every discussion yourself. `get_user_activity` does that fan-out server-side with concurrency control.",
1485
1673
  listEventsSchema,
1486
1674
  listEvents
1487
1675
  );
1488
1676
  registerTool(
1489
1677
  server,
1490
1678
  "get_user_activity",
1491
- "Aggregate one user's activity across a set of groups. Server-side: fans out across every discussion in the specified groups, fetches its event stream, filters to events authored by the target user, and returns counts (by kind, by group, by month), plus first/last activity timestamps and a sample of recent events. Required: `user_id`, `group_ids` (1-50; pass the result of `list_groups` for instance-wide). Optional `since` / `until` (ISO-8601) bound the time window. Cost: one outbound HTTP call per discussion in scope (plus one `list_discussions` per group). Wide scans of an active instance commonly hit 100-300 calls in 5-30 seconds. Concurrency-capped at 6. Use this for 'tell me about user X', 'how active has user Y been in Q1', or before promoting/awarding someone. For one discussion at a time, use `list_events`.",
1679
+ "Aggregate one user's activity across a set of groups. Server-side: fans out across every discussion in the specified groups, fetches its event stream, filters to events authored by the target user, and returns counts (by kind, by group, by month), plus first/last activity timestamps and a sample of recent events. Required: `user_id`, `group_ids` (1-50; pass the result of `list_groups` for instance-wide). Optional `since` / `until` (ISO-8601) bound the time window; `until` must be later than `since` when both are supplied. USE THIS for any user-centric question \u2014 single-user OR comparing multiple users. Examples that all map to this tool: 'tell me about user X', 'how active has Y been in Q1', 'compare participation across two groups (e.g. two teams or committees)', 'rank members of group N by participation', 'who's the most engaged contributor since June', 'build a participation card for each member'. For an N-user comparison, **call this tool N times** (once per user) \u2014 that's the intended pattern and is materially cheaper than reconstructing the same data from `list_polls` + `list_memberships`. Why call this instead of fanning out `list_polls`/`list_memberships` yourself: (1) Participation here is read from the canonical event stream \u2014 'voted' vs 'didn't vote' is unambiguous; you can't tell those apart from `list_polls` alone. (2) Round-trip count is the same or lower in aggregate, because each user's activity scan reuses the same `list_discussions` fetches in your conversation context. (3) The result is pre-aggregated by kind/group/month \u2014 Claude doesn't need to count anything client-side. Cost: one outbound HTTP call per discussion in scope (plus one `list_discussions` per group). A single user-activity call on a ~200-discussion instance is ~200 calls in 5-10 seconds, concurrency-capped at 6. That sounds large but is the correct denominator for comparison: building the same answer from `list_polls` requires the same discussion-scan + a separate `list_memberships` per group + client-side cross-referencing. The fan-out is bounded by a global cap, so a single call can't run away. COMPLETENESS: check `scope.complete`. When it's false the counts are a LOWER BOUND \u2014 inspect `scope.groups_failed` (groups the bot couldn't read, e.g. it isn't a member), `scope.groups_truncated` (a group's discussion listing hit the page cap), `scope.discussions_failed`, `scope.discussions_truncated` (very long threads), and `scope.discussions_capped` (scan hit the global ceiling). Report partial results as partial; don't present them as the whole picture. For one discussion at a time, use `list_events`. For 'what groups can the user see', use `list_groups` first to scope the call.",
1492
1680
  getUserActivitySchema,
1493
1681
  getUserActivity
1494
1682
  );
@@ -1584,11 +1772,18 @@ function mountTransport(app2, opts) {
1584
1772
  limit: rateLimitMax,
1585
1773
  standardHeaders: "draft-7",
1586
1774
  legacyHeaders: false,
1587
- keyGenerator: (req) => {
1588
- const clientId = req.auth?.clientId;
1589
- if (clientId) return clientId;
1590
- return ipKeyGenerator(req.ip ?? "");
1591
- },
1775
+ // Key the limiter on the SOURCE IP, never the OAuth client_id.
1776
+ // Under open DCR (the public deployment) any caller can POST
1777
+ // /register for unlimited fresh client_ids, so keying on client_id
1778
+ // would let one source mint a brand-new 300/min bucket at will —
1779
+ // silently defeating the limit. trust proxy=1 (Cloud Run's single
1780
+ // front-end hop) makes req.ip the real client address (not
1781
+ // X-Forwarded-For-spoofable past that hop), and ipKeyGenerator
1782
+ // normalises IPv6 to a /56 so a /128 walk can't sidestep it. In
1783
+ // static-client mode this is also strictly better than the old
1784
+ // behaviour, which bucketed every caller under the one shared
1785
+ // client_id.
1786
+ keyGenerator: (req) => ipKeyGenerator(req.ip ?? ""),
1592
1787
  skip: () => rateLimitDisabled,
1593
1788
  handler: (_req, res) => {
1594
1789
  res.status(429).json({
package/dist/index.js CHANGED
@@ -36,7 +36,7 @@ function logEvent(event, fields, opts = {}) {
36
36
  }
37
37
  function redactPath(path) {
38
38
  const noQuery = path.split("?")[0] ?? path;
39
- return noQuery.replace(/\/\d+(?:,\d+)*/g, "/:id");
39
+ return noQuery.replace(/\/\d+(?:,\d+)*/g, "/:id").replace(/\/(discussions|polls)\/[^/]+/g, "/$1/:id");
40
40
  }
41
41
  var requestContext = new AsyncLocalStorage();
42
42
  function getRequestContext() {
@@ -164,8 +164,15 @@ async function fetchWithTimeout(url, options) {
164
164
  async function throwForStatus(res) {
165
165
  if (res.status === 401 || res.status === 403) {
166
166
  const detail = await parseErrorBody(res);
167
+ const detailStr = String(detail ?? "").trim();
168
+ let pathHint = "the requested resource";
169
+ try {
170
+ pathHint = redactPath(new URL(res.url).pathname);
171
+ } catch {
172
+ }
173
+ const detailSuffix = detailStr && detailStr !== String(res.status) ? `: ${detailStr}` : "";
167
174
  throw new LoomioAuthError(
168
- `Loomio API returned ${res.status}: ${detail}. Check that LOOMIO_API_KEY is valid and has access to the requested resource.`,
175
+ `Loomio API returned ${res.status} for ${pathHint}${detailSuffix}. Loomio sends the same 403 whether the connector's key is invalid/expired OR the key's user simply lacks the role this resource requires (some endpoints \u2014 e.g. listing a group's members \u2014 require the group-admin/coordinator role). Verify the key, and that the bot user has the needed role on the target group.`,
169
176
  res.status
170
177
  );
171
178
  }
@@ -236,6 +243,21 @@ async function loomioGet(path, params) {
236
243
  start.cleanup();
237
244
  }
238
245
  }
246
+ async function loomioGetStatus(path, params) {
247
+ const url = buildUrl(B2_AUTH, path, params);
248
+ const start = await doFetch(url, { headers: baseHeaders() });
249
+ try {
250
+ return await consumeBody(start, async () => {
251
+ try {
252
+ await start.res.text();
253
+ } catch {
254
+ }
255
+ return start.res.status;
256
+ });
257
+ } finally {
258
+ start.cleanup();
259
+ }
260
+ }
239
261
  function encodeForm(body) {
240
262
  const u = new URLSearchParams();
241
263
  for (const [k, v] of Object.entries(body)) {
@@ -364,6 +386,9 @@ import { z } from "zod";
364
386
  var positiveId = z.number().int().positive();
365
387
  var loomioKey = z.string().regex(/^[A-Za-z0-9_-]+$/, "Loomio short keys may only contain letters, numbers, _ and -.");
366
388
  var idOrKey = z.union([loomioKey, positiveId]);
389
+ var isoTimestamp = z.string().refine((v) => !Number.isNaN(Date.parse(v)), {
390
+ message: "must be a parseable ISO-8601 timestamp (e.g. 2026-01-31 or 2026-01-31T00:00:00Z)."
391
+ }).optional();
367
392
  function encodePathSegment(value) {
368
393
  const raw = String(value);
369
394
  if (raw === "" || raw === "." || raw === "..") {
@@ -508,19 +533,75 @@ async function createPoll(input) {
508
533
 
509
534
  // src/tools/memberships.ts
510
535
  import { z as z4 } from "zod";
536
+
537
+ // src/loomio/access.ts
538
+ async function classifyGroupForbidden(groupId) {
539
+ const probeStatus = await loomioGetStatus("/b2/polls", {
540
+ group_id: groupId,
541
+ limit: 1,
542
+ status: "all"
543
+ });
544
+ if (probeStatus === 200) return { kind: "not_admin" };
545
+ if (probeStatus === 403) return { kind: "no_access" };
546
+ return { kind: "inconclusive", probeStatus };
547
+ }
548
+ function adminRequiredError(opts) {
549
+ const { groupId, resource, classification, fallback } = opts;
550
+ const tail = fallback ? ` ${fallback}` : "";
551
+ switch (classification.kind) {
552
+ case "not_admin":
553
+ return new LoomioAuthError(
554
+ `Loomio denied access to ${resource} for group ${groupId} (HTTP 403). The connector's Loomio key is valid \u2014 it can read group ${groupId} \u2014 but this data is only visible to a group admin (coordinator), and the connector's bot user is a plain member. This is a Loomio permission boundary, not a connector or client bug, and it is often intentional: the bot is deliberately kept as a non-admin so it can't read everyone's email. If this data is genuinely needed, a coordinator of group ${groupId} can grant the bot the coordinator role (Loomio \u2192 the group \u2192 Members \u2192 the bot user \u2192 Make coordinator).${tail}`,
555
+ 403
556
+ );
557
+ case "no_access":
558
+ return new LoomioAuthError(
559
+ `Loomio denied access to ${resource} for group ${groupId} (HTTP 403), and a follow-up check shows the connector can't read group ${groupId} at all. That means one of: the Loomio API key is invalid or expired, or the connector's bot user is not a member of group ${groupId}. Verify LOOMIO_API_KEY and that the bot has been added to the group.`,
560
+ 403
561
+ );
562
+ default:
563
+ return new LoomioAuthError(
564
+ `Loomio denied access to ${resource} for group ${groupId} (HTTP 403). This endpoint typically requires the connector's bot user to be an admin (coordinator) of the group, but a follow-up access check was inconclusive (HTTP ${classification.probeStatus}), so the exact cause \u2014 missing admin role, an invalid key, or the bot not being a member \u2014 couldn't be confirmed. Verify the group id, the API key, and the bot's role on the group.${tail}`,
565
+ 403
566
+ );
567
+ }
568
+ }
569
+
570
+ // src/tools/memberships.ts
571
+ var NON_ADMIN_FALLBACK = "Member display names, usernames, and ids (but not email addresses) are available without admin via get_user_activity / list_events, which read the group's event stream.";
572
+ async function explainForbidden(groupId, original, resource, fallback) {
573
+ try {
574
+ const classification = await classifyGroupForbidden(groupId);
575
+ return adminRequiredError({ groupId, resource, classification, fallback });
576
+ } catch {
577
+ return original;
578
+ }
579
+ }
511
580
  var listMembershipsSchema = z4.object({
512
581
  group_id: positiveId.describe(
513
- "ID of the Loomio group whose memberships to list (required). Caller must be a group admin; the response includes member email addresses."
582
+ "ID of the Loomio group whose memberships to list (required). The connector's bot user must be an admin (coordinator) of the group \u2014 Loomio only returns the member list, including email addresses, to group admins. For a non-admin bot this returns a clear 403 explaining the role requirement; names/usernames/ids (not emails) are still reachable via get_user_activity / list_events."
514
583
  ),
515
584
  limit: z4.number().int().min(1).max(200).optional().describe("Page size. Loomio defaults to 50."),
516
585
  offset: z4.number().int().min(0).optional().describe("Page offset. Defaults to 0.")
517
586
  });
518
587
  async function listMemberships(input) {
519
- return loomioGet("/b2/memberships", {
520
- group_id: input.group_id,
521
- ...input.limit !== void 0 ? { limit: input.limit } : {},
522
- ...input.offset !== void 0 ? { offset: input.offset } : {}
523
- });
588
+ try {
589
+ return await loomioGet("/b2/memberships", {
590
+ group_id: input.group_id,
591
+ ...input.limit !== void 0 ? { limit: input.limit } : {},
592
+ ...input.offset !== void 0 ? { offset: input.offset } : {}
593
+ });
594
+ } catch (err) {
595
+ if (err instanceof LoomioAuthError && err.status === 403) {
596
+ throw await explainForbidden(
597
+ input.group_id,
598
+ err,
599
+ "the member list (names, emails, roles)",
600
+ NON_ADMIN_FALLBACK
601
+ );
602
+ }
603
+ throw err;
604
+ }
524
605
  }
525
606
  var manageMembershipsSchema = z4.object({
526
607
  group_id: positiveId.describe(
@@ -534,7 +615,14 @@ var manageMembershipsSchema = z4.object({
534
615
  )
535
616
  });
536
617
  async function manageMemberships(input) {
537
- return loomioPost("/b2/memberships", input);
618
+ try {
619
+ return await loomioPost("/b2/memberships", input);
620
+ } catch (err) {
621
+ if (err instanceof LoomioAuthError && err.status === 403) {
622
+ throw await explainForbidden(input.group_id, err, "membership changes");
623
+ }
624
+ throw err;
625
+ }
538
626
  }
539
627
 
540
628
  // src/tools/groups.ts
@@ -645,34 +733,79 @@ async function listGroups(input) {
645
733
 
646
734
  // src/tools/events.ts
647
735
  import { z as z6 } from "zod";
736
+ var CONCURRENCY2 = 6;
737
+ var EVENTS_PAGE_SIZE = 200;
738
+ var MAX_EVENTS_PAGES = 10;
739
+ var MAX_DISCUSSION_PAGES = 20;
740
+ var MAX_SCAN_DISCUSSIONS = 500;
648
741
  var listEventsSchema = z6.object({
649
742
  discussion_id: positiveId.describe(
650
743
  "ID of the discussion whose event stream to fetch. Required \u2014 Loomio's v1/events endpoint silently returns empty without it."
651
744
  ),
652
- limit: z6.number().int().min(1).max(200).optional().describe("Page size. Loomio defaults to 50; cap is 200."),
653
- offset: z6.number().int().min(0).optional().describe("Page offset (Loomio's `from` parameter). Defaults to 0."),
745
+ limit: z6.number().int().min(1).max(200).optional().describe(
746
+ "Page size for a single-page fetch. Omit limit/offset to let the connector paginate the full discussion stream up to its bounded cap."
747
+ ),
748
+ offset: z6.number().int().min(0).optional().describe(
749
+ "Page offset (Loomio's `from` parameter). When supplied, list_events returns exactly that page instead of auto-paginating."
750
+ ),
654
751
  kinds: z6.array(z6.string().min(1)).optional().describe(
655
752
  "Optional filter to only these event kinds (applied client-side after fetch \u2014 Loomio doesn't filter `kind` server-side). Common kinds: new_discussion, new_comment, comment_edited, poll_created, stance_created, outcome_created, discussion_moved, discussion_closed, reaction."
656
753
  )
657
754
  });
755
+ function mergeEventResponse(into, from) {
756
+ into.events = [...into.events ?? [], ...from.events ?? []];
757
+ into.comments = [...into.comments ?? [], ...from.comments ?? []];
758
+ into.users = [...into.users ?? [], ...from.users ?? []];
759
+ into.polls = [...into.polls ?? [], ...from.polls ?? []];
760
+ into.parent_events = [...into.parent_events ?? [], ...from.parent_events ?? []];
761
+ into.meta = from.meta ?? into.meta;
762
+ }
763
+ function filterKinds(resp, kinds) {
764
+ if (!kinds?.length) return resp;
765
+ const allowed = new Set(kinds);
766
+ return {
767
+ ...resp,
768
+ events: (resp.events ?? []).filter((e) => allowed.has(e.kind))
769
+ };
770
+ }
658
771
  async function listEvents(input) {
659
- const resp = await loomioGet("/v1/events", {
660
- discussion_id: input.discussion_id,
661
- ...input.limit !== void 0 ? { per: input.limit } : {},
662
- ...input.offset !== void 0 ? { from: input.offset } : {}
663
- });
664
- if (input.kinds?.length) {
665
- const allowed = new Set(input.kinds);
666
- return {
667
- ...resp,
668
- events: (resp.events ?? []).filter((e) => allowed.has(e.kind))
669
- };
772
+ if (input.limit !== void 0 || input.offset !== void 0) {
773
+ const resp = await loomioGet("/v1/events", {
774
+ discussion_id: input.discussion_id,
775
+ ...input.limit !== void 0 ? { per: input.limit } : {},
776
+ ...input.offset !== void 0 ? { from: input.offset } : {}
777
+ });
778
+ return filterKinds(resp, input.kinds);
670
779
  }
671
- return resp;
780
+ const merged = {};
781
+ let offset = 0;
782
+ let pagesFetched = 0;
783
+ let truncated = false;
784
+ for (let page = 0; page < MAX_EVENTS_PAGES; page++) {
785
+ const resp = await loomioGet("/v1/events", {
786
+ discussion_id: input.discussion_id,
787
+ per: EVENTS_PAGE_SIZE,
788
+ from: offset
789
+ });
790
+ pagesFetched++;
791
+ const evs = resp.events ?? [];
792
+ mergeEventResponse(merged, resp);
793
+ if (evs.length < EVENTS_PAGE_SIZE) break;
794
+ offset += EVENTS_PAGE_SIZE;
795
+ if (page === MAX_EVENTS_PAGES - 1) truncated = true;
796
+ }
797
+ return filterKinds(
798
+ {
799
+ ...merged,
800
+ scope: {
801
+ complete: !truncated,
802
+ pages_fetched: pagesFetched,
803
+ events_truncated: truncated
804
+ }
805
+ },
806
+ input.kinds
807
+ );
672
808
  }
673
- var CONCURRENCY2 = 6;
674
- var EVENTS_PAGE_SIZE = 200;
675
- var MAX_EVENTS_PAGES = 10;
676
809
  var ACTIVITY_KINDS = /* @__PURE__ */ new Set([
677
810
  "new_discussion",
678
811
  "new_comment",
@@ -688,13 +821,27 @@ var getUserActivitySchema = z6.object({
688
821
  group_ids: z6.array(positiveId).min(1).max(50).describe(
689
822
  "Groups to scan. Required \u2014 pass the result of `list_groups` (or a subset of it) to make the cost explicit. ~1 outbound HTTP request per discussion in scope, plus one list_discussions call per group; ~100-300 calls is typical for a wide scan. Capped at 50 groups per call."
690
823
  ),
691
- since: z6.string().optional().describe("ISO-8601 timestamp; ignore events before this time."),
692
- until: z6.string().optional().describe("ISO-8601 timestamp; ignore events at or after this time.")
824
+ since: isoTimestamp.describe(
825
+ "ISO-8601 timestamp; ignore events before this time. Rejected if unparseable (so a typo can't silently widen the scan to all history)."
826
+ ),
827
+ until: isoTimestamp.describe(
828
+ "ISO-8601 timestamp; ignore events at or after this time. Rejected if unparseable."
829
+ )
830
+ }).superRefine((input, ctx) => {
831
+ if (!input.since || !input.until) return;
832
+ if (Date.parse(input.until) <= Date.parse(input.since)) {
833
+ ctx.addIssue({
834
+ code: "custom",
835
+ path: ["until"],
836
+ message: "until must be later than since."
837
+ });
838
+ }
693
839
  });
694
840
  async function listDiscussionIdsForGroup(groupId) {
695
841
  const all = [];
696
842
  let offset = 0;
697
- for (let page = 0; page < 20; page++) {
843
+ let truncated = false;
844
+ for (let page = 0; page < MAX_DISCUSSION_PAGES; page++) {
698
845
  const resp = await loomioGet("/b2/discussions", {
699
846
  group_id: groupId,
700
847
  status: "all",
@@ -705,12 +852,14 @@ async function listDiscussionIdsForGroup(groupId) {
705
852
  for (const d of ds) all.push({ id: d.id, group_id: groupId });
706
853
  if (ds.length < EVENTS_PAGE_SIZE) break;
707
854
  offset += EVENTS_PAGE_SIZE;
855
+ if (page === MAX_DISCUSSION_PAGES - 1) truncated = true;
708
856
  }
709
- return all;
857
+ return { discussions: all, truncated };
710
858
  }
711
859
  async function fetchAllEventsForDiscussion(discussionId) {
712
860
  const all = [];
713
861
  let offset = 0;
862
+ let truncated = false;
714
863
  for (let page = 0; page < MAX_EVENTS_PAGES; page++) {
715
864
  const resp = await loomioGet("/v1/events", {
716
865
  discussion_id: discussionId,
@@ -721,10 +870,11 @@ async function fetchAllEventsForDiscussion(discussionId) {
721
870
  for (const e of evs) all.push(e);
722
871
  if (evs.length < EVENTS_PAGE_SIZE) break;
723
872
  offset += EVENTS_PAGE_SIZE;
873
+ if (page === MAX_EVENTS_PAGES - 1) truncated = true;
724
874
  }
725
- return all;
875
+ return { events: all, truncated };
726
876
  }
727
- async function runWithConcurrency(items, worker, concurrency) {
877
+ async function runWithConcurrency(items, worker, concurrency, recoverError = () => true) {
728
878
  const results = new Array(items.length).fill(null);
729
879
  let next = 0;
730
880
  async function take() {
@@ -733,7 +883,8 @@ async function runWithConcurrency(items, worker, concurrency) {
733
883
  if (i >= items.length) return;
734
884
  try {
735
885
  results[i] = await worker(items[i]);
736
- } catch {
886
+ } catch (err) {
887
+ if (!recoverError(err)) throw err;
737
888
  results[i] = null;
738
889
  }
739
890
  }
@@ -741,26 +892,55 @@ async function runWithConcurrency(items, worker, concurrency) {
741
892
  await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, take));
742
893
  return results;
743
894
  }
895
+ function isRecoverableScanError(err) {
896
+ if (err instanceof LoomioAuthError) return err.status === 403;
897
+ return err instanceof LoomioApiError;
898
+ }
899
+ function rememberRecentSample(samples, e) {
900
+ samples.push({
901
+ kind: e.kind,
902
+ discussion_id: e.discussion_id,
903
+ created_at: e.created_at,
904
+ eventable_type: e.eventable_type,
905
+ eventable_id: e.eventable_id
906
+ });
907
+ samples.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at));
908
+ if (samples.length > 10) samples.length = 10;
909
+ }
744
910
  async function getUserActivity(input) {
745
911
  const groupIds = [...new Set(input.group_ids)];
746
912
  const groupDiscussionLists = await runWithConcurrency(
747
913
  groupIds,
748
914
  (gid) => listDiscussionIdsForGroup(gid),
749
- CONCURRENCY2
915
+ CONCURRENCY2,
916
+ isRecoverableScanError
750
917
  );
918
+ const groupsFailed = [];
919
+ const groupsTruncated = [];
751
920
  const discussions = [];
752
- for (const list of groupDiscussionLists) {
753
- if (list) discussions.push(...list);
754
- }
921
+ groupDiscussionLists.forEach((list, i) => {
922
+ if (list) {
923
+ discussions.push(...list.discussions);
924
+ if (list.truncated) groupsTruncated.push(groupIds[i]);
925
+ } else {
926
+ groupsFailed.push(groupIds[i]);
927
+ }
928
+ });
929
+ const discussionsCapped = discussions.length > MAX_SCAN_DISCUSSIONS;
930
+ const scanDiscussions = discussionsCapped ? discussions.slice(0, MAX_SCAN_DISCUSSIONS) : discussions;
755
931
  const since = input.since ? Date.parse(input.since) : Number.NEGATIVE_INFINITY;
756
932
  const until = input.until ? Date.parse(input.until) : Number.POSITIVE_INFINITY;
757
933
  let eventsExamined = 0;
934
+ let discussionsFailed = 0;
935
+ let discussionsTruncated = 0;
758
936
  const counts = {
759
937
  total: 0,
760
938
  new_discussion: 0,
761
939
  new_comment: 0,
940
+ comment_edited: 0,
762
941
  poll_created: 0,
763
942
  stance_created: 0,
943
+ stance_updated: 0,
764
944
  outcome_created: 0,
765
945
  reaction: 0,
766
946
  other: 0
@@ -771,12 +951,20 @@ async function getUserActivity(input) {
771
951
  let lastActivity = null;
772
952
  const samples = [];
773
953
  const eventStreams = await runWithConcurrency(
774
- discussions,
775
- async (d) => ({ groupId: d.group_id, events: await fetchAllEventsForDiscussion(d.id) }),
776
- CONCURRENCY2
954
+ scanDiscussions,
955
+ async (d) => {
956
+ const { events, truncated } = await fetchAllEventsForDiscussion(d.id);
957
+ return { groupId: d.group_id, events, truncated };
958
+ },
959
+ CONCURRENCY2,
960
+ isRecoverableScanError
777
961
  );
778
962
  for (const s of eventStreams) {
779
- if (!s) continue;
963
+ if (!s) {
964
+ discussionsFailed++;
965
+ continue;
966
+ }
967
+ if (s.truncated) discussionsTruncated++;
780
968
  for (const e of s.events) {
781
969
  eventsExamined++;
782
970
  if (e.actor_id !== input.user_id) continue;
@@ -794,25 +982,24 @@ async function getUserActivity(input) {
794
982
  byMonth[month] = (byMonth[month] ?? 0) + 1;
795
983
  if (!firstActivity || e.created_at < firstActivity) firstActivity = e.created_at;
796
984
  if (!lastActivity || e.created_at > lastActivity) lastActivity = e.created_at;
797
- if (samples.length < 10) {
798
- samples.push({
799
- kind: e.kind,
800
- discussion_id: e.discussion_id,
801
- created_at: e.created_at,
802
- eventable_type: e.eventable_type,
803
- eventable_id: e.eventable_id
804
- });
805
- }
985
+ rememberRecentSample(samples, e);
806
986
  }
807
987
  }
988
+ const complete = groupsFailed.length === 0 && discussionsFailed === 0 && discussionsTruncated === 0 && groupsTruncated.length === 0 && !discussionsCapped;
808
989
  return {
809
990
  user_id: input.user_id,
810
991
  scope: {
811
992
  group_ids: groupIds,
812
- discussions_scanned: discussions.length,
993
+ discussions_scanned: scanDiscussions.length,
813
994
  events_examined: eventsExamined,
814
995
  since: input.since ?? null,
815
- until: input.until ?? null
996
+ until: input.until ?? null,
997
+ complete,
998
+ groups_failed: groupsFailed,
999
+ discussions_failed: discussionsFailed,
1000
+ discussions_truncated: discussionsTruncated,
1001
+ discussions_capped: discussionsCapped,
1002
+ groups_truncated: groupsTruncated
816
1003
  },
817
1004
  counts,
818
1005
  by_group: byGroup,
@@ -861,8 +1048,9 @@ function createLoomioMcpServer() {
861
1048
  const b3Enabled = hasB3ApiKey();
862
1049
  const server2 = new McpServer({
863
1050
  name: "loomiomcp",
864
- version: "0.1.0",
865
- description: "MCP server for Loomio (loomio.com / self-hosted). Wraps Loomio's b2 public API: read and create discussions, polls, comments, plus list and manage group memberships. Read-only mode supported via LOOMIO_MCP_READONLY=1 (Cloud Run pattern). Optional b3 admin operations (deactivate / reactivate user) when LOOMIO_B3_API_KEY is set \u2014 instance operators only. Read tools annotated with readOnlyHint so MCP clients can auto-approve safe calls; destructive writes (manage_memberships with remove_absent, deactivate_user) carry destructiveHint.",
1051
+ // Keep in sync with package.json on each release.
1052
+ version: "0.0.6",
1053
+ description: "MCP server for Loomio (loomio.com / self-hosted). Wraps Loomio's b2 public API: read and create discussions, polls, and comments; list and manage group memberships; read per-discussion event streams; and aggregate a user's participation across groups. Read-only mode supported via LOOMIO_MCP_READONLY=1 (Cloud Run pattern). Optional b3 admin operations (deactivate / reactivate user) when LOOMIO_B3_API_KEY is set \u2014 instance operators only. Read tools annotated with readOnlyHint so MCP clients can auto-approve safe calls; destructive writes (manage_memberships with remove_absent, deactivate_user) carry destructiveHint.",
866
1054
  websiteUrl: "https://github.com/soil-dev/loomiomcp",
867
1055
  icons: ICONS
868
1056
  });
@@ -899,7 +1087,7 @@ function createLoomioMcpServer() {
899
1087
  registerTool(
900
1088
  server2,
901
1089
  "list_polls",
902
- "List polls in a Loomio group, ordered by creation date (newest first). Required: `group_id`. Optional `status` filter \u2014 'active' (default), 'closed', 'all' (every kept poll); `limit` 1-200 (default 50); `offset` for pagination. Caller must be a group member. Use to answer 'what's up for vote in group X', 'show me past poll results', or before create_poll to check what's already proposed.",
1090
+ "List polls in a Loomio group, ordered by creation date (newest first). Required: `group_id`. Optional `status` filter \u2014 'active' (default), 'closed', 'all' (every kept poll); `limit` 1-200 (default 50); `offset` for pagination. Caller must be a group member. Use to answer 'what's up for vote in group X', 'show me past poll results', or before create_poll to check what's already proposed. FOR PER-USER PARTICIPATION QUESTIONS \u2014 'who voted', 'how often did X vote', 'compare members' turnout' \u2014 prefer `get_user_activity`. It returns participation directly (via the underlying events stream) and avoids the ambiguity between 'didn't vote' and 'abstained' that you can't tell apart from a `list_polls` response alone.",
903
1091
  listPollsSchema,
904
1092
  listPolls
905
1093
  );
@@ -915,7 +1103,7 @@ function createLoomioMcpServer() {
915
1103
  registerTool(
916
1104
  server2,
917
1105
  "list_memberships",
918
- "List members of a Loomio group with their email addresses, roles, and join state. Required: `group_id`. Caller MUST be a group admin (non-admins get HTTP 403; the response is server-side scoped to include `include_email: true`). Optional `limit` 1-200 (default 50) and `offset` for pagination. Use to answer 'who's in group X', 'find a member by email', or \u2014 critically \u2014 BEFORE calling manage_memberships with remove_absent=true, since the diff between current and intended members is what makes that destructive call safe.",
1106
+ "List members of a Loomio group with their email addresses, roles, and join state. Required: `group_id`. Caller MUST be a group admin (non-admins get HTTP 403; the response is server-side scoped to include `include_email: true`). Optional `limit` 1-200 (default 50) and `offset` for pagination. Use to answer 'who's in group X', 'find a member by email', or \u2014 critically \u2014 BEFORE calling manage_memberships with remove_absent=true, since the diff between current and intended members is what makes that destructive call safe. Do NOT use this tool to construct a participation analysis (e.g. 'how active is each member', 'who voted in our polls') by combining its output with `list_polls`. That reconstruction is more expensive in round-trips AND ambiguous about abstain-vs-didn't-vote. Use `get_user_activity` per member instead \u2014 it answers participation directly from the event stream.",
919
1107
  listMembershipsSchema,
920
1108
  listMemberships
921
1109
  );
@@ -929,14 +1117,14 @@ function createLoomioMcpServer() {
929
1117
  registerTool(
930
1118
  server2,
931
1119
  "list_events",
932
- "Fetch the full event stream for one discussion \u2014 every new_comment, poll_created, stance_created, outcome_created, reaction, discussion_moved, etc. \u2014 with actor_id, kind, parent_id, created_at, and pointers to the underlying eventable record. Required: `discussion_id`. Optional `limit` (1-200, default 50), `offset` (Loomio's `from` param), and `kinds` (client-side filter to specific event kinds). The response also embeds related `comments`, `users`, and `polls` arrays for in-place resolution. Use this to answer 'show me the reply tree for thread X', 'who participated in discussion Y', or as the building block for cross-discussion aggregations. Loomio's v1/events endpoint requires a discussion_id; there's no instance-wide or per-user index \u2014 for user-centric questions, use `get_user_activity`.",
1120
+ "Fetch the event stream for ONE discussion \u2014 new_comment, poll_created, stance_created, outcome_created, reaction, discussion_moved, etc. \u2014 with actor_id, kind, parent_id, created_at, and pointers to the underlying eventable record. Required: `discussion_id`. By default the connector paginates Loomio's v1/events endpoint up to a bounded cap and returns `scope.complete`; if you pass `limit` and/or `offset`, it returns exactly that one page. Optional `kinds` filters client-side after fetch. The response also embeds related `comments`, `users`, and `polls` arrays for in-place resolution. Use this to answer 'show me the reply tree for thread X', 'who participated in discussion Y', or as the building block for cross-discussion aggregations. Loomio's v1/events endpoint REQUIRES a discussion_id; there is no instance-wide, per-group, or per-user index. For user-centric questions across many discussions ('how active is X', 'compare members across groups'), use `get_user_activity` \u2014 do NOT loop `list_events` over every discussion yourself. `get_user_activity` does that fan-out server-side with concurrency control.",
933
1121
  listEventsSchema,
934
1122
  listEvents
935
1123
  );
936
1124
  registerTool(
937
1125
  server2,
938
1126
  "get_user_activity",
939
- "Aggregate one user's activity across a set of groups. Server-side: fans out across every discussion in the specified groups, fetches its event stream, filters to events authored by the target user, and returns counts (by kind, by group, by month), plus first/last activity timestamps and a sample of recent events. Required: `user_id`, `group_ids` (1-50; pass the result of `list_groups` for instance-wide). Optional `since` / `until` (ISO-8601) bound the time window. Cost: one outbound HTTP call per discussion in scope (plus one `list_discussions` per group). Wide scans of an active instance commonly hit 100-300 calls in 5-30 seconds. Concurrency-capped at 6. Use this for 'tell me about user X', 'how active has user Y been in Q1', or before promoting/awarding someone. For one discussion at a time, use `list_events`.",
1127
+ "Aggregate one user's activity across a set of groups. Server-side: fans out across every discussion in the specified groups, fetches its event stream, filters to events authored by the target user, and returns counts (by kind, by group, by month), plus first/last activity timestamps and a sample of recent events. Required: `user_id`, `group_ids` (1-50; pass the result of `list_groups` for instance-wide). Optional `since` / `until` (ISO-8601) bound the time window; `until` must be later than `since` when both are supplied. USE THIS for any user-centric question \u2014 single-user OR comparing multiple users. Examples that all map to this tool: 'tell me about user X', 'how active has Y been in Q1', 'compare participation across two groups (e.g. two teams or committees)', 'rank members of group N by participation', 'who's the most engaged contributor since June', 'build a participation card for each member'. For an N-user comparison, **call this tool N times** (once per user) \u2014 that's the intended pattern and is materially cheaper than reconstructing the same data from `list_polls` + `list_memberships`. Why call this instead of fanning out `list_polls`/`list_memberships` yourself: (1) Participation here is read from the canonical event stream \u2014 'voted' vs 'didn't vote' is unambiguous; you can't tell those apart from `list_polls` alone. (2) Round-trip count is the same or lower in aggregate, because each user's activity scan reuses the same `list_discussions` fetches in your conversation context. (3) The result is pre-aggregated by kind/group/month \u2014 Claude doesn't need to count anything client-side. Cost: one outbound HTTP call per discussion in scope (plus one `list_discussions` per group). A single user-activity call on a ~200-discussion instance is ~200 calls in 5-10 seconds, concurrency-capped at 6. That sounds large but is the correct denominator for comparison: building the same answer from `list_polls` requires the same discussion-scan + a separate `list_memberships` per group + client-side cross-referencing. The fan-out is bounded by a global cap, so a single call can't run away. COMPLETENESS: check `scope.complete`. When it's false the counts are a LOWER BOUND \u2014 inspect `scope.groups_failed` (groups the bot couldn't read, e.g. it isn't a member), `scope.groups_truncated` (a group's discussion listing hit the page cap), `scope.discussions_failed`, `scope.discussions_truncated` (very long threads), and `scope.discussions_capped` (scan hit the global ceiling). Report partial results as partial; don't present them as the whole picture. For one discussion at a time, use `list_events`. For 'what groups can the user see', use `list_groups` first to scope the call.",
940
1128
  getUserActivitySchema,
941
1129
  getUserActivity
942
1130
  );
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "loomiomcp",
3
- "version": "0.0.2",
4
- "description": "Model Context Protocol server for Loomio. Lets Claude (Desktop, Code, or web Projects via Custom Connector) read and create Loomio discussions, polls, and group memberships in plain English.",
3
+ "version": "0.0.6",
4
+ "description": "Model Context Protocol server for Loomio. Lets Claude (Desktop, Code, or web Projects via Custom Connector) read and create Loomio discussions, polls, and comments, manage group memberships, and analyse member activity in plain English.",
5
5
  "keywords": [
6
6
  "mcp",
7
7
  "model-context-protocol",