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/LICENSE +201 -0
- package/README.md +82 -0
- package/dist/http.js +1569 -0
- package/dist/index.js +804 -0
- package/package.json +65 -0
package/dist/http.js
ADDED
|
@@ -0,0 +1,1569 @@
|
|
|
1
|
+
// src/loomio/client.ts
|
|
2
|
+
import { fetch } from "undici";
|
|
3
|
+
|
|
4
|
+
// src/env.ts
|
|
5
|
+
function readBool(name) {
|
|
6
|
+
const raw = process.env[name]?.toLowerCase();
|
|
7
|
+
return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
|
|
8
|
+
}
|
|
9
|
+
function readPositiveInt(name, fallback, min = 1) {
|
|
10
|
+
const raw = process.env[name];
|
|
11
|
+
if (raw === void 0 || raw === "") return fallback;
|
|
12
|
+
const n = Number(raw);
|
|
13
|
+
if (!Number.isFinite(n) || n < min) return fallback;
|
|
14
|
+
return Math.floor(n);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// src/log.ts
|
|
18
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
19
|
+
function logVerbose() {
|
|
20
|
+
return readBool("LOOMIO_MCP_LOG_VERBOSE");
|
|
21
|
+
}
|
|
22
|
+
var chainHandlers = {
|
|
23
|
+
"tool.call": (ctx, f) => {
|
|
24
|
+
if (typeof f["tool"] === "string") ctx.tools.push(f["tool"]);
|
|
25
|
+
},
|
|
26
|
+
"loomio.request": (ctx) => {
|
|
27
|
+
ctx.loomioCalls += 1;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
function logEvent(event, fields, opts = {}) {
|
|
31
|
+
const ctx = requestContext.getStore();
|
|
32
|
+
if (ctx) chainHandlers[event]?.(ctx, fields);
|
|
33
|
+
if (!opts.force && !logVerbose()) return;
|
|
34
|
+
process.stderr.write(
|
|
35
|
+
`${JSON.stringify({ event, ...fields, timestamp: (/* @__PURE__ */ new Date()).toISOString() })}
|
|
36
|
+
`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
function redactPath(path) {
|
|
40
|
+
const noQuery = path.split("?")[0] ?? path;
|
|
41
|
+
return noQuery.replace(/\/\d+(?:,\d+)*/g, "/:id");
|
|
42
|
+
}
|
|
43
|
+
var requestContext = new AsyncLocalStorage();
|
|
44
|
+
function withRequestContext(initial, fn) {
|
|
45
|
+
const ctx = {
|
|
46
|
+
...initial,
|
|
47
|
+
tools: [],
|
|
48
|
+
loomioCalls: 0,
|
|
49
|
+
startedAt: Date.now()
|
|
50
|
+
};
|
|
51
|
+
return requestContext.run(ctx, async () => {
|
|
52
|
+
try {
|
|
53
|
+
return await fn();
|
|
54
|
+
} finally {
|
|
55
|
+
logEvent("tool.chain", {
|
|
56
|
+
...ctx.clientId ? { clientId: ctx.clientId } : {},
|
|
57
|
+
tools: ctx.tools,
|
|
58
|
+
toolCount: ctx.tools.length,
|
|
59
|
+
loomioCalls: ctx.loomioCalls,
|
|
60
|
+
durationMs: Date.now() - ctx.startedAt
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
function getRequestContext() {
|
|
66
|
+
return requestContext.getStore();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// src/loomio/client.ts
|
|
70
|
+
var DEFAULT_BASE_URL = "https://www.loomio.com/api";
|
|
71
|
+
function baseUrl() {
|
|
72
|
+
const override = process.env["LOOMIO_API_BASE_URL"];
|
|
73
|
+
if (!override) return DEFAULT_BASE_URL;
|
|
74
|
+
if (!URL.canParse(override)) {
|
|
75
|
+
throw new LoomioAuthError(
|
|
76
|
+
`LOOMIO_API_BASE_URL is not a valid URL: ${JSON.stringify(override)}`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
const u = new URL(override);
|
|
80
|
+
const isLocal = u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "[::1]" || u.hostname === "::1";
|
|
81
|
+
if (u.protocol !== "https:" && !(u.protocol === "http:" && isLocal)) {
|
|
82
|
+
throw new LoomioAuthError(
|
|
83
|
+
`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.`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return override;
|
|
87
|
+
}
|
|
88
|
+
function isReadOnly() {
|
|
89
|
+
return readBool("LOOMIO_MCP_READONLY");
|
|
90
|
+
}
|
|
91
|
+
var LoomioReadOnlyError = class extends Error {
|
|
92
|
+
constructor(method) {
|
|
93
|
+
super(
|
|
94
|
+
`loomiomcp is running in read-only mode (LOOMIO_MCP_READONLY is set). ${method} requests are refused. Unset LOOMIO_MCP_READONLY to enable writes.`
|
|
95
|
+
);
|
|
96
|
+
this.name = "LoomioReadOnlyError";
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
var LoomioAuthError = class extends Error {
|
|
100
|
+
constructor(message, status) {
|
|
101
|
+
super(message);
|
|
102
|
+
this.status = status;
|
|
103
|
+
this.name = "LoomioAuthError";
|
|
104
|
+
}
|
|
105
|
+
status;
|
|
106
|
+
};
|
|
107
|
+
var LoomioApiError = class extends Error {
|
|
108
|
+
constructor(status, message) {
|
|
109
|
+
super(message);
|
|
110
|
+
this.status = status;
|
|
111
|
+
this.name = "LoomioApiError";
|
|
112
|
+
}
|
|
113
|
+
status;
|
|
114
|
+
};
|
|
115
|
+
function getApiKey() {
|
|
116
|
+
const key = process.env["LOOMIO_API_KEY"];
|
|
117
|
+
if (!key) {
|
|
118
|
+
throw new LoomioAuthError(
|
|
119
|
+
"LOOMIO_API_KEY environment variable is not set. Generate one in Loomio under your profile \u2192 API keys."
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
return key;
|
|
123
|
+
}
|
|
124
|
+
function getB3ApiKey() {
|
|
125
|
+
const key = process.env["LOOMIO_B3_API_KEY"];
|
|
126
|
+
if (!key) {
|
|
127
|
+
throw new LoomioAuthError(
|
|
128
|
+
"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."
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
return key;
|
|
132
|
+
}
|
|
133
|
+
function hasB3ApiKey() {
|
|
134
|
+
return Boolean(process.env["LOOMIO_B3_API_KEY"]);
|
|
135
|
+
}
|
|
136
|
+
function baseHeaders() {
|
|
137
|
+
return {
|
|
138
|
+
Accept: "application/json"
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
async function parseErrorBody(res) {
|
|
142
|
+
try {
|
|
143
|
+
const body = await res.json();
|
|
144
|
+
if (body.errors && typeof body.errors === "object") {
|
|
145
|
+
const parts = [];
|
|
146
|
+
for (const [field, msgs] of Object.entries(body.errors)) {
|
|
147
|
+
const msg = Array.isArray(msgs) ? msgs.join(", ") : String(msgs);
|
|
148
|
+
parts.push(`${field}: ${msg}`);
|
|
149
|
+
}
|
|
150
|
+
if (parts.length > 0) return parts.join("; ");
|
|
151
|
+
}
|
|
152
|
+
if (body.message) return body.message;
|
|
153
|
+
if (body.error) return body.error;
|
|
154
|
+
return res.statusText;
|
|
155
|
+
} catch {
|
|
156
|
+
return res.statusText;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
var REQUEST_TIMEOUT_MS = 6e4;
|
|
160
|
+
function isAbortError(err) {
|
|
161
|
+
return err instanceof Error && (err.name === "AbortError" || /aborted/i.test(err.message));
|
|
162
|
+
}
|
|
163
|
+
function timeoutError() {
|
|
164
|
+
throw new LoomioApiError(
|
|
165
|
+
504,
|
|
166
|
+
`Loomio API request timed out after ${REQUEST_TIMEOUT_MS / 1e3}s. The Loomio API may be slow or hung; retry after a short wait.`
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
async function fetchWithTimeout(url, options) {
|
|
170
|
+
const hasCallerSignal = !!options && options.signal !== void 0;
|
|
171
|
+
const controller = hasCallerSignal ? void 0 : new AbortController();
|
|
172
|
+
const timer = controller && setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
173
|
+
timer?.unref();
|
|
174
|
+
const cleanup = () => {
|
|
175
|
+
if (timer) clearTimeout(timer);
|
|
176
|
+
};
|
|
177
|
+
const opts = controller ? { ...options ?? {}, signal: controller.signal } : options ?? {};
|
|
178
|
+
try {
|
|
179
|
+
const res = await fetch(url, opts);
|
|
180
|
+
return { res, cleanup };
|
|
181
|
+
} catch (err) {
|
|
182
|
+
cleanup();
|
|
183
|
+
if (isAbortError(err)) timeoutError();
|
|
184
|
+
throw err;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
async function throwForStatus(res) {
|
|
188
|
+
if (res.status === 401 || res.status === 403) {
|
|
189
|
+
const detail = await parseErrorBody(res);
|
|
190
|
+
throw new LoomioAuthError(
|
|
191
|
+
`Loomio API returned ${res.status}: ${detail}. Check that LOOMIO_API_KEY is valid and has access to the requested resource.`,
|
|
192
|
+
res.status
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
if (!res.ok) {
|
|
196
|
+
const msg = await parseErrorBody(res);
|
|
197
|
+
throw new LoomioApiError(res.status, `Loomio API error ${res.status}: ${msg}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
async function handleResponse(res) {
|
|
201
|
+
await throwForStatus(res);
|
|
202
|
+
try {
|
|
203
|
+
return await res.json();
|
|
204
|
+
} catch (err) {
|
|
205
|
+
if (isAbortError(err)) timeoutError();
|
|
206
|
+
throw err;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
var B2_AUTH = { field: "api_key", getValue: getApiKey };
|
|
210
|
+
var B3_AUTH = { field: "b3_api_key", getValue: getB3ApiKey };
|
|
211
|
+
function buildUrl(auth, path, params) {
|
|
212
|
+
const url = new URL(`${baseUrl()}${path}`);
|
|
213
|
+
if (params) {
|
|
214
|
+
for (const [key, value] of Object.entries(params)) {
|
|
215
|
+
if (value !== void 0) {
|
|
216
|
+
url.searchParams.set(key, String(value));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
url.searchParams.set(auth.field, auth.getValue());
|
|
221
|
+
return url.toString();
|
|
222
|
+
}
|
|
223
|
+
async function doFetch(url, options) {
|
|
224
|
+
const startedAt = Date.now();
|
|
225
|
+
const method = options?.method ?? "GET";
|
|
226
|
+
const first = await fetchWithTimeout(url, options);
|
|
227
|
+
return { ...first, startedAt, method, url };
|
|
228
|
+
}
|
|
229
|
+
async function consumeBody(start, body) {
|
|
230
|
+
try {
|
|
231
|
+
return await body();
|
|
232
|
+
} finally {
|
|
233
|
+
emitLoomioRequest(start.method, start.url, start.res, Date.now() - start.startedAt);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
function emitLoomioRequest(method, url, res, durationMs) {
|
|
237
|
+
let path = "";
|
|
238
|
+
try {
|
|
239
|
+
path = redactPath(new URL(url).pathname);
|
|
240
|
+
} catch {
|
|
241
|
+
path = "?";
|
|
242
|
+
}
|
|
243
|
+
const lenHeader = res.headers.get("content-length");
|
|
244
|
+
const responseBytes = lenHeader ? Number.parseInt(lenHeader, 10) : 0;
|
|
245
|
+
logEvent("loomio.request", {
|
|
246
|
+
method,
|
|
247
|
+
path,
|
|
248
|
+
status: res.status,
|
|
249
|
+
durationMs,
|
|
250
|
+
responseBytes: Number.isFinite(responseBytes) ? responseBytes : 0
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
async function loomioGet(path, params) {
|
|
254
|
+
const url = buildUrl(B2_AUTH, path, params);
|
|
255
|
+
const start = await doFetch(url, { headers: baseHeaders() });
|
|
256
|
+
try {
|
|
257
|
+
return await consumeBody(start, () => handleResponse(start.res));
|
|
258
|
+
} finally {
|
|
259
|
+
start.cleanup();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function encodeForm(body) {
|
|
263
|
+
const u = new URLSearchParams();
|
|
264
|
+
for (const [k, v] of Object.entries(body)) {
|
|
265
|
+
if (v === void 0 || v === null) continue;
|
|
266
|
+
if (Array.isArray(v)) {
|
|
267
|
+
for (const item of v) u.append(`${k}[]`, String(item));
|
|
268
|
+
} else {
|
|
269
|
+
u.append(k, String(v));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return u.toString();
|
|
273
|
+
}
|
|
274
|
+
async function loomioPost(path, body, opts = {}) {
|
|
275
|
+
if (isReadOnly()) throw new LoomioReadOnlyError("POST");
|
|
276
|
+
const url = buildUrl(B2_AUTH, path, opts.params);
|
|
277
|
+
const encoding = opts.encoding ?? "json";
|
|
278
|
+
const start = await doFetch(url, {
|
|
279
|
+
method: "POST",
|
|
280
|
+
headers: {
|
|
281
|
+
...baseHeaders(),
|
|
282
|
+
"Content-Type": encoding === "form" ? "application/x-www-form-urlencoded" : "application/json"
|
|
283
|
+
},
|
|
284
|
+
body: encoding === "form" ? encodeForm(body) : JSON.stringify(body)
|
|
285
|
+
});
|
|
286
|
+
try {
|
|
287
|
+
return await consumeBody(start, () => handleResponse(start.res));
|
|
288
|
+
} finally {
|
|
289
|
+
start.cleanup();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
async function loomioPostB3(path, params) {
|
|
293
|
+
if (isReadOnly()) throw new LoomioReadOnlyError("POST");
|
|
294
|
+
const url = buildUrl(B3_AUTH, path, params);
|
|
295
|
+
const start = await doFetch(url, {
|
|
296
|
+
method: "POST",
|
|
297
|
+
headers: { ...baseHeaders(), "Content-Type": "application/json" },
|
|
298
|
+
body: "{}"
|
|
299
|
+
});
|
|
300
|
+
try {
|
|
301
|
+
return await consumeBody(start, () => handleResponse(start.res));
|
|
302
|
+
} finally {
|
|
303
|
+
start.cleanup();
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// src/auth/provider.ts
|
|
308
|
+
import { createHash, randomBytes, randomUUID, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
309
|
+
import {
|
|
310
|
+
InvalidGrantError,
|
|
311
|
+
InvalidTargetError,
|
|
312
|
+
InvalidTokenError
|
|
313
|
+
} from "@modelcontextprotocol/sdk/server/auth/errors.js";
|
|
314
|
+
import {
|
|
315
|
+
checkResourceAllowed,
|
|
316
|
+
resourceUrlFromServerUrl
|
|
317
|
+
} from "@modelcontextprotocol/sdk/shared/auth-utils.js";
|
|
318
|
+
|
|
319
|
+
// src/auth/token.ts
|
|
320
|
+
import { createHmac, timingSafeEqual } from "crypto";
|
|
321
|
+
import { z } from "zod";
|
|
322
|
+
var SignedTokenClaimsSchema = z.object({
|
|
323
|
+
type: z.enum(["access", "refresh"]),
|
|
324
|
+
clientId: z.string().min(1),
|
|
325
|
+
resource: z.string().optional(),
|
|
326
|
+
scopes: z.array(z.string()),
|
|
327
|
+
expiresAt: z.number().int(),
|
|
328
|
+
nonce: z.string().min(1)
|
|
329
|
+
});
|
|
330
|
+
function b64urlEncode(buf) {
|
|
331
|
+
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
332
|
+
}
|
|
333
|
+
function b64urlDecode(s) {
|
|
334
|
+
const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - s.length % 4);
|
|
335
|
+
return Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64");
|
|
336
|
+
}
|
|
337
|
+
function sign(payload, key) {
|
|
338
|
+
return b64urlEncode(createHmac("sha256", key).update(payload).digest());
|
|
339
|
+
}
|
|
340
|
+
function issueToken(claims, signingKey2) {
|
|
341
|
+
const payloadB64 = b64urlEncode(Buffer.from(JSON.stringify(claims), "utf8"));
|
|
342
|
+
const sig = sign(payloadB64, signingKey2);
|
|
343
|
+
return `${payloadB64}.${sig}`;
|
|
344
|
+
}
|
|
345
|
+
var TokenSignatureError = class extends Error {
|
|
346
|
+
constructor(message) {
|
|
347
|
+
super(message);
|
|
348
|
+
this.name = "TokenSignatureError";
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
var TokenExpiredError = class extends Error {
|
|
352
|
+
constructor() {
|
|
353
|
+
super("token expired");
|
|
354
|
+
this.name = "TokenExpiredError";
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
function verifyToken(token, signingKey2) {
|
|
358
|
+
const parts = token.split(".");
|
|
359
|
+
if (parts.length !== 2) {
|
|
360
|
+
throw new TokenSignatureError("malformed token");
|
|
361
|
+
}
|
|
362
|
+
const [payloadB64, providedSig] = parts;
|
|
363
|
+
const expectedSig = sign(payloadB64, signingKey2);
|
|
364
|
+
const a = Buffer.from(providedSig);
|
|
365
|
+
const b = Buffer.from(expectedSig);
|
|
366
|
+
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
|
367
|
+
throw new TokenSignatureError("invalid signature");
|
|
368
|
+
}
|
|
369
|
+
let raw;
|
|
370
|
+
try {
|
|
371
|
+
raw = JSON.parse(b64urlDecode(payloadB64).toString("utf8"));
|
|
372
|
+
} catch {
|
|
373
|
+
throw new TokenSignatureError("malformed payload");
|
|
374
|
+
}
|
|
375
|
+
const parsed = SignedTokenClaimsSchema.safeParse(raw);
|
|
376
|
+
if (!parsed.success) {
|
|
377
|
+
throw new TokenSignatureError("malformed payload");
|
|
378
|
+
}
|
|
379
|
+
const claims = parsed.data;
|
|
380
|
+
if (claims.expiresAt < Date.now()) {
|
|
381
|
+
throw new TokenExpiredError();
|
|
382
|
+
}
|
|
383
|
+
return claims;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// src/auth/provider.ts
|
|
387
|
+
var ACCESS_TOKEN_TTL_MS = 1 * 24 * 60 * 60 * 1e3;
|
|
388
|
+
var REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
389
|
+
var AUTH_CODE_TTL_MS = 5 * 60 * 1e3;
|
|
390
|
+
var AUTH_CODE_MAX_ENTRIES = 1e4;
|
|
391
|
+
var AUTH_CODE_GC_INTERVAL_MS = 60 * 1e3;
|
|
392
|
+
var InMemoryClientsStore = class {
|
|
393
|
+
clients = /* @__PURE__ */ new Map();
|
|
394
|
+
getClient(clientId) {
|
|
395
|
+
return this.clients.get(clientId);
|
|
396
|
+
}
|
|
397
|
+
registerClient(client) {
|
|
398
|
+
const clientId = randomUUID();
|
|
399
|
+
const full = {
|
|
400
|
+
...client,
|
|
401
|
+
client_id: clientId,
|
|
402
|
+
client_id_issued_at: Math.floor(Date.now() / 1e3)
|
|
403
|
+
};
|
|
404
|
+
this.clients.set(clientId, full);
|
|
405
|
+
return full;
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
var FixedClientStore = class {
|
|
409
|
+
client;
|
|
410
|
+
constructor(args) {
|
|
411
|
+
if (!args.clientId || args.clientId.length < 1) {
|
|
412
|
+
throw new Error("FixedClientStore: clientId is required");
|
|
413
|
+
}
|
|
414
|
+
if (!args.clientSecret || args.clientSecret.length < 16) {
|
|
415
|
+
throw new Error("FixedClientStore: clientSecret must be at least 16 chars");
|
|
416
|
+
}
|
|
417
|
+
if (!args.redirectUris.length) {
|
|
418
|
+
throw new Error("FixedClientStore: at least one redirectUri is required");
|
|
419
|
+
}
|
|
420
|
+
this.client = {
|
|
421
|
+
client_id: args.clientId,
|
|
422
|
+
client_secret: args.clientSecret,
|
|
423
|
+
client_id_issued_at: Math.floor(Date.now() / 1e3),
|
|
424
|
+
client_secret_expires_at: 0,
|
|
425
|
+
redirect_uris: args.redirectUris,
|
|
426
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
427
|
+
response_types: ["code"],
|
|
428
|
+
token_endpoint_auth_method: "client_secret_post",
|
|
429
|
+
...args.clientName ? { client_name: args.clientName } : {}
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
getClient(clientId) {
|
|
433
|
+
if (clientId !== this.client.client_id) return void 0;
|
|
434
|
+
return this.client;
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
var OAuthProvider = class {
|
|
438
|
+
clientsStore;
|
|
439
|
+
authCodes = /* @__PURE__ */ new Map();
|
|
440
|
+
signingKey;
|
|
441
|
+
resourceUrl;
|
|
442
|
+
gcTimer;
|
|
443
|
+
skipLocalPkceValidation = true;
|
|
444
|
+
constructor(args) {
|
|
445
|
+
if (!args.signingKey || args.signingKey.length < 16) {
|
|
446
|
+
throw new Error("OAuthProvider: signing key must be at least 16 chars long");
|
|
447
|
+
}
|
|
448
|
+
this.clientsStore = args.clientsStore;
|
|
449
|
+
this.signingKey = args.signingKey;
|
|
450
|
+
this.resourceUrl = args.resourceUrl ? resourceUrlFromServerUrl(args.resourceUrl) : void 0;
|
|
451
|
+
if (args.enableAuthCodeGc ?? true) {
|
|
452
|
+
this.gcTimer = setInterval(() => this.gcAuthCodes(), AUTH_CODE_GC_INTERVAL_MS);
|
|
453
|
+
this.gcTimer.unref();
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
shutdown() {
|
|
457
|
+
if (this.gcTimer) clearInterval(this.gcTimer);
|
|
458
|
+
}
|
|
459
|
+
async authorize(client, params, res) {
|
|
460
|
+
const resource = this.resolveResource(params.resource);
|
|
461
|
+
const code = randomBytes(32).toString("hex");
|
|
462
|
+
this.authCodes.set(code, {
|
|
463
|
+
clientId: client.client_id,
|
|
464
|
+
codeChallenge: params.codeChallenge,
|
|
465
|
+
redirectUri: params.redirectUri,
|
|
466
|
+
resource,
|
|
467
|
+
expiresAt: Date.now() + AUTH_CODE_TTL_MS
|
|
468
|
+
});
|
|
469
|
+
this.gcAuthCodes();
|
|
470
|
+
while (this.authCodes.size > AUTH_CODE_MAX_ENTRIES) {
|
|
471
|
+
const oldest = this.authCodes.keys().next().value;
|
|
472
|
+
if (oldest === void 0) break;
|
|
473
|
+
this.authCodes.delete(oldest);
|
|
474
|
+
}
|
|
475
|
+
const url = new URL(params.redirectUri);
|
|
476
|
+
url.searchParams.set("code", code);
|
|
477
|
+
if (params.state) url.searchParams.set("state", params.state);
|
|
478
|
+
res.redirect(url.toString());
|
|
479
|
+
}
|
|
480
|
+
async challengeForAuthorizationCode(_client, authorizationCode) {
|
|
481
|
+
const state = this.authCodes.get(authorizationCode);
|
|
482
|
+
if (!state || state.expiresAt < Date.now()) {
|
|
483
|
+
throw new InvalidGrantError("invalid or expired authorization code");
|
|
484
|
+
}
|
|
485
|
+
return state.codeChallenge;
|
|
486
|
+
}
|
|
487
|
+
async exchangeAuthorizationCode(client, authorizationCode, codeVerifier, redirectUri, resource) {
|
|
488
|
+
const state = this.authCodes.get(authorizationCode);
|
|
489
|
+
if (!state || state.expiresAt < Date.now()) {
|
|
490
|
+
throw new InvalidGrantError("invalid or expired authorization code");
|
|
491
|
+
}
|
|
492
|
+
if (state.clientId !== client.client_id) {
|
|
493
|
+
throw new InvalidGrantError("authorization code was issued to a different client");
|
|
494
|
+
}
|
|
495
|
+
if (redirectUri && redirectUri !== state.redirectUri) {
|
|
496
|
+
throw new InvalidGrantError("redirect_uri mismatch");
|
|
497
|
+
}
|
|
498
|
+
if (!codeVerifier) {
|
|
499
|
+
throw new InvalidGrantError("code_verifier required");
|
|
500
|
+
}
|
|
501
|
+
const expected = createHash("sha256").update(codeVerifier).digest("base64url");
|
|
502
|
+
const expectedBuf = Buffer.from(expected, "utf8");
|
|
503
|
+
const storedBuf = Buffer.from(state.codeChallenge, "utf8");
|
|
504
|
+
if (expectedBuf.length !== storedBuf.length || !timingSafeEqual2(expectedBuf, storedBuf)) {
|
|
505
|
+
throw new InvalidGrantError("code_verifier does not match the challenge");
|
|
506
|
+
}
|
|
507
|
+
const requestedResource = this.resolveResource(resource);
|
|
508
|
+
if (requestedResource && state.resource && requestedResource !== state.resource) {
|
|
509
|
+
throw new InvalidGrantError("resource mismatch");
|
|
510
|
+
}
|
|
511
|
+
this.authCodes.delete(authorizationCode);
|
|
512
|
+
return this.issueTokenPair(client.client_id, requestedResource ?? state.resource);
|
|
513
|
+
}
|
|
514
|
+
async exchangeRefreshToken(client, refreshToken, _scopes, resource) {
|
|
515
|
+
let claims;
|
|
516
|
+
try {
|
|
517
|
+
claims = verifyToken(refreshToken, this.signingKey);
|
|
518
|
+
} catch (err) {
|
|
519
|
+
if (err instanceof TokenSignatureError || err instanceof TokenExpiredError) {
|
|
520
|
+
throw new InvalidGrantError("invalid refresh token");
|
|
521
|
+
}
|
|
522
|
+
throw err;
|
|
523
|
+
}
|
|
524
|
+
if (claims.type !== "refresh") {
|
|
525
|
+
throw new InvalidGrantError("not a refresh token");
|
|
526
|
+
}
|
|
527
|
+
if (claims.clientId !== client.client_id) {
|
|
528
|
+
throw new InvalidGrantError("refresh token was issued to a different client");
|
|
529
|
+
}
|
|
530
|
+
this.assertClaimsResource(claims, InvalidGrantError);
|
|
531
|
+
const requestedResource = this.resolveResource(resource);
|
|
532
|
+
if (requestedResource && claims.resource && requestedResource !== claims.resource) {
|
|
533
|
+
throw new InvalidGrantError("refresh token resource mismatch");
|
|
534
|
+
}
|
|
535
|
+
return this.issueTokenPair(client.client_id, requestedResource ?? claims.resource);
|
|
536
|
+
}
|
|
537
|
+
async verifyAccessToken(token) {
|
|
538
|
+
let claims;
|
|
539
|
+
try {
|
|
540
|
+
claims = verifyToken(token, this.signingKey);
|
|
541
|
+
} catch (err) {
|
|
542
|
+
if (err instanceof TokenSignatureError) {
|
|
543
|
+
throw new InvalidTokenError("invalid token");
|
|
544
|
+
}
|
|
545
|
+
if (err instanceof TokenExpiredError) {
|
|
546
|
+
throw new InvalidTokenError("token expired");
|
|
547
|
+
}
|
|
548
|
+
throw err;
|
|
549
|
+
}
|
|
550
|
+
if (claims.type !== "access") {
|
|
551
|
+
throw new InvalidTokenError("not an access token");
|
|
552
|
+
}
|
|
553
|
+
this.assertClaimsResource(claims, InvalidTokenError);
|
|
554
|
+
return {
|
|
555
|
+
token,
|
|
556
|
+
clientId: claims.clientId,
|
|
557
|
+
scopes: claims.scopes,
|
|
558
|
+
expiresAt: Math.floor(claims.expiresAt / 1e3)
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
issueTokenPair(clientId, resource) {
|
|
562
|
+
const now = Date.now();
|
|
563
|
+
const access = issueToken(
|
|
564
|
+
{
|
|
565
|
+
type: "access",
|
|
566
|
+
clientId,
|
|
567
|
+
...resource ? { resource } : {},
|
|
568
|
+
scopes: [],
|
|
569
|
+
expiresAt: now + ACCESS_TOKEN_TTL_MS,
|
|
570
|
+
nonce: randomBytes(8).toString("hex")
|
|
571
|
+
},
|
|
572
|
+
this.signingKey
|
|
573
|
+
);
|
|
574
|
+
const refresh = issueToken(
|
|
575
|
+
{
|
|
576
|
+
type: "refresh",
|
|
577
|
+
clientId,
|
|
578
|
+
...resource ? { resource } : {},
|
|
579
|
+
scopes: [],
|
|
580
|
+
expiresAt: now + REFRESH_TOKEN_TTL_MS,
|
|
581
|
+
nonce: randomBytes(8).toString("hex")
|
|
582
|
+
},
|
|
583
|
+
this.signingKey
|
|
584
|
+
);
|
|
585
|
+
return {
|
|
586
|
+
access_token: access,
|
|
587
|
+
token_type: "Bearer",
|
|
588
|
+
expires_in: Math.floor(ACCESS_TOKEN_TTL_MS / 1e3),
|
|
589
|
+
refresh_token: refresh
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
resolveResource(requestedResource) {
|
|
593
|
+
if (!this.resourceUrl) {
|
|
594
|
+
return requestedResource ? resourceUrlFromServerUrl(requestedResource).href : void 0;
|
|
595
|
+
}
|
|
596
|
+
if (!requestedResource) return this.resourceUrl.href;
|
|
597
|
+
if (!checkResourceAllowed({
|
|
598
|
+
requestedResource,
|
|
599
|
+
configuredResource: this.resourceUrl
|
|
600
|
+
})) {
|
|
601
|
+
throw new InvalidTargetError(
|
|
602
|
+
`requested resource is not this MCP server: ${requestedResource.href}`
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
return this.resourceUrl.href;
|
|
606
|
+
}
|
|
607
|
+
assertClaimsResource(claims, ErrorClass) {
|
|
608
|
+
if (!this.resourceUrl) return;
|
|
609
|
+
if (!claims.resource) {
|
|
610
|
+
throw new ErrorClass("token is missing MCP resource audience");
|
|
611
|
+
}
|
|
612
|
+
if (!checkResourceAllowed({
|
|
613
|
+
requestedResource: claims.resource,
|
|
614
|
+
configuredResource: this.resourceUrl
|
|
615
|
+
})) {
|
|
616
|
+
throw new ErrorClass("token was issued for a different MCP resource");
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
gcAuthCodes() {
|
|
620
|
+
const now = Date.now();
|
|
621
|
+
for (const [code, state] of this.authCodes.entries()) {
|
|
622
|
+
if (state.expiresAt < now) this.authCodes.delete(code);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
// src/http/config.ts
|
|
628
|
+
var DEFAULT_ANTHROPIC_REDIRECT_URIS = [
|
|
629
|
+
"https://claude.ai/api/mcp/auth_callback",
|
|
630
|
+
"https://claude.ai/api/oauth/callback",
|
|
631
|
+
"https://claude.ai/oauth/callback"
|
|
632
|
+
];
|
|
633
|
+
var DEFAULT_MCP_CLIENT_ORIGINS = ["https://claude.ai"];
|
|
634
|
+
function isLocalHostname(hostname) {
|
|
635
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
|
|
636
|
+
}
|
|
637
|
+
function isLocalhostUrl(url) {
|
|
638
|
+
if (!URL.canParse(url)) return false;
|
|
639
|
+
return isLocalHostname(new URL(url).hostname);
|
|
640
|
+
}
|
|
641
|
+
function selectMode(env = process.env, publicBaseUrl2) {
|
|
642
|
+
const CLIENT_ID = env["MCP_OAUTH_CLIENT_ID"];
|
|
643
|
+
const CLIENT_SECRET = env["MCP_OAUTH_CLIENT_SECRET"];
|
|
644
|
+
const REDIRECT_URIS_ENV = env["MCP_OAUTH_REDIRECT_URIS"];
|
|
645
|
+
const insecureAutoApprove = env["MCP_OAUTH_INSECURE_AUTO_APPROVE"] === "1" || env["MCP_OAUTH_INSECURE_AUTO_APPROVE"]?.toLowerCase() === "true";
|
|
646
|
+
if (CLIENT_ID && CLIENT_SECRET) {
|
|
647
|
+
const redirectUris = REDIRECT_URIS_ENV ? REDIRECT_URIS_ENV.split(",").map((s) => s.trim()).filter(Boolean) : DEFAULT_ANTHROPIC_REDIRECT_URIS;
|
|
648
|
+
if (!redirectUris.length) {
|
|
649
|
+
return {
|
|
650
|
+
error: "MCP_OAUTH_REDIRECT_URIS was set but contained no usable URIs"
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
const bad = redirectUris.find((u) => !URL.canParse(u));
|
|
654
|
+
if (bad) {
|
|
655
|
+
return {
|
|
656
|
+
error: `MCP_OAUTH_REDIRECT_URIS contains a malformed URL: ${bad}`
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
return {
|
|
660
|
+
ok: {
|
|
661
|
+
kind: "static-client",
|
|
662
|
+
clientId: CLIENT_ID,
|
|
663
|
+
clientSecret: CLIENT_SECRET,
|
|
664
|
+
redirectUris
|
|
665
|
+
}
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
if (CLIENT_ID || CLIENT_SECRET) {
|
|
669
|
+
return {
|
|
670
|
+
error: "MCP_OAUTH_CLIENT_ID and MCP_OAUTH_CLIENT_SECRET must both be set to enable static-client mode (got only one)."
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
if (insecureAutoApprove) {
|
|
674
|
+
const isLocal = publicBaseUrl2 !== void 0 && isLocalhostUrl(publicBaseUrl2);
|
|
675
|
+
const acknowledged = env["MCP_OAUTH_I_KNOW_WHAT_IM_DOING"]?.toLowerCase() === "yes";
|
|
676
|
+
if (!isLocal && !acknowledged) {
|
|
677
|
+
return {
|
|
678
|
+
error: "MCP_OAUTH_INSECURE_AUTO_APPROVE is set but PUBLIC_BASE_URL is not a localhost address. This mode lets anyone who can reach the URL register an OAuth client and use the shared Loomio API key; it must not be exposed publicly. Either:\n - Point PUBLIC_BASE_URL at http://localhost / 127.0.0.1 / ::1 (recommended), or\n - Set MCP_OAUTH_I_KNOW_WHAT_IM_DOING=yes to acknowledge the risk (only do this on a private network)."
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
return { ok: { kind: "insecure-auto-approve" } };
|
|
682
|
+
}
|
|
683
|
+
return {
|
|
684
|
+
error: "No OAuth mode configured. Either:\n - Set MCP_OAUTH_CLIENT_ID and MCP_OAUTH_CLIENT_SECRET (recommended for public deployments)\n - Or set MCP_OAUTH_INSECURE_AUTO_APPROVE=1 (only safe for local development or private-network deployments)"
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
function resolveBaseConfig(env = process.env) {
|
|
688
|
+
const publicBaseUrl2 = env["PUBLIC_BASE_URL"];
|
|
689
|
+
if (!publicBaseUrl2) {
|
|
690
|
+
return {
|
|
691
|
+
error: "PUBLIC_BASE_URL is not set. It must be the public origin of this server (e.g. https://example.run.app), used to build OAuth metadata and authorization redirect URLs."
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
if (!URL.canParse(publicBaseUrl2)) {
|
|
695
|
+
return {
|
|
696
|
+
error: `PUBLIC_BASE_URL is not a valid URL: ${publicBaseUrl2}`
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
const parsedBaseUrl = new URL(publicBaseUrl2);
|
|
700
|
+
const isLocal = isLocalHostname(parsedBaseUrl.hostname);
|
|
701
|
+
const isHttps = parsedBaseUrl.protocol === "https:";
|
|
702
|
+
const isHttpLocal = parsedBaseUrl.protocol === "http:" && isLocal;
|
|
703
|
+
if (!isHttps && !isHttpLocal) {
|
|
704
|
+
return {
|
|
705
|
+
error: `PUBLIC_BASE_URL must be https://\u2026 (or http://localhost for development); got ${parsedBaseUrl.protocol}//${parsedBaseUrl.hostname}.`
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
const signingKey2 = env["MCP_OAUTH_SIGNING_KEY"];
|
|
709
|
+
if (!signingKey2 || signingKey2.length < 16) {
|
|
710
|
+
return {
|
|
711
|
+
error: "MCP_OAUTH_SIGNING_KEY must be set and at least 16 chars long. It is the HMAC key used to sign OAuth access tokens; rotating it invalidates all outstanding tokens."
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
const portRaw = env["PORT"] ?? "8080";
|
|
715
|
+
const port2 = Number(portRaw);
|
|
716
|
+
if (!Number.isInteger(port2) || port2 < 1 || port2 > 65535) {
|
|
717
|
+
return {
|
|
718
|
+
error: `PORT must be an integer in 1..65535 (got ${JSON.stringify(portRaw)}).`
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
const jsonLimit2 = env["MCP_HTTP_JSON_LIMIT"] ?? "1mb";
|
|
722
|
+
const allowedOriginsRaw = env["MCP_ALLOWED_ORIGINS"];
|
|
723
|
+
const extraOrigins = allowedOriginsRaw ? allowedOriginsRaw.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
|
724
|
+
const parsedExtraOrigins = [];
|
|
725
|
+
for (const origin of extraOrigins) {
|
|
726
|
+
if (!URL.canParse(origin)) {
|
|
727
|
+
return {
|
|
728
|
+
error: `MCP_ALLOWED_ORIGINS contains a malformed URL: ${origin}`
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
const parsedOrigin = new URL(origin);
|
|
732
|
+
const isAllowedHttps = parsedOrigin.protocol === "https:";
|
|
733
|
+
const isAllowedHttpLocal = parsedOrigin.protocol === "http:" && isLocalHostname(parsedOrigin.hostname);
|
|
734
|
+
if (!isAllowedHttps && !isAllowedHttpLocal) {
|
|
735
|
+
return {
|
|
736
|
+
error: `MCP_ALLOWED_ORIGINS entries must be https:// origins (or http://localhost for development); got ${parsedOrigin.protocol}//${parsedOrigin.hostname}.`
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
parsedExtraOrigins.push(parsedOrigin);
|
|
740
|
+
}
|
|
741
|
+
const allowedOrigins = Array.from(
|
|
742
|
+
/* @__PURE__ */ new Set([
|
|
743
|
+
parsedBaseUrl.origin,
|
|
744
|
+
...DEFAULT_MCP_CLIENT_ORIGINS,
|
|
745
|
+
...parsedExtraOrigins.map((origin) => origin.origin)
|
|
746
|
+
])
|
|
747
|
+
);
|
|
748
|
+
const trustProxyRaw = env["MCP_HTTP_TRUST_PROXY"] ?? "1";
|
|
749
|
+
const trustProxy2 = Number(trustProxyRaw);
|
|
750
|
+
if (!Number.isInteger(trustProxy2) || trustProxy2 < 0 || trustProxy2 > 10) {
|
|
751
|
+
return {
|
|
752
|
+
error: `MCP_HTTP_TRUST_PROXY must be an integer in 0..10 (got ${JSON.stringify(trustProxyRaw)}). Use 1 for Cloud Run / single-proxy fronts, 2+ for multi-hop ingress, 0 for bare-IP deployments.`
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
return {
|
|
756
|
+
ok: { publicBaseUrl: publicBaseUrl2, signingKey: signingKey2, port: port2, jsonLimit: jsonLimit2, allowedOrigins, trustProxy: trustProxy2 }
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// src/http/app.ts
|
|
761
|
+
import express3 from "express";
|
|
762
|
+
|
|
763
|
+
// src/icon.ts
|
|
764
|
+
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">
|
|
765
|
+
<!-- Placeholder mark: letter L on a tinted circle. Visually neutral \u2014
|
|
766
|
+
does not reproduce any Loomio trademark. -->
|
|
767
|
+
<circle cx="32" cy="32" r="28" fill="#2563EB"/>
|
|
768
|
+
<path d="M22 16 L22 48 L44 48 L44 42 L28 42 L28 16 Z" fill="#FFFFFF"/>
|
|
769
|
+
</svg>`;
|
|
770
|
+
var ICON_DATA_URI = `data:image/svg+xml;base64,${Buffer.from(ICON_SVG, "utf8").toString("base64")}`;
|
|
771
|
+
var ICONS = [
|
|
772
|
+
{
|
|
773
|
+
src: ICON_DATA_URI,
|
|
774
|
+
mimeType: "image/svg+xml",
|
|
775
|
+
sizes: ["64x64", "any"]
|
|
776
|
+
}
|
|
777
|
+
];
|
|
778
|
+
|
|
779
|
+
// src/http/oauth-routes.ts
|
|
780
|
+
import { createHash as createHash2, timingSafeEqual as timingSafeEqual3 } from "crypto";
|
|
781
|
+
import express from "express";
|
|
782
|
+
import { mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
|
|
783
|
+
function secretDigest(value) {
|
|
784
|
+
return createHash2("sha256").update(value, "utf8").digest();
|
|
785
|
+
}
|
|
786
|
+
function timingSafeSecretEqual(provided, expected) {
|
|
787
|
+
return timingSafeEqual3(secretDigest(provided), secretDigest(expected));
|
|
788
|
+
}
|
|
789
|
+
function mountOAuthRoutes(app2, opts) {
|
|
790
|
+
const { oauthProvider: oauthProvider2, issuerUrl: issuerUrl2, resourceName, mcpResourceUrl: mcpResourceUrl2 } = opts;
|
|
791
|
+
app2.post("/token", express.urlencoded({ extended: false }), async (req, res, next) => {
|
|
792
|
+
const sendInvalidClient = (description) => {
|
|
793
|
+
res.status(401).json({
|
|
794
|
+
error: "invalid_client",
|
|
795
|
+
error_description: description
|
|
796
|
+
});
|
|
797
|
+
};
|
|
798
|
+
const body = req.body ?? {};
|
|
799
|
+
const clientId = typeof body["client_id"] === "string" ? body["client_id"] : void 0;
|
|
800
|
+
const providedSecret = typeof body["client_secret"] === "string" ? body["client_secret"] : void 0;
|
|
801
|
+
if (!clientId) {
|
|
802
|
+
sendInvalidClient("client credentials required");
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
let expected;
|
|
806
|
+
try {
|
|
807
|
+
expected = await oauthProvider2.clientsStore.getClient(clientId);
|
|
808
|
+
} catch {
|
|
809
|
+
res.status(500).json({
|
|
810
|
+
error: "server_error",
|
|
811
|
+
error_description: "client lookup failed"
|
|
812
|
+
});
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
const expectedSecret = expected && typeof expected.client_secret === "string" && expected.client_secret ? expected.client_secret : "";
|
|
816
|
+
const secretsMatch = timingSafeSecretEqual(providedSecret ?? "", expectedSecret);
|
|
817
|
+
if (!expected) {
|
|
818
|
+
sendInvalidClient("client authentication failed");
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
if (!expectedSecret) {
|
|
822
|
+
next();
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
const expiresAt = expected.client_secret_expires_at;
|
|
826
|
+
const secretExpired = typeof expiresAt === "number" && expiresAt !== 0 && expiresAt < Math.floor(Date.now() / 1e3);
|
|
827
|
+
if (providedSecret === void 0 || !secretsMatch || secretExpired) {
|
|
828
|
+
sendInvalidClient("client authentication failed");
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
next();
|
|
832
|
+
});
|
|
833
|
+
app2.use(
|
|
834
|
+
mcpAuthRouter({
|
|
835
|
+
provider: oauthProvider2,
|
|
836
|
+
issuerUrl: issuerUrl2,
|
|
837
|
+
scopesSupported: [],
|
|
838
|
+
resourceName,
|
|
839
|
+
resourceServerUrl: mcpResourceUrl2
|
|
840
|
+
})
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// src/http/transport.ts
|
|
845
|
+
import express2 from "express";
|
|
846
|
+
import { ipKeyGenerator, rateLimit } from "express-rate-limit";
|
|
847
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
848
|
+
import { getOAuthProtectedResourceMetadataUrl } from "@modelcontextprotocol/sdk/server/auth/router.js";
|
|
849
|
+
import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
|
|
850
|
+
import { SUPPORTED_PROTOCOL_VERSIONS } from "@modelcontextprotocol/sdk/types.js";
|
|
851
|
+
|
|
852
|
+
// src/server.ts
|
|
853
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
854
|
+
|
|
855
|
+
// src/server/register-tool.ts
|
|
856
|
+
var READ_PREFIXES = ["search_", "filter_", "get_", "list_", "show_", "run_"];
|
|
857
|
+
function isReadOnlyByName(name) {
|
|
858
|
+
return READ_PREFIXES.some((p) => name.startsWith(p));
|
|
859
|
+
}
|
|
860
|
+
function isDestructive(name) {
|
|
861
|
+
return name.startsWith("delete_") || name === "manage_memberships" || name === "deactivate_user";
|
|
862
|
+
}
|
|
863
|
+
function inferAnnotations(name) {
|
|
864
|
+
const readOnly = isReadOnlyByName(name);
|
|
865
|
+
return {
|
|
866
|
+
readOnlyHint: readOnly,
|
|
867
|
+
destructiveHint: isDestructive(name),
|
|
868
|
+
idempotentHint: readOnly,
|
|
869
|
+
openWorldHint: true
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
function argFieldNames(input) {
|
|
873
|
+
if (input === null || typeof input !== "object" || Array.isArray(input)) return [];
|
|
874
|
+
return Object.keys(input);
|
|
875
|
+
}
|
|
876
|
+
function emitToolCall(opts) {
|
|
877
|
+
logEvent("tool.call", {
|
|
878
|
+
tool: opts.tool,
|
|
879
|
+
...opts.clientId ? { clientId: opts.clientId } : {},
|
|
880
|
+
argFields: opts.argFields,
|
|
881
|
+
durationMs: Date.now() - opts.startedAt,
|
|
882
|
+
outcome: opts.outcome
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
function wrapAsText(result) {
|
|
886
|
+
return {
|
|
887
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
function registerTool(server, name, description, schema, handler) {
|
|
891
|
+
const registerWithSchema = server.registerTool.bind(server);
|
|
892
|
+
registerWithSchema(
|
|
893
|
+
name,
|
|
894
|
+
{ description, inputSchema: schema, annotations: inferAnnotations(name) },
|
|
895
|
+
async (input) => {
|
|
896
|
+
const startedAt = Date.now();
|
|
897
|
+
const argFields = argFieldNames(input);
|
|
898
|
+
const clientId = getRequestContext()?.clientId;
|
|
899
|
+
try {
|
|
900
|
+
const result = await handler(input);
|
|
901
|
+
emitToolCall({ tool: name, clientId, argFields, startedAt, outcome: "success" });
|
|
902
|
+
return wrapAsText(result);
|
|
903
|
+
} catch (err) {
|
|
904
|
+
emitToolCall({ tool: name, clientId, argFields, startedAt, outcome: "error" });
|
|
905
|
+
throw err;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// src/tools/discussions.ts
|
|
912
|
+
import { z as z3 } from "zod";
|
|
913
|
+
|
|
914
|
+
// src/tools/_common.ts
|
|
915
|
+
import { z as z2 } from "zod";
|
|
916
|
+
var positiveId = z2.number().int().positive();
|
|
917
|
+
var loomioKey = z2.string().regex(/^[A-Za-z0-9_-]+$/, "Loomio short keys may only contain letters, numbers, _ and -.");
|
|
918
|
+
var idOrKey = z2.union([loomioKey, positiveId]);
|
|
919
|
+
function encodePathSegment(value) {
|
|
920
|
+
const raw = String(value);
|
|
921
|
+
if (raw === "" || raw === "." || raw === "..") {
|
|
922
|
+
throw new Error("id_or_key must be a non-empty Loomio id or short key.");
|
|
923
|
+
}
|
|
924
|
+
return encodeURIComponent(raw);
|
|
925
|
+
}
|
|
926
|
+
var PollTypeEnum = z2.enum([
|
|
927
|
+
"proposal",
|
|
928
|
+
"poll",
|
|
929
|
+
"count",
|
|
930
|
+
"score",
|
|
931
|
+
"ranked_choice",
|
|
932
|
+
"meeting",
|
|
933
|
+
"dot_vote"
|
|
934
|
+
]);
|
|
935
|
+
|
|
936
|
+
// src/tools/discussions.ts
|
|
937
|
+
var getDiscussionSchema = z3.object({
|
|
938
|
+
id_or_key: idOrKey.describe(
|
|
939
|
+
"Discussion id (numeric) or short string key (e.g. 'abcDEF12'). Loomio accepts either."
|
|
940
|
+
)
|
|
941
|
+
});
|
|
942
|
+
async function getDiscussion(input) {
|
|
943
|
+
return loomioGet(`/b2/discussions/${encodePathSegment(input.id_or_key)}`);
|
|
944
|
+
}
|
|
945
|
+
var listDiscussionsSchema = z3.object({
|
|
946
|
+
group_id: positiveId.describe("ID of the Loomio group whose discussions to list (required)."),
|
|
947
|
+
status: z3.enum(["open", "closed", "all"]).optional().describe(
|
|
948
|
+
"Filter by status. 'open' = unlocked, 'closed' = locked, 'all' = every kept discussion. Loomio defaults to 'open'."
|
|
949
|
+
),
|
|
950
|
+
limit: z3.number().int().min(1).max(200).optional().describe("Page size. Loomio defaults to 50."),
|
|
951
|
+
offset: z3.number().int().min(0).optional().describe("Page offset. Defaults to 0.")
|
|
952
|
+
});
|
|
953
|
+
async function listDiscussions(input) {
|
|
954
|
+
return loomioGet("/b2/discussions", {
|
|
955
|
+
group_id: input.group_id,
|
|
956
|
+
...input.status ? { status: input.status } : {},
|
|
957
|
+
...input.limit !== void 0 ? { limit: input.limit } : {},
|
|
958
|
+
...input.offset !== void 0 ? { offset: input.offset } : {}
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
var createDiscussionSchema = z3.object({
|
|
962
|
+
title: z3.string().min(1).describe("Discussion title (required)."),
|
|
963
|
+
group_id: positiveId.describe("ID of the Loomio group to create the discussion in (required)."),
|
|
964
|
+
description: z3.string().optional().describe("Optional discussion body."),
|
|
965
|
+
description_format: z3.enum(["md", "html"]).optional().describe("Format of `description`. Defaults to Loomio's group default when omitted."),
|
|
966
|
+
private: z3.boolean().optional().describe(
|
|
967
|
+
"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."
|
|
968
|
+
),
|
|
969
|
+
recipient_audience: z3.enum(["group"]).optional().describe("Audience selector for notification recipients. 'group' = notify whole group."),
|
|
970
|
+
recipient_user_ids: z3.array(positiveId).optional().describe("Explicit list of user IDs to notify."),
|
|
971
|
+
recipient_emails: z3.array(z3.string().email()).optional().describe("Explicit list of email addresses to notify."),
|
|
972
|
+
recipient_message: z3.string().optional().describe("Optional custom message to include in notifications.")
|
|
973
|
+
});
|
|
974
|
+
async function resolveDiscussionPrivate(groupId) {
|
|
975
|
+
try {
|
|
976
|
+
const resp = await loomioGet(`/v1/groups/${groupId}`);
|
|
977
|
+
const opts = resp.groups?.[0]?.discussion_privacy_options;
|
|
978
|
+
return opts !== "public_only";
|
|
979
|
+
} catch (err) {
|
|
980
|
+
if ((err instanceof LoomioAuthError || err instanceof LoomioApiError) && err.status === 403) {
|
|
981
|
+
return true;
|
|
982
|
+
}
|
|
983
|
+
throw err;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
async function createDiscussion(input) {
|
|
987
|
+
if (isReadOnly()) throw new LoomioReadOnlyError("POST");
|
|
988
|
+
const priv = input.private !== void 0 ? input.private : await resolveDiscussionPrivate(input.group_id);
|
|
989
|
+
return loomioPost("/b2/discussions", { ...input, private: priv });
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// src/tools/polls.ts
|
|
993
|
+
import { z as z4 } from "zod";
|
|
994
|
+
var getPollSchema = z4.object({
|
|
995
|
+
id_or_key: idOrKey.describe("Poll id (numeric) or short string key.")
|
|
996
|
+
});
|
|
997
|
+
async function getPoll(input) {
|
|
998
|
+
return loomioGet(`/b2/polls/${encodePathSegment(input.id_or_key)}`);
|
|
999
|
+
}
|
|
1000
|
+
var listPollsSchema = z4.object({
|
|
1001
|
+
group_id: positiveId.describe("ID of the Loomio group whose polls to list (required)."),
|
|
1002
|
+
status: z4.enum(["active", "closed", "all"]).optional().describe("Filter polls by status. Loomio defaults to 'active'."),
|
|
1003
|
+
limit: z4.number().int().min(1).max(200).optional().describe("Page size. Loomio defaults to 50."),
|
|
1004
|
+
offset: z4.number().int().min(0).optional().describe("Page offset. Defaults to 0.")
|
|
1005
|
+
});
|
|
1006
|
+
async function listPolls(input) {
|
|
1007
|
+
return loomioGet("/b2/polls", {
|
|
1008
|
+
group_id: input.group_id,
|
|
1009
|
+
...input.status ? { status: input.status } : {},
|
|
1010
|
+
...input.limit !== void 0 ? { limit: input.limit } : {},
|
|
1011
|
+
...input.offset !== void 0 ? { offset: input.offset } : {}
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
var createPollSchema = z4.object({
|
|
1015
|
+
title: z4.string().min(1).describe("Poll title (required)."),
|
|
1016
|
+
poll_type: PollTypeEnum.describe(
|
|
1017
|
+
"Poll type. One of: proposal, poll, count, score, ranked_choice, meeting, dot_vote."
|
|
1018
|
+
),
|
|
1019
|
+
group_id: positiveId.optional().describe(
|
|
1020
|
+
"Group the poll belongs to. Required when not attaching to an existing discussion via discussion_id."
|
|
1021
|
+
),
|
|
1022
|
+
discussion_id: positiveId.optional().describe(
|
|
1023
|
+
"Attach the poll to an existing discussion. When set, group_id is taken from the discussion."
|
|
1024
|
+
),
|
|
1025
|
+
details: z4.string().optional().describe("Optional poll body / context."),
|
|
1026
|
+
details_format: z4.enum(["md", "html"]).optional().describe("Format of `details`. Defaults to 'md'."),
|
|
1027
|
+
options: z4.array(z4.string().min(1)).optional().describe(
|
|
1028
|
+
"Voting options. `proposal` has built-in agree/disagree/abstain options; for poll / count / score / ranked_choice / meeting / dot_vote you MUST supply your own."
|
|
1029
|
+
),
|
|
1030
|
+
closing_at: z4.string().optional().describe("ISO-8601 timestamp at which the poll closes."),
|
|
1031
|
+
specified_voters_only: z4.boolean().optional().describe("If true, only users in recipient_user_ids / recipient_emails can vote."),
|
|
1032
|
+
hide_results: z4.enum(["off", "until_vote", "until_closed"]).optional().describe("Results visibility policy. Defaults to 'off'."),
|
|
1033
|
+
shuffle_options: z4.boolean().optional().describe("If true, shuffle option display order."),
|
|
1034
|
+
anonymous: z4.boolean().optional().describe("If true, hide voter identities."),
|
|
1035
|
+
recipient_audience: z4.enum(["group"]).optional(),
|
|
1036
|
+
notify_on_closing_soon: z4.enum(["nobody", "author", "voters", "undecided_voters", "all_members"]).optional().describe("Who Loomio notifies as the closing date approaches. Defaults to 'nobody'."),
|
|
1037
|
+
recipient_user_ids: z4.array(positiveId).optional(),
|
|
1038
|
+
recipient_emails: z4.array(z4.string().email()).optional(),
|
|
1039
|
+
recipient_message: z4.string().optional(),
|
|
1040
|
+
notify_recipients: z4.boolean().optional().describe("If false, suppress the initial notification email. Defaults to false.")
|
|
1041
|
+
}).superRefine((input, ctx) => {
|
|
1042
|
+
if (input.group_id === void 0 && input.discussion_id === void 0) {
|
|
1043
|
+
ctx.addIssue({
|
|
1044
|
+
code: "custom",
|
|
1045
|
+
path: ["group_id"],
|
|
1046
|
+
message: "Either group_id or discussion_id is required."
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
if (input.poll_type !== "proposal" && !input.options?.length) {
|
|
1050
|
+
ctx.addIssue({
|
|
1051
|
+
code: "custom",
|
|
1052
|
+
path: ["options"],
|
|
1053
|
+
message: "options is required for non-proposal poll types."
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
});
|
|
1057
|
+
async function createPoll(input) {
|
|
1058
|
+
return loomioPost("/b2/polls", input);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// src/tools/memberships.ts
|
|
1062
|
+
import { z as z5 } from "zod";
|
|
1063
|
+
var listMembershipsSchema = z5.object({
|
|
1064
|
+
group_id: positiveId.describe(
|
|
1065
|
+
"ID of the Loomio group whose memberships to list (required). Caller must be a group admin; the response includes member email addresses."
|
|
1066
|
+
),
|
|
1067
|
+
limit: z5.number().int().min(1).max(200).optional().describe("Page size. Loomio defaults to 50."),
|
|
1068
|
+
offset: z5.number().int().min(0).optional().describe("Page offset. Defaults to 0.")
|
|
1069
|
+
});
|
|
1070
|
+
async function listMemberships(input) {
|
|
1071
|
+
return loomioGet("/b2/memberships", {
|
|
1072
|
+
group_id: input.group_id,
|
|
1073
|
+
...input.limit !== void 0 ? { limit: input.limit } : {},
|
|
1074
|
+
...input.offset !== void 0 ? { offset: input.offset } : {}
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
var manageMembershipsSchema = z5.object({
|
|
1078
|
+
group_id: positiveId.describe(
|
|
1079
|
+
"ID of the Loomio group to modify (required). Caller must be a group admin."
|
|
1080
|
+
),
|
|
1081
|
+
emails: z5.array(z5.string().email()).min(1).describe(
|
|
1082
|
+
"Email addresses to ensure are members. Each address that isn't already a member is invited / added."
|
|
1083
|
+
),
|
|
1084
|
+
remove_absent: z5.boolean().optional().describe(
|
|
1085
|
+
"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."
|
|
1086
|
+
)
|
|
1087
|
+
});
|
|
1088
|
+
async function manageMemberships(input) {
|
|
1089
|
+
return loomioPost("/b2/memberships", input);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// src/tools/groups.ts
|
|
1093
|
+
import { z as z6 } from "zod";
|
|
1094
|
+
var DEFAULT_START_ID = 1;
|
|
1095
|
+
var DEFAULT_END_ID = 200;
|
|
1096
|
+
var MAX_END_ID = 1e4;
|
|
1097
|
+
var MAX_PROBE_SPAN = 500;
|
|
1098
|
+
var CONCURRENCY = 5;
|
|
1099
|
+
var listGroupsSchema = z6.object({
|
|
1100
|
+
start_id: z6.number().int().min(1).optional().describe("First group_id to probe (inclusive). Defaults to 1."),
|
|
1101
|
+
end_id: z6.number().int().min(1).max(MAX_END_ID).optional().describe(
|
|
1102
|
+
"Last group_id to probe (inclusive). Defaults to 200. A single call may scan at most 500 ids; use multiple calls for wider ranges."
|
|
1103
|
+
),
|
|
1104
|
+
stop_after_consecutive_misses: z6.number().int().min(1).max(MAX_PROBE_SPAN).optional().describe(
|
|
1105
|
+
"Early-exit heuristic: stop probing after this many consecutive 404/403 misses. Saves wall time on sparse id ranges. Defaults to 50."
|
|
1106
|
+
)
|
|
1107
|
+
}).superRefine((input, ctx) => {
|
|
1108
|
+
const start = input.start_id ?? DEFAULT_START_ID;
|
|
1109
|
+
const end = input.end_id ?? DEFAULT_END_ID;
|
|
1110
|
+
if (end < start) {
|
|
1111
|
+
ctx.addIssue({
|
|
1112
|
+
code: "custom",
|
|
1113
|
+
path: ["end_id"],
|
|
1114
|
+
message: "end_id must be greater than or equal to start_id."
|
|
1115
|
+
});
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
if (end - start + 1 > MAX_PROBE_SPAN) {
|
|
1119
|
+
ctx.addIssue({
|
|
1120
|
+
code: "custom",
|
|
1121
|
+
path: ["end_id"],
|
|
1122
|
+
message: `A single list_groups call may scan at most ${MAX_PROBE_SPAN} ids.`
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
});
|
|
1126
|
+
async function probeOne(id) {
|
|
1127
|
+
try {
|
|
1128
|
+
const resp = await loomioGet("/b2/polls", {
|
|
1129
|
+
group_id: id,
|
|
1130
|
+
limit: 1,
|
|
1131
|
+
status: "all"
|
|
1132
|
+
});
|
|
1133
|
+
return { id, groups: resp.groups ?? [] };
|
|
1134
|
+
} catch (err) {
|
|
1135
|
+
if (err instanceof LoomioApiError && err.status === 404) return { id, groups: [] };
|
|
1136
|
+
if (err instanceof LoomioAuthError && err.status === 403) return { id, groups: [] };
|
|
1137
|
+
throw err;
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
function slim(g) {
|
|
1141
|
+
return {
|
|
1142
|
+
id: g.id,
|
|
1143
|
+
key: g.key,
|
|
1144
|
+
handle: g.handle,
|
|
1145
|
+
name: g.name,
|
|
1146
|
+
parent_id: g.parent_id,
|
|
1147
|
+
discussion_privacy_options: g.discussion_privacy_options,
|
|
1148
|
+
is_visible_to_public: g.is_visible_to_public,
|
|
1149
|
+
memberships_count: g.memberships_count
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
async function listGroups(input) {
|
|
1153
|
+
const parsed = listGroupsSchema.parse(input);
|
|
1154
|
+
const start = parsed.start_id ?? DEFAULT_START_ID;
|
|
1155
|
+
const end = parsed.end_id ?? DEFAULT_END_ID;
|
|
1156
|
+
const maxMisses = parsed.stop_after_consecutive_misses ?? 50;
|
|
1157
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
1158
|
+
const found = [];
|
|
1159
|
+
let consecutiveMisses = 0;
|
|
1160
|
+
let earlyExit = false;
|
|
1161
|
+
let lastScanned = start - 1;
|
|
1162
|
+
for (let batchStart = start; batchStart <= end; batchStart += CONCURRENCY) {
|
|
1163
|
+
const ids = [];
|
|
1164
|
+
for (let id = batchStart; id < batchStart + CONCURRENCY && id <= end; id++) {
|
|
1165
|
+
ids.push(id);
|
|
1166
|
+
}
|
|
1167
|
+
const results = await Promise.all(ids.map((id) => probeOne(id)));
|
|
1168
|
+
for (const r of results) {
|
|
1169
|
+
lastScanned = r.id;
|
|
1170
|
+
if (r.groups.length > 0) {
|
|
1171
|
+
for (const g of r.groups) {
|
|
1172
|
+
if (!seenIds.has(g.id)) {
|
|
1173
|
+
seenIds.add(g.id);
|
|
1174
|
+
found.push(slim(g));
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
consecutiveMisses = 0;
|
|
1178
|
+
} else {
|
|
1179
|
+
consecutiveMisses++;
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
if (consecutiveMisses >= maxMisses) {
|
|
1183
|
+
earlyExit = true;
|
|
1184
|
+
break;
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
return {
|
|
1188
|
+
groups: found,
|
|
1189
|
+
scanned: {
|
|
1190
|
+
from: start,
|
|
1191
|
+
to: lastScanned,
|
|
1192
|
+
stopped_early: earlyExit,
|
|
1193
|
+
total_found: found.length
|
|
1194
|
+
}
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// src/tools/comments.ts
|
|
1199
|
+
import { z as z7 } from "zod";
|
|
1200
|
+
var createCommentSchema = z7.object({
|
|
1201
|
+
discussion_id: positiveId.describe("ID of the discussion to comment on."),
|
|
1202
|
+
body: z7.string().min(1).describe("Comment body (required)."),
|
|
1203
|
+
body_format: z7.enum(["md", "html"]).optional().describe("Format of `body`. Defaults to Loomio's group default when omitted.")
|
|
1204
|
+
});
|
|
1205
|
+
async function createComment(input) {
|
|
1206
|
+
const { discussion_id, ...rest } = input;
|
|
1207
|
+
return loomioPost("/b2/comments", rest, {
|
|
1208
|
+
params: { discussion_id },
|
|
1209
|
+
encoding: "form"
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// src/tools/admin.ts
|
|
1214
|
+
import { z as z8 } from "zod";
|
|
1215
|
+
var deactivateUserSchema = z8.object({
|
|
1216
|
+
id: positiveId.describe("Loomio user id to deactivate. Returns 404 if not active.")
|
|
1217
|
+
});
|
|
1218
|
+
async function deactivateUser(input) {
|
|
1219
|
+
return loomioPostB3("/b3/users/deactivate", { id: input.id });
|
|
1220
|
+
}
|
|
1221
|
+
var reactivateUserSchema = z8.object({
|
|
1222
|
+
id: positiveId.describe(
|
|
1223
|
+
"Loomio user id to reactivate. Returns 404 if not currently deactivated."
|
|
1224
|
+
)
|
|
1225
|
+
});
|
|
1226
|
+
async function reactivateUser(input) {
|
|
1227
|
+
return loomioPostB3("/b3/users/reactivate", { id: input.id });
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// src/server.ts
|
|
1231
|
+
function createLoomioMcpServer() {
|
|
1232
|
+
const readOnly = isReadOnly();
|
|
1233
|
+
const b3Enabled = hasB3ApiKey();
|
|
1234
|
+
const server = new McpServer({
|
|
1235
|
+
name: "loomiomcp",
|
|
1236
|
+
version: "0.1.0",
|
|
1237
|
+
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.",
|
|
1238
|
+
websiteUrl: "https://github.com/soil-dev/loomiomcp",
|
|
1239
|
+
icons: ICONS
|
|
1240
|
+
});
|
|
1241
|
+
registerTool(
|
|
1242
|
+
server,
|
|
1243
|
+
"get_discussion",
|
|
1244
|
+
"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.",
|
|
1245
|
+
getDiscussionSchema,
|
|
1246
|
+
getDiscussion
|
|
1247
|
+
);
|
|
1248
|
+
registerTool(
|
|
1249
|
+
server,
|
|
1250
|
+
"list_discussions",
|
|
1251
|
+
"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.",
|
|
1252
|
+
listDiscussionsSchema,
|
|
1253
|
+
listDiscussions
|
|
1254
|
+
);
|
|
1255
|
+
if (!readOnly) {
|
|
1256
|
+
registerTool(
|
|
1257
|
+
server,
|
|
1258
|
+
"create_discussion",
|
|
1259
|
+
"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.",
|
|
1260
|
+
createDiscussionSchema,
|
|
1261
|
+
createDiscussion
|
|
1262
|
+
);
|
|
1263
|
+
}
|
|
1264
|
+
registerTool(
|
|
1265
|
+
server,
|
|
1266
|
+
"get_poll",
|
|
1267
|
+
"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.",
|
|
1268
|
+
getPollSchema,
|
|
1269
|
+
getPoll
|
|
1270
|
+
);
|
|
1271
|
+
registerTool(
|
|
1272
|
+
server,
|
|
1273
|
+
"list_polls",
|
|
1274
|
+
"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.",
|
|
1275
|
+
listPollsSchema,
|
|
1276
|
+
listPolls
|
|
1277
|
+
);
|
|
1278
|
+
if (!readOnly) {
|
|
1279
|
+
registerTool(
|
|
1280
|
+
server,
|
|
1281
|
+
"create_poll",
|
|
1282
|
+
"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.",
|
|
1283
|
+
createPollSchema,
|
|
1284
|
+
createPoll
|
|
1285
|
+
);
|
|
1286
|
+
}
|
|
1287
|
+
registerTool(
|
|
1288
|
+
server,
|
|
1289
|
+
"list_memberships",
|
|
1290
|
+
"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.",
|
|
1291
|
+
listMembershipsSchema,
|
|
1292
|
+
listMemberships
|
|
1293
|
+
);
|
|
1294
|
+
registerTool(
|
|
1295
|
+
server,
|
|
1296
|
+
"list_groups",
|
|
1297
|
+
"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.",
|
|
1298
|
+
listGroupsSchema,
|
|
1299
|
+
listGroups
|
|
1300
|
+
);
|
|
1301
|
+
if (!readOnly) {
|
|
1302
|
+
registerTool(
|
|
1303
|
+
server,
|
|
1304
|
+
"manage_memberships",
|
|
1305
|
+
"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.",
|
|
1306
|
+
manageMembershipsSchema,
|
|
1307
|
+
manageMemberships
|
|
1308
|
+
);
|
|
1309
|
+
}
|
|
1310
|
+
if (!readOnly) {
|
|
1311
|
+
registerTool(
|
|
1312
|
+
server,
|
|
1313
|
+
"create_comment",
|
|
1314
|
+
"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.",
|
|
1315
|
+
createCommentSchema,
|
|
1316
|
+
createComment
|
|
1317
|
+
);
|
|
1318
|
+
}
|
|
1319
|
+
if (b3Enabled && !readOnly) {
|
|
1320
|
+
registerTool(
|
|
1321
|
+
server,
|
|
1322
|
+
"deactivate_user",
|
|
1323
|
+
"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.",
|
|
1324
|
+
deactivateUserSchema,
|
|
1325
|
+
deactivateUser
|
|
1326
|
+
);
|
|
1327
|
+
registerTool(
|
|
1328
|
+
server,
|
|
1329
|
+
"reactivate_user",
|
|
1330
|
+
"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.",
|
|
1331
|
+
reactivateUserSchema,
|
|
1332
|
+
reactivateUser
|
|
1333
|
+
);
|
|
1334
|
+
}
|
|
1335
|
+
return server;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// src/http/transport.ts
|
|
1339
|
+
var DEFAULT_MCP_RATE_LIMIT_WINDOW_MS = 6e4;
|
|
1340
|
+
var DEFAULT_MCP_RATE_LIMIT_MAX = 600;
|
|
1341
|
+
var MAX_MEMORY_STORE_WINDOW_MS = 2 ** 31 - 1;
|
|
1342
|
+
function resolveMcpRateLimitConfig() {
|
|
1343
|
+
const windowMs = Math.min(
|
|
1344
|
+
readPositiveInt("MCP_HTTP_RATE_LIMIT_WINDOW_MS", DEFAULT_MCP_RATE_LIMIT_WINDOW_MS),
|
|
1345
|
+
MAX_MEMORY_STORE_WINDOW_MS
|
|
1346
|
+
);
|
|
1347
|
+
return {
|
|
1348
|
+
windowMs,
|
|
1349
|
+
limit: readPositiveInt("MCP_HTTP_RATE_LIMIT_MAX", DEFAULT_MCP_RATE_LIMIT_MAX),
|
|
1350
|
+
disabled: process.env["MCP_HTTP_RATE_LIMIT_DISABLED"] === "1"
|
|
1351
|
+
};
|
|
1352
|
+
}
|
|
1353
|
+
function mountTransport(app2, opts) {
|
|
1354
|
+
const { oauthProvider: oauthProvider2, mcpResourceUrl: mcpResourceUrl2, jsonLimit: jsonLimit2, allowedOrigins } = opts;
|
|
1355
|
+
const mcpResourceMetadataUrl = getOAuthProtectedResourceMetadataUrl(mcpResourceUrl2);
|
|
1356
|
+
const guardOrigin = (req, res, next) => {
|
|
1357
|
+
const origin = req.get("Origin");
|
|
1358
|
+
if (!origin) {
|
|
1359
|
+
next();
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
let normalizedOrigin;
|
|
1363
|
+
try {
|
|
1364
|
+
normalizedOrigin = new URL(origin).origin;
|
|
1365
|
+
} catch {
|
|
1366
|
+
res.status(403).json({
|
|
1367
|
+
jsonrpc: "2.0",
|
|
1368
|
+
error: { code: -32e3, message: "Invalid Origin header" },
|
|
1369
|
+
id: null
|
|
1370
|
+
});
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
if (!allowedOrigins.includes(normalizedOrigin)) {
|
|
1374
|
+
res.status(403).json({
|
|
1375
|
+
jsonrpc: "2.0",
|
|
1376
|
+
error: { code: -32e3, message: "Origin is not allowed" },
|
|
1377
|
+
id: null
|
|
1378
|
+
});
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
next();
|
|
1382
|
+
};
|
|
1383
|
+
const {
|
|
1384
|
+
windowMs: rateLimitWindowMs,
|
|
1385
|
+
limit: rateLimitMax,
|
|
1386
|
+
disabled: rateLimitDisabled
|
|
1387
|
+
} = resolveMcpRateLimitConfig();
|
|
1388
|
+
const mcpRateLimit = rateLimit({
|
|
1389
|
+
windowMs: rateLimitWindowMs,
|
|
1390
|
+
limit: rateLimitMax,
|
|
1391
|
+
standardHeaders: "draft-7",
|
|
1392
|
+
legacyHeaders: false,
|
|
1393
|
+
keyGenerator: (req) => {
|
|
1394
|
+
const clientId = req.auth?.clientId;
|
|
1395
|
+
if (clientId) return clientId;
|
|
1396
|
+
return ipKeyGenerator(req.ip ?? "");
|
|
1397
|
+
},
|
|
1398
|
+
skip: () => rateLimitDisabled,
|
|
1399
|
+
handler: (_req, res) => {
|
|
1400
|
+
res.status(429).json({
|
|
1401
|
+
jsonrpc: "2.0",
|
|
1402
|
+
error: { code: -32e3, message: "Too Many Requests" },
|
|
1403
|
+
id: null
|
|
1404
|
+
});
|
|
1405
|
+
}
|
|
1406
|
+
});
|
|
1407
|
+
const guardProtocolVersion = (req, res, next) => {
|
|
1408
|
+
const protocolVersion = req.get("MCP-Protocol-Version");
|
|
1409
|
+
if (protocolVersion && !SUPPORTED_PROTOCOL_VERSIONS.includes(protocolVersion)) {
|
|
1410
|
+
res.status(400).json({
|
|
1411
|
+
jsonrpc: "2.0",
|
|
1412
|
+
error: {
|
|
1413
|
+
code: -32e3,
|
|
1414
|
+
message: `Bad Request: Unsupported protocol version: ${protocolVersion}`
|
|
1415
|
+
},
|
|
1416
|
+
id: null
|
|
1417
|
+
});
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1420
|
+
next();
|
|
1421
|
+
};
|
|
1422
|
+
app2.post(
|
|
1423
|
+
"/mcp",
|
|
1424
|
+
guardOrigin,
|
|
1425
|
+
requireBearerAuth({
|
|
1426
|
+
verifier: oauthProvider2,
|
|
1427
|
+
resourceMetadataUrl: mcpResourceMetadataUrl
|
|
1428
|
+
}),
|
|
1429
|
+
mcpRateLimit,
|
|
1430
|
+
guardProtocolVersion,
|
|
1431
|
+
express2.json({ limit: jsonLimit2 }),
|
|
1432
|
+
async (req, res) => {
|
|
1433
|
+
try {
|
|
1434
|
+
const clientId = req.auth?.clientId;
|
|
1435
|
+
const server = createLoomioMcpServer();
|
|
1436
|
+
const transport = new StreamableHTTPServerTransport({});
|
|
1437
|
+
res.on("close", () => {
|
|
1438
|
+
void transport.close();
|
|
1439
|
+
void server.close();
|
|
1440
|
+
});
|
|
1441
|
+
await withRequestContext({ clientId }, async () => {
|
|
1442
|
+
await server.connect(transport);
|
|
1443
|
+
await transport.handleRequest(req, res, req.body);
|
|
1444
|
+
});
|
|
1445
|
+
} catch (err) {
|
|
1446
|
+
const name = err instanceof Error ? err.name : typeof err;
|
|
1447
|
+
const status = err && typeof err === "object" && "status" in err ? Number(err.status) : void 0;
|
|
1448
|
+
const summary = status !== void 0 ? `${name} ${status}` : name;
|
|
1449
|
+
if (process.env["MCP_HTTP_DEBUG"] === "1") {
|
|
1450
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1451
|
+
console.error(`[loomiomcp] /mcp error: ${summary} \u2014 ${message}`);
|
|
1452
|
+
} else {
|
|
1453
|
+
console.error(`[loomiomcp] /mcp error: ${summary}`);
|
|
1454
|
+
}
|
|
1455
|
+
if (!res.headersSent) {
|
|
1456
|
+
res.status(500).json({ error: "internal_error" });
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
);
|
|
1461
|
+
const methodNotAllowed = (_req, res) => {
|
|
1462
|
+
res.set("Allow", "POST").status(405).json({
|
|
1463
|
+
error: "method_not_allowed",
|
|
1464
|
+
message: "Use POST for MCP requests; this server runs in stateless mode."
|
|
1465
|
+
});
|
|
1466
|
+
};
|
|
1467
|
+
app2.get(
|
|
1468
|
+
"/mcp",
|
|
1469
|
+
guardOrigin,
|
|
1470
|
+
requireBearerAuth({
|
|
1471
|
+
verifier: oauthProvider2,
|
|
1472
|
+
resourceMetadataUrl: mcpResourceMetadataUrl
|
|
1473
|
+
}),
|
|
1474
|
+
mcpRateLimit,
|
|
1475
|
+
guardProtocolVersion,
|
|
1476
|
+
methodNotAllowed
|
|
1477
|
+
);
|
|
1478
|
+
app2.delete(
|
|
1479
|
+
"/mcp",
|
|
1480
|
+
guardOrigin,
|
|
1481
|
+
requireBearerAuth({
|
|
1482
|
+
verifier: oauthProvider2,
|
|
1483
|
+
resourceMetadataUrl: mcpResourceMetadataUrl
|
|
1484
|
+
}),
|
|
1485
|
+
mcpRateLimit,
|
|
1486
|
+
guardProtocolVersion,
|
|
1487
|
+
methodNotAllowed
|
|
1488
|
+
);
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
// src/http/app.ts
|
|
1492
|
+
function createApp(opts) {
|
|
1493
|
+
const { oauthProvider: oauthProvider2, issuerUrl: issuerUrl2, jsonLimit: jsonLimit2, allowedOrigins } = opts;
|
|
1494
|
+
const resourceName = opts.resourceName ?? "Loomio MCP";
|
|
1495
|
+
const trustProxy2 = opts.trustProxy ?? 1;
|
|
1496
|
+
const mcpResourceUrl2 = new URL("/mcp", issuerUrl2);
|
|
1497
|
+
const app2 = express3();
|
|
1498
|
+
app2.set("trust proxy", trustProxy2);
|
|
1499
|
+
mountOAuthRoutes(app2, {
|
|
1500
|
+
oauthProvider: oauthProvider2,
|
|
1501
|
+
issuerUrl: issuerUrl2,
|
|
1502
|
+
resourceName,
|
|
1503
|
+
mcpResourceUrl: mcpResourceUrl2
|
|
1504
|
+
});
|
|
1505
|
+
const iconHandler = (_req, res) => {
|
|
1506
|
+
res.set("Content-Type", "image/svg+xml").set("Cache-Control", "public, max-age=86400").send(ICON_SVG);
|
|
1507
|
+
};
|
|
1508
|
+
app2.get("/icon.svg", iconHandler);
|
|
1509
|
+
app2.get("/favicon.ico", iconHandler);
|
|
1510
|
+
mountTransport(app2, {
|
|
1511
|
+
oauthProvider: oauthProvider2,
|
|
1512
|
+
mcpResourceUrl: mcpResourceUrl2,
|
|
1513
|
+
jsonLimit: jsonLimit2,
|
|
1514
|
+
allowedOrigins
|
|
1515
|
+
});
|
|
1516
|
+
return app2;
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
// src/http.ts
|
|
1520
|
+
function fatal(message) {
|
|
1521
|
+
console.error(`[loomiomcp] FATAL: ${message}`);
|
|
1522
|
+
process.exit(1);
|
|
1523
|
+
}
|
|
1524
|
+
if (!process.env["LOOMIO_API_KEY"]) {
|
|
1525
|
+
fatal(
|
|
1526
|
+
"LOOMIO_API_KEY environment variable is not set. Generate one in Loomio under your profile \u2192 API keys."
|
|
1527
|
+
);
|
|
1528
|
+
}
|
|
1529
|
+
var baseResult = resolveBaseConfig();
|
|
1530
|
+
if ("error" in baseResult) fatal(baseResult.error);
|
|
1531
|
+
var { publicBaseUrl, signingKey, port, jsonLimit, trustProxy } = baseResult.ok;
|
|
1532
|
+
var modeResult = selectMode(process.env, publicBaseUrl);
|
|
1533
|
+
if ("error" in modeResult) fatal(modeResult.error);
|
|
1534
|
+
var mode = modeResult.ok;
|
|
1535
|
+
var issuerUrl = new URL(publicBaseUrl);
|
|
1536
|
+
var mcpResourceUrl = new URL("/mcp", issuerUrl);
|
|
1537
|
+
var oauthProvider = mode.kind === "static-client" ? new OAuthProvider({
|
|
1538
|
+
clientsStore: new FixedClientStore({
|
|
1539
|
+
clientId: mode.clientId,
|
|
1540
|
+
clientSecret: mode.clientSecret,
|
|
1541
|
+
redirectUris: mode.redirectUris,
|
|
1542
|
+
clientName: "loomiomcp pre-registered client"
|
|
1543
|
+
}),
|
|
1544
|
+
signingKey,
|
|
1545
|
+
resourceUrl: mcpResourceUrl
|
|
1546
|
+
}) : new OAuthProvider({
|
|
1547
|
+
clientsStore: new InMemoryClientsStore(),
|
|
1548
|
+
signingKey,
|
|
1549
|
+
resourceUrl: mcpResourceUrl
|
|
1550
|
+
});
|
|
1551
|
+
var app = createApp({
|
|
1552
|
+
oauthProvider,
|
|
1553
|
+
issuerUrl,
|
|
1554
|
+
jsonLimit,
|
|
1555
|
+
allowedOrigins: baseResult.ok.allowedOrigins,
|
|
1556
|
+
trustProxy
|
|
1557
|
+
});
|
|
1558
|
+
app.listen(port, () => {
|
|
1559
|
+
const readMode = isReadOnly() ? "read-only" : "read-write";
|
|
1560
|
+
const authLabel = mode.kind === "static-client" ? "static-client" : "INSECURE_AUTO_APPROVE";
|
|
1561
|
+
console.log(
|
|
1562
|
+
`[loomiomcp] HTTP server listening on :${port} | mode=${readMode} | auth=${authLabel} | issuer=${issuerUrl}`
|
|
1563
|
+
);
|
|
1564
|
+
if (mode.kind === "insecure-auto-approve") {
|
|
1565
|
+
console.warn(
|
|
1566
|
+
"[loomiomcp] WARNING: auth mode is INSECURE_AUTO_APPROVE. Anyone who can reach this URL can register a client and use the configured Loomio API key. Suitable only for local development or private-network deployments."
|
|
1567
|
+
);
|
|
1568
|
+
}
|
|
1569
|
+
});
|