23 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 25-catalog-enrichment-agent-tools | 02 | execute | 2 |
|
|
true |
|
|
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.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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
```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),
});
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 };
export const itemToolDefinitions = [
{ name: "...", description: "...", inputSchema: { /* z.* fields */ } },
];
export function registerItemTools(db: Db, userId: number) {
return { tool_name: async (args): Promise<ToolResult> => { ... } };
}
// 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);
}
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; }
{/* Image */}
<div className="aspect-[16/9] bg-gray-50 rounded-xl overflow-hidden mb-6">
{item.imageUrl ? ( <img ... /> ) : ( <div>...</div> )}
</div>
{/* Header */}
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
bun test tests/routes/global-items.test.ts
- 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
POST /api/global-items and POST /api/global-items/bulk endpoints operational with Zod validation, all route tests passing
Task 2: MCP catalog tools — upsert_catalog_item and bulk_upsert_catalog
src/server/mcp/tools/catalog.ts, src/server/mcp/index.ts, tests/mcp/tools.test.ts
- 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)
**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
- `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.
bun test tests/mcp/tools.test.ts
- 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
- `bun test tests/mcp/tools.test.ts` exits 0
Two MCP catalog tools registered and functional with attribution fields, all MCP tool tests passing
Task 3: Client attribution display on catalog detail page
src/client/hooks/useGlobalItems.ts, src/client/routes/global-items/$globalItemId.tsx
- 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)
**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 →
</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.
bun run lint && bun run build
- 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
Catalog detail page shows image attribution inline below image (credit + source link) and product page link, client types updated
- `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
<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>