feat: add MCP tool handlers, definitions, and collection resource

Wrap existing service layer with MCP-compatible tool handlers for items,
categories, threads/candidates, setups, and image fetching. Add collection
summary resource for overview data. All 14 MCP-specific tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-03 13:35:27 +02:00
parent a10156142f
commit 8919829167
7 changed files with 987 additions and 0 deletions

View File

@@ -0,0 +1,41 @@
import type { db as prodDb } from "@/db/index.ts";
import { getAllCategories } from "../../services/category.service.ts";
import { getAllItems } from "../../services/item.service.ts";
import { getAllSetups } from "../../services/setup.service.ts";
import { getAllThreads } from "../../services/thread.service.ts";
import { getGlobalTotals } from "../../services/totals.service.ts";
type Db = typeof prodDb;
export function getCollectionSummary(db: Db) {
const totals = getGlobalTotals(db);
const categories = getAllCategories(db);
const items = getAllItems(db);
const setups = getAllSetups(db);
const activeThreads = getAllThreads(db, false);
// Build items-by-category map
const itemsByCategory: Record<string, number> = {};
for (const item of items) {
const catName = item.categoryName;
itemsByCategory[catName] = (itemsByCategory[catName] ?? 0) + 1;
}
return {
overview: {
totalItems: totals?.itemCount ?? 0,
totalWeightGrams: totals?.totalWeight ?? 0,
totalCostCents: totals?.totalCost ?? 0,
categoryCount: categories.length,
setupCount: setups.length,
activeThreadCount: activeThreads.length,
},
itemsByCategory,
activeThreads: activeThreads.map((t) => ({
id: t.id,
name: t.name,
candidateCount: t.candidateCount,
category: t.categoryName,
})),
};
}

View File

@@ -0,0 +1,72 @@
import type { db as prodDb } from "@/db/index.ts";
import {
createCategory,
getAllCategories,
} from "../../services/category.service.ts";
type Db = typeof prodDb;
interface ToolResult {
content: Array<{ type: "text"; text: string }>;
}
function textResult(data: unknown): ToolResult {
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
}
function errorResult(message: string): ToolResult {
return {
content: [{ type: "text", text: JSON.stringify({ error: message }) }],
};
}
export const categoryToolDefinitions = [
{
name: "list_categories",
description: "List all gear categories.",
inputSchema: {
type: "object" as const,
properties: {},
},
},
{
name: "create_category",
description: "Create a new gear category.",
inputSchema: {
type: "object" as const,
properties: {
name: { type: "string", description: "Category name" },
icon: {
type: "string",
description: "Icon name (defaults to 'package')",
},
},
required: ["name"],
},
},
];
export function registerCategoryTools(db: Db) {
return {
list_categories: async (): Promise<ToolResult> => {
try {
const cats = getAllCategories(db);
return textResult(cats);
} catch (err) {
return errorResult((err as Error).message);
}
},
create_category: async (args: {
name: string;
icon?: string;
}): Promise<ToolResult> => {
try {
const cat = createCategory(db, args);
return textResult(cat);
} catch (err) {
return errorResult((err as Error).message);
}
},
};
}

View File

@@ -0,0 +1,48 @@
import { fetchImageFromUrl } from "../../services/image.service.ts";
interface ToolResult {
content: Array<{ type: "text"; text: string }>;
}
function textResult(data: unknown): ToolResult {
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
}
function errorResult(message: string): ToolResult {
return {
content: [{ type: "text", text: JSON.stringify({ error: message }) }],
};
}
export const imageToolDefinitions = [
{
name: "upload_image_from_url",
description:
"Fetch an image from a URL and save it locally. Returns the filename to use with create_item or add_candidate.",
inputSchema: {
type: "object" as const,
properties: {
url: {
type: "string",
description: "URL of the image to fetch (jpeg, png, or webp)",
},
},
required: ["url"],
},
},
];
export function registerImageTools() {
return {
upload_image_from_url: async (args: {
url: string;
}): Promise<ToolResult> => {
try {
const result = await fetchImageFromUrl(args.url);
return textResult(result);
} catch (err) {
return errorResult((err as Error).message);
}
},
};
}

View File

@@ -0,0 +1,190 @@
import type { db as prodDb } from "@/db/index.ts";
import {
createItem,
deleteItem,
getAllItems,
getItemById,
updateItem,
} from "../../services/item.service.ts";
type Db = typeof prodDb;
interface ToolResult {
content: Array<{ type: "text"; text: string }>;
}
function textResult(data: unknown): ToolResult {
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
}
function errorResult(message: string): ToolResult {
return {
content: [{ type: "text", text: JSON.stringify({ error: message }) }],
};
}
export const itemToolDefinitions = [
{
name: "list_items",
description:
"List all items in the gear collection, optionally filtered by category.",
inputSchema: {
type: "object" as const,
properties: {
categoryId: {
type: "number",
description: "Filter items by category ID",
},
},
},
},
{
name: "get_item",
description: "Get a single item by its ID, including all details.",
inputSchema: {
type: "object" as const,
properties: {
id: { type: "number", description: "The item ID" },
},
required: ["id"],
},
},
{
name: "create_item",
description:
"Add a new item to the gear collection. Use this for items you've already decided on. For items you're still researching, use create_thread instead.",
inputSchema: {
type: "object" as const,
properties: {
name: { type: "string", description: "Item name" },
categoryId: { type: "number", description: "Category ID" },
weightGrams: { type: "number", description: "Weight in grams" },
priceCents: { type: "number", description: "Price in cents" },
notes: { type: "string", description: "Notes about the item" },
productUrl: { type: "string", description: "URL to the product page" },
imageFilename: {
type: "string",
description: "Filename of an uploaded image",
},
imageSourceUrl: {
type: "string",
description: "Original URL the image was fetched from",
},
},
required: ["name", "categoryId"],
},
},
{
name: "update_item",
description: "Update an existing item's fields.",
inputSchema: {
type: "object" as const,
properties: {
id: { type: "number", description: "The item ID to update" },
name: { type: "string", description: "Item name" },
categoryId: { type: "number", description: "Category ID" },
weightGrams: { type: "number", description: "Weight in grams" },
priceCents: { type: "number", description: "Price in cents" },
notes: { type: "string", description: "Notes about the item" },
productUrl: { type: "string", description: "URL to the product page" },
imageFilename: {
type: "string",
description: "Filename of an uploaded image",
},
imageSourceUrl: {
type: "string",
description: "Original URL the image was fetched from",
},
},
required: ["id"],
},
},
{
name: "delete_item",
description: "Delete an item from the gear collection by ID.",
inputSchema: {
type: "object" as const,
properties: {
id: { type: "number", description: "The item ID to delete" },
},
required: ["id"],
},
},
];
export function registerItemTools(db: Db) {
return {
list_items: async (args: { categoryId?: number }): Promise<ToolResult> => {
try {
const items = getAllItems(db);
if (args.categoryId) {
return textResult(
items.filter((i) => i.categoryId === args.categoryId),
);
}
return textResult(items);
} catch (err) {
return errorResult((err as Error).message);
}
},
get_item: async (args: { id: number }): Promise<ToolResult> => {
try {
const item = getItemById(db, args.id);
if (!item) return errorResult(`Item ${args.id} not found`);
return textResult(item);
} catch (err) {
return errorResult((err as Error).message);
}
},
create_item: async (args: {
name: string;
categoryId: number;
weightGrams?: number;
priceCents?: number;
notes?: string;
productUrl?: string;
imageFilename?: string;
imageSourceUrl?: string;
}): Promise<ToolResult> => {
try {
const item = createItem(db, args);
return textResult(item);
} catch (err) {
return errorResult((err as Error).message);
}
},
update_item: async (args: {
id: number;
name?: string;
categoryId?: number;
weightGrams?: number;
priceCents?: number;
notes?: string;
productUrl?: string;
imageFilename?: string;
imageSourceUrl?: string;
}): Promise<ToolResult> => {
try {
const { id, ...data } = args;
const item = updateItem(db, id, data);
if (!item) return errorResult(`Item ${id} not found`);
return textResult(item);
} catch (err) {
return errorResult((err as Error).message);
}
},
delete_item: async (args: { id: number }): Promise<ToolResult> => {
try {
const item = deleteItem(db, args.id);
if (!item) return errorResult(`Item ${args.id} not found`);
return textResult({ deleted: true, item });
} catch (err) {
return errorResult((err as Error).message);
}
},
};
}

View File

@@ -0,0 +1,131 @@
import type { db as prodDb } from "@/db/index.ts";
import {
createSetup,
getAllSetups,
getSetupWithItems,
syncSetupItems,
updateSetup,
} from "../../services/setup.service.ts";
type Db = typeof prodDb;
interface ToolResult {
content: Array<{ type: "text"; text: string }>;
}
function textResult(data: unknown): ToolResult {
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
}
function errorResult(message: string): ToolResult {
return {
content: [{ type: "text", text: JSON.stringify({ error: message }) }],
};
}
export const setupToolDefinitions = [
{
name: "list_setups",
description:
"List all gear setups with item counts and weight/cost totals.",
inputSchema: {
type: "object" as const,
properties: {},
},
},
{
name: "get_setup",
description: "Get a setup with all its items and details.",
inputSchema: {
type: "object" as const,
properties: {
id: { type: "number", description: "Setup ID" },
},
required: ["id"],
},
},
{
name: "create_setup",
description: "Create a new gear setup (e.g. 'Bikepacking weekend').",
inputSchema: {
type: "object" as const,
properties: {
name: { type: "string", description: "Setup name" },
},
required: ["name"],
},
},
{
name: "update_setup",
description:
"Update a setup's name and/or replace its item list. Pass itemIds to set exactly which items belong to this setup.",
inputSchema: {
type: "object" as const,
properties: {
id: { type: "number", description: "Setup ID" },
name: { type: "string", description: "New setup name" },
itemIds: {
type: "array",
items: { type: "number" },
description: "Array of item IDs to include in the setup",
},
},
required: ["id"],
},
},
];
export function registerSetupTools(db: Db) {
return {
list_setups: async (): Promise<ToolResult> => {
try {
const setupList = getAllSetups(db);
return textResult(setupList);
} catch (err) {
return errorResult((err as Error).message);
}
},
get_setup: async (args: { id: number }): Promise<ToolResult> => {
try {
const setup = getSetupWithItems(db, args.id);
if (!setup) return errorResult(`Setup ${args.id} not found`);
return textResult(setup);
} catch (err) {
return errorResult((err as Error).message);
}
},
create_setup: async (args: { name: string }): Promise<ToolResult> => {
try {
const setup = createSetup(db, args);
return textResult(setup);
} catch (err) {
return errorResult((err as Error).message);
}
},
update_setup: async (args: {
id: number;
name?: string;
itemIds?: number[];
}): Promise<ToolResult> => {
try {
let setup = null;
if (args.name) {
setup = updateSetup(db, args.id, { name: args.name });
if (!setup) return errorResult(`Setup ${args.id} not found`);
}
if (args.itemIds) {
syncSetupItems(db, args.id, args.itemIds);
}
// Return updated setup with items
const result = getSetupWithItems(db, args.id);
if (!result) return errorResult(`Setup ${args.id} not found`);
return textResult(result);
} catch (err) {
return errorResult((err as Error).message);
}
},
};
}

View File

@@ -0,0 +1,252 @@
import type { db as prodDb } from "@/db/index.ts";
import {
createCandidate,
createThread,
deleteCandidate,
getAllThreads,
getThreadWithCandidates,
resolveThread,
updateCandidate,
} from "../../services/thread.service.ts";
type Db = typeof prodDb;
interface ToolResult {
content: Array<{ type: "text"; text: string }>;
}
function textResult(data: unknown): ToolResult {
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
}
function errorResult(message: string): ToolResult {
return {
content: [{ type: "text", text: JSON.stringify({ error: message }) }],
};
}
export const threadToolDefinitions = [
{
name: "list_threads",
description:
"List research threads. Threads are the recommended way to evaluate gear purchases — each thread tracks multiple candidates for a single gear slot, making it easy to compare options before committing.",
inputSchema: {
type: "object" as const,
properties: {
includeResolved: {
type: "boolean",
description:
"Include resolved threads (default: false, only active threads)",
},
},
},
},
{
name: "get_thread",
description:
"Get a thread with all its candidates for detailed comparison.",
inputSchema: {
type: "object" as const,
properties: {
id: { type: "number", description: "Thread ID" },
},
required: ["id"],
},
},
{
name: "create_thread",
description:
"Start a new research thread for a gear slot. This is the preferred workflow: create a thread, add candidates with pros/cons/prices, compare them, then resolve the thread to add the winner to your collection.",
inputSchema: {
type: "object" as const,
properties: {
name: {
type: "string",
description: "Thread name (e.g. 'Handlebar bag')",
},
categoryId: { type: "number", description: "Category ID" },
},
required: ["name", "categoryId"],
},
},
{
name: "resolve_thread",
description:
"Resolve a research thread by picking the winning candidate. The winner is automatically added to the gear collection as a new item, and the thread is marked as resolved.",
inputSchema: {
type: "object" as const,
properties: {
threadId: { type: "number", description: "Thread ID" },
candidateId: {
type: "number",
description: "ID of the winning candidate",
},
},
required: ["threadId", "candidateId"],
},
},
{
name: "add_candidate",
description: "Add a candidate option to a research thread for comparison.",
inputSchema: {
type: "object" as const,
properties: {
threadId: { type: "number", description: "Thread ID" },
name: { type: "string", description: "Candidate name" },
categoryId: { type: "number", description: "Category ID" },
weightGrams: { type: "number", description: "Weight in grams" },
priceCents: { type: "number", description: "Price in cents" },
notes: { type: "string", description: "Notes" },
productUrl: { type: "string", description: "Product URL" },
imageFilename: { type: "string", description: "Image filename" },
pros: { type: "string", description: "Pros of this candidate" },
cons: { type: "string", description: "Cons of this candidate" },
},
required: ["threadId", "name", "categoryId"],
},
},
{
name: "update_candidate",
description:
"Update a candidate's details (name, price, pros, cons, etc.).",
inputSchema: {
type: "object" as const,
properties: {
id: { type: "number", description: "Candidate ID" },
name: { type: "string", description: "Candidate name" },
weightGrams: { type: "number", description: "Weight in grams" },
priceCents: { type: "number", description: "Price in cents" },
categoryId: { type: "number", description: "Category ID" },
notes: { type: "string", description: "Notes" },
productUrl: { type: "string", description: "Product URL" },
imageFilename: { type: "string", description: "Image filename" },
imageSourceUrl: { type: "string", description: "Image source URL" },
status: {
type: "string",
description: "Status: researching, ordered, or arrived",
},
pros: { type: "string", description: "Pros" },
cons: { type: "string", description: "Cons" },
},
required: ["id"],
},
},
{
name: "remove_candidate",
description: "Remove a candidate from a research thread.",
inputSchema: {
type: "object" as const,
properties: {
id: { type: "number", description: "Candidate ID to remove" },
},
required: ["id"],
},
},
];
export function registerThreadTools(db: Db) {
return {
list_threads: async (args: {
includeResolved?: boolean;
}): Promise<ToolResult> => {
try {
const threadList = getAllThreads(db, args.includeResolved ?? false);
return textResult(threadList);
} catch (err) {
return errorResult((err as Error).message);
}
},
get_thread: async (args: { id: number }): Promise<ToolResult> => {
try {
const thread = getThreadWithCandidates(db, args.id);
if (!thread) return errorResult(`Thread ${args.id} not found`);
return textResult(thread);
} catch (err) {
return errorResult((err as Error).message);
}
},
create_thread: async (args: {
name: string;
categoryId: number;
}): Promise<ToolResult> => {
try {
const thread = createThread(db, args);
return textResult(thread);
} catch (err) {
return errorResult((err as Error).message);
}
},
resolve_thread: async (args: {
threadId: number;
candidateId: number;
}): Promise<ToolResult> => {
try {
const result = resolveThread(db, args.threadId, args.candidateId);
if (!result.success) {
return errorResult(result.error ?? "Failed to resolve thread");
}
return textResult(result);
} catch (err) {
return errorResult((err as Error).message);
}
},
add_candidate: async (args: {
threadId: number;
name: string;
categoryId: number;
weightGrams?: number;
priceCents?: number;
notes?: string;
productUrl?: string;
imageFilename?: string;
pros?: string;
cons?: string;
}): Promise<ToolResult> => {
try {
const { threadId, ...data } = args;
const candidate = createCandidate(db, threadId, data);
return textResult(candidate);
} catch (err) {
return errorResult((err as Error).message);
}
},
update_candidate: async (args: {
id: number;
name?: string;
weightGrams?: number;
priceCents?: number;
categoryId?: number;
notes?: string;
productUrl?: string;
imageFilename?: string;
imageSourceUrl?: string;
status?: "researching" | "ordered" | "arrived";
pros?: string;
cons?: string;
}): Promise<ToolResult> => {
try {
const { id, ...data } = args;
const candidate = updateCandidate(db, id, data);
if (!candidate) return errorResult(`Candidate ${id} not found`);
return textResult(candidate);
} catch (err) {
return errorResult((err as Error).message);
}
},
remove_candidate: async (args: { id: number }): Promise<ToolResult> => {
try {
const candidate = deleteCandidate(db, args.id);
if (!candidate) return errorResult(`Candidate ${args.id} not found`);
return textResult({ deleted: true, candidate });
} catch (err) {
return errorResult((err as Error).message);
}
},
};
}

253
tests/mcp/tools.test.ts Normal file
View File

@@ -0,0 +1,253 @@
import { describe, expect, test } from "bun:test";
import { getCollectionSummary } from "../../src/server/mcp/resources/collection.ts";
import { registerCategoryTools } from "../../src/server/mcp/tools/categories.ts";
import { registerItemTools } from "../../src/server/mcp/tools/items.ts";
import { registerSetupTools } from "../../src/server/mcp/tools/setups.ts";
import { registerThreadTools } from "../../src/server/mcp/tools/threads.ts";
import { createTestDb } from "../helpers/db.ts";
function parseResult(result: {
content: Array<{ type: string; text: string }>;
}) {
return JSON.parse(result.content[0].text);
}
describe("MCP Item Tools", () => {
test("list_items returns array", async () => {
const db = createTestDb();
const tools = registerItemTools(db);
const result = await tools.list_items({});
const data = parseResult(result);
expect(Array.isArray(data)).toBe(true);
});
test("create_item creates and returns item", async () => {
const db = createTestDb();
const tools = registerItemTools(db);
const result = await tools.create_item({
name: "Test Tent",
categoryId: 1,
weightGrams: 1200,
priceCents: 35000,
});
const data = parseResult(result);
expect(data.name).toBe("Test Tent");
expect(data.weightGrams).toBe(1200);
expect(data.priceCents).toBe(35000);
expect(data.id).toBeDefined();
});
test("get_item retrieves by ID", async () => {
const db = createTestDb();
const tools = registerItemTools(db);
const created = parseResult(
await tools.create_item({ name: "Sleeping Bag", categoryId: 1 }),
);
const result = await tools.get_item({ id: created.id });
const data = parseResult(result);
expect(data.name).toBe("Sleeping Bag");
expect(data.id).toBe(created.id);
});
test("get_item returns error for missing item", async () => {
const db = createTestDb();
const tools = registerItemTools(db);
const result = await tools.get_item({ id: 999 });
const data = parseResult(result);
expect(data.error).toContain("not found");
});
test("delete_item removes item", async () => {
const db = createTestDb();
const tools = registerItemTools(db);
const created = parseResult(
await tools.create_item({ name: "To Delete", categoryId: 1 }),
);
const deleteResult = await tools.delete_item({ id: created.id });
const data = parseResult(deleteResult);
expect(data.deleted).toBe(true);
// Verify it's gone
const getResult = await tools.get_item({ id: created.id });
const getData = parseResult(getResult);
expect(getData.error).toContain("not found");
});
});
describe("MCP Category Tools", () => {
test("list_categories returns array with Uncategorized", async () => {
const db = createTestDb();
const tools = registerCategoryTools(db);
const result = await tools.list_categories();
const data = parseResult(result);
expect(Array.isArray(data)).toBe(true);
expect(data.length).toBeGreaterThanOrEqual(1);
expect(data.some((c: any) => c.name === "Uncategorized")).toBe(true);
});
test("create_category creates a new category", async () => {
const db = createTestDb();
const tools = registerCategoryTools(db);
const result = await tools.create_category({
name: "Shelter",
icon: "tent",
});
const data = parseResult(result);
expect(data.name).toBe("Shelter");
expect(data.icon).toBe("tent");
});
});
describe("MCP Thread Tools", () => {
test("create_thread starts a thread with status active", async () => {
const db = createTestDb();
const tools = registerThreadTools(db);
const result = await tools.create_thread({
name: "Handlebar Bag",
categoryId: 1,
});
const data = parseResult(result);
expect(data.name).toBe("Handlebar Bag");
expect(data.status).toBe("active");
});
test("add_candidate adds to thread", async () => {
const db = createTestDb();
const tools = registerThreadTools(db);
const thread = parseResult(
await tools.create_thread({ name: "Saddle Bag", categoryId: 1 }),
);
const result = await tools.add_candidate({
threadId: thread.id,
name: "Apidura Racing",
categoryId: 1,
priceCents: 8500,
pros: "Lightweight",
cons: "Expensive",
});
const data = parseResult(result);
expect(data.name).toBe("Apidura Racing");
expect(data.threadId).toBe(thread.id);
expect(data.pros).toBe("Lightweight");
});
test("resolve_thread picks winner and creates item", async () => {
const db = createTestDb();
const threadTools = registerThreadTools(db);
const itemTools = registerItemTools(db);
// Create thread with two candidates
const thread = parseResult(
await threadTools.create_thread({ name: "Frame Bag", categoryId: 1 }),
);
const candidate1 = parseResult(
await threadTools.add_candidate({
threadId: thread.id,
name: "Revelate Tangle",
categoryId: 1,
priceCents: 12000,
}),
);
await threadTools.add_candidate({
threadId: thread.id,
name: "Ortlieb Frame Pack",
categoryId: 1,
priceCents: 9000,
});
// Resolve with first candidate
const resolveResult = await threadTools.resolve_thread({
threadId: thread.id,
candidateId: candidate1.id,
});
const resolveData = parseResult(resolveResult);
expect(resolveData.success).toBe(true);
expect(resolveData.item.name).toBe("Revelate Tangle");
// Check item was added to collection
const items = parseResult(await itemTools.list_items({}));
expect(items.some((i: any) => i.name === "Revelate Tangle")).toBe(true);
// Check thread is now resolved
const threadList = parseResult(
await threadTools.list_threads({ includeResolved: true }),
);
const resolved = threadList.find((t: any) => t.id === thread.id);
expect(resolved.status).toBe("resolved");
});
});
describe("MCP Setup Tools", () => {
test("create_setup and list_setups", async () => {
const db = createTestDb();
const tools = registerSetupTools(db);
await tools.create_setup({ name: "Weekend Trip" });
const result = await tools.list_setups();
const data = parseResult(result);
expect(data.length).toBe(1);
expect(data[0].name).toBe("Weekend Trip");
});
test("get_setup returns setup with items", async () => {
const db = createTestDb();
const setupTools = registerSetupTools(db);
const itemTools = registerItemTools(db);
const setup = parseResult(
await setupTools.create_setup({ name: "Overnighter" }),
);
const item = parseResult(
await itemTools.create_item({ name: "Bivvy", categoryId: 1 }),
);
await setupTools.update_setup({ id: setup.id, itemIds: [item.id] });
const result = await setupTools.get_setup({ id: setup.id });
const data = parseResult(result);
expect(data.name).toBe("Overnighter");
expect(data.items.length).toBe(1);
expect(data.items[0].name).toBe("Bivvy");
});
});
describe("MCP Collection Summary Resource", () => {
test("returns overview with correct counts", () => {
const db = createTestDb();
const summary = getCollectionSummary(db);
expect(summary.overview).toBeDefined();
expect(summary.overview.totalItems).toBe(0);
expect(summary.overview.categoryCount).toBe(1); // Uncategorized
expect(summary.itemsByCategory).toBeDefined();
expect(summary.activeThreads).toBeDefined();
expect(Array.isArray(summary.activeThreads)).toBe(true);
});
test("reflects items and threads after creation", async () => {
const db = createTestDb();
const itemTools = registerItemTools(db);
const threadTools = registerThreadTools(db);
await itemTools.create_item({
name: "Tent",
categoryId: 1,
weightGrams: 1500,
});
await itemTools.create_item({
name: "Sleeping Pad",
categoryId: 1,
weightGrams: 500,
});
await threadTools.create_thread({
name: "Cook System",
categoryId: 1,
});
const summary = getCollectionSummary(db);
expect(summary.overview.totalItems).toBe(2);
expect(summary.overview.totalWeightGrams).toBe(2000);
expect(summary.overview.activeThreadCount).toBe(1);
expect(summary.itemsByCategory.Uncategorized).toBe(2);
expect(summary.activeThreads.length).toBe(1);
expect(summary.activeThreads[0].name).toBe("Cook System");
});
});