Files
accounted/.claude/rules/mcp-server.md
T
MattssonandClaude Fable 5.1 bc5da12372 fix(mcp-oauth): allowlist Cursor's OAuth callbacks so its dynamic registration succeeds (#2225)
* fix(mcp-oauth): allowlist Cursor's OAuth callbacks so its dynamic registration succeeds

Cursor (IDE, CLI, and Grok Bot on top of it) registers three redirect URIs
in one /register request: cursor://anysphere.cursor-mcp/oauth/callback,
https://www.cursor.com/agents/mcp/oauth/callback and
http://localhost:8787/callback. Only the loopback matched a built-in
pattern and /register fails the whole set on any unknown URI, so every
Cursor connection to the URL we hand out in Settings died with
"Redirect URI not allowed". Users cannot self-register the cursor://
form either (the settings panel requires https).

Add a built-in `cursor` provider with the two non-loopback callbacks as
exact matches (no cursor.com prefix), name it "Cursor (Anysphere)" on
the consent page, list the pre-approved clients in the OAuth clients
settings text (sv + en) and the mcp-server rules, and cover the
register, allowlist and consent paths with tests.

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

* fix(mcp-oauth): show the cursor:// deeplink unverified and let CSP pass its post-consent redirect

Review findings on #2225, one pass:

- Skeptic (correctness), REFUTED: new URL('cursor://...').origin is the
  string "null", so the consent page emitted form-action 'self' null and
  Chromium would block the 303 to the deeplink after Allow. The header
  now uses the scheme-source (cursor:) when the origin is opaque; a test
  pins the header on the cursor:// URI.
- Skeptic (security), CodeRabbit (Major) and Superagent (P2): a custom
  scheme can be claimed by any local app (RFC 8252 section 8.4), so it
  must not be presented as a vendor-verified callback. The deeplink is
  its own provider, cursor_deeplink, rendered "Cursor (Anysphere)" with
  the localhost tag "Din egen dator" and verified: false. The https
  cursor.com callback keeps the verified label. A test pins that a code
  minted without a code_challenge can never be exchanged, which is what
  keeps a scheme hijack from turning into a token.
- CodeRabbit (Minor): the rules doc now says the Grok callback matches
  with or without the trailing slash.
- Regression skeptic: docs/WHITELABEL.md listed only Claude and
  localhost and pointed at the wrong file; now lists the built-ins and
  points at lib/auth/oauth-allowlist.ts.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 15:33:58 +02:00

7.1 KiB

paths
paths
extensions/general/mcp-server/**
packages/accounted-mcp/**
packages/gnubok-mcp/**

MCP Server

Accounted exposes its bookkeeping engine as an MCP server for Claude Desktop/Code.

MCP extension (extensions/general/mcp-server/): 150+ tools (count name: 'gnubok_ in server.ts; docs say "150+", never an exact number, because it drifts) covering transactions, categorization, customers/suppliers, invoices, accounts, fiscal periods, reports (trial balance, GL, BS, IS, AR/supplier ledger, VAT, KPI), reconciliation, salary runs, AGI, year-end, document upload, and loadable skills. JSON-RPC 2.0. Endpoint: /api/extensions/ext/mcp-server/mcp.

OAuth 2.1 for Claude, ChatGPT, Grok and Cursor connectors: .well-known/oauth-protected-resource + .well-known/oauth-authorization-server discovery; /api/mcp-oauth/authorize, /token (PKCE), /register. Stateless AES-256-GCM auth codes (lib/auth/oauth-codes.ts). Single-use via oauth_used_codes. Allowlist: claude.ai/api/*, claude.com/api/*, chatgpt.com/connector/oauth/*, chatgpt.com/connector_platform_oauth_redirect, grok.com/connectors-oauth-exchange-code/ (exact, trailing slash optional), cursor://anysphere.cursor-mcp/oauth/callback (exact; shown unverified on the consent page, loopback trust), www.cursor.com/agents/mcp/oauth/callback (exact), localhost. /register rejects the whole request if any one URI is unknown (Cursor sends three at once).

npm packages: packages/accounted-mcp is the Accounted stdio-to-HTTP bridge for new installs. packages/gnubok-mcp is the permanent compatibility package for existing configurations.

Tool namespaces: internal tool ids and authorization maps remain canonical gnubok_*. The Accounted MCP surface is explicitly selected with ?tool_namespace=accounted; it advertises accounted_* and accepts both aliases. Requests without the selector must retain the legacy server identity, catalog, and behavior.

Lazy auth (issue #1814, public-tools.ts): a client with no token may initialize, ping, list tools/prompts/resources and call the three public documentation tools (gnubok_search_tools, gnubok_list_skills, gnubok_load_skill), rate-limited per truncated IP. Every other request, i.e. the first company-scoped tools/call, answers a transport-level 401 + WWW-Authenticate: Bearer resource_metadata="..." from handleMcpRequest in server.ts (around line 18620). That challenge is what Claude.ai turns into its Connect card and Claude Code into /mcp login, and the account can be created inside it, so never answer a tokenless call with a 200 + isError. A public tool must read no tenant data, need no scope (absent from TOOL_SCOPE_MAP) and be company-independent.

Feedback + tasks: gnubok_feedback lets an agent report a missing tool, a misleading description, a wrong result or a positive signal; it emits an agent.feedback row to event_log (rate-limited 1 per 60 s per actor) and is read by the feedback-triage loop. Long-running tool calls use the MCP Tasks extension (io.modelcontextprotocol/tasks, tasks.ts): a task-capable client gets a task handle immediately and polls tasks/get; rows live in mcp_tasks (service-role writes only) so handles survive serverless instance turnover.

Tool authoring conventions (enforced by tests)

  • Every inputSchema must declare additionalProperties: false at the top level. Guarded by extensions/general/mcp-server/__tests__/strict-schemas.test.ts.
  • Tool descriptions must be ≤ 280 chars (guarded by output-schema.test.ts). No Args: / Returns: / Examples: blocks: those belong in JSON Schema, not description prose. Use agent-native hints like "Use to…" / "Call X first" instead.
  • Completion-signal pattern: tools that stage operations return STAGED_OPERATION_SCHEMA: { staged, risk_level, actor, message, preview, period_status?, next? }. The staged: true boolean is the explicit completion signal; agents must not infer completion from prose. Do NOT introduce a parallel { success, shouldContinue, output } envelope.
  • Machine-readable staging contract: tools/list (and gnubok_search_tools detail=full) attach a derived _meta to staging writes so an agent knows the contract WITHOUT reading prose. deriveToolMeta() keys off outputSchema === STAGED_OPERATION_SCHEMA and emits { requires_approval: true, approve_tool: 'gnubok_approve_pending_operation', preflight? }; it merges under any literal _meta (e.g. UI widget hints), which wins on collision. Add to TOOL_PREFLIGHT_MAP when a write has a genuine read-only pre-flight (e.g. gnubok_run_year_end → gnubok_year_end_readiness). A new staging tool inherits _meta for free: just keep its description declaring it stages (guarded by __tests__/staging-meta.test.ts). confirmed=true belongs on the APPROVE call for high-risk ops, never on the staging tool; only some tools accept dry_run/idempotency_key: never imply they are universal.
  • Skill/atom summaries: gnubok_list_skills and gnubok_get_agent_briefing pass registry description fields through toSummary() (skills/atoms.ts): the raw SKILL.md frontmatter is a long keyword-stuffed trigger list authored for CLI matching, not display copy, and gets truncated mid-sentence otherwise. Full bodies are fetched via gnubok_load_skill. The local .claude/skills/* are the Claude-Code surface; the agent_atom_registry rows seeded from the same bodies are the canonical connector surface: when they overlap, the connector atom is authoritative for MCP users.
  • Tools that touch a fiscal-period-bound date (categorize, mark paid, create voucher, correct/reverse entry, approve supplier invoice) pass dateForPeriodCheck to stagePendingOperation so the response includes period_status: { period_id, status: open|locked|closed, lock_date }. Widgets and agents use this to disable writes without round-trips.
  • Qualified identifiers: no bare id in tool OUTPUT schemas: every identifier is fully qualified (transaction_id, journal_entry_id, fact_id, dimension_value_id, …) so agents never guess which entity an id belongs to. Guarded by __tests__/qualified-ids.test.ts (a shrinking grandfathered list carries the deprecated id aliases; new tools must use qualified names only).
  • Error envelope: every tool failure flows through the single dispatch point (toToolError → getStructuredError) and returns { error: { code, message_sv, message_en, retryable, remediation? } }. retryable is ALWAYS an explicit boolean: true means transient (back off and retry the identical call, pairing with idempotency_key where the tool accepts one); false means permanent for these inputs (fix arguments/state, never blind-retry). Unclassified transient failures (deadlock, statement timeout, connection drop, upstream 429/5xx) surface as code TRANSIENT_ERROR. Don't wrap errors in ad-hoc shapes inside tools: throw (typed errors or plain Error; SQLSTATE/message inference handles classification) and let the dispatch layer build the envelope. Client-side failures (e.g. the claude.ai approval elicitation's "No approval received") never reach this envelope: idempotency keys are what make those blind retries safe.