mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(mcp): allow minting and consenting to health:write connector tokens
Let a user opt a connector token into logging. The settings card gains a write toggle; the mint endpoint accepts a closed scope choice (read, or read_write) and builds the permission array explicitly — it can mint only [health:read] or [health:read, health:write], never a wildcard or any other grant. The OAuth flow grants health:write only when a client requests it, and the consent screen states in plain language that the app will be able to read AND log/write health data when write is requested; the token endpoint grants exactly the approved scopes and never widens. Read-only behaviour is unchanged. Add the English and German strings for the new toggle and warning.
This commit is contained in:
1 parent
6de61de010
commit
ddfe9170bd
11 files changed
+195
-19
No files matched your search
+3
-1
@@ -4907,7 +4907,9 @@
|
||||
"connectDescription": "Füge diese URL in die Einrichtung des eigenen Connectors deines Assistenten ein und autorisiere dich mit dem Token unten oder melde dich bei Aufforderung an.",
|
||||
"tokensTitle": "Connector-Tokens",
|
||||
"tokensDescription": "Erzeuge ein schreibgeschütztes Token, um einen Assistenten zu verbinden. Das Token wird nur einmal angezeigt.",
|
||||
"scopeNote": "Tokens sind auf health:read beschränkt — nur Lesezugriff auf deine eigenen Daten. Sie können niemals Daten schreiben oder Admin-Funktionen erreichen.",
|
||||
"scopeNote": "Tokens sind auf health:read beschränkt — nur Lesezugriff auf deine eigenen Daten. Sie können niemals Admin-Funktionen erreichen.",
|
||||
"writeScopeToggleLabel": "Eintragen erlauben (health:write)",
|
||||
"writeScopeNote": "Fügt den Scope health:write hinzu, damit der Assistent neue Messwerte und Stimmungseinträge in dein Konto eintragen kann. Jeder Schreibvorgang wird zuvor bestätigt. Das Token kann weiterhin keine vorhandenen Einträge löschen oder ändern, keine Medikamente bearbeiten und keine REST- oder Admin-Funktionen erreichen. Für reinen Lesezugriff ausgeschaltet lassen.",
|
||||
"connectionsTitle": "Verbundene Assistenten",
|
||||
"connectionsDescription": "Assistenten, die du über die Anmeldung autorisiert hast. Das Widerrufen einer Verbindung beendet den Zugriff sofort, einschließlich der Möglichkeit zur Erneuerung.",
|
||||
"noConnections": "Keine verbundenen Assistenten.",
|
||||
|
||||
+3
-1
@@ -4907,7 +4907,9 @@
|
||||
"connectDescription": "Paste this URL into your assistant's custom-connector setup, then authorize with the token below or sign in when prompted.",
|
||||
"tokensTitle": "Connector tokens",
|
||||
"tokensDescription": "Mint a read-only token to connect an assistant. The token is shown once.",
|
||||
"scopeNote": "Tokens are scoped to health:read — read-only access to your own records. They can never write data or reach admin functions.",
|
||||
"scopeNote": "Tokens are scoped to health:read — read-only access to your own records. They can never reach admin functions.",
|
||||
"writeScopeToggleLabel": "Allow logging (health:write)",
|
||||
"writeScopeNote": "Adds the health:write scope so the assistant can log new measurements and mood entries to your account. Every write is confirmed first. The token still can't delete or edit existing entries, change medications, or reach REST or admin functions. Leave off for read-only access.",
|
||||
"connectionsTitle": "Connected assistants",
|
||||
"connectionsDescription": "Assistants you authorized through the sign-in flow. Revoking a connection ends its access immediately, including its ability to refresh.",
|
||||
"noConnections": "No connected assistants.",
|
||||
|
||||
+3
-1
@@ -4907,7 +4907,9 @@
|
||||
"connectDescription": "Pega esta URL en la configuración del conector personalizado de tu asistente y autoriza con el token de abajo o inicia sesión cuando se te solicite.",
|
||||
"tokensTitle": "Tokens del conector",
|
||||
"tokensDescription": "Genera un token de solo lectura para conectar un asistente. El token se muestra una sola vez.",
|
||||
"scopeNote": "Los tokens se limitan a health:read: acceso de solo lectura a tus propios registros. Nunca pueden escribir datos ni acceder a funciones de administración.",
|
||||
"scopeNote": "Los tokens se limitan a health:read: acceso de solo lectura a tus propios registros. Nunca pueden acceder a funciones de administración.",
|
||||
"writeScopeToggleLabel": "Permitir registro (health:write)",
|
||||
"writeScopeNote": "Añade el ámbito health:write para que el asistente pueda registrar nuevas mediciones y entradas de estado de ánimo en tu cuenta. Cada escritura se confirma antes. El token sigue sin poder eliminar ni editar entradas existentes, cambiar medicamentos ni acceder a funciones REST o de administración. Déjalo desactivado para acceso de solo lectura.",
|
||||
"connectionsTitle": "Asistentes conectados",
|
||||
"connectionsDescription": "Asistentes que autorizaste mediante el inicio de sesión. Revocar una conexión finaliza su acceso de inmediato, incluida su capacidad de renovación.",
|
||||
"noConnections": "No hay asistentes conectados.",
|
||||
|
||||
+3
-1
@@ -4907,7 +4907,9 @@
|
||||
"connectDescription": "Collez cette URL dans la configuration du connecteur personnalisé de votre assistant, puis autorisez avec le jeton ci-dessous ou connectez-vous lorsque vous y êtes invité.",
|
||||
"tokensTitle": "Jetons du connecteur",
|
||||
"tokensDescription": "Générez un jeton en lecture seule pour connecter un assistant. Le jeton n'est affiché qu'une seule fois.",
|
||||
"scopeNote": "Les jetons sont limités à health:read : accès en lecture seule à vos propres données. Ils ne peuvent jamais écrire de données ni atteindre les fonctions d'administration.",
|
||||
"scopeNote": "Les jetons sont limités à health:read : accès en lecture seule à vos propres données. Ils ne peuvent jamais atteindre les fonctions d'administration.",
|
||||
"writeScopeToggleLabel": "Autoriser l'enregistrement (health:write)",
|
||||
"writeScopeNote": "Ajoute la portée health:write pour que l'assistant puisse enregistrer de nouvelles mesures et entrées d'humeur dans votre compte. Chaque écriture est d'abord confirmée. Le jeton ne peut toujours pas supprimer ni modifier des entrées existantes, changer les médicaments, ni atteindre les fonctions REST ou d'administration. Laissez désactivé pour un accès en lecture seule.",
|
||||
"connectionsTitle": "Assistants connectés",
|
||||
"connectionsDescription": "Assistants que vous avez autorisés via la connexion. Révoquer une connexion met fin à son accès immédiatement, y compris sa capacité à se renouveler.",
|
||||
"noConnections": "Aucun assistant connecté.",
|
||||
|
||||
+3
-1
@@ -4907,7 +4907,9 @@
|
||||
"connectDescription": "Incolla questo URL nella configurazione del connettore personalizzato del tuo assistente e autorizza con il token qui sotto o accedi quando richiesto.",
|
||||
"tokensTitle": "Token del connettore",
|
||||
"tokensDescription": "Genera un token in sola lettura per collegare un assistente. Il token viene mostrato una sola volta.",
|
||||
"scopeNote": "I token sono limitati a health:read: accesso in sola lettura ai tuoi dati. Non possono mai scrivere dati né raggiungere funzioni di amministrazione.",
|
||||
"scopeNote": "I token sono limitati a health:read: accesso in sola lettura ai tuoi dati. Non possono mai raggiungere funzioni di amministrazione.",
|
||||
"writeScopeToggleLabel": "Consenti la registrazione (health:write)",
|
||||
"writeScopeNote": "Aggiunge l'ambito health:write così l'assistente può registrare nuove misurazioni e voci dell'umore nel tuo account. Ogni scrittura viene prima confermata. Il token non può comunque eliminare o modificare voci esistenti, cambiare i farmaci o raggiungere funzioni REST o di amministrazione. Lascia disattivato per l'accesso in sola lettura.",
|
||||
"connectionsTitle": "Assistenti collegati",
|
||||
"connectionsDescription": "Assistenti che hai autorizzato tramite l'accesso. Revocare una connessione ne termina subito l'accesso, inclusa la capacità di rinnovarsi.",
|
||||
"noConnections": "Nessun assistente collegato.",
|
||||
|
||||
+3
-1
@@ -4907,7 +4907,9 @@
|
||||
"connectDescription": "Wklej ten adres URL w konfiguracji własnego łącznika asystenta, a następnie autoryzuj za pomocą poniższego tokenu lub zaloguj się po wyświetleniu monitu.",
|
||||
"tokensTitle": "Tokeny łącznika",
|
||||
"tokensDescription": "Wygeneruj token tylko do odczytu, aby połączyć asystenta. Token jest wyświetlany tylko raz.",
|
||||
"scopeNote": "Tokeny są ograniczone do health:read — dostęp tylko do odczytu własnych danych. Nigdy nie mogą zapisywać danych ani uzyskać dostępu do funkcji administracyjnych.",
|
||||
"scopeNote": "Tokeny są ograniczone do health:read — dostęp tylko do odczytu własnych danych. Nigdy nie mogą uzyskać dostępu do funkcji administracyjnych.",
|
||||
"writeScopeToggleLabel": "Zezwól na zapis (health:write)",
|
||||
"writeScopeNote": "Dodaje zakres health:write, aby asystent mógł zapisywać nowe pomiary i wpisy nastroju na Twoim koncie. Każdy zapis jest najpierw potwierdzany. Token nadal nie może usuwać ani edytować istniejących wpisów, zmieniać leków ani uzyskiwać dostępu do funkcji REST lub administracyjnych. Pozostaw wyłączone, aby uzyskać dostęp tylko do odczytu.",
|
||||
"connectionsTitle": "Połączeni asystenci",
|
||||
"connectionsDescription": "Asystenci autoryzowani podczas logowania. Cofnięcie połączenia natychmiast kończy dostęp, w tym możliwość odnawiania.",
|
||||
"noConnections": "Brak połączonych asystentów.",
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
isMcpOriginConfigured,
|
||||
resolveBaseOrigin,
|
||||
SCOPE_HEALTH_READ,
|
||||
SCOPE_HEALTH_WRITE,
|
||||
} from "@/lib/mcp/oauth/config";
|
||||
import { redirectUriAllowed, resolveClient } from "@/lib/mcp/oauth/clients";
|
||||
import { isValidChallenge } from "@/lib/mcp/oauth/pkce";
|
||||
@@ -86,12 +87,19 @@ function html(body: string, status = 200): Response {
|
||||
);
|
||||
}
|
||||
|
||||
/** The single scope this read surface grants, plus optional refresh continuity. */
|
||||
/**
|
||||
* The scopes this surface grants for a request. `health:read` is always
|
||||
* granted (the floor). `health:write` is granted ONLY when explicitly
|
||||
* requested (RFC 8707 / incremental consent, SEP-835) — it admits the
|
||||
* confirmed `/mcp` write tools and is surfaced plainly on the consent screen.
|
||||
* `offline_access` enables refresh continuity. Nothing else is ever granted.
|
||||
*/
|
||||
function grantScope(requested: string | undefined): string {
|
||||
const wants = new Set(
|
||||
(requested ?? SCOPE_HEALTH_READ).split(/\s+/).filter(Boolean),
|
||||
);
|
||||
const granted = [SCOPE_HEALTH_READ];
|
||||
if (wants.has(SCOPE_HEALTH_WRITE)) granted.push(SCOPE_HEALTH_WRITE);
|
||||
if (wants.has("offline_access")) granted.push("offline_access");
|
||||
return granted.join(" ");
|
||||
}
|
||||
@@ -299,12 +307,19 @@ export async function GET(request: NextRequest): Promise<Response> {
|
||||
v.clientSource === "cimd"
|
||||
? `<p>Verified origin: <code>${htmlEscape(v.clientOrigin)}</code></p>`
|
||||
: `<p><strong>Unverified application</strong> (dynamically registered — the name above is self-reported and not verified).</p>`;
|
||||
// Plain-language access summary. When write is granted, say so explicitly —
|
||||
// the app will be able to READ and LOG/WRITE health data, not just read.
|
||||
const writeGranted = v.scope.split(/\s+/).includes(SCOPE_HEALTH_WRITE);
|
||||
const accessSummary = writeGranted
|
||||
? `<p><strong>This grants read AND write access:</strong> the application will be able to read your own health records <strong>and log new measurements and mood entries</strong> to your account on your behalf. It cannot delete or change existing entries, edit medications, or reach admin functions.</p>`
|
||||
: `<p>Scope: <code>${htmlEscape(v.scope)}</code> — read-only access to your own health records.</p>`;
|
||||
return html(
|
||||
`<main>
|
||||
<h1>Authorize access</h1>
|
||||
<p><strong>${htmlEscape(v.clientName)}</strong> is requesting access to your HealthLog data.</p>
|
||||
${provenance}
|
||||
<p>Scope: <code>${htmlEscape(v.scope)}</code> — read-only access to your own health records.</p>
|
||||
<p>Scope: <code>${htmlEscape(v.scope)}</code></p>
|
||||
${accessSummary}
|
||||
<form method="POST" action="/api/mcp/oauth/authorize">
|
||||
${hidden("response_type", "code")}
|
||||
${hidden("client_id", v.clientId)}
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
* Token endpoint (OAuth 2.1) — `authorization_code` + `refresh_token` grants.
|
||||
*
|
||||
* Exchanges the bridge AS's self-describing artifacts for a REAL `hlk_`
|
||||
* `ApiToken` (the access token) minted through `issue-token.ts` with the narrow
|
||||
* `health:read` scope (ADR-006). The access token is therefore an ordinary
|
||||
* `ApiToken` (the access token) minted through `issue-token.ts` with EXACTLY
|
||||
* the scopes the authorization code carried — `health:read`, or
|
||||
* `health:read health:write` when the user consented to logging (ADR-006). A
|
||||
* `health:write` token is still audience-bound to `/mcp`. The access token is
|
||||
* therefore an ordinary
|
||||
* Bearer the `/mcp` resolver already understands — bound to `<userId>:<tokenId>`
|
||||
* (REQ-SEC-11), revocable, and short-lived. There is no token passthrough: the
|
||||
* MCP layer never forwards a client-presented upstream credential.
|
||||
@@ -42,6 +45,7 @@ import {
|
||||
isMcpOriginConfigured,
|
||||
REFRESH_TOKEN_TTL_DAYS,
|
||||
SCOPE_HEALTH_READ,
|
||||
SCOPE_HEALTH_WRITE,
|
||||
} from "@/lib/mcp/oauth/config";
|
||||
import { signArtifact, verifyArtifact } from "@/lib/mcp/oauth/artifacts";
|
||||
import { verifyPkceS256 } from "@/lib/mcp/oauth/pkce";
|
||||
@@ -124,10 +128,21 @@ async function mintTokenPair(args: {
|
||||
connectionId?: string;
|
||||
refreshJti?: string;
|
||||
}): Promise<Response> {
|
||||
// Grant EXACTLY the scopes the authorization code carried (read, or
|
||||
// read+write). `health:read` is always present; `health:write` is added
|
||||
// only when the user consented to it at the authorize step. The token is
|
||||
// never widened beyond the approved grant, and `offline_access` is a
|
||||
// refresh-continuity marker, not an API permission.
|
||||
const grantedScopes = new Set(args.scope.split(/\s+/).filter(Boolean));
|
||||
const permissions = [SCOPE_HEALTH_READ];
|
||||
if (grantedScopes.has(SCOPE_HEALTH_WRITE)) {
|
||||
permissions.push(SCOPE_HEALTH_WRITE);
|
||||
}
|
||||
|
||||
const access = await issueApiToken({
|
||||
userId: args.userId,
|
||||
name: "MCP connector",
|
||||
permissions: [SCOPE_HEALTH_READ],
|
||||
permissions,
|
||||
expiresInMinutes: ACCESS_TOKEN_TTL_MINUTES,
|
||||
...(args.connectionId ? { mcpConnectionId: args.connectionId } : {}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Guard: the MCP connector mint endpoint mints ONLY the two allowed scope
|
||||
* shapes — `["health:read"]` (default) or `["health:read","health:write"]` —
|
||||
* and never a wildcard or any other grant.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
apiToken: { findMany: vi.fn() },
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/hmac", () => ({ hashToken: (s: string) => `hash(${s})` }));
|
||||
vi.mock("@/lib/app-settings", () => ({ isApiGloballyEnabled: vi.fn() }));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/auth/issue-token", () => ({
|
||||
issueApiToken: vi.fn(),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({ get: () => undefined })),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { issueApiToken } from "@/lib/auth/issue-token";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
function postReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/mcp/tokens", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
|
||||
vi.mocked(issueApiToken).mockResolvedValue({
|
||||
token: "hlk_abc",
|
||||
tokenId: "tok-1",
|
||||
name: "n",
|
||||
expiresAt: new Date(),
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/mcp/tokens — scope minting", () => {
|
||||
it("defaults to read-only [health:read]", async () => {
|
||||
const res = await POST(postReq({ name: "laptop" }));
|
||||
expect(res.status).toBe(201);
|
||||
expect(issueApiToken).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ permissions: ["health:read"] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("mints read+write when scope=read_write", async () => {
|
||||
const res = await POST(postReq({ name: "laptop", scope: "read_write" }));
|
||||
expect(res.status).toBe(201);
|
||||
expect(issueApiToken).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
permissions: ["health:read", "health:write"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects any scope outside the closed set (no wildcard path)", async () => {
|
||||
for (const bad of ["*", "admin", "health:write", "write", "read_admin"]) {
|
||||
vi.mocked(issueApiToken).mockClear();
|
||||
const res = await POST(postReq({ name: "x", scope: bad }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(issueApiToken).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("never mints a wildcard for any input", async () => {
|
||||
await POST(postReq({ name: "a" }));
|
||||
await POST(postReq({ name: "b", scope: "read_write" }));
|
||||
for (const call of vi.mocked(issueApiToken).mock.calls) {
|
||||
expect(call[0].permissions).not.toContain("*");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -8,9 +8,14 @@
|
||||
* `health:read` scope automatically for cloud connectors. Both surface here so
|
||||
* the user can see and revoke every credential that can read over MCP.
|
||||
*
|
||||
* `userId` is always narrowed from the session — never a body field. Scope is
|
||||
* hardcoded `["health:read"]`, so this endpoint can never be coerced into
|
||||
* minting a broader grant (no mass assignment).
|
||||
* `userId` is always narrowed from the session — never a body field. The
|
||||
* caller may choose ONE of exactly two shapes via `scope`: `read` →
|
||||
* `["health:read"]` (default) or `read_write` → `["health:read",
|
||||
* "health:write"]`. The `permissions` array is built explicitly from that
|
||||
* closed choice, so this endpoint can never be coerced into minting a
|
||||
* wildcard or any other grant (no mass assignment). A `health:write` token is
|
||||
* still audience-bound to `/mcp` (the resource-server guard refuses it on
|
||||
* every REST write) — it only admits the confirmed in-process write tools.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
@@ -27,11 +32,16 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { issueApiToken } from "@/lib/auth/issue-token";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { SCOPE_HEALTH_READ } from "@/lib/mcp/oauth/config";
|
||||
import { SCOPE_HEALTH_READ, SCOPE_HEALTH_WRITE } from "@/lib/mcp/oauth/config";
|
||||
|
||||
const createSchema = z.object({
|
||||
name: z.string().min(1, "Name required").max(100),
|
||||
expiresInDays: z.number().int().min(1).max(365).optional(),
|
||||
// The ONLY two scope shapes this endpoint will mint. `read` (default) is the
|
||||
// least-privilege read token; `read_write` adds `health:write` so the
|
||||
// confirmed `/mcp` write tools become available. Anything else is rejected
|
||||
// by the enum — the endpoint can never mint a wildcard or arbitrary grant.
|
||||
scope: z.enum(["read", "read_write"]).optional().default("read"),
|
||||
});
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
@@ -84,17 +94,24 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
// Build the permission array explicitly from the closed scope choice —
|
||||
// never spread, never wildcard. `read` → read-only; `read_write` → read plus
|
||||
// the `/mcp`-audience-bound write scope.
|
||||
const permissions =
|
||||
parsed.data.scope === "read_write"
|
||||
? [SCOPE_HEALTH_READ, SCOPE_HEALTH_WRITE]
|
||||
: [SCOPE_HEALTH_READ];
|
||||
|
||||
const issued = await issueApiToken({
|
||||
userId: user.id,
|
||||
name: parsed.data.name,
|
||||
// Dedicated, least-privilege MCP read scope — never wildcard, never write.
|
||||
permissions: [SCOPE_HEALTH_READ],
|
||||
permissions,
|
||||
expiresInDays: parsed.data.expiresInDays ?? 90,
|
||||
});
|
||||
|
||||
await auditLog("mcp.tokens.create", {
|
||||
userId: user.id,
|
||||
details: { tokenId: issued.tokenId, scope: SCOPE_HEALTH_READ },
|
||||
details: { tokenId: issued.tokenId, scope: permissions.join(" ") },
|
||||
});
|
||||
|
||||
// Return the raw token ONCE — it can never be retrieved again.
|
||||
|
||||
@@ -257,6 +257,7 @@ function McpTokensCard() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const [newName, setNewName] = useState("");
|
||||
const [allowWrite, setAllowWrite] = useState(false);
|
||||
const [newToken, setNewToken] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [tokenMsg, setTokenMsg] = useState<string | null>(null);
|
||||
@@ -277,12 +278,17 @@ function McpTokensCard() {
|
||||
const res = await apiFetchRaw("/api/mcp/tokens", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: newName.trim() }),
|
||||
body: JSON.stringify({
|
||||
name: newName.trim(),
|
||||
// Closed choice — read-only, or read + the audience-bound write scope.
|
||||
scope: allowWrite ? "read_write" : "read",
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (res.ok) {
|
||||
setNewToken(json.data.token);
|
||||
setNewName("");
|
||||
setAllowWrite(false);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.mcpTokens() });
|
||||
} else {
|
||||
setTokenMsg(json.error || t("common.error"));
|
||||
@@ -336,6 +342,22 @@ function McpTokensCard() {
|
||||
{t("settings.mcp.scopeNote")}
|
||||
</p>
|
||||
|
||||
<div className="border-border bg-muted/30 space-y-2 rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-medium">
|
||||
{t("settings.mcp.writeScopeToggleLabel")}
|
||||
</span>
|
||||
<Switch
|
||||
checked={allowWrite}
|
||||
onCheckedChange={setAllowWrite}
|
||||
aria-label={t("settings.mcp.writeScopeToggleLabel")}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-[11px] leading-relaxed">
|
||||
{t("settings.mcp.writeScopeNote")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreate} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={newName}
|
||||
|
||||
Reference in new issue
Block a user