Skip to content

Releases: soil-dev/loomiomcp

v0.0.14 — verified against Loomio 3.9.0; 8 s health-probe deadline; dependency updates

Choose a tag to compare

@arapov arapov released this 02 Oct 09:30
v0.0.14
f8ab5f1

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_VERSION 3.8.1 →
    3.9.0; the reference instance upgraded on 2026-09-23). From Loomio's
    source at tags v3.8.1, v3.8.2, v3.9.0 and master (e96b61311,
    2026-10-02): the namespace :b2 and namespace :b3 blocks of
    config/routes.rb are byte-identical at all four, so is the OpenAPI
    document (it moved from docs/user_manual/integrations/api/openapi.yaml
    to docs/en/user_manual/integrations/api/openapi.yaml on master,
    c47d783cf), and permitted_params.rb is 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_user and pending). The one change under a connector write
    path is GroupService.invite, which b2 memberships#create
    (manage_memberships) calls: it now also resolves an optional
    recipient_audience the connector never sends, and a nil kind
    resolves to User.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_drift and check_connection's drift note now
    key off 3.9.
  • Key-health probe: 8 s deadline (HEALTH_PROBE_TIMEOUT_MS in
    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, so GET /health hung 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
    forced loomio.auth "unreachable / timeout" event fired a minute
    late. Now both probe requests run under 8 s: an upstream hang becomes
    a prompt 503 unreachable (reason timeout) plus the forced event,
    from the connector itself. Plumbing: fetchWithTimeout takes a
    per-call timeoutMs (default: the unchanged 60 s REQUEST_TIMEOUT_MS),
    loomioGetRaw / loomioGetPublic accept { 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/sdk 1.30.0 → 1.31.0, undici 8.10.2 →
    8.11.2, @types/node 25.9.8 → 25.9.9. npm audit: 0 vulnerabilities.
    Majors deliberately not taken (biome 2.5, typescript 7, vitest 5,
    @types/node 26).

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:

  • PollSerializer drops voting_system and adds result_heading_keys
    and weighted_voting; StanceSerializer adds weight; the
    permitted poll attributes gain weighted_voting. The connector reads
    voting_system nowhere — it is an optional passthrough in
    src/loomio/types.ts, one name in list_thread_items' poll slim
    list (ITEM_POLL_FIELDS, src/tools/threads.ts) and comments in
    src/loomio/visibility.ts, src/tools/polls.ts and
    src/loomio/client.ts — so its absence changes nothing. New poll
    fields pass through on get_poll, list_polls rows and the
    create / update echoes (shapePoll removes a fixed deny-list) and
    are dropped on list_thread_items polls (ITEM_POLL_FIELDS is an
    allow-list); a stance weight is dropped everywhere (STANCE_FIELDS,
    OWN_STANCE_FIELDS). No tool sends weighted_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}/markdown now calls authorize!(:export, thread),
    where Topic :export = can?(:show, topic) — no practical change
    for a thread the user can see (an invisible one was already 404).
    ThreadMarkdownService itself does change (df28cf845): it now applies
    only the until-closed rule (poll.results_available?), so an open
    until_vote poll's results are exported to every reader with :show
    on the thread — get_thread_markdown will disclose them to
    non-voters —, comments under votes of an open until_closed poll are
    omitted from the export and from the search index /b2/search reads,
    and replies to stances are no longer rejected
    (Comment#parent_vote_results_visible_to_all removed). The service's
    layout also changes on master (front matter gains key, ·-joined
    title, nested blockquotes); the connector passes the string through
    verbatim today and markdownHeading still finds the first H1. Decide
    whether get_thread_markdown should warn about or strip until_vote
    results when a tag ships this.
  • b2 memberships#create no longer calls PollService.group_members_added:
    members added through manage_memberships are 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

Choose a tag to compare

@arapov arapov released this 21 Sep 06:51
v0.0.13
67570ac

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/sdk 1.29.0 → 1.30.0, undici 8.9.0 → 8.10.2,
    express-rate-limit 8.5.2 → 8.7.0, zod 4.4.3 → 4.6.5. Development:
    vitest 4.1.7 → 4.1.11, @types/node 25.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

Choose a tag to compare

@arapov arapov released this 21 Sep 06:37
v0.0.12
062a0ad

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 public GET /v1/boot/version)
    and reuses the probe's own groups body, which the probe now keeps
    beside its verdict (getCachedGroupsIndex in
    src/loomio/health-cache.ts — stored beside LoomioHealth, never on
    it, so /health cannot 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 what tools/list advertises), the API key's user {id,
    name, username} (resolved from its own membership rows), groups[]
    with member_state member | pending | parent and admin,
    groups_total, and fixed-wording notes[] (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_types profile can drop; enabled already says whether it is
    active) plus new_host / discarded_by, plus member,
    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&offset with
    exclude_types=topic group parent membership reaction translation
    (compact minus tag, because Loomio gates the rows' tags FIELD on
    include_type?('tag') — plain compact=1 would 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
    exact total. Rows carry topic_id, type, the fronting record's
    id / key / title / author_id, group_id, the topic counters
    (tags included) and url. group_id / type filter
    client-side within the page (Loomio's route has no group filter)
    and scope.note says so. since (ISO-8601) is a client-side cutoff
    too — Loomio's route reads only limit / 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 in scope.undated_dropped, and scope.exhausted turns 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; total stays the whole visible set.
  • list_thread_items — the successor to list_events:
    GET /b2/threads/{topic_id}/items. Addressed by exactly one of
    topic_id (1 call), discussion_id or poll_id (2 calls: one
    compact=1 GET to read the record's topic_id). Loomio's items route
    is unpaginated, so the whole thread is fetched once per call and
    limit (default 200, max 1000) / offset slice it here; kinds[]
    filters client-side with other as passthrough for unknown kinds;
    total / matched / returned say what was counted. Side-loads are
    narrowed to what the returned items reference and slimmed: comments
    (body capped by body_max_chars, default 4000), polls (identity,
    schedule, hide_results, participation counts, poll_options[] {id,
    name, priority} — the ids a stance's option_scores is keyed by, so
    a vote is readable without a get_poll — and stance_counts only
    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_chars bound 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 returns truncated_by_budget: true plus next_offset
    (next_offset is also set when limit ended the slice; scope.chars
    says what the budget counted). Three side-load profiles: compact=1 for a bare
    topic_id (the response must name the thread), compact plus
    discussion when the thread's record is already in hand — a
    discussion_id was just resolved, or get_discussion passed its
    record through — so the opening post is not serialised a second time,
    and, with include_reactions, the explicit list minus reaction
    (keeping discussion, whose serializer carries the opening post's
    reactions); scope.profile names the one used. A thread the user
    cannot see is 404, never 403, and the error says so; a 404 while
    resolving a discussion_id / poll_id says "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 own ThreadMarkdownService rendering (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 with truncated and chars. 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, !term negation, trigram "did you mean" for short unquoted
    queries), author_id alone for a user's 20 newest visible items, or
    both; group_id, types[] (sent as Loomio's comma-separated
    types=), tag, order (authored_at_desc default,
    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_name because the controller emits Loomio's
    "Parent - Subgroup" full_name, not the short name), author,
    authored_at, discussion_key / poll_key, sequence_id, a
    deep-link url. Vote reasons follow the poll-visibility rule below:
    Loomio indexes the stances of open until_vote polls (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_stance roots (no extra call): in a query search the
    row is dropped and counted in scope.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
    what list_thread_items strips — while in author-only mode (nothing
    to probe) it stays with snippet: 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 — ONE GET /b2/reports?section=users
    for a whole group set (group_ids 1–50 as a comma-separated string,
    or group_scope: 'my'), start_month / end_month as YYYY-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,
    reactions and total (threads+comments+polls+votes+outcomes),
    sorted by total desc; country dropped; groups echoes what Loomio
    counted and groups_not_visible names 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
    update and destroy actions 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...
Read more

v0.0.11 — detect, explain, stop lying

Choose a tag to compare

@arapov arapov released this 20 Sep 16:33
v0.0.11
5b5ad12

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 → unreachable with a detail) plus the public
    GET /v1/boot/version for the instance's Loomio version. Cached 60 s
    with a shared in-flight promise, so a hammered /health costs Loomio
    one request pair per minute. Every key_status change (including the
    first result) emits a forced loomio.auth event that bypasses the
    LOOMIO_MCP_LOG_VERBOSE gate, carrying key_status, loomio_version
    and a closed-vocabulary reason (unauthenticated_body, waf,
    unrecognised_403, http_<status>, timeout, network_error,
    config_error) — never the free-text detail, which may quote an
    upstream body fragment or error message and is reserved for the
    startup stderr warning. A one-time forced loomio.version_drift fires
    on a major.minor mismatch. Never logs or returns the key.
  • GET /health on 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 iff key_status === "valid", else 503. Exposes exactly
    those fields: no key material, no detail, no Loomio hostname. Point
    an uptime check at it with content match "key_status":"valid"
    (DEPLOY.md). LOOMIO_MCP_HEALTH_PATH moves the page (e.g. to
    /-/health) for hosting front-ends that reserve /healthz and answer
    it with their own 404 before the container is reached; verify with
    curl that the connector's JSON comes back before trusting an alert.
  • Startup key check. The HTTP entry probes after listen and logs
    the verdict; the stdio entry probes once and, on rejected, 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 for VERSION and
    TESTED_LOOMIO_VERSION. McpServer, the User-Agent and /health
    import it; tests/version.test.ts pins package.json to it (0.0.9
    shipped with the two out of step).

Changed:

  • 403s are classified (classifyForbidden in src/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
    problem type mentioning 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: only GET /b2/discussions?group_id= and
    GET /b2/polls?group_id= go through records_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 gets 200 []),
    on every /…/:id read (which answers "Not authorized to …") and on
    writes the same body can only mean the key was rejected, and the
    message says so. Writes include POST /b2/discussions and
    POST /b2/polls, whose paths coincide with the two gated GET lists:
    the classifier is method-aware, so a rotated key on create_* is
    never hedged towards "group not visible" (the one bare refusal a
    valid key can hit on a write — POST /b2/polls for an anonymous poll
    with no future closing_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_KEY must equal the Loomio
    server's ENV['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 fresh rejected makes
    it definitive, a fresh valid on a gated list points at visibility
    first, and a stale verdict is ignored — so a startup valid from 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
    (clip in src/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
    LoomioApiError with the Retry-After header when present; the
    invitation-limit JSON 429 is surfaced with Loomio's own message.
  • list_memberships passes Loomio's response through. When
    memberships is empty it adds scope.note: on Loomio ≥ 3.8 a
    non-member (or a hidden group) gets 200 [], 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_email appears 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_memberships sends remove_absent: 1 on 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_i in
    Ruby, so the previous body raised NoMethodError → 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 answers 403 "User is not an admin"; parent-
    group admin and instance admin do not count) and the full blast radius
    of remove_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.ts and explainForbidden are 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_discussions always sends status (default open).
    Loomio's own fall-through for a missing status is every kept thread
    including locked ones, so omitting it silently widened the list.
    The description states the default.
  • deactivate_user / reactivate_user use the member routes
    POST /b3/users/{id}/deactivate and …/{id}/reactivate; the ?id=
    collection routes are marked deprecated: true in 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 show active: true);
    reactivation is synchronous and restores the memberships the
    deactivation revoked
    .
  • list_groups no longer claims that `is_admin...
Read more

v0.0.10

Choose a tag to compare

@arapov arapov released this 25 Jul 13:16
v0.0.10
8483f7e

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-server path traversal in serve-static on 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/sdk to 1.24.3 — breaking the core dependency to patch a bundled framework we never invoke.
  • esbuild dev-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

Choose a tag to compare

@arapov arapov released this 25 Jul 13:02
v0.0.9
0e67bdf

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 from buildUrl() into a single new authHeaders() in src/loomio/client.ts.
  • No configuration change. LOOMIO_API_KEY and LOOMIO_B3_API_KEY are 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

Choose a tag to compare

@arapov arapov released this 04 Jun 12:11
v0.0.8
4db78d5

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 a client_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_id guard. getClient rejects a client_id over 16 KB before any HMAC/JSON work (legit ids are ~400 bytes).

See CHANGELOG.md.

v0.0.7

Choose a tag to compare

@arapov arapov released this 04 Jun 08:08
v0.0.7
7634cfe

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

Choose a tag to compare

@arapov arapov released this 29 May 14:49
v0.0.6
34013f8

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-dating list_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

Choose a tag to compare

@arapov arapov released this 29 May 09:43
v0.0.5
7616a1c

Audit follow-ups + general-purpose hygiene. No new tools, counts unchanged (8 reads + 4 writes + 2 b3 admin).

list_events

  • Without limit/offset it now auto-paginates the full discussion stream (merging embedded comments/users/polls across pages) up to a bounded cap, and reports scope.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_edited and stance_updated were in the activity set but missing from the counts object, so they were silently bucketed as other. Now counted under their own keys.
  • New scope.groups_truncated completeness signal (groups whose discussion listing hit the page cap), joining the complete / groups_failed / discussions_failed / discussions_truncated / discussions_capped set from 0.0.4.
  • until must be later than since when 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.org placeholders and describe the Cloud Run / IaC deployment pattern generically. No behaviour change.

See CHANGELOG.md.