loomiomcp 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,804 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+
6
+ // src/loomio/client.ts
7
+ import { fetch } from "undici";
8
+
9
+ // src/env.ts
10
+ function readBool(name) {
11
+ const raw = process.env[name]?.toLowerCase();
12
+ return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
13
+ }
14
+
15
+ // src/log.ts
16
+ import { AsyncLocalStorage } from "async_hooks";
17
+ function logVerbose() {
18
+ return readBool("LOOMIO_MCP_LOG_VERBOSE");
19
+ }
20
+ var chainHandlers = {
21
+ "tool.call": (ctx, f) => {
22
+ if (typeof f["tool"] === "string") ctx.tools.push(f["tool"]);
23
+ },
24
+ "loomio.request": (ctx) => {
25
+ ctx.loomioCalls += 1;
26
+ }
27
+ };
28
+ function logEvent(event, fields, opts = {}) {
29
+ const ctx = requestContext.getStore();
30
+ if (ctx) chainHandlers[event]?.(ctx, fields);
31
+ if (!opts.force && !logVerbose()) return;
32
+ process.stderr.write(
33
+ `${JSON.stringify({ event, ...fields, timestamp: (/* @__PURE__ */ new Date()).toISOString() })}
34
+ `
35
+ );
36
+ }
37
+ function redactPath(path) {
38
+ const noQuery = path.split("?")[0] ?? path;
39
+ return noQuery.replace(/\/\d+(?:,\d+)*/g, "/:id");
40
+ }
41
+ var requestContext = new AsyncLocalStorage();
42
+ function getRequestContext() {
43
+ return requestContext.getStore();
44
+ }
45
+
46
+ // src/loomio/client.ts
47
+ var DEFAULT_BASE_URL = "https://www.loomio.com/api";
48
+ function baseUrl() {
49
+ const override = process.env["LOOMIO_API_BASE_URL"];
50
+ if (!override) return DEFAULT_BASE_URL;
51
+ if (!URL.canParse(override)) {
52
+ throw new LoomioAuthError(
53
+ `LOOMIO_API_BASE_URL is not a valid URL: ${JSON.stringify(override)}`
54
+ );
55
+ }
56
+ const u = new URL(override);
57
+ const isLocal = u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "[::1]" || u.hostname === "::1";
58
+ if (u.protocol !== "https:" && !(u.protocol === "http:" && isLocal)) {
59
+ throw new LoomioAuthError(
60
+ `LOOMIO_API_BASE_URL must be https:// (or http:// on localhost); got ${u.protocol}//${u.hostname}. Sending the Loomio API key to that URL would expose it.`
61
+ );
62
+ }
63
+ return override;
64
+ }
65
+ function isReadOnly() {
66
+ return readBool("LOOMIO_MCP_READONLY");
67
+ }
68
+ var LoomioReadOnlyError = class extends Error {
69
+ constructor(method) {
70
+ super(
71
+ `loomiomcp is running in read-only mode (LOOMIO_MCP_READONLY is set). ${method} requests are refused. Unset LOOMIO_MCP_READONLY to enable writes.`
72
+ );
73
+ this.name = "LoomioReadOnlyError";
74
+ }
75
+ };
76
+ var LoomioAuthError = class extends Error {
77
+ constructor(message, status) {
78
+ super(message);
79
+ this.status = status;
80
+ this.name = "LoomioAuthError";
81
+ }
82
+ status;
83
+ };
84
+ var LoomioApiError = class extends Error {
85
+ constructor(status, message) {
86
+ super(message);
87
+ this.status = status;
88
+ this.name = "LoomioApiError";
89
+ }
90
+ status;
91
+ };
92
+ function getApiKey() {
93
+ const key = process.env["LOOMIO_API_KEY"];
94
+ if (!key) {
95
+ throw new LoomioAuthError(
96
+ "LOOMIO_API_KEY environment variable is not set. Generate one in Loomio under your profile \u2192 API keys."
97
+ );
98
+ }
99
+ return key;
100
+ }
101
+ function getB3ApiKey() {
102
+ const key = process.env["LOOMIO_B3_API_KEY"];
103
+ if (!key) {
104
+ throw new LoomioAuthError(
105
+ "LOOMIO_B3_API_KEY environment variable is not set. The b3 admin endpoints require a server-instance secret (ENV['B3_API_KEY'] on the Loomio server, >16 chars). Only Loomio instance operators have this."
106
+ );
107
+ }
108
+ return key;
109
+ }
110
+ function hasB3ApiKey() {
111
+ return Boolean(process.env["LOOMIO_B3_API_KEY"]);
112
+ }
113
+ function baseHeaders() {
114
+ return {
115
+ Accept: "application/json"
116
+ };
117
+ }
118
+ async function parseErrorBody(res) {
119
+ try {
120
+ const body = await res.json();
121
+ if (body.errors && typeof body.errors === "object") {
122
+ const parts = [];
123
+ for (const [field, msgs] of Object.entries(body.errors)) {
124
+ const msg = Array.isArray(msgs) ? msgs.join(", ") : String(msgs);
125
+ parts.push(`${field}: ${msg}`);
126
+ }
127
+ if (parts.length > 0) return parts.join("; ");
128
+ }
129
+ if (body.message) return body.message;
130
+ if (body.error) return body.error;
131
+ return res.statusText;
132
+ } catch {
133
+ return res.statusText;
134
+ }
135
+ }
136
+ var REQUEST_TIMEOUT_MS = 6e4;
137
+ function isAbortError(err) {
138
+ return err instanceof Error && (err.name === "AbortError" || /aborted/i.test(err.message));
139
+ }
140
+ function timeoutError() {
141
+ throw new LoomioApiError(
142
+ 504,
143
+ `Loomio API request timed out after ${REQUEST_TIMEOUT_MS / 1e3}s. The Loomio API may be slow or hung; retry after a short wait.`
144
+ );
145
+ }
146
+ async function fetchWithTimeout(url, options) {
147
+ const hasCallerSignal = !!options && options.signal !== void 0;
148
+ const controller = hasCallerSignal ? void 0 : new AbortController();
149
+ const timer = controller && setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
150
+ timer?.unref();
151
+ const cleanup = () => {
152
+ if (timer) clearTimeout(timer);
153
+ };
154
+ const opts = controller ? { ...options ?? {}, signal: controller.signal } : options ?? {};
155
+ try {
156
+ const res = await fetch(url, opts);
157
+ return { res, cleanup };
158
+ } catch (err) {
159
+ cleanup();
160
+ if (isAbortError(err)) timeoutError();
161
+ throw err;
162
+ }
163
+ }
164
+ async function throwForStatus(res) {
165
+ if (res.status === 401 || res.status === 403) {
166
+ const detail = await parseErrorBody(res);
167
+ throw new LoomioAuthError(
168
+ `Loomio API returned ${res.status}: ${detail}. Check that LOOMIO_API_KEY is valid and has access to the requested resource.`,
169
+ res.status
170
+ );
171
+ }
172
+ if (!res.ok) {
173
+ const msg = await parseErrorBody(res);
174
+ throw new LoomioApiError(res.status, `Loomio API error ${res.status}: ${msg}`);
175
+ }
176
+ }
177
+ async function handleResponse(res) {
178
+ await throwForStatus(res);
179
+ try {
180
+ return await res.json();
181
+ } catch (err) {
182
+ if (isAbortError(err)) timeoutError();
183
+ throw err;
184
+ }
185
+ }
186
+ var B2_AUTH = { field: "api_key", getValue: getApiKey };
187
+ var B3_AUTH = { field: "b3_api_key", getValue: getB3ApiKey };
188
+ function buildUrl(auth, path, params) {
189
+ const url = new URL(`${baseUrl()}${path}`);
190
+ if (params) {
191
+ for (const [key, value] of Object.entries(params)) {
192
+ if (value !== void 0) {
193
+ url.searchParams.set(key, String(value));
194
+ }
195
+ }
196
+ }
197
+ url.searchParams.set(auth.field, auth.getValue());
198
+ return url.toString();
199
+ }
200
+ async function doFetch(url, options) {
201
+ const startedAt = Date.now();
202
+ const method = options?.method ?? "GET";
203
+ const first = await fetchWithTimeout(url, options);
204
+ return { ...first, startedAt, method, url };
205
+ }
206
+ async function consumeBody(start, body) {
207
+ try {
208
+ return await body();
209
+ } finally {
210
+ emitLoomioRequest(start.method, start.url, start.res, Date.now() - start.startedAt);
211
+ }
212
+ }
213
+ function emitLoomioRequest(method, url, res, durationMs) {
214
+ let path = "";
215
+ try {
216
+ path = redactPath(new URL(url).pathname);
217
+ } catch {
218
+ path = "?";
219
+ }
220
+ const lenHeader = res.headers.get("content-length");
221
+ const responseBytes = lenHeader ? Number.parseInt(lenHeader, 10) : 0;
222
+ logEvent("loomio.request", {
223
+ method,
224
+ path,
225
+ status: res.status,
226
+ durationMs,
227
+ responseBytes: Number.isFinite(responseBytes) ? responseBytes : 0
228
+ });
229
+ }
230
+ async function loomioGet(path, params) {
231
+ const url = buildUrl(B2_AUTH, path, params);
232
+ const start = await doFetch(url, { headers: baseHeaders() });
233
+ try {
234
+ return await consumeBody(start, () => handleResponse(start.res));
235
+ } finally {
236
+ start.cleanup();
237
+ }
238
+ }
239
+ function encodeForm(body) {
240
+ const u = new URLSearchParams();
241
+ for (const [k, v] of Object.entries(body)) {
242
+ if (v === void 0 || v === null) continue;
243
+ if (Array.isArray(v)) {
244
+ for (const item of v) u.append(`${k}[]`, String(item));
245
+ } else {
246
+ u.append(k, String(v));
247
+ }
248
+ }
249
+ return u.toString();
250
+ }
251
+ async function loomioPost(path, body, opts = {}) {
252
+ if (isReadOnly()) throw new LoomioReadOnlyError("POST");
253
+ const url = buildUrl(B2_AUTH, path, opts.params);
254
+ const encoding = opts.encoding ?? "json";
255
+ const start = await doFetch(url, {
256
+ method: "POST",
257
+ headers: {
258
+ ...baseHeaders(),
259
+ "Content-Type": encoding === "form" ? "application/x-www-form-urlencoded" : "application/json"
260
+ },
261
+ body: encoding === "form" ? encodeForm(body) : JSON.stringify(body)
262
+ });
263
+ try {
264
+ return await consumeBody(start, () => handleResponse(start.res));
265
+ } finally {
266
+ start.cleanup();
267
+ }
268
+ }
269
+ async function loomioPostB3(path, params) {
270
+ if (isReadOnly()) throw new LoomioReadOnlyError("POST");
271
+ const url = buildUrl(B3_AUTH, path, params);
272
+ const start = await doFetch(url, {
273
+ method: "POST",
274
+ headers: { ...baseHeaders(), "Content-Type": "application/json" },
275
+ body: "{}"
276
+ });
277
+ try {
278
+ return await consumeBody(start, () => handleResponse(start.res));
279
+ } finally {
280
+ start.cleanup();
281
+ }
282
+ }
283
+
284
+ // src/server.ts
285
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
286
+
287
+ // src/icon.ts
288
+ var ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="loomiomcp">
289
+ <!-- Placeholder mark: letter L on a tinted circle. Visually neutral \u2014
290
+ does not reproduce any Loomio trademark. -->
291
+ <circle cx="32" cy="32" r="28" fill="#2563EB"/>
292
+ <path d="M22 16 L22 48 L44 48 L44 42 L28 42 L28 16 Z" fill="#FFFFFF"/>
293
+ </svg>`;
294
+ var ICON_DATA_URI = `data:image/svg+xml;base64,${Buffer.from(ICON_SVG, "utf8").toString("base64")}`;
295
+ var ICONS = [
296
+ {
297
+ src: ICON_DATA_URI,
298
+ mimeType: "image/svg+xml",
299
+ sizes: ["64x64", "any"]
300
+ }
301
+ ];
302
+
303
+ // src/server/register-tool.ts
304
+ var READ_PREFIXES = ["search_", "filter_", "get_", "list_", "show_", "run_"];
305
+ function isReadOnlyByName(name) {
306
+ return READ_PREFIXES.some((p) => name.startsWith(p));
307
+ }
308
+ function isDestructive(name) {
309
+ return name.startsWith("delete_") || name === "manage_memberships" || name === "deactivate_user";
310
+ }
311
+ function inferAnnotations(name) {
312
+ const readOnly = isReadOnlyByName(name);
313
+ return {
314
+ readOnlyHint: readOnly,
315
+ destructiveHint: isDestructive(name),
316
+ idempotentHint: readOnly,
317
+ openWorldHint: true
318
+ };
319
+ }
320
+ function argFieldNames(input) {
321
+ if (input === null || typeof input !== "object" || Array.isArray(input)) return [];
322
+ return Object.keys(input);
323
+ }
324
+ function emitToolCall(opts) {
325
+ logEvent("tool.call", {
326
+ tool: opts.tool,
327
+ ...opts.clientId ? { clientId: opts.clientId } : {},
328
+ argFields: opts.argFields,
329
+ durationMs: Date.now() - opts.startedAt,
330
+ outcome: opts.outcome
331
+ });
332
+ }
333
+ function wrapAsText(result) {
334
+ return {
335
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
336
+ };
337
+ }
338
+ function registerTool(server2, name, description, schema, handler) {
339
+ const registerWithSchema = server2.registerTool.bind(server2);
340
+ registerWithSchema(
341
+ name,
342
+ { description, inputSchema: schema, annotations: inferAnnotations(name) },
343
+ async (input) => {
344
+ const startedAt = Date.now();
345
+ const argFields = argFieldNames(input);
346
+ const clientId = getRequestContext()?.clientId;
347
+ try {
348
+ const result = await handler(input);
349
+ emitToolCall({ tool: name, clientId, argFields, startedAt, outcome: "success" });
350
+ return wrapAsText(result);
351
+ } catch (err) {
352
+ emitToolCall({ tool: name, clientId, argFields, startedAt, outcome: "error" });
353
+ throw err;
354
+ }
355
+ }
356
+ );
357
+ }
358
+
359
+ // src/tools/discussions.ts
360
+ import { z as z2 } from "zod";
361
+
362
+ // src/tools/_common.ts
363
+ import { z } from "zod";
364
+ var positiveId = z.number().int().positive();
365
+ var loomioKey = z.string().regex(/^[A-Za-z0-9_-]+$/, "Loomio short keys may only contain letters, numbers, _ and -.");
366
+ var idOrKey = z.union([loomioKey, positiveId]);
367
+ function encodePathSegment(value) {
368
+ const raw = String(value);
369
+ if (raw === "" || raw === "." || raw === "..") {
370
+ throw new Error("id_or_key must be a non-empty Loomio id or short key.");
371
+ }
372
+ return encodeURIComponent(raw);
373
+ }
374
+ var PollTypeEnum = z.enum([
375
+ "proposal",
376
+ "poll",
377
+ "count",
378
+ "score",
379
+ "ranked_choice",
380
+ "meeting",
381
+ "dot_vote"
382
+ ]);
383
+
384
+ // src/tools/discussions.ts
385
+ var getDiscussionSchema = z2.object({
386
+ id_or_key: idOrKey.describe(
387
+ "Discussion id (numeric) or short string key (e.g. 'abcDEF12'). Loomio accepts either."
388
+ )
389
+ });
390
+ async function getDiscussion(input) {
391
+ return loomioGet(`/b2/discussions/${encodePathSegment(input.id_or_key)}`);
392
+ }
393
+ var listDiscussionsSchema = z2.object({
394
+ group_id: positiveId.describe("ID of the Loomio group whose discussions to list (required)."),
395
+ status: z2.enum(["open", "closed", "all"]).optional().describe(
396
+ "Filter by status. 'open' = unlocked, 'closed' = locked, 'all' = every kept discussion. Loomio defaults to 'open'."
397
+ ),
398
+ limit: z2.number().int().min(1).max(200).optional().describe("Page size. Loomio defaults to 50."),
399
+ offset: z2.number().int().min(0).optional().describe("Page offset. Defaults to 0.")
400
+ });
401
+ async function listDiscussions(input) {
402
+ return loomioGet("/b2/discussions", {
403
+ group_id: input.group_id,
404
+ ...input.status ? { status: input.status } : {},
405
+ ...input.limit !== void 0 ? { limit: input.limit } : {},
406
+ ...input.offset !== void 0 ? { offset: input.offset } : {}
407
+ });
408
+ }
409
+ var createDiscussionSchema = z2.object({
410
+ title: z2.string().min(1).describe("Discussion title (required)."),
411
+ group_id: positiveId.describe("ID of the Loomio group to create the discussion in (required)."),
412
+ description: z2.string().optional().describe("Optional discussion body."),
413
+ description_format: z2.enum(["md", "html"]).optional().describe("Format of `description`. Defaults to Loomio's group default when omitted."),
414
+ private: z2.boolean().optional().describe(
415
+ "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."
416
+ ),
417
+ recipient_audience: z2.enum(["group"]).optional().describe("Audience selector for notification recipients. 'group' = notify whole group."),
418
+ recipient_user_ids: z2.array(positiveId).optional().describe("Explicit list of user IDs to notify."),
419
+ recipient_emails: z2.array(z2.string().email()).optional().describe("Explicit list of email addresses to notify."),
420
+ recipient_message: z2.string().optional().describe("Optional custom message to include in notifications.")
421
+ });
422
+ async function resolveDiscussionPrivate(groupId) {
423
+ try {
424
+ const resp = await loomioGet(`/v1/groups/${groupId}`);
425
+ const opts = resp.groups?.[0]?.discussion_privacy_options;
426
+ return opts !== "public_only";
427
+ } catch (err) {
428
+ if ((err instanceof LoomioAuthError || err instanceof LoomioApiError) && err.status === 403) {
429
+ return true;
430
+ }
431
+ throw err;
432
+ }
433
+ }
434
+ async function createDiscussion(input) {
435
+ if (isReadOnly()) throw new LoomioReadOnlyError("POST");
436
+ const priv = input.private !== void 0 ? input.private : await resolveDiscussionPrivate(input.group_id);
437
+ return loomioPost("/b2/discussions", { ...input, private: priv });
438
+ }
439
+
440
+ // src/tools/polls.ts
441
+ import { z as z3 } from "zod";
442
+ var getPollSchema = z3.object({
443
+ id_or_key: idOrKey.describe("Poll id (numeric) or short string key.")
444
+ });
445
+ async function getPoll(input) {
446
+ return loomioGet(`/b2/polls/${encodePathSegment(input.id_or_key)}`);
447
+ }
448
+ var listPollsSchema = z3.object({
449
+ group_id: positiveId.describe("ID of the Loomio group whose polls to list (required)."),
450
+ status: z3.enum(["active", "closed", "all"]).optional().describe("Filter polls by status. Loomio defaults to 'active'."),
451
+ limit: z3.number().int().min(1).max(200).optional().describe("Page size. Loomio defaults to 50."),
452
+ offset: z3.number().int().min(0).optional().describe("Page offset. Defaults to 0.")
453
+ });
454
+ async function listPolls(input) {
455
+ return loomioGet("/b2/polls", {
456
+ group_id: input.group_id,
457
+ ...input.status ? { status: input.status } : {},
458
+ ...input.limit !== void 0 ? { limit: input.limit } : {},
459
+ ...input.offset !== void 0 ? { offset: input.offset } : {}
460
+ });
461
+ }
462
+ var createPollSchema = z3.object({
463
+ title: z3.string().min(1).describe("Poll title (required)."),
464
+ poll_type: PollTypeEnum.describe(
465
+ "Poll type. One of: proposal, poll, count, score, ranked_choice, meeting, dot_vote."
466
+ ),
467
+ group_id: positiveId.optional().describe(
468
+ "Group the poll belongs to. Required when not attaching to an existing discussion via discussion_id."
469
+ ),
470
+ discussion_id: positiveId.optional().describe(
471
+ "Attach the poll to an existing discussion. When set, group_id is taken from the discussion."
472
+ ),
473
+ details: z3.string().optional().describe("Optional poll body / context."),
474
+ details_format: z3.enum(["md", "html"]).optional().describe("Format of `details`. Defaults to 'md'."),
475
+ options: z3.array(z3.string().min(1)).optional().describe(
476
+ "Voting options. `proposal` has built-in agree/disagree/abstain options; for poll / count / score / ranked_choice / meeting / dot_vote you MUST supply your own."
477
+ ),
478
+ closing_at: z3.string().optional().describe("ISO-8601 timestamp at which the poll closes."),
479
+ specified_voters_only: z3.boolean().optional().describe("If true, only users in recipient_user_ids / recipient_emails can vote."),
480
+ hide_results: z3.enum(["off", "until_vote", "until_closed"]).optional().describe("Results visibility policy. Defaults to 'off'."),
481
+ shuffle_options: z3.boolean().optional().describe("If true, shuffle option display order."),
482
+ anonymous: z3.boolean().optional().describe("If true, hide voter identities."),
483
+ recipient_audience: z3.enum(["group"]).optional(),
484
+ notify_on_closing_soon: z3.enum(["nobody", "author", "voters", "undecided_voters", "all_members"]).optional().describe("Who Loomio notifies as the closing date approaches. Defaults to 'nobody'."),
485
+ recipient_user_ids: z3.array(positiveId).optional(),
486
+ recipient_emails: z3.array(z3.string().email()).optional(),
487
+ recipient_message: z3.string().optional(),
488
+ notify_recipients: z3.boolean().optional().describe("If false, suppress the initial notification email. Defaults to false.")
489
+ }).superRefine((input, ctx) => {
490
+ if (input.group_id === void 0 && input.discussion_id === void 0) {
491
+ ctx.addIssue({
492
+ code: "custom",
493
+ path: ["group_id"],
494
+ message: "Either group_id or discussion_id is required."
495
+ });
496
+ }
497
+ if (input.poll_type !== "proposal" && !input.options?.length) {
498
+ ctx.addIssue({
499
+ code: "custom",
500
+ path: ["options"],
501
+ message: "options is required for non-proposal poll types."
502
+ });
503
+ }
504
+ });
505
+ async function createPoll(input) {
506
+ return loomioPost("/b2/polls", input);
507
+ }
508
+
509
+ // src/tools/memberships.ts
510
+ import { z as z4 } from "zod";
511
+ var listMembershipsSchema = z4.object({
512
+ group_id: positiveId.describe(
513
+ "ID of the Loomio group whose memberships to list (required). Caller must be a group admin; the response includes member email addresses."
514
+ ),
515
+ limit: z4.number().int().min(1).max(200).optional().describe("Page size. Loomio defaults to 50."),
516
+ offset: z4.number().int().min(0).optional().describe("Page offset. Defaults to 0.")
517
+ });
518
+ async function listMemberships(input) {
519
+ return loomioGet("/b2/memberships", {
520
+ group_id: input.group_id,
521
+ ...input.limit !== void 0 ? { limit: input.limit } : {},
522
+ ...input.offset !== void 0 ? { offset: input.offset } : {}
523
+ });
524
+ }
525
+ var manageMembershipsSchema = z4.object({
526
+ group_id: positiveId.describe(
527
+ "ID of the Loomio group to modify (required). Caller must be a group admin."
528
+ ),
529
+ emails: z4.array(z4.string().email()).min(1).describe(
530
+ "Email addresses to ensure are members. Each address that isn't already a member is invited / added."
531
+ ),
532
+ remove_absent: z4.boolean().optional().describe(
533
+ "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."
534
+ )
535
+ });
536
+ async function manageMemberships(input) {
537
+ return loomioPost("/b2/memberships", input);
538
+ }
539
+
540
+ // src/tools/groups.ts
541
+ import { z as z5 } from "zod";
542
+ var DEFAULT_START_ID = 1;
543
+ var DEFAULT_END_ID = 200;
544
+ var MAX_END_ID = 1e4;
545
+ var MAX_PROBE_SPAN = 500;
546
+ var CONCURRENCY = 5;
547
+ var listGroupsSchema = z5.object({
548
+ start_id: z5.number().int().min(1).optional().describe("First group_id to probe (inclusive). Defaults to 1."),
549
+ end_id: z5.number().int().min(1).max(MAX_END_ID).optional().describe(
550
+ "Last group_id to probe (inclusive). Defaults to 200. A single call may scan at most 500 ids; use multiple calls for wider ranges."
551
+ ),
552
+ stop_after_consecutive_misses: z5.number().int().min(1).max(MAX_PROBE_SPAN).optional().describe(
553
+ "Early-exit heuristic: stop probing after this many consecutive 404/403 misses. Saves wall time on sparse id ranges. Defaults to 50."
554
+ )
555
+ }).superRefine((input, ctx) => {
556
+ const start = input.start_id ?? DEFAULT_START_ID;
557
+ const end = input.end_id ?? DEFAULT_END_ID;
558
+ if (end < start) {
559
+ ctx.addIssue({
560
+ code: "custom",
561
+ path: ["end_id"],
562
+ message: "end_id must be greater than or equal to start_id."
563
+ });
564
+ return;
565
+ }
566
+ if (end - start + 1 > MAX_PROBE_SPAN) {
567
+ ctx.addIssue({
568
+ code: "custom",
569
+ path: ["end_id"],
570
+ message: `A single list_groups call may scan at most ${MAX_PROBE_SPAN} ids.`
571
+ });
572
+ }
573
+ });
574
+ async function probeOne(id) {
575
+ try {
576
+ const resp = await loomioGet("/b2/polls", {
577
+ group_id: id,
578
+ limit: 1,
579
+ status: "all"
580
+ });
581
+ return { id, groups: resp.groups ?? [] };
582
+ } catch (err) {
583
+ if (err instanceof LoomioApiError && err.status === 404) return { id, groups: [] };
584
+ if (err instanceof LoomioAuthError && err.status === 403) return { id, groups: [] };
585
+ throw err;
586
+ }
587
+ }
588
+ function slim(g) {
589
+ return {
590
+ id: g.id,
591
+ key: g.key,
592
+ handle: g.handle,
593
+ name: g.name,
594
+ parent_id: g.parent_id,
595
+ discussion_privacy_options: g.discussion_privacy_options,
596
+ is_visible_to_public: g.is_visible_to_public,
597
+ memberships_count: g.memberships_count
598
+ };
599
+ }
600
+ async function listGroups(input) {
601
+ const parsed = listGroupsSchema.parse(input);
602
+ const start = parsed.start_id ?? DEFAULT_START_ID;
603
+ const end = parsed.end_id ?? DEFAULT_END_ID;
604
+ const maxMisses = parsed.stop_after_consecutive_misses ?? 50;
605
+ const seenIds = /* @__PURE__ */ new Set();
606
+ const found = [];
607
+ let consecutiveMisses = 0;
608
+ let earlyExit = false;
609
+ let lastScanned = start - 1;
610
+ for (let batchStart = start; batchStart <= end; batchStart += CONCURRENCY) {
611
+ const ids = [];
612
+ for (let id = batchStart; id < batchStart + CONCURRENCY && id <= end; id++) {
613
+ ids.push(id);
614
+ }
615
+ const results = await Promise.all(ids.map((id) => probeOne(id)));
616
+ for (const r of results) {
617
+ lastScanned = r.id;
618
+ if (r.groups.length > 0) {
619
+ for (const g of r.groups) {
620
+ if (!seenIds.has(g.id)) {
621
+ seenIds.add(g.id);
622
+ found.push(slim(g));
623
+ }
624
+ }
625
+ consecutiveMisses = 0;
626
+ } else {
627
+ consecutiveMisses++;
628
+ }
629
+ }
630
+ if (consecutiveMisses >= maxMisses) {
631
+ earlyExit = true;
632
+ break;
633
+ }
634
+ }
635
+ return {
636
+ groups: found,
637
+ scanned: {
638
+ from: start,
639
+ to: lastScanned,
640
+ stopped_early: earlyExit,
641
+ total_found: found.length
642
+ }
643
+ };
644
+ }
645
+
646
+ // src/tools/comments.ts
647
+ import { z as z6 } from "zod";
648
+ var createCommentSchema = z6.object({
649
+ discussion_id: positiveId.describe("ID of the discussion to comment on."),
650
+ body: z6.string().min(1).describe("Comment body (required)."),
651
+ body_format: z6.enum(["md", "html"]).optional().describe("Format of `body`. Defaults to Loomio's group default when omitted.")
652
+ });
653
+ async function createComment(input) {
654
+ const { discussion_id, ...rest } = input;
655
+ return loomioPost("/b2/comments", rest, {
656
+ params: { discussion_id },
657
+ encoding: "form"
658
+ });
659
+ }
660
+
661
+ // src/tools/admin.ts
662
+ import { z as z7 } from "zod";
663
+ var deactivateUserSchema = z7.object({
664
+ id: positiveId.describe("Loomio user id to deactivate. Returns 404 if not active.")
665
+ });
666
+ async function deactivateUser(input) {
667
+ return loomioPostB3("/b3/users/deactivate", { id: input.id });
668
+ }
669
+ var reactivateUserSchema = z7.object({
670
+ id: positiveId.describe(
671
+ "Loomio user id to reactivate. Returns 404 if not currently deactivated."
672
+ )
673
+ });
674
+ async function reactivateUser(input) {
675
+ return loomioPostB3("/b3/users/reactivate", { id: input.id });
676
+ }
677
+
678
+ // src/server.ts
679
+ function createLoomioMcpServer() {
680
+ const readOnly = isReadOnly();
681
+ const b3Enabled = hasB3ApiKey();
682
+ const server2 = new McpServer({
683
+ name: "loomiomcp",
684
+ version: "0.1.0",
685
+ description: "MCP server for Loomio (loomio.com / self-hosted). Wraps Loomio's b2 public API: read and create discussions, polls, comments, plus list and manage group memberships. 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.",
686
+ websiteUrl: "https://github.com/soil-dev/loomiomcp",
687
+ icons: ICONS
688
+ });
689
+ registerTool(
690
+ server2,
691
+ "get_discussion",
692
+ "Fetch a single Loomio discussion (thread) by id or short string key. Returns the full record \u2014 title, description, group, author, ranges/last-activity timestamps, and embedded users \u2014 in one round-trip. Use for 'show me discussion X', 'what's in thread Y', or to resolve an id_or_key referenced by another tool's output. To enumerate a group's discussions instead of fetching one, use list_discussions.",
693
+ getDiscussionSchema,
694
+ getDiscussion
695
+ );
696
+ registerTool(
697
+ server2,
698
+ "list_discussions",
699
+ "List discussions in a Loomio group, ordered by latest activity. Required: `group_id`. Optional `status` filter \u2014 'open' (unlocked, default), 'closed' (locked), 'all' (every kept thread); `limit` 1-200 (Loomio default 50); `offset` for pagination. Caller must be a group member. Use to answer 'what's being discussed in group X', 'show me recent threads', or before create_discussion to check for duplicates. For one specific thread, use get_discussion.",
700
+ listDiscussionsSchema,
701
+ listDiscussions
702
+ );
703
+ if (!readOnly) {
704
+ registerTool(
705
+ server2,
706
+ "create_discussion",
707
+ "Create a new Loomio discussion (thread) in a group. Required: `title`, `group_id`. The `private` field is auto-resolved from the group's `discussion_privacy_options` when omitted (matches Loomio's web UI default \u2014 public_only \u2192 false, anything else \u2192 true); pass it explicitly to override. Optional `description` + `description_format` ('md' / 'html') set the body. Notification recipients can be specified via `recipient_audience: 'group'` (notify all members), `recipient_user_ids` (explicit user ids), or `recipient_emails` (invite new people), optionally with a `recipient_message`. Caller must be allowed to start discussions in the group.",
708
+ createDiscussionSchema,
709
+ createDiscussion
710
+ );
711
+ }
712
+ registerTool(
713
+ server2,
714
+ "get_poll",
715
+ "Fetch a single Loomio poll (proposal / vote / multi-choice / score / ranked_choice / meeting / dot_vote) by id or short string key. Returns the poll record \u2014 title, type, options, closing state, voter visibility settings, and embedded author. Use for 'show me poll X', 'what's the result of Y', or to follow up on a poll id referenced elsewhere. To enumerate a group's polls, use list_polls.",
716
+ getPollSchema,
717
+ getPoll
718
+ );
719
+ registerTool(
720
+ server2,
721
+ "list_polls",
722
+ "List polls in a Loomio group, ordered by creation date (newest first). Required: `group_id`. Optional `status` filter \u2014 'active' (default), 'closed', 'all' (every kept poll); `limit` 1-200 (default 50); `offset` for pagination. Caller must be a group member. Use to answer 'what's up for vote in group X', 'show me past poll results', or before create_poll to check what's already proposed.",
723
+ listPollsSchema,
724
+ listPolls
725
+ );
726
+ if (!readOnly) {
727
+ registerTool(
728
+ server2,
729
+ "create_poll",
730
+ "Create a new Loomio poll. Required: `title`, `poll_type` \u2014 one of 'proposal' (built-in agree / disagree / abstain), 'poll' (single-choice), 'count' (count signers), 'score' (1-5 rating, configurable via min_score / max_score), 'ranked_choice' (STV), 'meeting' (time poll), 'dot_vote' (point allocation, see dots_per_person). For every type except 'proposal' you MUST supply `options` (array of strings). Either supply `group_id` for a standalone poll or `discussion_id` to attach to an existing thread. Optional: `details` + `details_format`, `closing_at` (ISO-8601), `anonymous`, `hide_results` ('off' / 'until_vote' / 'until_closed'), `specified_voters_only`, `shuffle_options`, `notify_on_closing_soon`, recipient fields. KNOWN UPSTREAM LIMITATION: Loomio's b2 `permitted_params` omits `:private`, so the auto-created Topic always defaults to `private: true` and groups with public-discussions-only policy reject the create with a 422 and empty errors hash. See NOTES-ON-LOOMIO-API.md. Workaround at the moment: create polls only in groups that allow private discussions.",
731
+ createPollSchema,
732
+ createPoll
733
+ );
734
+ }
735
+ registerTool(
736
+ server2,
737
+ "list_memberships",
738
+ "List members of a Loomio group with their email addresses, roles, and join state. Required: `group_id`. Caller MUST be a group admin (non-admins get HTTP 403; the response is server-side scoped to include `include_email: true`). Optional `limit` 1-200 (default 50) and `offset` for pagination. Use to answer 'who's in group X', 'find a member by email', or \u2014 critically \u2014 BEFORE calling manage_memberships with remove_absent=true, since the diff between current and intended members is what makes that destructive call safe.",
739
+ listMembershipsSchema,
740
+ listMemberships
741
+ );
742
+ registerTool(
743
+ server2,
744
+ "list_groups",
745
+ "List groups the connector's api-key user can see, by probing a group_id range. Loomio's API has no native 'list groups' endpoint that honours api-key auth (v1's profile/groups needs a session; the v1 explore endpoint returns only public groups). This tool works around that by issuing one `b2/polls?group_id=N&limit=1&status=all` per id and collecting the group objects from the 200 responses \u2014 404s skipped, 403s treated as soft misses. Scope: returns every group the bot is a **member** of (plus their parent groups, which b2/polls embeds in the response). Bot users with `is_admin: true` bypass the membership check and see every group on the instance. Optional knobs: `start_id` (default 1), `end_id` (default 200; a single call may scan at most 500 ids), `stop_after_consecutive_misses` (default 50; early-exit on sparse id ranges). Caveat: this is the right tool to answer 'what groups can you see' and similar discovery questions, but it costs O(end_id - start_id) outbound calls \u2014 typically ~50\u2013200 HTTP requests in 2\u20135 seconds. The returned group objects are slimmed to `{id, key, handle, name, parent_id, discussion_privacy_options, is_visible_to_public, memberships_count}`; to drill in, use `list_memberships`, `list_discussions`, `list_polls` with the relevant id.",
746
+ listGroupsSchema,
747
+ listGroups
748
+ );
749
+ if (!readOnly) {
750
+ registerTool(
751
+ server2,
752
+ "manage_memberships",
753
+ "Invite users to a Loomio group by email and (optionally) REMOVE members not in the supplied list. Required: `group_id` (caller must be a group admin), `emails` (array of email addresses). Default mode is additive: every address in `emails` that isn't already a member is invited / added; no existing member is touched. DANGEROUS OPTION \u2014 `remove_absent: true`: Loomio REMOVES every existing group member whose email is NOT in `emails`. The zero-or-stale-emails case can wipe the entire group. There is no server-side dry-run and no undo. ALWAYS call list_memberships first, compute the diff explicitly, and confirm with a human before invoking with remove_absent=true. Returns `{added_emails: [...], removed_emails: [...]}` listing exactly what changed.",
754
+ manageMembershipsSchema,
755
+ manageMemberships
756
+ );
757
+ }
758
+ if (!readOnly) {
759
+ registerTool(
760
+ server2,
761
+ "create_comment",
762
+ "Post a comment (reply) on an existing Loomio discussion. Required: `discussion_id`, `body`. Optional `body_format` ('md' or 'html'; defaults to the group's setting). Caller must be permitted to post in the discussion's group. Use for 'reply to thread X', 'add a follow-up to discussion Y', or to chain a series of automated updates. For starting a new thread instead, use create_discussion.",
763
+ createCommentSchema,
764
+ createComment
765
+ );
766
+ }
767
+ if (b3Enabled && !readOnly) {
768
+ registerTool(
769
+ server2,
770
+ "deactivate_user",
771
+ "INSTANCE-ADMIN. Deactivate a Loomio user account instance-wide by user id. Required: `id` (numeric Loomio user id). Authenticates using `LOOMIO_B3_API_KEY` (matched against `ENV['B3_API_KEY']` on the Loomio server; \u226517 chars). This secret authenticates the SERVER, not the calling user \u2014 only set LOOMIO_B3_API_KEY if you operate the Loomio instance and have already deployed loomiomcp in a single-tenant context. Schedules an async DeactivateUserWorker that revokes sessions, memberships, and email subscriptions. Reversible via reactivate_user as long as the user record persists. Returns 404 if the user is not currently active.",
772
+ deactivateUserSchema,
773
+ deactivateUser
774
+ );
775
+ registerTool(
776
+ server2,
777
+ "reactivate_user",
778
+ "INSTANCE-ADMIN. Reactivate a previously-deactivated Loomio user by id. Required: `id`. Authenticates with LOOMIO_B3_API_KEY (server-instance admin secret \u2014 see deactivate_user). Restores the user's ability to log in, but does NOT restore prior memberships or subscriptions; those must be re-applied separately. Returns 404 if the user is not currently deactivated.",
779
+ reactivateUserSchema,
780
+ reactivateUser
781
+ );
782
+ }
783
+ return server2;
784
+ }
785
+
786
+ // src/index.ts
787
+ if (!process.env["LOOMIO_API_KEY"]) {
788
+ console.error(
789
+ "[loomiomcp] LOOMIO_API_KEY environment variable is not set. Generate one in Loomio under your profile \u2192 API keys."
790
+ );
791
+ process.exit(1);
792
+ }
793
+ var server = createLoomioMcpServer();
794
+ var transport = new StdioServerTransport();
795
+ if (isReadOnly()) {
796
+ console.error("[loomiomcp] read-only mode: write tools are not registered");
797
+ }
798
+ try {
799
+ await server.connect(transport);
800
+ } catch (err) {
801
+ const message = err instanceof Error ? err.message : String(err);
802
+ console.error(`[loomiomcp] Failed to start: ${message}`);
803
+ process.exit(1);
804
+ }