fix(mcp): eager-auth flag so claude.ai's connector dialog detects OAuth, not "None" (#2159)

claude.ai's two-step "Add custom connector" dialog probes the server URL
without credentials and pre-fills the Authentication choice from the
answer. Our lazy-auth endpoint (issue #1814) answers 200 on an anonymous
initialize, which the dialog reads as an authless server: it suggests
"None", and a connector added with that default never opens the sign-in
when the challenge arrives later. Per Anthropic's connector docs a 401 is
the only answer it reads as OAuth ("Claude does not honor a
WWW-Authenticate header on a 200 response").

- `auth=required` on the endpoint URL (extensions/general/mcp-server/
  auth-mode.ts) turns lazy auth off for that URL: every tokenless
  request, initialize included, answers the 401 + WWW-Authenticate
  challenge. Callers with a token are unaffected; the bare URL keeps
  lazy auth for Claude Code, the plugin, Cursor and ChatGPT, and existing
  connector records are untouched.
- The links we control carry the flag: Settings -> API & MCP (install
  link and copy block), the onboarding checklist, both docs pages and
  claude-plugin/CONNECTORS.md (plugin 1.2.3). The docs' Path A now
  describes the eager flow (sign-in opens on Add) instead of telling
  users to override the dialog's "None".
- Tests: eager-auth.test.ts (401 on initialize/tools/list/public tools,
  namespaced metadata pointer, token no-op, exact-flag only); checklist
  link shape updated.

Companion: gnubok-website PR (Kom igång connector link + regenerated
connect-claude / anslut-claude pages).


Claude-Session: https://claude.ai/code/session_013yw62FMXGSzo6icFDiBwP3

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-02 14:51:32 +02:00
committed by GitHub
parent 6a85efb00a
commit 678acfe7ef
11 changed files with 211 additions and 31 deletions
+1
View File
@@ -1493,3 +1493,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-02] BankID signup no longer returns a magic link: the account is created unconfirmed and the typed address must click a mailed link before bankid_linked (and the MFA exemption) is set. Chosen over a pending-signup table: one nullable column (bankid_identities.email_verified_at) and the existing auth callback carry the state.
[2026-09-02] Removed the skattekonto drift email (skattekonto.drift_detected event, handler, /api/extensions/skatteverket/skattekonto/drift route, cron hook) instead of fixing it: it alerted on raw saldo-vs-1630 gaps that unbooked rows explain by construction (2026-09-02: Arcim 35 842 kr, 100% explained, while the Hem notice and reconciliation page said nothing was wrong), repeated every 24 h, and was the only surface of a May-2026 feature whose promised dashboard tile was never built. Since 2026-08-25 the reconciliation page and the Hem notice (detectSkvUnexplained, gated on unexplained_difference) are the surface. Considered gating the mail on unexplained_difference + once per episode (built, then dropped): after that gate it only fires on integrity findings the engine itself calls 'never a user task'. skattekonto_drift_tolerance stays (Hem notice reads it); stale skattekonto_drift_last_alert_at rows in extension_data are inert.
[2026-09-02] parties phase 0, golden set stays out of git: the labelling sample is prod voucher text with person names (salary, expense claims) and the repo is public, so the draw SQL is versioned but the rows and labels live in gitignored dev_docs/parties/golden/.
[2026-09-02] MCP eager-auth flag (`auth=required`) on the claude.ai connector links instead of reverting lazy auth: claude.ai's two-step Add-custom-connector dialog probes the URL without credentials and pre-fills Authentication "None" when the lazy handshake answers 200, which blocks the sign-in later; per Anthropic's docs a 401 is the only answer it reads as OAuth. The flag lives in the URL, so the links we control (Settings, onboarding checklist, both docs pages, website) get OAuth detected while the bare URL keeps lazy auth for Claude Code, the plugin, Cursor and ChatGPT, and existing connector records stay untouched. Rejected: keying eager auth off `client=claude-connector` (documented as telemetry-only) and sniffing the probe's user agent (fragile, undocumented).
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "accounted",
"displayName": "Accounted",
"description": "Official Accounted plugin: Swedish double-entry bookkeeping flows for Claude. Connects your ledger over MCP and ships short workflow skills (daily bookkeeping, health check, month close, VAT, payroll, year-end) that work from the company's live data and load Swedish accounting knowledge from the product when needed. Every write is staged for your approval; nothing is booked on its own.",
"version": "1.2.2",
"version": "1.2.3",
"author": {
"name": "Accounted (erp-mafia)"
},
+3 -3
View File
@@ -11,7 +11,7 @@ This plugin bundles exactly one connector: the Accounted MCP server, the same se
One-click add on claude.ai (opens the Add custom connector dialog prefilled; the params are `connectorName`/`connectorUrl`):
```
https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=Accounted&connectorUrl=https%3A%2F%2Fapp.accounted.se%2Fapi%2Fextensions%2Fext%2Fmcp-server%2Fmcp%3Ftool_namespace%3Daccounted
https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=Accounted&connectorUrl=https%3A%2F%2Fapp.accounted.se%2Fapi%2Fextensions%2Fext%2Fmcp-server%2Fmcp%3Ftool_namespace%3Daccounted%26client%3Dclaude-connector%26auth%3Drequired
```
Pair the link with a starter prompt the user pastes as their first message. It stays copy-paste ready for everyone because it points the agent at what it already knows (Claude memory, earlier chats) instead of containing the user's own data; the server-side onboarding skill carries the rest of the flow:
@@ -20,8 +20,8 @@ Pair the link with a starter prompt the user pastes as their first message. It s
## How the connection works
- **Lazy authentication.** The server answers `initialize`, `tools/list` and the documentation tools (`accounted_search_tools`, `accounted_list_skills`, `accounted_load_skill`) without any credentials. The first company-scoped call returns an authentication challenge, which Claude Code surfaces as `/mcp` → authenticate and Claude.ai as an inline Connect prompt.
- **Adding it by hand in Claude.ai** (Settings → Connectors → Add custom connector): choose Authentication **"Required when the server asks"** (not the auto-detected "None") and OAuth client **"register one automatically" (DCR)**; the server does not advertise CIMD yet. No extra headers, Streamable HTTP.
- **Lazy authentication.** The server answers `initialize`, `tools/list` and the documentation tools (`accounted_search_tools`, `accounted_list_skills`, `accounted_load_skill`) without any credentials. The first company-scoped call returns an authentication challenge, which Claude Code surfaces as `/mcp` → authenticate and Claude.ai as an inline Connect prompt. `auth=required` on the URL (the share link above carries it) turns lazy authentication off for that URL: every tokenless request, `initialize` included, answers the challenge. That is what claude.ai's Add-custom-connector dialog needs, because it probes the URL without credentials and only reads a 401 as OAuth.
- **Adding it by hand in Claude.ai** (Settings → Connectors → Add custom connector): paste the URL **with `auth=required`**; the dialog then detects Authentication **"Always required"** and OAuth client **"register one automatically" (DCR)**; the server does not advertise CIMD yet. Without the flag the dialog auto-detects "None" (the lazy handshake looks authless to it) and the choice must be overridden to "Required when the server asks". No extra headers, Streamable HTTP.
- **Account creation inside the sign-in.** A user who has no Accounted account creates one on the sign-in screen the challenge opens (BankID or e-mail + 2FA). No visit to the website first. `/accounted:setup` walks the whole flow, including creating the company from the conversation.
- **Every write is staged.** Write tools create a pending operation with a preview; nothing is booked until the user approves, either in chat via `accounted_approve_pending_operation` or in the web app.
- **Data stays in the user's tenant.** The connector only ever sees companies the signed-in user is a member of, enforced server-side per call.
+9 -2
View File
@@ -313,6 +313,13 @@ export function ApiKeysPanel() {
// distribution marker (server reads it; never used for auth).
const mcpUrl = (client: string) =>
`${mcpBase}?tool_namespace=accounted&client=${client}`
// claude.ai's Add-custom-connector dialog probes the URL without credentials
// and pre-fills Authentication "None" when the lazy handshake answers 200,
// which blocks the sign-in later. `auth=required` makes every tokenless
// request answer the 401 challenge so the dialog detects OAuth instead
// (extensions/general/mcp-server/auth-mode.ts). Claude Code, Cursor and the
// stdio bridge keep the lazy URL.
const claudeConnectorUrl = `${mcpUrl('claude-connector')}&auth=required`
// claude.ai install link: opens Add-custom-connector with name and URL
// prefilled. It only prefills the dialog, so the user still reviews and
@@ -322,7 +329,7 @@ export function ApiKeysPanel() {
const claudeInstallUrl =
'https://claude.ai/customize/connectors?modal=add-custom-connector' +
`&connectorName=${encodeURIComponent(branding.appName)}` +
`&connectorUrl=${encodeURIComponent(mcpUrl('claude-connector'))}`
`&connectorUrl=${encodeURIComponent(claudeConnectorUrl)}`
return (
<>
@@ -419,7 +426,7 @@ export function ApiKeysPanel() {
path: (chunks) => <strong>{chunks}</strong>,
})}
</p>
<CopyBlock text={mcpUrl('claude-connector')} copyAriaLabel={t('copy_aria')} />
<CopyBlock text={claudeConnectorUrl} copyAriaLabel={t('copy_aria')} />
</div>
<div>
@@ -0,0 +1,131 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { eventBus } from '@/lib/events/bus'
// Eager authentication (auth-mode.ts): `auth=required` on the endpoint URL
// turns lazy auth off for that URL, so a tokenless caller is challenged on
// every request, initialize included. claude.ai's Add-custom-connector dialog
// probes the URL without credentials and only reads a 401 as OAuth; a 200
// makes it pre-fill "None", which blocks the sign-in later.
const mocks = vi.hoisted(() => ({
validateApiKey: vi.fn(),
checkRateLimit: vi.fn(),
}))
vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/auth/api-keys')>()
return {
...actual,
validateApiKey: (...args: unknown[]) => mocks.validateApiKey(...args),
createServiceClientNoCookies: vi.fn(() => ({
from: vi.fn(() => {
throw new Error('anonymous requests must not touch tenant tables')
}),
})),
}
})
vi.mock('@/lib/auth/rate-limit-http', () => ({
checkRateLimit: (...args: unknown[]) => mocks.checkRateLimit(...args),
}))
vi.mock('../skills', async (importOriginal) => {
const actual = await importOriginal<typeof import('../skills')>()
return {
...actual,
loadAllSkills: vi.fn().mockResolvedValue([]),
}
})
import { handleMcpRequest } from '../server'
import { isEagerAuthRequested } from '../auth-mode'
const ENDPOINT = 'http://localhost:3000/api/extensions/ext/mcp-server/mcp'
const CHALLENGE_RE =
/^Bearer resource_metadata="http:\/\/localhost:3000\/\.well-known\/oauth-protected-resource(\?tool_namespace=accounted)?"$/
function rpc(
method: string,
params?: Record<string, unknown>,
opts: { token?: string; query?: string } = {}
): Request {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (opts.token) headers.Authorization = `Bearer ${opts.token}`
const url = opts.query ? `${ENDPOINT}?${opts.query}` : ENDPOINT
return new Request(url, {
method: 'POST',
headers,
body: JSON.stringify({ jsonrpc: '2.0', id: 7, method, ...(params ? { params } : {}) }),
})
}
describe('MCP eager authentication (auth=required)', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
mocks.checkRateLimit.mockResolvedValue({ ok: true })
mocks.validateApiKey.mockResolvedValue({
userId: 'user-1',
companyId: '11111111-1111-4111-8111-111111111111',
scopes: ['companies:read'],
apiKeyId: 'key-1',
apiKeyName: 'Test key',
mode: 'live',
})
})
it('challenges a tokenless initialize with a transport-level 401 + WWW-Authenticate', async () => {
const response = await handleMcpRequest(
rpc('initialize', { protocolVersion: '2025-06-18' }, { query: 'auth=required' })
)
expect(response.status).toBe(401)
expect(response.headers.get('WWW-Authenticate')).toMatch(CHALLENGE_RE)
expect(mocks.validateApiKey).not.toHaveBeenCalled()
expect(mocks.checkRateLimit).not.toHaveBeenCalled()
})
it('keeps the namespaced metadata pointer on the challenge', async () => {
const response = await handleMcpRequest(
rpc('initialize', { protocolVersion: '2025-06-18' }, { query: 'tool_namespace=accounted&client=claude-connector&auth=required' })
)
expect(response.status).toBe(401)
expect(response.headers.get('WWW-Authenticate')).toContain(
'/.well-known/oauth-protected-resource?tool_namespace=accounted"'
)
})
it('challenges the catalog and the public documentation tools as well', async () => {
const list = await handleMcpRequest(rpc('tools/list', undefined, { query: 'auth=required' }))
expect(list.status).toBe(401)
const call = await handleMcpRequest(
rpc('tools/call', { name: 'gnubok_list_skills', arguments: {} }, { query: 'auth=required' })
)
expect(call.status).toBe(401)
expect(call.headers.get('WWW-Authenticate')).toMatch(CHALLENGE_RE)
})
it('is a no-op for a caller that holds a token', async () => {
const response = await handleMcpRequest(
rpc('tools/call', { name: 'gnubok_list_skills', arguments: {} }, { token: 'gnubok_sk_x', query: 'auth=required' })
)
expect(response.status).toBe(200)
expect(mocks.validateApiKey).toHaveBeenCalledWith('gnubok_sk_x')
})
it('only the exact flag opts out of lazy authentication', async () => {
for (const query of ['auth=optional', 'auth=Required', 'authx=required']) {
const response = await handleMcpRequest(
rpc('initialize', { protocolVersion: '2025-06-18' }, { query })
)
expect(response.status, query).toBe(200)
}
expect(mocks.validateApiKey).not.toHaveBeenCalled()
})
it('isEagerAuthRequested reads the flag off the request URL', () => {
expect(isEagerAuthRequested(new Request(`${ENDPOINT}?auth=required`))).toBe(true)
expect(isEagerAuthRequested(new Request(`${ENDPOINT}?tool_namespace=accounted&auth=required`))).toBe(true)
expect(isEagerAuthRequested(new Request(ENDPOINT))).toBe(false)
expect(isEagerAuthRequested(new Request(`${ENDPOINT}?auth=`))).toBe(false)
})
})
@@ -0,0 +1,31 @@
/**
* Per-URL authentication mode for the MCP endpoint.
*
* The default is lazy (issue #1814): a client with no token may initialize,
* list the catalog and call the public documentation tools (public-tools.ts);
* the first protected call answers 401 + WWW-Authenticate, which the client
* turns into its Connect prompt.
*
* `auth=required` on the endpoint URL makes that URL eager instead: EVERY
* tokenless request answers the 401 challenge, `initialize` included. It has
* one consumer. claude.ai's two-step "Add custom connector" dialog probes the
* URL without credentials and pre-fills the Authentication choice from the
* answer (Anthropic: "Claude checks the URL and pre-fills the authentication
* settings it detects"). A 200 on that probe is read as "None", an authless
* server, and a connector added with that default never opens the sign-in
* when the challenge arrives later. A 401 is the only answer the dialog reads
* as OAuth (Anthropic: "Claude does not honor a WWW-Authenticate header on a
* 200 response"), so the links we control (Settings -> API & MCP, the
* onboarding checklist, both docs pages, the website) carry the flag.
*
* The bare URL keeps lazy authentication for Claude Code, the plugin, Cursor,
* ChatGPT developer mode and hand-typed adds, and connector records created
* before the flag existed are unaffected. Either way the flag only changes the
* answer to tokenless requests: a caller that holds a token never notices it.
*/
export const AUTH_MODE_QUERY_PARAM = 'auth'
export const AUTH_MODE_REQUIRED = 'required'
export function isEagerAuthRequested(request: Request): boolean {
return new URL(request.url).searchParams.get(AUTH_MODE_QUERY_PARAM) === AUTH_MODE_REQUIRED
}
+6
View File
@@ -33,6 +33,7 @@ import {
anonymousRateLimitIdentifier,
isPublicTool,
} from './public-tools'
import { isEagerAuthRequested } from './auth-mode'
import { createLogger } from '@/lib/logger'
import { roundOre, sumOre } from '@/lib/money'
import { currentAppVersion } from '@/lib/reports/app-version'
@@ -20228,6 +20229,11 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
// be parsed keeps the pre-lazy-auth answer for a tokenless caller (401), so
// probing the endpoint without credentials learns nothing new.
const token = extractBearerToken(request)
// Eager authentication (auth-mode.ts): the URL opted out of lazy auth, so a
// tokenless caller is challenged before the body is even looked at. This is
// what makes claude.ai's Add-custom-connector probe detect OAuth instead of
// "None"; a caller with a token is unaffected.
if (!token && isEagerAuthRequested(request)) return unauthorized()
let body: JsonRpcRequest
try {
body = await request.json()
+8 -9
View File
@@ -21,11 +21,11 @@ Det finns tre vägar in, beroende på vilken klient du använder. Alla tre når
## Väg A: claude.ai eller Claude Desktop (ett klick)
**[→ Anslut Accounted till Claude](https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=Accounted&connectorUrl=https%3A%2F%2Fapp.accounted.se%2Fapi%2Fextensions%2Fext%2Fmcp-server%2Fmcp%3Ftool_namespace%3Daccounted%26client%3Dclaude-connector)**
**[→ Anslut Accounted till Claude](https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=Accounted&connectorUrl=https%3A%2F%2Fapp.accounted.se%2Fapi%2Fextensions%2Fext%2Fmcp-server%2Fmcp%3Ftool_namespace%3Daccounted%26client%3Dclaude-connector%26auth%3Drequired)**
Länken öppnar claude.ai med namn och adress ifyllda. Du granskar värdena och godkänner; länken fyller bara i formuläret och ger ingenting i sig. Ingen API-nyckel att hålla reda på.
**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).
**Du behöver inget Accounted-konto ännu.** Inloggningen öppnas direkt när du lägger till anslutningen, och där skapar du som ny kontot på plats (BankID eller e-post + 2FA).
**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.
@@ -33,22 +33,21 @@ Länken öppnar claude.ai med namn och adress ifyllda. Du granskar värdena och
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.
1. **Connector-dialogen.** claude.ai öppnar **Add custom connector** med namn och adress ifyllda. Kontrollera adressen och gå vidare: dialogen känner av att servern kräver inloggning (**Always required**) och att Claude kan registrera sig själv automatiskt. Behåll de valen och klicka **Add**. Claude Desktop visar samma dialog under Inställningar → Connectors.
2. **Inloggningen öppnas.** claude.ai ber dig ansluta och ö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, så visas anslutningen som ansluten med Accounteds verktyg listade.
3. **Ställ din första fråga.** Till exempel *"Vilket bolag är jag ansluten till?"*. 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".
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 via länken ovan, med autentisering kvar på **Always required**.
#### Lägga till manuellt i stället
I **claude.ai** (Inställningar → Connectors) eller **Claude Desktop** (Inställningar → Connectors → Add custom connector), välj **Add custom connector** och klistra in:
\`\`\`
https://app.accounted.se/api/extensions/ext/mcp-server/mcp?tool_namespace=accounted&client=claude-connector
https://app.accounted.se/api/extensions/ext/mcp-server/mcp?tool_namespace=accounted&client=claude-connector&auth=required
\`\`\`
Behåll \`tool_namespace=accounted\`: den väljer verktygsnamnen som guiden utgår från. \`client=claude-connector\` är bara statistik. Frågar dialogen om autentisering, välj **"Required when the server asks"**, inte det automatiskt föreslagna "None".
Behåll alla tre parametrarna. \`tool_namespace=accounted\` väljer verktygsnamnen som guiden utgår från. \`auth=required\` gör att dialogen känner av inloggningen (**Always required**): utan den accepterar servern en anonym handskakning, dialogen föreslår **None**, och en anslutning som läggs till med det förvalet öppnar aldrig inloggningen. Lägger du ändå till adressen utan parametern, välj **Required when the server asks** själv. \`client=claude-connector\` är bara statistik.
## Väg B: Claude Code (plugin)
+8 -9
View File
@@ -14,11 +14,11 @@ There are three ways to connect, depending on your client. All three reach the s
## Path A: claude.ai or Claude Desktop (one click)
**[→ Connect Accounted to Claude](https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=Accounted&connectorUrl=https%3A%2F%2Fapp.accounted.se%2Fapi%2Fextensions%2Fext%2Fmcp-server%2Fmcp%3Ftool_namespace%3Daccounted%26client%3Dclaude-connector)**
**[→ Connect Accounted to Claude](https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=Accounted&connectorUrl=https%3A%2F%2Fapp.accounted.se%2Fapi%2Fextensions%2Fext%2Fmcp-server%2Fmcp%3Ftool_namespace%3Daccounted%26client%3Dclaude-connector%26auth%3Drequired)**
The link opens claude.ai with the connector name and URL already filled in. You review the values and confirm; the link only prefills the dialog, it grants nothing on its own. No API key to manage.
**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).
**You do not need an Accounted account yet.** Adding the connector opens the Accounted sign-in straight away, and a new user creates the account right there (BankID or e-mail + 2FA).
**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.
@@ -26,22 +26,21 @@ The link opens claude.ai with the connector name and URL already filled in. You
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.
1. **The connector dialog.** claude.ai opens **Add custom connector** with the name and URL filled in. Check the URL and continue: the dialog detects that the server requires a sign-in (**Always required**) and that Claude can register itself automatically. Keep those and click **Add**. Claude Desktop shows the same dialog under Settings → Connectors.
2. **The sign-in opens.** claude.ai asks you to connect and 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, and the connector shows as connected with the Accounted tools listed.
3. **Ask your first question.** For example *"Which company am I connected to?"*. 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".
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 from the link above, with authentication left on **Always required**.
#### Adding it by hand instead
In **claude.ai** (Settings → Connectors) or **Claude Desktop** (Settings → Connectors → Add custom connector), choose **Add custom connector** and paste:
\`\`\`
https://app.accounted.se/api/extensions/ext/mcp-server/mcp?tool_namespace=accounted&client=claude-connector
https://app.accounted.se/api/extensions/ext/mcp-server/mcp?tool_namespace=accounted&client=claude-connector&auth=required
\`\`\`
Keep \`tool_namespace=accounted\`: it selects the tool names this guide uses. \`client=claude-connector\` is telemetry-only. If the dialog asks about authentication, choose **"Required when the server asks"**, not the auto-detected "None".
Keep all three parameters. \`tool_namespace=accounted\` selects the tool names this guide uses. \`auth=required\` makes the dialog detect the sign-in (**Always required**): without it the server accepts an anonymous handshake, the dialog suggests **None**, and a connector added with that default never opens the sign-in. If you do add the bare URL, choose **Required when the server asks** by hand. \`client=claude-connector\` is telemetry-only.
## Path B: Claude Code (plugin)
+7 -3
View File
@@ -106,16 +106,16 @@ describe('claudeStepDone', () => {
})
describe('claudeConnectorLink', () => {
it('builds the claude.ai deep link with namespace and client marker, from the page origin', () => {
it('builds the claude.ai deep link with namespace, client marker and eager-auth flag, 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',
'&connectorUrl=https%3A%2F%2Fapp.testbrand.example%2Fapi%2Fextensions%2Fext%2Fmcp-server%2Fmcp%3Ftool_namespace%3Daccounted%26client%3Dclaude-connector%26auth%3Drequired',
)
})
it('matches the Settings → API & MCP button shape (tool_namespace + client=claude-connector)', () => {
it('matches the Settings → API & MCP button shape (tool_namespace + client=claude-connector + auth=required)', () => {
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')
@@ -124,5 +124,9 @@ describe('claudeConnectorLink', () => {
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')
// Without this flag claude.ai's Add-custom-connector dialog pre-fills
// Authentication "None" (the lazy handshake answers 200) and the sign-in
// never opens.
expect(server.searchParams.get('auth')).toBe('required')
})
})
+6 -4
View File
@@ -80,12 +80,14 @@ export function claudeStepDone(input: { oauthKeyCount: number | null | undefined
* 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.
* names), `client` is a telemetry-only distribution marker, `auth=required`
* makes claude.ai's dialog detect OAuth instead of "None" (see
* extensions/general/mcp-server/auth-mode.ts), 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`
const serverUrl = `${input.origin}/api/extensions/ext/mcp-server/mcp?tool_namespace=accounted&client=claude-connector&auth=required`
return (
'https://claude.ai/customize/connectors?modal=add-custom-connector' +
`&connectorName=${encodeURIComponent(input.appName)}` +