=== NEW === - studio/ — MCPEngine Studio scaffold (Next.js monorepo, build plan) - docs/FACTORY-V2.md — Factory v2 architecture doc - docs/CALENDLY_MCP_BUILD_SUMMARY.md — Calendly MCP build report === UPDATED SERVERS === - fieldedge: Added jobs-tools, UI build script, main entry update - lightspeed: Updated main + server entry points - squarespace: Added collection-browser + page-manager apps - toast: Added main + server entry points === INFRA === - infra/command-center/state.json — Updated pipeline state - infra/command-center/FACTORY-V2.md — Factory v2 operator playbook
171 lines
4.5 KiB
TypeScript
171 lines
4.5 KiB
TypeScript
// 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<PipelineEvent> {
|
|
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<string, string> {
|
|
const bindings: Record<string, string> = {};
|
|
|
|
// 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;
|
|
}
|