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