fix(mcp): rank list_tags by use and let it narrow, instead of every tag at once

A real library of 488 models has 4,306 tags: the whole list came to 63 KB,
more of an assistant's context than one answer should take. It now
returns the most used 200 by default, with how many models have each,
and takes a query to narrow them.
This commit is contained in:
Rui Valim committed 2026-10-20 15:00:20 -03:00
1 parent 7a31ed48e2
commit 04e97197d7
5 files changed
+42 -11

No files matched your search

+24 -5
View File
@@ -6,7 +6,7 @@ import { HttpError } from "../utils/fileUtils";
import { SYSTEM_COLLECTIONS, systemCollectionKeyForId } from "../services/collectionService";
import { getDashboardSummary } from "../services/dashboardService";
import { printOutById } from "../services/printLoader";
import { listPrints, listTagsOf, PRINT_SORT_MODES, SELF_AUTHOR_ID } from "../services/printQuery";
import { listPrints, 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";
@@ -235,11 +235,30 @@ const getCollection: McpToolDefinition = {
const listTags: McpToolDefinition = {
name: "list_tags",
title: "List tags",
description: "Every tag in the library, so models can then be listed by the exact tag names it uses.",
description:
"The library's tags, most used first, with how many models have each, so models can then be listed by the exact " +
"tag names it uses. A big library has thousands: narrow them with query.",
readOnly: true,
inputSchema: {},
async run({ userId }) {
return { tags: await listTagsOf(userId, {}) };
inputSchema: {
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) {
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.
const counts = new Map<string, { tag: string; model_count: number }>();
for (const row of rows) {
for (const tag of new Set(row.tags.map((t) => t.trim()).filter(Boolean))) {
const key = tag.toLowerCase();
if (query && !key.includes(query)) continue;
const entry = counts.get(key);
if (entry) entry.model_count++;
else counts.set(key, { tag, model_count: 1 });
}
}
const all = [...counts.values()].toSorted((a, b) => b.model_count - a.model_count || a.tag.localeCompare(b.tag));
return { tags: all.slice(0, (args.limit as number | undefined) ?? 200), total: all.length };
},
};
+15 -3
View File
@@ -314,11 +314,23 @@ describe("the tools", () => {
expect(other.isError).toBe(true);
});
it("list_tags lists only the user's tags", async () => {
it("list_tags lists only the user's tags, most used first, and narrows by query", async () => {
const { data } = await callTool(alice.token, "list_tags");
expect(data.tags.map((t: string) => t.toLowerCase())).toEqual(
expect.arrayContaining(["dragon", "flexi", "calibration"]),
const byTag = new Map(
data.tags.map((t: { tag: string; model_count: number }) => [t.tag.toLowerCase(), t.model_count]),
);
expect(byTag.get("dragon")).toBe(1);
expect(byTag.get("calibration")).toBe(1);
expect(data.total).toBe(3);
const narrowed = await callTool(alice.token, "list_tags", { query: "DRAG", limit: 5 });
expect(narrowed.data.tags.map((t: { tag: string }) => t.tag.toLowerCase())).toEqual(["dragon"]);
// Bob's "dragon" model doesn't count towards Alice's tags.
expect(narrowed.data.tags[0].model_count).toBe(1);
const one = await callTool(alice.token, "list_tags", { limit: 1 });
expect(one.data.tags).toHaveLength(1);
expect(one.data.total).toBe(3);
});
it("list_categories and library_stats answer for the user", async () => {
+1 -1
View File
@@ -45,7 +45,7 @@ These read your library, and are on for a new token:
| `get_model` | One model's description, files, tags, category, author, collections and where it was imported from. |
| `list_collections` | Your collections and how many models each holds, plus Favourites and Browsing History. |
| `get_collection` | The models in one collection. |
| `list_tags` | Every tag in your library. |
| `list_tags` | Your tags, most used first, narrowed by a word if you like. |
| `list_categories` | Your category and folder tree. |
| `get_author` | A designer, where to find them, and their models in your library. |
| `library_stats` | Your totals, and your most viewed, most printed and newest models. |
+1 -1
View File
@@ -233,7 +233,7 @@
},
"list_tags": {
"title": "List tags",
"description": "See every tag in your library."
"description": "See your tags, most used first."
},
"list_categories": {
"title": "List categories",
+1 -1
View File
@@ -234,7 +234,7 @@
},
"list_tags": {
"title": "Žymių sąrašas",
"description": "Matyti visas bibliotekos žymes."
"description": "Matyti savo žymes, dažniausiai naudojamas pirmiau."
},
"list_categories": {
"title": "Kategorijų sąrašas",