mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 22:22:24 -05:00
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:
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;
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user