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
+18
View File
@@ -179,3 +179,21 @@ test('manifestet og service workeren findes, og ikonerne findes også', async ({
expect(worker.ok()).toBeTruthy();
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. */
let fresh = $state(null);
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(() => {
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) {
if (!confirm(`Tilbagekald "${token.name}"? Alt, der bruger den, holder op med at virke.`))
return;
@@ -100,6 +113,22 @@
{#if copied}<span class="saved">Kopieret.</span>{/if}
<button class="secondary" onclick={() => (fresh = null)}>Færdig</button>
</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>
{/if}
@@ -194,6 +223,18 @@
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 {
display: flex;
flex-direction: column;