From dec1b0dbe3f8566a77bc981017ed29a0bbbc3703 Mon Sep 17 00:00:00 2001 From: Tautvydas Derzinskas Date: Wed, 21 Oct 2026 08:47:49 +0200 Subject: [PATCH] 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. --- .../migration.sql | 0 backend/src/mcp/tools.ts | 126 ++++--- backend/src/routes/auth.ts | 34 +- backend/src/routes/mcp.ts | 15 +- backend/src/routes/settings.ts | 24 ++ backend/src/services/auditLog.ts | 1 + backend/src/services/collectionService.ts | 6 +- backend/src/services/settingsService.ts | 23 ++ backend/src/services/singleImport.ts | 7 +- backend/tests/apiToken.test.ts | 3 +- backend/tests/mcp.test.ts | 107 +++++- docs/guides/api-token.md | 2 +- docs/guides/mcp.md | 29 +- frontend/src/api/admin.ts | 1 + frontend/src/api/auth.ts | 5 +- frontend/src/api/settings.ts | 21 ++ .../components/Layout/GlobalSearch/pages.ts | 3 + .../permissionAnimations/McpToolTile.tsx | 326 ++++++++++++++++++ frontend/src/i18n/locales/en.json | 35 +- frontend/src/i18n/locales/es.json | 98 +++++- frontend/src/i18n/locales/fr.json | 98 +++++- frontend/src/i18n/locales/lt.json | 35 +- .../src/pages/AdminSettingsPage/AuthPage.tsx | 5 +- .../pages/AdminSettingsPage/McpSection.tsx | 87 +++++ frontend/src/pages/LogsPage/index.tsx | 1 + .../pages/ProfilePage/ApiTokenMcpTools.tsx | 111 ------ .../ProfilePage/ApiTokenPermissionsDialog.tsx | 36 ++ .../src/pages/ProfilePage/ApiTokenSection.tsx | 92 ++++- .../ProfilePage/McpPermissionsSection.tsx | 135 ++++++++ 29 files changed, 1257 insertions(+), 209 deletions(-) rename backend/prisma/migrations/{20261025120000_api_token_mcp_tools => 20261030120000_api_token_mcp_tools}/migration.sql (100%) create mode 100644 frontend/src/components/permissionAnimations/McpToolTile.tsx create mode 100644 frontend/src/pages/AdminSettingsPage/McpSection.tsx delete mode 100644 frontend/src/pages/ProfilePage/ApiTokenMcpTools.tsx create mode 100644 frontend/src/pages/ProfilePage/ApiTokenPermissionsDialog.tsx create mode 100644 frontend/src/pages/ProfilePage/McpPermissionsSection.tsx diff --git a/backend/prisma/migrations/20261025120000_api_token_mcp_tools/migration.sql b/backend/prisma/migrations/20261030120000_api_token_mcp_tools/migration.sql similarity index 100% rename from backend/prisma/migrations/20261025120000_api_token_mcp_tools/migration.sql rename to backend/prisma/migrations/20261030120000_api_token_mcp_tools/migration.sql diff --git a/backend/src/mcp/tools.ts b/backend/src/mcp/tools.ts index 6a3cd81..ec9fe80 100644 --- a/backend/src/mcp/tools.ts +++ b/backend/src/mcp/tools.ts @@ -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); diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 767dcd6..aa70cde 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -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(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" }); }), ); diff --git a/backend/src/routes/mcp.ts b/backend/src/routes/mcp.ts index d246c17..268e1e8 100644 --- a/backend/src/routes/mcp.ts +++ b/backend/src/routes/mcp.ts @@ -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(); diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 76c3b5e..c87884d 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -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) => { diff --git a/backend/src/services/auditLog.ts b/backend/src/services/auditLog.ts index af96d31..af7acdd 100644 --- a/backend/src/services/auditLog.ts +++ b/backend/src/services/auditLog.ts @@ -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" diff --git a/backend/src/services/collectionService.ts b/backend/src/services/collectionService.ts index 8a1087b..6a03d4b 100644 --- a/backend/src/services/collectionService.ts +++ b/backend/src/services/collectionService.ts @@ -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 { diff --git a/backend/src/services/settingsService.ts b/backend/src/services/settingsService.ts index 381d30e..4d76b78 100644 --- a/backend/src/services/settingsService.ts +++ b/backend/src/services/settingsService.ts @@ -85,6 +85,29 @@ export async function setPreviewMode(value: PreviewMode): Promise { }); } +// 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 { + 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 { + 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. diff --git a/backend/src/services/singleImport.ts b/backend/src/services/singleImport.ts index 275c5d4..67046c1 100644 --- a/backend/src/services/singleImport.ts +++ b/backend/src/services/singleImport.ts @@ -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( userId: string, body: T, @@ -33,6 +34,10 @@ export async function prepareSingleImport( 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 }; } diff --git a/backend/tests/apiToken.test.ts b/backend/tests/apiToken.test.ts index b0a4cdc..43c9b88 100644 --- a/backend/tests/apiToken.test.ts +++ b/backend/tests/apiToken.test.ts @@ -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"); }); diff --git a/backend/tests/mcp.test.ts b/backend/tests/mcp.test.ts index e2508a9..6d81a06 100644 --- a/backend/tests/mcp.test.ts +++ b/backend/tests/mcp.test.ts @@ -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"); + }); +}); diff --git a/docs/guides/api-token.md b/docs/guides/api-token.md index b6a1282..a90b587 100644 --- a/docs/guides/api-token.md +++ b/docs/guides/api-token.md @@ -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). diff --git a/docs/guides/mcp.md b/docs/guides/mcp.md index f77cf64..f833854 100644 --- a/docs/guides/mcp.md +++ b/docs/guides/mcp.md @@ -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. diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 5509c7b..52c17c4 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -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" diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 624d8f7..970744f 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -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 }; diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts index 0738721..d228d72 100644 --- a/frontend/src/api/settings.ts +++ b/frontend/src/api/settings.ts @@ -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"); diff --git a/frontend/src/components/Layout/GlobalSearch/pages.ts b/frontend/src/components/Layout/GlobalSearch/pages.ts index 8e2f1b3..8fdd05a 100644 --- a/frontend/src/components/Layout/GlobalSearch/pages.ts +++ b/frontend/src/components/Layout/GlobalSearch/pages.ts @@ -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", diff --git a/frontend/src/components/permissionAnimations/McpToolTile.tsx b/frontend/src/components/permissionAnimations/McpToolTile.tsx new file mode 100644 index 0000000..6553985 --- /dev/null +++ b/frontend/src/components/permissionAnimations/McpToolTile.tsx @@ -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 { + return { position: "absolute", left: x, top: y, width: w, height: h }; +} + +function run(frames: ReturnType, 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 ( + <> + + + {children} + + + ); +} + +/** A model card: a picture area and a line of text. */ +function Card({ sx }: { sx: SxProps }) { + return ( + + + + + ); +} + +const pictures: Record React.ReactNode> = { + search_models: () => ( + <> + {[0, 1, 2, 3].map((i) => ( + + ))} + + + + + + ), + list_models: () => ( + + + {[0, 1, 2, 3, 4].map((i) => ( + + + + + ))} + + + ), + get_model: () => , + list_collections: () => ( + <> + + + + + ), + get_collection: () => ( + + + + ), + list_tags: () => ( + <> + {[ + { x: 13, y: 9, opacity: 0.45, delay: "0.3s" }, + { x: 9, y: 17, opacity: 1, delay: "0s" }, + ].map((tag) => ( + + + + ))} + + ), + list_categories: () => ( + <> + + + {[18, 27].map((y, i) => ( + + + + + ))} + + ), + get_author: () => ( + + + + + ), + library_stats: () => ( + <> + {[10, 18, 13, 22].map((height, i) => ( + + ))} + + + ), + get_import_status: () => ( + <> + + + + ), + import_model: () => ( + <> + + + + + + + ), + create_collection: () => ( + + + + + + + ), + add_to_collection: () => ( + + + + ), +}; + +/** A plug, for a tool the frontend doesn't have a picture of yet. */ +function Fallback() { + return ( + <> + + + + + + ); +} + +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 ( + + {picture ? picture() : } + + ); +} diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index e5d7edd..86ea8af 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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": { diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index d7e4dfb..4dd3829 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -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": { diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 2b63bb7..fc01643 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -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": { diff --git a/frontend/src/i18n/locales/lt.json b/frontend/src/i18n/locales/lt.json index 0923b35..6154eaf 100644 --- a/frontend/src/i18n/locales/lt.json +++ b/frontend/src/i18n/locales/lt.json @@ -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": { diff --git a/frontend/src/pages/AdminSettingsPage/AuthPage.tsx b/frontend/src/pages/AdminSettingsPage/AuthPage.tsx index d265fe8..ad8a2d1 100644 --- a/frontend/src/pages/AdminSettingsPage/AuthPage.tsx +++ b/frontend/src/pages/AdminSettingsPage/AuthPage.tsx @@ -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) { + ); } diff --git a/frontend/src/pages/AdminSettingsPage/McpSection.tsx b/frontend/src/pages/AdminSettingsPage/McpSection.tsx new file mode 100644 index 0000000..4c76d4b --- /dev/null +++ b/frontend/src/pages/AdminSettingsPage/McpSection.tsx @@ -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("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 ( + + + + + + {t("adminSettings.mcp.accessLabel")} + + + + void select(next)} + disabled={loading || saving} + options={LEVELS.map((level) => ({ value: level, label: t(`adminSettings.mcp.levels.${level}`) }))} + /> + {saving && } + + + {t(`adminSettings.mcp.levelHelp.${access}`)} + + + + + ); +} diff --git a/frontend/src/pages/LogsPage/index.tsx b/frontend/src/pages/LogsPage/index.tsx index 51e5076..b4b8b50 100644 --- a/frontend/src/pages/LogsPage/index.tsx +++ b/frontend/src/pages/LogsPage/index.tsx @@ -36,6 +36,7 @@ const ACTION_COLORS: Record = { 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", diff --git a/frontend/src/pages/ProfilePage/ApiTokenMcpTools.tsx b/frontend/src/pages/ProfilePage/ApiTokenMcpTools.tsx deleted file mode 100644 index 5e840bf..0000000 --- a/frontend/src/pages/ProfilePage/ApiTokenMcpTools.tsx +++ /dev/null @@ -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(null); - const [error, setError] = React.useState(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(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 ( - - - {t("profile.apiToken.mcp.heading")} - - - {t("profile.apiToken.mcp.description")} - - - {info.available_mcp_tools.map((tool) => ( - void toggle(tool, e.target.checked)} - /> - } - label={ - - - {label(tool, "title")} - {!tool.read_only && ( - - )} - - - {label(tool, "description")} - - - } - /> - ))} - - {error && ( - setError(null)}> - {error} - - )} - - ); -} diff --git a/frontend/src/pages/ProfilePage/ApiTokenPermissionsDialog.tsx b/frontend/src/pages/ProfilePage/ApiTokenPermissionsDialog.tsx new file mode 100644 index 0000000..583c2aa --- /dev/null +++ b/frontend/src/pages/ProfilePage/ApiTokenPermissionsDialog.tsx @@ -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 ( + + {t("profile.apiToken.permissions.title")} + + + + + + + + + + ); +} diff --git a/frontend/src/pages/ProfilePage/ApiTokenSection.tsx b/frontend/src/pages/ProfilePage/ApiTokenSection.tsx index b08bc5e..18d550f 100644 --- a/frontend/src/pages/ProfilePage/ApiTokenSection.tsx +++ b/frontend/src/pages/ProfilePage/ApiTokenSection.tsx @@ -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(null); const [newToken, setNewToken] = React.useState(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(null); const tokenInput = React.useRef(null); + const commandBox = React.useRef(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) { {info && ( - + + {info.configured && ( + + setPermissionsOpen(true)} + aria-label={t("profile.apiToken.permissions.title")} + > + + + + )} {info.configured && (