From e92365b86ef33469a9921e6430d135993a9d2806 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Tue, 25 Aug 2026 13:05:59 +0200 Subject: [PATCH] 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 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 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 --- DECISIONS.md | 2 ++ .../__tests__/route.test.ts | 34 +++++++++++++++++++ .../oauth-authorization-server/route.ts | 10 ++++++ claude-plugin/README.md | 2 +- claude-plugin/skills/start/SKILL.md | 4 +-- packages/accounted-mcp/README.md | 22 ++++++++++-- packages/accounted-mcp/index.mjs | 8 +++++ 7 files changed, 76 insertions(+), 6 deletions(-) create mode 100644 app/.well-known/oauth-authorization-server/__tests__/route.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index d6219cc8..6604424b 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1217,3 +1217,5 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/.well-known/oauth-authorization-server/__tests__/route.test.ts b/app/.well-known/oauth-authorization-server/__tests__/route.test.ts new file mode 100644 index 00000000..d576e00b --- /dev/null +++ b/app/.well-known/oauth-authorization-server/__tests__/route.test.ts @@ -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) + }) +}) diff --git a/app/.well-known/oauth-authorization-server/route.ts b/app/.well-known/oauth-authorization-server/route.ts index c475238c..f758616f 100644 --- a/app/.well-known/oauth-authorization-server/route.ts +++ b/app/.well-known/oauth-authorization-server/route.ts @@ -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, diff --git a/claude-plugin/README.md b/claude-plugin/README.md index 96a6e383..2f61d8f6 100644 --- a/claude-plugin/README.md +++ b/claude-plugin/README.md @@ -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 diff --git a/claude-plugin/skills/start/SKILL.md b/claude-plugin/skills/start/SKILL.md index a3bad734..6d52d24c 100644 --- a/claude-plugin/skills/start/SKILL.md +++ b/claude-plugin/skills/start/SKILL.md @@ -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: diff --git a/packages/accounted-mcp/README.md b/packages/accounted-mcp/README.md index c04f9f3c..20faa3f4 100644 --- a/packages/accounted-mcp/README.md +++ b/packages/accounted-mcp/README.md @@ -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 diff --git a/packages/accounted-mcp/index.mjs b/packages/accounted-mcp/index.mjs index 168c0867..e70b9fb4 100644 --- a/packages/accounted-mcp/index.mjs +++ b/packages/accounted-mcp/index.mjs @@ -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' +