Files
GearBox/.planning/phases/25-catalog-enrichment-agent-tools/25-02-PLAN.md

564 lines
23 KiB
Markdown

---
phase: 25-catalog-enrichment-agent-tools
plan: 02
type: execute
wave: 2
depends_on: ["25-01"]
files_modified:
- src/server/routes/global-items.ts
- src/server/mcp/tools/catalog.ts
- src/server/mcp/index.ts
- src/client/hooks/useGlobalItems.ts
- src/client/routes/global-items/$globalItemId.tsx
- tests/routes/global-items.test.ts
- tests/mcp/tools.test.ts
autonomous: true
requirements:
- CATL-03
- CATL-04
- SEED-01
- SEED-02
- SEED-03
must_haves:
truths:
- "POST /api/global-items upserts a single catalog item and returns the item with id"
- "POST /api/global-items/bulk upserts up to 100 items in a single transaction and returns created/updated counts"
- "POST /api/global-items/bulk rejects the entire batch if any item fails validation"
- "MCP tool upsert_catalog_item writes a global item with attribution fields"
- "MCP tool bulk_upsert_catalog batch-writes global items via the bulk service"
- "Catalog detail page shows image credit and source link below the image when present"
artifacts:
- path: "src/server/routes/global-items.ts"
provides: "POST / and POST /bulk route handlers"
contains: "bulkUpsertGlobalItems"
- path: "src/server/mcp/tools/catalog.ts"
provides: "upsert_catalog_item and bulk_upsert_catalog MCP tool definitions + handlers"
exports: ["catalogToolDefinitions", "registerCatalogTools"]
- path: "src/server/mcp/index.ts"
provides: "Catalog tool registration in createMcpServer"
contains: "registerCatalogTools"
- path: "src/client/routes/global-items/$globalItemId.tsx"
provides: "Attribution display below image"
contains: "imageCredit"
- path: "tests/routes/global-items.test.ts"
provides: "Tests for POST single and bulk endpoints"
- path: "tests/mcp/tools.test.ts"
provides: "Tests for catalog MCP tools"
key_links:
- from: "src/server/routes/global-items.ts"
to: "src/server/services/global-item.service.ts"
via: "import and call upsertGlobalItem / bulkUpsertGlobalItems"
pattern: "import.*upsertGlobalItem.*bulkUpsertGlobalItems"
- from: "src/server/mcp/tools/catalog.ts"
to: "src/server/services/global-item.service.ts"
via: "import and call upsertGlobalItem / bulkUpsertGlobalItems"
pattern: "import.*upsertGlobalItem.*bulkUpsertGlobalItems"
- from: "src/server/mcp/index.ts"
to: "src/server/mcp/tools/catalog.ts"
via: "import catalogToolDefinitions + registerCatalogTools"
pattern: "catalogToolDefinitions.*registerCatalogTools"
---
<objective>
Add HTTP upsert endpoints, MCP catalog tools, and client-side attribution display for global items.
Purpose: Complete the API and agent tooling layer so MCP agents can seed the catalog, and display attribution metadata on catalog detail pages.
Output: POST /api/global-items, POST /api/global-items/bulk, two MCP tools, attribution UI on detail page, passing tests.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/25-catalog-enrichment-agent-tools/25-01-SUMMARY.md
@src/server/routes/global-items.ts
@src/server/mcp/index.ts
@src/server/mcp/tools/items.ts
@src/server/mcp/tools/images.ts
@src/server/services/global-item.service.ts
@src/shared/schemas.ts
@src/client/hooks/useGlobalItems.ts
@src/client/routes/global-items/$globalItemId.tsx
@tests/routes/global-items.test.ts
@tests/mcp/tools.test.ts
<interfaces>
<!-- From Plan 01 output: service functions (src/server/services/global-item.service.ts) -->
```typescript
export async function upsertGlobalItem(
db: Db,
data: {
brand: string; model: string; category?: string; weightGrams?: number;
priceCents?: number; imageUrl?: string; description?: string;
sourceUrl?: string; imageCredit?: string; imageSourceUrl?: string;
tags?: string[];
},
): Promise<{ item: GlobalItem; created: boolean }>;
export async function bulkUpsertGlobalItems(
db: Db,
itemsData: Array<{ /* same fields as above */ }>,
): Promise<{ created: number; updated: number; items: GlobalItem[] }>;
```
<!-- From Plan 01 output: Zod schemas (src/shared/schemas.ts) -->
```typescript
export const upsertGlobalItemSchema = z.object({
brand: z.string().min(1), model: z.string().min(1),
category: z.string().optional(), weightGrams: z.number().nonnegative().optional(),
priceCents: z.number().int().nonnegative().optional(),
imageUrl: z.string().url().optional().or(z.literal("")),
description: z.string().optional(), sourceUrl: z.string().url().optional().or(z.literal("")),
imageCredit: z.string().optional(), imageSourceUrl: z.string().url().optional().or(z.literal("")),
tags: z.array(z.string().min(1).max(100)).max(20).optional(),
});
export const bulkUpsertGlobalItemsSchema = z.object({
items: z.array(upsertGlobalItemSchema).min(1).max(100),
});
```
<!-- Existing route pattern (src/server/routes/global-items.ts) -->
```typescript
import { Hono } from "hono";
type Env = { Variables: { db?: any } };
const app = new Hono<Env>();
app.get("/", async (c) => { ... });
app.get("/:id", async (c) => { ... });
export { app as globalItemRoutes };
```
<!-- MCP tool pattern (src/server/mcp/tools/items.ts) -->
```typescript
export const itemToolDefinitions = [
{ name: "...", description: "...", inputSchema: { /* z.* fields */ } },
];
export function registerItemTools(db: Db, userId: number) {
return { tool_name: async (args): Promise<ToolResult> => { ... } };
}
```
<!-- MCP registration pattern (src/server/mcp/index.ts) -->
```typescript
// Image tools (no userId needed):
const imageHandlers = registerImageTools();
for (const def of imageToolDefinitions) {
const handler = imageHandlers[def.name as keyof typeof imageHandlers];
server.tool(def.name, def.description, def.inputSchema, handler);
}
```
<!-- Client GlobalItem interface (src/client/hooks/useGlobalItems.ts lines 4-14) -->
```typescript
interface GlobalItem {
id: number; brand: string; model: string; category: string | null;
weightGrams: number | null; priceCents: number | null;
imageUrl: string | null; description: string | null; createdAt: string;
}
interface GlobalItemWithOwnerCount extends GlobalItem { ownerCount: number; }
```
<!-- Catalog detail page image section ($globalItemId.tsx lines 65-85) -->
```tsx
{/* Image */}
<div className="aspect-[16/9] bg-gray-50 rounded-xl overflow-hidden mb-6">
{item.imageUrl ? ( <img ... /> ) : ( <div>...</div> )}
</div>
{/* Header */}
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: HTTP routes for single and bulk upsert</name>
<files>src/server/routes/global-items.ts, tests/routes/global-items.test.ts</files>
<read_first>
- src/server/routes/global-items.ts (current GET-only routes)
- src/server/routes/setups.ts (POST route with zValidator pattern)
- src/server/services/global-item.service.ts (upsertGlobalItem, bulkUpsertGlobalItems signatures from Plan 01)
- src/shared/schemas.ts (upsertGlobalItemSchema, bulkUpsertGlobalItemsSchema from Plan 01)
- src/server/index.ts (auth middleware — confirm POST on /api/global-items requires auth, lines 150-170)
- tests/routes/global-items.test.ts (existing test structure)
- tests/helpers/db.ts (createTestDb helper)
</read_first>
<behavior>
- POST /api/global-items with valid body returns 200 with { item, created: true/false }
- POST /api/global-items with invalid body (missing brand) returns 400
- POST /api/global-items/bulk with valid body returns 200 with { created, updated, items }
- POST /api/global-items/bulk with >100 items returns 400
- POST /api/global-items/bulk with invalid item in array returns 400 (rejected before DB)
- POST /api/global-items/bulk with empty array returns 400
</behavior>
<action>
**1. Add imports and POST routes to `src/server/routes/global-items.ts`:**
Add these imports at the top:
```typescript
import { zValidator } from "@hono/zod-validator";
import {
upsertGlobalItemSchema,
bulkUpsertGlobalItemsSchema,
} from "../../shared/schemas.ts";
import {
upsertGlobalItem,
bulkUpsertGlobalItems,
} from "../services/global-item.service.ts";
```
Update the existing imports to include the new service functions (keep `getGlobalItemWithOwnerCount` and `searchGlobalItems`).
Add after the existing GET routes:
```typescript
// Single item upsert — per D-10
app.post("/", zValidator("json", upsertGlobalItemSchema), async (c) => {
const db = c.get("db");
const data = c.req.valid("json");
const result = await upsertGlobalItem(db, data);
return c.json(result);
});
// Bulk upsert — per D-06, D-07, D-08
app.post("/bulk", zValidator("json", bulkUpsertGlobalItemsSchema), async (c) => {
const db = c.get("db");
const { items } = c.req.valid("json");
const result = await bulkUpsertGlobalItems(db, items);
return c.json(result);
});
```
No auth middleware changes needed — the existing auth middleware in `src/server/index.ts` already requires auth for all non-GET requests on `/api/global-items*`.
**2. Add tests to `tests/routes/global-items.test.ts`:**
Add a `describe("POST /api/global-items")` block with tests:
- Valid single upsert returns 200 with item and created flag
- Missing brand returns 400
- Duplicate (brand, model) upserts instead of creating duplicate
Add a `describe("POST /api/global-items/bulk")` block with tests:
- Valid bulk upsert returns 200 with created/updated counts
- Empty items array returns 400
- Array with >100 items returns 400 (mock or construct 101 items)
- Invalid item in array returns 400 and nothing is persisted
- Mix of new and existing items returns correct counts
</action>
<verify>
<automated>bun test tests/routes/global-items.test.ts</automated>
</verify>
<acceptance_criteria>
- src/server/routes/global-items.ts contains `app.post("/", zValidator("json", upsertGlobalItemSchema)`
- src/server/routes/global-items.ts contains `app.post("/bulk", zValidator("json", bulkUpsertGlobalItemsSchema)`
- src/server/routes/global-items.ts contains `import.*upsertGlobalItem`
- src/server/routes/global-items.ts contains `import.*bulkUpsertGlobalItems`
- tests/routes/global-items.test.ts contains at least 4 test cases with `POST`
- `bun test tests/routes/global-items.test.ts` exits 0
</acceptance_criteria>
<done>POST /api/global-items and POST /api/global-items/bulk endpoints operational with Zod validation, all route tests passing</done>
</task>
<task type="auto">
<name>Task 2: MCP catalog tools — upsert_catalog_item and bulk_upsert_catalog</name>
<files>src/server/mcp/tools/catalog.ts, src/server/mcp/index.ts, tests/mcp/tools.test.ts</files>
<read_first>
- src/server/mcp/tools/items.ts (full file — tool definition + handler pattern with ToolResult, textResult, errorResult)
- src/server/mcp/tools/images.ts (no-userId factory pattern)
- src/server/mcp/index.ts (registration loop pattern, createMcpServer function)
- src/server/services/global-item.service.ts (upsertGlobalItem, bulkUpsertGlobalItems from Plan 01)
- tests/mcp/tools.test.ts (existing MCP tool test structure)
</read_first>
<action>
**1. Create `src/server/mcp/tools/catalog.ts`:**
```typescript
import { z } from "zod";
import type { db as prodDb } from "../../../db/index.ts";
import {
upsertGlobalItem,
bulkUpsertGlobalItems,
} 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<ToolResult> => {
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<ToolResult> => {
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);
}
},
};
}
```
Per D-11 (upsert_catalog_item), D-12 (bulk_upsert_catalog), D-13 (auth via existing MCP middleware), D-14 (register in index.ts following pattern), SEED-03 (attribution fields as parameters).
**2. Register in `src/server/mcp/index.ts`:**
Add import at the top with the other tool imports:
```typescript
import {
catalogToolDefinitions,
registerCatalogTools,
} from "./tools/catalog.ts";
```
Add registration block inside `createMcpServer` function, after the image tools registration (around line 56):
```typescript
// 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);
}
```
Do NOT modify the `createMcpServer(db, userId)` function signature — just pass `db` only to `registerCatalogTools`.
**3. Add tests to `tests/mcp/tools.test.ts`:**
Add a `describe("catalog tools")` block with tests:
- `upsert_catalog_item` creates a new global item and returns it with created: true
- `upsert_catalog_item` updates existing item on (brand, model) match
- `upsert_catalog_item` includes attribution fields (sourceUrl, imageCredit, imageSourceUrl) in result — pass all three attribution fields and assert they appear in the returned item (SEED-03 coverage)
- `bulk_upsert_catalog` processes array and returns created/updated counts
- `bulk_upsert_catalog` returns totalProcessed matching input length
- Tool definitions include all attribution fields in inputSchema
Test by calling `registerCatalogTools(db)` directly and invoking handlers, following the pattern in the existing MCP tools tests.
</action>
<verify>
<automated>bun test tests/mcp/tools.test.ts</automated>
</verify>
<acceptance_criteria>
- src/server/mcp/tools/catalog.ts exists and contains `export const catalogToolDefinitions`
- src/server/mcp/tools/catalog.ts contains `export function registerCatalogTools`
- src/server/mcp/tools/catalog.ts contains `upsert_catalog_item` in definitions
- src/server/mcp/tools/catalog.ts contains `bulk_upsert_catalog` in definitions
- src/server/mcp/tools/catalog.ts contains `sourceUrl` and `imageCredit` and `imageSourceUrl` in inputSchema
- src/server/mcp/index.ts contains `import.*catalogToolDefinitions.*registerCatalogTools`
- src/server/mcp/index.ts contains `registerCatalogTools(db)`
- tests/mcp/tools.test.ts contains `upsert_catalog_item` in at least 2 test descriptions
- tests/mcp/tools.test.ts contains at least one test that passes sourceUrl, imageCredit, and imageSourceUrl to upsert_catalog_item and asserts they appear in the returned item (SEED-03 coverage)
- `bun test tests/mcp/tools.test.ts` exits 0
</acceptance_criteria>
<done>Two MCP catalog tools registered and functional with attribution fields, all MCP tool tests passing</done>
</task>
<task type="auto">
<name>Task 3: Client attribution display on catalog detail page</name>
<files>src/client/hooks/useGlobalItems.ts, src/client/routes/global-items/$globalItemId.tsx</files>
<read_first>
- src/client/hooks/useGlobalItems.ts (GlobalItem interface at lines 4-14)
- src/client/routes/global-items/$globalItemId.tsx (full component, image section at lines 65-85)
</read_first>
<action>
**1. Update `GlobalItem` interface in `src/client/hooks/useGlobalItems.ts`:**
Add three new fields to the `GlobalItem` interface (after `description`):
```typescript
interface GlobalItem {
id: number;
brand: string;
model: string;
category: string | null;
weightGrams: number | null;
priceCents: number | null;
imageUrl: string | null;
description: string | null;
sourceUrl: string | null;
imageCredit: string | null;
imageSourceUrl: string | null;
createdAt: string;
}
```
`GlobalItemWithOwnerCount` extends `GlobalItem` so it inherits the new fields automatically.
**2. Add attribution display to `src/client/routes/global-items/$globalItemId.tsx`:**
Insert attribution text immediately after the image `div` (after line 85 — the closing `</div>` of the image section) and before the `{/* Header */}` comment. Per D-03: inline below the image, not collapsible.
```tsx
{/* Attribution */}
{(item.imageCredit || item.imageSourceUrl) && (
<p className="text-xs text-gray-400 mt-1 mb-6">
{item.imageCredit && <span>Photo: {item.imageCredit}</span>}
{item.imageCredit && item.imageSourceUrl && <span> · </span>}
{item.imageSourceUrl && (
<a
href={item.imageSourceUrl}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-gray-600 transition-colors"
>
Source
</a>
)}
</p>
)}
```
Also add `sourceUrl` display: if `item.sourceUrl` exists, show it as a link in the specs/details section (after the description, at the bottom):
```tsx
{item.sourceUrl && (
<div className="mt-4">
<a
href={item.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-500 hover:text-blue-600 underline transition-colors"
>
View product page &rarr;
</a>
</div>
)}
```
Remove the existing `mb-6` from the image div's parent className (the `<div className="aspect-[16/9] bg-gray-50 rounded-xl overflow-hidden mb-6">`) and let the attribution `<p>` handle the spacing with its `mb-6` class.
</action>
<verify>
<automated>bun run lint && bun run build</automated>
</verify>
<acceptance_criteria>
- src/client/hooks/useGlobalItems.ts GlobalItem interface contains `sourceUrl: string | null`
- src/client/hooks/useGlobalItems.ts GlobalItem interface contains `imageCredit: string | null`
- src/client/hooks/useGlobalItems.ts GlobalItem interface contains `imageSourceUrl: string | null`
- src/client/routes/global-items/$globalItemId.tsx contains `item.imageCredit`
- src/client/routes/global-items/$globalItemId.tsx contains `item.imageSourceUrl`
- src/client/routes/global-items/$globalItemId.tsx contains `item.sourceUrl`
- src/client/routes/global-items/$globalItemId.tsx contains `Photo:`
- `bun run build` exits 0 (no TypeScript errors)
- `bun run lint` exits 0
</acceptance_criteria>
<done>Catalog detail page shows image attribution inline below image (credit + source link) and product page link, client types updated. Manual verification required: open a catalog item with imageCredit set and confirm credit and source link render below the image (CATL-03 is visual — no automated test).</done>
</task>
</tasks>
<verification>
- `bun test tests/routes/global-items.test.ts` exits 0
- `bun test tests/mcp/tools.test.ts` exits 0
- `bun run build` exits 0
- `bun run lint` exits 0
- `bun test` full suite exits 0
</verification>
<success_criteria>
- POST /api/global-items accepts and upserts a single catalog item with attribution fields
- POST /api/global-items/bulk accepts up to 100 items, rejects entire batch on validation failure, returns created/updated counts
- upsert_catalog_item MCP tool writes to globalItems with all attribution fields
- bulk_upsert_catalog MCP tool batch-writes via the bulk service
- Catalog detail page displays image credit and source link below the image when present
- Catalog detail page displays product page link when sourceUrl is present
- All tests pass, build succeeds, lint clean
</success_criteria>
<output>
After completion, create `.planning/phases/25-catalog-enrichment-agent-tools/25-02-SUMMARY.md`
</output>