"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, Link2, 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; } interface McpConnectionInfo { id: string; clientName: string; scope: string; createdAt: string; lastUsedAt: string | null; } export function McpSection() { return (
); } function McpConnectionsCard() { const { t } = useTranslations(); const { isAuthenticated } = useAuth(); const queryClient = useQueryClient(); const { data: connections } = useQuery({ queryKey: queryKeys.mcpConnections(), queryFn: async () => apiGet("/api/mcp/connections"), enabled: isAuthenticated, }); async function handleRevoke(connectionId: string) { try { const res = await apiFetchRaw(`/api/mcp/connections/${connectionId}`, { method: "DELETE", }); if (!res.ok) { toast.error(t("settings.mcp.connectionRevokeFailed")); return; } queryClient.invalidateQueries({ queryKey: queryKeys.mcpConnections() }); } catch { toast.error(t("settings.mcp.connectionRevokeFailed")); } } const list = connections ?? []; return (
{list.length === 0 ? (

{t("settings.mcp.noConnections")}

) : (
    {list.map((conn) => (
  • {conn.clientName}

    {t("settings.mcp.connectionConnected")}

    {t("settings.tokenTablePermissions")}: {" "} {conn.scope}

    {t("settings.mcp.connectionLastUsed")}: {" "} {conn.lastUsedAt ? formatDateTime(conn.lastUsedAt) : t("settings.mcp.connectionNeverUsed")}

    {t("settings.mcp.connectionRevoke")} {t("settings.mcp.connectionRevokeDescription")} {t("common.cancel")} handleRevoke(conn.id)} > {t("settings.mcp.connectionRevoke")}
  • ))}
)}
); } 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 [allowWrite, setAllowWrite] = useState(false); 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(), // 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")); } } 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")}

{t("settings.mcp.writeScopeToggleLabel")}

{t("settings.mcp.writeScopeNote")}

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")}
  • ); })}
)}
); }