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
@@ -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,