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 +23 -9
- package/dist/http.js +414 -25
- package/dist/index.js +402 -20
- package/package.json +2 -2
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
|
|
6
|
-
plain English. Targets Loomio's **b2** API
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
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
|
|
63
|
-
skipped at server-init time. This is the
|
|
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}
|
|
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).
|
|
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
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
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
|
-
|
|
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/
|
|
1286
|
+
// src/tools/events.ts
|
|
1199
1287
|
import { z as z7 } from "zod";
|
|
1200
|
-
var
|
|
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:
|
|
1203
|
-
body_format:
|
|
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
|
|
1215
|
-
var deactivateUserSchema =
|
|
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 =
|
|
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
|
-
|
|
1237
|
-
|
|
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
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
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}
|
|
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).
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
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/
|
|
734
|
+
// src/tools/events.ts
|
|
647
735
|
import { z as z6 } from "zod";
|
|
648
|
-
var
|
|
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:
|
|
651
|
-
body_format:
|
|
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
|
|
663
|
-
var deactivateUserSchema =
|
|
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 =
|
|
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
|
-
|
|
685
|
-
|
|
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.
|
|
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",
|