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:
Tautvydas Derzinskas committed 2026-10-21 22:01:14 +02:00
1 parent 89abf5240b
commit cc5aa030cc
11 files changed
+641 -115

No files matched your search

+1 -1
View File
@@ -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
+79
View File
@@ -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,
+41 -5
View File
@@ -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": {
+41 -5
View File
@@ -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": {
+41 -5
View File
@@ -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": {
+41 -5
View File
@@ -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")}