mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 22:22:24 -05:00
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:
1 parent
12a6375832
commit
dec1b0dbe3
29 files changed
+1257
-209
No files matched your search
File renamed without changes.
+81
-45
@@ -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);
|
||||
@@ -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" });
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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.
|
||||
@@ -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"
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user