v5.11.0
Minor Changes
-
2169915: Custom MCP connector: connect any proprietary system as a live tool source for the support agent.
Orgs can now point Munin at an MCP server they host themselves (
vendor: "custom-mcp", newmcpconnector domain). While the in-house agent handles a conversation, the remote server's tools are composed alongside the built-in ones under anext_<connection>_namespace, so the agent can answer from the org's own system of record — subscriptions, memberships, internal CRM data — without Munin persisting any of it.The trust model externalizes the discipline the built-in self-service tools already follow: remote tools take no identity parameters. Munin sends a short-lived ES256-signed identity assertion (
X-Munin-IdentityJWT) on every call, verifiable against a new public per-org JWKS endpoint (/v1/public/connectors/:orgId/jwks, keys minted lazily into the newconnector_signing_keystable).The assertion deliberately carries no
verifiedboolean. It reportsemail_provenance/phone_provenance—authenticated(identity-verified widget session or delegated token),channel_asserted(an emailFrom:, SMS sender or caller ID, all spoofable), orself_reported(typed by an anonymous visitor) — and the receiving server decides what each level may disclose. Provenance is computed from the channel the current turn arrived on, not from the end-user record, so an identity that was authenticated once in the widget is still reported aschannel_assertedwhen someone later emails claiming to be that person. Unknown channels fall back tochannel_assertedrather than over-claiming.Because the connected server is a customer-facing tool source rather than a toolbox for admin agents, a connection exposes nothing by default: only tool names listed in the connection's
allowedToolsreach a conversation, a call to a withheld tool is refused even if the model guesses the name, and a server with an empty allow-list stays connected and silent.connectors_test_connectionreports what the server offers versus what is actually exposed, warns about exposed tools the server has not marked read-only, and flags allow-listed names the server does not provide.Remote listings are capped at 20 tools, descriptions are sanitized and truncated before reaching the model, results stay fenced as untrusted data, all outbound traffic goes through the SSRF-guarded fetch (new
safeFetchCompatin@getmunin/core), and a down or slow server degrades to "agent runs without those tools" — never a failed conversation.Setup follows the existing connector flow (credential link for the bearer token,
connectors_test_connectionprobes the server and lists its tools), the dashboard Integrations page gets a Custom MCP card, andskill://connectors/connect-custom-mcp-serverdocuments the server contract with a reference implementation to hand to the customer's developers.skill://connectors/connect-external-systemalso gains the same caveat for the built-in commerce and bookings connectors, whose self-service tools have always trusted an inbound emailFrom:header or SMS sender the same way: fine for order status, not sufficient on its own for anything whose disclosure to the wrong person causes real harm.SectionHeadin@getmunin/uigains an optionalsubtitleslot, and the Integrations page's private copy of that component is deleted in favour of it — the copy had drifted to a smaller heading than every other settings page used.The docs site gains an Integrations guide category and a "Connect your own system" guide covering the customer-facing warning, the allow-list flow and the provenance levels — the first guide-level documentation for connectors of any kind.
-
9d09f89: Show on each data-connection card who can actually reach it.
The Integrations page listed every connector in one undifferentiated section, so nothing told an operator that connecting Bing Webmaster Tools exposes no surface at all to the customer-facing chatbot, while connecting Gastroplanner lets customers cancel their own bookings. That is a material fact when you are about to hand a vendor credential over.
Audience is a property of the domain's tool surface, not of the vendor, so it is derived from
ConnectorDomainin the backend (audienceForDomain) and carried on both the vendor and connection DTOs rather than recomputed in the dashboard.commerceandbookingsship admin tools and a self-service half, so they read "Customers + team".seois admin-only — its fiveseo_*tools areaudiences: ['admin'],seo:readis absent from bothCONNECTOR_DOMAIN_SCOPESandSELF_SERVICE_SCOPES— so it reads "Team only". Custom MCP servers are proxied only into end-user agent sessions, so they read "Customers only".No enforcement changes: the audience gate, the connector scope map and the delegated-token scope allow-list already decided this. The badge only makes the existing decision visible.
-
e055fa3: Teach the connector trunk to authorize by OAuth redirect, not just static credentials.
ConnectorAdaptermodelled one kind of credential: something a human pastes into the/connect/credentialsform once. That covers Shopify, Magento, Gastroplanner and Bing, and it cannot express a vendor that hands out a short-lived access token behind a redirect. Adapters now declare an optionaloauthcapability (authorizeUrl/exchangeCode/refresh/revoke) and the trunk drives the rest:connectors_get_authorize_urlmints an HMAC-signed state,/v1/connectors/oauth/callbackexchanges the code, andConnectorOAuthServiceowns the tokens from there. Adapters without the capability are untouched.The redirect was the easy half. A refresh is a write on a read path: it happens while a read tool is running, inside that request's tenant transaction, and the new refresh token has to survive even when the vendor call it enabled then fails. So refreshes run in their own root-db transaction under
SELECT … FOR UPDATEon the connection row, re-reading the grant inside the lock so a parallel instance that already refreshed wins rather than both racing to burn the same token, with an in-process single-flight map collapsing concurrent callers in one process.The same reasoning has a sharper edge that a test caught: marking a connection
expiredmust happen in a separate committed transaction. Doing it inside the transaction that then throws rolls the marker back along with the error, so a dead grant would look healthy on the next call and re-fail forever. Refresh failure is now a two-phase operation — release the lock, commit the state change, then raiseconnectors_expiredtelling the operator to reconnect.Two things this changes for every connector.
credentialStategrows fromactive | pendingtoactive | pending | expired | revoked, andconfig.oauthbecomes reserved for the trunk — adapters must not touch it. SincebuildStoredConfigreturns a whole new config, the trunk re-attaches the grant after calling it;publicConfigbeing an allow-list is what already keeps token ciphertext out of DTOs, and thependingbranch of the connection DTO now strips the grant explicitly rather than dumping raw config.An OAuth connection stays
pendingeven once its client secret is stored, because a client secret alone can't call the vendor — soapplyCredentialsno longer runs a connection test it would certainly fail, and instead points at the authorize step.connectors_delete_connectionrevokes the grant at the vendor before deleting the row, and treats a vendor that has already dropped it as success, since the local tokens are gone either way.Self-hosters can't use Munin's OAuth client, so client id and secret are per-connection config fields rather than deployment env vars.
Two smaller improvements fall out.
connectors_list_vendorsreports which vendors areoauth, so an agent can tell the two setup paths apart before creating anything. AndresolveScopeno longer claims "no active connection configured" when one exists but is unusable — it names the connection and its state, so "expired, reconnect it" stops reading like "you never set this up". -
26364fd:
settings.liveUrlnow accepts a per-locale map, not just one template.{locale}substitutes the locale code as Munin stores it —nb-NO, notno— so a site that spells its paths/no/blog/…had no way to express its live URL and every publish announced without a link. A collection can now map locale code to template, with an optionaldefaultfor the rest:Keys match case-insensitively. A locale named by neither the map nor
defaultpublishes without a link, which is how a locale that is not on the site yet stays out of the announcement. A plain string still means one template for every locale, so nothing configured today changes. -
c8ed388: Add a search-console connector domain (
seo) with Bing Webmaster Tools behind it.Munin's analytics answer the post-click half of a traffic question — which pages got viewed, where visitors came from, what they searched for on the site and found nothing. A search console answers the pre-click half: what people typed, how often the site was shown, and where it ranked. The point of connecting one is that the same agent can then act on the gap, because Munin already owns the fix (
cms_update_entry,kb_create_document) and now owns a write verb to ask for a recrawl.skill://seo/improve-search-performancewalks that loop end to end.Five admin-only tools on the new
seo:read/seo:writescopes:seo_list_properties,seo_list_queries,seo_list_pages,seo_inspect_url, andseo_submit_urls. No self-service half — no end user asks about impressions or average position — and no DB work:connector_connections.domainis free-textvarchar(32)and its RLS policy is domain-agnostic, so the new domain is a union widening, not a migration.Three things this design commits to, all consequences of search-console data behaving unlike every connector already in the trunk:
Reads stay live; nothing is cached. Bing publishes no per-day ceiling on
GetQueryStats-style reads (throttling exists and surfaces asErrorCode4, now mapped to a502naming the throttle rather than a bare500), so there is no quota argument for persisting vendor data. The real constraint is shape, not volume:GetQueryStatstakes no date-range parameters and returns every week Bing holds on each call, so windowing and top-N truncation happen in the adapter. Trend-over-time would need history the vendor won't hand over retroactively, and that is a deliberate feature with its own table — not an optimization to smuggle in here.The window reported is the window covered. Bing aggregates into whole weeks ending Friday and lags 2–3 days, so results are aggregated per query across the requested range — impressions and clicks summed,
avgPositionweighted by impressions — and the response carries the range actually covered,nullwhen nothing fell in range. Echoing back the requestedfrom/towould have read as precision the data doesn't have.Cardinality is new. A commerce connection is one store; a search-console connection is one account holding many verified properties. So every verb takes a
siteUrl, resolved the wayconnectionIdalready is: omit it when unambiguous, and when several are verified the error names them.seo_submit_urlsadditionally refuses URLs outside the resolved property, and pre-checks the vendor's remaining daily and monthly quota so an over-budget batch is rejected whole rather than partially submitted — a client error, not a gateway error.Adding
'seo'toConnectorDomaindeliberately breaks the exhaustive domain map behind the voice self-service tool gate. That map now enumerates self-service domains only, so an operator-facing domain cannot reach an end-user surface by being forgotten;seo:*scopes are likewise absent fromSELF_SERVICE_SCOPESandCONNECTOR_DOMAIN_SCOPES.Google Search Console fits the same
SeoAdaptercontract but is not included: it is OAuth-only, andConnectorAdaptermodels static credentials exclusively — no authorize redirect, no refresh rotation, no revocation. That belongs in the trunk as its own change, where the hard part is not the redirect but that a token refresh is a write on a read path inside the request's tenant transaction. -
9991922: Add Google Search Console to the
seodomain, behind the sameseo_*tools.This is the payoff for drawing
SeoAdapterbefore there was a second vendor:GoogleSearchConsoleAdapterimplements the same contract, registers into the same domain, and everyseo_*tool works against it with no tool-layer change at all. It authorizes through the connector trunk's OAuth capability, so there is no Google-specific auth code either — an org supplies its own OAuth client id and secret, then approves the Google account by redirect.Where the two engines genuinely differ, the interface admits it rather than faking it.
submitUrlsis optional onSeoAdapter, and Google doesn't implement it: Search Console has no URL-submission endpoint (its Indexing API covers only job postings and broadcast events). Soseo_submit_urlsrefuses on a Google connection with a message naming the vendor, instead of silently no-op'ing or pretending to queue something. Field coverage differs the same way — Bing reportshttpStatus,discoveredAtandinboundAnchorCount; Google reportsdetail, its coverage state, which is the single most useful string it has ("Submitted and indexed", "Crawled - currently not indexed").detailis new onSeoUrlStatusand null for Bing. A null field means the engine doesn't expose it, not that the value is zero, and the skill and tool descriptions now say so.Both engines aggregate identically despite reporting differently. Google honours an exact date range where Bing returns whole weeks, but the adapter still requests
['date', <dimension>]and folds rows the same way — impressions and clicks summed per key,avgPositionweighted by impressions,ctrrecomputed after aggregation rather than averaged from per-row values. That keeps the returnedwindowhonestly derived from the rows present in both adapters, so an agent reading one result cannot tell which engine produced it except by the fields that are null.Two Google specifics worth recording. The authorize URL sets
access_type=offlineandprompt=consent, because without forced consent a repeat authorization returns no refresh token and the connection would appear to succeed and then fail on first refresh. Andinvalid_granton refresh maps toOAuthGrantRevokedErrorwhile every other token failure stays a vendor error — that distinction is what lets the trunk mark a connectionexpiredfor a genuinely dead grant without doing so on a transient Google 500.Property paths are URL-encoded, so both
https://example.com/andsc-domain:example.comproperties work.webmasters.readonlyis a sensitive scope. An org's own OAuth client works unverified against accounts it owns, which is the self-hosting and single-tenant case; distributing one client to customers requires Google app verification (CASA assessment, privacy policy, demo video).Unrelated fix, surfaced by this work:
runMigrationsnow takes a Postgres advisory lock for the duration. Concurrent callers were racingCREATE EXTENSION IF NOT EXISTS, which fails withtuple concurrently updated. It only bites on a cold database — several integration test files callingrunMigrationsat once — so it never reproduced on a warm local DB and would have shown up as a flaky CI failure in a file unrelated to whatever change added the extra racer. Adding two integration test files here was enough to trigger it. -
8ebf92a: Publishing several locales of one article now posts one Slack line with a thread, not four headlines side by side.
A four-locale batch published four near-identical announcements into the content channel, and nothing in them said they were the same article.
cms.entry.*payloads now carrytranslationGroupId— the id every locale variant of an article already shares, which webhook subscribers can use to revalidate a whole language switcher — and the bridge worker threads on it: the locale that publishes first gets the channel message, the rest of its group post as replies under it, and Slack's own reply count does the summarising.Grouping is per UTC day, following the outreach-campaign parent already in
slack_notification_links(newsubject_typecms_translation_group, no buttons, never resolved). A locale published the next day starts a fresh channel message rather than reviving yesterday's thread, and an entry with no siblings posts exactly as before. No migration — the existing table carries it.
Patch Changes
-
0106285: A public reply now retires the pending handover draft, and every conversation opens in one drawer that shows the whole thread.
An agent that answers and escalates in the same turn writes its
suggestedReplybefore the public reply goes out. The runtime used to clear that draft only when the two strings matched exactly, so a paraphrase survived: the customer had already been answered, but the dashboard still opened with the near-identical draft loaded in the composer, one keypress from a duplicate message.The rule is now structural and lives in
ConvService.sendMessage, where it holds for every MCP host and for humans too: a draft is a proposal for the next outbound message, so any public message from an agent or a teammate retires it tometadata.kind: 'draft_reply_superseded'with asupersededByMessageIdlink. The row stays in the API for audit, the composer stops offering it, andpreserveAttentionstill keeps the conversation flagged for a human. The runtime's string comparison is gone, and bothconv_request_handoverandconv_request_humanstate the retirement rule in their descriptions.The dashboard's two conversation drawers are now one. A flagged conversation used to open a review drawer showing the last customer message and a draft with no surrounding thread — while the same conversation opened as a full chat from the recent-conversations list. The merged drawer always renders the thread — minus drafts, which belong in the composer rather than the transcript, so a retired suggestion no longer shows up as an internal note next to the near-identical reply that retired it — and a pending draft prefills the composer under an "ai suggestion · edit before sending" banner with a discard action, so the operator sees what the agent already said before deciding what to add. Sending keeps passing
fromDraftId, so an unedited approved draft still queues no curation pass and an edited one still curates the delta.Sending no longer claims the conversation implicitly — Take over is now the only thing that claims it. The live card marks conversations the agent has already answered since the customer's last message.
-
adf6c2b: Validate
sourceandreadDepthon analytics import instead of letting Postgres reject them.analytics_import(andPOST /v1/analytics/import) acceptedsourceas any string up to 8
characters, butanalytics_view_eventshas a check constraint allowing onlypixel,beaconor
tracker. Importing a plausible-looking value such aswebpassed schema validation and then
failed inside the database, surfacing as500 Internal server errorwith no indication of which
field was wrong — during a bulk import of hundreds of events, with nothing written.The enum already existed in
analytics.tools.tsasViewSourceSchemaand was applied to all six
read filters, but never to the import path — the one place the value comes from the caller. It now
lives iningest-fields.tsalongside the other shared ingest boundary helpers, so the MCP tool and
the HTTP controller validate against one definition.readDepthhad the same gap and is now bounded
to 0–100 to match its own check constraint. Both boundaries now return a validation error naming the
field. -
ca3c846: Refuse ambiguous legacy identity hashes on
/v1/a/identifyand log a deprecation warning for the rest.The pre-
mn.identity.v1payload is the bare concatenation${externalId}:${visitorId}, which admits more than one field split whenever either field contains a colon: a signature minted for("tenant:7", "vid")also verifies for("tenant", "7:vid"), so a visitor holding one legitimately signed hash could re-bind it with the field boundary shifted and attach their browser to a different external id. Legacy hashes whose fields contain a colon are now rejected withidentity_hash_ambiguous; colon-free legacy hashes (which have exactly one valid split) keep verifying but logidentify.legacy_hashnaming the tracker, so operators can see who still signs the old payload before the fallback is removed. Signatures over the length-prefixedmn.identity.v1payload are unaffected. -
e8a76f0: Render message bodies mirrored into Slack as Slack mrkdwn instead of raw markdown.
Agent replies are written in markdown, but Slack's mrkdwn is a different dialect: it
reads*bold*, not**bold**, and has no link, heading or list syntax at all. The
bridge previously only HTML-escaped the body, so a mirrored reply showed literal**
around every bold span,[label](url)link pairs and###heading markers.markdownToMrkdwnnow converts before posting:**bold**/__bold__→*bold*,
*italic*→_italic_,~~strike~~→~strike~, headings → a bold line,
[label](url)/→<url|label>, bullets →•/◦/▪by nesting depth,
task items →☐/☑, and horizontal rules are dropped. Code spans and fenced blocks
are held aside so emphasis inside them survives untouched, and fence language hints
are stripped because Slack renders them as body text. Conversion covers mirrored
conversation messages (both the unfurled and author-labeled variants) and outreach
draft bodies in approval cards.Body truncation is now link- and fence-aware: it backs off a cut that would land inside
a<url|label>span or a trailing HTML entity, and closes a fence left open by the cut. -
bec14d1: Fix three mobile-viewport regressions in the dashboard shell.
Side sheets were sized with
h-fullon aposition: fixedelement, which resolves against the initial containing block — on iOS Safari that is the large viewport, so the bottom of every drawer sat behind the browser toolbar and the footer actions (approve, dismiss, cancel scheduled) were half-covered. They now use100dvhanchored to the top, which tracks the toolbar as it collapses and expands.The docs-link row under the Connect MCP snippet on Get started could not shrink: the docs URL is one long unbreakable token, so the flex row overflowed its card and pushed the copy button past the right edge of the viewport. The link may now wrap and the button no longer shrinks.
The dashboard topbar used an 8px gap on mobile, which left the org selector nearly touching the logo once a
leftSlotwas supplied. Mobile now gets 16px; the desktop gap (which also has a rule between the two) is unchanged. -
8ebf92a: Publishing several locales of one article now posts one Slack line with a thread, not four headlines side by side.
A four-locale batch published four near-identical announcements into the content channel, and nothing in them said they were the same article.
cms.entry.*payloads now carrytranslationGroupId— the id every locale variant of an article already shares, which webhook subscribers can use to revalidate a whole language switcher — and the bridge worker threads on it: the locale that publishes first gets the channel message, the rest of its group post as replies under it, and Slack's own reply count does the summarising.Grouping is per UTC day, following the outreach-campaign parent already in
slack_notification_links(newsubject_typecms_translation_group, no buttons, never resolved). A locale published the next day starts a fresh channel message rather than reviving yesterday's thread, and an entry with no siblings posts exactly as before. No migration — the existing table carries it.- @getmunin/types@5.11.0
-
bec14d1: Fix three mobile-viewport regressions in the dashboard shell.
Side sheets were sized with
h-fullon aposition: fixedelement, which resolves against the initial containing block — on iOS Safari that is the large viewport, so the bottom of every drawer sat behind the browser toolbar and the footer actions (approve, dismiss, cancel scheduled) were half-covered. They now use100dvhanchored to the top, which tracks the toolbar as it collapses and expands.The docs-link row under the Connect MCP snippet on Get started could not shrink: the docs URL is one long unbreakable token, so the flex row overflowed its card and pushed the copy button past the right edge of the viewport. The link may now wrap and the button no longer shrinks.
The dashboard topbar used an 8px gap on mobile, which left the org selector nearly touching the logo once a
leftSlotwas supplied. Mobile now gets 16px; the desktop gap (which also has a rule between the two) is unchanged.- @getmunin/types@5.11.0
Published packages
@getmunin/analytics-tracker@5.11.0@getmunin/chat-widget@5.11.0@getmunin/agent-host@5.11.0@getmunin/agent-runtime@5.11.0@getmunin/backend-core@5.11.0@getmunin/core@5.11.0@getmunin/dashboard-pages@5.11.0@getmunin/db@5.11.0@getmunin/docs-pages@5.11.0@getmunin/emails@5.11.0@getmunin/inspector-app@5.11.0@getmunin/mcp-toolkit@5.11.0@getmunin/sdk@5.11.0@getmunin/types@5.11.0@getmunin/ui@5.11.0
{ "liveUrl": { "default": "https://www.example.com/en/blog/{slug}", "nb-NO": "https://www.example.com/no/blog/{slug}", "sv-SE": "https://www.example.com/sv/blog/{slug}", }, }