feat(mcp): distribution polish for agent-first onboarding: CIMD, plugin start skill, bridge hint (#1814 PR 4) (#1866)

* feat(mcp): distribution polish for agent-first onboarding: CIMD, plugin start skill, bridge hint

Fourth PR of agent-first onboarding (#1814).

- The OAuth AS metadata advertises client_id_metadata_document_supported
  next to the existing `none` token auth, the pair Claude.ai, Claude Code
  and Codex look for to use CIMD instead of registering a DCR client per
  connection. authorize/token never keyed on client_id (the redirect-URI
  allowlist is the trust boundary), so nothing else changes; DCR stays
  for ChatGPT.
- The plugin's start skill no longer sends a user without an account to
  the website: the /mcp OAuth screen creates the account, and a
  NO_COMPANY_YET briefing failure routes to the onboarding skill and
  accounted_create_company. README updated to match.
- `npx accounted-mcp` without ACCOUNTED_API_KEY prints the OAuth
  alternative (Claude Code, Codex, Claude.ai connector) and that the
  account can be created on the sign-in screen; package README too.

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

* fix(oauth): do not advertise CIMD until redirect URIs are matched against the client document

CodeRabbit on #1866: advertising client_id_metadata_document_supported
makes Claude and Codex send URL client_ids and expects an exact
redirect_uri match against that document; the authorize endpoint only
checks the global allowlist and never fetches client metadata. The flag
is withheld until an SSRF-safe, cached CIMD fetch with exact redirect
matching exists. DCR stays the registration path (stateless, so free).

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-25 13:05:59 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 31e0cd6e05
commit e92365b86e
7 changed files with 76 additions and 6 deletions
+2
View File
@@ -1217,3 +1217,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-24] The MCP consent page pre-ticks companies:write for an account that has no company yet: that account is connecting in order to create a company, and a default that dead-ends on insufficient scope right after signup would be the worse default. Still an untickable checkbox, still bounded by the client's scope ceiling.
[2026-08-25] gnubok_create_company / POST /api/v1/companies require f_skatt explicitly and org_number whenever vat_registered (review findings on #1864): a defaulted F-skatt approval or a VAT-registered company with no momsregistreringsnummer would flow straight into invoices (ML 17 kap 24 §, SE-R-005). Explicit beats convenient on a legal fact.
[2026-08-25] An enskild firma's first fiscal year must end on 31 December in the programmatic setup paths, mirroring the wizard's own rule text: the calendar-year mandate (BFL 3 kap. 1 §) is not lifted by the first-year extension.
[2026-08-24] The OAuth AS metadata advertises client_id_metadata_document_supported (CIMD, #1814 PR 4) without fetching or validating the client's metadata document: authorize/token never keyed anything on client_id, the redirect_uri allowlist is the trust boundary, and CIMD only changes what Claude/Codex send as client_id (an HTTPS URL instead of a DCR-minted UUID). Fetching the document would add a network dependency to every consent for no gain in this design. DCR stays for ChatGPT.
[2026-08-25] CIMD is NOT advertised after all (reverses the 2026-08-24 entry; CodeRabbit on #1866): the spec expects an AS that advertises client_id_metadata_document_supported to fetch the document and match redirect_uri exactly against it, and our authorize endpoint only checks the global allowlist. Advertising would claim a check we skip. Add the flag together with an SSRF-safe cached CIMD fetch + exact redirect matching (localhost port-agnostic for Claude Code/Codex); DCR is free for us (stateless register), so nothing is lost meanwhile.
@@ -0,0 +1,34 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { GET } from '../route'
describe('GET /.well-known/oauth-authorization-server', () => {
beforeEach(() => {
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.example.test')
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('does not advertise CIMD until redirect URIs are validated against the client document', async () => {
// Advertising client_id_metadata_document_supported makes Claude and
// Codex send URL client_ids and expects an exact redirect_uri match
// against that document; the authorize endpoint only checks the global
// allowlist today, so the flag must stay off (see the route comment).
const res = await GET(new Request('https://app.example.test/.well-known/oauth-authorization-server'))
expect(res.status).toBe(200)
const body = await res.json()
expect(body.client_id_metadata_document_supported).toBeUndefined()
expect(body.token_endpoint_auth_methods_supported).toContain('none')
// DCR (stateless register endpoint) remains the registration path.
expect(body.registration_endpoint).toBe('https://app.example.test/api/mcp-oauth/register')
expect(body.code_challenge_methods_supported).toEqual(['S256'])
expect(body.authorization_response_iss_parameter_supported).toBe(true)
})
it('does not enumerate write scopes in public discovery', async () => {
const res = await GET(new Request('https://app.example.test/.well-known/oauth-authorization-server'))
const body = await res.json()
expect(body.scopes_supported).toContain('mcp')
expect(body.scopes_supported.some((s: string) => s.endsWith(':write'))).toBe(false)
})
})
@@ -23,6 +23,16 @@ export async function GET(request: Request) {
grant_types_supported: ['authorization_code', 'refresh_token'],
code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: ['none', 'client_secret_post'],
// Client ID Metadata Documents (MCP auth spec 2025-11-25) are deliberately
// NOT advertised yet. Advertising the flag makes Claude.ai, Claude Code
// and Codex send an HTTPS URL as client_id, and the spec then expects the
// authorization server to fetch that document and match redirect_uri
// exactly against its redirect_uris. Our authorize endpoint validates
// redirect_uri against the global allowlist only (lib/auth/oauth-allowlist.ts)
// and never fetches client metadata, so advertising CIMD would claim a
// check we do not perform. The stateless register endpoint makes DCR
// free for us, so nothing is lost by waiting: add the flag together with
// an SSRF-safe, cached CIMD fetch and exact redirect matching.
// RFC 9207: the authorize endpoint includes `iss` in every authorization
// response (success and error) so clients can detect mix-up attacks.
authorization_response_iss_parameter_supported: true,
+1 -1
View File
@@ -12,7 +12,7 @@ The official plugin for [Accounted](https://app.accounted.se), the open-source S
/plugin install accounted@accounted
```
Then run `/mcp` and authenticate with Accounted (OAuth consent screen; read-only scopes by default, write scopes are ticked explicitly). Start with `/accounted:start`.
Then run `/mcp` and authenticate with Accounted (OAuth consent screen; read-only scopes by default, write scopes are ticked explicitly). No account yet? Create it on that same screen, with BankID or e-mail. Start with `/accounted:start`: for a brand-new account it walks you through setting up the company (company form, organisationsnummer, VAT, fiscal year) right here in the conversation, then hands you the bank and Skatteverket connect links.
## Skills
+2 -2
View File
@@ -10,8 +10,8 @@ Verify the connection, learn who this company is, and surface what needs attenti
## Flow
1. Call `accounted_get_agent_briefing`. This is the single source for company facts: entity type (aktiebolag or enskild firma), accounting method (faktureringsmetoden or kontantmetoden), VAT period, employees, and ledger context. Never assume these; the flows below behave differently depending on them.
- If the call fails with an auth error, the MCP server is not connected yet: tell the user to run `/mcp` and authenticate with Accounted (OAuth consent screen; read-only scopes by default, write scopes are ticked explicitly). Self-hosted users: see the plugin README.
- If the user has no Accounted account at all, say so plainly and point them at https://app.accounted.se to create one. The plugin drives an existing ledger; it cannot bookkeep without one. Do not attempt any other flow until a company is connected.
- If the call fails with an auth error, the MCP server is not connected yet: tell the user to run `/mcp` and authenticate with Accounted (OAuth consent screen; read-only scopes by default, write scopes are ticked explicitly). A user who has no Accounted account creates it on that same screen (BankID or e-mail, about a minute); nobody needs to visit the website first. Self-hosted users: see the plugin README.
- If the call fails with `NO_COMPANY_YET`, the account exists but has no company: this is a brand-new user. Load `accounted_load_skill("onboarding")` and follow it. It gathers the facts (company form, organisationsnummer, VAT and moms period, accounting method, fiscal year), previews and creates the company with `accounted_create_company`, then hands out the bank and Skatteverket connect links. Do not attempt any other flow until the company exists.
2. Read `Accounted://attention` and `Accounted://period/active`.
3. Present a short orientation in the user's language: company name and form, active fiscal period and its lock status, and the top 3 items needing attention.
4. Point at the flows, matched to what attention showed:
+19 -3
View File
@@ -41,15 +41,31 @@ compatibility. Only the MCP integration is being renamed in this release.
The API key scopes determine which tools are visible and callable. Write tools
stage pending operations for explicit approval before anything is booked.
## OAuth connector
## OAuth connector (no API key, no account needed up front)
Clients with OAuth custom-connector support can connect directly without this
bridge:
Clients with OAuth support connect directly without this bridge and without an
existing API key. The sign-in screen lets a new user create the Accounted
account (BankID or e-mail), and company setup then continues in the
conversation through the `onboarding` skill and `accounted_create_company`:
```text
https://app.accounted.se/api/extensions/ext/mcp-server/mcp?tool_namespace=accounted
```
```bash
# Claude Code
claude mcp add --transport http accounted \
"https://app.accounted.se/api/extensions/ext/mcp-server/mcp?tool_namespace=accounted"
# OpenAI Codex
codex mcp add accounted --url \
"https://app.accounted.se/api/extensions/ext/mcp-server/mcp?tool_namespace=accounted"
```
Claude.ai and Claude Desktop: Settings > Connectors > Add custom connector,
paste the URL. The connector works before you connect (documentation tools);
the first company-scoped call opens the Connect prompt.
## Compatibility
The legacy `gnubok-mcp` package, environment variables, endpoint behavior, and
+8
View File
@@ -54,6 +54,14 @@ if (!API_KEY) {
'Error: ACCOUNTED_API_KEY is required.\n' +
'Get your API key at: https://app.accounted.se/settings?tab=api\n' +
'\n' +
'No API key (or no account yet)? Connect over OAuth instead; the sign-in\n' +
'screen lets you create the account, and setup continues in the chat:\n' +
' claude mcp add --transport http accounted \\\n' +
' "https://app.accounted.se/api/extensions/ext/mcp-server/mcp?tool_namespace=accounted"\n' +
' codex mcp add accounted --url \\\n' +
' "https://app.accounted.se/api/extensions/ext/mcp-server/mcp?tool_namespace=accounted"\n' +
' Claude.ai / Desktop: Settings > Connectors > Add custom connector with that URL.\n' +
'\n' +
'Add it to your Claude Desktop config:\n' +
'{\n' +
' "mcpServers": {\n' +