Repository navigation
Releases: soil-dev/loomiomcp
Release list
v0.0.14 — verified against Loomio 3.9.0; 8 s health-probe deadline; dependency updates
Maintenance release: verified against Loomio 3.9.0, a fast deadline for
the key-health probe, dependency updates. No tool text changes; the
tools/list catalogue is byte-identical to 0.0.12 / 0.0.13 (35 631 B for
the 24 full-mode tools, re-measured with scripts/catalog-size.mjs).
Changed:
- Tested against Loomio 3.9.0 (
TESTED_LOOMIO_VERSION3.8.1 →
3.9.0; the reference instance upgraded on 2026-09-23). From Loomio's
source at tagsv3.8.1,v3.8.2,v3.9.0and master (e96b61311,
2026-10-02): thenamespace :b2andnamespace :b3blocks of
config/routes.rbare byte-identical at all four, so is the OpenAPI
document (it moved fromdocs/user_manual/integrations/api/openapi.yaml
todocs/en/user_manual/integrations/api/openapi.yamlon master,
c47d783cf), andpermitted_params.rbis unchanged through 3.9.0.
The 3.8.1 → 3.9.0 diff (206 files) is dominated by sign-in / passkey
/ session code and touches no b2 / b3 controller, no permitted
parameter (app/models/permitted_params.rb), no ability and no
serializer the b2 API emits (the only serializers in it are
current_userandpending). The one change under a connector write
path isGroupService.invite, which b2memberships#create
(manage_memberships) calls: it now also resolves an optional
recipient_audiencethe connector never sends, and a nil kind
resolves toUser.none, so the call is unchanged in effect. The
remaining backend changes —Stance.redeemable,TopicItemService.move,
Membership#remove_admin!removed, passkey rate limits — alter no b2
request or response. A live read regression of all 14 read tools against the
3.9.0 instance passed 27/27 with response shapes identical to the
3.8.1 captures. The 10 write tools and the 4 b3 tools are unchanged
and were not re-run live on 3.9.0 (their request bodies target
controllers and permitted parameters that did not change between the
tags).loomio.version_driftandcheck_connection's drift note now
key off 3.9. - Key-health probe: 8 s deadline (
HEALTH_PROBE_TIMEOUT_MSin
src/loomio/health.ts), down from the 60 s every request shared.
Incident 2026-10-01 20:03–20:05 UTC: Loomio hung for ~2 minutes; the
probe's 60 s deadline equalled the hosting platform's request
timeout, soGET /healthhung for 60 s and the platform answered
504 instead of the connector answering 503 — the uptime
checker (which gives up after ~10 s) never saw a verdict, and the
forcedloomio.auth"unreachable / timeout" event fired a minute
late. Now both probe requests run under 8 s: an upstream hang becomes
a prompt 503unreachable(reasontimeout) plus the forced event,
from the connector itself. Plumbing:fetchWithTimeouttakes a
per-calltimeoutMs(default: the unchanged 60 sREQUEST_TIMEOUT_MS),
loomioGetRaw/loomioGetPublicaccept{ timeoutMs }, and the
504 message names the deadline that actually applied ("8s" for the
probe, "60s" for a tool call) instead of a fixed figure. Tool calls
keep the 60 s deadline: a slow but working Loomio should finish a
big read. - Dependencies updated within their declared ranges:
@modelcontextprotocol/sdk1.30.0 → 1.31.0,undici8.10.2 →
8.11.2,@types/node25.9.8 → 25.9.9.npm audit: 0 vulnerabilities.
Majors deliberately not taken (biome 2.5, typescript 7, vitest 5,
@types/node26).
Upcoming in Loomio master (not yet released as of e96b61311,
2026-10-02; nothing deployed runs it). Listed so the next re-verify
knows where to look — the connector is already tolerant of all of it:
PollSerializerdropsvoting_systemand addsresult_heading_keys
andweighted_voting;StanceSerializeraddsweight; the
permitted poll attributes gainweighted_voting. The connector reads
voting_systemnowhere — it is an optional passthrough in
src/loomio/types.ts, one name inlist_thread_items' poll slim
list (ITEM_POLL_FIELDS,src/tools/threads.ts) and comments in
src/loomio/visibility.ts,src/tools/polls.tsand
src/loomio/client.ts— so its absence changes nothing. New poll
fields pass through onget_poll,list_pollsrows and the
create / update echoes (shapePollremoves a fixed deny-list) and
are dropped onlist_thread_itemspolls (ITEM_POLL_FIELDSis an
allow-list); a stanceweightis dropped everywhere (STANCE_FIELDS,
OWN_STANCE_FIELDS). No tool sendsweighted_voting.- User-scoped fields (
my_stance,current_user_followed, …) are
emitted only when a current user is present. An API-key request
always has one, so b2 responses to the connector are unaffected. GET /b2/threads/{id}/markdownnow callsauthorize!(:export, thread),
where Topic:export=can?(:show, topic)— no practical change
for a thread the user can see (an invisible one was already 404).
ThreadMarkdownServiceitself does change (df28cf845): it now applies
only the until-closed rule (poll.results_available?), so an open
until_votepoll's results are exported to every reader with:show
on the thread —get_thread_markdownwill disclose them to
non-voters —, comments under votes of an openuntil_closedpoll are
omitted from the export and from the search index/b2/searchreads,
and replies to stances are no longer rejected
(Comment#parent_vote_results_visible_to_allremoved). The service's
layout also changes on master (front matter gainskey,·-joined
title, nested blockquotes); the connector passes the string through
verbatim today andmarkdownHeadingstill finds the first H1. Decide
whetherget_thread_markdownshould warn about or strip until_vote
results when a tag ships this.- b2
memberships#createno longer callsPollService.group_members_added:
members added throughmanage_membershipsare no longer auto-enrolled
in the group's open polls. A Loomio-side behaviour change, not a
connector one; noted here because a caller may expect the old effect.
Verified: biome, tsc, the test suite (adds: per-call timeoutMs on
fetchWithTimeout, both probe requests under HEALTH_PROBE_TIMEOUT_MS,
/health answering 503 unreachable when the probe times out) and
the build are green; the catalogue re-measured as above.
v0.0.13 — dependency security updates
Dependency security release. No behaviour change; tool catalogue,
schemas and descriptions are byte-identical to 0.0.12 (35 631 B for the
24 full-mode tools, re-measured with scripts/catalog-size.mjs).
Changed:
- Runtime dependencies updated within their declared ranges:
@modelcontextprotocol/sdk1.29.0 → 1.30.0,undici8.9.0 → 8.10.2,
express-rate-limit8.5.2 → 8.7.0,zod4.4.3 → 4.6.5. Development:
vitest4.1.7 → 4.1.11,@types/node25.9.1 → 25.9.8. - Transitive advisories cleared (
npm audit: 0 vulnerabilities, was
8 — 3 high, 4 moderate, 1 low):fast-uri(via ajv, high) → 3.1.8,
ip-address(via express-rate-limit, high) → 10.7.2,nanoid(via
postcss/tsup, high) → 3.3.19,hono(via the MCP SDK, moderate) →
4.13.8,qs(via express, moderate) → 6.16.0,esbuild(dev, low)
pinned outside the affected range.
Verified: biome, tsc, 572 tests and the build are green; the stdio
server was exercised live against a Loomio 3.8.1 instance (read tools
through the new SDK/undici) and the production smoke test runs after
deploy.
v0.0.12 — adopt Loomio 3.8's API; make it efficient
Adopt Loomio 3.8's API; make it efficient. Every capability Loomio
3.8.1's b2 User API (and, opt-in, its b3 Server API) offers is now a
tool with a description written for an AI caller; every read costs as
few upstream calls and bytes as the API allows; and nothing a tool
returns is wrong (a hidden poll result, a thread in the wrong group) or
bloated (a 50-row list with full HTML bodies and avatar metadata). The
0.0.11 hotfix made the connector's failures loud; this release makes
its answers right. Tool count 8 reads + 4 writes + 2 b3 → 14 reads +
10 writes + 4 b3 (28 with everything enabled; 14 in read-only mode).
Tested against Loomio 3.8.1: every request shape and response field
described below was checked against Loomio's controllers, serializers,
services, routes and controller tests at tag v3.8.1, and against
sanitised live captures of every read endpoint from a 3.8.1 instance.
The write path was exercised live on 2026-09-20 in a sandbox group of
that instance (see Known limitations for exactly what was and was not
run). TESTED_LOOMIO_VERSION stays 3.8.1.
Added:
check_connection— "does the connector work and what can it
see", in one tool call and at most one request pair. Forces the
key-health probe (GET /b2/groups+ the publicGET /v1/boot/version)
and reuses the probe's own groups body, which the probe now keeps
beside its verdict (getCachedGroupsIndexin
src/loomio/health-cache.ts— stored besideLoomioHealth, never on
it, so/healthcannot grow a list of the user's groups). Returns
connector_version,tested_loomio_version,loomio_version,
key_status,readonly,b3_enabled(true iff the b3 tools are
registered: the secret set AND not read-only — on a read-only
deployment with the secret set it is false and a note says why,
matching whattools/listadvertises), the API key'suser{id,
name, username} (resolved from its own membership rows),groups[]
withmember_statemember|pending|parentandadmin,
groups_total, and fixed-wordingnotes[](rejected-key remediation,
valid key but no groups, pending invitations, non-member parents,
version drift, readonly / b3 flags). Never returns key material or
upstream error text. Registered first, so it is the first tool a
client reads.get_group—GET /b2/groups/{id|key|handle}(Loomio's
ModelLocator tries the three in that order). Full record minus
attachment / link-preview / cover metadata and minus the billing block
GroupSerializer adds for any member (subscription: plan, state, seat
caps, renewal / expiry dates — a serializer attribute no
exclude_typesprofile can drop;enabledalready says whether it is
active) plusnew_host/discarded_by, plusmember,
membership{accepted, admin, delegate, title},parent,
subgroups_count,url. Works for publicly visible groups the user
has not joined (can?(:show, group)); a hidden group's
403 "Not authorized to show Group."is classified as hidden group
— the id is right, the remedy is membership — and 404 as no such
group.list_threads—GET /b2/threads?limit&offsetwith
exclude_types=topic group parent membership reaction translation
(compact minustag, because Loomio gates the rows'tagsFIELD on
include_type?('tag')— plaincompact=1would strip it): every
thread visible to the user across all groups (member groups plus
public threads of public groups), newest activity first, with Loomio's
exacttotal. Rows carrytopic_id,type, the fronting record's
id/key/title/author_id,group_id, the topic counters
(tagsincluded) andurl.group_id/typefilter
client-side within the page (Loomio's route has no group filter)
andscope.notesays so.since(ISO-8601) is a client-side cutoff
too — Loomio's route reads onlylimit/offset— applied to the
newest-first rows: older rows are dropped, rows with no
last_activity_at(Postgres sorts those FIRST) are dropped and
counted inscope.undated_dropped, andscope.exhaustedturns true
when the page's last dated row already predates the cutoff (or the
page was short), so "what's new this week" is a bounded loop instead
of a guess;totalstays the whole visible set.list_thread_items— the successor tolist_events:
GET /b2/threads/{topic_id}/items. Addressed by exactly one of
topic_id(1 call),discussion_idorpoll_id(2 calls: one
compact=1GET to read the record'stopic_id). Loomio's items route
is unpaginated, so the whole thread is fetched once per call and
limit(default 200, max 1000) /offsetslice it here;kinds[]
filters client-side withotheras passthrough for unknown kinds;
total/matched/returnedsay what was counted. Side-loads are
narrowed to what the returned items reference and slimmed: comments
(body capped bybody_max_chars, default 4000), polls (identity,
schedule,hide_results, participation counts,poll_options[]{id,
name, priority} — the ids a stance'soption_scoresis keyed by, so
a vote is readable without aget_poll— andstance_countsonly
when visible, aligned with those options), stances (participant_id
null on anonymous polls), outcomes, users {id, name, username}.
max_total_chars(default 120000, -1 = none) budgets the WHOLE reply
in shaped characters:limit×body_max_charsbound each dimension
but not their product (200 comments at 4000 chars ≈ 900 KB, fifteen
times get_thread_markdown's document cap), so the slice stops before
the item that would overrun the budget — never before the first —
and returnstruncated_by_budget: trueplusnext_offset
(next_offsetis also set whenlimitended the slice;scope.chars
says what the budget counted). Three side-load profiles:compact=1for a bare
topic_id(the response must name the thread), compact plus
discussionwhen the thread's record is already in hand — a
discussion_idwas just resolved, orget_discussionpassed its
record through — so the opening post is not serialised a second time,
and, withinclude_reactions, the explicit list minusreaction
(keepingdiscussion, whose serializer carries the opening post's
reactions);scope.profilenames the one used. A thread the user
cannot see is 404, never 403, and the error says so; a 404 while
resolving adiscussion_id/poll_idsays "no such id or key"
instead (those show routes answer 403 for an invisible record), so
the two wordings never suggest a membership problem that is not one.get_thread_markdown—GET /b2/threads/{topic_id}/markdown:
Loomio's ownThreadMarkdownServicerendering (front matter, title,
body, every comment / poll / vote / outcome in order; Loomio applies
the poll vote-visibility rule itself here).max_chars(default
60000, -1 = all) caps from the end withtruncatedandchars. The
best single call for "summarise this thread".search_content—GET /b2/search?compact=1: full-text over
discussions, comments, polls, stances and outcomes the user can see
(member groups plus public groups' public threads).query(prefix
matching,!termnegation, trigram "did you mean" for short unquoted
queries),author_idalone for a user's 20 newest visible items, or
both;group_id,types[](sent as Loomio's comma-separated
types=),tag,order(authored_at_descdefault,
authored_at_asc,relevance). Each hit:type,id(the RECORD id,
not the pg_search row id),title,snippet(Loomio's<b>highlight
→**match**, HTML entities decoded, ≤ 400 chars),group{id,
full_name, handle} (full_namebecause the controller emits Loomio's
"Parent - Subgroup"full_name, not the shortname),author,
authored_at,discussion_key/poll_key,sequence_id, a
deep-linkurl. Vote reasons follow the poll-visibility rule below:
Loomio indexes the stances of openuntil_votepolls (its own web
search shows a member those reasons), so a Stance hit whose poll hides
results from the connector's user is gated from the response's own
polls/my_stanceroots (no extra call): in aquerysearch the
row is dropped and counted inscope.hidden_stance_hits_dropped
— with a term to probe, the hit's mere existence would confirm the
term occurs in the withheld reason, a word-by-word oracle over exactly
whatlist_thread_itemsstrips — while in author-only mode (nothing
to probe) it stays withsnippet: null+snippet_hidden_reason.
Loomio's hard cap: 20 results, no paging, no total —capped: true(computed over Loomio's rows, before the drop) says the match
set is larger.get_participation_report— ONEGET /b2/reports?section=users
for a whole group set (group_ids1–50 as a comma-separated string,
orgroup_scope: 'my'),start_month/end_monthasYYYY-MM
(default: the 12 months ending this month; validated client-side —
Loomio answers 500 to anything else),delegates_only. Every user's
threads,comments,polls,votes(=votes_cast),
votes_issued,votes_missed,all_votes_cast,outcomes,
reactionsandtotal(threads+comments+polls+votes+outcomes),
sorted bytotaldesc;countrydropped;groupsechoes what Loomio
counted andgroups_not_visiblenames requested groups it dropped
silently (the connector's user is not a member — Loomio's built-in
fence). THE tool for "who is most engaged" / "rank the members".update_discussion/delete_discussion,update_poll/
delete_poll,update_comment/delete_comment— the b2
updateanddestroyactions Loomio 3.1.0 added. PATCH bodies are
nested for discussions and polls and flat for comments (same rule as
create, below);update_*refuse an empty change set a...
v0.0.11 — detect, explain, stop lying
Hotfix: detect, explain, stop lying. Between Loomio 3.0.24 (the
release this connector was built against) and 3.8.1, Loomio changed
its API in ways that made the connector fail quietly: a rotated API
key turned every call into one generic 403, get_user_activity
reported zero activity for everyone once its upstream endpoint was
removed, and list_groups answered an empty list when the key was
dead. This release makes each of those failures loud and specific.
It adds no new Loomio endpoints — the ports to Loomio's newer b2
surface (native groups listing, thread items, topic side-load) are
0.0.12. Tool count unchanged (8 reads + 4 writes + 2 b3 admin).
Tested against Loomio 3.8.1: every behaviour described below was
checked against the controllers, serializers, routes and controller
tests at Loomio tag v3.8.1. TESTED_LOOMIO_VERSION in
src/version.ts records that, and the health probe warns once when
the instance's major.minor differs from it — Loomio publishes no API
compatibility policy, so a new minor is a prompt to re-verify.
Added:
- Key-health probe (
src/loomio/health.ts). An authenticated
GET /b2/groups(200 →valid; 403 with Loomio's unauthenticated
body →rejected; anything else, including a CDN/WAF 403 that never
reached Loomio →unreachablewith adetail) plus the public
GET /v1/boot/versionfor the instance's Loomio version. Cached 60 s
with a shared in-flight promise, so a hammered/healthcosts Loomio
one request pair per minute. Everykey_statuschange (including the
first result) emits a forcedloomio.authevent that bypasses the
LOOMIO_MCP_LOG_VERBOSEgate, carryingkey_status,loomio_version
and a closed-vocabularyreason(unauthenticated_body,waf,
unrecognised_403,http_<status>,timeout,network_error,
config_error) — never the free-textdetail, which may quote an
upstream body fragment or error message and is reserved for the
startup stderr warning. A one-time forcedloomio.version_driftfires
on amajor.minormismatch. Never logs or returns the key. GET /healthon the HTTP transport (src/http/health.ts).
Unauthenticated, per-IP rate-limited (same config as/mcp, separate
bucket),Cache-Control: no-store. Body:
{status, connector_version, key_status, loomio_version, checked_at}
— HTTP 200 iffkey_status === "valid", else 503. Exposes exactly
those fields: no key material, nodetail, no Loomio hostname. Point
an uptime check at it with content match"key_status":"valid"
(DEPLOY.md).LOOMIO_MCP_HEALTH_PATHmoves the page (e.g. to
/-/health) for hosting front-ends that reserve/healthzand answer
it with their own 404 before the container is reached; verify with
curlthat the connector's JSON comes back before trusting an alert.- Startup key check. The HTTP entry probes after
listenand logs
the verdict; the stdio entry probes once and, onrejected, writes a
one-paragraph warning to stderr (key rotated? where the current one
is). Neither exits — a transient error must not kill the server, and a
live process with classified 403s is more diagnosable than a
crash-loop. User-Agent: loomiomcp/<version>on every outbound request. A
CDN/WAF in front of an instance (Cloudflare is common) blocks default
library user-agents outright; an explicit UA also lets instance
operators pick the connector out of Loomio's logs.src/version.ts— single source of truth forVERSIONand
TESTED_LOOMIO_VERSION.McpServer, the User-Agent and/health
import it;tests/version.test.tspinspackage.jsonto it (0.0.9
shipped with the two out of step).
Changed:
- 403s are classified (
classifyForbiddeninsrc/loomio/client.ts,
pure and unit-tested). Loomio ≥ 3.1.1 answers 403 with a body that
says why, and the connector now reads it: a CDN/WAF body (JSON
problemtypementioning cloudflare, or non-JSON) → "blocked in front
of Loomio, not a permissions error — check WAF rules / User-Agent";
the generic"You are not authorized to access this page."→
unauthenticated — most often a rotated key (Loomio regenerates a
user's key when that user's password changes, and 3.3.1 rotated every
key once) — with remediation (/health,GET /api/b2/groups, the
user's API access page/profile/api_access). The classification is
path-aware: onlyGET /b2/discussions?group_id=and
GET /b2/polls?group_id=go throughrecords_visible_in_group, so
only there does the message add "or the group is not visible to the
connector's user"; on/b2/memberships(a non-member gets200 []),
on every/…/:idread (which answers"Not authorized to …") and on
writes the same body can only mean the key was rejected, and the
message says so. Writes includePOST /b2/discussionsand
POST /b2/polls, whose paths coincide with the two gated GET lists:
the classifier is method-aware, so a rotated key oncreate_*is
never hedged towards "group not visible" (the one bare refusal a
valid key can hit on a write —POST /b2/pollsfor an anonymous poll
with no futureclosing_at, raised after the poll was saved — is
named in the message). On/b3/paths the generic body is about the
b3 server secret:LOOMIO_B3_API_KEYmust equal the Loomio
server'sENV['B3_API_KEY'](itself longer than 16 characters), and
the message says exactly that — never the per-user key, never
/profile/api_access— and the health verdict, which probes the b2
key only, does not colour it.
The key-health verdict colours the message only when
it is fresh (younger than the 60 s cache): a freshrejectedmakes
it definitive, a freshvalidon a gated list points at visibility
first, and a stale verdict is ignored — so a startupvalidfrom days
ago can never make a post-rotation 403 read as "not a key problem".
"Not authorized to <action> <Model>."→ per-record permission,
surfaced verbatim;"User is not an admin"→ needs the group admin
(coordinator) role on that group; a numeric body or
action: "upgrade"→ Loomio subscription/plan limit; anything else →
the body text. The old "Loomio sends the same 403 whether the key is
invalid or the user lacks the role" text is gone — it described
Loomio ≤ 3.1.0. A 401 is explained per namespace: Loomio's b2/b3
API answers its own authentication failures with 403, so a 401 on a
b2/b3 path came from something in front of Loomio (proxy, CDN,
basic-auth gate); on a v1 path Loomio itself can answer 401
(require_current_user, "you gotta be signed in") because v1 is a
session-cookie API and the connector's key is not a v1 credential. - Upstream text in error messages is clipped to 200 characters
(clipinsrc/loomio/client.ts) at every echo site: non-JSON error
bodies (a CDN's multi-KB HTML 502 page), the uncatalogued-403 body,
"Not authorized to …"remainders, plan-limit and Cloudflare titles,
and Rack::Attack's 429 text. The suffix states the original length.
These messages land in MCP tool results (agent context) and inside
get_user_activity's "Last error"; unbounded upstream text there is
both noise and an injection surface. - 429 handled. Loomio's Rack::Attack throttle (per client IP, 900
requests per 5 minutes by default,text/plain) becomes a clear
LoomioApiErrorwith theRetry-Afterheader when present; the
invitation-limit JSON 429 is surfaced with Loomio's own message. list_membershipspasses Loomio's response through. When
membershipsis empty it addsscope.note: on Loomio ≥ 3.8 a
non-member (or a hidden group) gets200 [], not 403, and a real
group always has at least its creator — so an empty roster almost
always means "the connector's user is not a member". Description
rewritten: any member can list the roster (ids, names, usernames,
roles, join state);user_emailappears only for groups where the
connector's user is an admin, or for members it invited. The "caller
MUST be a group admin / 403" claims are removed.manage_membershipssendsremove_absent: 1on the wire when the
flag is true and omits the key otherwise. Loomio reads
params[:remove_absent].to_i == 1; a JSON boolean has no#to_iin
Ruby, so the previous body raisedNoMethodError→ HTTP 500 — after
the invitations had been sent. The zod schema keeps the boolean.
Description now states the role requirement (group admin/coordinator
on that group; Loomio answers403 "User is not an admin"; parent-
group admin and instance admin do not count) and the full blast radius
ofremove_absent: pending invitees are revoked too, the revocation
cascades to subgroups, and the connector's own user is removed if its
email is absent.- Membership 403 fence removed.
src/loomio/access.ts,
tests/access.test.tsandexplainForbiddenare deleted. The fence
existed because Loomio ≤ 3.1.0 returned a bare{"error":403}for
every refusal; the classified bodies make it redundant, and its extra
probe was one more way to spend Loomio's rate budget. list_discussionsalways sendsstatus(defaultopen).
Loomio's own fall-through for a missingstatusis every kept thread
including locked ones, so omitting it silently widened the list.
The description states the default.deactivate_user/reactivate_useruse the member routes
POST /b3/users/{id}/deactivateand…/{id}/reactivate; the?id=
collection routes are markeddeprecated: truein Loomio's OpenAPI
document. The response is typed as{ success: true, user }and
returned. Descriptions: deactivation runs asynchronously
server-side (the echoed user may still showactive: true);
reactivation is synchronous and restores the memberships the
deactivation revoked.list_groupsno longer claims that `is_admin...
v0.0.10
Dependency security updates. No code, tool, or API changes. Clears every high-severity advisory in the tree: 9 vulnerabilities → 3, 5 high → 0.
| package | from → to | |
|---|---|---|
| undici | 8.3.0 → 8.9.0 | runtime — HTTP client for every outbound Loomio call |
| body-parser | → 2.3.0 | transitive |
| fast-uri | → 3.1.4 | transitive |
| postcss | → 8.5.23 | transitive |
| hono | → 4.12.32 | transitive |
| vite | → 8.1.5 | transitive (dev) |
The undici fixes are the ones that matter here — response-queue poisoning via keep-alive socket reuse, cross-user information disclosure via a shared-cache whitespace bypass, and a Set-Cookie SameSite downgrade — because this is a multi-user service funnelling every caller through one upstream identity over pooled keep-alive connections.
Supersedes Dependabot PRs #5, #6, #8, #10, #11, #12.
Three advisories left unfixed — deliberately, all unreachable
@hono/node-serverpath traversal inserve-staticon Windows. The connector uses the express/stdio transports, never hono, never serves static files, and ships in a Linux container. npm's proposed fix downgrades@modelcontextprotocol/sdkto 1.24.3 — breaking the core dependency to patch a bundled framework we never invoke.esbuilddev-server file read on Windows — devDependency, absent from the production image (npm ci --omit=dev).
Verification
130 tests / typecheck / lint / build, plus a live check against a real Loomio instance — which is what actually exercises the undici upgrade, since the unit suite mocks undici. Bearer auth, paginated reads, probe-based list_groups, and get_user_activity aggregation all returned real data.
See CHANGELOG.md.
v0.0.9
Breaking upstream change — this release is required. Loomio moved API-key authentication to an HTTP bearer header and now rejects keys passed in the query string. Every request from v0.0.8 and earlier fails against an updated Loomio instance.
What changed
- Bearer authentication. b2 and b3 now send
Authorization: Bearer <key>; URLs carry no credential. Auth injection moved frombuildUrl()into a single newauthHeaders()insrc/loomio/client.ts. - No configuration change.
LOOMIO_API_KEYandLOOMIO_B3_API_KEYare unchanged — upgrade and restart.
Why it looked like a permissions bug
Loomio answers a query-string key with 403 {"error":"You are not authorized to access this page."} — byte-identical to its response for an invalid key or a missing group role. A rejected auth scheme is therefore indistinguishable from a permissions problem, which sends you hunting for a lost membership. Verified on a live instance:
| scheme | result |
|---|---|
?api_key= |
403 |
Authorization: Bearer |
200 |
| no credential | 403 (same body as ?api_key=) |
tests/loomio-auth.test.ts now pins the scheme so a regression fails in CI instead.
Note
b3 bearer support follows Loomio's published note but is untested here — it needs a server-instance B3_API_KEY, which only Loomio instance operators hold.
See CHANGELOG.md.
v0.0.8
Follow-up hardening on the v0.0.7 stateless OAuth client store. No tool/API changes.
- Public clients honoured. A client registering with
token_endpoint_auth_method: "none"(PKCE-only) is no longer issued aclient_secret— 0.0.7 derived one for every client, leaving public clients in a contradictory state. Confidential (client_secret_post) clients, including Claude.ai's, are unchanged → no re-auth at this upgrade. - Oversized
client_idguard.getClientrejects aclient_idover 16 KB before any HMAC/JSON work (legit ids are ~400 bytes).
See CHANGELOG.md.
v0.0.7
Fixes frequent connector re-authentication on the public (open-DCR) deployment.
Cause
Registered OAuth clients were held in an in-memory map, so any process recycle — a redeploy, scale-to-zero, or a refresh routed to a different instance — lost the registration and forced the client to re-authenticate. (Access/refresh tokens are stateless and survive; but the SDK re-validates the client on every refresh, and the lookup missed.)
Fix
New StatelessClientsStore: the client_id is now a signed blob (HMAC, same key as the tokens) encoding the registration metadata. getClient verifies the signature and reconstructs the client instead of a map lookup, and the client_secret is derived deterministically from the client_id. So any instance recognises any client with zero shared state — refresh and re-authorize survive restarts, scale-to-zero, redeploys, and multi-instance routing. Mirrors the existing stateless-token design.
Verified end-to-end against the real MCP SDK with an actual process restart: after killing the server and starting a fresh process (empty memory, same signing key), a refresh with the original credentials succeeds and tools/list returns.
Notes
- At the upgrade, clients registered under the old in-memory UUIDs re-authenticate once, then stay connected.
- Revocation remains global only (rotate
MCP_OAUTH_SIGNING_KEY, which also invalidates outstanding tokens). - No tool/API changes; tool counts unchanged.
See CHANGELOG.md.
v0.0.6
Tooling and project-metadata only — no API, tool, or behaviour changes. The compiled output is identical to v0.0.5 apart from the advertised version string.
- CI: added
.github/workflows/ci.yml— typecheck + tests + lint + build on every push/PR, across Node 22 and 24. glama.json: corrected a stale inventory (it still said "9 tools", pre-datinglist_events/get_user_activity). Now accurately describes 12 tools (8 read, 4 write) plus the 2 opt-in b3 admin tools.- README: npm / CI / license / Glama badges.
See CHANGELOG.md.
v0.0.5
Audit follow-ups + general-purpose hygiene. No new tools, counts unchanged (8 reads + 4 writes + 2 b3 admin).
list_events
- Without
limit/offsetit now auto-paginates the full discussion stream (merging embeddedcomments/users/pollsacross pages) up to a bounded cap, and reportsscope.complete/pages_fetched/events_truncated. Pass either pagination knob to get exactly one page. Previously a single default-page fetch could silently miss later events in a long thread.
get_user_activity
- Count fix:
comment_editedandstance_updatedwere in the activity set but missing from thecountsobject, so they were silently bucketed asother. Now counted under their own keys. - New
scope.groups_truncatedcompleteness signal (groups whose discussion listing hit the page cap), joining thecomplete/groups_failed/discussions_failed/discussions_truncated/discussions_cappedset from 0.0.4. untilmust be later thansincewhen both are supplied.
General-purpose hygiene
- Removed deployment-specific references so the connector reads as the general-purpose tool it is. Tool descriptions (which ship to every client over MCP) now use neutral examples instead of one instance's group names; docs use
example.orgplaceholders and describe the Cloud Run / IaC deployment pattern generically. No behaviour change.
See CHANGELOG.md.