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,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);
}
},
};
}