import { z } from 'zod' import { resolveAsset } from '../refs.js' import { assetTypeSchema } from '../schemas.js' import type { MarketV1Client } from '../v1/client.js' import type { AssetRefEntry } from '../v1/index.js' import { base64, result } from './result.js' import type { MarketMcpServer } from './types.js' const includeUnapprovedSchema = z .boolean() .default(false) .describe( 'Include unapproved assets only when the user explicitly asks to inspect unmoderated content and accepts that it has not passed Market review.', ) const searchMarketSchema = z .object({ query: z.string().min(1).max(200), type: assetTypeSchema, limit: z.coerce.number().int().min(1).max(5).default(3), includeUnapproved: includeUnapprovedSchema, }) .strict() const assetRefSchema = z.string().min(1).describe('Asset name or exact name@version') const assetInput = z .object({ asset: assetRefSchema, includeUnapproved: includeUnapprovedSchema, }) .strict() export function registerAssetTools( server: MarketMcpServer, client: MarketV1Client, fetcher: typeof globalThis.fetch, ): void { server.registerTool( 'list_market_asset_types', { title: 'List Market asset types', description: 'List current Drawcall Market asset types with their search, generation, and installation guidance.', annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true, idempotentHint: true, }, }, async () => result({ types: await client.asset.installMetadata() }), ) server.registerTool( 'search_market', { title: 'Search Market', description: 'Search Drawcall Market for assets matching one concrete need. Returns exact name@version references; call get_market_asset before selecting one.', inputSchema: searchMarketSchema, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true, idempotentHint: true, }, }, async ({ query, type, limit, includeUnapproved }) => { const response = await client.asset.search({ query, type, limit, page: 1, includeUnapproved, }) if (!('items' in response)) { throw new Error('Market search unexpectedly returned an exact asset result') } return result({ assets: response.items.map((asset) => { const previewUrl = safePublicUrl(asset.previewUrl, asset) return { asset: `${asset.name}@${asset.latestVersion}`, name: asset.name, version: asset.latestVersion, type: asset.type, description: asset.description, approved: asset.approved, access: asset.access, hasPreview: Boolean(asset.previewUrl), ...(previewUrl ? { previewUrl } : {}), } }), total: response.total, }) }, ) server.registerTool( 'get_market_asset', { title: 'Get Market asset', description: 'Get one exact Drawcall Market asset manifest with its version history, dependencies, and file metadata. Capability-bearing URLs and owner identifiers are never returned.', inputSchema: assetInput, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true, idempotentHint: true, }, }, async ({ asset, includeUnapproved }) => result({ asset: mcpAsset(await resolveAsset(client.asset, asset, { includeUnapproved })), }), ) server.registerTool( 'get_market_asset_preview', { title: 'Get Market asset preview', description: 'Return the preview image for one exact Drawcall Market asset. The server fetches restricted previews without returning their capability-bearing URL.', inputSchema: assetInput, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true, idempotentHint: true, }, }, async ({ asset: ref, includeUnapproved }) => { const asset = await resolveAsset(client.asset, ref, { includeUnapproved }) if (!asset.previewUrl) throw new Error(`Asset "${ref}" has no preview image`) const response = await fetchPreview(fetcher, asset.previewUrl, ref) if (!response.ok) { throw new Error(`Could not read preview for "${ref}": ${response.status}`) } const mimeType = response.headers.get('content-type')?.split(';', 1)[0] if (!mimeType?.startsWith('image/')) { throw new Error(`Preview for "${ref}" returned ${mimeType ?? 'no media type'}`) } return { content: [ { type: 'image', data: base64(new Uint8Array(await response.arrayBuffer())), mimeType, }, ], structuredContent: { asset: `${asset.name}@${asset.version}`, mediaType: mimeType, }, } }, ) } function mcpAsset(asset: AssetRefEntry): Record { const publicAsset = asset.access === 'public' && asset.approved const previewUrl = safePublicUrl(asset.previewUrl, asset) const zipUrl = safePublicUrl(asset.zipUrl, asset) return { name: asset.name, type: asset.type, version: asset.version, description: asset.description, approved: asset.approved, access: asset.access, npmDependencies: asset.npmDependencies, assetDependencies: asset.assetDependencies, skillDependencies: asset.skillDependencies, files: asset.files.map(({ url, ...file }) => { const publicUrl = safePublicUrl(url, asset) return publicUrl ? { ...file, url: publicUrl } : file }), hasPreview: Boolean(asset.previewUrl), versions: asset.versions, ...(previewUrl ? { previewUrl } : {}), ...(zipUrl ? { zipUrl } : {}), ...(!publicAsset ? { restrictedUrlsOmitted: true } : {}), } } async function fetchPreview( fetcher: typeof globalThis.fetch, url: string, ref: string, ): Promise { try { return await fetcher(url) } catch { throw new Error(`Could not read preview for "${ref}"`) } } function safePublicUrl( url: string | null, asset: Pick, ): string | undefined { if (!url || asset.access !== 'public' || !asset.approved) return undefined try { const parsed = new URL(url) return parsed.searchParams.has('key') ? undefined : parsed.href } catch { return undefined } }