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:
41
src/server/mcp/resources/collection.ts
Normal file
41
src/server/mcp/resources/collection.ts
Normal 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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
72
src/server/mcp/tools/categories.ts
Normal file
72
src/server/mcp/tools/categories.ts
Normal 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
48
src/server/mcp/tools/images.ts
Normal file
48
src/server/mcp/tools/images.ts
Normal 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
190
src/server/mcp/tools/items.ts
Normal file
190
src/server/mcp/tools/items.ts
Normal 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
131
src/server/mcp/tools/setups.ts
Normal file
131
src/server/mcp/tools/setups.ts
Normal 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
252
src/server/mcp/tools/threads.ts
Normal file
252
src/server/mcp/tools/threads.ts
Normal 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
253
tests/mcp/tools.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user