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.
Files changed (3) hide show
  1. package/dist/http.js +201 -7
  2. package/dist/index.js +201 -7
  3. 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/comments.ts
1198
+ // src/tools/events.ts
1199
1199
  import { z as z7 } from "zod";
1200
- var createCommentSchema = z7.object({
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: z7.string().min(1).describe("Comment body (required)."),
1203
- body_format: z7.enum(["md", "html"]).optional().describe("Format of `body`. Defaults to Loomio's group default when omitted.")
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 z8 } from "zod";
1215
- var deactivateUserSchema = z8.object({
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 = z8.object({
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/comments.ts
646
+ // src/tools/events.ts
647
647
  import { z as z6 } from "zod";
648
- var createCommentSchema = z6.object({
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: z6.string().min(1).describe("Comment body (required)."),
651
- body_format: z6.enum(["md", "html"]).optional().describe("Format of `body`. Defaults to Loomio's group default when omitted.")
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 z7 } from "zod";
663
- var deactivateUserSchema = z7.object({
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 = z7.object({
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.1",
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",