feat(mcp): forbind med kontoen i stedet for med et token

Claude's forbindelsesdialog prøver OAuth først. Indtil nu var svaret 404,
og eneste vej ind var at lave et token og sætte det ind i en adresse. Nu
er instansen sin egen autorisationsserver for MCP-klienter:

- /.well-known/oauth-protected-resource(/mcp) og
  /.well-known/oauth-authorization-server peger på login'et; en 401 fra
  /mcp bærer WWW-Authenticate med resource_metadata
- /oauth/register (RFC 7591), kun offentlige klienter; registreringer,
  ingen godkender, ryddes efter et døgn
- /oauth/authorize sender til /forbind, som logger ind med det almindelige
  login og spørger "Claude vil forbinde til din konto" — og viser, hvor
  adgangen sendes hen, fordi navnet er klientens eget
- /oauth/token: authorization code + S256 PKCE, koden gælder ét minut og
  én gang; ud kommer et almindeligt API-token, listet under Tokens med
  klientens navn, og en ny forbindelse fra samme klient erstatter det
- /mcp tager nu også Authorization: Bearer; ?key= virker stadig

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kristianwind
2026-10-31 22:49:48 +01:00
co-authored by Claude Opus 5.5
parent 4a2aa81725
commit e7df876c4a
19 changed files with 1792 additions and 55 deletions
+54 -18
View File
@@ -12,39 +12,70 @@ conversation.
## Connecting it
**Make a token** under **Settings → API tokens**. The page then shows the
connector address to go with it — token and all — which is the thing to copy:
**Add the connector** in Claude with your instance's address followed by `/mcp`,
and nothing else:
```
https://todo.example.dk/mcp?key=vrd_…
https://todo.example.dk/mcp
```
**Add the connector** in Claude with that address, and nothing else. The dialog
asks for a name and a URL; leave the OAuth fields empty.
Claude sends you to verdande to sign in — the ordinary sign-in, passkey and
second factor included — and then asks one question: *"Claude wants to connect
to your account"*. Press **Allow** and you are connected. Leave the dialog's
OAuth client fields empty; Claude registers itself.
!!! note "Why the key is in the address"
The dialog has no field for a bearer token — only OAuth client credentials —
so a header cannot be configured from it. The address *is* the credential
here, exactly as it is for the [calendar feed](caldav.md#subscribing-to-a-feed).
Treat it like a password: anyone holding it reaches what you reach. The
server logs request paths without their query strings, so it does not end up
in the log.
The approval screen shows two things: the name the program gave itself, and
**where the access is sent**. Any program can call itself Claude; only Claude is
answered at `claude.ai`. If that line says something you do not recognise, press
**Deny**.
!!! warning "It has to be reachable from the internet"
Claude connects from Anthropic's side, not from your browser. An instance only
reachable on your LAN or over a VPN cannot be used this way.
### With a token in the address instead
A client that cannot sign in can be given a token in the address. **Make a token**
under **Settings → API tokens**; the page then shows the address to go with it:
```
https://todo.example.dk/mcp?key=vrd_…
```
Treat that address like a password: anyone holding it reaches what you reach. The
server logs request paths without their query strings, so it does not end up in
the log.
### With a header instead
Anything that *can* send a header should, and use the API path:
Anything that *can* send a header should:
```bash
curl -H "Authorization: Bearer vrd_…" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
https://todo.example.dk/api/v1/mcp
https://todo.example.dk/mcp
```
Both endpoints run the same server and reach the same things.
`/api/v1/mcp` answers the same way. Both run the same server and reach the same
things.
### How the sign-in works
verdande is its own OAuth authorization server, for MCP clients only — the shape
the MCP specification asks for:
- `/.well-known/oauth-protected-resource/mcp` and
`/.well-known/oauth-authorization-server` say where to sign in. An unsigned
request to `/mcp` is answered 401 with a `WWW-Authenticate` header pointing at
the first.
- Clients register themselves at `/oauth/register`. Public clients only: a program
running somewhere else cannot keep a secret, so PKCE (S256) is the proof
instead. A registration nobody approves is removed after a day.
- The code a client gets back is good for one minute and one exchange.
- What it is exchanged for is an ordinary personal API token. No refresh token and
no expiry: it is listed under **Settings → API tokens** with the client's name,
marked *connected with your account*, and connecting the same client again
replaces it rather than adding another.
## What Claude can do
@@ -56,6 +87,9 @@ Both endpoints run the same server and reach the same things.
| `update_task` | Change a title, priority, date or project. |
| `complete_task` | Tick one off. |
| `add_comment` | Comment on a task. |
| `search_notes` | Find notes by text. |
| `create_note` | Write a note, as Markdown. |
| `update_note` | Change a note. |
`create_task` prefers natural language, so *"add file the VAT return tomorrow at
10am p1 #Accounts"* goes through the same parser the app uses — dates, priorities,
@@ -71,8 +105,10 @@ scope to configure.
## What it cannot do
Delete projects, manage members, change settings or read another account. The tools
are the six above; there is no general escape hatch.
are the nine above; there is no general escape hatch.
!!! note "Revoking it"
**Settings → API tokens**, delete the token. The connector stops working
immediately, and nothing else about your account is affected.
**Settings → API tokens**, delete the token — a connection made by signing in
is listed there under the program's name. The connector stops working
immediately, and asks you to sign in again the next time it is used. Nothing
else about your account is affected.
+222 -10
View File
@@ -3965,6 +3965,61 @@ paths:
"422": { $ref: "#/components/responses/Validation" }
"502": { $ref: "#/components/responses/Upstream" }
/api/v1/oauth/request:
get:
tags: [integrations]
summary: What the approval page is approving.
description: |
Takes the `/oauth/authorize` query unchanged. Answers with the client's
self-declared name and the host the code will be sent to — the part a
client cannot misstate. Needs a session.
security:
- session: []
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
client_name: { type: string }
redirect_to: { type: string, example: claude.ai }
"400": { $ref: "#/components/responses/BadRequest" }
"403": { $ref: "#/components/responses/Forbidden" }
/api/v1/oauth/approve:
post:
tags: [integrations]
summary: Approve or refuse a connection.
description: |
Needs a session — a token cannot approve, or one leaked token could
mint others that outlive its revocation. Answers with the address to
send the browser to: the client's, with `code` or `error=access_denied`.
security:
- session: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [query, approve]
properties:
query: { type: string, description: "The `/oauth/authorize` query string, unchanged." }
approve: { type: boolean }
responses:
"200":
description: Where to go next.
content:
application/json:
schema:
type: object
properties:
redirect_to: { type: string }
"400": { $ref: "#/components/responses/BadRequest" }
"403": { $ref: "#/components/responses/Forbidden" }
/api/v1/mcp:
post:
tags: [integrations]
@@ -4426,21 +4481,27 @@ paths:
/mcp:
post:
tags: [integrations]
summary: The MCP endpoint, with the token in the query string.
summary: The MCP endpoint at the address a person types into a connector.
description: |
The same server as `/api/v1/mcp`, for a client that can only be given a
URL. Claude's custom-connector dialog asks for a name, an address and
OAuth client credentials — there is no field for a bearer token — so the
API path cannot be configured from it.
The same server as `/api/v1/mcp`. Two credentials are accepted: a bearer
token in `Authorization` — what a client holds after signing in with the
account through `/oauth/authorize` — or a token in `?key=`, for a client
that can only be given a URL.
The key is the only credential accepted here. A session cookie is
ignored: this route sits outside the CSRF check, and honouring a cookie
would make it a cross-site request that acts as whoever is signed in.
security: []
Without either, the 401 carries `WWW-Authenticate: Bearer
resource_metadata="…/.well-known/oauth-protected-resource/mcp"`, which is
how a client finds the sign-in.
A session cookie is ignored: this route sits outside the CSRF check, and
honouring a cookie would make it a cross-site request that acts as
whoever is signed in.
security:
- bearer: []
- {}
parameters:
- name: key
in: query
required: true
required: false
schema: { type: string, example: vrd_… }
description: A personal API token. Treat the whole address as a password.
requestBody:
@@ -4470,6 +4531,136 @@ paths:
"202": { description: A notification. No body. }
"401": { $ref: "#/components/responses/Unauthorized" }
/.well-known/oauth-protected-resource:
get:
tags: [integrations]
summary: Protected-resource metadata for the MCP endpoint (RFC 9728).
security: []
responses:
"200": { $ref: "#/components/responses/ProtectedResource" }
/.well-known/oauth-protected-resource/mcp:
get:
tags: [integrations]
summary: The same document, at the path RFC 9728 derives from `/mcp`.
security: []
responses:
"200": { $ref: "#/components/responses/ProtectedResource" }
/.well-known/oauth-authorization-server:
get:
tags: [integrations]
summary: Authorization-server metadata (RFC 8414).
description: |
The instance is its own authorization server, for MCP clients only.
Authorization code with S256 PKCE, public clients, no refresh tokens.
security: []
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
issuer: { type: string }
authorization_endpoint: { type: string }
token_endpoint: { type: string }
registration_endpoint: { type: string }
code_challenge_methods_supported: { type: array, items: { type: string } }
/oauth/register:
post:
tags: [integrations]
summary: Dynamic client registration (RFC 7591).
description: |
Open to anybody, rate limited, public clients only. Redirect addresses
must be https, http on a loopback address (any port), or an app's own
scheme. Metadata the server does not use is ignored. A registration that
is never approved is removed after a day.
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [redirect_uris]
properties:
client_name: { type: string }
redirect_uris: { type: array, items: { type: string } }
token_endpoint_auth_method: { type: string, enum: [none] }
responses:
"201":
description: Registered.
content:
application/json:
schema:
type: object
properties:
client_id: { type: string }
client_name: { type: string }
redirect_uris: { type: array, items: { type: string } }
"400": { $ref: "#/components/responses/OAuthError" }
"429": { description: Too many attempts. }
/oauth/authorize:
get:
tags: [integrations]
summary: Where a client sends the browser to sign in and approve.
description: |
Checks the request and redirects to the app's approval page, `/forbind`,
which signs the person in first if needed. An unknown client or an
unregistered redirect address is refused here with 400, and nothing is
sent anywhere; other problems are sent back to the client as `error=`.
security: []
parameters:
- { name: response_type, in: query, required: true, schema: { type: string, enum: [code] } }
- { name: client_id, in: query, required: true, schema: { type: string } }
- { name: redirect_uri, in: query, schema: { type: string } }
- { name: state, in: query, schema: { type: string } }
- { name: code_challenge, in: query, required: true, schema: { type: string } }
- { name: code_challenge_method, in: query, required: true, schema: { type: string, enum: [S256] } }
responses:
"302": { description: "To the approval page, or back to the client with an error." }
"400": { description: The client or its redirect address cannot be trusted. }
/oauth/token:
post:
tags: [integrations]
summary: Exchange an authorization code for a token.
description: |
`authorization_code` only. The code is good for one minute and one
attempt. The token is an ordinary personal API token, listed under
Settings → Tokens with the client's name; connecting the same client
again replaces it.
security: []
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
type: object
required: [grant_type, code, client_id, code_verifier]
properties:
grant_type: { type: string, enum: [authorization_code] }
code: { type: string }
client_id: { type: string }
redirect_uri: { type: string }
code_verifier: { type: string }
responses:
"200":
description: The token.
content:
application/json:
schema:
type: object
properties:
access_token: { type: string, example: vrd_… }
token_type: { type: string, const: Bearer }
"400": { $ref: "#/components/responses/OAuthError" }
"429": { description: Too many attempts. }
/.well-known/caldav:
get:
tags: [integrations]
@@ -4504,6 +4695,24 @@ components:
browser's own session is never accepted here.
responses:
ProtectedResource:
description: OK
content:
application/json:
schema:
type: object
properties:
resource: { type: string, example: https://todo.example.dk/mcp }
authorization_servers: { type: array, items: { type: string } }
OAuthError:
description: The error shape of RFC 6749 §5.2.
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: invalid_grant }
error_description: { type: string }
BadRequest:
description: The body could not be read.
content:
@@ -5382,6 +5591,9 @@ components:
created_at: { type: string, format: date-time }
last_used_at: { type: string, format: date-time }
expires_at: { type: string, format: date-time }
connected:
type: boolean
description: "True for a token an app received by signing in with the account, rather than one made by hand."
Notification:
type: object