feat(tool-registry): wardrobe.* MCP tools — listGarments/listOutfits/createOutfit/tryOn (M5)

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-23 18:59:31 +02:00
parent 66b7e08df2
commit 7e3f53f8a5

View file

@ -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<typeof listGarmentsInput, typeof listGarmentsOutput> =
{
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<RawGarmentRow>(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<typeof listGarmentsInput, typeof listGarmentsOutput> = {
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<RawGarmentRow>(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<string, unknown>, GARMENT_ENCRYPTED_FIELDS, key)
const decrypted = (await Promise.all(
alive.map((row) =>
decryptRecordFields(
row as unknown as Record<string, unknown>,
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<typeof createOutfitInput, typeof createOutfitOutput> =
{
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<typeof createOutfitInput, typeof createOutfitOutput> = {
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<string, unknown>,
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<typeof createOutfitInput, typeof cre
occasion: input.occasion,
tags: input.tags,
isFavorite: false,
};
const encrypted = await encryptRecordFields(
plaintext as unknown as Record<string, unknown>,
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<typeof tryOnInput, typeof tryOnOutput> = {
// 1. Fetch outfit + garments + meImages, decrypt what's needed.
const key = await ctx.getMasterKey();
const outfitsRes = await pullAll<RawOutfitRow>(
syncCfg(ctx),
OUTFITS_APP_ID,
OUTFITS_TABLE
);
const outfitsRes = await pullAll<RawOutfitRow>(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<typeof tryOnInput, typeof tryOnOutput> = {
throw new Error('Outfit has no garments');
}
const garmentsRes = await pullAll<RawGarmentRow>(
syncCfg(ctx),
GARMENTS_APP_ID,
GARMENTS_TABLE
);
const garmentsRes = await pullAll<RawGarmentRow>(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