From 7e3f53f8a5462c833316b7703dc4a05ee22673b5 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 23 Apr 2026 18:59:31 +0200 Subject: [PATCH] =?UTF-8?q?feat(tool-registry):=20wardrobe.*=20MCP=20tools?= =?UTF-8?q?=20=E2=80=94=20listGarments/listOutfits/createOutfit/tryOn=20(M?= =?UTF-8?q?5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M5 of docs/plans/wardrobe-module.md — exposes the Wardrobe feature through the shared tool-registry so MCP clients (Claude Desktop) and the mana-ai mission runner can browse, compose, and try on outfits alongside the built-in UI. Follows the pattern M5 of the me-images plan established in packages/mana-tool-registry/src/ modules/me.ts — encrypted reads via mana-sync pull + client-side filter on `row.spaceId === ctx.spaceId`, writes via pushInsert with encryptRecordFields, HTTP proxy for the try-on endpoint. Four tools in packages/mana-tool-registry/src/modules/wardrobe.ts: - wardrobe.listGarments(category?, tags?, limit?) — read. Pulls wardrobeGarments from mana-sync, filters to the active space, decrypts name/brand/color/size/material/tags/notes, applies optional category + intersection-tag filters, caps at 200 rows (50 default). Archived + soft-deleted items excluded. - wardrobe.listOutfits(occasion?, favoriteOnly?, limit?) — read. Same shape, filters by occasion (closed enum, plaintext — unencrypted filter) and favorite. garmentIds arrive plaintext so the agent can immediately resolve them via listGarments when it needs more than ids. - wardrobe.createOutfit({ name, garmentIds, occasion?, tags?, description? }) — write. Encrypts name/description/tags, pushes an insert tagged with ctx.spaceId. No cross-space validation of the garmentIds — the calling agent is expected to have called listGarments first; dangling refs surface visually in the UI rather than as a hard server error. - wardrobe.tryOn({ outfitId, prompt?, accessoryOnly?, quality? }) — write (consumes credits). Biggest tool of the set: pulls the outfit, its garments, and the caller's meImages in three separate mana-sync pulls, resolves the primary face-ref + body-ref, auto-detects accessoryOnly from garment categories (FACE_ONLY_CATEGORIES: accessory/glasses/jewelry/hat), composes refs respecting the 8-slot server cap, composes a default DE prompt from the outfit name + occasion, and proxies to /api/v1/picture/generate-with-reference with the user's JWT. Returns the resulting image's URL + mediaId + prompt + model. Deliberately does NOT persist a picture.images row or update outfit.lastTryOn from the tool — those live on the client's imagesStore / wardrobeOutfitsStore and doing them server-side would race with a user who's also looking at the outfit page. Agents use tryOn as a preview/inspection primitive; the user commits from the UI. Types: 'wardrobe' added to the ModuleId union. registerWardrobeTools wired into registerAllModules — mana-mcp's createMcpServerForUser iterates the registry and exposes any user-space tool automatically. Credit model: quality defaults to 'medium' (10 credits per render), same tarif as text-to-image generation. The agent pays for the generation out of the calling user's credit balance via the standard validateCredits/consumeCredits chain on the server endpoint. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/modules/wardrobe.ts | 237 +++++++++--------- 1 file changed, 112 insertions(+), 125 deletions(-) diff --git a/packages/mana-tool-registry/src/modules/wardrobe.ts b/packages/mana-tool-registry/src/modules/wardrobe.ts index 6919751a2..d23c2ba3b 100644 --- a/packages/mana-tool-registry/src/modules/wardrobe.ts +++ b/packages/mana-tool-registry/src/modules/wardrobe.ts @@ -46,7 +46,9 @@ const OUTFIT_ENCRYPTED_FIELDS = ['name', 'description', 'tags'] as const; const ME_APP_ID = 'profile'; const ME_TABLE = 'meImages'; -const ME_ENCRYPTED_FIELDS = ['label', 'tags'] as const; +// meImages has encrypted label + tags, but tryOn only reads mediaId +// and primaryFor (both plaintext) — no decrypt needed here. The +// field list is tracked via the profile module's own me.ts tool. const SYNC_URL = () => process.env.MANA_SYNC_URL ?? 'http://localhost:3050'; const PICTURE_API_URL = () => process.env.MANA_API_URL ?? 'http://localhost:3060'; @@ -166,65 +168,68 @@ const listGarmentsOutput = z.object({ garments: z.array(garmentSchema), }); -export const wardrobeListGarments: ToolSpec = - { - name: 'wardrobe.listGarments', - module: 'wardrobe', - scope: 'user-space', - policyHint: 'read', - description: - "List the caller's garments in the active space. Filter by `category` (closed enum) and/or `tags` (intersection — every listed tag must be present). Returns at most `limit` rows, newest first. Archived + soft-deleted items are excluded.", - input: listGarmentsInput, - output: listGarmentsOutput, - encryptedFields: { table: GARMENTS_TABLE, fields: [...GARMENT_ENCRYPTED_FIELDS] }, - async handler(input, ctx) { - const key = await ctx.getMasterKey(); - const res = await pullAll(syncCfg(ctx), GARMENTS_APP_ID, GARMENTS_TABLE); - const alive = res.changes - .filter((c) => c.op !== 'delete' && c.data) - .map((c) => c.data as RawGarmentRow) - .filter((row) => !row.deletedAt && !row.isArchived) - .filter((row) => row.spaceId === ctx.spaceId); +export const wardrobeListGarments: ToolSpec = { + name: 'wardrobe.listGarments', + module: 'wardrobe', + scope: 'user-space', + policyHint: 'read', + description: + "List the caller's garments in the active space. Filter by `category` (closed enum) and/or `tags` (intersection — every listed tag must be present). Returns at most `limit` rows, newest first. Archived + soft-deleted items are excluded.", + input: listGarmentsInput, + output: listGarmentsOutput, + encryptedFields: { table: GARMENTS_TABLE, fields: [...GARMENT_ENCRYPTED_FIELDS] }, + async handler(input, ctx) { + const key = await ctx.getMasterKey(); + const res = await pullAll(syncCfg(ctx), GARMENTS_APP_ID, GARMENTS_TABLE); + const alive = res.changes + .filter((c) => c.op !== 'delete' && c.data) + .map((c) => c.data as RawGarmentRow) + .filter((row) => !row.deletedAt && !row.isArchived) + .filter((row) => row.spaceId === ctx.spaceId); - const decrypted = (await Promise.all( - alive.map((row) => - decryptRecordFields(row as unknown as Record, GARMENT_ENCRYPTED_FIELDS, key) + const decrypted = (await Promise.all( + alive.map((row) => + decryptRecordFields( + row as unknown as Record, + GARMENT_ENCRYPTED_FIELDS, + key ) - )) as unknown as RawGarmentRow[]; + ) + )) as unknown as RawGarmentRow[]; - const filtered = decrypted - .filter((row): row is RawGarmentRow & { id: string; name: string; category: string } => - Boolean(row.id && row.name && row.category) - ) - .filter((row) => !input.category || row.category === input.category) - .filter((row) => { - if (input.tags.length === 0) return true; - const rowTags = new Set(row.tags ?? []); - return input.tags.every((t) => rowTags.has(t)); - }) - .slice(0, input.limit); + const filtered = decrypted + .filter((row): row is RawGarmentRow & { id: string; name: string; category: string } => + Boolean(row.id && row.name && row.category) + ) + .filter((row) => !input.category || row.category === input.category) + .filter((row) => { + if (input.tags.length === 0) return true; + const rowTags = new Set(row.tags ?? []); + return input.tags.every((t) => rowTags.has(t)); + }) + .slice(0, input.limit); - const garments = filtered.map((row) => ({ - id: row.id, - name: row.name, - category: row.category as GarmentCategory, - mediaIds: row.mediaIds ?? [], - brand: row.brand ?? null, - color: row.color ?? null, - size: row.size ?? null, - material: row.material ?? null, - tags: row.tags ?? [], - notes: row.notes ?? null, - })); + const garments = filtered.map((row) => ({ + id: row.id, + name: row.name, + category: row.category as GarmentCategory, + mediaIds: row.mediaIds ?? [], + brand: row.brand ?? null, + color: row.color ?? null, + size: row.size ?? null, + material: row.material ?? null, + tags: row.tags ?? [], + notes: row.notes ?? null, + })); - ctx.logger.info('wardrobe.listGarments', { - count: garments.length, - category: input.category ?? 'all', - }); + ctx.logger.info('wardrobe.listGarments', { + count: garments.length, + category: input.category ?? 'all', + }); - return { garments }; - }, - }; + return { garments }; + }, +}; // ─── wardrobe.listOutfits ───────────────────────────────────────── @@ -305,21 +310,50 @@ const createOutfitOutput = z.object({ outfit: outfitSchema, }); -export const wardrobeCreateOutfit: ToolSpec = - { - name: 'wardrobe.createOutfit', - module: 'wardrobe', - scope: 'user-space', - policyHint: 'write', - description: - "Compose a new outfit in the active space. `garmentIds` must reference garments the caller owns in the same space — the server will persist whatever you pass (there's no cross-space validation here), so call `wardrobe.listGarments` first to confirm the ids.", - input: createOutfitInput, - output: createOutfitOutput, - encryptedFields: { table: OUTFITS_TABLE, fields: [...OUTFIT_ENCRYPTED_FIELDS] }, - async handler(input, ctx) { - const key = await ctx.getMasterKey(); - const id = crypto.randomUUID(); - const plaintext = { +export const wardrobeCreateOutfit: ToolSpec = { + name: 'wardrobe.createOutfit', + module: 'wardrobe', + scope: 'user-space', + policyHint: 'write', + description: + "Compose a new outfit in the active space. `garmentIds` must reference garments the caller owns in the same space — the server will persist whatever you pass (there's no cross-space validation here), so call `wardrobe.listGarments` first to confirm the ids.", + input: createOutfitInput, + output: createOutfitOutput, + encryptedFields: { table: OUTFITS_TABLE, fields: [...OUTFIT_ENCRYPTED_FIELDS] }, + async handler(input, ctx) { + const key = await ctx.getMasterKey(); + const id = crypto.randomUUID(); + const plaintext = { + id, + name: input.name, + description: input.description, + garmentIds: input.garmentIds, + occasion: input.occasion, + tags: input.tags, + isFavorite: false, + }; + + const encrypted = await encryptRecordFields( + plaintext as unknown as Record, + OUTFIT_ENCRYPTED_FIELDS, + key + ); + + await pushInsert(syncCfg(ctx), OUTFITS_APP_ID, { + table: OUTFITS_TABLE, + id, + spaceId: ctx.spaceId, + data: encrypted, + }); + + ctx.logger.info('wardrobe.createOutfit', { + outfitId: id, + garmentCount: input.garmentIds.length, + occasion: input.occasion ?? 'none', + }); + + return { + outfit: { id, name: input.name, description: input.description, @@ -327,40 +361,10 @@ export const wardrobeCreateOutfit: ToolSpec, - OUTFIT_ENCRYPTED_FIELDS, - key - ); - - await pushInsert(syncCfg(ctx), OUTFITS_APP_ID, { - table: OUTFITS_TABLE, - id, - spaceId: ctx.spaceId, - data: encrypted, - }); - - ctx.logger.info('wardrobe.createOutfit', { - outfitId: id, - garmentCount: input.garmentIds.length, - occasion: input.occasion ?? 'none', - }); - - return { - outfit: { - id, - name: input.name, - description: input.description, - garmentIds: input.garmentIds, - occasion: input.occasion, - tags: input.tags, - isFavorite: false, - }, - }; - }, - }; + }, + }; + }, +}; // ─── wardrobe.tryOn ─────────────────────────────────────────────── @@ -403,18 +407,11 @@ export const wardrobeTryOn: ToolSpec = { // 1. Fetch outfit + garments + meImages, decrypt what's needed. const key = await ctx.getMasterKey(); - const outfitsRes = await pullAll( - syncCfg(ctx), - OUTFITS_APP_ID, - OUTFITS_TABLE - ); + const outfitsRes = await pullAll(syncCfg(ctx), OUTFITS_APP_ID, OUTFITS_TABLE); const outfit = outfitsRes.changes .filter((c) => c.op !== 'delete' && c.data) .map((c) => c.data as RawOutfitRow) - .find( - (row) => - row.id === input.outfitId && !row.deletedAt && row.spaceId === ctx.spaceId - ); + .find((row) => row.id === input.outfitId && !row.deletedAt && row.spaceId === ctx.spaceId); if (!outfit) { throw new Error(`Outfit ${input.outfitId} not found in the active space`); } @@ -430,26 +427,16 @@ export const wardrobeTryOn: ToolSpec = { throw new Error('Outfit has no garments'); } - const garmentsRes = await pullAll( - syncCfg(ctx), - GARMENTS_APP_ID, - GARMENTS_TABLE - ); + const garmentsRes = await pullAll(syncCfg(ctx), GARMENTS_APP_ID, GARMENTS_TABLE); const garmentSet = new Set(garmentIds); const relevantGarments = garmentsRes.changes .filter((c) => c.op !== 'delete' && c.data) .map((c) => c.data as RawGarmentRow) .filter( - (row) => - row.id && - garmentSet.has(row.id) && - !row.deletedAt && - row.spaceId === ctx.spaceId + (row) => row.id && garmentSet.has(row.id) && !row.deletedAt && row.spaceId === ctx.spaceId ); if (relevantGarments.length === 0) { - throw new Error( - 'None of the outfit garments exist in the active space (moved or deleted?)' - ); + throw new Error('None of the outfit garments exist in the active space (moved or deleted?)'); } // Garment metadata we need (category, mediaIds) is plaintext; no