feat: admin MCP access, token permissions dialog, and review fixes

Follow-up to the review of the MCP endpoint, after merging develop.

Backend
- Instance-wide MCP access an admin sets (Off / Read only / Read and
  change, default Read and change), at GET/POST /settings/mcp and logged
  as mcp_access_changed. Off turns every token away at /api/mcp; Read
  only offers and answers only the tools that read, refuses turning a
  write tool on, and keeps a token's earlier write choices so they come
  back with Read and change. GET /profile/api-token carries mcp_access
  and only the tools it offers.
- Model links come from the public URL an admin saved, not the
  PUBLIC_URL env var it's only seeded from.
- An import refuses a category that isn't the user's own, for POST
  /import and import_model alike.
- add_to_collection inserts and logs only the models it actually adds.
- get_import_status refuses a task the web app started instead of
  failing on its result.
- The migration is now 20261030120000_api_token_mcp_tools, after every
  migration on develop and on open branches.
- Tests for each access level, the category check, a web app task id
  and the logged count.

Frontend
- The token's MCP tools move off the profile into a Token permissions
  dialog behind a cog beside the token, with MCP as its first section
  so the REST API's permissions can join it later.
- Each tool is a row: a small CSS tile picturing what it does (moving
  only while its row is hovered or focused, still for reduced motion,
  greyed when denied), its name and description, and a Deny / Allow
  Segmented control. Tools are grouped by whether they read or change
  the library.
- Admin Auth page: an AI assistants (MCP) section to set the access.
- The connect command gets a copy button and is hidden while MCP is off.
- Regenerating says the token's tools go back to the read-only ones.
- Save errors go through toasts.
- Spanish and French strings for every MCP key, and the new keys in all
  four languages.

Docs
- mcp.md and api-token.md: the Token permissions dialog, the admin
  setting, the regenerate reset, and what an assistant reading scraped
  descriptions could be talked into.
This commit is contained in:
Tautvydas Derzinskas committed 2026-10-21 08:47:49 +02:00
1 parent 12a6375832
commit dec1b0dbe3
29 files changed
+1257 -209

No files matched your search

+81 -45
View File
@@ -1,14 +1,19 @@
import { z } from "zod";
import { prisma } from "../db";
import { PUBLIC_URL } from "../config";
import { toAuthorOut, type PrintOut } from "../dto";
import { HttpError } from "../utils/fileUtils";
import { SYSTEM_COLLECTIONS, systemCollectionKeyForId } from "../services/collectionService";
import type { McpAccess } from "../services/settingsService";
import {
addModelsToCollection,
createCollection,
isSystemCollectionId,
SYSTEM_COLLECTIONS,
systemCollectionKeyForId,
} from "../services/collectionService";
import { getDashboardSummary } from "../services/dashboardService";
import { printOutById } from "../services/printLoader";
import { listPrints, PRINT_SORT_MODES, SELF_AUTHOR_ID } from "../services/printQuery";
import { listPrints, PRINT_SORT_MODES, SELF_AUTHOR_ID, type PrintListPage } from "../services/printQuery";
import { search } from "../services/searchService";
import { addModelsToCollection, createCollection, isSystemCollectionId } from "../services/collectionService";
import { getImportTask, startImportTask, type ImportTaskState } from "../services/importTaskService";
import { importAndDescribe, prepareSingleImport } from "../services/singleImport";
@@ -19,7 +24,11 @@ import { importAndDescribe, prepareSingleImport } from "../services/singleImport
// A token reaches only the tools its user ticked (see routes/mcp.ts). A tool that writes must say so with
// `readOnly: false`: it's then off for every token until its user turns it on.
export type McpToolContext = { userId: string };
export type McpToolContext = {
userId: string;
/** The instance's address as an admin set it, "" when unset: see getPublicUrl. */
publicUrl: string;
};
export type McpToolDefinition = {
name: string;
@@ -43,11 +52,11 @@ const pageArgs = {
};
/** The model's page in the Thingport web app, when the instance knows its own address. */
function modelUrl(id: string): string | null {
return PUBLIC_URL ? `${PUBLIC_URL}/models/${id}` : null;
function modelUrl(ctx: McpToolContext, id: string): string | null {
return ctx.publicUrl ? `${ctx.publicUrl}/models/${id}` : null;
}
function modelSummary(print: PrintOut) {
function modelSummary(ctx: McpToolContext, print: PrintOut) {
return {
id: print.id,
title: print.title || print.name,
@@ -60,12 +69,16 @@ function modelSummary(print: PrintOut) {
print_count: print.print_count,
favorite: print.is_favorite,
added_at: print.created_at,
url: modelUrl(print.id),
url: modelUrl(ctx, print.id),
};
}
function page(prints: PrintOut[], total: number, hasMore: boolean, nextOffset: number) {
return { models: prints.map(modelSummary), total, next_offset: hasMore ? nextOffset : null };
function page(ctx: McpToolContext, result: PrintListPage) {
return {
models: result.prints.map((print) => modelSummary(ctx, print)),
total: result.total,
next_offset: result.hasMore ? result.nextOffset : null,
};
}
const searchModels: McpToolDefinition = {
@@ -79,10 +92,11 @@ const searchModels: McpToolDefinition = {
query: z.string().min(1).describe("Words to look for, e.g. 'benchy' or 'cable clip'."),
limit: z.number().int().min(1).max(25).optional().describe("How many models to return, up to 25."),
},
async run({ userId }, args) {
async run(ctx, args) {
const { userId } = ctx;
const result = await search(userId, args.query as string, args.limit as number | undefined);
return {
models: result.models.map(modelSummary),
models: result.models.map((print) => modelSummary(ctx, print)),
more_models: result.has_more_models,
collections: result.collections.map((c) => ({ id: c.id, name: c.name, model_count: c.item_count })),
tags: result.tags.map((t) => ({ tag: t.tag, model_count: t.count })),
@@ -115,7 +129,8 @@ const listModels: McpToolDefinition = {
.describe("newest (default), popular (most viewed), downloads (most printed), or relevance (needs query)."),
...pageArgs,
},
async run({ userId }, args) {
async run(ctx, args) {
const { userId } = ctx;
const result = await listPrints(
userId,
{
@@ -131,7 +146,7 @@ const listModels: McpToolDefinition = {
offset: args.offset as number | undefined,
},
);
return page(result.prints, result.total, result.hasMore, result.nextOffset);
return page(ctx, result);
},
};
@@ -143,7 +158,8 @@ const getModel: McpToolDefinition = {
"imported from, and how often it was viewed and printed.",
readOnly: true,
inputSchema: { id: z.string().min(1).describe("The model's id, from search_models or list_models.") },
async run({ userId }, args) {
async run(ctx, args) {
const { userId } = ctx;
// printOutById, not GET /print/:id: an assistant looking at a model isn't the user viewing it, so
// its view count and Browsing History stay as they are.
const print = await printOutById(userId, args.id as string);
@@ -152,7 +168,7 @@ const getModel: McpToolDefinition = {
select: { collection: { select: { id: true, name: true } } },
});
return {
...modelSummary(print),
...modelSummary(ctx, print),
description: print.notes,
author: print.author
? {
@@ -177,7 +193,8 @@ const listCollections: McpToolDefinition = {
description: "The user's collections, with how many models each holds, plus Favourites and Browsing History.",
readOnly: true,
inputSchema: {},
async run({ userId }) {
async run(ctx) {
const { userId } = ctx;
const [collections, favoriteCount, historyCount] = await Promise.all([
prisma.collection.findMany({
where: { userId },
@@ -213,7 +230,8 @@ const getCollection: McpToolDefinition = {
id: z.string().min(1).describe("The collection's id, from list_collections ('favorites' and 'history' work too)."),
...pageArgs,
},
async run({ userId }, args) {
async run(ctx, args) {
const { userId } = ctx;
const id = args.id as string;
const systemKey = systemCollectionKeyForId(id);
const collection = systemKey
@@ -228,7 +246,7 @@ const getCollection: McpToolDefinition = {
{ collectionId: id },
{ limit: (args.limit as number | undefined) ?? DEFAULT_PAGE, offset: args.offset as number | undefined },
);
return { ...collection, ...page(result.prints, result.total, result.hasMore, result.nextOffset) };
return { ...collection, ...page(ctx, result) };
},
};
@@ -243,7 +261,8 @@ const listTags: McpToolDefinition = {
query: z.string().optional().describe("Only tags containing this, ignoring case, e.g. 'drag' for 'dragon'."),
limit: z.number().int().min(1).max(500).optional().describe("How many to return, up to 500 (default 200)."),
},
async run({ userId }, args) {
async run(ctx, args) {
const { userId } = ctx;
const query = ((args.query as string | undefined) ?? "").trim().toLowerCase();
const rows = await prisma.print.findMany({ where: { userId }, select: { tags: true } });
// Counted case-insensitively, under the spelling seen first, as the library's tag list does.
@@ -270,7 +289,8 @@ const listCategories: McpToolDefinition = {
"list_models' category_id.",
readOnly: true,
inputSchema: {},
async run({ userId }) {
async run(ctx) {
const { userId } = ctx;
const [categories, counts] = await Promise.all([
prisma.category.findMany({
where: { userId },
@@ -301,7 +321,8 @@ const getAuthor: McpToolDefinition = {
id: z.string().min(1).describe("The author's id, from a model's author."),
...pageArgs,
},
async run({ userId }, args) {
async run(ctx, args) {
const { userId } = ctx;
// Authors are shared across users; only this user's models of theirs are listed.
const author = await prisma.author.findUnique({ where: { id: args.id as string } });
if (!author) throw new HttpError(404, "Author not found");
@@ -318,7 +339,7 @@ const getAuthor: McpToolDefinition = {
provider: out.provider,
bio: out.bio_translated ?? out.bio,
links: out.links,
...page(result.prints, result.total, result.hasMore, result.nextOffset),
...page(ctx, result),
};
},
};
@@ -331,7 +352,8 @@ const libraryStats: McpToolDefinition = {
"most recently added models, top authors and the sites models came from.",
readOnly: true,
inputSchema: {},
async run({ userId }) {
async run(ctx) {
const { userId } = ctx;
const summary = await getDashboardSummary(userId);
const model = (m: (typeof summary.topViewed)[number]) => ({
id: m.id,
@@ -339,7 +361,7 @@ const libraryStats: McpToolDefinition = {
view_count: m.viewCount,
print_count: m.printCount,
added_at: m.createdAt.toISOString(),
url: modelUrl(m.id),
url: modelUrl(ctx, m.id),
});
return {
model_count: summary.modelCount,
@@ -368,7 +390,7 @@ export const mcpImportTiming = { waitMs: 25_000, pollMs: 250 };
type ImportedModel = PrintOut & { import_outcome: "created" | "already_imported" | "profile_added" };
/** What a finished, failed or still running import looks like to the assistant. */
function importStateOut(taskId: string, state: ImportTaskState) {
function importStateOut(ctx: McpToolContext, taskId: string, state: ImportTaskState) {
if (state.status === "running") {
return {
status: "running",
@@ -377,11 +399,13 @@ function importStateOut(taskId: string, state: ImportTaskState) {
};
}
if (state.status === "error") throw new HttpError(state.http_status, state.detail);
const result = state.result as { model: ImportedModel; collection: { id: string; name: string } | null };
const result = state.result as { model?: ImportedModel; collection?: { id: string; name: string } | null };
// A task id from POST /import?async=1 belongs to the same user but holds a bare model.
if (!result?.model) throw new HttpError(404, "No such import from import_model");
return {
status: result.model.import_outcome,
model: modelSummary(result.model),
collection: result.collection,
model: modelSummary(ctx, result.model),
collection: result.collection ?? null,
};
}
@@ -402,7 +426,8 @@ const importModel: McpToolDefinition = {
category_id: z.string().optional().describe("A category to file it in (an id from list_categories)."),
collection_id: z.string().optional().describe("A collection to add it to (an id from list_collections)."),
},
async run({ userId }, args) {
async run(ctx, args) {
const { userId } = ctx;
const collectionId = args.collection_id as string | undefined;
// Checked first, so a bad collection doesn't leave an import half done.
if (collectionId) {
@@ -410,8 +435,8 @@ const importModel: McpToolDefinition = {
const owned = await prisma.collection.count({ where: { id: collectionId, userId } });
if (!owned) throw new HttpError(404, "Collection not found");
}
// No captcha: like Thingport Grab, an assistant has nowhere to show one, and the token's user turned
// this tool on themselves.
// No captcha: like Thingport Grab, an assistant has nowhere to show one. The token's user turned this
// tool on themselves, and an admin who doesn't want that sets MCP to read only.
const { url, body } = await prepareSingleImport(userId, {
url: args.url as string,
title: (args.title as string | undefined) ?? null,
@@ -429,7 +454,7 @@ const importModel: McpToolDefinition = {
await new Promise((resolve) => setTimeout(resolve, mcpImportTiming.pollMs));
state = getImportTask(taskId, userId)!;
}
return importStateOut(taskId, state);
return importStateOut(ctx, taskId, state);
},
};
@@ -439,12 +464,13 @@ const getImportStatus: McpToolDefinition = {
description: "How an import that import_model left running is getting on, and the model once it's done.",
readOnly: true,
inputSchema: { task_id: z.string().min(1).describe("The task_id import_model returned.") },
async run({ userId }, args) {
async run(ctx, args) {
const { userId } = ctx;
const taskId = args.task_id as string;
const state = getImportTask(taskId, userId);
// Tasks live in memory for a while after they finish; a restart forgets them.
if (!state) throw new HttpError(404, "No such import: it may have finished a while ago, or the server restarted");
return importStateOut(taskId, state);
return importStateOut(ctx, taskId, state);
},
};
@@ -458,7 +484,8 @@ const createCollectionTool: McpToolDefinition = {
description: z.string().trim().max(2000).optional(),
tags: z.array(z.string()).optional(),
},
async run({ userId }, args) {
async run(ctx, args) {
const { userId } = ctx;
const collection = await createCollection(userId, {
name: args.name as string,
description: (args.description as string | undefined) ?? null,
@@ -484,7 +511,8 @@ const addToCollection: McpToolDefinition = {
.max(100)
.describe("The models, ids from search_models or list_models."),
},
async run({ userId }, args) {
async run(ctx, args) {
const { userId } = ctx;
const result = await addModelsToCollection(userId, args.collection_id as string, args.model_ids as string[]);
return {
collection: { id: result.collection.id, name: result.collection.name },
@@ -513,13 +541,21 @@ export const MCP_TOOLS: readonly McpToolDefinition[] = [
export const MCP_TOOL_NAMES = MCP_TOOLS.map((tool) => tool.name);
/** What the profile page offers to tick. */
export const MCP_TOOL_CATALOG = MCP_TOOLS.map((tool) => ({
name: tool.name,
title: tool.title,
description: tool.description,
read_only: tool.readOnly,
}));
/** The tools the instance's MCP access lets a token reach, ticked or not. */
export function mcpToolsFor(access: McpAccess): readonly McpToolDefinition[] {
if (access === "off") return [];
return access === "read" ? MCP_TOOLS.filter((tool) => tool.readOnly) : MCP_TOOLS;
}
/** What the profile page offers to tick under this access. */
export function mcpToolCatalog(access: McpAccess) {
return mcpToolsFor(access).map((tool) => ({
name: tool.name,
title: tool.title,
description: tool.description,
read_only: tool.readOnly,
}));
}
/** What a new token gets when its user doesn't choose: every tool that only reads. */
export const DEFAULT_MCP_TOOL_NAMES = MCP_TOOLS.filter((tool) => tool.readOnly).map((tool) => tool.name);
+28 -6
View File
@@ -14,7 +14,8 @@ import { checkCaptcha } from "../services/captchaService";
import { createLog } from "../services/auditLog";
import { seedDefaultCategories } from "../services/categoryService";
import { generateApiToken, getApiToken, revokeApiToken, setApiTokenMcpTools } from "../services/apiTokenService";
import { DEFAULT_MCP_TOOL_NAMES, MCP_TOOL_CATALOG, MCP_TOOL_NAMES } from "../mcp/tools";
import { DEFAULT_MCP_TOOL_NAMES, MCP_TOOL_NAMES, mcpToolCatalog, mcpToolsFor } from "../mcp/tools";
import { getMcpAccess, type McpAccess } from "../services/settingsService";
import { toUserOut } from "../dto";
import type { Prisma, Role } from "@prisma/client";
@@ -394,11 +395,24 @@ router.patch(
// The API token itself is only in the response that generates it; this shows which one is in use.
// Generating and revoking can't be done with the token itself: requireAuth lets it only read.
/** The token with what the instance's MCP access lets it choose from. */
function withMcpCatalog<T>(token: T, access: McpAccess) {
return { ...token, mcp_access: access, available_mcp_tools: mcpToolCatalog(access) };
}
/** Refuses a tool the instance's MCP access doesn't offer, rather than storing a choice that can't apply. */
function assertMcpToolsOffered(names: string[], access: McpAccess): void {
const offered = new Set(mcpToolsFor(access).map((tool) => tool.name));
if (names.some((name) => !offered.has(name))) {
throw new HttpError(403, "An admin has turned off MCP tools that change the library");
}
}
router.get(
"/profile/api-token",
requireAuth,
asyncHandler(async (req, res) => {
res.json({ ...(await getApiToken(req.userId!)), available_mcp_tools: MCP_TOOL_CATALOG });
res.json(withMcpCatalog(await getApiToken(req.userId!), await getMcpAccess()));
}),
);
@@ -414,8 +428,10 @@ router.post(
requireAuth,
asyncHandler(async (req, res) => {
const body = parseBody(generateApiTokenSchema, req.body ?? {});
const access = await getMcpAccess();
if (body.mcp_tools) assertMcpToolsOffered(body.mcp_tools, access);
const token = await generateApiToken(req.userId!, body.mcp_tools ?? DEFAULT_MCP_TOOL_NAMES);
res.json({ ...token, available_mcp_tools: MCP_TOOL_CATALOG });
res.json(withMcpCatalog(token, access));
void createLog({ userId: req.userId!, action: "api_token_generated", details: { mcp_tools: token.mcp_tools } });
}),
);
@@ -425,8 +441,14 @@ router.put(
requireAuth,
asyncHandler(async (req, res) => {
const body = parseBody(z.object({ mcp_tools: mcpToolsSchema }), req.body);
const token = await setApiTokenMcpTools(req.userId!, body.mcp_tools);
res.json({ ...token, available_mcp_tools: MCP_TOOL_CATALOG });
const access = await getMcpAccess();
if (access === "off") throw new HttpError(403, "An admin has turned MCP off");
assertMcpToolsOffered(body.mcp_tools, access);
// Tools the access hides keep their tick, so turning MCP back to read and change restores them.
const offered = new Set(mcpToolsFor(access).map((tool) => tool.name));
const hidden = (await getApiToken(req.userId!)).mcp_tools.filter((name) => !offered.has(name));
const token = await setApiTokenMcpTools(req.userId!, [...body.mcp_tools, ...hidden]);
res.json(withMcpCatalog(token, access));
void createLog({
userId: req.userId!,
action: "api_token_mcp_tools_changed",
@@ -440,7 +462,7 @@ router.delete(
requireAuth,
asyncHandler(async (req, res) => {
await revokeApiToken(req.userId!);
res.json({ ...(await getApiToken(req.userId!)), available_mcp_tools: MCP_TOOL_CATALOG });
res.json(withMcpCatalog(await getApiToken(req.userId!), await getMcpAccess()));
void createLog({ userId: req.userId!, action: "api_token_revoked" });
}),
);
+11 -4
View File
@@ -3,13 +3,14 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
import { asyncHandler } from "../utils/asyncHandler";
import { findApiTokenUser, isApiToken } from "../services/apiTokenService";
import { buildMcpServer } from "../mcp/server";
import { MCP_TOOL_NAMES } from "../mcp/tools";
import { mcpToolsFor } from "../mcp/tools";
import { getMcpAccess, getPublicUrl } from "../services/settingsService";
// The MCP endpoint (Streamable HTTP, stateless): an assistant connects with the user's API token.
//
// Not behind requireAuth, which refuses an API token anything but GET: every MCP message is a POST.
// The read-only promise is kept by the tools instead. A token only ever reaches the ones its user
// ticked, and a tool that writes is off until they do. Mounted before every router that applies
// ticked and the instance's MCP access offers, and a tool that writes is off until they do. Mounted before every router that applies
// requireAuth to all its paths, or those would turn the POST away first.
const router = Router();
@@ -36,8 +37,14 @@ router.post(
jsonRpcError(res, 401, "Send your Thingport API token as 'Authorization: Bearer tp_...'");
return;
}
const allowed = new Set(user.apiTokenMcpTools.filter((name) => MCP_TOOL_NAMES.includes(name)));
const server = buildMcpServer({ userId: user.id }, allowed);
const access = await getMcpAccess();
if (access === "off") {
jsonRpcError(res, 403, "An admin has turned MCP off on this Thingport");
return;
}
const offered = new Set(mcpToolsFor(access).map((tool) => tool.name));
const allowed = new Set(user.apiTokenMcpTools.filter((name) => offered.has(name)));
const server = buildMcpServer({ userId: user.id, publicUrl: await getPublicUrl() }, allowed);
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
res.on("close", () => {
void transport.close();
+24
View File
@@ -36,6 +36,9 @@ import {
setAiDescriptionPrefs,
type AiCategorizationPrefs,
type AiDescriptionPrefs,
getMcpAccess,
setMcpAccess,
MCP_ACCESS_LEVELS,
} from "../services/settingsService";
import { getDatabaseInfo, testAndSwitchDatabase } from "../services/databaseSettingsService";
import {
@@ -321,6 +324,27 @@ router.patch(
}),
);
// What API tokens may do at /api/mcp. The profile reads it with the token (GET /profile/api-token).
router.get(
"/settings/mcp",
requireAdmin,
asyncHandler(async (_req, res) => {
res.json({ access: await getMcpAccess() });
}),
);
const mcpAccessSchema = z.object({ access: z.enum(MCP_ACCESS_LEVELS as [string, ...string[]]) });
router.post(
"/settings/mcp",
requireAdmin,
asyncHandler(async (req, res) => {
const { access } = parseBody(mcpAccessSchema, req.body) as { access: (typeof MCP_ACCESS_LEVELS)[number] };
await setMcpAccess(access);
res.json({ access });
void createLog({ userId: req.userId!, action: "mcp_access_changed", details: { access } });
}),
);
router.get(
"/settings/previews",
asyncHandler(async (_req, res) => {
+1
View File
@@ -9,6 +9,7 @@ export type LogAction =
| "api_token_generated"
| "api_token_revoked"
| "api_token_mcp_tools_changed"
| "mcp_access_changed"
| "user_invited"
| "authors_linked"
| "descriptions_refetched"
+3 -3
View File
@@ -106,13 +106,13 @@ export async function addModelsToCollection(
);
const unique = [...new Set(printIds)];
const added = unique.filter((id) => owned.has(id) && !present.has(id));
await addPrintsToCollection(collection.id, [...owned]);
if (owned.size) {
await addPrintsToCollection(collection.id, added);
if (added.length) {
void createLog({
userId,
action: "collection_item_added",
targetId: collection.id,
details: { name: collection.name, count: owned.size },
details: { name: collection.name, count: added.length },
});
}
return {
+23
View File
@@ -85,6 +85,29 @@ export async function setPreviewMode(value: PreviewMode): Promise<void> {
});
}
// What API tokens may do at /api/mcp, instance-wide. "read" also keeps a write tool off for a token
// whose user ticked it, without forgetting the choice.
export type McpAccess = "off" | "read" | "write";
export const MCP_ACCESS_LEVELS: readonly McpAccess[] = ["off", "read", "write"];
const MCP_ACCESS_KEY = "mcp_access";
const DEFAULT_MCP_ACCESS: McpAccess = "write";
export async function getMcpAccess(): Promise<McpAccess> {
const row = await prisma.setting.findUnique({ where: { key: MCP_ACCESS_KEY } });
const value = row?.value;
return typeof value === "string" && MCP_ACCESS_LEVELS.includes(value as McpAccess)
? (value as McpAccess)
: DEFAULT_MCP_ACCESS;
}
export async function setMcpAccess(value: McpAccess): Promise<void> {
await prisma.setting.upsert({
where: { key: MCP_ACCESS_KEY },
create: { key: MCP_ACCESS_KEY, value },
update: { value },
});
}
const THINGIVERSE_ACCESS_TOKEN_KEY = "thingiverse_access_token";
// Instance-wide: the token belongs to whichever Thingiverse account registered the app.
+6 -1
View File
@@ -1,3 +1,4 @@
import { prisma } from "../db";
import { toPrintOut } from "../dto";
import { HttpError } from "../utils/fileUtils";
import { normalizeImportUrl } from "../utils/urlUtils";
@@ -25,7 +26,7 @@ export async function withStoredMakerworldCookie<T extends { makerworld_cookie?:
}
/** The checks before an import starts: the stored MakerWorld cookie, a normalized public URL (never an
* internal address) and a provider an admin hasn't turned off. */
* internal address), a provider an admin hasn't turned off, and a category of the user's own. */
export async function prepareSingleImport<T extends SingleImportBody>(
userId: string,
body: T,
@@ -33,6 +34,10 @@ export async function prepareSingleImport<T extends SingleImportBody>(
const withCookie = await withStoredMakerworldCookie(userId, body);
const url = await normalizeImportUrl(withCookie.url);
await assertCanImportUrl(userId, url);
if (withCookie.category_id) {
const owned = await prisma.category.count({ where: { id: withCookie.category_id, userId } });
if (!owned) throw new HttpError(404, "Category not found");
}
return { url, body: withCookie };
}
+2 -1
View File
@@ -28,8 +28,9 @@ describe("API token", () => {
it("reports no token for a new account", async () => {
const res = await request(app).get("/api/profile/api-token").set(auth(session));
expect(res.status).toBe(200);
const { available_mcp_tools, ...token } = res.body;
const { available_mcp_tools, mcp_access, ...token } = res.body;
expect(token).toEqual({ configured: false, hint: null, created_at: null, last_used_at: null, mcp_tools: [] });
expect(mcp_access).toBe("write");
// What the profile page offers to tick, token or not.
expect(available_mcp_tools.map((tool: { name: string }) => tool.name)).toContain("search_models");
});
+106 -1
View File
@@ -1,11 +1,12 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { beforeAll, describe, expect, it } from "vitest";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import request from "supertest";
import { createApp } from "../src/app";
import { prisma } from "../src/db";
import { DEFAULT_MCP_TOOL_NAMES, MCP_TOOLS, mcpImportTiming } from "../src/mcp/tools";
import { setMcpAccess } from "../src/services/settingsService";
// /api/mcp end to end: who gets in, which tools each token sees, and that every tool only ever
// reads the token owner's own library.
@@ -421,6 +422,12 @@ describe("the tools that write", () => {
});
expect(second.data.added).toEqual([]);
expect(second.data.already_in).toEqual([own]);
// Logged once, for the one model actually added.
await new Promise((resolve) => setTimeout(resolve, 50));
const logs = await prisma.log.findMany({
where: { userId: writer.userId, action: "collection_item_added", targetId: collection.id },
});
expect(logs.map((log) => (log.details as { count: number }).count)).toEqual([1]);
});
it("add_to_collection won't touch another user's collection or a built-in one", async () => {
@@ -513,8 +520,106 @@ describe("the tools that write", () => {
}
});
it("import_model refuses another user's category before importing anything", async () => {
const bobCategory = await prisma.category.findFirstOrThrow({ where: { userId: bob.userId } });
mockFile();
try {
const before = await prisma.print.count({ where: { userId: writer.userId } });
const result = await callTool(writer.token, "import_model", { url: FILE_URL, category_id: bobCategory.id });
expect(result.isError).toBe(true);
expect(result.text).toMatch(/category not found/i);
expect(await prisma.print.count({ where: { userId: writer.userId } })).toBe(before);
} finally {
global.fetch = originalFetch;
}
});
it("get_import_status won't read an import the web app started", async () => {
const restUrl = "https://93.184.215.14/files/mcp-rest-widget.stl";
global.fetch = (async (input: RequestInfo | URL) => {
if (String(input) !== restUrl) throw new Error(`Unexpected fetch to ${String(input)}`);
return new Response("solid rest\nendsolid rest\n", { headers: { "content-type": "application/octet-stream" } });
}) as typeof fetch;
try {
const started = await request(app).post("/api/import?async=1").set(auth(writer.session)).send({ url: restUrl });
expect(started.status).toBe(202);
await new Promise((resolve) => setTimeout(resolve, 300));
const result = await callTool(writer.token, "get_import_status", { task_id: started.body.task_id });
expect(result.isError).toBe(true);
expect(result.text).toMatch(/import_model/);
} finally {
global.fetch = originalFetch;
}
});
it("get_import_status says when it doesn't know the import", async () => {
const unknown = await callTool(writer.token, "get_import_status", { task_id: "no-such-task" });
expect(unknown.isError).toBe(true);
});
});
describe("the instance's MCP access", () => {
const WRITE_TOOLS = ["import_model", "create_collection", "add_to_collection"];
let writer: Account;
beforeAll(async () => {
const user = await register("access");
writer = {
...user,
token: await apiToken(user.session, { mcp_tools: [...DEFAULT_MCP_TOOL_NAMES, ...WRITE_TOOLS] }),
};
});
// Instance-wide: put back the default whatever a test leaves behind.
afterEach(() => setMcpAccess("write"));
it("can be changed by an admin only", async () => {
const res = await request(app).post("/api/settings/mcp").set(auth(writer.session)).send({ access: "off" });
expect(res.status).toBe(403);
});
it("turned off, shuts the endpoint and the token's tool choice", async () => {
await setMcpAccess("off");
const res = await mcp(writer.token, "tools/list");
expect(res.status).toBe(403);
expect(res.body.error.message).toMatch(/turned MCP off/i);
const profile = await request(app).get("/api/profile/api-token").set(auth(writer.session));
expect(profile.body.mcp_access).toBe("off");
expect(profile.body.available_mcp_tools).toEqual([]);
const put = await request(app)
.put("/api/profile/api-token/mcp-tools")
.set(auth(writer.session))
.send({ mcp_tools: DEFAULT_MCP_TOOL_NAMES });
expect(put.status).toBe(403);
});
it("read only, hides the write tools without forgetting them", async () => {
await setMcpAccess("read");
expect(await toolNames(writer.token)).toEqual([...DEFAULT_MCP_TOOL_NAMES].toSorted());
const call = await mcp(writer.token, "tools/call", { name: "create_collection", arguments: { name: "Nope" } });
expect(call.body.error ?? (call.body.result?.isError ? call.body.result : null)).toBeTruthy();
const profile = await request(app).get("/api/profile/api-token").set(auth(writer.session));
expect(profile.body.available_mcp_tools.every((tool: { read_only: boolean }) => tool.read_only)).toBe(true);
const refused = await request(app)
.put("/api/profile/api-token/mcp-tools")
.set(auth(writer.session))
.send({ mcp_tools: ["import_model"] });
expect(refused.status).toBe(403);
// Unticking a read tool keeps the hidden write ticks, so they come back with read and change.
const kept = await request(app)
.put("/api/profile/api-token/mcp-tools")
.set(auth(writer.session))
.send({ mcp_tools: DEFAULT_MCP_TOOL_NAMES.filter((name) => name !== "library_stats") });
expect(kept.status).toBe(200);
expect(kept.body.mcp_tools).toEqual(expect.arrayContaining(WRITE_TOOLS));
await setMcpAccess("write");
const names = await toolNames(writer.token);
for (const tool of WRITE_TOOLS) expect(names).toContain(tool);
expect(names).not.toContain("library_stats");
});
});
+1 -1
View File
@@ -41,7 +41,7 @@ browser history and proxy logs.
## Connect an AI assistant
The same token connects an AI assistant to your library over MCP, with the tools you tick on your Profile. The tools that
The same token connects an AI assistant to your library over MCP, with the tools you allow in **Token permissions** (the cog next to the token). The tools that
read are on for a new token; the ones that change your library, such as importing a model, stay off until you turn them
on. See [Connect an AI assistant with MCP](mcp.md).
+24 -5
View File
@@ -8,16 +8,17 @@ file them into collections: "import this Printables link into my Benchies collec
It's built into the backend, so there's nothing extra to install or run. The endpoint is `/api/mcp` on the same address
you open Thingport at, and the assistant signs in with your [API token](api-token.md).
## 1. Generate a token and pick its tools
## 1. Generate a token and choose its tools
1. Open the user menu (your avatar, top right) and choose **Profile**.
2. Next to **API token**, click **Generate**, and copy the token straight away: it's shown only once.
3. Under **MCP tools**, tick or untick what the assistant may use. Changes apply at once, to the token you already
have.
3. Click the cog next to the token to open **Token permissions**, and under **MCP** set each tool to **Allow** or
**Deny**. Changes apply at once, to the token you already have.
A new token gets every tool that only reads. The tools that change your library are marked **Changes your library**,
stay off until you turn them on, and ask you to confirm when you do. The token still can't change anything over the
REST API, and an MCP tool is only reachable if it's ticked here.
REST API, and an MCP tool is only reachable if it's allowed here. Regenerating the token sets its tools back to the ones
that only read.
## 2. Add Thingport to your assistant
@@ -71,7 +72,25 @@ view, so it doesn't change your Browsing History or your most viewed models.
If your instance has a public address set (`PUBLIC_URL`), models come back with a link to their page in Thingport, so
the assistant can hand you one.
## What an admin decides
On the admin **Auth** page, **AI assistants (MCP)** 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
again if the admin goes back to **Read and change**.
- **Off**: `/api/mcp` turns every token away.
Since an MCP import doesn't ask for a captcha, an admin who requires one for imports may want **Read only**.
## A word on what the assistant reads
Model descriptions come from the sites they were imported from, and the assistant reads them. A description written to
look like instructions could talk it into calling another tool you gave it. The worst it can do is import models or
file them into collections, since no tool deletes anything, but keep the tools that change the library off unless you
use them.
## Turn it off
- **Untick tools** on your Profile to take them away from the assistant without replacing the token.
- **Deny tools** in **Token permissions** to take them away from the assistant without replacing the token.
- **Revoke** the token to cut the assistant off altogether. Anything else using the same token loses access too.
+1
View File
@@ -60,6 +60,7 @@ export type LogAction =
| "api_token_generated"
| "api_token_revoked"
| "api_token_mcp_tools_changed"
| "mcp_access_changed"
| "user_invited"
| "authors_linked"
| "descriptions_refetched"
+4 -1
View File
@@ -1,3 +1,4 @@
import type { McpAccess } from "./settings";
import { authHeaders } from "../utils/auth";
import { apiBase, assertOk, readErrorMessage, EmailNotVerifiedError, UnauthorizedError } from "./client";
import type { CaptchaAnswer } from "./captcha";
@@ -24,8 +25,10 @@ export type ApiTokenInfo = {
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. */
/** Every tool the instance's MCP access offers, to choose from. */
available_mcp_tools: McpToolInfo[];
/** What an admin lets API tokens do over MCP. */
mcp_access: McpAccess;
};
export type GeneratedApiToken = ApiTokenInfo & { token: string };
+21
View File
@@ -3,6 +3,9 @@ import { apiBase, assertOk, readErrorMessage, UnauthorizedError } from "./client
import type { AccentColor, RenderColor, ThemeSelection } from "../constants/settingsOptions";
import { invalidateAiCategorizationStatus } from "./aiCategorization";
/** What API tokens may do at /api/mcp, instance-wide. */
export type McpAccess = "off" | "read" | "write";
export type StorageToken =
"category-or-folder" | "collection" | "tags" | "creator" | "model" | "filename" | "id" | "plate";
@@ -265,6 +268,24 @@ export const settingsApi = {
return (await res.json()).move;
},
getMcp: async (): Promise<{ access: McpAccess }> => {
const res = await fetch(`${apiBase()}/settings/mcp`, { headers: authHeaders() });
if (res.status === 401) throw new UnauthorizedError();
assertOk(res, "Failed to load MCP settings");
return res.json();
},
updateMcp: async (access: McpAccess): Promise<{ access: McpAccess }> => {
const res = await fetch(`${apiBase()}/settings/mcp`, {
method: "POST",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ access }),
});
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to update MCP settings"));
return res.json();
},
getPreviews: async (): Promise<{ mode: PreviewMode }> => {
const res = await fetch(`${apiBase()}/settings/previews`, { headers: authHeaders() });
assertOk(res, "Failed to load preview settings");
@@ -113,6 +113,9 @@ export const SEARCH_PAGES: SearchPage[] = [
"registrations",
"session length",
"public url",
"mcp",
"ai assistant",
"api token",
]),
adminPage("adminRendering", "/admin-rendering", "app:adminSettings.rendering.heading", [
"rendering",
@@ -0,0 +1,326 @@
import type React from "react";
import { keyframes } from "@emotion/react";
import Box from "@mui/material/Box";
import type { SxProps, Theme } from "@mui/material/styles";
// A small picture of what each MCP tool does, sized to a permission row. Still until its row is hovered
// or focused (a `.permission-row` ancestor), so a list of them doesn't all move at once; every
// animation's 0% is its resting pose.
const SIZE = 40;
const wander = keyframes`
0%, 100% { transform: translate(0, 0); }
25% { transform: translate(6px, 1px); }
50% { transform: translate(3px, 7px); }
75% { transform: translate(-3px, 3px); }
`;
const scroll = keyframes`
from { transform: translateY(0); }
to { transform: translateY(-7px); }
`;
const tilt = keyframes`
0%, 100% { transform: perspective(60px) rotateY(0deg); }
50% { transform: perspective(60px) rotateY(-28deg); }
`;
const fanLeft = keyframes`
0%, 100% { transform: rotate(-4deg); }
50% { transform: rotate(-16deg) translateX(-3px); }
`;
const fanRight = keyframes`
0%, 100% { transform: rotate(4deg); }
50% { transform: rotate(16deg) translateX(3px); }
`;
const rise = keyframes`
0%, 100% { transform: translateY(3px); }
50% { transform: translateY(-4px); }
`;
const swing = keyframes`
0%, 100% { transform: rotate(0deg); }
30% { transform: rotate(-12deg); }
70% { transform: rotate(10deg); }
`;
const blink = keyframes`
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
`;
const bob = keyframes`
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-3px); }
`;
const grow = keyframes`
0%, 100% { transform: scaleY(1); }
50% { transform: scaleY(0.4); }
`;
const spin = keyframes`
to { transform: rotate(360deg); }
`;
const drop = keyframes`
0% { transform: translateY(0); opacity: 1; }
60% { transform: translateY(8px); opacity: 1; }
61% { transform: translateY(-4px); opacity: 0; }
100% { transform: translateY(0); opacity: 1; }
`;
const pulse = keyframes`
0%, 100% { transform: scale(1); }
50% { transform: scale(1.3); }
`;
const fileIn = keyframes`
0% { transform: translateY(0); opacity: 1; }
65% { transform: translateY(13px); opacity: 1; }
66% { transform: translateY(13px); opacity: 0; }
67% { transform: translateY(-4px); opacity: 0; }
100% { transform: translateY(0); opacity: 1; }
`;
type Accent = "primary" | "warning";
function at(x: number, y: number, w: number, h: number): SxProps<Theme> {
return { position: "absolute", left: x, top: y, width: w, height: h };
}
function run(frames: ReturnType<typeof keyframes>, seconds: number, extra = "") {
return `${frames} ${seconds}s ease-in-out infinite ${extra}`.trim();
}
/** A folder: its back and tab, then `children` (what's inside), then the front flap over them. */
function Folder({ accent, children }: { accent: Accent; children?: React.ReactNode }) {
return (
<>
<Box sx={{ ...at(8, 12, 10, 5), bgcolor: `${accent}.main`, borderRadius: "2px 2px 0 0" }} />
<Box sx={{ ...at(8, 15, 24, 16), bgcolor: `${accent}.main`, borderRadius: "2px" }} />
{children}
<Box sx={{ ...at(8, 21, 24, 10), bgcolor: `${accent}.dark`, borderRadius: "0 0 2px 2px" }} />
</>
);
}
/** A model card: a picture area and a line of text. */
function Card({ sx }: { sx: SxProps<Theme> }) {
return (
<Box
sx={{
bgcolor: "background.paper",
border: 1.5,
borderColor: "text.disabled",
borderRadius: "3px",
overflow: "hidden",
...sx,
}}
>
<Box sx={{ height: "55%", bgcolor: "primary.main", opacity: 0.35 }} />
<Box sx={{ mx: "15%", mt: "12%", height: 2, borderRadius: 1, bgcolor: "text.disabled" }} />
</Box>
);
}
const pictures: Record<string, () => React.ReactNode> = {
search_models: () => (
<>
{[0, 1, 2, 3].map((i) => (
<Box
key={i}
sx={{
...at(9 + (i % 2) * 12, 9 + Math.floor(i / 2) * 12, 9, 9),
borderRadius: "2px",
bgcolor: "text.disabled",
opacity: 0.35,
}}
/>
))}
<Box sx={{ ...at(9, 9, 20, 20), animation: run(wander, 2.6) }}>
<Box sx={{ ...at(0, 0, 14, 14), border: 2.5, borderColor: "primary.main", borderRadius: "50%" }} />
<Box
sx={{
...at(11, 13, 8, 3),
bgcolor: "primary.main",
borderRadius: 1,
transform: "rotate(45deg)",
}}
/>
</Box>
</>
),
list_models: () => (
<Box sx={{ ...at(9, 8, 22, 25), overflow: "hidden" }}>
<Box sx={{ animation: `${scroll} 1.4s linear infinite` }}>
{[0, 1, 2, 3, 4].map((i) => (
<Box key={i} sx={{ display: "flex", gap: "3px", mb: "3px", height: 4 }}>
<Box sx={{ width: 4, bgcolor: "primary.main", borderRadius: "1px" }} />
<Box sx={{ flex: 1, bgcolor: "text.disabled", opacity: 0.5, borderRadius: 1 }} />
</Box>
))}
</Box>
</Box>
),
get_model: () => <Card sx={{ ...at(10, 7, 20, 26), animation: run(tilt, 2.4) }} />,
list_collections: () => (
<>
<Card sx={{ ...at(12, 9, 16, 22), transformOrigin: "50% 100%", animation: run(fanLeft, 2.2) }} />
<Card sx={{ ...at(12, 9, 16, 22), transformOrigin: "50% 100%", animation: run(fanRight, 2.2) }} />
<Card sx={{ ...at(12, 9, 16, 22), borderColor: "primary.main" }} />
</>
),
get_collection: () => (
<Folder accent="primary">
<Card sx={{ ...at(13, 5, 14, 15), animation: run(rise, 2) }} />
</Folder>
),
list_tags: () => (
<>
{[
{ x: 13, y: 9, opacity: 0.45, delay: "0.3s" },
{ x: 9, y: 17, opacity: 1, delay: "0s" },
].map((tag) => (
<Box
key={tag.y}
sx={{
...at(tag.x, tag.y, 20, 12),
bgcolor: "primary.main",
opacity: tag.opacity,
clipPath: "polygon(30% 0, 100% 0, 100% 100%, 30% 100%, 0 50%)",
transformOrigin: "0 50%",
animation: run(swing, 2.4, tag.delay),
}}
>
<Box sx={{ ...at(4, 4.5, 3, 3), borderRadius: "50%", bgcolor: "background.paper" }} />
</Box>
))}
</>
),
list_categories: () => (
<>
<Box sx={{ ...at(8, 8, 10, 6), bgcolor: "primary.main", borderRadius: "2px" }} />
<Box sx={{ ...at(11, 14, 2, 15), bgcolor: "text.disabled" }} />
{[18, 27].map((y, i) => (
<Box key={y}>
<Box sx={{ ...at(11, y + 2, 8, 2), bgcolor: "text.disabled" }} />
<Box
sx={{
...at(19, y, 13, 6),
bgcolor: "primary.main",
borderRadius: "2px",
animation: run(blink, 1.6, `${i * 0.4}s`),
}}
/>
</Box>
))}
</>
),
get_author: () => (
<Box sx={{ ...at(0, 0, SIZE, SIZE), animation: run(bob, 1.6) }}>
<Box sx={{ ...at(15, 8, 10, 10), borderRadius: "50%", bgcolor: "primary.main" }} />
<Box sx={{ ...at(10, 20, 20, 12), borderRadius: "10px 10px 2px 2px", bgcolor: "primary.main" }} />
</Box>
),
library_stats: () => (
<>
{[10, 18, 13, 22].map((height, i) => (
<Box
key={height}
sx={{
...at(9 + i * 6, 31 - height, 4, height),
bgcolor: "primary.main",
borderRadius: "1px 1px 0 0",
transformOrigin: "bottom",
animation: run(grow, 1.8, `${i * 0.15}s`),
}}
/>
))}
<Box sx={{ ...at(7, 31, 26, 2), bgcolor: "text.disabled", borderRadius: 1 }} />
</>
),
get_import_status: () => (
<>
<Box
sx={{
...at(10, 10, 20, 20),
borderRadius: "50%",
border: 3,
borderColor: "divider",
borderTopColor: "primary.main",
borderRightColor: "primary.main",
animation: `${spin} 1.2s linear infinite`,
}}
/>
<Box sx={{ ...at(18, 18, 4, 4), borderRadius: "50%", bgcolor: "primary.main" }} />
</>
),
import_model: () => (
<>
<Box sx={{ ...at(13, 6, 14, 18), animation: run(drop, 1.8) }}>
<Box sx={{ ...at(5.5, 0, 3, 10), bgcolor: "warning.main" }} />
<Box sx={{ ...at(1, 9, 12, 7), bgcolor: "warning.main", clipPath: "polygon(0 0, 100% 0, 50% 100%)" }} />
</Box>
<Box
sx={{
...at(9, 24, 22, 9),
border: 2.5,
borderTop: 0,
borderColor: "text.disabled",
borderRadius: "0 0 4px 4px",
}}
/>
</>
),
create_collection: () => (
<Folder accent="warning">
<Box sx={{ ...at(14, 18, 12, 12), zIndex: 1, animation: run(pulse, 1.6) }}>
<Box sx={{ ...at(4.5, 0, 3, 12), bgcolor: "background.paper", borderRadius: 1 }} />
<Box sx={{ ...at(0, 4.5, 12, 3), bgcolor: "background.paper", borderRadius: 1 }} />
</Box>
</Folder>
),
add_to_collection: () => (
<Folder accent="warning">
<Card sx={{ ...at(14, 3, 12, 13), animation: run(fileIn, 2) }} />
</Folder>
),
};
/** A plug, for a tool the frontend doesn't have a picture of yet. */
function Fallback() {
return (
<>
<Box sx={{ ...at(13, 12, 14, 12), bgcolor: "primary.main", borderRadius: "3px 3px 6px 6px" }} />
<Box sx={{ ...at(15, 7, 3, 6), bgcolor: "text.disabled" }} />
<Box sx={{ ...at(22, 7, 3, 6), bgcolor: "text.disabled" }} />
<Box sx={{ ...at(18.5, 24, 3, 9), bgcolor: "text.disabled" }} />
</>
);
}
type Props = {
tool: string;
/** Off: greyed out, as the tool can't be called. */
allowed: boolean;
};
export default function McpToolTile({ tool, allowed }: Props) {
const picture = pictures[tool];
return (
<Box
aria-hidden
sx={{
display: { xs: "none", sm: "block" },
position: "relative",
flexShrink: 0,
width: SIZE,
height: SIZE,
borderRadius: "10px",
bgcolor: "action.hover",
border: 1,
borderColor: "divider",
overflow: "hidden",
opacity: allowed ? 1 : 0.45,
filter: allowed ? "none" : "grayscale(1)",
transition: "opacity 200ms, filter 200ms",
"& *": { animationPlayState: "paused" },
".permission-row:hover & *, .permission-row:focus-within & *": { animationPlayState: "running" },
"@media (prefers-reduced-motion: reduce)": { transition: "none", "& *": { animation: "none !important" } },
}}
>
{picture ? picture() : <Fallback />}
</Box>
);
}
+31 -4
View File
@@ -197,7 +197,7 @@
"regenerate": "Regenerate",
"revoke": "Revoke",
"regenerateTitle": "Regenerate API token?",
"regenerateMessage": "The current token stops working at once. Anything using it will need the new one.",
"regenerateMessage": "The current token stops working at once. Anything using it will need the new one, and its MCP tools go back to the ones that only read.",
"revokeTitle": "Revoke API token?",
"revokeMessage": "The token stops working at once, and anything using it loses access to your library.",
"copyNow": "Copy your token now. It won't be shown again.",
@@ -205,7 +205,7 @@
"copied": "Copied",
"failed": "Something went wrong with your API token. Please try again.",
"mcp": {
"heading": "MCP tools",
"heading": "MCP",
"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",
@@ -266,7 +266,16 @@
},
"writeConfirmTitle": "Let the assistant use “{{tool}}”?",
"writeConfirmMessage": "This tool changes your library. An assistant connected with this token will be able to use it without asking you each time. Nothing it does deletes models.",
"writeConfirm": "Turn on"
"writeConfirm": "Turn on",
"adminOff": "An admin has turned MCP off on this Thingport.",
"adminReadOnly": "An admin allows only the tools that read.",
"copyCommand": "Copy command",
"reads": "Reads your library",
"allow": "Allow",
"deny": "Deny"
},
"permissions": {
"title": "Token permissions"
}
},
"language": {
@@ -1318,7 +1327,8 @@
"grab_connected": "Thingport Grab connected",
"grab_disconnected": "Thingport Grab removed",
"collections_merged": "Collections merged",
"tags_merged": "Tags merged"
"tags_merged": "Tags merged",
"mcp_access_changed": "Changed the MCP access"
},
"authorsLinked": "{{count}} model(s) linked",
"descriptionsRefetched": "{{email}} — {{updated}} of {{total}} description(s) changed",
@@ -1536,6 +1546,23 @@
"saving": "Saving…",
"saved": "Saved.",
"failed": "Couldn't save the public URL."
},
"mcp": {
"heading": "AI assistants (MCP)",
"subtitle": "What API tokens may do at /api/mcp.",
"accessLabel": "MCP access",
"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": {
"off": "Off",
"read": "Read only",
"write": "Read and change"
},
"levelHelp": {
"off": "No API token can connect over MCP.",
"read": "Tokens can search and read their own library, never change it.",
"write": "Tokens can also import models and file them into collections, if their user turns that on."
}
}
},
"download": {
+95 -3
View File
@@ -197,13 +197,86 @@
"regenerate": "Regenerar",
"revoke": "Revocar",
"regenerateTitle": "¿Regenerar el token de API?",
"regenerateMessage": "El token actual dejará de funcionar de inmediato. Cualquier cosa que lo use necesitará el nuevo.",
"regenerateMessage": "El token actual deja de funcionar al instante. Todo lo que lo use necesitará el nuevo, y sus herramientas MCP vuelven a ser solo las de lectura.",
"revokeTitle": "¿Revocar el token de API?",
"revokeMessage": "El token dejará de funcionar de inmediato y todo lo que lo use perderá el acceso a tu biblioteca.",
"copyNow": "Copia tu token ahora. No se volverá a mostrar.",
"copy": "Copiar",
"copied": "Copiado",
"failed": "Algo ha salido mal con tu token de API. Inténtalo de nuevo."
"failed": "Algo ha salido mal con tu token de API. Inténtalo de nuevo.",
"mcp": {
"adminOff": "Un administrador ha desactivado MCP en este Thingport.",
"adminReadOnly": "Un administrador solo permite las herramientas de lectura.",
"copyCommand": "Copiar comando",
"heading": "MCP",
"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.",
"tools": {
"search_models": {
"title": "Buscar modelos",
"description": "Encuentra modelos por palabras de su título, etiquetas, creador o descripción."
},
"list_models": {
"title": "Listar modelos",
"description": "Lista modelos por etiqueta, categoría, colección o autor."
},
"get_model": {
"title": "Ver un modelo",
"description": "Lee la descripción, los archivos, las etiquetas, el autor y las colecciones de un modelo."
},
"list_collections": {
"title": "Listar colecciones",
"description": "Ve tus colecciones y cuántos modelos tiene cada una."
},
"get_collection": {
"title": "Ver una colección",
"description": "Lista los modelos de una colección."
},
"list_tags": {
"title": "Listar etiquetas",
"description": "Ve tus etiquetas, las más usadas primero."
},
"list_categories": {
"title": "Listar categorías",
"description": "Ve tus categorías y carpetas."
},
"get_author": {
"title": "Ver un autor",
"description": "Lee sobre un diseñador y ve sus modelos en tu biblioteca."
},
"library_stats": {
"title": "Estadísticas de la biblioteca",
"description": "Tus totales y los modelos más vistos, más impresos y más recientes."
},
"get_import_status": {
"title": "Seguir una importación",
"description": "Sigue una importación que aún está en curso."
},
"import_model": {
"title": "Importar un modelo",
"description": "Importa un modelo desde un enlace, como + Añadir > Importar."
},
"create_collection": {
"title": "Crear una colección",
"description": "Crea una colección nueva y vacía."
},
"add_to_collection": {
"title": "Añadir a una colección",
"description": "Pon modelos de tu biblioteca en una de tus colecciones."
}
},
"writeConfirmTitle": "¿Dejar que el asistente use «{{tool}}»?",
"writeConfirmMessage": "Esta herramienta cambia tu biblioteca. Un asistente conectado con este token podrá usarla sin preguntarte cada vez. Nada de lo que haga elimina modelos.",
"writeConfirm": "Activar",
"reads": "Lee tu biblioteca",
"allow": "Permitir",
"deny": "Denegar"
},
"permissions": {
"title": "Permisos del token"
}
},
"language": {
"heading": "Idioma",
@@ -1253,7 +1326,9 @@
"grab_connected": "Thingport Grab conectado",
"grab_disconnected": "Thingport Grab eliminado",
"collections_merged": "Colecciones fusionadas",
"tags_merged": "Etiquetas fusionadas"
"tags_merged": "Etiquetas fusionadas",
"mcp_access_changed": "Cambió el acceso MCP",
"api_token_mcp_tools_changed": "Cambió las herramientas MCP de su token de API"
},
"authorsLinked": "{{count}} modelo(s) vinculado(s)",
"descriptionsRefetched": "{{email}} — {{updated}} de {{total}} descripción(es) modificada(s)",
@@ -1471,6 +1546,23 @@
"saving": "Guardando…",
"saved": "Guardado.",
"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",
"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": {
"off": "Desactivado",
"read": "Solo lectura",
"write": "Leer y cambiar"
},
"levelHelp": {
"off": "Ningún token de API puede conectarse por MCP.",
"read": "Los tokens pueden buscar y leer su propia biblioteca, nunca cambiarla.",
"write": "Los tokens también pueden importar modelos y guardarlos en colecciones, si su usuario lo activa."
}
}
},
"download": {
+95 -3
View File
@@ -197,13 +197,86 @@
"regenerate": "Régénérer",
"revoke": "Révoquer",
"regenerateTitle": "Régénérer le jeton d’API ?",
"regenerateMessage": "Le jeton actuel cessera de fonctionner immédiatement. Toute application qui l’utilise devra utiliser le nouveau.",
"regenerateMessage": "Le jeton actuel cesse de fonctionner aussitôt. Tout ce qui l'utilise aura besoin du nouveau, et ses outils MCP reviennent aux seuls outils de lecture.",
"revokeTitle": "Révoquer le jeton d’API ?",
"revokeMessage": "Le jeton cessera de fonctionner immédiatement, et toute application qui l’utilise perdra l’accès à votre bibliothèque.",
"copyNow": "Copiez votre jeton maintenant. Il ne sera plus affiché.",
"copy": "Copier",
"copied": "Copié",
"failed": "Une erreur s’est produite avec votre jeton d’API. Veuillez réessayer."
"failed": "Une erreur s’est produite avec votre jeton d’API. Veuillez réessayer.",
"mcp": {
"adminOff": "Un administrateur a désactivé MCP sur ce Thingport.",
"adminReadOnly": "Un administrateur n'autorise que les outils de lecture.",
"copyCommand": "Copier la commande",
"heading": "MCP",
"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.",
"tools": {
"search_models": {
"title": "Rechercher des modèles",
"description": "Trouve des modèles par les mots de leur titre, tags, créateur ou description."
},
"list_models": {
"title": "Lister les modèles",
"description": "Liste les modèles par tag, catégorie, collection ou auteur."
},
"get_model": {
"title": "Voir un modèle",
"description": "Lit la description, les fichiers, les tags, l'auteur et les collections d'un modèle."
},
"list_collections": {
"title": "Lister les collections",
"description": "Voit vos collections et le nombre de modèles de chacune."
},
"get_collection": {
"title": "Voir une collection",
"description": "Liste les modèles d'une collection."
},
"list_tags": {
"title": "Lister les tags",
"description": "Voit vos tags, les plus utilisés d'abord."
},
"list_categories": {
"title": "Lister les catégories",
"description": "Voit vos catégories et dossiers."
},
"get_author": {
"title": "Voir un auteur",
"description": "Lit la présentation d'un designer et voit ses modèles dans votre bibliothèque."
},
"library_stats": {
"title": "Statistiques de la bibliothèque",
"description": "Vos totaux et vos modèles les plus vus, les plus imprimés et les plus récents."
},
"get_import_status": {
"title": "Suivre un import",
"description": "Suit un import encore en cours."
},
"import_model": {
"title": "Importer un modèle",
"description": "Importe un modèle depuis un lien, comme + Ajouter > Importer."
},
"create_collection": {
"title": "Créer une collection",
"description": "Crée une nouvelle collection vide."
},
"add_to_collection": {
"title": "Ajouter à une collection",
"description": "Range des modèles de votre bibliothèque dans une de vos collections."
}
},
"writeConfirmTitle": "Laisser l'assistant utiliser « {{tool}} » ?",
"writeConfirmMessage": "Cet outil modifie votre bibliothèque. Un assistant connecté avec ce jeton pourra l'utiliser sans vous demander à chaque fois. Rien de ce qu'il fait ne supprime de modèles.",
"writeConfirm": "Activer",
"reads": "Lit votre bibliothèque",
"allow": "Autoriser",
"deny": "Refuser"
},
"permissions": {
"title": "Autorisations du jeton"
}
},
"language": {
"heading": "Langue",
@@ -1253,7 +1326,9 @@
"grab_connected": "Thingport Grab connecté",
"grab_disconnected": "Thingport Grab retiré",
"collections_merged": "Collections fusionnées",
"tags_merged": "Étiquettes fusionnées"
"tags_merged": "Étiquettes fusionnées",
"mcp_access_changed": "A modifié l'accès MCP",
"api_token_mcp_tools_changed": "A modifié les outils MCP de son jeton d'API"
},
"authorsLinked": "{{count}} modèle(s) relié(s)",
"descriptionsRefetched": "{{email}} — {{updated}} description(s) sur {{total}} modifiée(s)",
@@ -1471,6 +1546,23 @@
"saving": "Enregistrement…",
"saved": "Enregistré.",
"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",
"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": {
"off": "Désactivé",
"read": "Lecture seule",
"write": "Lire et modifier"
},
"levelHelp": {
"off": "Aucun jeton d'API ne peut se connecter via MCP.",
"read": "Les jetons peuvent chercher et lire leur propre bibliothèque, jamais la modifier.",
"write": "Les jetons peuvent aussi importer des modèles et les ranger dans des collections, si leur utilisateur l'active."
}
}
},
"download": {
+31 -4
View File
@@ -198,7 +198,7 @@
"regenerate": "Generuoti iš naujo",
"revoke": "Atšaukti",
"regenerateTitle": "Generuoti API raktą iš naujo?",
"regenerateMessage": "Dabartinis raktas iškart nustos veikti. Viskam, kas jį naudoja, reikės naujo rakto.",
"regenerateMessage": "Dabartinis raktas iškart nustos veikti. Viskam, kas jį naudoja, reikės naujo, o jo MCP įrankiai vėl bus tik skaitantys.",
"revokeTitle": "Atšaukti API raktą?",
"revokeMessage": "Raktas iškart nustos veikti, ir viskas, kas jį naudoja, praras prieigą prie jūsų bibliotekos.",
"copyNow": "Nukopijuokite raktą dabar. Jis daugiau nebus rodomas.",
@@ -206,7 +206,7 @@
"copied": "Nukopijuota",
"failed": "Kažkas nepavyko su jūsų API raktu. Bandykite dar kartą.",
"mcp": {
"heading": "MCP įrankiai",
"heading": "MCP",
"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ą",
@@ -267,7 +267,16 @@
},
"writeConfirmTitle": "Leisti asistentui naudoti „{{tool}}“?",
"writeConfirmMessage": "Šis įrankis keičia jūsų biblioteką. Su šiuo raktu prijungtas asistentas galės jį naudoti kiekvieną kartą jūsų neklausdamas. Jis niekada netrina modelių.",
"writeConfirm": "Įjungti"
"writeConfirm": "Įjungti",
"adminOff": "Administratorius šiame Thingport išjungė MCP.",
"adminReadOnly": "Administratorius leidžia tik skaitančius įrankius.",
"copyCommand": "Kopijuoti komandą",
"reads": "Skaito biblioteką",
"allow": "Leisti",
"deny": "Drausti"
},
"permissions": {
"title": "Rakto leidimai"
}
},
"language": {
@@ -1347,7 +1356,8 @@
"grab_connected": "Prijungtas Thingport Grab",
"grab_disconnected": "Pašalintas Thingport Grab",
"collections_merged": "Kolekcijos sujungtos",
"tags_merged": "Žymos sujungtos"
"tags_merged": "Žymos sujungtos",
"mcp_access_changed": "Pakeitė MCP prieigą"
},
"authorsLinked": "Susieta modelių: {{count}}",
"descriptionsRefetched": "{{email}} — pakeista aprašymų: {{updated}} iš {{total}}",
@@ -1577,6 +1587,23 @@
"saving": "Saugoma…",
"saved": "Išsaugota.",
"failed": "Nepavyko išsaugoti viešo adreso."
},
"mcp": {
"heading": "DI asistentai (MCP)",
"subtitle": "Ką API raktai gali daryti per /api/mcp.",
"accessLabel": "MCP prieiga",
"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": {
"off": "Išjungta",
"read": "Tik skaityti",
"write": "Skaityti ir keisti"
},
"levelHelp": {
"off": "Joks API raktas negali prisijungti per MCP.",
"read": "Raktai gali ieškoti ir skaityti savo biblioteką, bet niekada jos nekeisti.",
"write": "Raktai taip pat gali importuoti modelius ir dėti juos į kolekcijas, jei naudotojas tai įjungia."
}
}
},
"download": {
@@ -1,6 +1,7 @@
import React from "react";
import Divider from "@mui/material/Divider";
import Stack from "@mui/material/Stack";
import McpSection from "./McpSection";
import PublicUrlSection from "./PublicUrlSection";
import RegistrationsSection from "./RegistrationsSection";
import SessionSection from "./SessionSection";
@@ -10,7 +11,8 @@ type Props = {
onUnauthorized?: () => void;
};
/** Where Thingport lives, who can join, how people sign in, and how long they stay signed in. */
/** Where Thingport lives, who can join, how people sign in, how long they stay signed in, and what API tokens may do
* over MCP. */
export default function AuthPage({ onUnauthorized }: Props) {
// Sign-in methods depend on the public URL (redirect URIs, whether a provider can be added): reloaded with it.
const [publicUrlVersion, setPublicUrlVersion] = React.useState(0);
@@ -20,6 +22,7 @@ export default function AuthPage({ onUnauthorized }: Props) {
<RegistrationsSection onUnauthorized={onUnauthorized} />
<SignInMethodsSection key={publicUrlVersion} onUnauthorized={onUnauthorized} />
<SessionSection onUnauthorized={onUnauthorized} />
<McpSection onUnauthorized={onUnauthorized} />
</Stack>
);
}
@@ -0,0 +1,87 @@
import React from "react";
import { useTranslation } from "react-i18next";
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 { 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";
type Props = {
onUnauthorized?: () => void;
};
const LEVELS: McpAccess[] = ["off", "read", "write"];
/** What API tokens may do at /api/mcp. Each user still picks their own token's tools within it. */
export default function McpSection({ onUnauthorized }: Props) {
const { t } = useTranslation("app");
const toast = useToast();
const [access, setAccess] = React.useState<McpAccess>("write");
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
React.useEffect(() => {
let active = true;
void (async () => {
try {
const data = await settingsApi.getMcp();
if (active) setAccess(data.access);
} catch (err) {
if (err instanceof UnauthorizedError) onUnauthorized?.();
} finally {
if (active) setLoading(false);
}
})();
return () => {
active = false;
};
}, [onUnauthorized]);
const select = async (next: McpAccess) => {
if (next === access || saving) return;
const previous = access;
setAccess(next);
setSaving(true);
try {
setAccess((await settingsApi.updateMcp(next)).access);
} catch (err) {
setAccess(previous);
if (err instanceof UnauthorizedError) onUnauthorized?.();
else toast({ message: t("adminSettings.mcp.failed"), severity: "error" });
} finally {
setSaving(false);
}
};
return (
<Stack spacing={3}>
<SectionHeader title={t("adminSettings.mcp.heading")} subtitle={t("adminSettings.mcp.subtitle")} />
<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>
<Typography variant="caption" color="text.secondary">
{t(`adminSettings.mcp.levelHelp.${access}`)}
</Typography>
</Stack>
</Paper>
</Stack>
);
}
+1
View File
@@ -36,6 +36,7 @@ const ACTION_COLORS: Record<LogAction, ActionColor> = {
api_token_revoked: "default",
// Can turn on a tool that writes.
api_token_mcp_tools_changed: "warning",
mcp_access_changed: "warning",
user_invited: "info",
authors_linked: "success",
descriptions_refetched: "warning",
@@ -1,111 +0,0 @@
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";
import { useConfirm } from "../../components/ConfirmProvider";
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 confirm = useConfirm();
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 (tool: McpToolInfo, on: boolean) => {
const name = tool.name;
// Reading needs no second thought; letting an assistant change the library does.
if (on && !tool.read_only) {
const ok = await confirm({
title: t("profile.apiToken.mcp.writeConfirmTitle", { tool: label(tool, "title") }),
message: t("profile.apiToken.mcp.writeConfirmMessage"),
confirmLabel: t("profile.apiToken.mcp.writeConfirm"),
});
if (!ok) return;
}
const next = info.available_mcp_tools
.map((available) => available.name)
.filter((other) => (other === name ? on : enabled.has(other)));
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, 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>
);
}
@@ -0,0 +1,36 @@
import { useTranslation } from "react-i18next";
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 Stack from "@mui/material/Stack";
import type { ApiTokenInfo } from "../../api/auth";
import McpPermissionsSection from "./McpPermissionsSection";
type Props = {
open: boolean;
onClose: () => void;
info: ApiTokenInfo;
onChange: (info: ApiTokenInfo) => void;
onUnauthorized?: () => void;
};
/** What the API token may do, a section per way of using it. Only MCP for now; the REST API's
* permissions are meant to join it here. */
export default function ApiTokenPermissionsDialog({ open, onClose, info, onChange, onUnauthorized }: Props) {
const { t } = useTranslation(["app", "common"]);
return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="sm">
<DialogTitle>{t("profile.apiToken.permissions.title")}</DialogTitle>
<DialogContent>
<Stack spacing={3}>
<McpPermissionsSection info={info} onChange={onChange} onUnauthorized={onUnauthorized} />
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>{t("common:close")}</Button>
</DialogActions>
</Dialog>
);
}
@@ -11,11 +11,12 @@ import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import CheckIcon from "@mui/icons-material/Check";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
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";
import ApiTokenPermissionsDialog from "./ApiTokenPermissionsDialog";
/** Same origin as the app: nginx (or Vite in dev) passes /api through to the backend. */
function mcpEndpoint(): string {
@@ -33,10 +34,12 @@ export default function ApiTokenSection({ onUnauthorized }: Props) {
const confirm = useConfirm();
const [info, setInfo] = React.useState<ApiTokenInfo | null>(null);
const [newToken, setNewToken] = React.useState<string | null>(null);
const [copied, setCopied] = React.useState(false);
const [copied, setCopied] = React.useState<"token" | "command" | null>(null);
const [busy, setBusy] = React.useState(false);
const [permissionsOpen, setPermissionsOpen] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const tokenInput = React.useRef<HTMLInputElement>(null);
const commandBox = React.useRef<HTMLPreElement>(null);
const handleError = React.useCallback(
(err: unknown) => {
@@ -73,7 +76,7 @@ export default function ApiTokenSection({ onUnauthorized }: Props) {
const { token, ...rest } = await authApi.generateApiToken();
setInfo(rest);
setNewToken(token);
setCopied(false);
setCopied(null);
} catch (err) {
handleError(err);
} finally {
@@ -107,12 +110,25 @@ export default function ApiTokenSection({ onUnauthorized }: Props) {
if (!newToken) return;
try {
await navigator.clipboard.writeText(newToken);
setCopied(true);
setCopied("token");
} catch {
tokenInput.current?.select();
}
};
const mcpCommand = newToken
? `claude mcp add --transport http thingport ${mcpEndpoint()} \\\n --header "Authorization: Bearer ${newToken}"`
: "";
const copyCommand = async () => {
try {
await navigator.clipboard.writeText(mcpCommand);
setCopied("command");
} catch {
// As with the token: selected, to copy by hand.
if (commandBox.current) window.getSelection()?.selectAllChildren(commandBox.current);
}
};
const details = (() => {
if (!info?.configured) return t("profile.apiToken.description");
const parts = [`tp_••••${info.hint ?? ""}`];
@@ -145,7 +161,18 @@ export default function ApiTokenSection({ onUnauthorized }: Props) {
</Typography>
</Box>
{info && (
<Stack direction="row" gap={0.5} flexShrink={0}>
<Stack direction="row" gap={0.5} flexShrink={0} alignItems="center">
{info.configured && (
<Tooltip title={t("profile.apiToken.permissions.title")}>
<IconButton
size="small"
onClick={() => setPermissionsOpen(true)}
aria-label={t("profile.apiToken.permissions.title")}
>
<SettingsOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
{info.configured && (
<Button variant="text" color="error" disabled={busy} onClick={revoke}>
{t("profile.apiToken.revoke")}
@@ -173,9 +200,9 @@ export default function ApiTokenSection({ onUnauthorized }: Props) {
input: {
endAdornment: (
<InputAdornment position="end">
<Tooltip title={copied ? t("profile.apiToken.copied") : t("profile.apiToken.copy")}>
<Tooltip title={copied === "token" ? t("profile.apiToken.copied") : t("profile.apiToken.copy")}>
<IconButton size="small" edge="end" onClick={copy} aria-label={t("profile.apiToken.copy")}>
{copied ? <CheckIcon fontSize="small" /> : <ContentCopyIcon fontSize="small" />}
{copied === "token" ? <CheckIcon fontSize="small" /> : <ContentCopyIcon fontSize="small" />}
</IconButton>
</Tooltip>
</InputAdornment>
@@ -183,18 +210,53 @@ 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>
{info?.mcp_access !== "off" && (
<>
<Typography variant="body2">{t("profile.apiToken.mcp.connect")}</Typography>
<Box sx={{ position: "relative" }}>
<Box
component="pre"
ref={commandBox}
sx={{
m: 0,
p: 1,
pr: 5,
borderRadius: 1,
bgcolor: "action.hover",
fontSize: "0.75rem",
overflowX: "auto",
}}
>
{mcpCommand}
</Box>
<Tooltip
title={copied === "command" ? t("profile.apiToken.copied") : t("profile.apiToken.mcp.copyCommand")}
>
<IconButton
size="small"
onClick={() => void copyCommand()}
aria-label={t("profile.apiToken.mcp.copyCommand")}
sx={{ position: "absolute", top: 4, right: 4 }}
>
{copied === "command" ? <CheckIcon fontSize="small" /> : <ContentCopyIcon fontSize="small" />}
</IconButton>
</Tooltip>
</Box>
</>
)}
</Stack>
</Alert>
)}
{info?.configured && <ApiTokenMcpTools info={info} onChange={setInfo} onUnauthorized={onUnauthorized} />}
{info?.configured && (
<ApiTokenPermissionsDialog
open={permissionsOpen}
onClose={() => setPermissionsOpen(false)}
info={info}
onChange={setInfo}
onUnauthorized={onUnauthorized}
/>
)}
{error && (
<Alert severity="error" onClose={() => setError(null)}>
@@ -0,0 +1,135 @@
import React from "react";
import { useTranslation } from "react-i18next";
import Box from "@mui/material/Box";
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";
import { useConfirm } from "../../components/ConfirmProvider";
import HelpTip from "../../components/HelpTip";
import Segmented from "../../components/controls/Segmented";
import McpToolTile from "../../components/permissionAnimations/McpToolTile";
import { useToast } from "../../components/ToastProvider";
type Props = {
info: ApiTokenInfo;
onChange: (info: ApiTokenInfo) => void;
onUnauthorized?: () => void;
};
/** Token permissions' MCP section: which tools the token may call at /api/mcp. Each choice saves on its own, so there's no
* unsaved state to lose; a refused save puts it back. */
export default function McpPermissionsSection({ info, onChange, onUnauthorized }: Props) {
const { t, i18n } = useTranslation(["app", "common"]);
const confirm = useConfirm();
const toast = useToast();
const [saving, setSaving] = 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 (tool: McpToolInfo, on: boolean) => {
const name = tool.name;
// Reading needs no second thought; letting an assistant change the library does.
if (on && !tool.read_only) {
const ok = await confirm({
title: t("profile.apiToken.mcp.writeConfirmTitle", { tool: label(tool, "title") }),
message: t("profile.apiToken.mcp.writeConfirmMessage"),
confirmLabel: t("profile.apiToken.mcp.writeConfirm"),
});
if (!ok) return;
}
// Only the offered tools: the server keeps the ticks of any an admin has hidden.
const next = info.available_mcp_tools
.map((available) => available.name)
.filter((other) => (other === name ? on : enabled.has(other)));
setSelected(next);
setSaving(name);
try {
onChange(await authApi.setApiTokenMcpTools(next));
} catch (err) {
setSelected(info.mcp_tools);
if (err instanceof UnauthorizedError) onUnauthorized?.();
else toast({ message: t("profile.apiToken.mcp.failed"), severity: "error" });
} finally {
setSaving(null);
}
};
const groups = [
{ key: "reads", tools: info.available_mcp_tools.filter((tool) => tool.read_only) },
{ key: "writes", tools: info.available_mcp_tools.filter((tool) => !tool.read_only) },
].filter((group) => group.tools.length);
return (
<Stack component="section" aria-labelledby="token-permissions-mcp" spacing={1}>
<Typography id="token-permissions-mcp" variant="subtitle1" component="h3" fontWeight={600}>
{t("profile.apiToken.mcp.heading")}
<HelpTip text={t("profile.apiToken.mcp.description")} />
</Typography>
{info.mcp_access === "off" ? (
<Typography variant="body2" color="text.secondary">
{t("profile.apiToken.mcp.adminOff")}
</Typography>
) : (
<Stack spacing={2}>
{groups.map((group) => (
<Stack key={group.key}>
<Typography variant="overline" color="text.secondary" sx={{ lineHeight: 2 }}>
{group.key === "reads" ? t("profile.apiToken.mcp.reads") : t("profile.apiToken.mcp.writes")}
</Typography>
{group.tools.map((tool) => {
const value = enabled.has(tool.name) ? "allow" : "deny";
return (
<Box
key={tool.name}
className="permission-row"
sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
py: 0.75,
"& + &": { borderTop: 1, borderColor: "divider" },
}}
>
<McpToolTile tool={tool.name} allowed={value === "allow"} />
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="body2">{label(tool, "title")}</Typography>
<Typography variant="caption" color="text.secondary" component="div">
{label(tool, "description")}
</Typography>
</Box>
<Box sx={{ flexShrink: 0 }}>
<Segmented
label={label(tool, "title")}
value={value}
disabled={saving !== null}
onChange={(next) => next !== value && void toggle(tool, next === "allow")}
options={[
{ value: "deny", label: t("profile.apiToken.mcp.deny") },
{ value: "allow", label: t("profile.apiToken.mcp.allow") },
]}
/>
</Box>
</Box>
);
})}
</Stack>
))}
{info.mcp_access === "read" && (
<Typography variant="caption" color="text.secondary">
{t("profile.apiToken.mcp.adminReadOnly")}
</Typography>
)}
</Stack>
)}
</Stack>
);
}