Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e28d8bb92 | ||
|
|
e63d852847 | ||
|
|
d7e250f860 | ||
|
|
a8a29a963d |
@@ -40,3 +40,8 @@ node_modules/
|
|||||||
|
|
||||||
# Built documentation
|
# Built documentation
|
||||||
/site_build/
|
/site_build/
|
||||||
|
|
||||||
|
# Vite writes these while bundling vite.config.js and removes them again — unless
|
||||||
|
# the process is killed, which during a hung build is exactly what happens. One
|
||||||
|
# was committed by accident.
|
||||||
|
/web/vite.config.js.timestamp-*.mjs
|
||||||
|
|||||||
@@ -126,14 +126,6 @@ and the manifest ships in this repository. It runs anywhere Docker does as well.
|
|||||||
|
|
||||||
### With Docker directly
|
### With Docker directly
|
||||||
|
|
||||||
The package is private, and an anonymous pull fails with `No such image` rather
|
|
||||||
than with a permission error. Log the host's Docker daemon in once, with a GitHub
|
|
||||||
token carrying the `read:packages` scope:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
echo "$GHCR_TOKEN" | docker login ghcr.io -u kristianwind --password-stdin
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d --name verdande \
|
docker run -d --name verdande \
|
||||||
-p 8080:8080 \
|
-p 8080:8080 \
|
||||||
|
|||||||
+27
-4
@@ -12,17 +12,40 @@ conversation.
|
|||||||
|
|
||||||
## Connecting it
|
## Connecting it
|
||||||
|
|
||||||
**Make a token.** **Settings → API tokens → New token**. It is shown once.
|
**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, add a custom connector with:
|
```
|
||||||
|
https://todo.example.dk/mcp?key=vrd_…
|
||||||
|
```
|
||||||
|
|
||||||
- **URL**: `https://todo.example.dk/api/v1/mcp`
|
**Add the connector** in Claude with that address, and nothing else. The dialog
|
||||||
- **Authentication**: Bearer token, using the token you just made
|
asks for a name and a URL; leave the OAuth fields empty.
|
||||||
|
|
||||||
|
!!! 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.
|
||||||
|
|
||||||
!!! warning "It has to be reachable from the internet"
|
!!! warning "It has to be reachable from the internet"
|
||||||
Claude connects from Anthropic's side, not from your browser. An instance only
|
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.
|
reachable on your LAN or over a VPN cannot be used this way.
|
||||||
|
|
||||||
|
### With a header instead
|
||||||
|
|
||||||
|
Anything that *can* send a header should, and use the API path:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "Authorization: Bearer vrd_…" \
|
||||||
|
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
|
||||||
|
https://todo.example.dk/api/v1/mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
Both endpoints run the same server and reach the same things.
|
||||||
|
|
||||||
## What Claude can do
|
## What Claude can do
|
||||||
|
|
||||||
| Tool | What it does |
|
| Tool | What it does |
|
||||||
|
|||||||
@@ -1787,6 +1787,53 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
"302": { description: "Back into the app, with the outcome as a query parameter." }
|
"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:
|
/.well-known/caldav:
|
||||||
get:
|
get:
|
||||||
tags: [integrations]
|
tags: [integrations]
|
||||||
|
|||||||
+46
-16
@@ -3,33 +3,53 @@
|
|||||||
verdande ships with a Rune manifest for
|
verdande ships with a Rune manifest for
|
||||||
[Yggdrasil Panel](https://github.com/kristianwind/yggdrasil).
|
[Yggdrasil Panel](https://github.com/kristianwind/yggdrasil).
|
||||||
|
|
||||||
## Before you start: the registry is private
|
## If the image will not pull
|
||||||
|
|
||||||
The image lives at `ghcr.io/kristianwind/verdande` and the package is **private**,
|
|
||||||
so an anonymous pull fails with a message that reads as though the image does not
|
|
||||||
exist at all:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
create container: Error response from daemon:
|
create container: Error response from daemon:
|
||||||
No such image: ghcr.io/kristianwind/verdande:latest
|
No such image: ghcr.io/kristianwind/verdande:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
That is a permission error wearing the wrong hat. The Docker daemon **on the
|
This message is a liar. It does not mean the tag is missing from the registry —
|
||||||
Yggdrasil host** has to be logged in once:
|
it means the image is not on the host, and the panel's attempt to fetch it failed
|
||||||
|
without saying so.
|
||||||
|
|
||||||
|
The usual cause is a **private** GHCR package. Yggdrasil pulls anonymously: its
|
||||||
|
`PullImage` calls the Docker SDK with no `RegistryAuth`, so a private image comes
|
||||||
|
back 401. The pull error is discarded, and `ContainerCreate` — which only ever
|
||||||
|
looks locally — then reports the image as missing.
|
||||||
|
|
||||||
|
!!! warning "`docker login` on the host does not help"
|
||||||
|
`docker login` is client-side. The daemon stores no credentials; the CLI
|
||||||
|
sends them itself with each request. Yggdrasil is a different client and
|
||||||
|
sends none, so logging in changes nothing for the panel.
|
||||||
|
|
||||||
|
**Make the package public.** Under
|
||||||
|
[Packages → verdande → Package settings](https://github.com/users/kristianwind/packages/container/package/verdande)
|
||||||
|
→ *Change visibility*. The repository stays private; only the built image becomes
|
||||||
|
readable, which is what the manifest and these pages have always assumed.
|
||||||
|
|
||||||
|
Once it is public, press **Start** on the server you already have. There is no
|
||||||
|
need to delete it and make another: the image reference is read from the rune at
|
||||||
|
container-create time, and both Start and Restart recreate the container.
|
||||||
|
|
||||||
|
To keep the package private you have to put the image on the host yourself,
|
||||||
|
before creating the server, and again after every release:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
echo "$GHCR_TOKEN" | docker login ghcr.io -u kristianwind --password-stdin
|
sudo docker login ghcr.io -u kristianwind
|
||||||
|
sudo docker pull ghcr.io/kristianwind/verdande:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
`GHCR_TOKEN` is a GitHub personal access token with the **`read:packages`** scope
|
Run it as the user the Docker daemon runs as — `sudo` if the panel does. The
|
||||||
and nothing else. The login is stored in `~/.docker/config.json` for whichever
|
panel's own pull still fails silently; create simply finds the image already
|
||||||
user runs the daemon — if Yggdrasil runs as root or through systemd, log in as
|
there.
|
||||||
that user, not as yourself.
|
|
||||||
|
|
||||||
!!! tip "Or make the package public"
|
!!! danger "This hides updates"
|
||||||
Under **Packages → verdande → Package settings → Change visibility**. The
|
The panel re-pulls on every recreate, fails quietly, and carries on with the
|
||||||
repository can stay private; only the built image becomes readable. Then no
|
local image. A new release then *looks* like it rolled out and did not. For
|
||||||
login is needed anywhere and this section stops applying.
|
something you intend to update, public is the right answer and host-pull is
|
||||||
|
the stopgap.
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
|
|
||||||
@@ -40,6 +60,16 @@ that user, not as yourself.
|
|||||||
3. Fill in the settings below.
|
3. Fill in the settings below.
|
||||||
4. Start it, open the URL, create the first account.
|
4. Start it, open the URL, create the first account.
|
||||||
|
|
||||||
|
!!! note "Updating the rune is a manual step"
|
||||||
|
A rune does not update itself when this manifest changes. The panel writes
|
||||||
|
the rune row from a UI action only — neither a restart nor a timer does it —
|
||||||
|
so pick up a new manifest with **Runes → verdande → Update**, or install it
|
||||||
|
again from Browse GitHub.
|
||||||
|
|
||||||
|
The catalogue list is cached in memory for ten minutes, so two attempts in a
|
||||||
|
row can both hand you the old version. Recent panel versions show the list's
|
||||||
|
age with a *fetch again* link.
|
||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
| Setting | What to put |
|
| Setting | What to put |
|
||||||
|
|||||||
@@ -5,15 +5,12 @@ cache, no queue and no second process.
|
|||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
!!! warning "Log in to the registry first"
|
!!! note "If the pull is refused"
|
||||||
The package is private, and an anonymous pull fails with `No such image`
|
The GHCR package is public, so this needs no credentials. If it is ever set
|
||||||
rather than with a permission error. Once per host:
|
back to private, `docker login ghcr.io` with a token carrying the
|
||||||
|
`read:packages` scope before pulling — and see
|
||||||
```bash
|
[the Rune page](rune.md#if-the-image-will-not-pull), because a private image
|
||||||
echo "$GHCR_TOKEN" | docker login ghcr.io -u kristianwind --password-stdin
|
fails differently and much more confusingly under Yggdrasil.
|
||||||
```
|
|
||||||
|
|
||||||
`GHCR_TOKEN` is a GitHub token with the `read:packages` scope.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d --name verdande \
|
docker run -d --name verdande \
|
||||||
|
|||||||
@@ -771,3 +771,112 @@ func TestUpdateNoticeIsForAdministratorsOnly(t *testing.T) {
|
|||||||
t.Error("the current version is hidden from members")
|
t.Error("the current version is hidden from members")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- MCP with the key in the URL --------------------------------------------------
|
||||||
|
|
||||||
|
// Claude's custom-connector dialog takes a URL and nothing else — no bearer
|
||||||
|
// token — so this is the only shape that can be configured from it.
|
||||||
|
func TestMCPAcceptsAKeyInTheQuery(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
var userID string
|
||||||
|
err := ts.db.QueryRowContext(t.Context(), `SELECT id FROM users LIMIT 1`).Scan(&userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
key := ts.apiToken(t, userID)
|
||||||
|
|
||||||
|
body := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`
|
||||||
|
resp, err := http.Post(ts.URL+"/mcp?key="+key, "application/json", strings.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status %d, want 200", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil {
|
||||||
|
t.Fatalf("the response was not JSON-RPC: %v", err)
|
||||||
|
}
|
||||||
|
result, _ := decoded["result"].(map[string]any)
|
||||||
|
if result == nil || result["protocolVersion"] == nil {
|
||||||
|
t.Errorf("no protocol version was announced: %v", decoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The endpoint that broke it: /mcp used not to exist, so it fell through to the
|
||||||
|
// SPA fallback and answered 200 with the app shell. A connector pointed at it
|
||||||
|
// reported success and then failed to parse a page of HTML.
|
||||||
|
func TestMCPWithoutAKeyIsNotTheAppShell(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
for _, url := range []string{ts.URL + "/mcp", ts.URL + "/mcp?key=not-a-verdande-token"} {
|
||||||
|
resp, err := http.Post(url, "application/json", strings.NewReader(`{"jsonrpc":"2.0","id":1}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Errorf("%s: status %d, want 401", url, resp.StatusCode)
|
||||||
|
}
|
||||||
|
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
||||||
|
t.Errorf("%s: content-type %q — a connector cannot read that", url, ct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A revoked key stops working, like any other.
|
||||||
|
func TestMCPKeyStopsWorkingWhenRevoked(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
var userID string
|
||||||
|
if err := ts.db.QueryRowContext(t.Context(), `SELECT id FROM users LIMIT 1`).Scan(&userID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
key := ts.apiToken(t, userID)
|
||||||
|
|
||||||
|
var id string
|
||||||
|
if err := ts.db.QueryRowContext(t.Context(), `SELECT id FROM api_tokens LIMIT 1`).Scan(&id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := ts.db.DeleteAPIToken(t.Context(), userID, id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.Post(ts.URL+"/mcp?key="+key, "application/json",
|
||||||
|
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Errorf("a revoked key still works: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The session cookie must NOT work here. This route sits outside the CSRF check,
|
||||||
|
// so a cookie-authenticated POST would be a cross-site request that acts as
|
||||||
|
// whoever is signed in — the exact hole the key-only rule exists to close.
|
||||||
|
func TestMCPWithKeyIgnoresTheSessionCookie(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
// ts.client holds the session from bootstrap. No key in the URL.
|
||||||
|
resp, err := ts.client.Post(ts.URL+"/mcp", "application/json",
|
||||||
|
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Errorf("a signed-in cookie authenticated a POST outside the CSRF check: status %d",
|
||||||
|
resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ package httpapi
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/kristianwind/verdande/internal/auth"
|
||||||
"github.com/kristianwind/verdande/internal/mcp"
|
"github.com/kristianwind/verdande/internal/mcp"
|
||||||
"github.com/kristianwind/verdande/internal/quickadd"
|
"github.com/kristianwind/verdande/internal/quickadd"
|
||||||
"github.com/kristianwind/verdande/internal/store"
|
"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)
|
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 ------------------------------------------------------------
|
// --- tool implementations ------------------------------------------------------------
|
||||||
|
|
||||||
func (s *Server) mcpListProjects(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
func (s *Server) mcpListProjects(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
||||||
|
|||||||
@@ -90,6 +90,18 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
// authenticated by the token in the recipient address.
|
// authenticated by the token in the recipient address.
|
||||||
r.Post("/inbound/mail", s.handleInboundMail)
|
r.Post("/inbound/mail", s.handleInboundMail)
|
||||||
|
|
||||||
|
// MCP with the token in the query string, for clients that cannot send a
|
||||||
|
// header. Claude's custom-connector dialog takes a URL and nothing else, so
|
||||||
|
// /api/v1/mcp — which wants Authorization: Bearer — cannot be configured from
|
||||||
|
// it at all. Same reasoning as the calendar feed above: a client that cannot
|
||||||
|
// be told to send a header means the token in the URL is the credential.
|
||||||
|
//
|
||||||
|
// Outside /api/v1 deliberately, because it must not be reachable with a
|
||||||
|
// session cookie. A cookie-authenticated POST with no CSRF check is a
|
||||||
|
// cross-site request waiting to happen; handleMCPWithKey accepts the key and
|
||||||
|
// nothing else.
|
||||||
|
r.Post("/mcp", s.handleMCPWithKey)
|
||||||
|
|
||||||
// Google sends the browser here after consent. Outside /api/v1 because it is a
|
// Google sends the browser here after consent. Outside /api/v1 because it is a
|
||||||
// top-level navigation rather than a fetch, but still behind the session — the
|
// top-level navigation rather than a fetch, but still behind the session — the
|
||||||
// tokens it exchanges belong to whoever is signed in.
|
// tokens it exchanges belong to whoever is signed in.
|
||||||
|
|||||||
@@ -179,3 +179,21 @@ test('manifestet og service workeren findes, og ikonerne findes også', async ({
|
|||||||
expect(worker.ok()).toBeTruthy();
|
expect(worker.ok()).toBeTruthy();
|
||||||
expect(await worker.text()).toContain("addEventListener('push'");
|
expect(await worker.text()).toContain("addEventListener('push'");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The MCP connector address must not fall through to the app shell.
|
||||||
|
*
|
||||||
|
* It did: `/mcp` was not a route, so the SPA fallback answered 200 with a page
|
||||||
|
* of HTML. A connector pointed at it reported a successful connection and then
|
||||||
|
* failed to parse the response — which looks like a broken client rather than a
|
||||||
|
* missing route. A 401 in JSON is the honest answer.
|
||||||
|
*/
|
||||||
|
test('MCP-adressen svarer JSON, ikke app-skallen', async ({ request }) => {
|
||||||
|
const response = await request.post('/mcp', {
|
||||||
|
data: { jsonrpc: '2.0', id: 1, method: 'initialize' }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status(), 'uden nøgle skal den afvise, ikke servere siden').toBe(401);
|
||||||
|
expect(response.headers()['content-type']).toContain('application/json');
|
||||||
|
expect((await response.json()).code).toBe('unauthorized');
|
||||||
|
});
|
||||||
|
|||||||
@@ -20,6 +20,10 @@
|
|||||||
/** The plaintext of the token just minted. Held only in this variable. */
|
/** The plaintext of the token just minted. Held only in this variable. */
|
||||||
let fresh = $state(null);
|
let fresh = $state(null);
|
||||||
let copied = $state(false);
|
let copied = $state(false);
|
||||||
|
let connectorCopied = $state(false);
|
||||||
|
|
||||||
|
/** What Claude's connector dialog needs, which is a URL and nothing else. */
|
||||||
|
let connectorURL = $derived(fresh ? `${location.origin}/mcp?key=${fresh.token}` : '');
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
api
|
api
|
||||||
@@ -59,6 +63,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyConnector() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(connectorURL);
|
||||||
|
connectorCopied = true;
|
||||||
|
} catch {
|
||||||
|
app.toast('Kunne ikke kopiere — markér teksten i stedet.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function revoke(token) {
|
async function revoke(token) {
|
||||||
if (!confirm(`Tilbagekald "${token.name}"? Alt, der bruger den, holder op med at virke.`))
|
if (!confirm(`Tilbagekald "${token.name}"? Alt, der bruger den, holder op med at virke.`))
|
||||||
return;
|
return;
|
||||||
@@ -100,6 +113,22 @@
|
|||||||
{#if copied}<span class="saved">Kopieret.</span>{/if}
|
{#if copied}<span class="saved">Kopieret.</span>{/if}
|
||||||
<button class="secondary" onclick={() => (fresh = null)}>Færdig</button>
|
<button class="secondary" onclick={() => (fresh = null)}>Færdig</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Shown here and nowhere else, because it contains the token, and the
|
||||||
|
token exists only in this moment. Claude's connector dialog takes a
|
||||||
|
URL and nothing else — there is no field for a header — so this is
|
||||||
|
the form it has to be in. -->
|
||||||
|
<div class="also">
|
||||||
|
<p class="hint">
|
||||||
|
Skal du bruge den som <a href="/indstillinger/integrationer">MCP-forbindelse</a>
|
||||||
|
i Claude? Så er det denne adresse, dialogen skal have:
|
||||||
|
</p>
|
||||||
|
<p class="mono value">{connectorURL}</p>
|
||||||
|
<div class="row">
|
||||||
|
<button class="secondary" onclick={copyConnector}>Kopiér adressen</button>
|
||||||
|
{#if connectorCopied}<span class="saved">Kopieret.</span>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -194,6 +223,18 @@
|
|||||||
user-select: all;
|
user-select: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.also {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s2);
|
||||||
|
padding-top: var(--s3);
|
||||||
|
border-top: 1px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.also a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
.what {
|
.what {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
// vite.config.js
|
|
||||||
import { sveltekit } from "file:///Users/kw/Documents/Code/ToDoApp/web/node_modules/@sveltejs/kit/src/exports/vite/index.js";
|
|
||||||
var vite_config_default = {
|
|
||||||
plugins: [sveltekit()],
|
|
||||||
server: {
|
|
||||||
port: 5173,
|
|
||||||
// In development the app runs on 5173 and the API on 8080. Proxying rather
|
|
||||||
// than pointing fetch at another origin keeps the session cookie
|
|
||||||
// first-party, which is what production looks like — otherwise every
|
|
||||||
// cookie and CSRF behaviour would differ between dev and the real thing.
|
|
||||||
proxy: {
|
|
||||||
"/api": {
|
|
||||||
target: "http://localhost:8080",
|
|
||||||
changeOrigin: false,
|
|
||||||
ws: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
export {
|
|
||||||
vite_config_default as default
|
|
||||||
};
|
|
||||||
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcuanMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvVXNlcnMva3cvRG9jdW1lbnRzL0NvZGUvVG9Eb0FwcC93ZWJcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZmlsZW5hbWUgPSBcIi9Vc2Vycy9rdy9Eb2N1bWVudHMvQ29kZS9Ub0RvQXBwL3dlYi92aXRlLmNvbmZpZy5qc1wiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9pbXBvcnRfbWV0YV91cmwgPSBcImZpbGU6Ly8vVXNlcnMva3cvRG9jdW1lbnRzL0NvZGUvVG9Eb0FwcC93ZWIvdml0ZS5jb25maWcuanNcIjtpbXBvcnQgeyBzdmVsdGVraXQgfSBmcm9tICdAc3ZlbHRlanMva2l0L3ZpdGUnO1xuXG5leHBvcnQgZGVmYXVsdCB7XG5cdHBsdWdpbnM6IFtzdmVsdGVraXQoKV0sXG5cdHNlcnZlcjoge1xuXHRcdHBvcnQ6IDUxNzMsXG5cdFx0Ly8gSW4gZGV2ZWxvcG1lbnQgdGhlIGFwcCBydW5zIG9uIDUxNzMgYW5kIHRoZSBBUEkgb24gODA4MC4gUHJveHlpbmcgcmF0aGVyXG5cdFx0Ly8gdGhhbiBwb2ludGluZyBmZXRjaCBhdCBhbm90aGVyIG9yaWdpbiBrZWVwcyB0aGUgc2Vzc2lvbiBjb29raWVcblx0XHQvLyBmaXJzdC1wYXJ0eSwgd2hpY2ggaXMgd2hhdCBwcm9kdWN0aW9uIGxvb2tzIGxpa2UgXHUyMDE0IG90aGVyd2lzZSBldmVyeVxuXHRcdC8vIGNvb2tpZSBhbmQgQ1NSRiBiZWhhdmlvdXIgd291bGQgZGlmZmVyIGJldHdlZW4gZGV2IGFuZCB0aGUgcmVhbCB0aGluZy5cblx0XHRwcm94eToge1xuXHRcdFx0Jy9hcGknOiB7XG5cdFx0XHRcdHRhcmdldDogJ2h0dHA6Ly9sb2NhbGhvc3Q6ODA4MCcsXG5cdFx0XHRcdGNoYW5nZU9yaWdpbjogZmFsc2UsXG5cdFx0XHRcdHdzOiB0cnVlXG5cdFx0XHR9XG5cdFx0fVxuXHR9XG59O1xuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUE4UixTQUFTLGlCQUFpQjtBQUV4VCxJQUFPLHNCQUFRO0FBQUEsRUFDZCxTQUFTLENBQUMsVUFBVSxDQUFDO0FBQUEsRUFDckIsUUFBUTtBQUFBLElBQ1AsTUFBTTtBQUFBO0FBQUE7QUFBQTtBQUFBO0FBQUEsSUFLTixPQUFPO0FBQUEsTUFDTixRQUFRO0FBQUEsUUFDUCxRQUFRO0FBQUEsUUFDUixjQUFjO0FBQUEsUUFDZCxJQUFJO0FBQUEsTUFDTDtBQUFBLElBQ0Q7QUFBQSxFQUNEO0FBQ0Q7IiwKICAibmFtZXMiOiBbXQp9Cg==
|
|
||||||
Reference in New Issue
Block a user