loomiomcp 0.0.1 → 0.0.2
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/dist/http.js +201 -7
- package/dist/index.js +201 -7
- package/package.json +1 -1
package/dist/http.js
CHANGED
|
@@ -1195,12 +1195,192 @@ async function listGroups(input) {
|
|
|
1195
1195
|
};
|
|
1196
1196
|
}
|
|
1197
1197
|
|
|
1198
|
-
// src/tools/
|
|
1198
|
+
// src/tools/events.ts
|
|
1199
1199
|
import { z as z7 } from "zod";
|
|
1200
|
-
var
|
|
1200
|
+
var listEventsSchema = z7.object({
|
|
1201
|
+
discussion_id: positiveId.describe(
|
|
1202
|
+
"ID of the discussion whose event stream to fetch. Required \u2014 Loomio's v1/events endpoint silently returns empty without it."
|
|
1203
|
+
),
|
|
1204
|
+
limit: z7.number().int().min(1).max(200).optional().describe("Page size. Loomio defaults to 50; cap is 200."),
|
|
1205
|
+
offset: z7.number().int().min(0).optional().describe("Page offset (Loomio's `from` parameter). Defaults to 0."),
|
|
1206
|
+
kinds: z7.array(z7.string().min(1)).optional().describe(
|
|
1207
|
+
"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
|
+
)
|
|
1209
|
+
});
|
|
1210
|
+
async function listEvents(input) {
|
|
1211
|
+
const resp = await loomioGet("/v1/events", {
|
|
1212
|
+
discussion_id: input.discussion_id,
|
|
1213
|
+
...input.limit !== void 0 ? { per: input.limit } : {},
|
|
1214
|
+
...input.offset !== void 0 ? { from: input.offset } : {}
|
|
1215
|
+
});
|
|
1216
|
+
if (input.kinds?.length) {
|
|
1217
|
+
const allowed = new Set(input.kinds);
|
|
1218
|
+
return {
|
|
1219
|
+
...resp,
|
|
1220
|
+
events: (resp.events ?? []).filter((e) => allowed.has(e.kind))
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
return resp;
|
|
1224
|
+
}
|
|
1225
|
+
var CONCURRENCY2 = 6;
|
|
1226
|
+
var EVENTS_PAGE_SIZE = 200;
|
|
1227
|
+
var MAX_EVENTS_PAGES = 10;
|
|
1228
|
+
var ACTIVITY_KINDS = /* @__PURE__ */ new Set([
|
|
1229
|
+
"new_discussion",
|
|
1230
|
+
"new_comment",
|
|
1231
|
+
"comment_edited",
|
|
1232
|
+
"poll_created",
|
|
1233
|
+
"stance_created",
|
|
1234
|
+
"stance_updated",
|
|
1235
|
+
"outcome_created",
|
|
1236
|
+
"reaction"
|
|
1237
|
+
]);
|
|
1238
|
+
var getUserActivitySchema = z7.object({
|
|
1239
|
+
user_id: positiveId.describe("Loomio user id whose activity to summarise."),
|
|
1240
|
+
group_ids: z7.array(positiveId).min(1).max(50).describe(
|
|
1241
|
+
"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
|
+
),
|
|
1243
|
+
since: z7.string().optional().describe("ISO-8601 timestamp; ignore events before this time."),
|
|
1244
|
+
until: z7.string().optional().describe("ISO-8601 timestamp; ignore events at or after this time.")
|
|
1245
|
+
});
|
|
1246
|
+
async function listDiscussionIdsForGroup(groupId) {
|
|
1247
|
+
const all = [];
|
|
1248
|
+
let offset = 0;
|
|
1249
|
+
for (let page = 0; page < 20; page++) {
|
|
1250
|
+
const resp = await loomioGet("/b2/discussions", {
|
|
1251
|
+
group_id: groupId,
|
|
1252
|
+
status: "all",
|
|
1253
|
+
limit: EVENTS_PAGE_SIZE,
|
|
1254
|
+
offset
|
|
1255
|
+
});
|
|
1256
|
+
const ds = resp.discussions ?? [];
|
|
1257
|
+
for (const d of ds) all.push({ id: d.id, group_id: groupId });
|
|
1258
|
+
if (ds.length < EVENTS_PAGE_SIZE) break;
|
|
1259
|
+
offset += EVENTS_PAGE_SIZE;
|
|
1260
|
+
}
|
|
1261
|
+
return all;
|
|
1262
|
+
}
|
|
1263
|
+
async function fetchAllEventsForDiscussion(discussionId) {
|
|
1264
|
+
const all = [];
|
|
1265
|
+
let offset = 0;
|
|
1266
|
+
for (let page = 0; page < MAX_EVENTS_PAGES; page++) {
|
|
1267
|
+
const resp = await loomioGet("/v1/events", {
|
|
1268
|
+
discussion_id: discussionId,
|
|
1269
|
+
per: EVENTS_PAGE_SIZE,
|
|
1270
|
+
from: offset
|
|
1271
|
+
});
|
|
1272
|
+
const evs = resp.events ?? [];
|
|
1273
|
+
for (const e of evs) all.push(e);
|
|
1274
|
+
if (evs.length < EVENTS_PAGE_SIZE) break;
|
|
1275
|
+
offset += EVENTS_PAGE_SIZE;
|
|
1276
|
+
}
|
|
1277
|
+
return all;
|
|
1278
|
+
}
|
|
1279
|
+
async function runWithConcurrency(items, worker, concurrency) {
|
|
1280
|
+
const results = new Array(items.length).fill(null);
|
|
1281
|
+
let next = 0;
|
|
1282
|
+
async function take() {
|
|
1283
|
+
for (; ; ) {
|
|
1284
|
+
const i = next++;
|
|
1285
|
+
if (i >= items.length) return;
|
|
1286
|
+
try {
|
|
1287
|
+
results[i] = await worker(items[i]);
|
|
1288
|
+
} catch {
|
|
1289
|
+
results[i] = null;
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, take));
|
|
1294
|
+
return results;
|
|
1295
|
+
}
|
|
1296
|
+
async function getUserActivity(input) {
|
|
1297
|
+
const groupIds = [...new Set(input.group_ids)];
|
|
1298
|
+
const groupDiscussionLists = await runWithConcurrency(
|
|
1299
|
+
groupIds,
|
|
1300
|
+
(gid) => listDiscussionIdsForGroup(gid),
|
|
1301
|
+
CONCURRENCY2
|
|
1302
|
+
);
|
|
1303
|
+
const discussions = [];
|
|
1304
|
+
for (const list of groupDiscussionLists) {
|
|
1305
|
+
if (list) discussions.push(...list);
|
|
1306
|
+
}
|
|
1307
|
+
const since = input.since ? Date.parse(input.since) : Number.NEGATIVE_INFINITY;
|
|
1308
|
+
const until = input.until ? Date.parse(input.until) : Number.POSITIVE_INFINITY;
|
|
1309
|
+
let eventsExamined = 0;
|
|
1310
|
+
const counts = {
|
|
1311
|
+
total: 0,
|
|
1312
|
+
new_discussion: 0,
|
|
1313
|
+
new_comment: 0,
|
|
1314
|
+
poll_created: 0,
|
|
1315
|
+
stance_created: 0,
|
|
1316
|
+
outcome_created: 0,
|
|
1317
|
+
reaction: 0,
|
|
1318
|
+
other: 0
|
|
1319
|
+
};
|
|
1320
|
+
const byGroup = {};
|
|
1321
|
+
const byMonth = {};
|
|
1322
|
+
let firstActivity = null;
|
|
1323
|
+
let lastActivity = null;
|
|
1324
|
+
const samples = [];
|
|
1325
|
+
const eventStreams = await runWithConcurrency(
|
|
1326
|
+
discussions,
|
|
1327
|
+
async (d) => ({ groupId: d.group_id, events: await fetchAllEventsForDiscussion(d.id) }),
|
|
1328
|
+
CONCURRENCY2
|
|
1329
|
+
);
|
|
1330
|
+
for (const s of eventStreams) {
|
|
1331
|
+
if (!s) continue;
|
|
1332
|
+
for (const e of s.events) {
|
|
1333
|
+
eventsExamined++;
|
|
1334
|
+
if (e.actor_id !== input.user_id) continue;
|
|
1335
|
+
const t = Date.parse(e.created_at);
|
|
1336
|
+
if (Number.isNaN(t) || t < since || t >= until) continue;
|
|
1337
|
+
if (!ACTIVITY_KINDS.has(e.kind)) {
|
|
1338
|
+
counts.other++;
|
|
1339
|
+
} else {
|
|
1340
|
+
const slot = e.kind;
|
|
1341
|
+
if (slot in counts && slot !== "total") counts[slot]++;
|
|
1342
|
+
}
|
|
1343
|
+
counts.total++;
|
|
1344
|
+
byGroup[String(s.groupId)] = (byGroup[String(s.groupId)] ?? 0) + 1;
|
|
1345
|
+
const month = e.created_at.slice(0, 7);
|
|
1346
|
+
byMonth[month] = (byMonth[month] ?? 0) + 1;
|
|
1347
|
+
if (!firstActivity || e.created_at < firstActivity) firstActivity = e.created_at;
|
|
1348
|
+
if (!lastActivity || e.created_at > lastActivity) lastActivity = e.created_at;
|
|
1349
|
+
if (samples.length < 10) {
|
|
1350
|
+
samples.push({
|
|
1351
|
+
kind: e.kind,
|
|
1352
|
+
discussion_id: e.discussion_id,
|
|
1353
|
+
created_at: e.created_at,
|
|
1354
|
+
eventable_type: e.eventable_type,
|
|
1355
|
+
eventable_id: e.eventable_id
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
return {
|
|
1361
|
+
user_id: input.user_id,
|
|
1362
|
+
scope: {
|
|
1363
|
+
group_ids: groupIds,
|
|
1364
|
+
discussions_scanned: discussions.length,
|
|
1365
|
+
events_examined: eventsExamined,
|
|
1366
|
+
since: input.since ?? null,
|
|
1367
|
+
until: input.until ?? null
|
|
1368
|
+
},
|
|
1369
|
+
counts,
|
|
1370
|
+
by_group: byGroup,
|
|
1371
|
+
by_month: byMonth,
|
|
1372
|
+
first_activity: firstActivity,
|
|
1373
|
+
last_activity: lastActivity,
|
|
1374
|
+
sample_events: samples
|
|
1375
|
+
};
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
// src/tools/comments.ts
|
|
1379
|
+
import { z as z8 } from "zod";
|
|
1380
|
+
var createCommentSchema = z8.object({
|
|
1201
1381
|
discussion_id: positiveId.describe("ID of the discussion to comment on."),
|
|
1202
|
-
body:
|
|
1203
|
-
body_format:
|
|
1382
|
+
body: z8.string().min(1).describe("Comment body (required)."),
|
|
1383
|
+
body_format: z8.enum(["md", "html"]).optional().describe("Format of `body`. Defaults to Loomio's group default when omitted.")
|
|
1204
1384
|
});
|
|
1205
1385
|
async function createComment(input) {
|
|
1206
1386
|
const { discussion_id, ...rest } = input;
|
|
@@ -1211,14 +1391,14 @@ async function createComment(input) {
|
|
|
1211
1391
|
}
|
|
1212
1392
|
|
|
1213
1393
|
// src/tools/admin.ts
|
|
1214
|
-
import { z as
|
|
1215
|
-
var deactivateUserSchema =
|
|
1394
|
+
import { z as z9 } from "zod";
|
|
1395
|
+
var deactivateUserSchema = z9.object({
|
|
1216
1396
|
id: positiveId.describe("Loomio user id to deactivate. Returns 404 if not active.")
|
|
1217
1397
|
});
|
|
1218
1398
|
async function deactivateUser(input) {
|
|
1219
1399
|
return loomioPostB3("/b3/users/deactivate", { id: input.id });
|
|
1220
1400
|
}
|
|
1221
|
-
var reactivateUserSchema =
|
|
1401
|
+
var reactivateUserSchema = z9.object({
|
|
1222
1402
|
id: positiveId.describe(
|
|
1223
1403
|
"Loomio user id to reactivate. Returns 404 if not currently deactivated."
|
|
1224
1404
|
)
|
|
@@ -1298,6 +1478,20 @@ function createLoomioMcpServer() {
|
|
|
1298
1478
|
listGroupsSchema,
|
|
1299
1479
|
listGroups
|
|
1300
1480
|
);
|
|
1481
|
+
registerTool(
|
|
1482
|
+
server,
|
|
1483
|
+
"list_events",
|
|
1484
|
+
"Fetch the full event stream for one discussion \u2014 every new_comment, poll_created, stance_created, outcome_created, reaction, discussion_moved, etc. \u2014 with actor_id, kind, parent_id, created_at, and pointers to the underlying eventable record. Required: `discussion_id`. Optional `limit` (1-200, default 50), `offset` (Loomio's `from` param), and `kinds` (client-side filter to specific event kinds). The response also embeds related `comments`, `users`, and `polls` arrays for in-place resolution. Use this to answer 'show me the reply tree for thread X', 'who participated in discussion Y', or as the building block for cross-discussion aggregations. Loomio's v1/events endpoint requires a discussion_id; there's no instance-wide or per-user index \u2014 for user-centric questions, use `get_user_activity`.",
|
|
1485
|
+
listEventsSchema,
|
|
1486
|
+
listEvents
|
|
1487
|
+
);
|
|
1488
|
+
registerTool(
|
|
1489
|
+
server,
|
|
1490
|
+
"get_user_activity",
|
|
1491
|
+
"Aggregate one user's activity across a set of groups. Server-side: fans out across every discussion in the specified groups, fetches its event stream, filters to events authored by the target user, and returns counts (by kind, by group, by month), plus first/last activity timestamps and a sample of recent events. Required: `user_id`, `group_ids` (1-50; pass the result of `list_groups` for instance-wide). Optional `since` / `until` (ISO-8601) bound the time window. Cost: one outbound HTTP call per discussion in scope (plus one `list_discussions` per group). Wide scans of an active instance commonly hit 100-300 calls in 5-30 seconds. Concurrency-capped at 6. Use this for 'tell me about user X', 'how active has user Y been in Q1', or before promoting/awarding someone. For one discussion at a time, use `list_events`.",
|
|
1492
|
+
getUserActivitySchema,
|
|
1493
|
+
getUserActivity
|
|
1494
|
+
);
|
|
1301
1495
|
if (!readOnly) {
|
|
1302
1496
|
registerTool(
|
|
1303
1497
|
server,
|
package/dist/index.js
CHANGED
|
@@ -643,12 +643,192 @@ async function listGroups(input) {
|
|
|
643
643
|
};
|
|
644
644
|
}
|
|
645
645
|
|
|
646
|
-
// src/tools/
|
|
646
|
+
// src/tools/events.ts
|
|
647
647
|
import { z as z6 } from "zod";
|
|
648
|
-
var
|
|
648
|
+
var listEventsSchema = z6.object({
|
|
649
|
+
discussion_id: positiveId.describe(
|
|
650
|
+
"ID of the discussion whose event stream to fetch. Required \u2014 Loomio's v1/events endpoint silently returns empty without it."
|
|
651
|
+
),
|
|
652
|
+
limit: z6.number().int().min(1).max(200).optional().describe("Page size. Loomio defaults to 50; cap is 200."),
|
|
653
|
+
offset: z6.number().int().min(0).optional().describe("Page offset (Loomio's `from` parameter). Defaults to 0."),
|
|
654
|
+
kinds: z6.array(z6.string().min(1)).optional().describe(
|
|
655
|
+
"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
|
+
)
|
|
657
|
+
});
|
|
658
|
+
async function listEvents(input) {
|
|
659
|
+
const resp = await loomioGet("/v1/events", {
|
|
660
|
+
discussion_id: input.discussion_id,
|
|
661
|
+
...input.limit !== void 0 ? { per: input.limit } : {},
|
|
662
|
+
...input.offset !== void 0 ? { from: input.offset } : {}
|
|
663
|
+
});
|
|
664
|
+
if (input.kinds?.length) {
|
|
665
|
+
const allowed = new Set(input.kinds);
|
|
666
|
+
return {
|
|
667
|
+
...resp,
|
|
668
|
+
events: (resp.events ?? []).filter((e) => allowed.has(e.kind))
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
return resp;
|
|
672
|
+
}
|
|
673
|
+
var CONCURRENCY2 = 6;
|
|
674
|
+
var EVENTS_PAGE_SIZE = 200;
|
|
675
|
+
var MAX_EVENTS_PAGES = 10;
|
|
676
|
+
var ACTIVITY_KINDS = /* @__PURE__ */ new Set([
|
|
677
|
+
"new_discussion",
|
|
678
|
+
"new_comment",
|
|
679
|
+
"comment_edited",
|
|
680
|
+
"poll_created",
|
|
681
|
+
"stance_created",
|
|
682
|
+
"stance_updated",
|
|
683
|
+
"outcome_created",
|
|
684
|
+
"reaction"
|
|
685
|
+
]);
|
|
686
|
+
var getUserActivitySchema = z6.object({
|
|
687
|
+
user_id: positiveId.describe("Loomio user id whose activity to summarise."),
|
|
688
|
+
group_ids: z6.array(positiveId).min(1).max(50).describe(
|
|
689
|
+
"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
|
+
),
|
|
691
|
+
since: z6.string().optional().describe("ISO-8601 timestamp; ignore events before this time."),
|
|
692
|
+
until: z6.string().optional().describe("ISO-8601 timestamp; ignore events at or after this time.")
|
|
693
|
+
});
|
|
694
|
+
async function listDiscussionIdsForGroup(groupId) {
|
|
695
|
+
const all = [];
|
|
696
|
+
let offset = 0;
|
|
697
|
+
for (let page = 0; page < 20; page++) {
|
|
698
|
+
const resp = await loomioGet("/b2/discussions", {
|
|
699
|
+
group_id: groupId,
|
|
700
|
+
status: "all",
|
|
701
|
+
limit: EVENTS_PAGE_SIZE,
|
|
702
|
+
offset
|
|
703
|
+
});
|
|
704
|
+
const ds = resp.discussions ?? [];
|
|
705
|
+
for (const d of ds) all.push({ id: d.id, group_id: groupId });
|
|
706
|
+
if (ds.length < EVENTS_PAGE_SIZE) break;
|
|
707
|
+
offset += EVENTS_PAGE_SIZE;
|
|
708
|
+
}
|
|
709
|
+
return all;
|
|
710
|
+
}
|
|
711
|
+
async function fetchAllEventsForDiscussion(discussionId) {
|
|
712
|
+
const all = [];
|
|
713
|
+
let offset = 0;
|
|
714
|
+
for (let page = 0; page < MAX_EVENTS_PAGES; page++) {
|
|
715
|
+
const resp = await loomioGet("/v1/events", {
|
|
716
|
+
discussion_id: discussionId,
|
|
717
|
+
per: EVENTS_PAGE_SIZE,
|
|
718
|
+
from: offset
|
|
719
|
+
});
|
|
720
|
+
const evs = resp.events ?? [];
|
|
721
|
+
for (const e of evs) all.push(e);
|
|
722
|
+
if (evs.length < EVENTS_PAGE_SIZE) break;
|
|
723
|
+
offset += EVENTS_PAGE_SIZE;
|
|
724
|
+
}
|
|
725
|
+
return all;
|
|
726
|
+
}
|
|
727
|
+
async function runWithConcurrency(items, worker, concurrency) {
|
|
728
|
+
const results = new Array(items.length).fill(null);
|
|
729
|
+
let next = 0;
|
|
730
|
+
async function take() {
|
|
731
|
+
for (; ; ) {
|
|
732
|
+
const i = next++;
|
|
733
|
+
if (i >= items.length) return;
|
|
734
|
+
try {
|
|
735
|
+
results[i] = await worker(items[i]);
|
|
736
|
+
} catch {
|
|
737
|
+
results[i] = null;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, take));
|
|
742
|
+
return results;
|
|
743
|
+
}
|
|
744
|
+
async function getUserActivity(input) {
|
|
745
|
+
const groupIds = [...new Set(input.group_ids)];
|
|
746
|
+
const groupDiscussionLists = await runWithConcurrency(
|
|
747
|
+
groupIds,
|
|
748
|
+
(gid) => listDiscussionIdsForGroup(gid),
|
|
749
|
+
CONCURRENCY2
|
|
750
|
+
);
|
|
751
|
+
const discussions = [];
|
|
752
|
+
for (const list of groupDiscussionLists) {
|
|
753
|
+
if (list) discussions.push(...list);
|
|
754
|
+
}
|
|
755
|
+
const since = input.since ? Date.parse(input.since) : Number.NEGATIVE_INFINITY;
|
|
756
|
+
const until = input.until ? Date.parse(input.until) : Number.POSITIVE_INFINITY;
|
|
757
|
+
let eventsExamined = 0;
|
|
758
|
+
const counts = {
|
|
759
|
+
total: 0,
|
|
760
|
+
new_discussion: 0,
|
|
761
|
+
new_comment: 0,
|
|
762
|
+
poll_created: 0,
|
|
763
|
+
stance_created: 0,
|
|
764
|
+
outcome_created: 0,
|
|
765
|
+
reaction: 0,
|
|
766
|
+
other: 0
|
|
767
|
+
};
|
|
768
|
+
const byGroup = {};
|
|
769
|
+
const byMonth = {};
|
|
770
|
+
let firstActivity = null;
|
|
771
|
+
let lastActivity = null;
|
|
772
|
+
const samples = [];
|
|
773
|
+
const eventStreams = await runWithConcurrency(
|
|
774
|
+
discussions,
|
|
775
|
+
async (d) => ({ groupId: d.group_id, events: await fetchAllEventsForDiscussion(d.id) }),
|
|
776
|
+
CONCURRENCY2
|
|
777
|
+
);
|
|
778
|
+
for (const s of eventStreams) {
|
|
779
|
+
if (!s) continue;
|
|
780
|
+
for (const e of s.events) {
|
|
781
|
+
eventsExamined++;
|
|
782
|
+
if (e.actor_id !== input.user_id) continue;
|
|
783
|
+
const t = Date.parse(e.created_at);
|
|
784
|
+
if (Number.isNaN(t) || t < since || t >= until) continue;
|
|
785
|
+
if (!ACTIVITY_KINDS.has(e.kind)) {
|
|
786
|
+
counts.other++;
|
|
787
|
+
} else {
|
|
788
|
+
const slot = e.kind;
|
|
789
|
+
if (slot in counts && slot !== "total") counts[slot]++;
|
|
790
|
+
}
|
|
791
|
+
counts.total++;
|
|
792
|
+
byGroup[String(s.groupId)] = (byGroup[String(s.groupId)] ?? 0) + 1;
|
|
793
|
+
const month = e.created_at.slice(0, 7);
|
|
794
|
+
byMonth[month] = (byMonth[month] ?? 0) + 1;
|
|
795
|
+
if (!firstActivity || e.created_at < firstActivity) firstActivity = e.created_at;
|
|
796
|
+
if (!lastActivity || e.created_at > lastActivity) lastActivity = e.created_at;
|
|
797
|
+
if (samples.length < 10) {
|
|
798
|
+
samples.push({
|
|
799
|
+
kind: e.kind,
|
|
800
|
+
discussion_id: e.discussion_id,
|
|
801
|
+
created_at: e.created_at,
|
|
802
|
+
eventable_type: e.eventable_type,
|
|
803
|
+
eventable_id: e.eventable_id
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
return {
|
|
809
|
+
user_id: input.user_id,
|
|
810
|
+
scope: {
|
|
811
|
+
group_ids: groupIds,
|
|
812
|
+
discussions_scanned: discussions.length,
|
|
813
|
+
events_examined: eventsExamined,
|
|
814
|
+
since: input.since ?? null,
|
|
815
|
+
until: input.until ?? null
|
|
816
|
+
},
|
|
817
|
+
counts,
|
|
818
|
+
by_group: byGroup,
|
|
819
|
+
by_month: byMonth,
|
|
820
|
+
first_activity: firstActivity,
|
|
821
|
+
last_activity: lastActivity,
|
|
822
|
+
sample_events: samples
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// src/tools/comments.ts
|
|
827
|
+
import { z as z7 } from "zod";
|
|
828
|
+
var createCommentSchema = z7.object({
|
|
649
829
|
discussion_id: positiveId.describe("ID of the discussion to comment on."),
|
|
650
|
-
body:
|
|
651
|
-
body_format:
|
|
830
|
+
body: z7.string().min(1).describe("Comment body (required)."),
|
|
831
|
+
body_format: z7.enum(["md", "html"]).optional().describe("Format of `body`. Defaults to Loomio's group default when omitted.")
|
|
652
832
|
});
|
|
653
833
|
async function createComment(input) {
|
|
654
834
|
const { discussion_id, ...rest } = input;
|
|
@@ -659,14 +839,14 @@ async function createComment(input) {
|
|
|
659
839
|
}
|
|
660
840
|
|
|
661
841
|
// src/tools/admin.ts
|
|
662
|
-
import { z as
|
|
663
|
-
var deactivateUserSchema =
|
|
842
|
+
import { z as z8 } from "zod";
|
|
843
|
+
var deactivateUserSchema = z8.object({
|
|
664
844
|
id: positiveId.describe("Loomio user id to deactivate. Returns 404 if not active.")
|
|
665
845
|
});
|
|
666
846
|
async function deactivateUser(input) {
|
|
667
847
|
return loomioPostB3("/b3/users/deactivate", { id: input.id });
|
|
668
848
|
}
|
|
669
|
-
var reactivateUserSchema =
|
|
849
|
+
var reactivateUserSchema = z8.object({
|
|
670
850
|
id: positiveId.describe(
|
|
671
851
|
"Loomio user id to reactivate. Returns 404 if not currently deactivated."
|
|
672
852
|
)
|
|
@@ -746,6 +926,20 @@ function createLoomioMcpServer() {
|
|
|
746
926
|
listGroupsSchema,
|
|
747
927
|
listGroups
|
|
748
928
|
);
|
|
929
|
+
registerTool(
|
|
930
|
+
server2,
|
|
931
|
+
"list_events",
|
|
932
|
+
"Fetch the full event stream for one discussion \u2014 every new_comment, poll_created, stance_created, outcome_created, reaction, discussion_moved, etc. \u2014 with actor_id, kind, parent_id, created_at, and pointers to the underlying eventable record. Required: `discussion_id`. Optional `limit` (1-200, default 50), `offset` (Loomio's `from` param), and `kinds` (client-side filter to specific event kinds). The response also embeds related `comments`, `users`, and `polls` arrays for in-place resolution. Use this to answer 'show me the reply tree for thread X', 'who participated in discussion Y', or as the building block for cross-discussion aggregations. Loomio's v1/events endpoint requires a discussion_id; there's no instance-wide or per-user index \u2014 for user-centric questions, use `get_user_activity`.",
|
|
933
|
+
listEventsSchema,
|
|
934
|
+
listEvents
|
|
935
|
+
);
|
|
936
|
+
registerTool(
|
|
937
|
+
server2,
|
|
938
|
+
"get_user_activity",
|
|
939
|
+
"Aggregate one user's activity across a set of groups. Server-side: fans out across every discussion in the specified groups, fetches its event stream, filters to events authored by the target user, and returns counts (by kind, by group, by month), plus first/last activity timestamps and a sample of recent events. Required: `user_id`, `group_ids` (1-50; pass the result of `list_groups` for instance-wide). Optional `since` / `until` (ISO-8601) bound the time window. Cost: one outbound HTTP call per discussion in scope (plus one `list_discussions` per group). Wide scans of an active instance commonly hit 100-300 calls in 5-30 seconds. Concurrency-capped at 6. Use this for 'tell me about user X', 'how active has user Y been in Q1', or before promoting/awarding someone. For one discussion at a time, use `list_events`.",
|
|
940
|
+
getUserActivitySchema,
|
|
941
|
+
getUserActivity
|
|
942
|
+
);
|
|
749
943
|
if (!readOnly) {
|
|
750
944
|
registerTool(
|
|
751
945
|
server2,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loomiomcp",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2",
|
|
4
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.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|