fix(mcp): explain the Claude-side steps after "Anslut till Claude" and tick the checklist on a real connection (#2133) (#2147)

* fix(mcp): explain the Claude-side steps after "Anslut till Claude" and tick the checklist on a real connection (#2133)

Lazy auth is by design: Claude lists the tools before any sign-in and the
first company-scoped call answers 401, which opens the Accounted sign-in.
Nothing told the user, so a "connected" status with an unanswered first
question read as a broken connection (Axel, Discord).

- Settings -> API & MCP: one sentence of expectation under the button, and
  the step-by-step guide link moved from under two disclosures to directly
  under the button.
- Docs (connect-claude / anslut-claude): new "What happens after you click"
  section for Path A covering the connector dialog, the tools appearing
  before sign-in, the first-call login + consent screen, "ask again", and
  the "Required when the server asks" auth setting that only the manual
  path mentioned.
- Hem checklist step "Anslut till Claude": deep link now carries
  client=claude-connector like the settings button (claudeConnectorLink),
  the footnote carries the same expectation line plus the guide link, and
  the done-signal is an unrevoked api_keys row minted by the MCP OAuth
  token route (OAUTH_MCP_KEY_NAME) instead of the in-app AI-profile flag,
  which never meant "connected to Claude".
- Tests: claudeStepDone with/without a key row, deep-link snapshot.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7iJQwKiRTDWSMnRm4WM4L

* fix(mcp): correct consent-page claims, stop the completion PATCH loop, count OAuth keys past RLS (#2133)

Three skeptic refutations on PR #2147, fixed in one pass:

- Docs (EN + SV): the consent page shows the company active in the app and
  pre-selects every scope for Claude's connector (founder decision
  2026-08-26); it has no company picker and nothing to tick. Steps 3-4 of
  the new section, the "Read-only by default" paragraph above it, the
  sandbox note and the 10-minute test now describe Endast läs under
  Behörigheter instead.
- Checklist completion: users with initial_setup_path NULL (skipped the
  books question, then imported) hit the route's "Välj först hur du vill
  komma igång" 400 and, with saving as an effect dependency, retried it
  forever with a toast. completionPatchBody() records path=migration when
  none was chosen, and a rejected PATCH is not retried within the session.
- hasMcpKey: api_keys' SELECT policy is company-scoped, so the user client
  could not see companyless (NULL company_id) or archived-company keys and
  the step stayed open for the user who had just connected. The head count
  now runs through the service client with an explicit user_id filter.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7iJQwKiRTDWSMnRm4WM4L

* fix(mcp): surface a failed OAuth-key count and reserve the marker name (#2133)

CodeRabbit round on PR #2147:

- app/(dashboard)/page.tsx: a failed api_keys count answered count null,
  which claudeStepDone read as "never connected". Throw to the error
  boundary like the settings fetch does instead of guessing.
- app/api/settings/api-keys: reject a hand-minted key named
  MCP-klient (OAuth) (400 VALIDATION_ERROR): that name is the marker the
  Hem checklist reads as "connected to Claude", so a manual key with it
  would tick the step without any connection. Test added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7iJQwKiRTDWSMnRm4WM4L

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-02 00:04:31 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 8b09b06e14
commit 4f33184a9a
15 changed files with 292 additions and 43 deletions
+1
View File
@@ -1475,6 +1475,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-01] mcp.tool_called gets errorCause = errorCauseTag(err) on the two execution catch paths only (#2051): SQLSTATE or coded-error code, else the error class name, capped at 64 chars; a plain Error deliberately tags null because the class name 'Error' is noise, and pre-execution denials pass nothing since their errorCode already IS the vocabulary. Raw driver messages stay out of event_log on purpose: a constraint-violation message can quote row values.
[2026-09-01] counterparty_aliases joins the categorization_templates audit-trigger strip list (20260901200000) instead of staying logged: prod falsified the original exclusion list within 30 minutes of 20260901103000 going live (15 of the first 16 UPDATE audit rows were alias+learning noise, ~800/day projected vs ~50/day of real rule changes), because the learning path merges aliases in the same write that bumps occurrence_count. Explicit trade-off: a human editing ONLY aliases is no longer logged; accepted since alias growth is overwhelmingly automatic and any change also touching accounts/VAT/pattern/active still logs (first real one, 19:02:17Z same day, captured correctly). Pre-fix noise rows stay in audit_log (append-only) and the read model stops labelling the column so they render as no-ops.
[2026-09-01] MCP catalog budget attacked at the duplicated staged envelope rather than by demoting more reads: measuring the payload by segment showed outputSchema is 38 % of the whole catalog (23 290 tokens) and STAGED_OPERATION_SCHEMA alone 14 736 of it, the same envelope transmitted 58 times, while descriptions (what the three previous rounds trimmed) are only 10 %. period_status now carries its shape in one sentence instead of declared JSON Schema, matching actor/approve/preview which were always bare objects; 2 552 tokens reclaimed with no tool demoted and no field removed. Every edit is in the LOOSER direction because the server emits structuredContent for every tool and the documented failure mode is a declaration too tight making a strict client reject a successful call. next kept additionalProperties: false: staging.test.ts pins it closed and a guard whose reason is not in front of you is not one to loosen for 420 tokens. Ceiling ratcheted to 60 000 rather than the usual ~300 margin, leaving ~1 070 deliberate working margin: server.ts took 70 commits in 14 days and the previous 116-token margin is what starts the ratchet-block-bump-demote cycle visible in the bench log.
[2026-09-01] Checklist "Anslut till Claude" done-signal = unrevoked api_keys row named MCP-klient (OAuth) for the USER, not per company: the OAuth token route is the only writer of that name and the key company_id is whatever was active at sign-in (null for companyless signups), so a company filter would miss real connections; the AI-profile flag it replaced never meant "connected to Claude" (#2133). Counted through the service client with an explicit user_id filter, not the user client: api_keys' SELECT policy is company-scoped (20260330130000), which hides companyless and archived-company keys and left the step open for exactly the user who had just connected (skeptic refutation on PR #2147). The manual create route reserves the name (400) rather than adding a source column: a migration for a cosmetic tick is not worth it, and the name is already the only marker every reader of that row uses. The consent-page default (all scopes pre-selected, founder decision 2026-08-26) is described, not changed; the compliance swarm's GDPR Art.25(2) finding on this PR targets that decision and is Emil's call, not this docs fix.
[2026-09-01] PR #2130 CodeRabbit P1 (org-number twin inherits a grant): the ombud path binds SKV system-credential access to an org number, and org numbers are public and tenant-editable, so while more than one live (non-archived) company claims the same 12-digit org number NO company may verify, mint a deep link, or be granted by the nightly sync on it (409 ORG_NUMBER_CONTESTED; cron counts them as contested and changes nothing). This does not re-add the company-creation org-number guard (org-number reuse stays allowed); it only fences the one feature where the org number is the authority boundary. Also: the cron honours summarizeGrants.recognized (unknown role codes = pinning problem, never a denial), mirroring probeViaOmbudsregister.
[2026-09-01] ENABLE_BANKING_SANDBOX removed from the enable-banking manifest and the index.ts header (#2131): the variable was declared as optional but never read anywhere; sandbox vs production is decided by ENABLE_BANKING_API_URL (api.tilisy.com vs api.enablebanking.com, api-client.ts derives isSandbox from the host). A dead variable declared in the manifest is what the self-hosting docs would otherwise have copied. The manifest now lists the two optional variables the code actually reads (API_URL, PSU_TYPE); the _PRODUCTION aliases stay undeclared on purpose, they are a hosted Vercel convention, not an operator contract.
+4 -3
View File
@@ -62,7 +62,7 @@ export async function HemChecklistSection({
userId,
now,
initialSetup,
agentBuilt,
hasMcpKey,
vatRegistered,
momsPeriod,
}: {
@@ -70,7 +70,8 @@ export async function HemChecklistSection({
userId: string
now: Date
initialSetup: InitialSetupState
agentBuilt: boolean
/** Live OAuth-minted MCP key exists for this user: see claudeStepDone(). */
hasMcpKey: boolean
vatRegistered: boolean
momsPeriod: MomsPeriod | null
}) {
@@ -160,7 +161,7 @@ export async function HemChecklistSection({
hasBankConnected={onboardingProgress.hasBankConnected}
hasSkatteverketConnected={onboardingProgress.hasSkatteverketConnected}
hasInboxItems={onboardingProgress.hasInboxItems}
hasAgentBuilt={agentBuilt}
hasMcpKey={hasMcpKey}
vatLine={vatLine}
sieSweep={
sieSweep
+39 -2
View File
@@ -5,6 +5,9 @@ import DashboardContent from '@/components/dashboard/DashboardContent'
import { ChecklistSkeleton, PanesSkeleton } from '@/components/dashboard/HemSkeletons'
import { COMPANY_PICKED_COOKIE } from '@/lib/company/context'
import { isCockpitLandingRole } from '@/lib/company/home-domain'
import { OAUTH_MCP_KEY_NAME } from '@/lib/auth/api-keys'
import { claudeStepDone } from '@/lib/onboarding/checklist'
import { createServiceClient } from '@/lib/supabase/server'
import {
getDashboardAuthContext,
getDashboardCompanyId,
@@ -71,7 +74,22 @@ export default async function DashboardPage() {
const now = new Date()
const [settingsRes, { data: profile }, agentProfile, { count: skatteverketTokenCount }] =
// Service role for the OAuth-key count below: api_keys' SELECT policy is
// company_id IN user_company_ids() (20260330130000), so through the user
// client a key minted companyless (company_id NULL, the connect-before-
// signup flow) or bound to a company the user has since archived or left is
// invisible, and the step would stay open for exactly the user who just
// connected. The query filters on user_id explicitly, so no other user's
// rows are reachable.
const serviceClient = await createServiceClient()
const [
settingsRes,
{ data: profile },
agentProfile,
{ count: skatteverketTokenCount },
{ count: oauthKeyCount, error: oauthKeyError },
] =
await Promise.all([
getDashboardSettings(),
// First name for the greeting.
@@ -80,6 +98,18 @@ export default async function DashboardPage() {
// The Skatteverket promo below the panes needs this flag in the shell;
// the checklist section reads it again for its own step (cheap head count).
supabase.from('skatteverket_tokens').select('*', { count: 'exact', head: true }).eq('user_id', user.id).eq('company_id', companyId),
// The checklist's "Anslut till Claude" step is done when the MCP OAuth
// token route has minted a key for this user (claudeStepDone). Keyed on
// the user, not the company: the Claude connection follows the person,
// and the key's company_id is whatever was active at sign-in (or null
// for a companyless signup), so a company filter would miss real
// connections. Revoked rows do not count.
serviceClient
.from('api_keys')
.select('*', { count: 'exact', head: true })
.eq('user_id', user.id)
.eq('name', OAUTH_MCP_KEY_NAME)
.is('revoked_at', null),
])
// A FAILED settings read must not masquerade as "onboarding not done":
@@ -90,6 +120,12 @@ export default async function DashboardPage() {
if (settingsError) {
throw new Error(`company_settings fetch failed: ${settingsError.message}`)
}
// Same rule for the OAuth-key count: a failed query answers count null,
// which claudeStepDone would read as "never connected" and re-open the
// Claude step for a connected user. Surface it instead of guessing.
if (oauthKeyError) {
throw new Error(`api_keys count failed: ${oauthKeyError.message}`)
}
// If onboarding is not complete, redirect to onboarding. Exception: a byrå
// member who did NOT explicitly pick this company this session goes to the
@@ -110,6 +146,7 @@ export default async function DashboardPage() {
}
const agentBuilt = Boolean(agentProfile?.verified_at)
const hasMcpKey = claudeStepDone({ oauthKeyCount })
const userFirstName = profile?.full_name?.trim().split(/\s+/)[0] ?? null
const initialSetup = {
path: settings.initial_setup_path ?? null,
@@ -137,7 +174,7 @@ export default async function DashboardPage() {
userId={user.id}
now={now}
initialSetup={initialSetup}
agentBuilt={agentBuilt}
hasMcpKey={hasMcpKey}
vatRegistered={settings.vat_registered}
momsPeriod={settings.moms_period ?? null}
/>
+2 -1
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { decryptAuthCode, verifyPkce, hashAuthCode } from '@/lib/auth/oauth-codes'
import {
generateApiKey,
OAUTH_MCP_KEY_NAME,
generateRefreshToken,
hashRefreshToken,
createServiceClientNoCookies,
@@ -161,7 +162,7 @@ async function handleAuthorizationCodeGrant(params: URLSearchParams) {
company_id: companyId,
key_hash: hash,
key_prefix: prefix,
name: 'MCP-klient (OAuth)',
name: OAUTH_MCP_KEY_NAME,
scopes: grantedScopes,
refresh_token_hash: refresh.hash,
})
@@ -108,6 +108,24 @@ describe('POST /api/settings/api-keys', () => {
expect(body.error.code).toBe('API_KEY_SCOPE_INVALID')
})
it('returns 400 for the reserved OAuth marker name (would fake a Claude connection)', async () => {
const { insertSpy } = setupFrom({ count: 0 })
const res = await POST(
createMockRequest('/api/settings/api-keys', {
method: 'POST',
body: { name: ' MCP-klient (OAuth) ', scopes: ['reports:read'] },
}),
{ params: Promise.resolve({}) },
)
const { status, body } = await parseJsonResponse<{
error: { code: string; details: { field: string; reason: string } }
}>(res)
expect(status).toBe(400)
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(body.error.details).toMatchObject({ field: 'name', reason: 'reserved' })
expect(insertSpy).not.toHaveBeenCalled()
})
it('returns 409 API_KEY_SOD_CONFLICT for stage+approve without acknowledgement', async () => {
setupFrom({ count: 0 })
const res = await POST(
+12
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import {
generateApiKey,
DEFAULT_SCOPES,
OAUTH_MCP_KEY_NAME,
validateScopes,
findStageApproveConflict,
} from '@/lib/auth/api-keys'
@@ -68,6 +69,17 @@ export const POST = withRouteContext(
// Empty body: use defaults.
}
// The OAuth token route's key name is the marker the Hem checklist reads
// as "connected to Claude" (there is no source column). A hand-minted key
// with that name would tick the step without any connection, so the name
// is reserved for the OAuth path.
if (name.trim() === OAUTH_MCP_KEY_NAME) {
return errorResponseFromCode('VALIDATION_ERROR', log, {
requestId,
details: { field: 'name', reason: 'reserved', reserved: OAUTH_MCP_KEY_NAME },
})
}
// Both live and test keys bind to the active company. A test key is
// simulation-only (the v1 wrapper forces dry-run on every write) so it can
// safely point at the real company without ever persisting anything.
+51 -12
View File
@@ -3,7 +3,7 @@
import { useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { useLocale, useTranslations } from 'next-intl'
import { Check } from 'lucide-react'
import posthog from 'posthog-js'
import { Badge } from '@/components/ui/badge'
@@ -13,7 +13,12 @@ import { cn } from '@/lib/utils'
import { useErrorToast } from '@/lib/hooks/use-error-toast'
import { useFormat } from '@/lib/hooks/use-format'
import { isAnalyticsEnabled } from '@/lib/analytics/enabled'
import { checklistNumbers, type VatDeadlineLine } from '@/lib/onboarding/checklist'
import {
checklistNumbers,
claudeConnectorLink,
completionPatchBody,
type VatDeadlineLine,
} from '@/lib/onboarding/checklist'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
@@ -27,7 +32,11 @@ interface NewUserChecklistProps {
hasBankConnected?: boolean
hasSkatteverketConnected?: boolean
hasInboxItems?: boolean
hasAgentBuilt?: boolean
/** The user holds a live OAuth-minted MCP key, i.e. a Claude (or other
* MCP client) connection completed its first sign-in. This is the only
* signal that means "connected to Claude"; the in-app AI-profile flag
* used to tick this step and never corresponded to it (issue #2133). */
hasMcpKey?: boolean
/** Personalized VAT-deadline line for the Skatteverket step (null = say nothing). */
vatLine?: VatDeadlineLine
/** Latest SIE reconciliation-sweep outcome: surfaces "X matchade, Y att
@@ -74,11 +83,12 @@ export default function NewUserChecklist({
hasBankConnected = false,
hasSkatteverketConnected = false,
hasInboxItems = false,
hasAgentBuilt = false,
hasMcpKey = false,
vatLine = null,
sieSweep = null,
}: NewUserChecklistProps) {
const t = useTranslations('initial_setup')
const locale = useLocale()
const { appName } = useBranding()
const router = useRouter()
const showError = useErrorToast()
@@ -92,6 +102,12 @@ export default function NewUserChecklist({
// step (companies whose completedAt arrives from the server never see it).
const [retiring, setRetiring] = useState<'verdict' | 'closing' | 'done' | null>(null)
const retireStartedRef = useRef(false)
// A completion PATCH the server rejected (4xx: no write role, no settings
// row) must not be retried in a loop: `saving` is a dependency of the
// completion effect, so without this latch every rejection re-armed the
// effect and re-raised the error toast forever. The next visit tries once
// more from server truth.
const completeRejectedRef = useRef(false)
// The ChatGPT side door on the Claude step: collapsed by default so the
// one-click Claude path stays the visual primary.
const [chatGptOpen, setChatGptOpen] = useState(false)
@@ -134,7 +150,7 @@ export default function NewUserChecklist({
// Companies built without the skatteverket/inbox extensions skip those steps.
const step3Done = !hasSkatteverket || hasSkatteverketConnected
const step4Done = !hasInbox || hasInboxItems
const step5Done = hasAgentBuilt
const step5Done = hasMcpKey
useEffect(() => {
// The block retires itself once every step is done; Dölj remains the
@@ -143,14 +159,18 @@ export default function NewUserChecklist({
if (
!state.completedAt &&
step1Done && step2Done && step3Done && step4Done && step5Done &&
saving === null
saving === null &&
!completeRejectedRef.current
) {
if (!retireStartedRef.current) {
retireStartedRef.current = true
setRetiring('verdict')
}
void persist({ completed: true }, 'complete').then((updated) => {
// completionPatchBody supplies a path when none was recorded: the route
// refuses completed:true without one, and this cohort looped on a 400.
void persist(completionPatchBody(state.path), 'complete').then((updated) => {
if (updated) captureSetup('onboarding_setup_completed', { path: updated.path })
else completeRejectedRef.current = true
})
}
// persist intentionally stays out: its identity follows the toast hook and
@@ -175,8 +195,9 @@ export default function NewUserChecklist({
if (state.dismissedAt) return null
// After the beat, stay retired even while the completion PATCH is still in
// flight or retrying: falling through to the full checklist here would
// flash it after the verdict already played. A failed PATCH keeps retrying
// invisibly; the next visit renders from server truth either way.
// flash it after the verdict already played. A rejected PATCH is not
// retried this session (completeRejectedRef); the next visit renders from
// server truth either way.
if (retiring === 'done') return null
if (state.completedAt && !retiring) return null
@@ -231,9 +252,11 @@ export default function NewUserChecklist({
// page origin so self-hosted and white-label domains link to themselves.
const goClaude = () => {
captureSetup('onboarding_setup_step_started', { step: 'claude' })
const serverUrl = `${window.location.origin}/api/extensions/ext/mcp-server/mcp?tool_namespace=accounted`
const link = `https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=${encodeURIComponent(appName)}&connectorUrl=${encodeURIComponent(serverUrl)}`
window.open(link, '_blank', 'noopener')
window.open(
claudeConnectorLink({ origin: window.location.origin, appName }),
'_blank',
'noopener',
)
}
// ChatGPT has no add-connector deep link (the user pastes the server URL
// into Developer mode manually), so the side door copies the URL instead.
@@ -428,6 +451,22 @@ export default function NewUserChecklist({
)}
footnote={
<div className="mt-2">
{/* What the click leads to on Claude's side. Tools list before
any sign-in (lazy auth, by design), so without this line a
"connected" status with an unanswered first question reads
as a broken connection (issue #2133). The guide carries the
full sequence; one language per URL, see ApiKeysPanel. */}
<p className="mb-2 max-w-prose text-xs leading-5 text-muted-foreground">
{t('step_claude_expectation')}{' '}
<a
href={locale === 'sv' ? '/docs/api/anslut-claude' : '/docs/api/connect-claude'}
target="_blank"
rel="noopener noreferrer"
className="underline decoration-border underline-offset-4 transition-colors hover:text-foreground"
>
{t('step_claude_guide_link')}
</a>
</p>
<button
type="button"
onClick={toggleChatGpt}
+16 -13
View File
@@ -375,6 +375,22 @@ export function ApiKeysPanel() {
<p className="mt-2 max-w-prose text-xs text-muted-foreground">
{t('connect_to_claude_help')}
</p>
{/* 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.
It sits right under the button: the steps on Claude's side after
the click (consent, first-call sign-in) live there, and a reader
who has just clicked should not have to open two disclosures to
find them (issue #2133). */}
<a
href={locale === 'sv' ? '/docs/api/anslut-claude' : '/docs/api/connect-claude'}
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-flex items-center gap-1.5 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>
</div>
<button
@@ -493,19 +509,6 @@ export function ApiKeysPanel() {
</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>
+11
View File
@@ -46,6 +46,17 @@ export function createServiceClientNoCookies() {
)
}
/**
* Name of every api_keys row minted by the MCP OAuth token route
* (app/api/mcp-oauth/token). It is the only marker those rows carry (there
* is no source column), so the Hem checklist's "Anslut till Claude" step
* matches on it to know a client completed its first sign-in. Renaming it
* would untick the step for every existing connection. The manual create
* route (app/api/settings/api-keys) rejects this name so a hand-minted key
* cannot fake the connection.
*/
export const OAUTH_MCP_KEY_NAME = 'MCP-klient (OAuth)'
export function generateApiKey(mode: ApiKeyMode = 'live'): { key: string; hash: string; prefix: string } {
const random = crypto.randomBytes(32).toString('base64url')
// Test keys carry an explicit `test_` infix so integrators can tell at a
+14 -3
View File
@@ -27,7 +27,18 @@ Länken öppnar claude.ai med namn och adress ifyllda. Du granskar värdena och
**Du behöver inget Accounted-konto ännu.** Anslutningen fungerar direkt: servern svarar på handskakningen och dokumentationsverktygen utan inloggning, och första anropet som rör ett bolag öppnar Accounteds inloggning, där du som ny skapar kontot (BankID eller e-post + 2FA).
**Läsrättigheter som standard.** På godkännandesidan väljer du bolag och ger läsrättigheter (lista fakturor, läsa rapporter, räkna moms). Skrivrättigheter (skapa faktura, kontera, bokföra verifikat, köra bokslut) listas separat och måste bockas i uttryckligen. Så kan en granskare ansluta läsande medan du själv har en anslutning med skrivrättigheter för det dagliga arbetet.
**Alla behörigheter förvalda, varje skrivning stannar ändå.** Godkännandesidan ger hela behörighetslistan med ett klick. Fäll ut **Behörigheter** och välj **Endast läs** för en läsande anslutning (lista fakturor, läsa rapporter, räkna moms): så kan en granskare ansluta läsande medan du själv har en anslutning med skrivrättigheter för det dagliga arbetet. Oavsett behörigheter lägger skrivverktygen (skapa faktura, kontera, bokföra verifikat, köra bokslut) bara upp en pending operation som du bekräftar innan något bokförs, och åtkomsten går att återkalla under Inställningar → API & MCP.
#### Vad som händer efter klicket
Resten av inställningarna görs på Claudes sida, i den här ordningen:
1. **Connector-dialogen.** claude.ai öppnar **Add custom connector** med namn och adress ifyllda. Kontrollera adressen och klicka **Add**. Frågar dialogen om autentisering, välj **"Required when the server asks"**, inte det automatiskt föreslagna "None": servern kräver ingen inloggning när du ansluter, så "None" ser rätt ut men stoppar inloggningen i steg 3. Claude Desktop visar samma dialog under Inställningar → Connectors.
2. **Verktygen dyker upp direkt.** Anslutningen visas som ansluten och Claude listar Accounteds verktyg innan du har loggat in. Det är avsiktligt: handskakningen och dokumentationsverktygen behöver inget konto.
3. **Första riktiga frågan öppnar inloggningen.** Fråga något om bokföringen, till exempel *"Vilket bolag är jag ansluten till?"*. Servern svarar att inloggning krävs, och claude.ai öppnar Accounteds inloggning (BankID eller e-post + 2FA) följd av godkännandesidan. Den visar bolaget som just nu är aktivt i appen (byt bolag i appen först om du har flera) med alla behörigheter förvalda; fäll ut **Behörigheter** och välj **Endast läs** för en läsande anslutning. Godkänn och **ställ frågan igen**: frågan som väntade när inloggningen öppnades görs inte om av sig själv. Statusen "ansluten" med en obesvarad första fråga betyder "logga in och fråga igen", inte att anslutningen är trasig.
4. **Klart.** Härifrån går varje fråga mot det bolaget, och skrivningar stannar under **/pending** tills du bekräftar.
Inloggad, men Claude säger fortfarande att servern inte går att nå? Fråga igen i samma chatt först. Hjälper inte det: öppna Inställningar → Connectors, ta bort anslutningen och lägg till den igen med autentisering satt till "Required when the server asks".
#### Lägga till manuellt i stället
@@ -108,7 +119,7 @@ Nyckelvärdet börjar fortfarande med \`gnubok_sk_\`. Det är ett stabilt kredit
## Testa med de här frågorna
Alla tre går mot den deterministiska sandlådan (använd en \`gnubok_sk_test_*\`-nyckel eller välj sandlådebolaget på godkännandesidan). De går igenom hela läsvägen utan att bokföra något.
Alla tre går mot den deterministiska sandlådan (använd en \`gnubok_sk_test_*\`-nyckel, eller gör sandlådebolaget aktivt i appen innan du loggar in från Claude). De går igenom hela läsvägen utan att bokföra något.
1. **"Visa mina okonterade banktransaktioner och föreslå konteringar."**
Claude kallar \`accounted_list_uncategorized_transactions\` och sedan \`accounted_suggest_categories\` och går igenom förslagen med dig. Godkänner du ett förslag läggs en \`accounted_categorize_transaction\` upp som pending operation. Ingenting bokförs förrän du bekräftar.
@@ -121,7 +132,7 @@ Alla tre går mot den deterministiska sandlådan (använd en \`gnubok_sk_test_*\
En snabb genomgång som visar att anslutningen fungerar innan du släpper in den på skarp data. Kör stegen i ordning. Varje steg säger vad du gör och vad du ska se.
1. **Anslut.** Väg A med bara läsrättigheter, väg B, eller väg C med en \`gnubok_sk_test_*\`-nyckel. → Claude listar Accounteds verktyg (rubriker som *List Uncategorized Transactions* och *VAT Declaration (Momsdeklaration)*).
1. **Anslut.** Väg A med **Endast läs** valt på godkännandesidan, väg B, eller väg C med en \`gnubok_sk_test_*\`-nyckel. → Claude listar Accounteds verktyg (rubriker som *List Uncategorized Transactions* och *VAT Declaration (Momsdeklaration)*).
2. **Kontrollera bolaget.** Fråga *"Vilket bolag är jag ansluten till?"* → Claude namnger sandlådebolaget (till exempel **Sandlådan Konsult**).
3. **Kör fråga 1** (okonterade och konteringsförslag). → En lista med okonterade rader plus förslag. Ingen bokföring sker.
4. **Kör fråga 2** (förfallna fakturor). → Minst en förfallen kundfaktura med åldersfördelning.
+14 -3
View File
@@ -20,7 +20,18 @@ The link opens claude.ai with the connector name and URL already filled in. You
**You do not need an Accounted account yet.** The connector works as soon as it is added: the server answers the handshake and the documentation tools without credentials, and the first company-scoped call opens the Accounted sign-in, where a new user creates the account (BankID or e-mail + 2FA).
**Read-only by default.** On the consent screen you pick the company and grant read scopes (list invoices, read reports, compute VAT). Write scopes (create invoice, categorise, book vouchers, run year-end) are listed separately and must be ticked explicitly, so a reviewer can connect read-only while you keep a write-enabled connection for daily work.
**All permissions pre-selected, every write still staged.** The consent page grants the full scope set with one click. Expand **Behörigheter** and choose **Endast läs** to connect read-only (list invoices, read reports, compute VAT): a reviewer can do that while you keep a write-enabled connection for daily work. Whatever the scopes, write tools (create invoice, categorise, book vouchers, run year-end) only stage a pending operation that you confirm before anything is booked, and the grant can be revoked under Settings → API & MCP.
#### What happens after you click
The rest of the setup happens on Claude's side, in this order:
1. **The connector dialog.** claude.ai opens **Add custom connector** with the name and URL filled in. Check the URL and click **Add**. If the dialog asks about authentication, choose **"Required when the server asks"**, not the auto-detected "None": the server does not demand a login at connect time, so "None" looks right but blocks the sign-in in step 3. Claude Desktop shows the same dialog under Settings → Connectors.
2. **The tools appear straight away.** The connector shows as connected and Claude lists the Accounted tools before you have signed in. That is by design: the handshake and the documentation tools need no account.
3. **The first real question opens the sign-in.** Ask something about your books, for example *"Which company am I connected to?"*. The server answers that a login is required, and claude.ai opens the Accounted sign-in (BankID or e-mail + 2FA), followed by the consent page. It shows the company that is currently active in the app (switch company in the app first if you have several) with every permission pre-selected; expand **Behörigheter** and choose **Endast läs** for a read-only connection. Approve, then **ask the question again**: the question that was waiting when the sign-in opened is not retried on its own. A "connected" status with an unanswered first question means "sign in, then ask again", not a broken connection.
4. **Done.** From here every question runs against that company; writes stage at **/pending** until you confirm.
Signed in, but Claude still says it cannot reach the server? Ask again in the same chat first. If that does not help, open Settings → Connectors, remove the connector, and add it again with authentication set to "Required when the server asks".
#### Adding it by hand instead
@@ -103,7 +114,7 @@ continue to work without changes.
## Try these prompts
All three run against the deterministic sandbox seed (use a \`gnubok_sk_test_*\` key or pick the sandbox company on the OAuth consent screen). They exercise the read path end-to-end without booking anything.
All three run against the deterministic sandbox seed (use a \`gnubok_sk_test_*\` key, or make the sandbox company the active company in the app before you sign in from Claude). They exercise the read path end-to-end without booking anything.
1. **"Show my uncategorized bank transactions and suggest categories."**
Claude calls \`accounted_list_uncategorized_transactions\` then \`accounted_suggest_categories\` and walks you through the proposals. Approving one stages an \`accounted_categorize_transaction\` pending operation: nothing is booked until you confirm.
@@ -116,7 +127,7 @@ All three run against the deterministic sandbox seed (use a \`gnubok_sk_test_*\`
A quick end-to-end pass to confirm the connection works before you trust it with real data. Run the steps in order; each lists what you do and what you should see.
1. **Connect.** Use Path A (read-only scopes only), Path B, or Path C with a \`gnubok_sk_test_*\` key. → Claude lists the Accounted tools (titles like *List Uncategorized Transactions*, *VAT Declaration (Momsdeklaration)*).
1. **Connect.** Use Path A (choose **Endast läs** on the consent page), Path B, or Path C with a \`gnubok_sk_test_*\` key. → Claude lists the Accounted tools (titles like *List Uncategorized Transactions*, *VAT Declaration (Momsdeklaration)*).
2. **Confirm the company.** Ask *"Which company am I connected to?"* → Claude names the sandbox company (e.g. **Sandlådan Konsult**).
3. **Run prompt 1** (*uncategorized + suggest categories*). → A list of uncategorised rows plus category suggestions; no booking happens.
4. **Run prompt 2** (*overdue invoices*). → At least one overdue customer invoice with aging.
+56 -1
View File
@@ -1,5 +1,11 @@
import { describe, expect, it } from 'vitest'
import { checklistNumbers, vatDeadlineLine } from '../checklist'
import {
checklistNumbers,
claudeConnectorLink,
claudeStepDone,
completionPatchBody,
vatDeadlineLine,
} from '../checklist'
describe('vatDeadlineLine', () => {
it('returns null when the company is not VAT-registered', () => {
@@ -71,3 +77,52 @@ describe('checklistNumbers', () => {
})
})
})
describe('completionPatchBody', () => {
it('keeps a recorded path out of the body', () => {
expect(completionPatchBody('bank')).toEqual({ completed: true })
expect(completionPatchBody('fresh')).toEqual({ completed: true })
})
it('records migration when no path was chosen, so the route accepts completion', () => {
// Skipped the books question, then imported: step 1 is only done via an
// import in that state. Without a path the route answers 400 and the
// completion effect used to retry it forever.
expect(completionPatchBody(null)).toEqual({ completed: true, path: 'migration' })
})
})
describe('claudeStepDone', () => {
it('is done once an OAuth-minted MCP key row exists', () => {
expect(claudeStepDone({ oauthKeyCount: 1 })).toBe(true)
expect(claudeStepDone({ oauthKeyCount: 3 })).toBe(true)
})
it('stays open without a key row, including a null head count', () => {
expect(claudeStepDone({ oauthKeyCount: 0 })).toBe(false)
expect(claudeStepDone({ oauthKeyCount: null })).toBe(false)
expect(claudeStepDone({ oauthKeyCount: undefined })).toBe(false)
})
})
describe('claudeConnectorLink', () => {
it('builds the claude.ai deep link with namespace and client marker, from the page origin', () => {
const link = claudeConnectorLink({ origin: 'https://app.testbrand.example', appName: 'Testbrand' })
expect(link).toBe(
'https://claude.ai/customize/connectors?modal=add-custom-connector' +
'&connectorName=Testbrand' +
'&connectorUrl=https%3A%2F%2Fapp.testbrand.example%2Fapi%2Fextensions%2Fext%2Fmcp-server%2Fmcp%3Ftool_namespace%3Daccounted%26client%3Dclaude-connector',
)
})
it('matches the Settings → API & MCP button shape (tool_namespace + client=claude-connector)', () => {
const link = claudeConnectorLink({ origin: 'http://localhost:3000', appName: 'Bokföring AB' })
const url = new URL(link)
expect(url.searchParams.get('connectorName')).toBe('Bokföring AB')
const server = new URL(url.searchParams.get('connectorUrl') ?? '')
expect(server.origin).toBe('http://localhost:3000')
expect(server.pathname).toBe('/api/extensions/ext/mcp-server/mcp')
expect(server.searchParams.get('tool_namespace')).toBe('accounted')
expect(server.searchParams.get('client')).toBe('claude-connector')
})
})
+46 -1
View File
@@ -1,4 +1,4 @@
import type { MomsPeriod } from '@/types'
import type { InitialSetupPath, MomsPeriod } from '@/types'
/**
* What the Skatteverket checklist step should say about VAT deadlines.
@@ -47,3 +47,48 @@ export function checklistNumbers(gates: { hasSkatteverket: boolean; hasInbox: bo
const assistant = receipts + (gates.hasInbox ? 1 : 0)
return { count: assistant, skv, receipts, assistant }
}
/**
* Body of the PATCH that retires the checklist once every step is done.
* The route refuses `completed: true` without a path ("Välj först hur du
* vill komma igång"). `path` is null when the journey's books question was
* skipped and the books then arrived through /import or MCP; in that state
* step 1 can only be done via an import, so `migration` is the truthful path
* to record. Without it that cohort looped on a 400 (PR #2147 skeptic).
*/
export function completionPatchBody(
path: InitialSetupPath | null,
): { completed: true; path?: InitialSetupPath } {
return path ? { completed: true } : { completed: true, path: 'migration' }
}
/**
* Done-state for the "Anslut till Claude" step. The only thing that means
* "connected" is a live API key minted by the MCP OAuth token route: it
* exists exactly when a client (claude.ai, Claude Desktop, Claude Code)
* completed the first-call sign-in. `oauthKeyCount` is the head count of
* that user's unrevoked rows named by OAUTH_MCP_KEY_NAME (lib/auth/api-keys).
* Before issue #2133 the step ticked on the in-app AI-profile flag, which
* has nothing to do with Claude; the step could show done for a user who
* never connected and stay open for one who had.
*/
export function claudeStepDone(input: { oauthKeyCount: number | null | undefined }): boolean {
return (input.oauthKeyCount ?? 0) > 0
}
/**
* The claude.ai Add-custom-connector deep link the checklist's Claude step
* opens. Same shape as the Settings → API & MCP button: `tool_namespace` is
* load-bearing (without it the server hands out legacy `gnubok_` tool
* names), `client` is a telemetry-only distribution marker, and the origin
* comes from the page so self-hosted and white-label domains link to
* themselves. The link only prefills the dialog; the user reviews there.
*/
export function claudeConnectorLink(input: { origin: string; appName: string }): string {
const serverUrl = `${input.origin}/api/extensions/ext/mcp-server/mcp?tool_namespace=accounted&client=claude-connector`
return (
'https://claude.ai/customize/connectors?modal=add-custom-connector' +
`&connectorName=${encodeURIComponent(input.appName)}` +
`&connectorUrl=${encodeURIComponent(serverUrl)}`
)
}
+4 -2
View File
@@ -1555,7 +1555,9 @@
"step_claude_chatgpt_link": "Using ChatGPT?",
"step_claude_chatgpt_steps": "In ChatGPT (Plus, Pro or Business): Settings → Apps → Advanced settings → turn on Developer mode. Choose Add custom connector, name it {appName} and paste the server address. Then start a new chat and ask for a walkthrough of your books.",
"step_claude_chatgpt_copy": "Copy server address",
"step_claude_chatgpt_copied": "Copied"
"step_claude_chatgpt_copied": "Copied",
"step_claude_expectation": "Claude lists the tools straight away. The first real question opens a login prompt: approve it and ask again.",
"step_claude_guide_link": "Guide: connect Claude step by step"
},
"tax_assessment_notices": {
"title": "Final tax notices and remaining tax",
@@ -2839,7 +2841,7 @@
"copy_failed": "Could not copy. Select the text above and copy it manually.",
"connect_mcp_title": "Connect MCP client",
"connect_to_claude": "Connect to Claude",
"connect_to_claude_help": "Opens claude.ai with the URL prefilled. You review and confirm there; no API key needed. Your instance must be reachable from the internet.",
"connect_to_claude_help": "Opens claude.ai with the URL prefilled. You review and confirm there; no API key needed. Your instance must be reachable from the internet. Claude lists the tools straight away; the first real question opens a login prompt, approve it and ask again.",
"other_clients": "Other clients: Claude Code, Cursor, plugin",
"claude_ai_manual": "claude.ai (manual)",
"works_with_ai": "Works with Claude, ChatGPT, and other AI assistants.",
+4 -2
View File
@@ -1555,7 +1555,9 @@
"step_claude_chatgpt_link": "Använder du ChatGPT?",
"step_claude_chatgpt_steps": "I ChatGPT (Plus, Pro eller Business): Inställningar → Appar → Avancerade inställningar → slå på Utvecklarläge. Välj Lägg till anpassad connector, namnge den {appName} och klistra in serveradressen. Starta sedan en ny chatt och be om en genomgång av bokföringen.",
"step_claude_chatgpt_copy": "Kopiera serveradress",
"step_claude_chatgpt_copied": "Kopierad"
"step_claude_chatgpt_copied": "Kopierad",
"step_claude_expectation": "Claude listar verktygen direkt. Första riktiga frågan öppnar en inloggning: godkänn den och ställ frågan igen.",
"step_claude_guide_link": "Guide: anslut Claude steg för steg"
},
"tax_assessment_notices": {
"title": "Slutskattebesked och kvarskatt",
@@ -2839,7 +2841,7 @@
"copy_failed": "Kunde inte kopiera. Markera texten ovan och kopiera manuellt.",
"connect_mcp_title": "Anslut MCP-klient",
"connect_to_claude": "Anslut till Claude",
"connect_to_claude_help": "Öppnar claude.ai med adressen ifylld. Du granskar och godkänner där; ingen API-nyckel behövs. Kräver att din instans går att nå från internet.",
"connect_to_claude_help": "Öppnar claude.ai med adressen ifylld. Du granskar och godkänner där; ingen API-nyckel behövs. Kräver att din instans går att nå från internet. Claude listar verktygen direkt; första riktiga frågan öppnar en inloggning, godkänn den och ställ frågan igen.",
"other_clients": "Andra klienter: Claude Code, Cursor, plugin",
"claude_ai_manual": "claude.ai (manuellt)",
"works_with_ai": "Fungerar med Claude, ChatGPT och andra AI-assistenter.",