loomiomcp 0.0.6 → 0.0.9

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
@@ -13,7 +13,7 @@ analyse member activity — in plain English. Targets Loomio's **b2** API
13
13
  [/help/api2](https://www.loomio.com/help/api2) and the namespace where
14
14
  the controllers actually live in the open-source repo.
15
15
 
16
- Tools (b2, per-user `?api_key=`):
16
+ Tools (b2, per-user API key):
17
17
 
18
18
  - `get_discussion(id_or_key)` — fetch one discussion
19
19
  - `list_discussions(group_id, status?, limit?, offset?)` — list a group's discussions
@@ -45,7 +45,7 @@ Tools (b2, per-user `?api_key=`):
45
45
  `remove_absent`.
46
46
  - `create_comment(discussion_id, body, body_format?)` — reply on a discussion
47
47
 
48
- Opt-in admin tools (b3, server-instance secret `?b3_api_key=`):
48
+ Opt-in admin tools (b3, server-instance secret):
49
49
 
50
50
  Set `LOOMIO_B3_API_KEY` to enable. Only useful for Loomio instance operators.
51
51
 
@@ -67,13 +67,23 @@ See DEPLOY.md for Cloud Run.
67
67
 
68
68
  ## Auth
69
69
 
70
- Loomio's b2 API authenticates by API key passed as a `?api_key=…` query
71
- parameter. The connector injects it server-side; it never reaches the
72
- MCP client. Generate one in Loomio under your profile → API keys.
70
+ Loomio authenticates by API key sent in an HTTP bearer header:
73
71
 
74
- The optional b3 admin namespace uses a different secret (`?b3_api_key=…`,
75
- validated against `ENV['B3_API_KEY']` on the Loomio server, >16 chars).
76
- Only relevant if you operate a Loomio instance.
72
+ ```text
73
+ Authorization: Bearer <API_KEY>
74
+ ```
75
+
76
+ The connector injects it server-side; it never reaches the MCP client.
77
+ Generate one in Loomio under your profile → API keys.
78
+
79
+ Keys passed in the query string (`?api_key=…`) are **rejected** — Loomio
80
+ removed that scheme in July 2026 because URLs are retained in browser
81
+ history, proxy logs, and monitoring systems. A request carrying its key
82
+ that way is treated as unauthenticated and 403s.
83
+
84
+ The optional b3 admin namespace uses the same bearer header with a
85
+ different secret (validated against `ENV['B3_API_KEY']` on the Loomio
86
+ server, >16 chars). Only relevant if you operate a Loomio instance.
77
87
 
78
88
  ## Read-only mode
79
89
 
package/dist/http.js CHANGED
@@ -213,9 +213,9 @@ async function handleResponse(res) {
213
213
  throw err;
214
214
  }
215
215
  }
216
- var B2_AUTH = { field: "api_key", getValue: getApiKey };
217
- var B3_AUTH = { field: "b3_api_key", getValue: getB3ApiKey };
218
- function buildUrl(auth, path, params) {
216
+ var B2_AUTH = getApiKey;
217
+ var B3_AUTH = getB3ApiKey;
218
+ function buildUrl(path, params) {
219
219
  const url = new URL(`${baseUrl()}${path}`);
220
220
  if (params) {
221
221
  for (const [key, value] of Object.entries(params)) {
@@ -224,9 +224,14 @@ function buildUrl(auth, path, params) {
224
224
  }
225
225
  }
226
226
  }
227
- url.searchParams.set(auth.field, auth.getValue());
228
227
  return url.toString();
229
228
  }
229
+ function authHeaders(auth) {
230
+ return {
231
+ ...baseHeaders(),
232
+ Authorization: `Bearer ${auth()}`
233
+ };
234
+ }
230
235
  async function doFetch(url, options) {
231
236
  const startedAt = Date.now();
232
237
  const method = options?.method ?? "GET";
@@ -258,8 +263,8 @@ function emitLoomioRequest(method, url, res, durationMs) {
258
263
  });
259
264
  }
260
265
  async function loomioGet(path, params) {
261
- const url = buildUrl(B2_AUTH, path, params);
262
- const start = await doFetch(url, { headers: baseHeaders() });
266
+ const url = buildUrl(path, params);
267
+ const start = await doFetch(url, { headers: authHeaders(B2_AUTH) });
263
268
  try {
264
269
  return await consumeBody(start, () => handleResponse(start.res));
265
270
  } finally {
@@ -267,8 +272,8 @@ async function loomioGet(path, params) {
267
272
  }
268
273
  }
269
274
  async function loomioGetStatus(path, params) {
270
- const url = buildUrl(B2_AUTH, path, params);
271
- const start = await doFetch(url, { headers: baseHeaders() });
275
+ const url = buildUrl(path, params);
276
+ const start = await doFetch(url, { headers: authHeaders(B2_AUTH) });
272
277
  try {
273
278
  return await consumeBody(start, async () => {
274
279
  try {
@@ -295,12 +300,12 @@ function encodeForm(body) {
295
300
  }
296
301
  async function loomioPost(path, body, opts = {}) {
297
302
  if (isReadOnly()) throw new LoomioReadOnlyError("POST");
298
- const url = buildUrl(B2_AUTH, path, opts.params);
303
+ const url = buildUrl(path, opts.params);
299
304
  const encoding = opts.encoding ?? "json";
300
305
  const start = await doFetch(url, {
301
306
  method: "POST",
302
307
  headers: {
303
- ...baseHeaders(),
308
+ ...authHeaders(B2_AUTH),
304
309
  "Content-Type": encoding === "form" ? "application/x-www-form-urlencoded" : "application/json"
305
310
  },
306
311
  body: encoding === "form" ? encodeForm(body) : JSON.stringify(body)
@@ -313,10 +318,10 @@ async function loomioPost(path, body, opts = {}) {
313
318
  }
314
319
  async function loomioPostB3(path, params) {
315
320
  if (isReadOnly()) throw new LoomioReadOnlyError("POST");
316
- const url = buildUrl(B3_AUTH, path, params);
321
+ const url = buildUrl(path, params);
317
322
  const start = await doFetch(url, {
318
323
  method: "POST",
319
- headers: { ...baseHeaders(), "Content-Type": "application/json" },
324
+ headers: { ...authHeaders(B3_AUTH), "Content-Type": "application/json" },
320
325
  body: "{}"
321
326
  });
322
327
  try {
@@ -327,7 +332,8 @@ async function loomioPostB3(path, params) {
327
332
  }
328
333
 
329
334
  // src/auth/provider.ts
330
- import { createHash, randomBytes, randomUUID, timingSafeEqual as timingSafeEqual2 } from "crypto";
335
+ import { createHash, createHmac as createHmac2, randomBytes, randomUUID, timingSafeEqual as timingSafeEqual2 } from "crypto";
336
+ import { z as z2 } from "zod";
331
337
  import {
332
338
  InvalidGrantError,
333
339
  InvalidTargetError,
@@ -376,6 +382,28 @@ var TokenExpiredError = class extends Error {
376
382
  this.name = "TokenExpiredError";
377
383
  }
378
384
  };
385
+ function signData(obj, signingKey2) {
386
+ const payloadB64 = b64urlEncode(Buffer.from(JSON.stringify(obj), "utf8"));
387
+ return `${payloadB64}.${sign(payloadB64, signingKey2)}`;
388
+ }
389
+ function verifyData(blob, signingKey2) {
390
+ const parts = blob.split(".");
391
+ if (parts.length !== 2) {
392
+ throw new TokenSignatureError("malformed signed blob");
393
+ }
394
+ const [payloadB64, providedSig] = parts;
395
+ const expectedSig = sign(payloadB64, signingKey2);
396
+ const a = Buffer.from(providedSig);
397
+ const b = Buffer.from(expectedSig);
398
+ if (a.length !== b.length || !timingSafeEqual(a, b)) {
399
+ throw new TokenSignatureError("invalid signature");
400
+ }
401
+ try {
402
+ return JSON.parse(b64urlDecode(payloadB64).toString("utf8"));
403
+ } catch {
404
+ throw new TokenSignatureError("malformed payload");
405
+ }
406
+ }
379
407
  function verifyToken(token, signingKey2) {
380
408
  const parts = token.split(".");
381
409
  if (parts.length !== 2) {
@@ -411,20 +439,96 @@ var REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
411
439
  var AUTH_CODE_TTL_MS = 5 * 60 * 1e3;
412
440
  var AUTH_CODE_MAX_ENTRIES = 1e4;
413
441
  var AUTH_CODE_GC_INTERVAL_MS = 60 * 1e3;
414
- var InMemoryClientsStore = class {
415
- clients = /* @__PURE__ */ new Map();
442
+ var StatelessClientClaimsSchema = z2.object({
443
+ k: z2.literal("client"),
444
+ redirect_uris: z2.array(z2.string()).min(1),
445
+ grant_types: z2.array(z2.string()).optional(),
446
+ response_types: z2.array(z2.string()).optional(),
447
+ token_endpoint_auth_method: z2.string().optional(),
448
+ scope: z2.string().optional(),
449
+ client_name: z2.string().optional(),
450
+ iat: z2.number().int()
451
+ });
452
+ var DEFAULT_GRANT_TYPES = ["authorization_code", "refresh_token"];
453
+ var DEFAULT_RESPONSE_TYPES = ["code"];
454
+ var DEFAULT_AUTH_METHOD = "client_secret_post";
455
+ var MAX_SIGNED_CLIENT_ID_BYTES = 16384;
456
+ var StatelessClientsStore = class {
457
+ signingKey;
458
+ constructor(signingKey2) {
459
+ if (!signingKey2 || signingKey2.length < 16) {
460
+ throw new Error("StatelessClientsStore: signing key must be at least 16 chars");
461
+ }
462
+ this.signingKey = signingKey2;
463
+ }
464
+ // Deterministic per-client secret for confidential clients: any instance
465
+ // recomputes the same value, so client_secret_post auth works without
466
+ // storing the secret.
467
+ deriveSecret(clientId) {
468
+ return createHmac2("sha256", this.signingKey).update(`client_secret:${clientId}`).digest("base64url");
469
+ }
470
+ reconstruct(clientId, c) {
471
+ const authMethod = c.token_endpoint_auth_method ?? DEFAULT_AUTH_METHOD;
472
+ const isPublicClient = authMethod === "none";
473
+ return {
474
+ client_id: clientId,
475
+ client_id_issued_at: c.iat,
476
+ redirect_uris: c.redirect_uris,
477
+ grant_types: c.grant_types ?? DEFAULT_GRANT_TYPES,
478
+ response_types: c.response_types ?? DEFAULT_RESPONSE_TYPES,
479
+ token_endpoint_auth_method: authMethod,
480
+ ...isPublicClient ? {} : {
481
+ client_secret: this.deriveSecret(clientId),
482
+ client_secret_expires_at: 0
483
+ // never expires — there's nothing to rotate per-client
484
+ },
485
+ ...c.scope ? { scope: c.scope } : {},
486
+ ...c.client_name ? { client_name: c.client_name } : {}
487
+ };
488
+ }
416
489
  getClient(clientId) {
417
- return this.clients.get(clientId);
490
+ if (Buffer.byteLength(clientId, "utf8") > MAX_SIGNED_CLIENT_ID_BYTES) return void 0;
491
+ let raw;
492
+ try {
493
+ raw = verifyData(clientId, this.signingKey);
494
+ } catch {
495
+ return void 0;
496
+ }
497
+ const parsed = StatelessClientClaimsSchema.safeParse(raw);
498
+ if (!parsed.success) return void 0;
499
+ return this.reconstruct(clientId, parsed.data);
418
500
  }
419
501
  registerClient(client) {
420
- const clientId = randomUUID();
421
- const full = {
502
+ const iat = Math.floor(Date.now() / 1e3);
503
+ const redirectUris = client.redirect_uris ?? [];
504
+ const authMethod = client.token_endpoint_auth_method ?? DEFAULT_AUTH_METHOD;
505
+ const isPublicClient = authMethod === "none";
506
+ const clientId = signData(
507
+ {
508
+ k: "client",
509
+ redirect_uris: redirectUris,
510
+ grant_types: client.grant_types ?? DEFAULT_GRANT_TYPES,
511
+ response_types: client.response_types ?? DEFAULT_RESPONSE_TYPES,
512
+ token_endpoint_auth_method: authMethod,
513
+ ...client.scope ? { scope: client.scope } : {},
514
+ ...client.client_name ? { client_name: client.client_name } : {},
515
+ iat
516
+ },
517
+ this.signingKey
518
+ );
519
+ return {
422
520
  ...client,
423
521
  client_id: clientId,
424
- client_id_issued_at: Math.floor(Date.now() / 1e3)
522
+ client_id_issued_at: iat,
523
+ redirect_uris: redirectUris,
524
+ grant_types: client.grant_types ?? DEFAULT_GRANT_TYPES,
525
+ response_types: client.response_types ?? DEFAULT_RESPONSE_TYPES,
526
+ token_endpoint_auth_method: authMethod,
527
+ ...isPublicClient ? {} : {
528
+ client_secret: this.deriveSecret(clientId),
529
+ client_secret_expires_at: 0
530
+ }
425
531
  };
426
- this.clients.set(clientId, full);
427
- return full;
428
532
  }
429
533
  };
430
534
  var FixedClientStore = class {
@@ -931,14 +1035,14 @@ function registerTool(server, name, description, schema, handler) {
931
1035
  }
932
1036
 
933
1037
  // src/tools/discussions.ts
934
- import { z as z3 } from "zod";
1038
+ import { z as z4 } from "zod";
935
1039
 
936
1040
  // 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)), {
1041
+ import { z as z3 } from "zod";
1042
+ var positiveId = z3.number().int().positive();
1043
+ var loomioKey = z3.string().regex(/^[A-Za-z0-9_-]+$/, "Loomio short keys may only contain letters, numbers, _ and -.");
1044
+ var idOrKey = z3.union([loomioKey, positiveId]);
1045
+ var isoTimestamp = z3.string().refine((v) => !Number.isNaN(Date.parse(v)), {
942
1046
  message: "must be a parseable ISO-8601 timestamp (e.g. 2026-01-31 or 2026-01-31T00:00:00Z)."
943
1047
  }).optional();
944
1048
  function encodePathSegment(value) {
@@ -948,7 +1052,7 @@ function encodePathSegment(value) {
948
1052
  }
949
1053
  return encodeURIComponent(raw);
950
1054
  }
951
- var PollTypeEnum = z2.enum([
1055
+ var PollTypeEnum = z3.enum([
952
1056
  "proposal",
953
1057
  "poll",
954
1058
  "count",
@@ -959,7 +1063,7 @@ var PollTypeEnum = z2.enum([
959
1063
  ]);
960
1064
 
961
1065
  // src/tools/discussions.ts
962
- var getDiscussionSchema = z3.object({
1066
+ var getDiscussionSchema = z4.object({
963
1067
  id_or_key: idOrKey.describe(
964
1068
  "Discussion id (numeric) or short string key (e.g. 'abcDEF12'). Loomio accepts either."
965
1069
  )
@@ -967,13 +1071,13 @@ var getDiscussionSchema = z3.object({
967
1071
  async function getDiscussion(input) {
968
1072
  return loomioGet(`/b2/discussions/${encodePathSegment(input.id_or_key)}`);
969
1073
  }
970
- var listDiscussionsSchema = z3.object({
1074
+ var listDiscussionsSchema = z4.object({
971
1075
  group_id: positiveId.describe("ID of the Loomio group whose discussions to list (required)."),
972
- status: z3.enum(["open", "closed", "all"]).optional().describe(
1076
+ status: z4.enum(["open", "closed", "all"]).optional().describe(
973
1077
  "Filter by status. 'open' = unlocked, 'closed' = locked, 'all' = every kept discussion. Loomio defaults to 'open'."
974
1078
  ),
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.")
1079
+ limit: z4.number().int().min(1).max(200).optional().describe("Page size. Loomio defaults to 50."),
1080
+ offset: z4.number().int().min(0).optional().describe("Page offset. Defaults to 0.")
977
1081
  });
978
1082
  async function listDiscussions(input) {
979
1083
  return loomioGet("/b2/discussions", {
@@ -983,18 +1087,18 @@ async function listDiscussions(input) {
983
1087
  ...input.offset !== void 0 ? { offset: input.offset } : {}
984
1088
  });
985
1089
  }
986
- var createDiscussionSchema = z3.object({
987
- title: z3.string().min(1).describe("Discussion title (required)."),
1090
+ var createDiscussionSchema = z4.object({
1091
+ title: z4.string().min(1).describe("Discussion title (required)."),
988
1092
  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(
1093
+ description: z4.string().optional().describe("Optional discussion body."),
1094
+ description_format: z4.enum(["md", "html"]).optional().describe("Format of `description`. Defaults to Loomio's group default when omitted."),
1095
+ private: z4.boolean().optional().describe(
992
1096
  "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
1097
  ),
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.")
1098
+ recipient_audience: z4.enum(["group"]).optional().describe("Audience selector for notification recipients. 'group' = notify whole group."),
1099
+ recipient_user_ids: z4.array(positiveId).optional().describe("Explicit list of user IDs to notify."),
1100
+ recipient_emails: z4.array(z4.string().email()).optional().describe("Explicit list of email addresses to notify."),
1101
+ recipient_message: z4.string().optional().describe("Optional custom message to include in notifications.")
998
1102
  });
999
1103
  async function resolveDiscussionPrivate(groupId) {
1000
1104
  try {
@@ -1015,18 +1119,18 @@ async function createDiscussion(input) {
1015
1119
  }
1016
1120
 
1017
1121
  // src/tools/polls.ts
1018
- import { z as z4 } from "zod";
1019
- var getPollSchema = z4.object({
1122
+ import { z as z5 } from "zod";
1123
+ var getPollSchema = z5.object({
1020
1124
  id_or_key: idOrKey.describe("Poll id (numeric) or short string key.")
1021
1125
  });
1022
1126
  async function getPoll(input) {
1023
1127
  return loomioGet(`/b2/polls/${encodePathSegment(input.id_or_key)}`);
1024
1128
  }
1025
- var listPollsSchema = z4.object({
1129
+ var listPollsSchema = z5.object({
1026
1130
  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.")
1131
+ status: z5.enum(["active", "closed", "all"]).optional().describe("Filter polls by status. Loomio defaults to 'active'."),
1132
+ limit: z5.number().int().min(1).max(200).optional().describe("Page size. Loomio defaults to 50."),
1133
+ offset: z5.number().int().min(0).optional().describe("Page offset. Defaults to 0.")
1030
1134
  });
1031
1135
  async function listPolls(input) {
1032
1136
  return loomioGet("/b2/polls", {
@@ -1036,8 +1140,8 @@ async function listPolls(input) {
1036
1140
  ...input.offset !== void 0 ? { offset: input.offset } : {}
1037
1141
  });
1038
1142
  }
1039
- var createPollSchema = z4.object({
1040
- title: z4.string().min(1).describe("Poll title (required)."),
1143
+ var createPollSchema = z5.object({
1144
+ title: z5.string().min(1).describe("Poll title (required)."),
1041
1145
  poll_type: PollTypeEnum.describe(
1042
1146
  "Poll type. One of: proposal, poll, count, score, ranked_choice, meeting, dot_vote."
1043
1147
  ),
@@ -1047,22 +1151,22 @@ var createPollSchema = z4.object({
1047
1151
  discussion_id: positiveId.optional().describe(
1048
1152
  "Attach the poll to an existing discussion. When set, group_id is taken from the discussion."
1049
1153
  ),
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(
1154
+ details: z5.string().optional().describe("Optional poll body / context."),
1155
+ details_format: z5.enum(["md", "html"]).optional().describe("Format of `details`. Defaults to 'md'."),
1156
+ options: z5.array(z5.string().min(1)).optional().describe(
1053
1157
  "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
1158
  ),
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.")
1159
+ closing_at: z5.string().optional().describe("ISO-8601 timestamp at which the poll closes."),
1160
+ specified_voters_only: z5.boolean().optional().describe("If true, only users in recipient_user_ids / recipient_emails can vote."),
1161
+ hide_results: z5.enum(["off", "until_vote", "until_closed"]).optional().describe("Results visibility policy. Defaults to 'off'."),
1162
+ shuffle_options: z5.boolean().optional().describe("If true, shuffle option display order."),
1163
+ anonymous: z5.boolean().optional().describe("If true, hide voter identities."),
1164
+ recipient_audience: z5.enum(["group"]).optional(),
1165
+ 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'."),
1166
+ recipient_user_ids: z5.array(positiveId).optional(),
1167
+ recipient_emails: z5.array(z5.string().email()).optional(),
1168
+ recipient_message: z5.string().optional(),
1169
+ notify_recipients: z5.boolean().optional().describe("If false, suppress the initial notification email. Defaults to false.")
1066
1170
  }).superRefine((input, ctx) => {
1067
1171
  if (input.group_id === void 0 && input.discussion_id === void 0) {
1068
1172
  ctx.addIssue({
@@ -1084,7 +1188,7 @@ async function createPoll(input) {
1084
1188
  }
1085
1189
 
1086
1190
  // src/tools/memberships.ts
1087
- import { z as z5 } from "zod";
1191
+ import { z as z6 } from "zod";
1088
1192
 
1089
1193
  // src/loomio/access.ts
1090
1194
  async function classifyGroupForbidden(groupId) {
@@ -1129,12 +1233,12 @@ async function explainForbidden(groupId, original, resource, fallback) {
1129
1233
  return original;
1130
1234
  }
1131
1235
  }
1132
- var listMembershipsSchema = z5.object({
1236
+ var listMembershipsSchema = z6.object({
1133
1237
  group_id: positiveId.describe(
1134
1238
  "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
1239
  ),
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.")
1240
+ limit: z6.number().int().min(1).max(200).optional().describe("Page size. Loomio defaults to 50."),
1241
+ offset: z6.number().int().min(0).optional().describe("Page offset. Defaults to 0.")
1138
1242
  });
1139
1243
  async function listMemberships(input) {
1140
1244
  try {
@@ -1155,14 +1259,14 @@ async function listMemberships(input) {
1155
1259
  throw err;
1156
1260
  }
1157
1261
  }
1158
- var manageMembershipsSchema = z5.object({
1262
+ var manageMembershipsSchema = z6.object({
1159
1263
  group_id: positiveId.describe(
1160
1264
  "ID of the Loomio group to modify (required). Caller must be a group admin."
1161
1265
  ),
1162
- emails: z5.array(z5.string().email()).min(1).describe(
1266
+ emails: z6.array(z6.string().email()).min(1).describe(
1163
1267
  "Email addresses to ensure are members. Each address that isn't already a member is invited / added."
1164
1268
  ),
1165
- remove_absent: z5.boolean().optional().describe(
1269
+ remove_absent: z6.boolean().optional().describe(
1166
1270
  "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
1271
  )
1168
1272
  });
@@ -1178,18 +1282,18 @@ async function manageMemberships(input) {
1178
1282
  }
1179
1283
 
1180
1284
  // src/tools/groups.ts
1181
- import { z as z6 } from "zod";
1285
+ import { z as z7 } from "zod";
1182
1286
  var DEFAULT_START_ID = 1;
1183
1287
  var DEFAULT_END_ID = 200;
1184
1288
  var MAX_END_ID = 1e4;
1185
1289
  var MAX_PROBE_SPAN = 500;
1186
1290
  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(
1291
+ var listGroupsSchema = z7.object({
1292
+ start_id: z7.number().int().min(1).optional().describe("First group_id to probe (inclusive). Defaults to 1."),
1293
+ end_id: z7.number().int().min(1).max(MAX_END_ID).optional().describe(
1190
1294
  "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
1295
  ),
1192
- stop_after_consecutive_misses: z6.number().int().min(1).max(MAX_PROBE_SPAN).optional().describe(
1296
+ stop_after_consecutive_misses: z7.number().int().min(1).max(MAX_PROBE_SPAN).optional().describe(
1193
1297
  "Early-exit heuristic: stop probing after this many consecutive 404/403 misses. Saves wall time on sparse id ranges. Defaults to 50."
1194
1298
  )
1195
1299
  }).superRefine((input, ctx) => {
@@ -1284,23 +1388,23 @@ async function listGroups(input) {
1284
1388
  }
1285
1389
 
1286
1390
  // src/tools/events.ts
1287
- import { z as z7 } from "zod";
1391
+ import { z as z8 } from "zod";
1288
1392
  var CONCURRENCY2 = 6;
1289
1393
  var EVENTS_PAGE_SIZE = 200;
1290
1394
  var MAX_EVENTS_PAGES = 10;
1291
1395
  var MAX_DISCUSSION_PAGES = 20;
1292
1396
  var MAX_SCAN_DISCUSSIONS = 500;
1293
- var listEventsSchema = z7.object({
1397
+ var listEventsSchema = z8.object({
1294
1398
  discussion_id: positiveId.describe(
1295
1399
  "ID of the discussion whose event stream to fetch. Required \u2014 Loomio's v1/events endpoint silently returns empty without it."
1296
1400
  ),
1297
- limit: z7.number().int().min(1).max(200).optional().describe(
1401
+ limit: z8.number().int().min(1).max(200).optional().describe(
1298
1402
  "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
1403
  ),
1300
- offset: z7.number().int().min(0).optional().describe(
1404
+ offset: z8.number().int().min(0).optional().describe(
1301
1405
  "Page offset (Loomio's `from` parameter). When supplied, list_events returns exactly that page instead of auto-paginating."
1302
1406
  ),
1303
- kinds: z7.array(z7.string().min(1)).optional().describe(
1407
+ kinds: z8.array(z8.string().min(1)).optional().describe(
1304
1408
  "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
1409
  )
1306
1410
  });
@@ -1368,9 +1472,9 @@ var ACTIVITY_KINDS = /* @__PURE__ */ new Set([
1368
1472
  "outcome_created",
1369
1473
  "reaction"
1370
1474
  ]);
1371
- var getUserActivitySchema = z7.object({
1475
+ var getUserActivitySchema = z8.object({
1372
1476
  user_id: positiveId.describe("Loomio user id whose activity to summarise."),
1373
- group_ids: z7.array(positiveId).min(1).max(50).describe(
1477
+ group_ids: z8.array(positiveId).min(1).max(50).describe(
1374
1478
  "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
1479
  ),
1376
1480
  since: isoTimestamp.describe(
@@ -1563,11 +1667,11 @@ async function getUserActivity(input) {
1563
1667
  }
1564
1668
 
1565
1669
  // src/tools/comments.ts
1566
- import { z as z8 } from "zod";
1567
- var createCommentSchema = z8.object({
1670
+ import { z as z9 } from "zod";
1671
+ var createCommentSchema = z9.object({
1568
1672
  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.")
1673
+ body: z9.string().min(1).describe("Comment body (required)."),
1674
+ body_format: z9.enum(["md", "html"]).optional().describe("Format of `body`. Defaults to Loomio's group default when omitted.")
1571
1675
  });
1572
1676
  async function createComment(input) {
1573
1677
  const { discussion_id, ...rest } = input;
@@ -1578,14 +1682,14 @@ async function createComment(input) {
1578
1682
  }
1579
1683
 
1580
1684
  // src/tools/admin.ts
1581
- import { z as z9 } from "zod";
1582
- var deactivateUserSchema = z9.object({
1685
+ import { z as z10 } from "zod";
1686
+ var deactivateUserSchema = z10.object({
1583
1687
  id: positiveId.describe("Loomio user id to deactivate. Returns 404 if not active.")
1584
1688
  });
1585
1689
  async function deactivateUser(input) {
1586
1690
  return loomioPostB3("/b3/users/deactivate", { id: input.id });
1587
1691
  }
1588
- var reactivateUserSchema = z9.object({
1692
+ var reactivateUserSchema = z10.object({
1589
1693
  id: positiveId.describe(
1590
1694
  "Loomio user id to reactivate. Returns 404 if not currently deactivated."
1591
1695
  )
@@ -1601,7 +1705,7 @@ function createLoomioMcpServer() {
1601
1705
  const server = new McpServer({
1602
1706
  name: "loomiomcp",
1603
1707
  // Keep in sync with package.json on each release.
1604
- version: "0.0.6",
1708
+ version: "0.0.9",
1605
1709
  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
1710
  websiteUrl: "https://github.com/soil-dev/loomiomcp",
1607
1711
  icons: ICONS
@@ -1933,7 +2037,10 @@ var oauthProvider = mode.kind === "static-client" ? new OAuthProvider({
1933
2037
  signingKey,
1934
2038
  resourceUrl: mcpResourceUrl
1935
2039
  }) : new OAuthProvider({
1936
- clientsStore: new InMemoryClientsStore(),
2040
+ // Stateless open-DCR store: registered clients survive restarts,
2041
+ // scale-to-zero, redeploys, and multi-instance routing, so callers
2042
+ // aren't forced to re-authenticate when the process recycles.
2043
+ clientsStore: new StatelessClientsStore(signingKey),
1937
2044
  signingKey,
1938
2045
  resourceUrl: mcpResourceUrl
1939
2046
  });
package/dist/index.js CHANGED
@@ -190,9 +190,9 @@ async function handleResponse(res) {
190
190
  throw err;
191
191
  }
192
192
  }
193
- var B2_AUTH = { field: "api_key", getValue: getApiKey };
194
- var B3_AUTH = { field: "b3_api_key", getValue: getB3ApiKey };
195
- function buildUrl(auth, path, params) {
193
+ var B2_AUTH = getApiKey;
194
+ var B3_AUTH = getB3ApiKey;
195
+ function buildUrl(path, params) {
196
196
  const url = new URL(`${baseUrl()}${path}`);
197
197
  if (params) {
198
198
  for (const [key, value] of Object.entries(params)) {
@@ -201,9 +201,14 @@ function buildUrl(auth, path, params) {
201
201
  }
202
202
  }
203
203
  }
204
- url.searchParams.set(auth.field, auth.getValue());
205
204
  return url.toString();
206
205
  }
206
+ function authHeaders(auth) {
207
+ return {
208
+ ...baseHeaders(),
209
+ Authorization: `Bearer ${auth()}`
210
+ };
211
+ }
207
212
  async function doFetch(url, options) {
208
213
  const startedAt = Date.now();
209
214
  const method = options?.method ?? "GET";
@@ -235,8 +240,8 @@ function emitLoomioRequest(method, url, res, durationMs) {
235
240
  });
236
241
  }
237
242
  async function loomioGet(path, params) {
238
- const url = buildUrl(B2_AUTH, path, params);
239
- const start = await doFetch(url, { headers: baseHeaders() });
243
+ const url = buildUrl(path, params);
244
+ const start = await doFetch(url, { headers: authHeaders(B2_AUTH) });
240
245
  try {
241
246
  return await consumeBody(start, () => handleResponse(start.res));
242
247
  } finally {
@@ -244,8 +249,8 @@ async function loomioGet(path, params) {
244
249
  }
245
250
  }
246
251
  async function loomioGetStatus(path, params) {
247
- const url = buildUrl(B2_AUTH, path, params);
248
- const start = await doFetch(url, { headers: baseHeaders() });
252
+ const url = buildUrl(path, params);
253
+ const start = await doFetch(url, { headers: authHeaders(B2_AUTH) });
249
254
  try {
250
255
  return await consumeBody(start, async () => {
251
256
  try {
@@ -272,12 +277,12 @@ function encodeForm(body) {
272
277
  }
273
278
  async function loomioPost(path, body, opts = {}) {
274
279
  if (isReadOnly()) throw new LoomioReadOnlyError("POST");
275
- const url = buildUrl(B2_AUTH, path, opts.params);
280
+ const url = buildUrl(path, opts.params);
276
281
  const encoding = opts.encoding ?? "json";
277
282
  const start = await doFetch(url, {
278
283
  method: "POST",
279
284
  headers: {
280
- ...baseHeaders(),
285
+ ...authHeaders(B2_AUTH),
281
286
  "Content-Type": encoding === "form" ? "application/x-www-form-urlencoded" : "application/json"
282
287
  },
283
288
  body: encoding === "form" ? encodeForm(body) : JSON.stringify(body)
@@ -290,10 +295,10 @@ async function loomioPost(path, body, opts = {}) {
290
295
  }
291
296
  async function loomioPostB3(path, params) {
292
297
  if (isReadOnly()) throw new LoomioReadOnlyError("POST");
293
- const url = buildUrl(B3_AUTH, path, params);
298
+ const url = buildUrl(path, params);
294
299
  const start = await doFetch(url, {
295
300
  method: "POST",
296
- headers: { ...baseHeaders(), "Content-Type": "application/json" },
301
+ headers: { ...authHeaders(B3_AUTH), "Content-Type": "application/json" },
297
302
  body: "{}"
298
303
  });
299
304
  try {
@@ -1049,7 +1054,7 @@ function createLoomioMcpServer() {
1049
1054
  const server2 = new McpServer({
1050
1055
  name: "loomiomcp",
1051
1056
  // Keep in sync with package.json on each release.
1052
- version: "0.0.6",
1057
+ version: "0.0.9",
1053
1058
  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
1059
  websiteUrl: "https://github.com/soil-dev/loomiomcp",
1055
1060
  icons: ICONS
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loomiomcp",
3
- "version": "0.0.6",
3
+ "version": "0.0.9",
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",