feat(settings): API & MCP tab: correct connector URL namespace, plugin path, Swedish guide (#2105)

* feat(settings): API & MCP tab: correct connector URL namespace, plugin path, Swedish guide

The in-product MCP URLs omitted `tool_namespace=accounted`, and
resolveMcpToolNamespace() falls back to the legacy `gnubok_` prefix when the
param is absent. Every connection made from Settings therefore got `gnubok_*`
tool names while the docs, the accounted-api skill, and
claude-plugin/.mcp.json all reference `accounted_*`.

- Add `tool_namespace=accounted` to the Claude.ai, Claude Code, and
  Claude Desktop snippets.
- Move the Claude Desktop bridge from `npx gnubok-mcp` / `GNUBOK_API_KEY` to
  `npx -y accounted-mcp` / `ACCOUNTED_API_KEY`, and emit `ACCOUNTED_URL` so
  self-hosted and white-label instances get a config pointing at their own
  host. The `gnubok_sk_` key prefix is unchanged: it is wire format.
- Surface the Claude Code plugin, the only path that configures the
  connection and the seven workflow commands in one step.
- Rename the settings tab "API" to "API & MCP" and rewrite its intro: the
  MCP connection is what most users come here for, not API keys.
- Link the step-by-step guide from the panel, locale-aware.

Docs: add a Swedish /docs/api/anslut-claude alongside the English page (the
docs site has no locale routing, so each language is its own URL), give both
the Claude Code plugin path, and teach the export and freshness scripts about
the new page so cross-repo drift is caught.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HfZgiZmNN6qGEfgvXqxAeg

* fix(settings,docs): Cursor is not Claude Code; use the accounted_ tool name

Review follow-up on #2105.

`claude mcp add` is a Claude Code command. Cursor does not read it, so the
"Claude Code / Cursor" row and the docs sentence pointing Cursor users at that
command were both wrong (the row predates this PR; the docs sentence did not).
Cursor now gets its own row and its own `~/.cursor/mcp.json` snippet with the
`url` field, in the panel and in both docs pages.

Also `vat_close_check` -> `accounted_vat_close_check` in the reviewer test on
both pages, matching the identifier used in the prompts section above it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HfZgiZmNN6qGEfgvXqxAeg

* feat(settings,docs): one-click Connect to Claude, and cut the panel to one action

Anthropic documents an install link for custom connectors:
https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=NAME&connectorUrl=ENCODED
(claude.com/docs/connectors/building/directory-vs-custom). It opens claude.ai
with the connector name and URL prefilled; the user still reviews and confirms,
and it grants nothing on its own. We were telling people to copy a URL and go
paste it somewhere else instead.

Settings panel, rendered and reviewed:
- "Connect to Claude" button is now the only thing above the fold. Everything
  that needs a config file or a terminal (claude.ai manual paste, Claude Code,
  the plugin, Cursor) moved into one "Other clients" disclosure, and the
  API-key methods keep theirs. Four code blocks -> one button, 1057px -> 719px.
- The connect group renders above the API-keys group. Connecting is why users
  open this tab; the tab's own intro says so.
- Each entry inside the disclosures shows its instruction as visible text.
  They were `?` HelpPopovers, so the panel read as opaque code blobs with no
  instructions on screen.
- Prose interpolates the brand's real casing, not the lowercased config key.

Docs, both languages: Path A leads with the install link and drops from five
manual steps to a link plus three short paragraphs, with the manual paste kept
under a subheading. No raw HTML: the docs renderer has no rehype-raw, so
<details> would have been silently dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HfZgiZmNN6qGEfgvXqxAeg

* fix(settings,docs): correct claude mcp add syntax and the SSR install link

Review follow-up. Both findings verified before acting on them.

`claude mcp add --help` gives `claude mcp add [options] <name> <commandOrUrl>`:
the URL is positional and there is no `--url` flag, so the API-key snippet
would have failed on a missing argument. Both commands now put
`--transport http` before the name and pass the URL positionally, in the panel
and in both docs pages.

The panel is server-rendered before it hydrates and window.location has no
server equivalent, so the install link was built from a relative mcpBase in the
first paint. A click in that window would hand claude.ai a connectorUrl it
cannot resolve. The origin now resolves after mount and the anchor carries no
href until it is known, which also makes it unclickable rather than wrong.
Verified: the SSR HTML contains no claude.ai href and no relative connectorUrl,
post-hydration the href is absolute, and there are no hydration warnings.

DECISIONS.md: code-span the `gnubok_*`/`accounted_*` wildcards so they stop
rendering as emphasis, and drop the "no one-click deeplink" claim from the
earlier entry rather than leave a false statement standing two lines above its
own correction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HfZgiZmNN6qGEfgvXqxAeg

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-01 16:56:19 +02:00
committed by GitHub
co-authored by Claude Opus 5 Jakob Wennberg
parent cd40127f0e
commit b5da51ea0a
11 changed files with 488 additions and 145 deletions
+209 -113
View File
@@ -22,11 +22,9 @@ import { useToast } from '@/components/ui/use-toast'
import {
SettingsGroup,
SettingsReveal,
SettingsRow,
SettingsRowNote,
} from '@/components/settings/SettingsRows'
import { AttnLine } from '@/components/ui/attn-line'
import { Loader2, Plus, Copy, Check, Trash2, Key, ChevronDown, AlertTriangle } from 'lucide-react'
import { Loader2, Plus, Copy, Check, Trash2, Key, ChevronDown, AlertTriangle, ArrowUpRight } from 'lucide-react'
import { cn, formatDateLong } from '@/lib/utils'
import { copyToClipboard } from '@/lib/browser/copy-to-clipboard'
import { getBranding } from '@/lib/branding/service'
@@ -175,6 +173,7 @@ export function ApiKeysPanel() {
const [showCreateDialog, setShowCreateDialog] = useState(false)
const [showKeyDialog, setShowKeyDialog] = useState(false)
const [showApiKeyMethods, setShowApiKeyMethods] = useState(false)
const [showOtherClients, setShowOtherClients] = useState(false)
const [newKeyName, setNewKeyName] = useState('')
// 'live' by default: this is the general MCP-key surface and the dominant case
// is a key for the user's real company. 'test' is an explicit opt-in: a
@@ -299,15 +298,216 @@ export function ApiKeysPanel() {
}
}
const mcpBase = typeof window !== 'undefined'
? `${window.location.origin}/api/extensions/ext/mcp-server/mcp`
: '/api/extensions/ext/mcp-server/mcp'
// Telemetry-only distribution-channel marker (server reads the `client` query
// param; never used for auth). Lets us measure which Claude surface connected.
const mcpUrl = (client: string) => `${mcpBase}?client=${client}`
// This panel is server-rendered before it hydrates, and window.location has
// no server equivalent. Reading the origin at render time therefore yields a
// relative URL in the first paint, and a click on the install link in that
// window would hand claude.ai a connectorUrl it cannot resolve. Resolve the
// origin after mount and withhold the link's href until it is known.
const [origin, setOrigin] = useState('')
useEffect(() => setOrigin(window.location.origin), [])
const mcpBase = `${origin}/api/extensions/ext/mcp-server/mcp`
// `tool_namespace=accounted` is load-bearing: resolveMcpToolNamespace()
// falls back to the legacy `gnubok_` tool prefix when the param is absent,
// so a URL without it hands the client tool names that none of our docs,
// skills, or the Claude Code plugin reference. `client` is a telemetry-only
// distribution marker (server reads it; never used for auth).
const mcpUrl = (client: string) =>
`${mcpBase}?tool_namespace=accounted&client=${client}`
// claude.ai install link: opens Add-custom-connector with name and URL
// prefilled. It only prefills the dialog, so the user still reviews and
// confirms, and Anthropic's cloud must be able to reach the URL: on
// localhost or a firewalled self-host the manual paste below is the path.
// https://claude.com/docs/connectors/building/directory-vs-custom
const claudeInstallUrl =
'https://claude.ai/customize/connectors?modal=add-custom-connector' +
`&connectorName=${encodeURIComponent(branding.appName)}` +
`&connectorUrl=${encodeURIComponent(mcpUrl('claude-connector'))}`
return (
<>
<SettingsGroup label={t('connect_mcp_title')}>
{/* The marketing site's halftone AI marks (Claude, OpenAI): a quiet
"works with" cue, not chrome. Text carries the meaning; the marks
are decorative. */}
<div className="flex items-center gap-3 px-1 pb-1 pt-3">
<div aria-hidden className="flex shrink-0 items-center gap-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={illustrationSrc('logo-claude')}
width={ILLUSTRATIONS['logo-claude'].w}
height={ILLUSTRATIONS['logo-claude'].h}
alt=""
loading="lazy"
decoding="async"
className="h-5 w-auto"
/>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={illustrationSrc('logo-openai')}
width={ILLUSTRATIONS['logo-openai'].w}
height={ILLUSTRATIONS['logo-openai'].h}
alt=""
loading="lazy"
decoding="async"
className="h-5 w-auto"
/>
</div>
<p className="text-xs text-muted-foreground">{t('works_with_ai')}</p>
</div>
{/* claude.ai is the path for nearly everyone, and the install link
makes it one click, so it is the only thing above the fold. Every
other client needs a config file or a terminal, which is a
different job: it lives behind one disclosure instead of four
code blocks competing with the button. */}
<div className="px-1 pb-4 pt-2">
<Button asChild size="lg">
<a
href={origin ? claudeInstallUrl : undefined}
aria-disabled={!origin}
target="_blank"
rel="noopener noreferrer"
>
{t('connect_to_claude')}
<ArrowUpRight className="ml-1.5 h-4 w-4" />
</a>
</Button>
<p className="mt-2 max-w-prose text-xs text-muted-foreground">
{t('connect_to_claude_help')}
</p>
</div>
<button
type="button"
aria-expanded={showOtherClients}
onClick={() => setShowOtherClients(!showOtherClients)}
className="flex w-full items-center gap-2 border-t border-border px-1 py-3 text-xs text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
<ChevronDown
className={cn(
'h-3.5 w-3.5 transition-transform duration-150',
!showOtherClients && '-rotate-90',
)}
/>
{t('other_clients')}
</button>
<SettingsReveal open={showOtherClients}>
<div className="space-y-6 pb-3 pt-1">
<div>
<p className="mb-1 text-sm">{t('claude_ai_manual')}</p>
<p className="mb-2 text-xs text-muted-foreground">
{t.rich('claude_ai_instructions', {
// Prose gets the brand's real casing; `connectorName` is the
// lowercased config key and reads wrong in a sentence.
connectorName: branding.appName,
path: (chunks) => <strong>{chunks}</strong>,
})}
</p>
<CopyBlock text={mcpUrl('claude-connector')} copyAriaLabel={t('copy_aria')} />
</div>
<div>
<p className="mb-1 text-sm">{t('claude_plugin_label')}</p>
<p className="mb-2 text-xs text-muted-foreground">{t('claude_plugin_instructions')}</p>
<CopyBlock
text={`/plugin marketplace add erp-mafia/accounted\n/plugin install accounted@accounted`}
copyAriaLabel={t('copy_aria')}
/>
</div>
<div>
<p className="mb-1 text-sm">Claude Code</p>
<p className="mb-2 text-xs text-muted-foreground">{t('terminal_runs_browser_login')}</p>
{/* URL is quoted: unquoted `?` in the query string trips zsh globbing. */}
<CopyBlock text={`claude mcp add --transport http ${connectorName} "${mcpUrl('claude-code')}"`} copyAriaLabel={t('copy_aria')} />
</div>
<div>
<p className="mb-1 text-sm">Cursor</p>
<p className="mb-2 text-xs text-muted-foreground">
{t.rich('cursor_instructions', {
code: (chunks) => <code className="text-xs">{chunks}</code>,
})}
</p>
<CopyBlock text={`{
"mcpServers": {
"${connectorName}": {
"url": "${mcpUrl('cursor')}"
}
}
}`} copyAriaLabel={t('copy_aria')} />
</div>
</div>
</SettingsReveal>
<button
type="button"
aria-expanded={showApiKeyMethods}
onClick={() => setShowApiKeyMethods(!showApiKeyMethods)}
className="flex w-full items-center gap-2 border-t border-border px-1 py-3 text-xs text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
<ChevronDown
className={cn(
'h-3.5 w-3.5 transition-transform duration-150',
!showApiKeyMethods && '-rotate-90',
)}
/>
{t('connect_with_api_key')}
</button>
<SettingsReveal open={showApiKeyMethods}>
<div className="space-y-6 pb-3 pt-1">
<div>
<p className="mb-1 text-sm">Claude Desktop</p>
<p className="mb-2 text-xs text-muted-foreground">
{t.rich('claude_desktop_instructions', {
code: (chunks) => <code className="text-xs">{chunks}</code>,
})}
</p>
{/* ACCOUNTED_URL is emitted so self-hosted and white-label
instances get a config that points at their own host: the
bridge otherwise defaults to the hosted endpoint. The key
value keeps the `gnubok_sk_` wire prefix on purpose. */}
<CopyBlock text={`{
"mcpServers": {
"${connectorName}": {
"command": "npx",
"args": ["-y", "accounted-mcp"],
"env": {
"ACCOUNTED_API_KEY": "gnubok_sk_...",
"ACCOUNTED_URL": "${mcpUrl('claude-desktop')}",
"ACCOUNTED_CLIENT": "claude-desktop"
}
}
}
}`} copyAriaLabel={t('copy_aria')} />
</div>
<div>
<p className="mb-1 text-sm">Claude Code</p>
<p className="mb-2 text-xs text-muted-foreground">
{t('terminal_with_api_key')}
</p>
<CopyBlock text={`claude mcp add --transport http ${connectorName} \\
"${mcpUrl('claude-code')}" \\
--header "Authorization: Bearer gnubok_sk_..."`} copyAriaLabel={t('copy_aria')} />
</div>
</div>
</SettingsReveal>
{/* The step-by-step guide is canonical on the docs site, in one
language per URL (the docs site has no locale routing). Root-relative
so the /docs/api/* 308 in next.config.ts forwards to docs.gnubok.se. */}
<a
href={locale === 'sv' ? '/docs/api/anslut-claude' : '/docs/api/connect-claude'}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-1 py-3 text-xs text-muted-foreground underline-offset-4 transition-colors duration-150 hover:text-foreground hover:underline"
>
{t('full_guide_link')}
<ArrowUpRight className="h-3 w-3" />
</a>
</SettingsGroup>
<SettingsGroup>
{/* Group eyebrow with the group's primary action on the right. Styling
mirrors SettingsGroup's label line; the "?" holds the old panel
@@ -388,110 +588,6 @@ export function ApiKeysPanel() {
)}
</SettingsGroup>
<SettingsGroup label={t('connect_mcp_title')}>
{/* The marketing site's halftone AI marks (Claude, OpenAI): a quiet
"works with" cue, not chrome. Text carries the meaning; the marks
are decorative. */}
<div className="flex items-center gap-3 px-1 pb-1 pt-3">
<div aria-hidden className="flex shrink-0 items-center gap-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={illustrationSrc('logo-claude')}
width={ILLUSTRATIONS['logo-claude'].w}
height={ILLUSTRATIONS['logo-claude'].h}
alt=""
loading="lazy"
decoding="async"
className="h-5 w-auto"
/>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={illustrationSrc('logo-openai')}
width={ILLUSTRATIONS['logo-openai'].w}
height={ILLUSTRATIONS['logo-openai'].h}
alt=""
loading="lazy"
decoding="async"
className="h-5 w-auto"
/>
</div>
<p className="text-xs text-muted-foreground">{t('works_with_ai')}</p>
</div>
<SettingsRow
label="Claude.ai"
align="baseline"
help={t.rich('claude_ai_instructions', {
connectorName,
path: (chunks) => <strong>{chunks}</strong>,
})}
>
<SettingsRowNote>{t('recommended_badge')}</SettingsRowNote>
<div className="w-full min-w-0">
<CopyBlock text={mcpUrl('claude-connector')} copyAriaLabel={t('copy_aria')} />
</div>
</SettingsRow>
<SettingsRow
label={t('claude_code_cursor')}
align="baseline"
help={t('terminal_runs_browser_login')}
>
{/* URL is quoted: unquoted `?` in the query string trips zsh globbing. */}
<div className="w-full min-w-0">
<CopyBlock text={`claude mcp add ${connectorName} --transport http "${mcpUrl('claude-code')}"`} copyAriaLabel={t('copy_aria')} />
</div>
</SettingsRow>
<button
type="button"
aria-expanded={showApiKeyMethods}
onClick={() => setShowApiKeyMethods(!showApiKeyMethods)}
className="flex items-center gap-2 px-1 py-3 text-xs text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
<ChevronDown
className={cn(
'h-3.5 w-3.5 transition-transform duration-150',
!showApiKeyMethods && '-rotate-90',
)}
/>
{t('connect_with_api_key')}
</button>
<SettingsReveal open={showApiKeyMethods}>
<div className="space-y-6 pb-3 pt-1">
<div>
<p className="mb-1 text-sm">Claude Desktop</p>
<p className="mb-2 text-xs text-muted-foreground">
{t.rich('claude_desktop_instructions', {
code: (chunks) => <code className="text-xs">{chunks}</code>,
})}
</p>
<CopyBlock text={`{
"mcpServers": {
"${connectorName}": {
"command": "npx",
"args": ["gnubok-mcp"],
"env": {
"GNUBOK_API_KEY": "gnubok_sk_...",
"GNUBOK_CLIENT": "claude-desktop"
}
}
}
}`} copyAriaLabel={t('copy_aria')} />
</div>
<div>
<p className="mb-1 text-sm">{t('claude_code_cursor')}</p>
<p className="mb-2 text-xs text-muted-foreground">
{t('terminal_with_api_key')}
</p>
<CopyBlock text={`claude mcp add ${connectorName} --transport http \\
--url "${mcpUrl('claude-code')}" \\
--header "Authorization: Bearer gnubok_sk_..."`} copyAriaLabel={t('copy_aria')} />
</div>
</div>
</SettingsReveal>
</SettingsGroup>
{/* Create key dialog */}
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogContent className="max-w-[calc(100vw-2rem)] rounded-xl p-4 sm:max-w-3xl sm:p-6">