loomiomcp 0.0.1 → 0.0.5

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
@@ -2,10 +2,11 @@
2
2
 
3
3
  Model Context Protocol server for [Loomio](https://www.loomio.com). Lets
4
4
  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.
5
+ write Loomio discussions, polls, comments, and group memberships — and
6
+ analyse member activity — in plain English. Targets Loomio's **b2** API
7
+ the canonical surface documented at
8
+ [/help/api2](https://www.loomio.com/help/api2) and the namespace where
9
+ the controllers actually live in the open-source repo.
9
10
 
10
11
  Tools (b2, per-user `?api_key=`):
11
12
 
@@ -16,11 +17,24 @@ Tools (b2, per-user `?api_key=`):
16
17
  - `list_polls(group_id, status?, limit?, offset?)` — list a group's polls
17
18
  - `create_poll(title, poll_type, …)` — start a new poll
18
19
  - `list_memberships(group_id, limit?, offset?)` — list a group's members with email
19
- addresses (caller must be a group admin)
20
+ addresses. Requires the connector's user to be a group **admin** (coordinator);
21
+ Loomio only returns the member list to admins. On a 403 the connector probes to
22
+ explain *why* — bot lacks the admin role vs. invalid key vs. not-a-member — and
23
+ points at `get_user_activity` / `list_events` for names/ids (email stays admin-only).
20
24
  - `list_groups({start_id?, end_id?, stop_after_consecutive_misses?})` — enumerate
21
25
  visible groups by probing `b2/polls` across an id range. Loomio has no
22
26
  api-key-authed list-groups endpoint; this is the workaround. Default scans
23
27
  are ~50–200 outbound calls; a single invocation is capped at 500 ids
28
+ - `list_events(discussion_id, limit?, offset?, kinds?)` — the event stream for one
29
+ discussion (comments, reactions, stances, outcomes, …) with `actor_id`, `kind`,
30
+ timestamps, and embedded `users` / `comments` / `polls`. With no `limit`/`offset`
31
+ it paginates up to a bounded cap and reports `scope.complete`; with either
32
+ pagination knob it returns that one page.
33
+ - `get_user_activity(user_id, group_ids, since?, until?)` — aggregate one user's
34
+ participation across groups (counts by kind / group / month, first/last activity).
35
+ The primary tool for any user-centric question; fans out server-side with a bounded
36
+ budget and reports completeness via `scope.complete`. If both `since` and `until`
37
+ are supplied, `until` must be later than `since`.
24
38
  - `manage_memberships({group_id, emails, remove_absent})` — add and (with
25
39
  `remove_absent: true`) **remove** members. See SECURITY.md before using
26
40
  `remove_absent`.
@@ -58,10 +72,10 @@ Only relevant if you operate a Loomio instance.
58
72
 
59
73
  ## Read-only mode
60
74
 
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.
75
+ Set `LOOMIO_MCP_READONLY=1` to register only the 8 read tools
76
+ (`get_*` / `list_*` / `get_user_activity`). All write tools
77
+ (`create_*`, `manage_*`) are skipped at server-init time. This is the
78
+ mode the Cloud Run deployment runs in.
65
79
 
66
80
  ## Docs map
67
81
 
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
@@ -1195,12 +1283,291 @@ async function listGroups(input) {
1195
1283
  };
1196
1284
  }
1197
1285
 
1198
- // src/tools/comments.ts
1286
+ // src/tools/events.ts
1199
1287
  import { z as z7 } from "zod";
1200
- var createCommentSchema = z7.object({
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;
1293
+ var listEventsSchema = z7.object({
1294
+ discussion_id: positiveId.describe(
1295
+ "ID of the discussion whose event stream to fetch. Required \u2014 Loomio's v1/events endpoint silently returns empty without it."
1296
+ ),
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
+ ),
1303
+ kinds: z7.array(z7.string().min(1)).optional().describe(
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."
1305
+ )
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
+ }
1323
+ async function listEvents(input) {
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);
1331
+ }
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
+ );
1360
+ }
1361
+ var ACTIVITY_KINDS = /* @__PURE__ */ new Set([
1362
+ "new_discussion",
1363
+ "new_comment",
1364
+ "comment_edited",
1365
+ "poll_created",
1366
+ "stance_created",
1367
+ "stance_updated",
1368
+ "outcome_created",
1369
+ "reaction"
1370
+ ]);
1371
+ var getUserActivitySchema = z7.object({
1372
+ user_id: positiveId.describe("Loomio user id whose activity to summarise."),
1373
+ group_ids: z7.array(positiveId).min(1).max(50).describe(
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."
1375
+ ),
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
+ }
1391
+ });
1392
+ async function listDiscussionIdsForGroup(groupId) {
1393
+ const all = [];
1394
+ let offset = 0;
1395
+ let truncated = false;
1396
+ for (let page = 0; page < MAX_DISCUSSION_PAGES; page++) {
1397
+ const resp = await loomioGet("/b2/discussions", {
1398
+ group_id: groupId,
1399
+ status: "all",
1400
+ limit: EVENTS_PAGE_SIZE,
1401
+ offset
1402
+ });
1403
+ const ds = resp.discussions ?? [];
1404
+ for (const d of ds) all.push({ id: d.id, group_id: groupId });
1405
+ if (ds.length < EVENTS_PAGE_SIZE) break;
1406
+ offset += EVENTS_PAGE_SIZE;
1407
+ if (page === MAX_DISCUSSION_PAGES - 1) truncated = true;
1408
+ }
1409
+ return { discussions: all, truncated };
1410
+ }
1411
+ async function fetchAllEventsForDiscussion(discussionId) {
1412
+ const all = [];
1413
+ let offset = 0;
1414
+ let truncated = false;
1415
+ for (let page = 0; page < MAX_EVENTS_PAGES; page++) {
1416
+ const resp = await loomioGet("/v1/events", {
1417
+ discussion_id: discussionId,
1418
+ per: EVENTS_PAGE_SIZE,
1419
+ from: offset
1420
+ });
1421
+ const evs = resp.events ?? [];
1422
+ for (const e of evs) all.push(e);
1423
+ if (evs.length < EVENTS_PAGE_SIZE) break;
1424
+ offset += EVENTS_PAGE_SIZE;
1425
+ if (page === MAX_EVENTS_PAGES - 1) truncated = true;
1426
+ }
1427
+ return { events: all, truncated };
1428
+ }
1429
+ async function runWithConcurrency(items, worker, concurrency, recoverError = () => true) {
1430
+ const results = new Array(items.length).fill(null);
1431
+ let next = 0;
1432
+ async function take() {
1433
+ for (; ; ) {
1434
+ const i = next++;
1435
+ if (i >= items.length) return;
1436
+ try {
1437
+ results[i] = await worker(items[i]);
1438
+ } catch (err) {
1439
+ if (!recoverError(err)) throw err;
1440
+ results[i] = null;
1441
+ }
1442
+ }
1443
+ }
1444
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, take));
1445
+ return results;
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
+ }
1462
+ async function getUserActivity(input) {
1463
+ const groupIds = [...new Set(input.group_ids)];
1464
+ const groupDiscussionLists = await runWithConcurrency(
1465
+ groupIds,
1466
+ (gid) => listDiscussionIdsForGroup(gid),
1467
+ CONCURRENCY2,
1468
+ isRecoverableScanError
1469
+ );
1470
+ const groupsFailed = [];
1471
+ const groupsTruncated = [];
1472
+ const discussions = [];
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;
1483
+ const since = input.since ? Date.parse(input.since) : Number.NEGATIVE_INFINITY;
1484
+ const until = input.until ? Date.parse(input.until) : Number.POSITIVE_INFINITY;
1485
+ let eventsExamined = 0;
1486
+ let discussionsFailed = 0;
1487
+ let discussionsTruncated = 0;
1488
+ const counts = {
1489
+ total: 0,
1490
+ new_discussion: 0,
1491
+ new_comment: 0,
1492
+ comment_edited: 0,
1493
+ poll_created: 0,
1494
+ stance_created: 0,
1495
+ stance_updated: 0,
1496
+ outcome_created: 0,
1497
+ reaction: 0,
1498
+ other: 0
1499
+ };
1500
+ const byGroup = {};
1501
+ const byMonth = {};
1502
+ let firstActivity = null;
1503
+ let lastActivity = null;
1504
+ const samples = [];
1505
+ const eventStreams = await runWithConcurrency(
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
1513
+ );
1514
+ for (const s of eventStreams) {
1515
+ if (!s) {
1516
+ discussionsFailed++;
1517
+ continue;
1518
+ }
1519
+ if (s.truncated) discussionsTruncated++;
1520
+ for (const e of s.events) {
1521
+ eventsExamined++;
1522
+ if (e.actor_id !== input.user_id) continue;
1523
+ const t = Date.parse(e.created_at);
1524
+ if (Number.isNaN(t) || t < since || t >= until) continue;
1525
+ if (!ACTIVITY_KINDS.has(e.kind)) {
1526
+ counts.other++;
1527
+ } else {
1528
+ const slot = e.kind;
1529
+ if (slot in counts && slot !== "total") counts[slot]++;
1530
+ }
1531
+ counts.total++;
1532
+ byGroup[String(s.groupId)] = (byGroup[String(s.groupId)] ?? 0) + 1;
1533
+ const month = e.created_at.slice(0, 7);
1534
+ byMonth[month] = (byMonth[month] ?? 0) + 1;
1535
+ if (!firstActivity || e.created_at < firstActivity) firstActivity = e.created_at;
1536
+ if (!lastActivity || e.created_at > lastActivity) lastActivity = e.created_at;
1537
+ rememberRecentSample(samples, e);
1538
+ }
1539
+ }
1540
+ const complete = groupsFailed.length === 0 && discussionsFailed === 0 && discussionsTruncated === 0 && groupsTruncated.length === 0 && !discussionsCapped;
1541
+ return {
1542
+ user_id: input.user_id,
1543
+ scope: {
1544
+ group_ids: groupIds,
1545
+ discussions_scanned: scanDiscussions.length,
1546
+ events_examined: eventsExamined,
1547
+ since: input.since ?? 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
1555
+ },
1556
+ counts,
1557
+ by_group: byGroup,
1558
+ by_month: byMonth,
1559
+ first_activity: firstActivity,
1560
+ last_activity: lastActivity,
1561
+ sample_events: samples
1562
+ };
1563
+ }
1564
+
1565
+ // src/tools/comments.ts
1566
+ import { z as z8 } from "zod";
1567
+ var createCommentSchema = z8.object({
1201
1568
  discussion_id: positiveId.describe("ID of the discussion to comment on."),
1202
- body: z7.string().min(1).describe("Comment body (required)."),
1203
- body_format: z7.enum(["md", "html"]).optional().describe("Format of `body`. Defaults to Loomio's group default when omitted.")
1569
+ body: z8.string().min(1).describe("Comment body (required)."),
1570
+ body_format: z8.enum(["md", "html"]).optional().describe("Format of `body`. Defaults to Loomio's group default when omitted.")
1204
1571
  });
1205
1572
  async function createComment(input) {
1206
1573
  const { discussion_id, ...rest } = input;
@@ -1211,14 +1578,14 @@ async function createComment(input) {
1211
1578
  }
1212
1579
 
1213
1580
  // src/tools/admin.ts
1214
- import { z as z8 } from "zod";
1215
- var deactivateUserSchema = z8.object({
1581
+ import { z as z9 } from "zod";
1582
+ var deactivateUserSchema = z9.object({
1216
1583
  id: positiveId.describe("Loomio user id to deactivate. Returns 404 if not active.")
1217
1584
  });
1218
1585
  async function deactivateUser(input) {
1219
1586
  return loomioPostB3("/b3/users/deactivate", { id: input.id });
1220
1587
  }
1221
- var reactivateUserSchema = z8.object({
1588
+ var reactivateUserSchema = z9.object({
1222
1589
  id: positiveId.describe(
1223
1590
  "Loomio user id to reactivate. Returns 404 if not currently deactivated."
1224
1591
  )
@@ -1233,8 +1600,9 @@ function createLoomioMcpServer() {
1233
1600
  const b3Enabled = hasB3ApiKey();
1234
1601
  const server = new McpServer({
1235
1602
  name: "loomiomcp",
1236
- version: "0.1.0",
1237
- 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.5",
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.",
1238
1606
  websiteUrl: "https://github.com/soil-dev/loomiomcp",
1239
1607
  icons: ICONS
1240
1608
  });
@@ -1271,7 +1639,7 @@ function createLoomioMcpServer() {
1271
1639
  registerTool(
1272
1640
  server,
1273
1641
  "list_polls",
1274
- "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.",
1275
1643
  listPollsSchema,
1276
1644
  listPolls
1277
1645
  );
@@ -1287,7 +1655,7 @@ function createLoomioMcpServer() {
1287
1655
  registerTool(
1288
1656
  server,
1289
1657
  "list_memberships",
1290
- "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.",
1291
1659
  listMembershipsSchema,
1292
1660
  listMemberships
1293
1661
  );
@@ -1298,6 +1666,20 @@ function createLoomioMcpServer() {
1298
1666
  listGroupsSchema,
1299
1667
  listGroups
1300
1668
  );
1669
+ registerTool(
1670
+ server,
1671
+ "list_events",
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.",
1673
+ listEventsSchema,
1674
+ listEvents
1675
+ );
1676
+ registerTool(
1677
+ server,
1678
+ "get_user_activity",
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.",
1680
+ getUserActivitySchema,
1681
+ getUserActivity
1682
+ );
1301
1683
  if (!readOnly) {
1302
1684
  registerTool(
1303
1685
  server,
@@ -1390,11 +1772,18 @@ function mountTransport(app2, opts) {
1390
1772
  limit: rateLimitMax,
1391
1773
  standardHeaders: "draft-7",
1392
1774
  legacyHeaders: false,
1393
- keyGenerator: (req) => {
1394
- const clientId = req.auth?.clientId;
1395
- if (clientId) return clientId;
1396
- return ipKeyGenerator(req.ip ?? "");
1397
- },
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 ?? ""),
1398
1787
  skip: () => rateLimitDisabled,
1399
1788
  handler: (_req, res) => {
1400
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
@@ -643,12 +731,291 @@ async function listGroups(input) {
643
731
  };
644
732
  }
645
733
 
646
- // src/tools/comments.ts
734
+ // src/tools/events.ts
647
735
  import { z as z6 } from "zod";
648
- var createCommentSchema = z6.object({
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;
741
+ var listEventsSchema = z6.object({
742
+ discussion_id: positiveId.describe(
743
+ "ID of the discussion whose event stream to fetch. Required \u2014 Loomio's v1/events endpoint silently returns empty without it."
744
+ ),
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
+ ),
751
+ kinds: z6.array(z6.string().min(1)).optional().describe(
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."
753
+ )
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
+ }
771
+ async function listEvents(input) {
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);
779
+ }
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
+ );
808
+ }
809
+ var ACTIVITY_KINDS = /* @__PURE__ */ new Set([
810
+ "new_discussion",
811
+ "new_comment",
812
+ "comment_edited",
813
+ "poll_created",
814
+ "stance_created",
815
+ "stance_updated",
816
+ "outcome_created",
817
+ "reaction"
818
+ ]);
819
+ var getUserActivitySchema = z6.object({
820
+ user_id: positiveId.describe("Loomio user id whose activity to summarise."),
821
+ group_ids: z6.array(positiveId).min(1).max(50).describe(
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."
823
+ ),
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
+ }
839
+ });
840
+ async function listDiscussionIdsForGroup(groupId) {
841
+ const all = [];
842
+ let offset = 0;
843
+ let truncated = false;
844
+ for (let page = 0; page < MAX_DISCUSSION_PAGES; page++) {
845
+ const resp = await loomioGet("/b2/discussions", {
846
+ group_id: groupId,
847
+ status: "all",
848
+ limit: EVENTS_PAGE_SIZE,
849
+ offset
850
+ });
851
+ const ds = resp.discussions ?? [];
852
+ for (const d of ds) all.push({ id: d.id, group_id: groupId });
853
+ if (ds.length < EVENTS_PAGE_SIZE) break;
854
+ offset += EVENTS_PAGE_SIZE;
855
+ if (page === MAX_DISCUSSION_PAGES - 1) truncated = true;
856
+ }
857
+ return { discussions: all, truncated };
858
+ }
859
+ async function fetchAllEventsForDiscussion(discussionId) {
860
+ const all = [];
861
+ let offset = 0;
862
+ let truncated = false;
863
+ for (let page = 0; page < MAX_EVENTS_PAGES; page++) {
864
+ const resp = await loomioGet("/v1/events", {
865
+ discussion_id: discussionId,
866
+ per: EVENTS_PAGE_SIZE,
867
+ from: offset
868
+ });
869
+ const evs = resp.events ?? [];
870
+ for (const e of evs) all.push(e);
871
+ if (evs.length < EVENTS_PAGE_SIZE) break;
872
+ offset += EVENTS_PAGE_SIZE;
873
+ if (page === MAX_EVENTS_PAGES - 1) truncated = true;
874
+ }
875
+ return { events: all, truncated };
876
+ }
877
+ async function runWithConcurrency(items, worker, concurrency, recoverError = () => true) {
878
+ const results = new Array(items.length).fill(null);
879
+ let next = 0;
880
+ async function take() {
881
+ for (; ; ) {
882
+ const i = next++;
883
+ if (i >= items.length) return;
884
+ try {
885
+ results[i] = await worker(items[i]);
886
+ } catch (err) {
887
+ if (!recoverError(err)) throw err;
888
+ results[i] = null;
889
+ }
890
+ }
891
+ }
892
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, take));
893
+ return results;
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
+ }
910
+ async function getUserActivity(input) {
911
+ const groupIds = [...new Set(input.group_ids)];
912
+ const groupDiscussionLists = await runWithConcurrency(
913
+ groupIds,
914
+ (gid) => listDiscussionIdsForGroup(gid),
915
+ CONCURRENCY2,
916
+ isRecoverableScanError
917
+ );
918
+ const groupsFailed = [];
919
+ const groupsTruncated = [];
920
+ const discussions = [];
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;
931
+ const since = input.since ? Date.parse(input.since) : Number.NEGATIVE_INFINITY;
932
+ const until = input.until ? Date.parse(input.until) : Number.POSITIVE_INFINITY;
933
+ let eventsExamined = 0;
934
+ let discussionsFailed = 0;
935
+ let discussionsTruncated = 0;
936
+ const counts = {
937
+ total: 0,
938
+ new_discussion: 0,
939
+ new_comment: 0,
940
+ comment_edited: 0,
941
+ poll_created: 0,
942
+ stance_created: 0,
943
+ stance_updated: 0,
944
+ outcome_created: 0,
945
+ reaction: 0,
946
+ other: 0
947
+ };
948
+ const byGroup = {};
949
+ const byMonth = {};
950
+ let firstActivity = null;
951
+ let lastActivity = null;
952
+ const samples = [];
953
+ const eventStreams = await runWithConcurrency(
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
961
+ );
962
+ for (const s of eventStreams) {
963
+ if (!s) {
964
+ discussionsFailed++;
965
+ continue;
966
+ }
967
+ if (s.truncated) discussionsTruncated++;
968
+ for (const e of s.events) {
969
+ eventsExamined++;
970
+ if (e.actor_id !== input.user_id) continue;
971
+ const t = Date.parse(e.created_at);
972
+ if (Number.isNaN(t) || t < since || t >= until) continue;
973
+ if (!ACTIVITY_KINDS.has(e.kind)) {
974
+ counts.other++;
975
+ } else {
976
+ const slot = e.kind;
977
+ if (slot in counts && slot !== "total") counts[slot]++;
978
+ }
979
+ counts.total++;
980
+ byGroup[String(s.groupId)] = (byGroup[String(s.groupId)] ?? 0) + 1;
981
+ const month = e.created_at.slice(0, 7);
982
+ byMonth[month] = (byMonth[month] ?? 0) + 1;
983
+ if (!firstActivity || e.created_at < firstActivity) firstActivity = e.created_at;
984
+ if (!lastActivity || e.created_at > lastActivity) lastActivity = e.created_at;
985
+ rememberRecentSample(samples, e);
986
+ }
987
+ }
988
+ const complete = groupsFailed.length === 0 && discussionsFailed === 0 && discussionsTruncated === 0 && groupsTruncated.length === 0 && !discussionsCapped;
989
+ return {
990
+ user_id: input.user_id,
991
+ scope: {
992
+ group_ids: groupIds,
993
+ discussions_scanned: scanDiscussions.length,
994
+ events_examined: eventsExamined,
995
+ since: input.since ?? 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
1003
+ },
1004
+ counts,
1005
+ by_group: byGroup,
1006
+ by_month: byMonth,
1007
+ first_activity: firstActivity,
1008
+ last_activity: lastActivity,
1009
+ sample_events: samples
1010
+ };
1011
+ }
1012
+
1013
+ // src/tools/comments.ts
1014
+ import { z as z7 } from "zod";
1015
+ var createCommentSchema = z7.object({
649
1016
  discussion_id: positiveId.describe("ID of the discussion to comment on."),
650
- body: z6.string().min(1).describe("Comment body (required)."),
651
- body_format: z6.enum(["md", "html"]).optional().describe("Format of `body`. Defaults to Loomio's group default when omitted.")
1017
+ body: z7.string().min(1).describe("Comment body (required)."),
1018
+ body_format: z7.enum(["md", "html"]).optional().describe("Format of `body`. Defaults to Loomio's group default when omitted.")
652
1019
  });
653
1020
  async function createComment(input) {
654
1021
  const { discussion_id, ...rest } = input;
@@ -659,14 +1026,14 @@ async function createComment(input) {
659
1026
  }
660
1027
 
661
1028
  // src/tools/admin.ts
662
- import { z as z7 } from "zod";
663
- var deactivateUserSchema = z7.object({
1029
+ import { z as z8 } from "zod";
1030
+ var deactivateUserSchema = z8.object({
664
1031
  id: positiveId.describe("Loomio user id to deactivate. Returns 404 if not active.")
665
1032
  });
666
1033
  async function deactivateUser(input) {
667
1034
  return loomioPostB3("/b3/users/deactivate", { id: input.id });
668
1035
  }
669
- var reactivateUserSchema = z7.object({
1036
+ var reactivateUserSchema = z8.object({
670
1037
  id: positiveId.describe(
671
1038
  "Loomio user id to reactivate. Returns 404 if not currently deactivated."
672
1039
  )
@@ -681,8 +1048,9 @@ function createLoomioMcpServer() {
681
1048
  const b3Enabled = hasB3ApiKey();
682
1049
  const server2 = new McpServer({
683
1050
  name: "loomiomcp",
684
- version: "0.1.0",
685
- 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.5",
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.",
686
1054
  websiteUrl: "https://github.com/soil-dev/loomiomcp",
687
1055
  icons: ICONS
688
1056
  });
@@ -719,7 +1087,7 @@ function createLoomioMcpServer() {
719
1087
  registerTool(
720
1088
  server2,
721
1089
  "list_polls",
722
- "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.",
723
1091
  listPollsSchema,
724
1092
  listPolls
725
1093
  );
@@ -735,7 +1103,7 @@ function createLoomioMcpServer() {
735
1103
  registerTool(
736
1104
  server2,
737
1105
  "list_memberships",
738
- "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.",
739
1107
  listMembershipsSchema,
740
1108
  listMemberships
741
1109
  );
@@ -746,6 +1114,20 @@ function createLoomioMcpServer() {
746
1114
  listGroupsSchema,
747
1115
  listGroups
748
1116
  );
1117
+ registerTool(
1118
+ server2,
1119
+ "list_events",
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.",
1121
+ listEventsSchema,
1122
+ listEvents
1123
+ );
1124
+ registerTool(
1125
+ server2,
1126
+ "get_user_activity",
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.",
1128
+ getUserActivitySchema,
1129
+ getUserActivity
1130
+ );
749
1131
  if (!readOnly) {
750
1132
  registerTool(
751
1133
  server2,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "loomiomcp",
3
- "version": "0.0.1",
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.5",
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",