feat(mcp): let the connector address carry its own key

verdande could not be added to Claude at all. The documented setup —
POST /api/v1/mcp with an Authorization header — cannot be configured from
Claude's custom-connector dialog, which asks for a name, a URL and OAuth
client credentials and has no field for a bearer token. The instructions
described something the interface does not offer.

Worse, the obvious guess failed silently in the most misleading way
available: /mcp was not a route, so the SPA fallback answered 200 with a
page of HTML. The connector reported a successful connection and then
failed to parse the response, which reads as a broken client rather than
a missing endpoint.

So /mcp exists now and takes its credential in the query string, exactly
as the calendar feed does and for the same reason: a client that cannot
be told to send a header means the address is the credential. The
request logger records paths without their query strings, so the key does
not reach the log.

It is deliberately outside /api/v1 and accepts the key and nothing else.
Reading a session cookie here would make it a state-changing POST outside
the CSRF check — a cross-site request that acts as whoever is signed in —
and there is a test that fails if that ever becomes true.

The API path is unchanged for anything that can send a header. The tokens
page now shows the connector address next to the token it belongs to,
since that is the one moment the token exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-17 14:04:03 +02:00
committed by Kristian Wind
co-authored by Claude Opus 5
parent d7e250f860
commit e63d852847
7 changed files with 292 additions and 4 deletions
+47
View File
@@ -1787,6 +1787,53 @@ paths:
responses:
"302": { description: "Back into the app, with the outcome as a query parameter." }
/mcp:
post:
tags: [integrations]
summary: The MCP endpoint, with the token in the query string.
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 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: []
parameters:
- name: key
in: query
required: true
schema: { type: string, example: vrd_… }
description: A personal API token. Treat the whole address as a password.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [jsonrpc, method]
properties:
jsonrpc: { type: string, const: "2.0" }
id: { oneOf: [{ type: string }, { type: integer }] }
method: { type: string }
params: { type: object }
responses:
"200":
description: A JSON-RPC response.
content:
application/json:
schema:
type: object
properties:
jsonrpc: { type: string, const: "2.0" }
id: { oneOf: [{ type: string }, { type: integer }] }
result: { type: object }
error: { type: object }
"202": { description: A notification. No body. }
"401": { $ref: "#/components/responses/Unauthorized" }
/.well-known/caldav:
get:
tags: [integrations]