mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 22:22:24 -05:00
feat: show how to connect AI assistants over MCP
The admin MCP section, now "MCP tools" with a clearer subtitle, gets an animated tile picturing the access level (link cut, reads flowing to the assistant, changes flowing back) and a How to connect button. The same button sits at the top of a token's MCP permissions, in place of the help icon there. How to connect opens a guide with this instance's address filled in: Claude Code, Claude's custom connectors, ChatGPT's developer mode connectors, Codex CLI, and Cursor and VS Code configs, each with a copy button. The copyable box moves out of What's new into a shared CodeBlock.
This commit is contained in:
1 parent
89abf5240b
commit
cc5aa030cc
11 files changed
+641
-115
No files matched your search
+1
-1
@@ -75,7 +75,7 @@ changes it, that change is kept.
|
||||
|
||||
## What an admin decides
|
||||
|
||||
On the admin **Auth** page, **AI assistants (MCP)** sets what every API token on the instance may do over MCP:
|
||||
On the admin **Auth** page, **MCP tools** sets what every API token on the instance may do over MCP:
|
||||
|
||||
- **Read and change** (the default): each user picks their token's tools, including the ones that change the library.
|
||||
- **Read only**: only the tools that read are offered or answered. A tool someone already allowed stays allowed, and works
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Box from "@mui/material/Box";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import CheckIcon from "@mui/icons-material/Check";
|
||||
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
|
||||
|
||||
/** A command or config to copy, in monospace, with a copy button. */
|
||||
export default function CodeBlock({ code }: { code: string }) {
|
||||
const { t } = useTranslation("app");
|
||||
const box = React.useRef<HTMLPreElement>(null);
|
||||
const [copyState, setCopyState] = React.useState<"idle" | "copied" | "failed">("idle");
|
||||
React.useEffect(() => {
|
||||
if (copyState === "idle") return;
|
||||
const timer = window.setTimeout(() => setCopyState("idle"), copyState === "failed" ? 4000 : 1500);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [copyState]);
|
||||
// The clipboard API only exists on HTTPS or localhost, and self-hosted instances are often plain HTTP on a LAN
|
||||
// address; there the command is selected, to copy by hand.
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopyState("copied");
|
||||
} catch {
|
||||
if (box.current) window.getSelection()?.selectAllChildren(box.current);
|
||||
setCopyState("failed");
|
||||
}
|
||||
};
|
||||
const copyLabel =
|
||||
copyState === "failed"
|
||||
? t("codeBlock.copyFailed")
|
||||
: copyState === "copied"
|
||||
? t("codeBlock.copied")
|
||||
: t("codeBlock.copy");
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: "relative",
|
||||
mt: 1,
|
||||
borderRadius: 1.5,
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
bgcolor: "action.hover",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="pre"
|
||||
ref={box}
|
||||
sx={{
|
||||
m: 0,
|
||||
p: 1.25,
|
||||
pr: 5,
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
whiteSpace: "pre-wrap",
|
||||
overflowWrap: "anywhere",
|
||||
}}
|
||||
>
|
||||
{code}
|
||||
</Box>
|
||||
<Tooltip title={copyLabel}>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={t("codeBlock.copy")}
|
||||
onClick={() => void copy()}
|
||||
sx={{ position: "absolute", top: 4, right: 4 }}
|
||||
>
|
||||
{copyState === "copied" ? (
|
||||
<CheckIcon sx={{ fontSize: 16 }} color="success" />
|
||||
) : (
|
||||
<ContentCopyIcon sx={{ fontSize: 16 }} />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { keyframes } from "@emotion/react";
|
||||
import Box from "@mui/material/Box";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import type { McpAccess } from "../../api/settings";
|
||||
import { prefersReducedMotion } from "../storageAnimations/parts";
|
||||
|
||||
// An assistant on the left, the library on the right, and the MCP link between them: cut when off, data
|
||||
// flowing to the assistant when it may read, and back to the library too when it may change it.
|
||||
const CYCLE_S = 1.8;
|
||||
const SLATE = "#334155";
|
||||
const TRACK = "#cbd5e1";
|
||||
const WRITE = "#f59e0b";
|
||||
|
||||
const toAssistant = keyframes`
|
||||
0% { transform: translateX(var(--span)); opacity: 0; }
|
||||
15%, 85% { opacity: 1; }
|
||||
100% { transform: translateX(0); opacity: 0; }
|
||||
`;
|
||||
const toLibrary = keyframes`
|
||||
0% { transform: translateX(0); opacity: 0; }
|
||||
15%, 85% { opacity: 1; }
|
||||
100% { transform: translateX(var(--span)); opacity: 0; }
|
||||
`;
|
||||
const blink = keyframes`
|
||||
0%, 92%, 100% { transform: scaleY(1); }
|
||||
95% { transform: scaleY(0.15); }
|
||||
`;
|
||||
|
||||
export default function McpLinkAnimation({ size, access }: { size: number; access: McpAccess }) {
|
||||
const theme = useTheme();
|
||||
const still = prefersReducedMotion();
|
||||
const accent = theme.palette.primary.main;
|
||||
const off = access === "off";
|
||||
const u = size / 76;
|
||||
const trackLeft = 27 * u;
|
||||
const trackWidth = 21 * u;
|
||||
const dot = 6 * u;
|
||||
const midY = 38 * u;
|
||||
const fade = { transition: "opacity 220ms ease-out, filter 220ms ease-out" };
|
||||
|
||||
const packet = (color: string, frames: ReturnType<typeof keyframes>, delay: number, startAtEnd: boolean) => (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
left: trackLeft,
|
||||
top: midY - dot / 2,
|
||||
width: dot,
|
||||
height: dot,
|
||||
borderRadius: "50%",
|
||||
bgcolor: color,
|
||||
"--span": `${trackWidth - dot}px`,
|
||||
transform: still ? `translateX(${startAtEnd ? trackWidth - dot : 0}px)` : undefined,
|
||||
animation: still ? "none" : `${frames} ${CYCLE_S}s ease-in-out ${delay}s infinite both`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box aria-hidden sx={{ position: "relative", width: size, height: size }}>
|
||||
{/* The assistant: a robot head with an antenna. */}
|
||||
<Box sx={{ ...fade, opacity: off ? 0.45 : 1, filter: off ? "grayscale(1)" : "none" }}>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
left: 14 * u,
|
||||
top: 20 * u,
|
||||
width: 2 * u,
|
||||
height: 7 * u,
|
||||
bgcolor: SLATE,
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
left: 12 * u,
|
||||
top: 16 * u,
|
||||
width: 6 * u,
|
||||
height: 6 * u,
|
||||
borderRadius: "50%",
|
||||
bgcolor: accent,
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
left: 3 * u,
|
||||
top: 27 * u,
|
||||
width: 24 * u,
|
||||
height: 22 * u,
|
||||
borderRadius: `${6 * u}px`,
|
||||
bgcolor: SLATE,
|
||||
}}
|
||||
>
|
||||
{[6, 14].map((left) => (
|
||||
<Box
|
||||
key={left}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
left: left * u,
|
||||
top: 8 * u,
|
||||
width: 4 * u,
|
||||
height: off ? 1.5 * u : 5 * u,
|
||||
borderRadius: `${2 * u}px`,
|
||||
bgcolor: "#ffffff",
|
||||
transformOrigin: "center",
|
||||
transition: "height 220ms ease-out",
|
||||
animation: still || off ? "none" : `${blink} 4s ease-in-out infinite`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* The link: solid with data on it, or dashed and cut. */}
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
left: trackLeft,
|
||||
top: midY - u,
|
||||
width: trackWidth,
|
||||
height: 0,
|
||||
borderTop: `${2 * u}px ${off ? "dashed" : "solid"} ${TRACK}`,
|
||||
}}
|
||||
/>
|
||||
{off ? (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
left: trackLeft + trackWidth / 2 - 5 * u,
|
||||
top: midY - 5 * u,
|
||||
width: 10 * u,
|
||||
height: 10 * u,
|
||||
borderRadius: "50%",
|
||||
border: `${1.5 * u}px solid #94a3b8`,
|
||||
bgcolor: "#ffffff",
|
||||
"&::after": {
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
top: "50%",
|
||||
width: "120%",
|
||||
height: 1.5 * u,
|
||||
bgcolor: "#94a3b8",
|
||||
transform: "translate(-50%, -50%) rotate(-45deg)",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{packet(accent, toAssistant, 0, true)}
|
||||
{packet(accent, toAssistant, CYCLE_S / 2, true)}
|
||||
{access === "write" && packet(WRITE, toLibrary, CYCLE_S / 4, false)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* The library: three models stacked. */}
|
||||
<Box sx={{ ...fade, opacity: off ? 0.6 : 1 }}>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
left: (49 + i * 3) * u,
|
||||
top: (26 + i * 4) * u,
|
||||
width: 18 * u,
|
||||
height: 16 * u,
|
||||
borderRadius: `${4 * u}px`,
|
||||
bgcolor: accent,
|
||||
opacity: 0.45 + i * 0.275,
|
||||
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.12)",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { keyframes } from "@emotion/react";
|
||||
import Button from "@mui/material/Button";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Link from "@mui/material/Link";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import CodeBlock from "../CodeBlock";
|
||||
import Segmented from "../controls/Segmented";
|
||||
|
||||
type Client = "claudeCode" | "claude" | "chatgpt" | "codex" | "editors";
|
||||
const CLIENTS: Client[] = ["claudeCode", "claude", "chatgpt", "codex", "editors"];
|
||||
const TOKEN = "tp_your_token";
|
||||
const GUIDE_URL = "https://github.com/TautvydasDerzinskas/Thingport/blob/main/docs/guides/mcp.md";
|
||||
|
||||
const fadeIn = keyframes`
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
`;
|
||||
|
||||
/** The commands and settings each client needs, with this instance's address filled in. */
|
||||
function snippets(endpoint: string) {
|
||||
return {
|
||||
claudeCode: `claude mcp add --transport http thingport ${endpoint} --header "Authorization: Bearer ${TOKEN}"`,
|
||||
remote: `${endpoint}\nAuthorization: Bearer ${TOKEN}`,
|
||||
codex: `export THINGPORT_TOKEN=${TOKEN}\ncodex mcp add thingport --url ${endpoint} --bearer-token-env-var THINGPORT_TOKEN`,
|
||||
cursor: JSON.stringify(
|
||||
{ mcpServers: { thingport: { url: endpoint, headers: { Authorization: `Bearer ${TOKEN}` } } } },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
vscode: JSON.stringify(
|
||||
{ servers: { thingport: { type: "http", url: endpoint, headers: { Authorization: `Bearer ${TOKEN}` } } } },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function Step({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Typography variant="body2" component="div">
|
||||
{children}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
function Note({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Typography variant="caption" color="text.secondary" component="p" sx={{ m: 0 }}>
|
||||
{children}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = { open: boolean; onClose: () => void };
|
||||
|
||||
/** How to point Claude, ChatGPT, Codex and editors at this instance's /api/mcp with an API token. */
|
||||
export default function McpConnectDialog({ open, onClose }: Props) {
|
||||
const { t } = useTranslation("app");
|
||||
const [client, setClient] = React.useState<Client>("claudeCode");
|
||||
const endpoint = `${window.location.origin}/api/mcp`;
|
||||
const code = snippets(endpoint);
|
||||
const k = (key: string) => `mcpConnect.${key}`;
|
||||
const strong = { strong: <strong /> };
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="sm" aria-labelledby="mcp-connect-title">
|
||||
<DialogTitle id="mcp-connect-title" sx={{ pr: 6 }}>
|
||||
{t(k("title"))}
|
||||
<IconButton
|
||||
aria-label={t("mcpConnect.close")}
|
||||
onClick={onClose}
|
||||
size="small"
|
||||
sx={{ position: "absolute", right: 12, top: 12 }}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Trans t={t} i18nKey={k("intro")} values={{ token: TOKEN }} components={{ ...strong, code: <code /> }} />
|
||||
</Typography>
|
||||
<Segmented<Client>
|
||||
label={t(k("clientLabel"))}
|
||||
value={client}
|
||||
onChange={setClient}
|
||||
options={CLIENTS.map((value) => ({ value, label: t(k(`clients.${value}`)) }))}
|
||||
/>
|
||||
<Stack
|
||||
key={client}
|
||||
spacing={1.25}
|
||||
sx={{
|
||||
animation: `${fadeIn} 180ms ease-out both`,
|
||||
"@media (prefers-reduced-motion: reduce)": { animation: "none" },
|
||||
}}
|
||||
>
|
||||
{client === "claudeCode" && (
|
||||
<>
|
||||
<Step>{t(k("claudeCode.step"))}</Step>
|
||||
<CodeBlock code={code.claudeCode} />
|
||||
</>
|
||||
)}
|
||||
{client === "claude" && (
|
||||
<>
|
||||
<Step>
|
||||
<Trans t={t} i18nKey={k("claude.step")} components={strong} />
|
||||
</Step>
|
||||
<CodeBlock code={code.remote} />
|
||||
<Note>{t(k("claude.note"))}</Note>
|
||||
</>
|
||||
)}
|
||||
{client === "chatgpt" && (
|
||||
<>
|
||||
<Step>
|
||||
<Trans t={t} i18nKey={k("chatgpt.step")} components={strong} />
|
||||
</Step>
|
||||
<CodeBlock code={code.remote} />
|
||||
<Note>{t(k("chatgpt.note"))}</Note>
|
||||
</>
|
||||
)}
|
||||
{client === "codex" && (
|
||||
<>
|
||||
<Step>{t(k("codex.step"))}</Step>
|
||||
<CodeBlock code={code.codex} />
|
||||
<Note>{t(k("codex.note"))}</Note>
|
||||
</>
|
||||
)}
|
||||
{client === "editors" && (
|
||||
<>
|
||||
<Step>
|
||||
<Trans t={t} i18nKey={k("editors.cursor")} components={{ code: <code /> }} />
|
||||
</Step>
|
||||
<CodeBlock code={code.cursor} />
|
||||
<Step>
|
||||
<Trans t={t} i18nKey={k("editors.vscode")} components={{ code: <code /> }} />
|
||||
</Step>
|
||||
<CodeBlock code={code.vscode} />
|
||||
<Note>{t(k("editors.note"))}</Note>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ justifyContent: "space-between", px: 3, pb: 2 }}>
|
||||
<Link
|
||||
href={GUIDE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="body2"
|
||||
underline="hover"
|
||||
sx={{ display: "inline-flex", alignItems: "center", gap: 0.5 }}
|
||||
>
|
||||
{t(k("guide"))}
|
||||
<OpenInNewIcon sx={{ fontSize: 14 }} />
|
||||
</Link>
|
||||
<Button onClick={onClose}>{t(k("close"))}</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -8,19 +8,17 @@ import Dialog from "@mui/material/Dialog";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Link from "@mui/material/Link";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { alpha, useTheme, type Theme } from "@mui/material/styles";
|
||||
import AutoAwesomeOutlinedIcon from "@mui/icons-material/AutoAwesomeOutlined";
|
||||
import BoltOutlinedIcon from "@mui/icons-material/BoltOutlined";
|
||||
import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined";
|
||||
import CheckIcon from "@mui/icons-material/Check";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
|
||||
import NewReleasesOutlinedIcon from "@mui/icons-material/NewReleasesOutlined";
|
||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import WarningAmberRoundedIcon from "@mui/icons-material/WarningAmberRounded";
|
||||
import { releaseNotesApi, type ReleaseNoteItem, type ReleaseNoteKind, type ReleaseNotes } from "../../api/releaseNotes";
|
||||
import CodeBlock from "../CodeBlock";
|
||||
import { ListRowSkeletons } from "../ListSkeleton";
|
||||
|
||||
export type WhatsNewRequest = {
|
||||
@@ -116,77 +114,6 @@ function Screenshots({
|
||||
);
|
||||
}
|
||||
|
||||
function CodeBlock({ code }: { code: string }) {
|
||||
const { t } = useTranslation("app");
|
||||
const box = React.useRef<HTMLPreElement>(null);
|
||||
const [copyState, setCopyState] = React.useState<"idle" | "copied" | "failed">("idle");
|
||||
React.useEffect(() => {
|
||||
if (copyState === "idle") return;
|
||||
const timer = window.setTimeout(() => setCopyState("idle"), copyState === "failed" ? 4000 : 1500);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [copyState]);
|
||||
// The clipboard API only exists on HTTPS or localhost, and self-hosted instances are often plain HTTP on a LAN
|
||||
// address; there the command is selected, to copy by hand.
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopyState("copied");
|
||||
} catch {
|
||||
if (box.current) window.getSelection()?.selectAllChildren(box.current);
|
||||
setCopyState("failed");
|
||||
}
|
||||
};
|
||||
const copyLabel =
|
||||
copyState === "failed"
|
||||
? t("whatsNew.copyFailed")
|
||||
: copyState === "copied"
|
||||
? t("whatsNew.copied")
|
||||
: t("whatsNew.copy");
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: "relative",
|
||||
mt: 1,
|
||||
borderRadius: 1.5,
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
bgcolor: "action.hover",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="pre"
|
||||
ref={box}
|
||||
sx={{
|
||||
m: 0,
|
||||
p: 1.25,
|
||||
pr: 5,
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
whiteSpace: "pre-wrap",
|
||||
overflowWrap: "anywhere",
|
||||
}}
|
||||
>
|
||||
{code}
|
||||
</Box>
|
||||
<Tooltip title={copyLabel}>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={t("whatsNew.copy")}
|
||||
onClick={() => void copy()}
|
||||
sx={{ position: "absolute", top: 4, right: 4 }}
|
||||
>
|
||||
{copyState === "copied" ? (
|
||||
<CheckIcon sx={{ fontSize: 16 }} color="success" />
|
||||
) : (
|
||||
<ContentCopyIcon sx={{ fontSize: 16 }} />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** A release's headline change, above the grouped list. */
|
||||
function HighlightCard({
|
||||
item,
|
||||
|
||||
@@ -207,7 +207,6 @@
|
||||
"mcp": {
|
||||
"heading": "MCP",
|
||||
"subtitle": "Tools an AI assistant may use",
|
||||
"description": "What an AI assistant connected with this token (over MCP, at /api/mcp) may use. Changes apply straight away, without a new token.",
|
||||
"connect": "To connect an AI assistant, for example Claude Code:",
|
||||
"writes": "Changes your library",
|
||||
"failed": "Couldn't save the token's MCP tools. Please try again.",
|
||||
@@ -1663,9 +1662,9 @@
|
||||
"failed": "Couldn't save the public URL."
|
||||
},
|
||||
"mcp": {
|
||||
"heading": "AI assistants (MCP)",
|
||||
"subtitle": "What API tokens may do at /api/mcp.",
|
||||
"accessLabel": "MCP access",
|
||||
"heading": "MCP tools",
|
||||
"subtitle": "Let AI assistants such as Claude search and manage the library with their user's API token.",
|
||||
"accessLabel": "What API tokens may do",
|
||||
"help": "Each user still chooses their own token's tools within this. Tools that change the library import without a captcha, as Thingport Grab does.",
|
||||
"failed": "Couldn't save the MCP access. Please try again.",
|
||||
"levels": {
|
||||
@@ -2195,10 +2194,47 @@
|
||||
"empty": "No release notes for this version yet.",
|
||||
"openGithub": "Open on GitHub",
|
||||
"zoom": "Enlarge screenshot",
|
||||
"highlight": "Highlight",
|
||||
"highlight": "Highlight"
|
||||
},
|
||||
"codeBlock": {
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"copyFailed": "Couldn't copy here. It's selected: press Ctrl+C or ⌘C."
|
||||
},
|
||||
"mcpConnect": {
|
||||
"open": "How to connect",
|
||||
"title": "Connect an AI assistant",
|
||||
"intro": "Each person connects with their own API token, made in <strong>Profile → API token</strong>. Put it in place of <code>{{token}}</code>.",
|
||||
"clientLabel": "AI assistant",
|
||||
"clients": {
|
||||
"claudeCode": "Claude Code",
|
||||
"claude": "Claude",
|
||||
"chatgpt": "ChatGPT",
|
||||
"codex": "Codex CLI",
|
||||
"editors": "Cursor & VS Code"
|
||||
},
|
||||
"claudeCode": {
|
||||
"step": "Run this in a terminal:"
|
||||
},
|
||||
"claude": {
|
||||
"step": "In Claude, open <strong>Settings → Connectors</strong>, choose <strong>Add custom connector</strong> and enter this address. Under request headers, add this Authorization header:",
|
||||
"note": "Claude connects from Anthropic's servers, so Thingport must be reachable from the internet over HTTPS. Request headers are in beta and may depend on your plan."
|
||||
},
|
||||
"chatgpt": {
|
||||
"step": "In ChatGPT, turn on <strong>Developer mode</strong> under <strong>Settings → Apps & Connectors</strong>, create a connector with this address, and choose <strong>Token</strong> as its authentication:",
|
||||
"note": "ChatGPT connects from OpenAI's servers, so Thingport must be reachable from the internet over HTTPS. The Token option depends on your plan; without it, use Codex CLI."
|
||||
},
|
||||
"codex": {
|
||||
"step": "Run this in a terminal:",
|
||||
"note": "THINGPORT_TOKEN must be set in the shell that starts Codex."
|
||||
},
|
||||
"editors": {
|
||||
"cursor": "Cursor: add this to <code>~/.cursor/mcp.json</code>.",
|
||||
"vscode": "VS Code: add this to <code>.vscode/mcp.json</code> in your workspace.",
|
||||
"note": "Other clients work the same way if they support Streamable HTTP and let you set a header."
|
||||
},
|
||||
"guide": "MCP guide",
|
||||
"close": "Close"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
|
||||
@@ -210,7 +210,6 @@
|
||||
"copyCommand": "Copiar comando",
|
||||
"heading": "MCP",
|
||||
"subtitle": "Herramientas que puede usar un asistente de IA",
|
||||
"description": "Lo que puede usar un asistente de IA conectado con este token (por MCP, en /api/mcp). Los cambios se aplican al instante, sin un token nuevo.",
|
||||
"connect": "Para conectar un asistente de IA, por ejemplo Claude Code:",
|
||||
"writes": "Cambia tu biblioteca",
|
||||
"failed": "No se pudieron guardar las herramientas MCP del token. Inténtalo de nuevo.",
|
||||
@@ -1663,9 +1662,9 @@
|
||||
"failed": "No se pudo guardar la URL pública."
|
||||
},
|
||||
"mcp": {
|
||||
"heading": "Asistentes de IA (MCP)",
|
||||
"subtitle": "Qué pueden hacer los tokens de API en /api/mcp.",
|
||||
"accessLabel": "Acceso MCP",
|
||||
"heading": "Herramientas MCP",
|
||||
"subtitle": "Permite que asistentes de IA como Claude busquen en la biblioteca y la gestionen con el token de API de su usuario.",
|
||||
"accessLabel": "Qué pueden hacer los tokens de API",
|
||||
"help": "Cada usuario sigue eligiendo las herramientas de su propio token dentro de este límite. Las herramientas que cambian la biblioteca importan sin captcha, como Thingport Grab.",
|
||||
"failed": "No se pudo guardar el acceso MCP. Inténtalo de nuevo.",
|
||||
"levels": {
|
||||
@@ -2195,10 +2194,47 @@
|
||||
"empty": "Aún no hay notas para esta versión.",
|
||||
"openGithub": "Abrir en GitHub",
|
||||
"zoom": "Ampliar captura",
|
||||
"highlight": "Destacado",
|
||||
"highlight": "Destacado"
|
||||
},
|
||||
"codeBlock": {
|
||||
"copy": "Copiar",
|
||||
"copied": "Copiado",
|
||||
"copyFailed": "No se pudo copiar aquí. Está seleccionado: pulsa Ctrl+C o ⌘C."
|
||||
},
|
||||
"mcpConnect": {
|
||||
"open": "Cómo conectar",
|
||||
"title": "Conecta un asistente de IA",
|
||||
"intro": "Cada persona se conecta con su propio token de API, creado en <strong>Perfil → Token de API</strong>. Ponlo en lugar de <code>{{token}}</code>.",
|
||||
"clientLabel": "Asistente de IA",
|
||||
"clients": {
|
||||
"claudeCode": "Claude Code",
|
||||
"claude": "Claude",
|
||||
"chatgpt": "ChatGPT",
|
||||
"codex": "Codex CLI",
|
||||
"editors": "Cursor y VS Code"
|
||||
},
|
||||
"claudeCode": {
|
||||
"step": "Ejecuta esto en una terminal:"
|
||||
},
|
||||
"claude": {
|
||||
"step": "En Claude, abre <strong>Settings → Connectors</strong>, elige <strong>Add custom connector</strong> e introduce esta dirección. En los encabezados de la solicitud, añade este encabezado Authorization:",
|
||||
"note": "Claude se conecta desde los servidores de Anthropic, así que Thingport debe ser accesible desde internet por HTTPS. Los encabezados de solicitud están en beta y pueden depender de tu plan."
|
||||
},
|
||||
"chatgpt": {
|
||||
"step": "En ChatGPT, activa el <strong>Developer mode</strong> en <strong>Settings → Apps & Connectors</strong>, crea un conector con esta dirección y elige <strong>Token</strong> como autenticación:",
|
||||
"note": "ChatGPT se conecta desde los servidores de OpenAI, así que Thingport debe ser accesible desde internet por HTTPS. La opción Token depende de tu plan; si no la tienes, usa Codex CLI."
|
||||
},
|
||||
"codex": {
|
||||
"step": "Ejecuta esto en una terminal:",
|
||||
"note": "THINGPORT_TOKEN debe estar definido en la terminal que inicia Codex."
|
||||
},
|
||||
"editors": {
|
||||
"cursor": "Cursor: añade esto a <code>~/.cursor/mcp.json</code>.",
|
||||
"vscode": "VS Code: añade esto a <code>.vscode/mcp.json</code> en tu espacio de trabajo.",
|
||||
"note": "Otros clientes funcionan igual si admiten Streamable HTTP y permiten definir un encabezado."
|
||||
},
|
||||
"guide": "Guía de MCP",
|
||||
"close": "Cerrar"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
|
||||
@@ -210,7 +210,6 @@
|
||||
"copyCommand": "Copier la commande",
|
||||
"heading": "MCP",
|
||||
"subtitle": "Les outils qu'un assistant IA peut utiliser",
|
||||
"description": "Ce qu'un assistant IA connecté avec ce jeton (via MCP, sur /api/mcp) peut utiliser. Les changements s'appliquent aussitôt, sans nouveau jeton.",
|
||||
"connect": "Pour connecter un assistant IA, par exemple Claude Code :",
|
||||
"writes": "Modifie votre bibliothèque",
|
||||
"failed": "Impossible d'enregistrer les outils MCP du jeton. Veuillez réessayer.",
|
||||
@@ -1663,9 +1662,9 @@
|
||||
"failed": "Impossible d’enregistrer l’URL publique."
|
||||
},
|
||||
"mcp": {
|
||||
"heading": "Assistants IA (MCP)",
|
||||
"subtitle": "Ce que les jetons d'API peuvent faire sur /api/mcp.",
|
||||
"accessLabel": "Accès MCP",
|
||||
"heading": "Outils MCP",
|
||||
"subtitle": "Laissez des assistants IA comme Claude chercher dans la bibliothèque et la gérer avec le jeton d’API de leur utilisateur.",
|
||||
"accessLabel": "Ce que les jetons d’API peuvent faire",
|
||||
"help": "Chaque utilisateur choisit toujours les outils de son propre jeton dans cette limite. Les outils qui modifient la bibliothèque importent sans captcha, comme Thingport Grab.",
|
||||
"failed": "Impossible d'enregistrer l'accès MCP. Veuillez réessayer.",
|
||||
"levels": {
|
||||
@@ -2208,10 +2207,47 @@
|
||||
"empty": "Pas encore de notes pour cette version.",
|
||||
"openGithub": "Ouvrir sur GitHub",
|
||||
"zoom": "Agrandir la capture",
|
||||
"highlight": "À la une",
|
||||
"highlight": "À la une"
|
||||
},
|
||||
"codeBlock": {
|
||||
"copy": "Copier",
|
||||
"copied": "Copié",
|
||||
"copyFailed": "Copie impossible ici. Le texte est sélectionné : appuyez sur Ctrl+C ou ⌘C."
|
||||
},
|
||||
"mcpConnect": {
|
||||
"open": "Comment se connecter",
|
||||
"title": "Connecter un assistant IA",
|
||||
"intro": "Chacun se connecte avec son propre jeton d’API, créé dans <strong>Profil → Jeton d’API</strong>. Mettez-le à la place de <code>{{token}}</code>.",
|
||||
"clientLabel": "Assistant IA",
|
||||
"clients": {
|
||||
"claudeCode": "Claude Code",
|
||||
"claude": "Claude",
|
||||
"chatgpt": "ChatGPT",
|
||||
"codex": "Codex CLI",
|
||||
"editors": "Cursor et VS Code"
|
||||
},
|
||||
"claudeCode": {
|
||||
"step": "Lancez ceci dans un terminal :"
|
||||
},
|
||||
"claude": {
|
||||
"step": "Dans Claude, ouvrez <strong>Settings → Connectors</strong>, choisissez <strong>Add custom connector</strong> et saisissez cette adresse. Dans les en-têtes de requête, ajoutez cet en-tête Authorization :",
|
||||
"note": "Claude se connecte depuis les serveurs d’Anthropic : Thingport doit donc être accessible depuis internet en HTTPS. Les en-têtes de requête sont en bêta et peuvent dépendre de votre offre."
|
||||
},
|
||||
"chatgpt": {
|
||||
"step": "Dans ChatGPT, activez le <strong>Developer mode</strong> dans <strong>Settings → Apps & Connectors</strong>, créez un connecteur avec cette adresse et choisissez <strong>Token</strong> comme authentification :",
|
||||
"note": "ChatGPT se connecte depuis les serveurs d’OpenAI : Thingport doit donc être accessible depuis internet en HTTPS. L’option Token dépend de votre offre ; sans elle, utilisez Codex CLI."
|
||||
},
|
||||
"codex": {
|
||||
"step": "Lancez ceci dans un terminal :",
|
||||
"note": "THINGPORT_TOKEN doit être défini dans le shell qui lance Codex."
|
||||
},
|
||||
"editors": {
|
||||
"cursor": "Cursor : ajoutez ceci à <code>~/.cursor/mcp.json</code>.",
|
||||
"vscode": "VS Code : ajoutez ceci à <code>.vscode/mcp.json</code> dans votre espace de travail.",
|
||||
"note": "Les autres clients fonctionnent de la même façon s’ils prennent en charge Streamable HTTP et permettent de définir un en-tête."
|
||||
},
|
||||
"guide": "Guide MCP",
|
||||
"close": "Fermer"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
|
||||
@@ -208,7 +208,6 @@
|
||||
"mcp": {
|
||||
"heading": "MCP",
|
||||
"subtitle": "Įrankiai, kuriuos gali naudoti DI asistentas",
|
||||
"description": "Ką gali naudoti DI asistentas, prijungtas su šiuo raktu (per MCP, adresu /api/mcp). Pakeitimai įsigalioja iš karto, naujo rakto nereikia.",
|
||||
"connect": "Kad prijungtumėte DI asistentą, pavyzdžiui Claude Code:",
|
||||
"writes": "Keičia biblioteką",
|
||||
"failed": "Nepavyko išsaugoti rakto MCP įrankių. Bandykite dar kartą.",
|
||||
@@ -1704,9 +1703,9 @@
|
||||
"failed": "Nepavyko išsaugoti viešo adreso."
|
||||
},
|
||||
"mcp": {
|
||||
"heading": "DI asistentai (MCP)",
|
||||
"subtitle": "Ką API raktai gali daryti per /api/mcp.",
|
||||
"accessLabel": "MCP prieiga",
|
||||
"heading": "MCP įrankiai",
|
||||
"subtitle": "Leiskite DI asistentams, pvz., Claude, ieškoti bibliotekoje ir ją tvarkyti naudotojo API raktu.",
|
||||
"accessLabel": "Ką gali API raktai",
|
||||
"help": "Kiekvienas naudotojas vis tiek pats pasirenka savo rakto įrankius šiose ribose. Biblioteką keičiantys įrankiai importuoja be captcha, kaip ir Thingport Grab.",
|
||||
"failed": "Nepavyko išsaugoti MCP prieigos. Bandykite dar kartą.",
|
||||
"levels": {
|
||||
@@ -2261,10 +2260,47 @@
|
||||
"empty": "Šiai versijai naujienų dar nėra.",
|
||||
"openGithub": "Atidaryti GitHub",
|
||||
"zoom": "Padidinti ekrano vaizdą",
|
||||
"highlight": "Svarbiausia",
|
||||
"highlight": "Svarbiausia"
|
||||
},
|
||||
"codeBlock": {
|
||||
"copy": "Kopijuoti",
|
||||
"copied": "Nukopijuota",
|
||||
"copyFailed": "Čia nepavyko nukopijuoti. Tekstas pažymėtas: spauskite Ctrl+C arba ⌘C."
|
||||
},
|
||||
"mcpConnect": {
|
||||
"open": "Kaip prijungti",
|
||||
"title": "Prijunkite DI asistentą",
|
||||
"intro": "Kiekvienas jungiasi savo API raktu, sukurtu <strong>Profilis → API raktas</strong>. Įrašykite jį vietoj <code>{{token}}</code>.",
|
||||
"clientLabel": "DI asistentas",
|
||||
"clients": {
|
||||
"claudeCode": "Claude Code",
|
||||
"claude": "Claude",
|
||||
"chatgpt": "ChatGPT",
|
||||
"codex": "Codex CLI",
|
||||
"editors": "Cursor ir VS Code"
|
||||
},
|
||||
"claudeCode": {
|
||||
"step": "Paleiskite terminale:"
|
||||
},
|
||||
"claude": {
|
||||
"step": "Claude atidarykite <strong>Settings → Connectors</strong>, pasirinkite <strong>Add custom connector</strong> ir įveskite šį adresą. Prie užklausos antraščių pridėkite šią Authorization antraštę:",
|
||||
"note": "Claude jungiasi iš Anthropic serverių, todėl Thingport turi būti pasiekiamas internetu per HTTPS. Užklausos antraštės yra beta versijos ir gali priklausyti nuo plano."
|
||||
},
|
||||
"chatgpt": {
|
||||
"step": "ChatGPT įjunkite <strong>Developer mode</strong> skiltyje <strong>Settings → Apps & Connectors</strong>, sukurkite jungtį su šiuo adresu ir autentifikavimui pasirinkite <strong>Token</strong>:",
|
||||
"note": "ChatGPT jungiasi iš OpenAI serverių, todėl Thingport turi būti pasiekiamas internetu per HTTPS. Token parinktis priklauso nuo plano; jei jos nėra, naudokite Codex CLI."
|
||||
},
|
||||
"codex": {
|
||||
"step": "Paleiskite terminale:",
|
||||
"note": "THINGPORT_TOKEN turi būti nustatytas apvalkale, iš kurio paleidžiamas Codex."
|
||||
},
|
||||
"editors": {
|
||||
"cursor": "Cursor: pridėkite tai į <code>~/.cursor/mcp.json</code>.",
|
||||
"vscode": "VS Code: pridėkite tai į darbo srities <code>.vscode/mcp.json</code>.",
|
||||
"note": "Kiti klientai veikia taip pat, jei palaiko Streamable HTTP ir leidžia nustatyti antraštę."
|
||||
},
|
||||
"guide": "MCP vadovas",
|
||||
"close": "Uždaryti"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Button from "@mui/material/Button";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import TerminalIcon from "@mui/icons-material/Terminal";
|
||||
import { UnauthorizedError } from "../../api/client";
|
||||
import { settingsApi, type McpAccess } from "../../api/settings";
|
||||
import Segmented from "../../components/controls/Segmented";
|
||||
import HelpTip from "../../components/HelpTip";
|
||||
import SectionHeader from "../../components/SectionHeader";
|
||||
import { useToast } from "../../components/ToastProvider";
|
||||
import { AnimatedTile } from "../../components/storageAnimations/parts";
|
||||
import McpLinkAnimation from "../../components/authAnimations/McpLinkAnimation";
|
||||
import McpConnectDialog from "../../components/mcp/McpConnectDialog";
|
||||
|
||||
type Props = {
|
||||
onUnauthorized?: () => void;
|
||||
@@ -24,6 +29,7 @@ export default function McpSection({ onUnauthorized }: Props) {
|
||||
const [access, setAccess] = React.useState<McpAccess>("write");
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [connectOpen, setConnectOpen] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
let active = true;
|
||||
@@ -60,28 +66,42 @@ export default function McpSection({ onUnauthorized }: Props) {
|
||||
|
||||
return (
|
||||
<Stack spacing={3}>
|
||||
<SectionHeader title={t("adminSettings.mcp.heading")} subtitle={t("adminSettings.mcp.subtitle")} />
|
||||
<SectionHeader
|
||||
title={t("adminSettings.mcp.heading")}
|
||||
subtitle={t("adminSettings.mcp.subtitle")}
|
||||
actions={
|
||||
<Button variant="outlined" startIcon={<TerminalIcon />} onClick={() => setConnectOpen(true)}>
|
||||
{t("mcpConnect.open")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Paper variant="outlined" sx={{ p: 2.5 }}>
|
||||
<Stack spacing={0.75} alignItems="flex-start">
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{t("adminSettings.mcp.accessLabel")}
|
||||
<HelpTip text={t("adminSettings.mcp.help")} />
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||
<Segmented
|
||||
label={t("adminSettings.mcp.accessLabel")}
|
||||
value={access}
|
||||
onChange={(next) => void select(next)}
|
||||
disabled={loading || saving}
|
||||
options={LEVELS.map((level) => ({ value: level, label: t(`adminSettings.mcp.levels.${level}`) }))}
|
||||
/>
|
||||
{saving && <CircularProgress size={14} />}
|
||||
<Stack direction="row" spacing={2.5} alignItems="center">
|
||||
<AnimatedTile>
|
||||
<McpLinkAnimation size={76} access={access} />
|
||||
</AnimatedTile>
|
||||
<Stack spacing={0.75} alignItems="flex-start" sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{t("adminSettings.mcp.accessLabel")}
|
||||
<HelpTip text={t("adminSettings.mcp.help")} />
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||
<Segmented
|
||||
label={t("adminSettings.mcp.accessLabel")}
|
||||
value={access}
|
||||
onChange={(next) => void select(next)}
|
||||
disabled={loading || saving}
|
||||
options={LEVELS.map((level) => ({ value: level, label: t(`adminSettings.mcp.levels.${level}`) }))}
|
||||
/>
|
||||
{saving && <CircularProgress size={14} />}
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t(`adminSettings.mcp.levelHelp.${access}`)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t(`adminSettings.mcp.levelHelp.${access}`)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
<McpConnectDialog open={connectOpen} onClose={() => setConnectOpen(false)} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import TerminalIcon from "@mui/icons-material/Terminal";
|
||||
import { authApi, type ApiTokenInfo, type McpToolInfo } from "../../api/auth";
|
||||
import { UnauthorizedError } from "../../api/client";
|
||||
import { useConfirm } from "../../components/ConfirmProvider";
|
||||
import HelpTip from "../../components/HelpTip";
|
||||
import McpConnectDialog from "../../components/mcp/McpConnectDialog";
|
||||
import { PanelHeader, SectionLabel } from "../../components/Layout/ConfigurationDialog/parts";
|
||||
import PermissionRow from "../../components/PermissionRow";
|
||||
import { useToast } from "../../components/ToastProvider";
|
||||
@@ -24,6 +26,7 @@ export default function McpPermissionsSection({ info, onChange, onUnauthorized }
|
||||
const confirm = useConfirm();
|
||||
const toast = useToast();
|
||||
const [saving, setSaving] = React.useState<string | null>(null);
|
||||
const [connectOpen, setConnectOpen] = React.useState(false);
|
||||
// Ahead of the server while a save is in flight; back in step with it once the save answers.
|
||||
const [selected, setSelected] = React.useState<string[]>(info.mcp_tools);
|
||||
React.useEffect(() => setSelected(info.mcp_tools), [info.mcp_tools]);
|
||||
@@ -73,8 +76,15 @@ export default function McpPermissionsSection({ info, onChange, onUnauthorized }
|
||||
<PanelHeader
|
||||
title={t("profile.apiToken.mcp.heading")}
|
||||
subtitle={t("profile.apiToken.mcp.subtitle")}
|
||||
action={<HelpTip text={t("profile.apiToken.mcp.description")} />}
|
||||
action={
|
||||
info.mcp_access !== "off" && (
|
||||
<Button size="small" startIcon={<TerminalIcon />} onClick={() => setConnectOpen(true)}>
|
||||
{t("mcpConnect.open")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<McpConnectDialog open={connectOpen} onClose={() => setConnectOpen(false)} />
|
||||
{info.mcp_access === "off" ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("profile.apiToken.mcp.adminOff")}
|
||||
|
||||
Reference in new issue
Block a user