feat(backend): MCP tools that import models and file them into collections

import_model imports a link through the same checks, log and
notification as + Add > Import, optionally into a collection, and hands
an import still running after 25 seconds over to get_import_status.
create_collection and add_to_collection make and fill collections. None
deletes or overwrites anything.

They're off for every token until its user turns them on. import_model
skips the captcha, as Thingport Grab does: an assistant has nowhere to
show one, and the user chose to give it the tool.
This commit is contained in:
Rui Valim committed 2026-10-20 14:54:41 -03:00
1 parent 278688e64c
commit a36a99639a
4 files changed
+329 -5

No files matched your search

@@ -5,6 +5,6 @@ ALTER TABLE "User" ADD COLUMN "apiTokenMcpTools" TEXT[] DEFAULT ARRAY[]::TEXT[];
UPDATE "User"
SET "apiTokenMcpTools" = ARRAY[
'search_models', 'list_models', 'get_model', 'list_collections', 'get_collection',
'list_tags', 'list_categories', 'get_author', 'library_stats'
'list_tags', 'list_categories', 'get_author', 'library_stats', 'get_import_status'
]
WHERE "apiTokenHash" IS NOT NULL;
+7 -1
View File
@@ -35,7 +35,13 @@ export function buildMcpServer(ctx: McpToolContext, allowedTools: ReadonlySet<st
title: tool.title,
description: tool.description,
inputSchema: tool.inputSchema,
annotations: { readOnlyHint: tool.readOnly, openWorldHint: false },
annotations: {
readOnlyHint: tool.readOnly,
// Nothing here deletes or overwrites.
destructiveHint: false,
idempotentHint: tool.idempotent ?? tool.readOnly,
openWorldHint: tool.openWorld ?? false,
},
},
async (args: Record<string, unknown>) => {
try {
+148 -2
View File
@@ -8,13 +8,16 @@ import { getDashboardSummary } from "../services/dashboardService";
import { printOutById } from "../services/printLoader";
import { listPrints, listTagsOf, PRINT_SORT_MODES, SELF_AUTHOR_ID } 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";
// The tools /api/mcp offers. Each runs as the token's user, through the same services as the REST
// API, so it sees exactly that user's library. Outputs are trimmed to what an assistant can use:
// no image or file URLs, which need a session to open anyway.
//
// `readOnly` is what lets an API token reach a tool at all over POST (see routes/mcp.ts). A tool that
// writes must say so, and is off for every token until its user ticks it.
// 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 };
@@ -23,6 +26,10 @@ export type McpToolDefinition = {
title: string;
description: string;
readOnly: boolean;
/** Calling it again with the same arguments changes nothing more. */
idempotent?: boolean;
/** Reaches outside this Thingport, e.g. a provider's site. */
openWorld?: boolean;
inputSchema: z.ZodRawShape;
run: (ctx: McpToolContext, args: Record<string, unknown>) => Promise<unknown>;
};
@@ -334,6 +341,141 @@ const libraryStats: McpToolDefinition = {
},
};
// An import that takes longer than this (a big zip, a slow provider) carries on in the background, and the
// assistant follows it with get_import_status rather than holding the call open.
// An object so tests can shorten the wait.
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) {
if (state.status === "running") {
return {
status: "running",
task_id: taskId,
note: "Still importing. Call get_import_status with this task_id in a little while.",
};
}
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 };
return {
status: result.model.import_outcome,
model: modelSummary(result.model),
collection: result.collection,
};
}
const importModel: McpToolDefinition = {
name: "import_model",
title: "Import a model",
description:
"Import a model into the library from its page on a supported site (MakerWorld, Printables, Thingiverse and " +
"the others Thingport supports), as + Add > Import does. A model already in the library isn't imported twice. " +
"Usually finishes in the call; a long import returns a task_id to check with get_import_status.",
readOnly: false,
idempotent: true,
openWorld: true,
inputSchema: {
url: z.string().min(1).describe("The model's page, e.g. https://www.printables.com/model/3161-3d-benchy"),
title: z.string().max(500).optional().describe("A title to use instead of the site's."),
tags: z.array(z.string()).optional().describe("Tags to use instead of the site's."),
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) {
const collectionId = args.collection_id as string | undefined;
// Checked first, so a bad collection doesn't leave an import half done.
if (collectionId) {
if (isSystemCollectionId(collectionId)) throw new HttpError(400, "Models can't be added to that collection");
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.
const { url, body } = await prepareSingleImport(userId, {
url: args.url as string,
title: (args.title as string | undefined) ?? null,
tags: (args.tags as string[] | undefined) ?? [],
category_id: (args.category_id as string | undefined) ?? null,
});
const taskId = startImportTask(userId, async () => {
const model = (await importAndDescribe(userId, url, body)) as ImportedModel;
const filed = collectionId ? (await addModelsToCollection(userId, collectionId, [model.id])).collection : null;
return { model, collection: filed ? { id: filed.id, name: filed.name } : null };
});
const deadline = Date.now() + mcpImportTiming.waitMs;
let state = getImportTask(taskId, userId)!;
while (state.status === "running" && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, mcpImportTiming.pollMs));
state = getImportTask(taskId, userId)!;
}
return importStateOut(taskId, state);
},
};
const getImportStatus: McpToolDefinition = {
name: "get_import_status",
title: "Check an import",
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) {
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);
},
};
const createCollectionTool: McpToolDefinition = {
name: "create_collection",
title: "Create a collection",
description: "Create a new, empty collection. Fill it with add_to_collection.",
readOnly: false,
inputSchema: {
name: z.string().trim().min(1).max(200).describe("The collection's name; it must not already be taken."),
description: z.string().trim().max(2000).optional(),
tags: z.array(z.string()).optional(),
},
async run({ userId }, args) {
const collection = await createCollection(userId, {
name: args.name as string,
description: (args.description as string | undefined) ?? null,
tags: args.tags as string[] | undefined,
});
return { id: collection.id, name: collection.name, description: collection.description, tags: collection.tags };
},
};
const addToCollection: McpToolDefinition = {
name: "add_to_collection",
title: "Add models to a collection",
description:
"Add models from the library to one of the user's collections. Models already in it stay as they are. " +
"Nothing is ever removed.",
readOnly: false,
idempotent: true,
inputSchema: {
collection_id: z.string().min(1).describe("The collection, an id from list_collections."),
model_ids: z
.array(z.string().min(1))
.min(1)
.max(100)
.describe("The models, ids from search_models or list_models."),
},
async run({ userId }, args) {
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 },
added: result.added,
already_in: result.alreadyIn,
not_found: result.notFound,
};
},
};
export const MCP_TOOLS: readonly McpToolDefinition[] = [
searchModels,
listModels,
@@ -344,6 +486,10 @@ export const MCP_TOOLS: readonly McpToolDefinition[] = [
listCategories,
getAuthor,
libraryStats,
getImportStatus,
importModel,
createCollectionTool,
addToCollection,
];
export const MCP_TOOL_NAMES = MCP_TOOLS.map((tool) => tool.name);
+173 -1
View File
@@ -5,7 +5,7 @@ import { 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 } from "../src/mcp/tools";
import { DEFAULT_MCP_TOOL_NAMES, MCP_TOOLS, mcpImportTiming } from "../src/mcp/tools";
// /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.
@@ -334,3 +334,175 @@ describe("the tools", () => {
expect(result.isError).toBe(true);
});
});
describe("the tools that write", () => {
const WRITE_TOOLS = ["import_model", "create_collection", "add_to_collection"];
// A public IP literal passes the SSRF check without a DNS lookup; fetch is mocked below.
const FILE_URL = "https://93.184.215.14/files/mcp-imported-widget.stl";
let writer: Account;
const originalFetch = global.fetch;
beforeAll(async () => {
const user = await register("writer");
writer = {
...user,
token: await apiToken(user.session, { mcp_tools: [...DEFAULT_MCP_TOOL_NAMES, ...WRITE_TOOLS] }),
};
});
function mockFile(contents = "solid widget\nendsolid widget\n") {
global.fetch = (async (input: RequestInfo | URL) => {
if (String(input) === FILE_URL) {
return new Response(contents, { headers: { "content-type": "application/octet-stream" } });
}
throw new Error(`Unexpected fetch to ${String(input)}`);
}) as typeof fetch;
}
it("are off for a new token, and marked as writing", async () => {
const names = await toolNames(alice.token);
for (const tool of WRITE_TOOLS) expect(names).not.toContain(tool);
const listed = (await mcp(writer.token, "tools/list")).body.result.tools as {
name: string;
annotations: { readOnlyHint: boolean; destructiveHint: boolean };
}[];
for (const tool of listed.filter((t) => WRITE_TOOLS.includes(t.name))) {
expect(tool.annotations.readOnlyHint).toBe(false);
expect(tool.annotations.destructiveHint).toBe(false);
}
});
it("refuses a write tool to a token that didn't turn it on", async () => {
const before = await prisma.collection.count({ where: { userId: alice.userId } });
const res = await mcp(alice.token, "tools/call", { name: "create_collection", arguments: { name: "Nope" } });
expect(res.body.error ?? (res.body.result?.isError ? res.body.result : null)).toBeTruthy();
expect(await prisma.collection.count({ where: { userId: alice.userId } })).toBe(before);
});
it("create_collection makes an empty collection, and refuses a name already taken", async () => {
const created = await callTool(writer.token, "create_collection", { name: "From the assistant", tags: ["ai"] });
expect(created.data.name).toBe("From the assistant");
const row = await prisma.collection.findUniqueOrThrow({ where: { id: created.data.id } });
expect(row.userId).toBe(writer.userId);
const again = await callTool(writer.token, "create_collection", { name: "from the ASSISTANT" });
expect(again.isError).toBe(true);
expect(again.text).toMatch(/already/i);
});
it("add_to_collection files the user's models and reports the rest", async () => {
const own = await upload(writer.session, "own-model.stl");
const { data: collection } = await callTool(writer.token, "create_collection", { name: "Filing test" });
const first = await callTool(writer.token, "add_to_collection", {
collection_id: collection.id,
model_ids: [own, bobSecretId, "no-such-model"],
});
expect(first.data.added).toEqual([own]);
// Another user's model reads as not found, never as added.
expect(first.data.not_found.toSorted()).toEqual([bobSecretId, "no-such-model"].toSorted());
expect(await prisma.collectionItem.count({ where: { printId: bobSecretId, collectionId: collection.id } })).toBe(0);
const second = await callTool(writer.token, "add_to_collection", {
collection_id: collection.id,
model_ids: [own],
});
expect(second.data.added).toEqual([]);
expect(second.data.already_in).toEqual([own]);
});
it("add_to_collection won't touch another user's collection or a built-in one", async () => {
const own = await upload(writer.session, "other-check.stl");
const theirs = await callTool(writer.token, "add_to_collection", {
collection_id: bobCollectionId,
model_ids: [own],
});
expect(theirs.isError).toBe(true);
expect(await prisma.collectionItem.count({ where: { collectionId: bobCollectionId } })).toBe(0);
const builtIn = await callTool(writer.token, "add_to_collection", { collection_id: "favorites", model_ids: [own] });
expect(builtIn.isError).toBe(true);
});
it("import_model imports a link into the library and the chosen collection, once", async () => {
mockFile();
try {
const { data: collection } = await callTool(writer.token, "create_collection", { name: "Imported here" });
const first = await callTool(writer.token, "import_model", {
url: FILE_URL,
title: "Widget from MCP",
collection_id: collection.id,
});
expect(first.isError).toBe(false);
expect(first.data.status).toBe("created");
expect(first.data.model.title).toBe("Widget from MCP");
expect(first.data.collection).toEqual({ id: collection.id, name: "Imported here" });
const print = await prisma.print.findUniqueOrThrow({ where: { id: first.data.model.id } });
expect(print.userId).toBe(writer.userId);
expect(await prisma.collectionItem.count({ where: { collectionId: collection.id, printId: print.id } })).toBe(1);
// The same trail as + Add > Import.
expect(
await prisma.log.count({ where: { userId: writer.userId, action: "model_imported", targetId: print.id } }),
).toBe(1);
} finally {
global.fetch = originalFetch;
}
});
it("import_model refuses an internal address", async () => {
const result = await callTool(writer.token, "import_model", { url: "http://127.0.0.1:8000/api/health" });
expect(result.isError).toBe(true);
expect(result.text).toMatch(/local addresses/i);
});
it("import_model checks the collection before importing anything", async () => {
mockFile();
try {
const before = await prisma.print.count({ where: { userId: writer.userId } });
const result = await callTool(writer.token, "import_model", { url: FILE_URL, collection_id: bobCollectionId });
expect(result.isError).toBe(true);
expect(await prisma.print.count({ where: { userId: writer.userId } })).toBe(before);
} finally {
global.fetch = originalFetch;
}
});
it("hands a slow import over to get_import_status, which only its user can read", async () => {
const slowUrl = "https://93.184.215.14/files/mcp-slow-widget.stl";
let release!: () => void;
const gate = new Promise<void>((resolve) => (release = resolve));
global.fetch = (async (input: RequestInfo | URL) => {
if (String(input) !== slowUrl) throw new Error(`Unexpected fetch to ${String(input)}`);
await gate;
return new Response("solid slow\nendsolid slow\n", { headers: { "content-type": "application/octet-stream" } });
}) as typeof fetch;
const saved = { ...mcpImportTiming };
mcpImportTiming.waitMs = 50;
mcpImportTiming.pollMs = 10;
try {
const started = await callTool(writer.token, "import_model", { url: slowUrl });
expect(started.data.status).toBe("running");
const taskId = started.data.task_id as string;
// Someone else's token can't follow it, even with the id.
const peek = await callTool(alice.token, "get_import_status", { task_id: taskId });
expect(peek.isError).toBe(true);
release();
let state = started.data;
for (let i = 0; i < 100 && state.status === "running"; i++) {
await new Promise((resolve) => setTimeout(resolve, 50));
state = (await callTool(writer.token, "get_import_status", { task_id: taskId })).data;
}
expect(state.status).toBe("created");
expect(state.model.id).toBeTruthy();
} finally {
Object.assign(mcpImportTiming, saved);
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);
});
});