loomiomcp 0.0.5 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # loomiomcp
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/loomiomcp)](https://www.npmjs.com/package/loomiomcp)
4
+ [![CI](https://github.com/soil-dev/loomiomcp/actions/workflows/ci.yml/badge.svg)](https://github.com/soil-dev/loomiomcp/actions/workflows/ci.yml)
5
+ [![License: Apache-2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE)
6
+ [![Glama](https://glama.ai/mcp/servers/soil-dev/loomiomcp/badge)](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
10
  write Loomio discussions, polls, comments, and group memberships — and
package/dist/http.js CHANGED
@@ -327,7 +327,8 @@ async function loomioPostB3(path, params) {
327
327
  }
328
328
 
329
329
  // src/auth/provider.ts
330
- import { createHash, randomBytes, randomUUID, timingSafeEqual as timingSafeEqual2 } from "crypto";
330
+ import { createHash, createHmac as createHmac2, randomBytes, randomUUID, timingSafeEqual as timingSafeEqual2 } from "crypto";
331
+ import { z as z2 } from "zod";
331
332
  import {
332
333
  InvalidGrantError,
333
334
  InvalidTargetError,
@@ -376,6 +377,28 @@ var TokenExpiredError = class extends Error {
376
377
  this.name = "TokenExpiredError";
377
378
  }
378
379
  };
380
+ function signData(obj, signingKey2) {
381
+ const payloadB64 = b64urlEncode(Buffer.from(JSON.stringify(obj), "utf8"));
382
+ return `${payloadB64}.${sign(payloadB64, signingKey2)}`;
383
+ }
384
+ function verifyData(blob, signingKey2) {
385
+ const parts = blob.split(".");
386
+ if (parts.length !== 2) {
387
+ throw new TokenSignatureError("malformed signed blob");
388
+ }
389
+ const [payloadB64, providedSig] = parts;
390
+ const expectedSig = sign(payloadB64, signingKey2);
391
+ const a = Buffer.from(providedSig);
392
+ const b = Buffer.from(expectedSig);
393
+ if (a.length !== b.length || !timingSafeEqual(a, b)) {
394
+ throw new TokenSignatureError("invalid signature");
395
+ }
396
+ try {
397
+ return JSON.parse(b64urlDecode(payloadB64).toString("utf8"));
398
+ } catch {
399
+ throw new TokenSignatureError("malformed payload");
400
+ }
401
+ }
379
402
  function verifyToken(token, signingKey2) {
380
403
  const parts = token.split(".");
381
404
  if (parts.length !== 2) {
@@ -411,20 +434,96 @@ var REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
411
434
  var AUTH_CODE_TTL_MS = 5 * 60 * 1e3;
412
435
  var AUTH_CODE_MAX_ENTRIES = 1e4;
413
436
  var AUTH_CODE_GC_INTERVAL_MS = 60 * 1e3;
414
- var InMemoryClientsStore = class {
415
- clients = /* @__PURE__ */ new Map();
437
+ var StatelessClientClaimsSchema = z2.object({
438
+ k: z2.literal("client"),
439
+ redirect_uris: z2.array(z2.string()).min(1),
440
+ grant_types: z2.array(z2.string()).optional(),
441
+ response_types: z2.array(z2.string()).optional(),
442
+ token_endpoint_auth_method: z2.string().optional(),
443
+ scope: z2.string().optional(),
444
+ client_name: z2.string().optional(),
445
+ iat: z2.number().int()
446
+ });
447
+ var DEFAULT_GRANT_TYPES = ["authorization_code", "refresh_token"];
448
+ var DEFAULT_RESPONSE_TYPES = ["code"];
449
+ var DEFAULT_AUTH_METHOD = "client_secret_post";
450
+ var MAX_SIGNED_CLIENT_ID_BYTES = 16384;
451
+ var StatelessClientsStore = class {
452
+ signingKey;
453
+ constructor(signingKey2) {
454
+ if (!signingKey2 || signingKey2.length < 16) {
455
+ throw new Error("StatelessClientsStore: signing key must be at least 16 chars");
456
+ }
457
+ this.signingKey = signingKey2;
458
+ }
459
+ // Deterministic per-client secret for confidential clients: any instance
460
+ // recomputes the same value, so client_secret_post auth works without
461
+ // storing the secret.
462
+ deriveSecret(clientId) {
463
+ return createHmac2("sha256", this.signingKey).update(`client_secret:${clientId}`).digest("base64url");
464
+ }
465
+ reconstruct(clientId, c) {
466
+ const authMethod = c.token_endpoint_auth_method ?? DEFAULT_AUTH_METHOD;
467
+ const isPublicClient = authMethod === "none";
468
+ return {
469
+ client_id: clientId,
470
+ client_id_issued_at: c.iat,
471
+ redirect_uris: c.redirect_uris,
472
+ grant_types: c.grant_types ?? DEFAULT_GRANT_TYPES,
473
+ response_types: c.response_types ?? DEFAULT_RESPONSE_TYPES,
474
+ token_endpoint_auth_method: authMethod,
475
+ ...isPublicClient ? {} : {
476
+ client_secret: this.deriveSecret(clientId),
477
+ client_secret_expires_at: 0
478
+ // never expires — there's nothing to rotate per-client
479
+ },
480
+ ...c.scope ? { scope: c.scope } : {},
481
+ ...c.client_name ? { client_name: c.client_name } : {}
482
+ };
483
+ }
416
484
  getClient(clientId) {
417
- return this.clients.get(clientId);
485
+ if (Buffer.byteLength(clientId, "utf8") > MAX_SIGNED_CLIENT_ID_BYTES) return void 0;
486
+ let raw;
487
+ try {
488
+ raw = verifyData(clientId, this.signingKey);
489
+ } catch {
490
+ return void 0;
491
+ }
492
+ const parsed = StatelessClientClaimsSchema.safeParse(raw);
493
+ if (!parsed.success) return void 0;
494
+ return this.reconstruct(clientId, parsed.data);
418
495
  }
419
496
  registerClient(client) {
420
- const clientId = randomUUID();
421
- const full = {
497
+ const iat = Math.floor(Date.now() / 1e3);
498
+ const redirectUris = client.redirect_uris ?? [];
499
+ const authMethod = client.token_endpoint_auth_method ?? DEFAULT_AUTH_METHOD;
500
+ const isPublicClient = authMethod === "none";
501
+ const clientId = signData(
502
+ {
503
+ k: "client",
504
+ redirect_uris: redirectUris,
505
+ grant_types: client.grant_types ?? DEFAULT_GRANT_TYPES,
506
+ response_types: client.response_types ?? DEFAULT_RESPONSE_TYPES,
507
+ token_endpoint_auth_method: authMethod,
508
+ ...client.scope ? { scope: client.scope } : {},
509
+ ...client.client_name ? { client_name: client.client_name } : {},
510
+ iat
511
+ },
512
+ this.signingKey
513
+ );
514
+ return {
422
515
  ...client,
423
516
  client_id: clientId,
424
- client_id_issued_at: Math.floor(Date.now() / 1e3)
517
+ client_id_issued_at: iat,
518
+ redirect_uris: redirectUris,
519
+ grant_types: client.grant_types ?? DEFAULT_GRANT_TYPES,
520
+ response_types: client.response_types ?? DEFAULT_RESPONSE_TYPES,
521
+ token_endpoint_auth_method: authMethod,
522
+ ...isPublicClient ? {} : {
523
+ client_secret: this.deriveSecret(clientId),
524
+ client_secret_expires_at: 0
525
+ }
425
526
  };
426
- this.clients.set(clientId, full);
427
- return full;
428
527
  }
429
528
  };
430
529
  var FixedClientStore = class {
@@ -931,14 +1030,14 @@ function registerTool(server, name, description, schema, handler) {
931
1030
  }
932
1031
 
933
1032
  // src/tools/discussions.ts
934
- import { z as z3 } from "zod";
1033
+ import { z as z4 } from "zod";
935
1034
 
936
1035
  // src/tools/_common.ts
937
- import { z as z2 } from "zod";
938
- var positiveId = z2.number().int().positive();
939
- var loomioKey = z2.string().regex(/^[A-Za-z0-9_-]+$/, "Loomio short keys may only contain letters, numbers, _ and -.");
940
- var idOrKey = z2.union([loomioKey, positiveId]);
941
- var isoTimestamp = z2.string().refine((v) => !Number.isNaN(Date.parse(v)), {
1036
+ import { z as z3 } from "zod";
1037
+ var positiveId = z3.number().int().positive();
1038
+ var loomioKey = z3.string().regex(/^[A-Za-z0-9_-]+$/, "Loomio short keys may only contain letters, numbers, _ and -.");
1039
+ var idOrKey = z3.union([loomioKey, positiveId]);
1040
+ var isoTimestamp = z3.string().refine((v) => !Number.isNaN(Date.parse(v)), {
942
1041
  message: "must be a parseable ISO-8601 timestamp (e.g. 2026-01-31 or 2026-01-31T00:00:00Z)."
943
1042
  }).optional();
944
1043
  function encodePathSegment(value) {
@@ -948,7 +1047,7 @@ function encodePathSegment(value) {
948
1047
  }
949
1048
  return encodeURIComponent(raw);
950
1049
  }
951
- var PollTypeEnum = z2.enum([
1050
+ var PollTypeEnum = z3.enum([
952
1051
  "proposal",
953
1052
  "poll",
954
1053
  "count",
@@ -959,7 +1058,7 @@ var PollTypeEnum = z2.enum([
959
1058
  ]);
960
1059
 
961
1060
  // src/tools/discussions.ts
962
- var getDiscussionSchema = z3.object({
1061
+ var getDiscussionSchema = z4.object({
963
1062
  id_or_key: idOrKey.describe(
964
1063
  "Discussion id (numeric) or short string key (e.g. 'abcDEF12'). Loomio accepts either."
965
1064
  )
@@ -967,13 +1066,13 @@ var getDiscussionSchema = z3.object({
967
1066
  async function getDiscussion(input) {
968
1067
  return loomioGet(`/b2/discussions/${encodePathSegment(input.id_or_key)}`);
969
1068
  }
970
- var listDiscussionsSchema = z3.object({
1069
+ var listDiscussionsSchema = z4.object({
971
1070
  group_id: positiveId.describe("ID of the Loomio group whose discussions to list (required)."),
972
- status: z3.enum(["open", "closed", "all"]).optional().describe(
1071
+ status: z4.enum(["open", "closed", "all"]).optional().describe(
973
1072
  "Filter by status. 'open' = unlocked, 'closed' = locked, 'all' = every kept discussion. Loomio defaults to 'open'."
974
1073
  ),
975
- limit: z3.number().int().min(1).max(200).optional().describe("Page size. Loomio defaults to 50."),
976
- offset: z3.number().int().min(0).optional().describe("Page offset. Defaults to 0.")
1074
+ limit: z4.number().int().min(1).max(200).optional().describe("Page size. Loomio defaults to 50."),
1075
+ offset: z4.number().int().min(0).optional().describe("Page offset. Defaults to 0.")
977
1076
  });
978
1077
  async function listDiscussions(input) {
979
1078
  return loomioGet("/b2/discussions", {
@@ -983,18 +1082,18 @@ async function listDiscussions(input) {
983
1082
  ...input.offset !== void 0 ? { offset: input.offset } : {}
984
1083
  });
985
1084
  }
986
- var createDiscussionSchema = z3.object({
987
- title: z3.string().min(1).describe("Discussion title (required)."),
1085
+ var createDiscussionSchema = z4.object({
1086
+ title: z4.string().min(1).describe("Discussion title (required)."),
988
1087
  group_id: positiveId.describe("ID of the Loomio group to create the discussion in (required)."),
989
- description: z3.string().optional().describe("Optional discussion body."),
990
- description_format: z3.enum(["md", "html"]).optional().describe("Format of `description`. Defaults to Loomio's group default when omitted."),
991
- private: z3.boolean().optional().describe(
1088
+ description: z4.string().optional().describe("Optional discussion body."),
1089
+ description_format: z4.enum(["md", "html"]).optional().describe("Format of `description`. Defaults to Loomio's group default when omitted."),
1090
+ private: z4.boolean().optional().describe(
992
1091
  "Visibility: true = group members only, false = publicly visible. Loomio's validator enforces this against the group's `discussion_privacy_options`: `public_only` requires `private: false`, `private_only` requires `private: true`, `public_or_private` allows either. When omitted, the connector reads the group's setting and chooses the value Loomio's web UI would pick: `false` for `public_only`, `true` otherwise (matching `Group#discussion_private_default`). Pass an explicit value to override."
993
1092
  ),
994
- recipient_audience: z3.enum(["group"]).optional().describe("Audience selector for notification recipients. 'group' = notify whole group."),
995
- recipient_user_ids: z3.array(positiveId).optional().describe("Explicit list of user IDs to notify."),
996
- recipient_emails: z3.array(z3.string().email()).optional().describe("Explicit list of email addresses to notify."),
997
- recipient_message: z3.string().optional().describe("Optional custom message to include in notifications.")
1093
+ recipient_audience: z4.enum(["group"]).optional().describe("Audience selector for notification recipients. 'group' = notify whole group."),
1094
+ recipient_user_ids: z4.array(positiveId).optional().describe("Explicit list of user IDs to notify."),
1095
+ recipient_emails: z4.array(z4.string().email()).optional().describe("Explicit list of email addresses to notify."),
1096
+ recipient_message: z4.string().optional().describe("Optional custom message to include in notifications.")
998
1097
  });
999
1098
  async function resolveDiscussionPrivate(groupId) {
1000
1099
  try {
@@ -1015,18 +1114,18 @@ async function createDiscussion(input) {
1015
1114
  }
1016
1115
 
1017
1116
  // src/tools/polls.ts
1018
- import { z as z4 } from "zod";
1019
- var getPollSchema = z4.object({
1117
+ import { z as z5 } from "zod";
1118
+ var getPollSchema = z5.object({
1020
1119
  id_or_key: idOrKey.describe("Poll id (numeric) or short string key.")
1021
1120
  });
1022
1121
  async function getPoll(input) {
1023
1122
  return loomioGet(`/b2/polls/${encodePathSegment(input.id_or_key)}`);
1024
1123
  }
1025
- var listPollsSchema = z4.object({
1124
+ var listPollsSchema = z5.object({
1026
1125
  group_id: positiveId.describe("ID of the Loomio group whose polls to list (required)."),
1027
- status: z4.enum(["active", "closed", "all"]).optional().describe("Filter polls by status. Loomio defaults to 'active'."),
1028
- limit: z4.number().int().min(1).max(200).optional().describe("Page size. Loomio defaults to 50."),
1029
- offset: z4.number().int().min(0).optional().describe("Page offset. Defaults to 0.")
1126
+ status: z5.enum(["active", "closed", "all"]).optional().describe("Filter polls by status. Loomio defaults to 'active'."),
1127
+ limit: z5.number().int().min(1).max(200).optional().describe("Page size. Loomio defaults to 50."),
1128
+ offset: z5.number().int().min(0).optional().describe("Page offset. Defaults to 0.")
1030
1129
  });
1031
1130
  async function listPolls(input) {
1032
1131
  return loomioGet("/b2/polls", {
@@ -1036,8 +1135,8 @@ async function listPolls(input) {
1036
1135
  ...input.offset !== void 0 ? { offset: input.offset } : {}
1037
1136
  });
1038
1137
  }
1039
- var createPollSchema = z4.object({
1040
- title: z4.string().min(1).describe("Poll title (required)."),
1138
+ var createPollSchema = z5.object({
1139
+ title: z5.string().min(1).describe("Poll title (required)."),
1041
1140
  poll_type: PollTypeEnum.describe(
1042
1141
  "Poll type. One of: proposal, poll, count, score, ranked_choice, meeting, dot_vote."
1043
1142
  ),
@@ -1047,22 +1146,22 @@ var createPollSchema = z4.object({
1047
1146
  discussion_id: positiveId.optional().describe(
1048
1147
  "Attach the poll to an existing discussion. When set, group_id is taken from the discussion."
1049
1148
  ),
1050
- details: z4.string().optional().describe("Optional poll body / context."),
1051
- details_format: z4.enum(["md", "html"]).optional().describe("Format of `details`. Defaults to 'md'."),
1052
- options: z4.array(z4.string().min(1)).optional().describe(
1149
+ details: z5.string().optional().describe("Optional poll body / context."),
1150
+ details_format: z5.enum(["md", "html"]).optional().describe("Format of `details`. Defaults to 'md'."),
1151
+ options: z5.array(z5.string().min(1)).optional().describe(
1053
1152
  "Voting options. `proposal` has built-in agree/disagree/abstain options; for poll / count / score / ranked_choice / meeting / dot_vote you MUST supply your own."
1054
1153
  ),
1055
- closing_at: z4.string().optional().describe("ISO-8601 timestamp at which the poll closes."),
1056
- specified_voters_only: z4.boolean().optional().describe("If true, only users in recipient_user_ids / recipient_emails can vote."),
1057
- hide_results: z4.enum(["off", "until_vote", "until_closed"]).optional().describe("Results visibility policy. Defaults to 'off'."),
1058
- shuffle_options: z4.boolean().optional().describe("If true, shuffle option display order."),
1059
- anonymous: z4.boolean().optional().describe("If true, hide voter identities."),
1060
- recipient_audience: z4.enum(["group"]).optional(),
1061
- notify_on_closing_soon: z4.enum(["nobody", "author", "voters", "undecided_voters", "all_members"]).optional().describe("Who Loomio notifies as the closing date approaches. Defaults to 'nobody'."),
1062
- recipient_user_ids: z4.array(positiveId).optional(),
1063
- recipient_emails: z4.array(z4.string().email()).optional(),
1064
- recipient_message: z4.string().optional(),
1065
- notify_recipients: z4.boolean().optional().describe("If false, suppress the initial notification email. Defaults to false.")
1154
+ closing_at: z5.string().optional().describe("ISO-8601 timestamp at which the poll closes."),
1155
+ specified_voters_only: z5.boolean().optional().describe("If true, only users in recipient_user_ids / recipient_emails can vote."),
1156
+ hide_results: z5.enum(["off", "until_vote", "until_closed"]).optional().describe("Results visibility policy. Defaults to 'off'."),
1157
+ shuffle_options: z5.boolean().optional().describe("If true, shuffle option display order."),
1158
+ anonymous: z5.boolean().optional().describe("If true, hide voter identities."),
1159
+ recipient_audience: z5.enum(["group"]).optional(),
1160
+ notify_on_closing_soon: z5.enum(["nobody", "author", "voters", "undecided_voters", "all_members"]).optional().describe("Who Loomio notifies as the closing date approaches. Defaults to 'nobody'."),
1161
+ recipient_user_ids: z5.array(positiveId).optional(),
1162
+ recipient_emails: z5.array(z5.string().email()).optional(),
1163
+ recipient_message: z5.string().optional(),
1164
+ notify_recipients: z5.boolean().optional().describe("If false, suppress the initial notification email. Defaults to false.")
1066
1165
  }).superRefine((input, ctx) => {
1067
1166
  if (input.group_id === void 0 && input.discussion_id === void 0) {
1068
1167
  ctx.addIssue({
@@ -1084,7 +1183,7 @@ async function createPoll(input) {
1084
1183
  }
1085
1184
 
1086
1185
  // src/tools/memberships.ts
1087
- import { z as z5 } from "zod";
1186
+ import { z as z6 } from "zod";
1088
1187
 
1089
1188
  // src/loomio/access.ts
1090
1189
  async function classifyGroupForbidden(groupId) {
@@ -1129,12 +1228,12 @@ async function explainForbidden(groupId, original, resource, fallback) {
1129
1228
  return original;
1130
1229
  }
1131
1230
  }
1132
- var listMembershipsSchema = z5.object({
1231
+ var listMembershipsSchema = z6.object({
1133
1232
  group_id: positiveId.describe(
1134
1233
  "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."
1135
1234
  ),
1136
- limit: z5.number().int().min(1).max(200).optional().describe("Page size. Loomio defaults to 50."),
1137
- offset: z5.number().int().min(0).optional().describe("Page offset. Defaults to 0.")
1235
+ limit: z6.number().int().min(1).max(200).optional().describe("Page size. Loomio defaults to 50."),
1236
+ offset: z6.number().int().min(0).optional().describe("Page offset. Defaults to 0.")
1138
1237
  });
1139
1238
  async function listMemberships(input) {
1140
1239
  try {
@@ -1155,14 +1254,14 @@ async function listMemberships(input) {
1155
1254
  throw err;
1156
1255
  }
1157
1256
  }
1158
- var manageMembershipsSchema = z5.object({
1257
+ var manageMembershipsSchema = z6.object({
1159
1258
  group_id: positiveId.describe(
1160
1259
  "ID of the Loomio group to modify (required). Caller must be a group admin."
1161
1260
  ),
1162
- emails: z5.array(z5.string().email()).min(1).describe(
1261
+ emails: z6.array(z6.string().email()).min(1).describe(
1163
1262
  "Email addresses to ensure are members. Each address that isn't already a member is invited / added."
1164
1263
  ),
1165
- remove_absent: z5.boolean().optional().describe(
1264
+ remove_absent: z6.boolean().optional().describe(
1166
1265
  "DANGEROUS. When true, Loomio REMOVES every existing member whose email is NOT in `emails`. Empty-emails (after dedupe) effectively removes the entire group. Default false. Only set true after reading list_memberships and confirming the diff with a human."
1167
1266
  )
1168
1267
  });
@@ -1178,18 +1277,18 @@ async function manageMemberships(input) {
1178
1277
  }
1179
1278
 
1180
1279
  // src/tools/groups.ts
1181
- import { z as z6 } from "zod";
1280
+ import { z as z7 } from "zod";
1182
1281
  var DEFAULT_START_ID = 1;
1183
1282
  var DEFAULT_END_ID = 200;
1184
1283
  var MAX_END_ID = 1e4;
1185
1284
  var MAX_PROBE_SPAN = 500;
1186
1285
  var CONCURRENCY = 5;
1187
- var listGroupsSchema = z6.object({
1188
- start_id: z6.number().int().min(1).optional().describe("First group_id to probe (inclusive). Defaults to 1."),
1189
- end_id: z6.number().int().min(1).max(MAX_END_ID).optional().describe(
1286
+ var listGroupsSchema = z7.object({
1287
+ start_id: z7.number().int().min(1).optional().describe("First group_id to probe (inclusive). Defaults to 1."),
1288
+ end_id: z7.number().int().min(1).max(MAX_END_ID).optional().describe(
1190
1289
  "Last group_id to probe (inclusive). Defaults to 200. A single call may scan at most 500 ids; use multiple calls for wider ranges."
1191
1290
  ),
1192
- stop_after_consecutive_misses: z6.number().int().min(1).max(MAX_PROBE_SPAN).optional().describe(
1291
+ stop_after_consecutive_misses: z7.number().int().min(1).max(MAX_PROBE_SPAN).optional().describe(
1193
1292
  "Early-exit heuristic: stop probing after this many consecutive 404/403 misses. Saves wall time on sparse id ranges. Defaults to 50."
1194
1293
  )
1195
1294
  }).superRefine((input, ctx) => {
@@ -1284,23 +1383,23 @@ async function listGroups(input) {
1284
1383
  }
1285
1384
 
1286
1385
  // src/tools/events.ts
1287
- import { z as z7 } from "zod";
1386
+ import { z as z8 } from "zod";
1288
1387
  var CONCURRENCY2 = 6;
1289
1388
  var EVENTS_PAGE_SIZE = 200;
1290
1389
  var MAX_EVENTS_PAGES = 10;
1291
1390
  var MAX_DISCUSSION_PAGES = 20;
1292
1391
  var MAX_SCAN_DISCUSSIONS = 500;
1293
- var listEventsSchema = z7.object({
1392
+ var listEventsSchema = z8.object({
1294
1393
  discussion_id: positiveId.describe(
1295
1394
  "ID of the discussion whose event stream to fetch. Required \u2014 Loomio's v1/events endpoint silently returns empty without it."
1296
1395
  ),
1297
- limit: z7.number().int().min(1).max(200).optional().describe(
1396
+ limit: z8.number().int().min(1).max(200).optional().describe(
1298
1397
  "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
1398
  ),
1300
- offset: z7.number().int().min(0).optional().describe(
1399
+ offset: z8.number().int().min(0).optional().describe(
1301
1400
  "Page offset (Loomio's `from` parameter). When supplied, list_events returns exactly that page instead of auto-paginating."
1302
1401
  ),
1303
- kinds: z7.array(z7.string().min(1)).optional().describe(
1402
+ kinds: z8.array(z8.string().min(1)).optional().describe(
1304
1403
  "Optional filter to only these event kinds (applied client-side after fetch \u2014 Loomio doesn't filter `kind` server-side). Common kinds: new_discussion, new_comment, comment_edited, poll_created, stance_created, outcome_created, discussion_moved, discussion_closed, reaction."
1305
1404
  )
1306
1405
  });
@@ -1368,9 +1467,9 @@ var ACTIVITY_KINDS = /* @__PURE__ */ new Set([
1368
1467
  "outcome_created",
1369
1468
  "reaction"
1370
1469
  ]);
1371
- var getUserActivitySchema = z7.object({
1470
+ var getUserActivitySchema = z8.object({
1372
1471
  user_id: positiveId.describe("Loomio user id whose activity to summarise."),
1373
- group_ids: z7.array(positiveId).min(1).max(50).describe(
1472
+ group_ids: z8.array(positiveId).min(1).max(50).describe(
1374
1473
  "Groups to scan. Required \u2014 pass the result of `list_groups` (or a subset of it) to make the cost explicit. ~1 outbound HTTP request per discussion in scope, plus one list_discussions call per group; ~100-300 calls is typical for a wide scan. Capped at 50 groups per call."
1375
1474
  ),
1376
1475
  since: isoTimestamp.describe(
@@ -1563,11 +1662,11 @@ async function getUserActivity(input) {
1563
1662
  }
1564
1663
 
1565
1664
  // src/tools/comments.ts
1566
- import { z as z8 } from "zod";
1567
- var createCommentSchema = z8.object({
1665
+ import { z as z9 } from "zod";
1666
+ var createCommentSchema = z9.object({
1568
1667
  discussion_id: positiveId.describe("ID of the discussion to comment on."),
1569
- body: z8.string().min(1).describe("Comment body (required)."),
1570
- body_format: z8.enum(["md", "html"]).optional().describe("Format of `body`. Defaults to Loomio's group default when omitted.")
1668
+ body: z9.string().min(1).describe("Comment body (required)."),
1669
+ body_format: z9.enum(["md", "html"]).optional().describe("Format of `body`. Defaults to Loomio's group default when omitted.")
1571
1670
  });
1572
1671
  async function createComment(input) {
1573
1672
  const { discussion_id, ...rest } = input;
@@ -1578,14 +1677,14 @@ async function createComment(input) {
1578
1677
  }
1579
1678
 
1580
1679
  // src/tools/admin.ts
1581
- import { z as z9 } from "zod";
1582
- var deactivateUserSchema = z9.object({
1680
+ import { z as z10 } from "zod";
1681
+ var deactivateUserSchema = z10.object({
1583
1682
  id: positiveId.describe("Loomio user id to deactivate. Returns 404 if not active.")
1584
1683
  });
1585
1684
  async function deactivateUser(input) {
1586
1685
  return loomioPostB3("/b3/users/deactivate", { id: input.id });
1587
1686
  }
1588
- var reactivateUserSchema = z9.object({
1687
+ var reactivateUserSchema = z10.object({
1589
1688
  id: positiveId.describe(
1590
1689
  "Loomio user id to reactivate. Returns 404 if not currently deactivated."
1591
1690
  )
@@ -1601,7 +1700,7 @@ function createLoomioMcpServer() {
1601
1700
  const server = new McpServer({
1602
1701
  name: "loomiomcp",
1603
1702
  // Keep in sync with package.json on each release.
1604
- version: "0.0.5",
1703
+ version: "0.0.8",
1605
1704
  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.",
1606
1705
  websiteUrl: "https://github.com/soil-dev/loomiomcp",
1607
1706
  icons: ICONS
@@ -1933,7 +2032,10 @@ var oauthProvider = mode.kind === "static-client" ? new OAuthProvider({
1933
2032
  signingKey,
1934
2033
  resourceUrl: mcpResourceUrl
1935
2034
  }) : new OAuthProvider({
1936
- clientsStore: new InMemoryClientsStore(),
2035
+ // Stateless open-DCR store: registered clients survive restarts,
2036
+ // scale-to-zero, redeploys, and multi-instance routing, so callers
2037
+ // aren't forced to re-authenticate when the process recycles.
2038
+ clientsStore: new StatelessClientsStore(signingKey),
1937
2039
  signingKey,
1938
2040
  resourceUrl: mcpResourceUrl
1939
2041
  });
package/dist/index.js CHANGED
@@ -1049,7 +1049,7 @@ function createLoomioMcpServer() {
1049
1049
  const server2 = new McpServer({
1050
1050
  name: "loomiomcp",
1051
1051
  // Keep in sync with package.json on each release.
1052
- version: "0.0.5",
1052
+ version: "0.0.8",
1053
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.",
1054
1054
  websiteUrl: "https://github.com/soil-dev/loomiomcp",
1055
1055
  icons: ICONS
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loomiomcp",
3
- "version": "0.0.5",
3
+ "version": "0.0.8",
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 comments, manage group memberships, and analyse member activity in plain English.",
5
5
  "keywords": [
6
6
  "mcp",