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:
co-authored by
Claude Opus 5
Jakob Wennberg
parent
cd40127f0e
commit
b5da51ea0a
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user