// Designer Service — streams MCP app UI design via Claude import Anthropic from '@anthropic-ai/sdk'; import { getSkill } from '../skills/loader'; import type { PipelineEvent, ToolDefinition, AppPattern, AppBundle, } from '../types'; const MODEL = 'claude-sonnet-4-5-20250514'; const MAX_TOKENS = 8192; export interface AppDesignConfig { name: string; pattern: AppPattern; widgets: string[]; } /** * Design an MCP app UI for the given tools and configuration. * Streams progress and yields the final AppBundle. */ export async function* designApp( tools: ToolDefinition[], appConfig: AppDesignConfig ): AsyncGenerator { const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY }); const systemPrompt = getSkill('designer'); yield { type: 'design:progress', app: appConfig.name, percent: 0, }; const toolDescriptions = tools.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema, outputSchema: t.outputSchema, })); try { const stream = client.messages.stream({ model: MODEL, max_tokens: MAX_TOKENS, system: systemPrompt, messages: [ { role: 'user', content: `Design an MCP App with the following requirements: ## App Configuration - **Name**: ${appConfig.name} - **Pattern**: ${appConfig.pattern} - **Widgets**: ${appConfig.widgets.join(', ')} ## Available MCP Tools \`\`\`json ${JSON.stringify(toolDescriptions, null, 2)} \`\`\` ## Requirements 1. Generate a single self-contained HTML file with embedded CSS and JavaScript 2. Use the "${appConfig.pattern}" layout pattern 3. Include the following widgets: ${appConfig.widgets.join(', ')} 4. Each widget should bind to the appropriate MCP tool(s) via window.mcpRequest() 5. The app must call tools via the MCP Apps bridge: window.mcpRequest(toolName, params) 6. Include responsive design, clean modern UI (Tailwind CDN is fine) 7. Include loading states, error handling, and empty states 8. Wrap the complete HTML in a \`\`\`html code block ## Tool Binding Format For each widget, specify which tool it uses as a data-mcp-tool attribute. Generate the complete app now.`, }, ], }); let fullText = ''; for await (const event of stream) { if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') { fullText += event.delta.text; const percent = Math.min(85, Math.floor((fullText.length / 3000) * 85)); // Yield progress at intervals if (percent % 10 < 2) { yield { type: 'design:progress', app: appConfig.name, percent, }; } } } const finalMessage = await stream.finalMessage(); yield { type: 'design:progress', app: appConfig.name, percent: 90 }; // Extract the HTML from the response const html = extractHtml(fullText); const toolBindings = extractToolBindings(fullText, tools); if (html) { const bundle: AppBundle = { id: crypto.randomUUID(), name: appConfig.name, pattern: appConfig.pattern, html, toolBindings, }; yield { type: 'design:complete', bundle }; } else { yield { type: 'error', message: 'Failed to extract HTML app from Claude response', recoverable: true, }; } } catch (error) { const msg = error instanceof Error ? error.message : String(error); yield { type: 'error', message: `App design failed: ${msg}`, recoverable: error instanceof Anthropic.RateLimitError, }; } } function extractHtml(text: string): string | null { const htmlMatch = text.match(/```html\s*\n([\s\S]*?)\n```/); return htmlMatch ? htmlMatch[1].trim() : null; } function extractToolBindings( text: string, tools: ToolDefinition[] ): Record { const bindings: Record = {}; // Look for data-mcp-tool attributes or mcpRequest calls for (const tool of tools) { if (text.includes(tool.name)) { // Map widget/component name to tool name const widgetMatch = text.match( new RegExp(`(?:data-mcp-tool|mcpRequest)\\s*[=(]\\s*['"]${tool.name}['"]`, 'i') ); if (widgetMatch) { bindings[tool.name] = tool.name; } } } // Fallback: just map all mentioned tools if (Object.keys(bindings).length === 0) { for (const tool of tools) { if (text.includes(tool.name)) { bindings[tool.name] = tool.name; } } } return bindings; }