feat(frontend): choose an API token's MCP tools on the profile

The API token section lists the MCP tools with a box each, saved as it's
ticked, and shows the command to connect Claude Code with the new token
and this instance's address. Changing the tools is logged.
This commit is contained in:
Rui Valim committed 2026-10-20 14:23:38 -03:00
1 parent 8567f195c0
commit ccb588361e
7 files changed
+238 -4

No files matched your search

+1
View File
@@ -59,6 +59,7 @@ export type LogAction =
| "password_reset"
| "api_token_generated"
| "api_token_revoked"
| "api_token_mcp_tools_changed"
| "user_invited"
| "authors_linked"
| "descriptions_refetched"
+25
View File
@@ -22,9 +22,22 @@ export type ApiTokenInfo = {
hint: string | null;
created_at: string | null;
last_used_at: string | null;
/** The MCP tools the token may call at /api/mcp. Empty without a token. */
mcp_tools: string[];
/** Every tool the instance offers, to choose from. */
available_mcp_tools: McpToolInfo[];
};
export type GeneratedApiToken = ApiTokenInfo & { token: string };
export type McpToolInfo = {
name: string;
/** English; the profile page shows its own translation when it has one. */
title: string;
description: string;
/** False for a tool that changes the library. */
read_only: boolean;
};
export type UpdateProfileInput = {
/** Left out by someone without a password, whose recent sign-in stands in for it. */
current_password?: string;
@@ -123,6 +136,18 @@ export const authApi = {
return res.json();
},
/** Changes what the current token may call over MCP; the token itself stays the same. */
setApiTokenMcpTools: async (mcpTools: string[]): Promise<ApiTokenInfo> => {
const res = await fetch(`${apiBase()}/profile/api-token/mcp-tools`, {
method: "PUT",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ mcp_tools: mcpTools }),
});
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to update the token's MCP tools"));
return res.json();
},
revokeApiToken: async (): Promise<ApiTokenInfo> => {
const res = await fetch(`${apiBase()}/profile/api-token`, { method: "DELETE", headers: authHeaders() });
assertOk(res, "Failed to revoke API token");
+48 -2
View File
@@ -189,7 +189,7 @@
"backToProfile": "Back to Profile",
"apiToken": {
"heading": "API token",
"description": "Lets other apps and services connect to your Thingport library, with read-only access.",
"description": "Lets other apps, services and AI assistants connect to your Thingport library. Over the API it can only read.",
"created": "created {{date}}",
"lastUsed": "last used {{when}}",
"neverUsed": "not used yet",
@@ -203,7 +203,52 @@
"copyNow": "Copy your token now. It won't be shown again.",
"copy": "Copy",
"copied": "Copied",
"failed": "Something went wrong with your API token. Please try again."
"failed": "Something went wrong with your API token. Please try again.",
"mcp": {
"heading": "MCP tools",
"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.",
"tools": {
"search_models": {
"title": "Search models",
"description": "Find models by words in their title, tags, creator or description."
},
"list_models": {
"title": "List models",
"description": "List models by tag, category, collection or author."
},
"get_model": {
"title": "Get a model",
"description": "Read one model's description, files, tags, author and collections."
},
"list_collections": {
"title": "List collections",
"description": "See your collections and how many models each holds."
},
"get_collection": {
"title": "Get a collection",
"description": "List the models in one collection."
},
"list_tags": {
"title": "List tags",
"description": "See every tag in your library."
},
"list_categories": {
"title": "List categories",
"description": "See your categories and folders."
},
"get_author": {
"title": "Get an author",
"description": "Read about a designer and see their models in your library."
},
"library_stats": {
"title": "Library stats",
"description": "Your totals, most viewed, most printed and newest models."
}
}
}
},
"language": {
"heading": "Language",
@@ -1238,6 +1283,7 @@
"password_reset": "Reset password",
"api_token_generated": "Generated an API token",
"api_token_revoked": "Revoked their API token",
"api_token_mcp_tools_changed": "Changed their API token's MCP tools",
"user_invited": "Invited a user",
"model_uploaded": "Model uploaded",
"model_imported": "Model imported",
+48 -2
View File
@@ -190,7 +190,7 @@
"backToProfile": "Grįžti į profilį",
"apiToken": {
"heading": "API raktas",
"description": "Leidžia kitoms programoms ir paslaugoms prisijungti prie jūsų Thingport bibliotekos tik skaitymo teisėmis.",
"description": "Leidžia kitoms programoms, paslaugoms ir DI asistentams prisijungti prie jūsų Thingport bibliotekos. Per API jis gali tik skaityti.",
"created": "sukurtas {{date}}",
"lastUsed": "paskutinį kartą naudotas {{when}}",
"neverUsed": "dar nenaudotas",
@@ -204,7 +204,52 @@
"copyNow": "Nukopijuokite raktą dabar. Jis daugiau nebus rodomas.",
"copy": "Kopijuoti",
"copied": "Nukopijuota",
"failed": "Kažkas nepavyko su jūsų API raktu. Bandykite dar kartą."
"failed": "Kažkas nepavyko su jūsų API raktu. Bandykite dar kartą.",
"mcp": {
"heading": "MCP įrankiai",
"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ą.",
"tools": {
"search_models": {
"title": "Ieškoti modelių",
"description": "Rasti modelius pagal žodžius pavadinime, žymėse, autoriuje ar aprašyme."
},
"list_models": {
"title": "Modelių sąrašas",
"description": "Išvardyti modelius pagal žymę, kategoriją, kolekciją ar autorių."
},
"get_model": {
"title": "Modelio informacija",
"description": "Perskaityti modelio aprašymą, failus, žymes, autorių ir kolekcijas."
},
"list_collections": {
"title": "Kolekcijų sąrašas",
"description": "Matyti savo kolekcijas ir kiek modelių kiekvienoje."
},
"get_collection": {
"title": "Kolekcijos turinys",
"description": "Išvardyti vienos kolekcijos modelius."
},
"list_tags": {
"title": "Žymių sąrašas",
"description": "Matyti visas bibliotekos žymes."
},
"list_categories": {
"title": "Kategorijų sąrašas",
"description": "Matyti savo kategorijas ir aplankus."
},
"get_author": {
"title": "Autoriaus informacija",
"description": "Sužinoti apie dizainerį ir matyti jo modelius jūsų bibliotekoje."
},
"library_stats": {
"title": "Bibliotekos statistika",
"description": "Jūsų suvestinė, daugiausiai peržiūrėti, spausdinti ir naujausi modeliai."
}
}
}
},
"language": {
"heading": "Kalba",
@@ -1267,6 +1312,7 @@
"password_reset": "Atkūrė slaptažodį",
"api_token_generated": "Sugeneravo API raktą",
"api_token_revoked": "Atšaukė savo API raktą",
"api_token_mcp_tools_changed": "Pakeitė savo API rakto MCP įrankius",
"user_invited": "Pakvietė naudotoją",
"model_uploaded": "Modelis įkeltas",
"model_imported": "Modelis importuotas",
+2
View File
@@ -34,6 +34,8 @@ const ACTION_COLORS: Record<LogAction, ActionColor> = {
password_reset: "warning",
api_token_generated: "warning",
api_token_revoked: "default",
// Can turn on a tool that writes.
api_token_mcp_tools_changed: "warning",
user_invited: "info",
authors_linked: "success",
descriptions_refetched: "warning",
@@ -0,0 +1,99 @@
import React from "react";
import { useTranslation } from "react-i18next";
import Alert from "@mui/material/Alert";
import Box from "@mui/material/Box";
import Checkbox from "@mui/material/Checkbox";
import Chip from "@mui/material/Chip";
import FormControlLabel from "@mui/material/FormControlLabel";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { authApi, type ApiTokenInfo, type McpToolInfo } from "../../api/auth";
import { UnauthorizedError } from "../../api/client";
type Props = {
info: ApiTokenInfo;
onChange: (info: ApiTokenInfo) => void;
onUnauthorized?: () => void;
};
/** Which MCP tools the token may call at /api/mcp. Each box saves on its own, so there's no
* unsaved state to lose; a refused save puts the box back. */
export default function ApiTokenMcpTools({ info, onChange, onUnauthorized }: Props) {
const { t, i18n } = useTranslation(["app", "common"]);
const [saving, setSaving] = React.useState<string | null>(null);
const [error, setError] = React.useState<string | null>(null);
// 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]);
const enabled = new Set(selected);
// A tool added on the server before its translation falls back to the server's English text.
const label = (tool: McpToolInfo, part: "title" | "description") => {
const key = `profile.apiToken.mcp.tools.${tool.name}.${part}`;
return i18n.exists(key, { ns: "app" }) ? t(key) : tool[part];
};
const toggle = async (name: string, on: boolean) => {
const next = info.available_mcp_tools
.map((tool) => tool.name)
.filter((tool) => (tool === name ? on : enabled.has(tool)));
setSelected(next);
setSaving(name);
setError(null);
try {
onChange(await authApi.setApiTokenMcpTools(next));
} catch (err) {
setSelected(info.mcp_tools);
if (err instanceof UnauthorizedError) onUnauthorized?.();
else setError(t("profile.apiToken.mcp.failed"));
} finally {
setSaving(null);
}
};
return (
<Box sx={{ border: 1, borderColor: "divider", borderRadius: 2, p: 1.5 }}>
<Typography variant="body2" fontWeight={600}>
{t("profile.apiToken.mcp.heading")}
</Typography>
<Typography variant="caption" color="text.secondary" component="p" sx={{ mb: 1 }}>
{t("profile.apiToken.mcp.description")}
</Typography>
<Stack spacing={0.5}>
{info.available_mcp_tools.map((tool) => (
<FormControlLabel
key={tool.name}
sx={{ alignItems: "flex-start", m: 0 }}
control={
<Checkbox
size="small"
sx={{ pt: 0.25 }}
checked={enabled.has(tool.name)}
disabled={saving !== null}
onChange={(e) => void toggle(tool.name, e.target.checked)}
/>
}
label={
<Box sx={{ pt: 0.25 }}>
<Stack direction="row" alignItems="center" gap={1}>
<Typography variant="body2">{label(tool, "title")}</Typography>
{!tool.read_only && (
<Chip size="small" color="warning" variant="outlined" label={t("profile.apiToken.mcp.writes")} />
)}
</Stack>
<Typography variant="caption" color="text.secondary">
{label(tool, "description")}
</Typography>
</Box>
}
/>
))}
</Stack>
{error && (
<Alert severity="error" sx={{ mt: 1 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
</Box>
);
}
@@ -15,6 +15,12 @@ import { authApi, type ApiTokenInfo } from "../../api/auth";
import { UnauthorizedError } from "../../api/client";
import { useConfirm } from "../../components/ConfirmProvider";
import { relativeTime } from "../../utils/relativeTime";
import ApiTokenMcpTools from "./ApiTokenMcpTools";
/** Same origin as the app: nginx (or Vite in dev) passes /api through to the backend. */
function mcpEndpoint(): string {
return `${window.location.origin}/api/mcp`;
}
type Props = {
onUnauthorized?: () => void;
@@ -177,10 +183,19 @@ export default function ApiTokenSection({ onUnauthorized }: Props) {
},
}}
/>
<Typography variant="body2">{t("profile.apiToken.mcp.connect")}</Typography>
<Box
component="pre"
sx={{ m: 0, p: 1, borderRadius: 1, bgcolor: "action.hover", fontSize: "0.75rem", overflowX: "auto" }}
>
{`claude mcp add --transport http thingport ${mcpEndpoint()} \\\n --header "Authorization: Bearer ${newToken}"`}
</Box>
</Stack>
</Alert>
)}
{info?.configured && <ApiTokenMcpTools info={info} onChange={setInfo} onUnauthorized={onUnauthorized} />}
{error && (
<Alert severity="error" onClose={() => setError(null)}>
{error}