halkyon/docs/adr/0006-assistant-connector.md
Tizian 「ティツィアーン」 56c5b36084
feat(assistant): Claude reads studysets through a connector on the instance
A Claude plan can't drive a chat inside another app, but Claude's apps
(every plan, the phone apps too) can add a remote MCP server as a custom
connector. The instance now is one (ADR 0006): Serverpod's web server
serves the MCP endpoint (/mcp, Streamable HTTP, JSON answers, no
sessions) with five read-only tools over the person's own synced rows
(their studysets, a studyset's cards and notes a page at a time, a
search across cards, a note, their progress), and the OAuth the
specification asks for: protected resource and authorization server
metadata, dynamic client registration, authorization code with PKCE
(S256 only), tokens bound to the resource, iss in the response, refresh
tokens that rotate. Caddy forwards these paths alone to the web server,
which production now runs on 8082.

People connect with a code from the app (Settings › Assistant › Connect
Claude): eight Crockford characters, valid for ten minutes and once,
typed on the authorization page, which names where it returns to, loads
nothing from elsewhere and may not be framed. Anonymous people can
connect too. Codes and tokens are kept only as SHA-256 hashes; an access
token lives an hour, a refresh token 30 days. The app lists what is
connected and disconnects it, which ends its tokens at once; deleting
the account deletes it; a daily job forgets what expired and clients
nobody connected within a week. InstanceInfo says where the connector is
(additive); the migration only adds server-only tables, so devices and
older apps are untouched.

The handlers take plain requests, so the integration tests drive the
whole flow (registration, the code, tokens, refresh, the tools, another
person's content, disconnecting, expiry, pruning, account deletion)
without a web server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-10 15:11:17 +02:00

81 lines
4.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ADR 0006: Claude reads studysets through a connector on the instance
- **Status:** accepted
- **Date:** 2026-10-10
## Context
People want to ask an assistant about what they learn: explain a card, quiz them on a
studyset, say what to repeat. The assistant should be able to read their material,
reading only for now, writing perhaps later. The first one is Claude; others, such as
ChatGPT, should be possible.
Most people who use Claude pay for a plan (Free, Pro, Max), not for the API. A plan
can't drive a chat inside another app: Anthropic allows no third party to offer
sign-in with Claude or to use a plan's limits ("Unless previously approved, Anthropic
does not allow third party developers to offer claude.ai login or rate limits for their
products", Agent SDK documentation, enforced since spring 2026). An in-app chat would
need everyone to buy API access and paste a key.
Claude's apps, on every plan and on phones too, can add a **custom connector**: a remote
MCP server that Claude calls from Anthropic's cloud, signed in with OAuth. The
conversation then happens in Claude, and Claude reads through the connector's tools
whatever it needs. ChatGPT and other clients speak MCP as well.
## Decision
1. **The instance is the connector.** It already holds every person's content in
plaintext (ADR 0002) and is reachable from the internet, which a phone isn't. Its web
server serves the MCP endpoint (`/mcp`, Streamable HTTP, answering with JSON, no
sessions) and the OAuth it needs. Caddy forwards those paths to it; the API server is
untouched.
2. **Read-only tools**, the person's own content only, whoever's token it is:
studysets, their cards and notes, a search across cards, and progress. Each tool is
marked read-only. Writing comes later, as tools of its own behind a scope of its
own.
3. **Connecting by code.** Anonymous accounts can't sign in on a web page, so the
authorization page asks for a code the app shows (Settings › Assistant): eight
characters, valid for ten minutes, used once, kept only as a hash. The page names
where the person returns to (the client's redirect host), so a look-alike client
can't pass as Claude. The code is the consent; a wrong one is refused without
saying more.
4. **OAuth as MCP specifies (2026-07-28), the parts Claude uses:** protected resource
metadata (RFC 9728), authorization server metadata (RFC 8414), dynamic client
registration (RFC 7591; a public client, or one with a secret in the form or as
Basic), authorization code with PKCE (S256 only), the `resource` parameter bound
into every token (RFC 8707), `iss` in the response (RFC 9207), refresh tokens that
rotate. Redirect URIs are https, or http on the device itself, and compared
exactly; a request naming an unknown client or URI gets an error page, never a
redirect. Client ID metadata documents are not supported yet: fetching a client's
URL from the server needs care against requests into the instance's own network,
and clients fall back to registration.
5. **Tokens are kept as hashes** (SHA-256 of 256 random bits): an access token lives
an hour, a refresh token 30 days from its last use and only once. A connection is
the person's grant to one client, one per client, renewed by connecting again;
the app lists them and disconnects one, which ends its tokens at once. Deleting
the account deletes them; a daily job deletes what expired and clients nobody
connected within a week.
6. **The pages and the endpoint:** the authorization page loads nothing from
elsewhere, runs no script, and may not be framed (`frame-ancestors 'none'`); it
speaks English or German as the browser asks. The MCP endpoint takes POST only,
refuses pages from plain-http origins (DNS rebinding), and every request needs a
token; the tools are built per request for the token's person.
7. **In the app, an opt-in hand-off.** A device setting, off unless turned on, shows a
field on Home and a button in the Library, on a studyset and in a study session.
Each opens `claude.ai/new?q=…` with the question and what it is about written in
(the studyset's name, a card's text), for the person to read and send in Claude.
Nothing is sent from the app; what the conversation contains is Claude's, under
Anthropic's terms.
## Consequences
- People use the plan they have; Halkyon Learn pays for nothing and holds no API key.
- The answer appears in Claude, not inside Halkyon Learn. An in-app chat for people with
an API key can follow behind the same opt-in.
- The instance gains an OAuth authorization server, small but security-relevant: codes
and tokens hashed, redirect URIs matched exactly, PKCE required, the pages not
framable, every path tested.
- What Claude reads leaves the instance for Anthropic, at the person's request; the
privacy policy says so.
- Another instance offers the same once it runs this version; the app shows the
connector's address from what the instance says about itself.