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