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 +18 -8
- package/dist/http.js +199 -92
- package/dist/index.js +18 -13
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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 =
|
|
217
|
-
var B3_AUTH =
|
|
218
|
-
function buildUrl(
|
|
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(
|
|
262
|
-
const start = await doFetch(url, { headers:
|
|
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(
|
|
271
|
-
const start = await doFetch(url, { headers:
|
|
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(
|
|
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
|
-
...
|
|
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(
|
|
321
|
+
const url = buildUrl(path, params);
|
|
317
322
|
const start = await doFetch(url, {
|
|
318
323
|
method: "POST",
|
|
319
|
-
headers: { ...
|
|
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
|
|
415
|
-
|
|
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
|
-
|
|
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
|
|
421
|
-
const
|
|
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:
|
|
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
|
|
1038
|
+
import { z as z4 } from "zod";
|
|
935
1039
|
|
|
936
1040
|
// src/tools/_common.ts
|
|
937
|
-
import { z as
|
|
938
|
-
var positiveId =
|
|
939
|
-
var loomioKey =
|
|
940
|
-
var idOrKey =
|
|
941
|
-
var isoTimestamp =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
1074
|
+
var listDiscussionsSchema = z4.object({
|
|
971
1075
|
group_id: positiveId.describe("ID of the Loomio group whose discussions to list (required)."),
|
|
972
|
-
status:
|
|
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:
|
|
976
|
-
offset:
|
|
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 =
|
|
987
|
-
title:
|
|
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:
|
|
990
|
-
description_format:
|
|
991
|
-
private:
|
|
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:
|
|
995
|
-
recipient_user_ids:
|
|
996
|
-
recipient_emails:
|
|
997
|
-
recipient_message:
|
|
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
|
|
1019
|
-
var getPollSchema =
|
|
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 =
|
|
1129
|
+
var listPollsSchema = z5.object({
|
|
1026
1130
|
group_id: positiveId.describe("ID of the Loomio group whose polls to list (required)."),
|
|
1027
|
-
status:
|
|
1028
|
-
limit:
|
|
1029
|
-
offset:
|
|
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 =
|
|
1040
|
-
title:
|
|
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:
|
|
1051
|
-
details_format:
|
|
1052
|
-
options:
|
|
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:
|
|
1056
|
-
specified_voters_only:
|
|
1057
|
-
hide_results:
|
|
1058
|
-
shuffle_options:
|
|
1059
|
-
anonymous:
|
|
1060
|
-
recipient_audience:
|
|
1061
|
-
notify_on_closing_soon:
|
|
1062
|
-
recipient_user_ids:
|
|
1063
|
-
recipient_emails:
|
|
1064
|
-
recipient_message:
|
|
1065
|
-
notify_recipients:
|
|
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
|
|
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 =
|
|
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:
|
|
1137
|
-
offset:
|
|
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 =
|
|
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:
|
|
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:
|
|
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
|
|
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 =
|
|
1188
|
-
start_id:
|
|
1189
|
-
end_id:
|
|
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:
|
|
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
|
|
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 =
|
|
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:
|
|
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:
|
|
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:
|
|
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 =
|
|
1475
|
+
var getUserActivitySchema = z8.object({
|
|
1372
1476
|
user_id: positiveId.describe("Loomio user id whose activity to summarise."),
|
|
1373
|
-
group_ids:
|
|
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
|
|
1567
|
-
var createCommentSchema =
|
|
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:
|
|
1570
|
-
body_format:
|
|
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
|
|
1582
|
-
var deactivateUserSchema =
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
194
|
-
var B3_AUTH =
|
|
195
|
-
function buildUrl(
|
|
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(
|
|
239
|
-
const start = await doFetch(url, { headers:
|
|
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(
|
|
248
|
-
const start = await doFetch(url, { headers:
|
|
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(
|
|
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
|
-
...
|
|
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(
|
|
298
|
+
const url = buildUrl(path, params);
|
|
294
299
|
const start = await doFetch(url, {
|
|
295
300
|
method: "POST",
|
|
296
|
-
headers: { ...
|
|
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.
|
|
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.
|
|
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",
|