From 06363771ee2d6e260912855a2d37153c611401af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 14:27:30 +0200 Subject: [PATCH] feat(settings): add an MCP connector card with health:read tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dedicated Settings card co-locates the off-by-default MCP module toggle with token management for connecting an external assistant: - Mint / list / revoke a dedicated `health:read`-scoped token (shown once, copy to clipboard) via new `/api/mcp/tokens` routes — never the `medication:ingest` scope, never the wildcard. - Surface the connector URL and the enable switch in one place, wired to the existing module-preference endpoint. - New section wired into the Settings shell next to API & Tokens, with EN + DE strings (and the other locales) under `settings.mcp` and `settings.sections.mcp`. --- messages/de.json | 18 +- messages/en.json | 18 +- messages/es.json | 18 +- messages/fr.json | 18 +- messages/it.json | 18 +- messages/pl.json | 18 +- src/app/api/mcp/tokens/[id]/route.ts | 44 +++ src/app/api/mcp/tokens/route.ts | 98 +++++ src/app/settings/[section]/page.tsx | 2 + .../__tests__/settings-shell.test.tsx | 1 + src/components/settings/mcp-section.tsx | 369 ++++++++++++++++++ .../settings/section-placeholder.tsx | 2 + src/components/settings/section-slugs.ts | 5 + src/components/settings/settings-shell.tsx | 5 + src/lib/query-keys/integrations.ts | 4 + 15 files changed, 632 insertions(+), 6 deletions(-) create mode 100644 src/app/api/mcp/tokens/[id]/route.ts create mode 100644 src/app/api/mcp/tokens/route.ts create mode 100644 src/components/settings/mcp-section.tsx diff --git a/messages/de.json b/messages/de.json index 3bfa2f640..a41e7f47c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4162,6 +4162,10 @@ "title": "Kanäle", "description": "Zustellung über Telegram, ntfy, Web-Push, Webhook und E-Mail.", "subtitle": "Wohin Erinnerungen und Hinweise zugestellt werden." + }, + "mcp": { + "title": "MCP-Connector", + "subtitle": "Verbinde einen externen Assistenten mit deinen eigenen Gesundheitsdaten." } }, "profile": "Profil", @@ -4892,7 +4896,19 @@ "polarDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der", "ouraDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der", "nightscoutDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der", - "integrationCredentialsHint": "Wo du diese Zugangsdaten findest und wie du deine eigene App registrierst, steht in der Einrichtungsanleitung oben." + "integrationCredentialsHint": "Wo du diese Zugangsdaten findest und wie du deine eigene App registrierst, steht in der Einrichtungsanleitung oben.", + "mcp": { + "enableTitle": "Remote-MCP-Endpunkt", + "enableDescription": "Erlaube einem MCP-fähigen Assistenten (Claude.ai, ChatGPT), deine Gesundheitsdaten über ein begrenztes Token zu lesen. Standardmäßig aus.", + "enabled": "Aktiv", + "disabled": "Inaktiv", + "enableToggleLabel": "MCP-Endpunkt aktivieren", + "endpointLabel": "Connector-URL", + "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." + } }, "admin": { "title": "Administration", diff --git a/messages/en.json b/messages/en.json index 0ac7a69b0..0a94608d8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4162,6 +4162,10 @@ "title": "Channels", "description": "Telegram, ntfy, Web Push, Webhook, and Email delivery.", "subtitle": "Where reminders and alerts are delivered." + }, + "mcp": { + "title": "MCP Connector", + "subtitle": "Connect an external assistant to your own health data." } }, "profile": "Profile", @@ -4892,7 +4896,19 @@ "polarDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the", "ouraDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the", "nightscoutDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the", - "integrationCredentialsHint": "Where to find these credentials and how to register your own app is covered in the setup guide above." + "integrationCredentialsHint": "Where to find these credentials and how to register your own app is covered in the setup guide above.", + "mcp": { + "enableTitle": "Remote MCP endpoint", + "enableDescription": "Let an MCP-capable assistant (Claude.ai, ChatGPT) read your health data over a scoped token. Off by default.", + "enabled": "Enabled", + "disabled": "Disabled", + "enableToggleLabel": "Enable the MCP endpoint", + "endpointLabel": "Connector URL", + "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." + } }, "admin": { "title": "Administration", diff --git a/messages/es.json b/messages/es.json index d4b0c0842..19d8b2a1a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4162,6 +4162,10 @@ "title": "Canales", "description": "Envío por Telegram, ntfy, Web Push, Webhook y correo electrónico.", "subtitle": "Dónde se entregan los recordatorios y avisos." + }, + "mcp": { + "title": "Conector MCP", + "subtitle": "Conecta un asistente externo a tus propios datos de salud." } }, "profile": "Perfil", @@ -4892,7 +4896,19 @@ "polarDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la", "ouraDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la", "nightscoutDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la", - "integrationCredentialsHint": "Dónde encontrar estas credenciales y cómo registrar tu propia aplicación se explica en la guía de configuración de arriba." + "integrationCredentialsHint": "Dónde encontrar estas credenciales y cómo registrar tu propia aplicación se explica en la guía de configuración de arriba.", + "mcp": { + "enableTitle": "Endpoint MCP remoto", + "enableDescription": "Permite que un asistente compatible con MCP (Claude.ai, ChatGPT) lea tus datos de salud mediante un token limitado. Desactivado por defecto.", + "enabled": "Activado", + "disabled": "Desactivado", + "enableToggleLabel": "Activar el endpoint MCP", + "endpointLabel": "URL del conector", + "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." + } }, "admin": { "title": "Administration", diff --git a/messages/fr.json b/messages/fr.json index a7f6ebc03..9861439ee 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4162,6 +4162,10 @@ "title": "Canaux", "description": "Distribution via Telegram, ntfy, Web Push, Webhook et e-mail.", "subtitle": "Où sont envoyés les rappels et alertes." + }, + "mcp": { + "title": "Connecteur MCP", + "subtitle": "Connectez un assistant externe à vos propres données de santé." } }, "profile": "Profil", @@ -4892,7 +4896,19 @@ "polarDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le", "ouraDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le", "nightscoutDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le", - "integrationCredentialsHint": "Où trouver ces identifiants et comment enregistrer ta propre application est expliqué dans le guide de configuration ci-dessus." + "integrationCredentialsHint": "Où trouver ces identifiants et comment enregistrer ta propre application est expliqué dans le guide de configuration ci-dessus.", + "mcp": { + "enableTitle": "Point de terminaison MCP distant", + "enableDescription": "Autorisez un assistant compatible MCP (Claude.ai, ChatGPT) à lire vos données de santé via un jeton restreint. Désactivé par défaut.", + "enabled": "Activé", + "disabled": "Désactivé", + "enableToggleLabel": "Activer le point de terminaison MCP", + "endpointLabel": "URL du connecteur", + "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." + } }, "admin": { "title": "Administration", diff --git a/messages/it.json b/messages/it.json index 2f07c29a2..4ba9e434d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4162,6 +4162,10 @@ "title": "Canali", "description": "Recapito tramite Telegram, ntfy, Web Push, Webhook ed e-mail.", "subtitle": "Dove vengono recapitati promemoria e avvisi." + }, + "mcp": { + "title": "Connettore MCP", + "subtitle": "Collega un assistente esterno ai tuoi dati sulla salute." } }, "profile": "Profilo", @@ -4892,7 +4896,19 @@ "polarDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la", "ouraDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la", "nightscoutDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la", - "integrationCredentialsHint": "Dove trovare queste credenziali e come registrare la tua app è spiegato nella guida alla configurazione qui sopra." + "integrationCredentialsHint": "Dove trovare queste credenziali e come registrare la tua app è spiegato nella guida alla configurazione qui sopra.", + "mcp": { + "enableTitle": "Endpoint MCP remoto", + "enableDescription": "Consenti a un assistente compatibile con MCP (Claude.ai, ChatGPT) di leggere i tuoi dati sulla salute tramite un token limitato. Disattivato per impostazione predefinita.", + "enabled": "Attivo", + "disabled": "Inattivo", + "enableToggleLabel": "Attiva l'endpoint MCP", + "endpointLabel": "URL del connettore", + "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." + } }, "admin": { "title": "Administration", diff --git a/messages/pl.json b/messages/pl.json index 101899b35..735be10f4 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4162,6 +4162,10 @@ "title": "Kanały", "description": "Dostarczanie przez Telegram, ntfy, Web Push, Webhook i e-mail.", "subtitle": "Gdzie dostarczane są przypomnienia i powiadomienia." + }, + "mcp": { + "title": "Łącznik MCP", + "subtitle": "Połącz zewnętrznego asystenta z własnymi danymi zdrowotnymi." } }, "profile": "Profil", @@ -4892,7 +4896,19 @@ "polarDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z", "ouraDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z", "nightscoutDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z", - "integrationCredentialsHint": "Gdzie znaleźć te dane logowania i jak zarejestrować własną aplikację, opisano w przewodniku konfiguracji powyżej." + "integrationCredentialsHint": "Gdzie znaleźć te dane logowania i jak zarejestrować własną aplikację, opisano w przewodniku konfiguracji powyżej.", + "mcp": { + "enableTitle": "Zdalny punkt końcowy MCP", + "enableDescription": "Zezwól asystentowi obsługującemu MCP (Claude.ai, ChatGPT) na odczyt danych zdrowotnych za pomocą ograniczonego tokenu. Domyślnie wyłączone.", + "enabled": "Włączony", + "disabled": "Wyłączony", + "enableToggleLabel": "Włącz punkt końcowy MCP", + "endpointLabel": "Adres URL łącznika", + "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." + } }, "admin": { "title": "Administracja", diff --git a/src/app/api/mcp/tokens/[id]/route.ts b/src/app/api/mcp/tokens/[id]/route.ts new file mode 100644 index 000000000..03e18f2f6 --- /dev/null +++ b/src/app/api/mcp/tokens/[id]/route.ts @@ -0,0 +1,44 @@ +/** + * Revoke an MCP connector token. Only the owning user can revoke; the scope + * filter keeps this endpoint to `health:read` tokens so it can never revoke a + * differently-scoped credential by id alone. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { isApiGloballyEnabled } from "@/lib/app-settings"; +import { prisma } from "@/lib/db"; +import { SCOPE_HEALTH_READ } from "@/lib/mcp/oauth/config"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const DELETE = apiHandler( + async (_request: Request, { params }: RouteParams) => { + const { user } = await requireAuth(); + annotate({ action: { name: "mcp.tokens.revoke" } }); + + if (!(await isApiGloballyEnabled())) { + return apiError("API is globally disabled", 403); + } + + const { id } = await params; + const token = await prisma.apiToken.findUnique({ where: { id } }); + + if ( + !token || + token.userId !== user.id || + !token.permissions.includes(SCOPE_HEALTH_READ) + ) { + return apiError("Token not found", 404); + } + + await prisma.apiToken.update({ where: { id }, data: { revoked: true } }); + await auditLog("mcp.tokens.revoke", { + userId: user.id, + details: { tokenId: id }, + }); + + return apiSuccess({ revoked: true }); + }, +); diff --git a/src/app/api/mcp/tokens/route.ts b/src/app/api/mcp/tokens/route.ts new file mode 100644 index 000000000..c6f6542f9 --- /dev/null +++ b/src/app/api/mcp/tokens/route.ts @@ -0,0 +1,98 @@ +/** + * MCP connector tokens — list + mint. + * + * The MCP settings card mints a DEDICATED `health:read`-scoped Bearer here (NOT + * the `medication:ingest` scope the generic `/api/tokens` mints, and NEVER the + * `["*"]` wildcard). The raw `hlk_` value is shown once. This is the manual / + * stdio / power-user path; the OAuth bridge (`/api/mcp/oauth/*`) mints the same + * `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). + */ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +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"; + +const createSchema = z.object({ + name: z.string().min(1, "Name required").max(100), + expiresInDays: z.number().int().min(1).max(365).optional(), +}); + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "mcp.tokens.list" } }); + + if (!(await isApiGloballyEnabled())) { + return apiError("API is globally disabled", 403); + } + + const tokens = await prisma.apiToken.findMany({ + where: { userId: user.id, permissions: { has: SCOPE_HEALTH_READ } }, + select: { + id: true, + name: true, + permissions: true, + lastUsedAt: true, + expiresAt: true, + createdAt: true, + revoked: true, + }, + orderBy: { createdAt: "desc" }, + }); + + return apiSuccess(tokens); +}); + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + annotate({ action: { name: "mcp.tokens.create" } }); + + if (!(await isApiGloballyEnabled())) { + return apiError("API is globally disabled", 403); + } + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = createSchema.safeParse(body); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422); + } + + 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], + expiresInDays: parsed.data.expiresInDays ?? 90, + }); + + await auditLog("mcp.tokens.create", { + userId: user.id, + details: { tokenId: issued.tokenId, scope: SCOPE_HEALTH_READ }, + }); + + // Return the raw token ONCE — it can never be retrieved again. + return apiSuccess( + { token: issued.token, name: issued.name, expiresAt: issued.expiresAt }, + 201, + ); +}); diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx index 8e7d77599..55cc92c84 100644 --- a/src/app/settings/[section]/page.tsx +++ b/src/app/settings/[section]/page.tsx @@ -16,6 +16,7 @@ import { IntegrationsSection } from "@/components/settings/integrations-section" import { LabsSection } from "@/components/settings/labs-section"; import { LayoutSection } from "@/components/settings/layout-section"; import { MedicationsSection } from "@/components/settings/medications-section"; +import { McpSection } from "@/components/settings/mcp-section"; import { ModulesSection } from "@/components/settings/modules-section"; import { MoodSection } from "@/components/settings/mood-section"; import { NotificationsSection } from "@/components/settings/notifications-section"; @@ -73,6 +74,7 @@ const SECTION_COMPONENTS: Record< vorsorge: VorsorgeSection, thresholds: ThresholdsSection, api: ApiSection, + mcp: McpSection, gesundheitsakte: GesundheitsakteSection, sharing: SharingSection, export: ExportSection, diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx index ebdc1945e..de16315d1 100644 --- a/src/components/settings/__tests__/settings-shell.test.tsx +++ b/src/components/settings/__tests__/settings-shell.test.tsx @@ -116,6 +116,7 @@ describe("SETTINGS_SECTION_SLUGS", () => { "ai", "coach", "api", + "mcp", "gesundheitsakte", "sharing", "export", diff --git a/src/components/settings/mcp-section.tsx b/src/components/settings/mcp-section.tsx new file mode 100644 index 000000000..f645b398a --- /dev/null +++ b/src/components/settings/mcp-section.tsx @@ -0,0 +1,369 @@ +"use client"; + +/** + * `` — Settings → MCP connector. v1.22.0. + * + * One card co-locating the two things a self-hoster needs to connect an external + * assistant (Claude.ai / ChatGPT) to their own data over the remote MCP + * endpoint: + * + * 1. the off-by-default `mcp` module toggle (the opt-in switch that turns the + * `/mcp` surface on for this account — it answers 404 until then), and + * 2. a dedicated `health:read`-scoped token (mint shown once, copy, list with + * last-used, revoke). This is the manual / stdio path; cloud connectors get + * the same scope automatically through the OAuth flow. + * + * The minted token is NEVER `medication:ingest` and NEVER `["*"]` — it is the + * least-privilege read scope and nothing more. + */ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Check, Copy, Key, Loader2, Plug, Trash2 } from "lucide-react"; +import { toast } from "sonner"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { useAuth } from "@/hooks/use-auth"; +import { formatDate, formatDateTime } from "@/lib/format"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { apiFetchRaw, apiGet, apiPatch } from "@/lib/api/api-fetch"; + +interface McpTokenInfo { + id: string; + name: string; + permissions: string[]; + lastUsedAt: string | null; + expiresAt: string | null; + createdAt: string; + revoked: boolean; +} + +export function McpSection() { + return ( +
+ + +
+ ); +} + +function McpEnableCard() { + const { t } = useTranslations(); + const { user } = useAuth(); + const queryClient = useQueryClient(); + + // The opt-in module is OFF until explicitly enabled — `user.modules.mcp` is + // the resolved state (true only when the user turned it on). + const enabled = user?.modules?.mcp === true; + const endpoint = + typeof window !== "undefined" ? `${window.location.origin}/mcp` : "/mcp"; + + const toggle = useMutation({ + mutationKey: queryKeys.modulesPrefs(), + mutationFn: async (next: boolean) => + apiPatch("/api/auth/me/modules", { mcp: next }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); + toast.success(t("settings.sections.modules.saved")); + }, + onError: () => toast.error(t("settings.sections.modules.error")), + }); + + return ( + + + {t("settings.mcp.enabled")} + + ) : ( + {t("settings.mcp.disabled")} + ) + } + /> + +
+
+ + {t("settings.mcp.enableToggleLabel")} + + toggle.mutate(next)} + aria-label={t("settings.mcp.enableToggleLabel")} + /> +
+ +
+

+ {t("settings.mcp.endpointLabel")} +

+ + {endpoint} + +

+ {t("settings.mcp.connectDescription")} +

+
+
+
+ ); +} + +function McpTokensCard() { + const { t } = useTranslations(); + const { isAuthenticated } = useAuth(); + const queryClient = useQueryClient(); + const [newName, setNewName] = useState(""); + const [newToken, setNewToken] = useState(null); + const [creating, setCreating] = useState(false); + const [tokenMsg, setTokenMsg] = useState(null); + const [tokenCopied, setTokenCopied] = useState(false); + + const { data: tokens } = useQuery({ + queryKey: queryKeys.mcpTokens(), + queryFn: async () => apiGet("/api/mcp/tokens"), + enabled: isAuthenticated, + }); + + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + setCreating(true); + setTokenMsg(null); + setNewToken(null); + try { + const res = await apiFetchRaw("/api/mcp/tokens", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: newName.trim() }), + }); + const json = await res.json(); + if (res.ok) { + setNewToken(json.data.token); + setNewName(""); + queryClient.invalidateQueries({ queryKey: queryKeys.mcpTokens() }); + } else { + setTokenMsg(json.error || t("common.error")); + } + } catch { + setTokenMsg(t("common.networkError")); + } finally { + setCreating(false); + } + } + + async function handleCopyToken() { + if (!newToken) return; + try { + await navigator.clipboard.writeText(newToken); + setTokenCopied(true); + toast.success(t("settings.tokenCopied")); + setTimeout(() => setTokenCopied(false), 2_000); + } catch { + toast.error(t("settings.tokenCopyFailed")); + } + } + + async function handleRevoke(tokenId: string) { + try { + const res = await apiFetchRaw(`/api/mcp/tokens/${tokenId}`, { + method: "DELETE", + }); + if (!res.ok) { + toast.error(t("settings.tokenRevokeFailed")); + return; + } + queryClient.invalidateQueries({ queryKey: queryKeys.mcpTokens() }); + } catch { + toast.error(t("settings.tokenRevokeFailed")); + } + } + + const activeTokens = (tokens ?? []).filter((tok) => !tok.revoked); + + return ( + + + +
+

+ {t("settings.mcp.scopeNote")} +

+ +
+ setNewName(e.target.value)} + placeholder={t("settings.tokenNamePlaceholder")} + maxLength={100} + className="flex-1" + /> + +
+ + {newToken && ( +
+

+ {t("settings.tokenCreated")} +

+
+ + {newToken} + + +
+
+ )} + + {tokenMsg && ( +

+ {tokenMsg} +

+ )} + +
+

+ {t("settings.activeTokensTitle")} +

+ {activeTokens.length === 0 ? ( +

+ {t("settings.noActiveTokens")} +

+ ) : ( +
    + {activeTokens.map((tok) => { + const isExpired = + tok.expiresAt && new Date(tok.expiresAt) < new Date(); + return ( +
  • +
    +

    + {tok.name} +

    + {isExpired ? ( + + {t("settings.tokenExpired")} + + ) : ( + + {t("settings.tokenActive")} + + )} +
    +

    + + {t("settings.tokenTablePermissions")}: + {" "} + {tok.permissions.join(", ")} +

    +

    + + {t("settings.tokenTableCreated")}: + {" "} + {formatDate(tok.createdAt)} +

    +

    + + {t("settings.tokenTableLastUsed")}: + {" "} + {tok.lastUsedAt + ? formatDateTime(tok.lastUsedAt) + : t("settings.tokenNeverUsed")} +

    + + + + + + + + {t("settings.tokenRevoke")} + + + {t("settings.tokenRevokeDescription")} + + + + + {t("common.cancel")} + + handleRevoke(tok.id)} + > + {t("settings.tokenRevoked")} + + + + +
  • + ); + })} +
+ )} +
+
+
+ ); +} diff --git a/src/components/settings/section-placeholder.tsx b/src/components/settings/section-placeholder.tsx index 577f0c2dc..80fa2a7e5 100644 --- a/src/components/settings/section-placeholder.tsx +++ b/src/components/settings/section-placeholder.tsx @@ -21,6 +21,7 @@ import { FlaskConical, Info, KeyRound, + Plug, Layers, LayoutDashboard, Link2, @@ -60,6 +61,7 @@ const SLUG_ICON: Record = { ai: Sparkles, coach: Bot, api: KeyRound, + mcp: Plug, gesundheitsakte: FileHeart, sharing: Share2, export: Download, diff --git a/src/components/settings/section-slugs.ts b/src/components/settings/section-slugs.ts index 48419670d..496516701 100644 --- a/src/components/settings/section-slugs.ts +++ b/src/components/settings/section-slugs.ts @@ -73,6 +73,11 @@ export const SETTINGS_SECTION_SLUGS = [ // the coach module. The `ai` entry keeps provider / model / BYOK only. "coach", "api", + // v1.22.0 — the remote MCP connector card: enable the opt-in `mcp` module and + // mint / revoke the dedicated `health:read` token used to connect an external + // assistant (Claude.ai / ChatGPT). Sits next to `api` (both are programmatic + // access surfaces). Not nav-gated — the card carries its own enable toggle. + "mcp", "gesundheitsakte", // v1.18.7 — `sharing` (clinician share links) sits directly after // `gesundheitsakte`, before `export`: minting a time-boxed read-only link diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index d9397a3ac..ed117fcec 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -27,6 +27,7 @@ import { FlaskConical, Info, KeyRound, + Plug, LayoutDashboard, Layers, Link2, @@ -208,6 +209,10 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ moduleGate: "coach", }, { slug: "api", titleKey: "settings.sections.api.title", icon: KeyRound }, + // v1.22.0 — remote MCP connector. Not module-gated: the card carries its own + // enable toggle (mirroring `gesundheitsakte`), so the entry-point stays + // reachable even with the opt-in module off. + { slug: "mcp", titleKey: "settings.sections.mcp.title", icon: Plug }, // v1.18.0 (S5) — the full health record (PDF + FHIR R4 + zip package) // earns its own home, lifted out of Export & Import. // diff --git a/src/lib/query-keys/integrations.ts b/src/lib/query-keys/integrations.ts index 17c459337..0434b52d4 100644 --- a/src/lib/query-keys/integrations.ts +++ b/src/lib/query-keys/integrations.ts @@ -5,6 +5,10 @@ */ export const integrationKeys = { tokens: () => ["tokens"] as const, + // v1.22.0 — MCP connector tokens (the dedicated `health:read` scope minted + // for the remote MCP endpoint). Distinct key from `tokens()` so the MCP + // settings card's mint / revoke mutations invalidate only this list. + mcpTokens: () => ["mcp-tokens"] as const, telegram: () => ["telegram"] as const, telegramSettings: () => ["telegram", "settings"] as const, withings: () => ["withings"] as const,