diff --git a/src/server/mcp/index.ts b/src/server/mcp/index.ts index 997244c..fceeddf 100644 --- a/src/server/mcp/index.ts +++ b/src/server/mcp/index.ts @@ -6,6 +6,10 @@ import { db as prodDb } from "../../db/index.ts"; import { verifyApiKey } from "../services/auth.service.ts"; import { verifyAccessToken } from "../services/oauth.service.ts"; import { getCollectionSummary } from "./resources/collection.ts"; +import { + catalogToolDefinitions, + registerCatalogTools, +} from "./tools/catalog.ts"; import { categoryToolDefinitions, registerCategoryTools, @@ -55,6 +59,13 @@ function createMcpServer(db: Db, userId: number): McpServer { server.tool(def.name, def.description, def.inputSchema, handler); } + // Register catalog tools (no userId needed — catalog is global) + const catalogHandlers = registerCatalogTools(db); + for (const def of catalogToolDefinitions) { + const handler = catalogHandlers[def.name as keyof typeof catalogHandlers]; + server.tool(def.name, def.description, def.inputSchema, handler); + } + // Register collection summary resource server.resource( "collection-summary", diff --git a/src/server/mcp/tools/catalog.ts b/src/server/mcp/tools/catalog.ts new file mode 100644 index 0000000..55f420d --- /dev/null +++ b/src/server/mcp/tools/catalog.ts @@ -0,0 +1,112 @@ +import { z } from "zod"; +import type { db as prodDb } from "../../../db/index.ts"; +import { + bulkUpsertGlobalItems, + upsertGlobalItem, +} from "../../services/global-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 }) }] }; +} + +const catalogItemInputSchema = { + brand: z.string().describe("Brand or manufacturer name"), + model: z.string().describe("Model name — combined with brand forms the unique identifier"), + category: z.string().optional().describe("Category name (e.g., 'Bags', 'Lights')"), + weightGrams: z.number().optional().describe("Weight in grams"), + priceCents: z.number().optional().describe("MSRP price in cents (e.g., 9999 = $99.99)"), + imageUrl: z.string().optional().describe("URL to the product image"), + description: z.string().optional().describe("Product description"), + sourceUrl: z.string().optional().describe("URL to the product page on manufacturer/retailer site"), + imageCredit: z.string().optional().describe("Image credit — photographer or source name"), + imageSourceUrl: z.string().optional().describe("Original URL where the image was sourced from"), + tags: z.array(z.string()).optional().describe("Tags for categorization (created automatically if new)"), +}; + +export const catalogToolDefinitions = [ + { + name: "upsert_catalog_item", + description: + "Add or update a single item in the global catalog. If an item with the same brand and model already exists, it will be updated. Includes attribution fields for image credit and source tracking. Requires authentication.", + inputSchema: catalogItemInputSchema, + }, + { + name: "bulk_upsert_catalog", + description: + "Add or update multiple items in the global catalog in a single batch (max 100). All items are processed in one transaction — if any item fails, the entire batch is rolled back. Each item is upserted on (brand, model) uniqueness.", + inputSchema: { + items: z + .array(z.object(catalogItemInputSchema)) + .max(100) + .describe("Array of catalog items to upsert (max 100 per batch)"), + }, + }, +]; + +// Catalog tools operate on shared catalog — no userId needed for data scoping +// db is passed for database access +export function registerCatalogTools(db: Db) { + return { + upsert_catalog_item: async (args: { + brand: string; + model: string; + category?: string; + weightGrams?: number; + priceCents?: number; + imageUrl?: string; + description?: string; + sourceUrl?: string; + imageCredit?: string; + imageSourceUrl?: string; + tags?: string[]; + }): Promise => { + try { + const result = await upsertGlobalItem(db, args); + return textResult({ + ...result.item, + created: result.created, + }); + } catch (err) { + return errorResult((err as Error).message); + } + }, + + bulk_upsert_catalog: async (args: { + items: Array<{ + brand: string; + model: string; + category?: string; + weightGrams?: number; + priceCents?: number; + imageUrl?: string; + description?: string; + sourceUrl?: string; + imageCredit?: string; + imageSourceUrl?: string; + tags?: string[]; + }>; + }): Promise => { + try { + const result = await bulkUpsertGlobalItems(db, args.items); + return textResult({ + created: result.created, + updated: result.updated, + totalProcessed: result.items.length, + items: result.items, + }); + } catch (err) { + return errorResult((err as Error).message); + } + }, + }; +} diff --git a/tests/mcp/tools.test.ts b/tests/mcp/tools.test.ts index 4d21756..f724b2f 100644 --- a/tests/mcp/tools.test.ts +++ b/tests/mcp/tools.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { getCollectionSummary } from "../../src/server/mcp/resources/collection.ts"; +import { registerCatalogTools } from "../../src/server/mcp/tools/catalog.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"; @@ -252,6 +253,112 @@ describe("MCP Collection Summary Resource", () => { }); }); +describe("MCP Catalog Tools", () => { + test("upsert_catalog_item creates a new global item with created=true", async () => { + const { db } = await createTestDb(); + const tools = registerCatalogTools(db); + const result = await tools.upsert_catalog_item({ + brand: "Revelate Designs", + model: "Terrapin System", + weightGrams: 235, + priceCents: 16500, + }); + const data = parseResult(result); + expect(data.brand).toBe("Revelate Designs"); + expect(data.model).toBe("Terrapin System"); + expect(data.created).toBe(true); + expect(data.id).toBeDefined(); + }); + + test("upsert_catalog_item updates existing item on brand+model match", async () => { + const { db } = await createTestDb(); + const tools = registerCatalogTools(db); + + // Create initial item + await tools.upsert_catalog_item({ + brand: "Apidura", + model: "Handlebar Pack", + }); + + // Update it + const result = await tools.upsert_catalog_item({ + brand: "Apidura", + model: "Handlebar Pack", + description: "Updated description", + weightGrams: 120, + }); + const data = parseResult(result); + expect(data.created).toBe(false); + expect(data.description).toBe("Updated description"); + expect(data.weightGrams).toBe(120); + }); + + test("upsert_catalog_item includes attribution fields in result (SEED-03)", async () => { + const { db } = await createTestDb(); + const tools = registerCatalogTools(db); + + const result = await tools.upsert_catalog_item({ + brand: "MSR", + model: "PocketRocket 2", + sourceUrl: "https://www.cascadedesigns.com/msr/pocket-rocket-2", + imageCredit: "MSR Photography", + imageSourceUrl: "https://cdn.cascadedesigns.com/images/pocket-rocket-2.jpg", + }); + const data = parseResult(result); + expect(data.sourceUrl).toBe("https://www.cascadedesigns.com/msr/pocket-rocket-2"); + expect(data.imageCredit).toBe("MSR Photography"); + expect(data.imageSourceUrl).toBe("https://cdn.cascadedesigns.com/images/pocket-rocket-2.jpg"); + }); + + test("bulk_upsert_catalog processes array and returns created/updated counts", async () => { + const { db } = await createTestDb(); + const tools = registerCatalogTools(db); + + const result = await tools.bulk_upsert_catalog({ + items: [ + { brand: "Revelate Designs", model: "Terrapin System" }, + { brand: "Apidura", model: "Handlebar Pack" }, + { brand: "MSR", model: "PocketRocket 2" }, + ], + }); + const data = parseResult(result); + expect(data.created).toBe(3); + expect(data.updated).toBe(0); + expect(data.totalProcessed).toBe(3); + expect(data.items).toHaveLength(3); + }); + + test("bulk_upsert_catalog returns totalProcessed matching input length", async () => { + const { db } = await createTestDb(); + const tools = registerCatalogTools(db); + + // Pre-create one item + await tools.upsert_catalog_item({ brand: "Revelate Designs", model: "Terrapin System" }); + + const result = await tools.bulk_upsert_catalog({ + items: [ + { brand: "Revelate Designs", model: "Terrapin System" }, + { brand: "Apidura", model: "Handlebar Pack" }, + ], + }); + const data = parseResult(result); + expect(data.totalProcessed).toBe(2); + expect(data.created).toBe(1); + expect(data.updated).toBe(1); + }); + + test("catalog tool definitions include attribution fields in inputSchema", () => { + const { catalogToolDefinitions } = require("../../src/server/mcp/tools/catalog.ts"); + const upsertDef = catalogToolDefinitions.find( + (d: { name: string }) => d.name === "upsert_catalog_item", + ); + expect(upsertDef).toBeDefined(); + expect(upsertDef.inputSchema.sourceUrl).toBeDefined(); + expect(upsertDef.inputSchema.imageCredit).toBeDefined(); + expect(upsertDef.inputSchema.imageSourceUrl).toBeDefined(); + }); +}); + describe("MCP Cross-User Isolation", () => { test("user 2 cannot see user 1's items via MCP tools", async () => { const { db, userId } = await createTestDb();