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
+38
View File
@@ -3,11 +3,13 @@ package httpapi
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"time"
"github.com/kristianwind/verdande/internal/auth"
"github.com/kristianwind/verdande/internal/mcp"
"github.com/kristianwind/verdande/internal/quickadd"
"github.com/kristianwind/verdande/internal/store"
@@ -124,6 +126,42 @@ func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, response)
}
// handleMCPWithKey is the same endpoint for a client that can only be given a
// URL: `POST /mcp?key=vrd_…`.
//
// Claude's custom-connector dialog asks for a name, an address and — under
// advanced settings — an OAuth client id and secret. There is no field for a
// bearer token, so /api/v1/mcp cannot be configured from it however correct the
// address is. The calendar feed already carries its credential in the URL for
// the same reason, and the request logger records the path without the query, so
// the key does not end up in the log.
//
// The key is the *only* thing accepted here. Reading a session cookie instead
// would make this a state-changing POST outside the CSRF check — which is to say
// a cross-site request that acts as whoever is signed in.
func (s *Server) handleMCPWithKey(w http.ResponseWriter, r *http.Request) {
key := r.URL.Query().Get("key")
if key == "" || !auth.IsAPIToken(key) {
// 401 rather than 404: unlike a task, the existence of this endpoint is
// not a secret — it is written in the documentation.
writeError(w, http.StatusUnauthorized, CodeUnauthorized,
"add ?key=<api token> to the URL")
return
}
user, err := s.db.UserByAPIToken(r.Context(), key)
if err != nil {
if !errors.Is(err, store.ErrNotFound) {
s.internal(w, "mcp key", err)
return
}
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key is not valid")
return
}
s.handleMCP(w, r.WithContext(withUser(r.Context(), user)))
}
// --- tool implementations ------------------------------------------------------------
func (s *Server) mcpListProjects(ctx context.Context, userID string, args json.RawMessage) (any, error) {