From 8d1acaf28183e0b787de4304ca0152c2a34cfe12 Mon Sep 17 00:00:00 2001 From: Jayesh Patil Date: Tue, 17 Feb 2026 19:42:46 -0500 Subject: [PATCH] feat: add Stitch Design server example --- examples/stitch-server/.gitignore | 3 + examples/stitch-server/README.md | 51 ++ examples/stitch-server/main.ts | 89 ++ examples/stitch-server/mcp-app.html | 13 + examples/stitch-server/package.json | 57 ++ examples/stitch-server/server.test.ts | 296 ++++++ examples/stitch-server/server.ts | 387 ++++++++ examples/stitch-server/src/App.tsx | 13 + examples/stitch-server/src/StitchApp.tsx | 187 ++++ .../src/components/DesignContext.tsx | 173 ++++ .../src/components/DesignViewer.tsx | 243 +++++ .../src/components/GenerateDesign.tsx | 165 ++++ .../src/components/ProjectList.tsx | 102 +++ .../src/components/ScreenList.tsx | 90 ++ examples/stitch-server/src/global.css | 859 ++++++++++++++++++ examples/stitch-server/src/types.ts | 133 +++ examples/stitch-server/stitch-client.ts | 590 ++++++++++++ examples/stitch-server/tsconfig.json | 20 + examples/stitch-server/tsconfig.server.json | 17 + examples/stitch-server/vite.config.ts | 24 + 20 files changed, 3512 insertions(+) create mode 100644 examples/stitch-server/.gitignore create mode 100644 examples/stitch-server/README.md create mode 100644 examples/stitch-server/main.ts create mode 100644 examples/stitch-server/mcp-app.html create mode 100644 examples/stitch-server/package.json create mode 100644 examples/stitch-server/server.test.ts create mode 100644 examples/stitch-server/server.ts create mode 100644 examples/stitch-server/src/App.tsx create mode 100644 examples/stitch-server/src/StitchApp.tsx create mode 100644 examples/stitch-server/src/components/DesignContext.tsx create mode 100644 examples/stitch-server/src/components/DesignViewer.tsx create mode 100644 examples/stitch-server/src/components/GenerateDesign.tsx create mode 100644 examples/stitch-server/src/components/ProjectList.tsx create mode 100644 examples/stitch-server/src/components/ScreenList.tsx create mode 100644 examples/stitch-server/src/global.css create mode 100644 examples/stitch-server/src/types.ts create mode 100644 examples/stitch-server/stitch-client.ts create mode 100644 examples/stitch-server/tsconfig.json create mode 100644 examples/stitch-server/tsconfig.server.json create mode 100644 examples/stitch-server/vite.config.ts diff --git a/examples/stitch-server/.gitignore b/examples/stitch-server/.gitignore new file mode 100644 index 000000000..f4e2c6d6b --- /dev/null +++ b/examples/stitch-server/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.tsbuildinfo diff --git a/examples/stitch-server/README.md b/examples/stitch-server/README.md new file mode 100644 index 000000000..090fe9976 --- /dev/null +++ b/examples/stitch-server/README.md @@ -0,0 +1,51 @@ +# Example: Stitch Design Server + +An MCP App server that integrates with [Google Stitch](https://stitch.google.com) for AI-powered UI design. Provides 6 tools for managing projects, browsing screens, generating new designs, and extracting design tokens — all rendered in an interactive React UI. + +## Tools + +| Tool | Description | +| --- | --- | +| `list-projects` | List all Stitch design projects | +| `list-screens` | List screens within a project | +| `design-viewer` | View a screen with image preview, code, and design tokens | +| `generate-design` | Generate a new screen from a text prompt | +| `extract-design-context` | Extract colors, fonts, spacing, and layout tokens | +| `create-project` | Create a new Stitch project | + +## Prerequisites + +- A Google Cloud project with the Stitch API enabled +- Application Default Credentials (ADC) configured: + ```bash + gcloud auth application-default login + ``` +- Set `GOOGLE_CLOUD_PROJECT` environment variable to your GCP project ID + +## MCP Client Configuration + +```json +{ + "mcpServers": { + "stitch-design": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-stitch", "--stdio"] + } + } +} +``` + +## Local Development + +```bash +npm install +npm run build +npm start # HTTP mode on port 3001 +npm start -- --stdio # stdio mode +``` + +## Running Tests + +```bash +bun test server.test.ts +``` diff --git a/examples/stitch-server/main.ts b/examples/stitch-server/main.ts new file mode 100644 index 000000000..22d7ac1ef --- /dev/null +++ b/examples/stitch-server/main.ts @@ -0,0 +1,89 @@ +/** + * Entry point for running the Stitch Design MCP server. + * Run with: npx @modelcontextprotocol/server-stitch + * Or: node dist/index.js [--stdio] + */ + +import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import cors from "cors"; +import type { Request, Response } from "express"; +import { createServer } from "./server.js"; + +/** + * Starts an MCP server with Streamable HTTP transport in stateless mode. + */ +export async function startStreamableHTTPServer( + createServer: () => McpServer, +): Promise { + const port = parseInt(process.env.PORT ?? "3001", 10); + + const app = createMcpExpressApp({ host: "0.0.0.0" }); + app.use(cors()); + + app.all("/mcp", async (req: Request, res: Response) => { + const server = createServer(); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + + res.on("close", () => { + transport.close().catch(() => {}); + server.close().catch(() => {}); + }); + + try { + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error("MCP error:", error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { code: -32603, message: "Internal server error" }, + id: null, + }); + } + } + }); + + const httpServer = app.listen(port, (err) => { + if (err) { + console.error("Failed to start server:", err); + process.exit(1); + } + console.log(`MCP server listening on http://localhost:${port}/mcp`); + }); + + const shutdown = () => { + console.log("\nShutting down..."); + httpServer.close(() => process.exit(0)); + }; + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); +} + +/** + * Starts an MCP server with stdio transport. + */ +export async function startStdioServer( + createServer: () => McpServer, +): Promise { + await createServer().connect(new StdioServerTransport()); +} + +async function main() { + if (process.argv.includes("--stdio")) { + await startStdioServer(createServer); + } else { + await startStreamableHTTPServer(createServer); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/examples/stitch-server/mcp-app.html b/examples/stitch-server/mcp-app.html new file mode 100644 index 000000000..3ff8f0cdd --- /dev/null +++ b/examples/stitch-server/mcp-app.html @@ -0,0 +1,13 @@ + + + + + + Stitch Design + + + +
+ + + diff --git a/examples/stitch-server/package.json b/examples/stitch-server/package.json new file mode 100644 index 000000000..ba4681a43 --- /dev/null +++ b/examples/stitch-server/package.json @@ -0,0 +1,57 @@ +{ + "name": "@modelcontextprotocol/server-stitch", + "version": "1.0.1", + "type": "module", + "description": "MCP App for Google Stitch — AI-powered UI design generation and management with interactive previews", + "repository": { + "type": "git", + "url": "https://github.com/modelcontextprotocol/ext-apps", + "directory": "examples/stitch-server" + }, + "license": "MIT", + "main": "dist/server.js", + "types": "dist/server.d.ts", + "bin": { + "mcp-server-stitch": "dist/index.js" + }, + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/server.d.ts", + "default": "./dist/server.js" + } + }, + "scripts": { + "build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --banner \"#!/usr/bin/env node\"", + "watch": "cross-env INPUT=mcp-app.html vite build --watch", + "serve": "bun --watch main.ts", + "start": "cross-env NODE_ENV=development npm run build && npm run serve", + "dev": "cross-env NODE_ENV=development concurrently 'npm run watch' 'npm run serve'", + "prepublishOnly": "npm run build" + }, + "dependencies": { + "@modelcontextprotocol/ext-apps": "^1.0.0", + "@modelcontextprotocol/sdk": "^1.24.0", + "cors": "^2.8.5", + "express": "^5.1.0", + "google-auth-library": "^9.0.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.0", + "@types/node": "22.10.0", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "@vitejs/plugin-react": "^4.3.4", + "concurrently": "^9.2.1", + "cross-env": "^10.1.0", + "typescript": "^5.9.3", + "vite": "^6.0.0", + "vite-plugin-singlefile": "^2.3.0" + } +} diff --git a/examples/stitch-server/server.test.ts b/examples/stitch-server/server.test.ts new file mode 100644 index 000000000..ad478093e --- /dev/null +++ b/examples/stitch-server/server.test.ts @@ -0,0 +1,296 @@ +import { describe, expect, it } from "bun:test"; +import { + STITCH_API_BASE, + STITCH_MCP_URL, + normalizeProjectId, + extractInlineStyles, + extractTailwindConfig, + extractTailwindColors, + extractTailwindFonts, + extractColors, + extractFonts, + extractSpacing, + extractLayouts, +} from "./stitch-client.js"; +import { createServer } from "./server.js"; + +// --------------------------------------------------------------------------- +// A. Configuration constants +// --------------------------------------------------------------------------- +describe("Configuration constants", () => { + it("STITCH_API_BASE points to the v1 REST endpoint", () => { + expect(STITCH_API_BASE).toBe("https://stitch.googleapis.com/v1"); + }); + + it("STITCH_MCP_URL points to the MCP JSON-RPC endpoint", () => { + expect(STITCH_MCP_URL).toBe("https://stitch.googleapis.com/mcp"); + }); +}); + +// --------------------------------------------------------------------------- +// B. normalizeProjectId +// --------------------------------------------------------------------------- +describe("normalizeProjectId", () => { + it('strips "projects/" prefix', () => { + expect(normalizeProjectId("projects/123")).toBe("123"); + }); + + it("returns bare ID unchanged", () => { + expect(normalizeProjectId("123")).toBe("123"); + }); + + it("strips only the first projects/ prefix", () => { + expect(normalizeProjectId("projects/projects/123")).toBe("projects/123"); + }); + + it("handles empty string", () => { + expect(normalizeProjectId("")).toBe(""); + }); +}); + +// --------------------------------------------------------------------------- +// C. extractColors +// --------------------------------------------------------------------------- +describe("extractColors", () => { + it("extracts hex colors from CSS", () => { + const css = "color: #ff0000; background: #00ff00;"; + const colors = extractColors(css); + expect(colors.length).toBe(2); + expect(colors[0]!.hex).toBe("#ff0000"); + expect(colors[1]!.hex).toBe("#00ff00"); + }); + + it("extracts rgb colors", () => { + const css = "color: rgb(255,0,0);"; + const colors = extractColors(css); + expect(colors.length).toBe(1); + expect(colors[0]!.hex).toBe("rgb(255,0,0)"); + }); + + it("deduplicates identical hex values", () => { + const css = "color: #ff0000; border-color: #ff0000;"; + const colors = extractColors(css); + expect(colors.length).toBe(1); + }); + + it("returns empty array for CSS with no colors", () => { + const css = "display: flex; margin: 10px;"; + const colors = extractColors(css); + expect(colors.length).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// D. extractFonts +// --------------------------------------------------------------------------- +describe("extractFonts", () => { + it("extracts font-family from CSS", () => { + const css = "font-family: 'Roboto', sans-serif;"; + const fonts = extractFonts(css); + expect(fonts.length).toBe(1); + expect(fonts[0]!.family).toBe("Roboto, sans-serif"); + }); + + it("extracts font-weight and font-size", () => { + const css = + "font-family: Arial; font-weight: 700; font-size: 24px;"; + const fonts = extractFonts(css); + expect(fonts.length).toBe(1); + expect(fonts[0]!.weight).toBe("700"); + expect(fonts[0]!.size).toBe("24px"); + }); + + it("returns empty array for CSS without font-family", () => { + const css = "color: red; margin: 10px;"; + const fonts = extractFonts(css); + expect(fonts.length).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// E. extractSpacing +// --------------------------------------------------------------------------- +describe("extractSpacing", () => { + it("extracts margin, padding, and gap values", () => { + const css = "margin: 10px; padding: 20px; gap: 8px;"; + const spacing = extractSpacing(css); + expect(spacing.length).toBe(3); + expect(spacing[0]!.name).toBe("margin"); + expect(spacing[0]!.value).toBe("10px"); + expect(spacing[1]!.name).toBe("padding"); + expect(spacing[2]!.name).toBe("gap"); + }); + + it("deduplicates same property-value pairs", () => { + const css = "margin: 10px; margin: 10px;"; + const spacing = extractSpacing(css); + expect(spacing.length).toBe(1); + }); + + it("returns empty array for CSS without spacing", () => { + const css = "color: red; display: flex;"; + const spacing = extractSpacing(css); + expect(spacing.length).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// F. extractLayouts +// --------------------------------------------------------------------------- +describe("extractLayouts", () => { + it("detects display: flex", () => { + const html = '
content
'; + const layouts = extractLayouts(html); + expect(layouts.some((l) => l.type === "Flexbox")).toBe(true); + }); + + it("detects display: grid", () => { + const html = '
content
'; + const layouts = extractLayouts(html); + expect(layouts.some((l) => l.type === "Grid")).toBe(true); + }); + + it("detects position: absolute", () => { + const html = '
content
'; + const layouts = extractLayouts(html); + expect(layouts.some((l) => l.type === "Absolute")).toBe(true); + }); + + it("detects Tailwind flex class", () => { + const html = '
content
'; + const layouts = extractLayouts(html); + expect(layouts.some((l) => l.type === "Flexbox")).toBe(true); + }); + + it("detects Tailwind grid class", () => { + const html = '
content
'; + const layouts = extractLayouts(html); + expect(layouts.some((l) => l.type === "Grid")).toBe(true); + }); + + it("detects Tailwind absolute class", () => { + const html = '
content
'; + const layouts = extractLayouts(html); + expect(layouts.some((l) => l.type === "Absolute")).toBe(true); + }); + + it("returns empty array for no layout markers", () => { + const html = "

Hello world

"; + const layouts = extractLayouts(html); + expect(layouts.length).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// G. extractInlineStyles +// --------------------------------------------------------------------------- +describe("extractInlineStyles", () => { + it("extracts content from "; + const styles = extractInlineStyles(html); + expect(styles).toContain(".foo { color: red; }"); + }); + + it("handles multiple "; + const styles = extractInlineStyles(html); + expect(styles).toContain(".a { color: red; }"); + expect(styles).toContain(".b { color: blue; }"); + }); + + it("returns empty string for HTML without style tags", () => { + const html = "
Hello
"; + const styles = extractInlineStyles(html); + expect(styles).toBe(""); + }); +}); + +// --------------------------------------------------------------------------- +// H. extractTailwindConfig +// --------------------------------------------------------------------------- +describe("extractTailwindConfig", () => { + it("extracts tailwind.config from script tags", () => { + const html = + ''; + const config = extractTailwindConfig(html); + expect(config).toContain("theme"); + expect(config).toContain("extend"); + }); + + it("returns empty string when no config found", () => { + const html = "
No tailwind here
"; + const config = extractTailwindConfig(html); + expect(config).toBe(""); + }); +}); + +// --------------------------------------------------------------------------- +// I. extractTailwindColors +// --------------------------------------------------------------------------- +describe("extractTailwindColors", () => { + it("parses color definitions from config", () => { + const config = "primary: '#135bec', secondary: '#ff5733'"; + const colors = extractTailwindColors(config); + expect(colors.length).toBe(2); + expect(colors[0]!.name).toBe("primary"); + expect(colors[0]!.hex).toBe("#135bec"); + }); + + it("parses DEFAULT color entry", () => { + const config = "DEFAULT: '#ffffff'"; + const colors = extractTailwindColors(config); + expect(colors.length).toBe(1); + expect(colors[0]!.hex).toBe("#ffffff"); + }); + + it("deduplicates by hex value", () => { + const config = "primary: '#135bec', main: '#135bec'"; + const colors = extractTailwindColors(config); + expect(colors.length).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// J. extractTailwindFonts +// --------------------------------------------------------------------------- +describe("extractTailwindFonts", () => { + it("extracts font families from Tailwind config", () => { + const config = "fontFamily: { sans: ['Inter', 'sans-serif'] }"; + const html = ""; + const fonts = extractTailwindFonts(config, html); + expect(fonts.length).toBe(1); + expect(fonts[0]!.family).toBe("Inter"); + }); + + it("extracts Google Fonts from HTML", () => { + const config = ""; + const html = + ''; + const fonts = extractTailwindFonts(config, html); + expect(fonts.length).toBe(1); + expect(fonts[0]!.family).toBe("Roboto"); + }); + + it("deduplicates fonts from config and Google Fonts", () => { + const config = "fontFamily: { sans: ['Roboto', 'sans-serif'] }"; + const html = + ''; + const fonts = extractTailwindFonts(config, html); + expect(fonts.length).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// K. Server creation +// --------------------------------------------------------------------------- +describe("createServer", () => { + it("returns an McpServer instance with correct name", () => { + const server = createServer(); + expect(server).toBeDefined(); + // McpServer stores its name internally + expect((server as unknown as { name: string }).name).toBeUndefined(); + // The server object should exist and be truthy + expect(!!server).toBe(true); + }); +}); diff --git a/examples/stitch-server/server.ts b/examples/stitch-server/server.ts new file mode 100644 index 000000000..22a70b839 --- /dev/null +++ b/examples/stitch-server/server.ts @@ -0,0 +1,387 @@ +import { + registerAppTool, + registerAppResource, + RESOURCE_MIME_TYPE, +} from "@modelcontextprotocol/ext-apps/server"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { + CallToolResult, + ReadResourceResult, +} from "@modelcontextprotocol/sdk/types.js"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { z } from "zod"; +import { StitchClient } from "./stitch-client.js"; + +// Works both from source (server.ts) and compiled (dist/server.js) +const DIST_DIR = import.meta.filename.endsWith(".ts") + ? path.join(import.meta.dirname, "dist") + : import.meta.dirname; + +const RESOURCE_URI = "ui://stitch-design/app.html"; + +function errorResult(err: unknown): CallToolResult { + const message = err instanceof Error ? err.message : String(err); + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ type: "error", data: { message } }), + }, + ], + isError: true, + }; +} + +export function createServer(): McpServer { + const server = new McpServer({ + name: "stitch-design", + version: "1.0.0", + }); + + const stitch = new StitchClient(); + + // Tool 1: List Projects + registerAppTool( + server, + "list-projects", + { + description: + 'List all Stitch design projects. Shows a visual grid of projects with names and metadata. Each project name is in format "projects/{id}" — use just the numeric ID when calling other tools.', + inputSchema: { + filter: z + .string() + .optional() + .describe('Filter: "view=owned" (default) or "view=shared"'), + }, + _meta: { + ui: { resourceUri: RESOURCE_URI }, + }, + }, + async (args): Promise => { + try { + const projects = await stitch.listProjects(args.filter); + return { + content: [ + { + type: "text", + text: JSON.stringify({ + type: "list_projects", + data: { projects }, + }), + }, + ], + }; + } catch (err) { + return errorResult(err); + } + }, + ); + + // Tool 2: List Screens + registerAppTool( + server, + "list-screens", + { + description: + "List all screens in a Stitch project. Shows a thumbnail grid of screen designs.", + inputSchema: { + projectId: z + .string() + .describe( + 'The Stitch project ID (numeric ID only, not "projects/..." format)', + ), + }, + _meta: { + ui: { resourceUri: RESOURCE_URI }, + }, + }, + async (args): Promise => { + try { + const screens = await stitch.listScreens(args.projectId); + return { + content: [ + { + type: "text", + text: JSON.stringify({ + type: "list_screens", + data: { projectId: args.projectId, screens }, + }), + }, + ], + }; + } catch (err) { + return errorResult(err); + } + }, + ); + + // Tool 3: Design Viewer + registerAppTool( + server, + "design-viewer", + { + description: + "View a Stitch screen design with image preview, HTML/CSS code, and extracted design tokens (colors, fonts, spacing).", + inputSchema: { + projectId: z + .string() + .describe( + 'The Stitch project ID (numeric ID only, not "projects/..." format)', + ), + screenId: z.string().describe("The screen ID to view"), + }, + _meta: { + ui: { resourceUri: RESOURCE_URI }, + }, + }, + async (args): Promise => { + try { + const [screen, imageData, codeData, designContext] = await Promise.all([ + stitch.getScreen(args.projectId, args.screenId), + stitch.fetchScreenImage(args.projectId, args.screenId), + stitch.fetchScreenCode(args.projectId, args.screenId), + stitch.extractDesignContext(args.projectId, args.screenId), + ]); + + return { + content: [ + { + type: "text", + text: JSON.stringify({ + type: "design_viewer", + data: { screen, imageData, codeData, designContext }, + }), + }, + ], + }; + } catch (err) { + return errorResult(err); + } + }, + ); + + // Tool 4: Generate Design + registerAppTool( + server, + "generate-design", + { + description: + "Generate a new screen design in a Google Stitch project using AI. Creates the design in Stitch and returns a preview with code. Use this tool when the user wants to generate, create, or design a new screen in Stitch. If no projectId is provided, a new project will be automatically created.", + inputSchema: { + projectId: z + .string() + .optional() + .describe( + 'The Stitch project ID (numeric ID only, not "projects/..." format). If omitted, a new project is auto-created.', + ), + prompt: z + .string() + .describe("Text description of the desired screen design"), + deviceType: z + .enum(["MOBILE", "DESKTOP", "TABLET"]) + .optional() + .describe("Target device type (default: MOBILE)"), + modelId: z + .enum(["GEMINI_3_PRO", "GEMINI_3_FLASH"]) + .optional() + .describe("AI model to use (default: GEMINI_3_FLASH)"), + }, + _meta: { + ui: { resourceUri: RESOURCE_URI }, + }, + }, + async (args): Promise => { + try { + // Determine or create the target project + let projectId = args.projectId; + if (!projectId) { + const projectTitle = + args.prompt.length > 50 + ? args.prompt.substring(0, 50).trim() + "..." + : args.prompt; + const project = await stitch.createProject(projectTitle); + projectId = project.name.replace(/^projects\//, ""); + } + + const result = await stitch.generateScreenFromText({ + projectId, + prompt: args.prompt, + deviceType: args.deviceType, + modelId: args.modelId, + }); + + // Extract suggestions from output components if present + const suggestions: string[] = []; + if (result.outputComponents) { + for (const component of result.outputComponents) { + if (component.suggestions) { + suggestions.push(...component.suggestions); + } + } + } + + // Use image URL directly from MCP response (avoids redundant API calls) + const imageData = result.screen?.screenshot?.downloadUrl || ""; + let codeData = { html: "", css: "" }; + + // Download HTML code content if available + if (result.screen?.htmlCode?.downloadUrl) { + try { + const html = await stitch.downloadFile( + result.screen.htmlCode.downloadUrl, + ); + codeData = { html, css: "" }; + } catch { + // Code download is optional + } + } + + return { + content: [ + { + type: "text", + text: JSON.stringify({ + type: "generate_design", + data: { + screen: result.screen, + allScreens: + result.allScreens.length > 1 + ? result.allScreens + : undefined, + imageData, + codeData, + suggestions: + suggestions.length > 0 ? suggestions : undefined, + prompt: args.prompt, + }, + }), + }, + ], + }; + } catch (err) { + return errorResult(err); + } + }, + ); + + // Tool 5: Extract Design Context + registerAppTool( + server, + "extract-design-context", + { + description: + "Extract design tokens (colors, fonts, spacing, layouts) from a Stitch screen. Useful for maintaining design consistency across screens.", + inputSchema: { + projectId: z + .string() + .describe( + 'The Stitch project ID (numeric ID only, not "projects/..." format)', + ), + screenId: z.string().describe("The screen ID to analyze"), + }, + _meta: { + ui: { resourceUri: RESOURCE_URI }, + }, + }, + async (args): Promise => { + try { + const designContext = await stitch.extractDesignContext( + args.projectId, + args.screenId, + ); + + return { + content: [ + { + type: "text", + text: JSON.stringify({ + type: "design_context", + data: designContext, + }), + }, + ], + }; + } catch (err) { + return errorResult(err); + } + }, + ); + + // Tool 6: Create Project + registerAppTool( + server, + "create-project", + { + description: + "Create a new Stitch design project. Returns the new project with its ID. Use this when the user wants to start a new design project in Stitch.", + inputSchema: { + title: z + .string() + .optional() + .describe( + 'Project title (e.g. "Product Dashboard Designs"). Defaults to "New Stitch Project".', + ), + }, + _meta: { + ui: { resourceUri: RESOURCE_URI }, + }, + }, + async (args): Promise => { + try { + const title = args.title || "New Stitch Project"; + const project = await stitch.createProject(title); + return { + content: [ + { + type: "text", + text: JSON.stringify({ + type: "create_project", + data: { project }, + }), + }, + ], + }; + } catch (err) { + return errorResult(err); + } + }, + ); + + // Register shared UI resource with CSP for external images + registerAppResource( + server, + RESOURCE_URI, + RESOURCE_URI, + { mimeType: RESOURCE_MIME_TYPE }, + async (): Promise => { + const html = await fs.readFile( + path.join(DIST_DIR, "mcp-app.html"), + "utf-8", + ); + + return { + contents: [ + { + uri: RESOURCE_URI, + mimeType: RESOURCE_MIME_TYPE, + text: html, + _meta: { + ui: { + csp: { + resourceDomains: [ + "https://lh3.googleusercontent.com", + "https://storage.googleapis.com", + "https://contribution.usercontent.google.com", + ], + }, + }, + }, + }, + ], + }; + }, + ); + + return server; +} diff --git a/examples/stitch-server/src/App.tsx b/examples/stitch-server/src/App.tsx new file mode 100644 index 000000000..89baa2315 --- /dev/null +++ b/examples/stitch-server/src/App.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import StitchApp from "./StitchApp"; +import "./global.css"; + +const root = document.getElementById("app"); +if (root) { + createRoot(root).render( + + + , + ); +} diff --git a/examples/stitch-server/src/StitchApp.tsx b/examples/stitch-server/src/StitchApp.tsx new file mode 100644 index 000000000..47d588ba8 --- /dev/null +++ b/examples/stitch-server/src/StitchApp.tsx @@ -0,0 +1,187 @@ +import { useState } from "react"; +import { useApp } from "@modelcontextprotocol/ext-apps/react"; +import type { App } from "@modelcontextprotocol/ext-apps"; +import type { ToolResultData } from "./types"; +import ProjectList from "./components/ProjectList"; +import ScreenList from "./components/ScreenList"; +import DesignViewer from "./components/DesignViewer"; +import GenerateDesign from "./components/GenerateDesign"; +import DesignContext from "./components/DesignContext"; + +export default function StitchApp() { + const [currentView, setCurrentView] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const { app, error: appError } = useApp({ + appInfo: { + name: "Stitch Design", + version: "1.0.0", + }, + capabilities: {}, + onAppCreated: (createdApp) => { + createdApp.ontoolresult = async (result) => { + setLoading(false); + + if (result.content) { + for (const item of result.content) { + if (item.type === "text" && item.text) { + try { + const data = JSON.parse(item.text); + if (data.type === "error") { + setError(data.data?.message || "An unknown error occurred"); + return; + } + if (data.type && data.data) { + setCurrentView(data as ToolResultData); + setError(null); + } + } catch { + // Non-JSON text, ignore + } + } + } + } + }; + + createdApp.ontoolinput = (_input) => { + setLoading(true); + setError(null); + }; + + createdApp.onerror = (err) => { + console.error("App error:", err); + setError(err.message || "An error occurred"); + setLoading(false); + }; + + createdApp.onhostcontextchanged = (context) => { + if (context.theme) { + document.documentElement.dataset.theme = context.theme; + } + }; + }, + }); + + if (appError) { + return ( +
+

Connection Error

+

{appError.message}

+
+ ); + } + + if (!app) { + return ( +
+
+

Connecting to Stitch Design...

+
+ ); + } + + const handleNavigate = (view: ToolResultData) => { + setCurrentView(view); + }; + + if (loading) { + return ( +
+
+

Loading...

+
+ ); + } + + if (error) { + return ( +
+

Error

+

{error}

+ +
+ ); + } + + return ( +
+ {renderView(currentView, app, handleNavigate, setLoading, setError)} +
+ ); +} + +function renderView( + view: ToolResultData | null, + app: App, + onNavigate: (view: ToolResultData) => void, + setLoading: (loading: boolean) => void, + setError: (error: string | null) => void, +) { + if (!view) { + return ( +
+

Stitch Design Agent

+

Waiting for a design command...

+

+ Try asking to list projects, view a screen, or generate a new design. +

+
+ ); + } + + const callTool = async (name: string, args: Record) => { + setLoading(true); + try { + const result = await app.callServerTool({ name, arguments: args }); + const textContent = result.content?.find((c) => c.type === "text"); + if (textContent && textContent.type === "text" && textContent.text) { + const data = JSON.parse(textContent.text); + if (data.type === "error") { + setError(data.data?.message || "An unknown error occurred"); + return; + } + if (data.type && data.data) { + onNavigate(data as ToolResultData); + } + } + } catch (err) { + setError(err instanceof Error ? err.message : "Tool call failed"); + } finally { + setLoading(false); + } + }; + + switch (view.type) { + case "list_projects": + return ; + case "list_screens": + return ; + case "design_viewer": + return ; + case "generate_design": + return ( + + ); + case "design_context": + return ; + case "create_project": + return ( +
+
+

Project Created

+
+
+

{view.data.project.title}

+

+ Project created successfully. You can now generate screens in it. +

+
+
+ ); + default: + return
Unknown view
; + } +} diff --git a/examples/stitch-server/src/components/DesignContext.tsx b/examples/stitch-server/src/components/DesignContext.tsx new file mode 100644 index 000000000..f65450060 --- /dev/null +++ b/examples/stitch-server/src/components/DesignContext.tsx @@ -0,0 +1,173 @@ +import { useState } from "react"; +import type { DesignContextData } from "../types"; + +interface DesignContextProps { + data: DesignContextData; +} + +export default function DesignContext({ data }: DesignContextProps) { + const { colors, fonts, spacing, layouts } = data; + const [copied, setCopied] = useState(false); + + const handleExportCSS = async () => { + const lines: string[] = [":root {"]; + + for (const color of colors) { + lines.push(` --${color.name}: ${color.hex};`); + } + for (const font of fonts) { + const safeName = font.family.toLowerCase().replace(/\s+/g, "-"); + lines.push(` --font-${safeName}: ${font.family};`); + } + for (const space of spacing) { + const safeName = space.name.toLowerCase().replace(/\s+/g, "-"); + lines.push(` --${safeName}: ${space.value};`); + } + + lines.push("}"); + + try { + await navigator.clipboard.writeText(lines.join("\n")); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Clipboard may not be available + } + }; + + const hasAnyTokens = + colors.length > 0 || + fonts.length > 0 || + spacing.length > 0 || + layouts.length > 0; + + if (!hasAnyTokens) { + return ( +
+
+

Design Context

+
+
+

No design tokens found

+

This screen does not have extractable design tokens.

+
+
+ ); + } + + return ( +
+
+

Design Context

+ +
+ +
+ {/* Colors */} + {colors.length > 0 && ( +
+

+ Colors {colors.length} +

+
+ {colors.map((color, i) => ( +
+
+
+ {color.hex} + {color.name} + {color.usage && ( + {color.usage} + )} +
+
+ ))} +
+
+ )} + + {/* Typography */} + {fonts.length > 0 && ( +
+

+ Typography {fonts.length} +

+
+ {fonts.map((font, i) => ( +
+
+ The quick brown fox jumps over the lazy dog +
+
+ {font.family} + + Weight: {font.weight} · Size: {font.size} + + {font.usage && ( + {font.usage} + )} +
+
+ ))} +
+
+ )} + + {/* Spacing */} + {spacing.length > 0 && ( +
+

+ Spacing {spacing.length} +

+
+ {spacing.map((space, i) => ( +
+ {space.name} +
+
+
+ {space.value} +
+ ))} +
+
+ )} + + {/* Layout Patterns */} + {layouts.length > 0 && ( +
+

+ Layout Patterns {layouts.length} +

+
+ {layouts.map((layout, i) => ( +
+ {layout.type} + {layout.description} +
+ ))} +
+
+ )} +
+
+ ); +} diff --git a/examples/stitch-server/src/components/DesignViewer.tsx b/examples/stitch-server/src/components/DesignViewer.tsx new file mode 100644 index 000000000..42f3cdcaa --- /dev/null +++ b/examples/stitch-server/src/components/DesignViewer.tsx @@ -0,0 +1,243 @@ +import { useState } from "react"; +import type { App } from "@modelcontextprotocol/ext-apps"; +import type { DesignViewerData } from "../types"; + +interface DesignViewerProps { + data: DesignViewerData; + app: App; + onCallTool: (name: string, args: Record) => Promise; +} + +type TabId = "code" | "tokens"; + +export default function DesignViewer({ + data, + app, + onCallTool, +}: DesignViewerProps) { + const { screen, imageData, codeData, designContext } = data; + const [activeTab, setActiveTab] = useState("code"); + const [copied, setCopied] = useState(false); + + const screenName = screen.title || "Untitled Screen"; + + // Extract project/screen IDs from screen.name (projects/{pid}/screens/{sid}) + const nameParts = screen.name?.split("/") || []; + const projectId = nameParts[1] || ""; + + const handleCopyCode = async () => { + const code = + activeTab === "code" + ? `\n${codeData.html}\n\n/* CSS */\n${codeData.css}` + : JSON.stringify(designContext, null, 2); + + try { + await navigator.clipboard.writeText(code); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Clipboard may not be available in sandboxed iframe + } + }; + + const handleOpenInStitch = () => { + if (screen.name) { + void app.openLink({ + url: `https://stitch.google.com/${screen.name}`, + }); + } + }; + + const handleBackToScreens = () => { + void onCallTool("list-screens", { projectId }); + }; + + return ( +
+
+ +

{screenName}

+
+ +
+
+ +
+ {/* Left: Image Preview */} +
+ {imageData ? ( + {screenName} + ) : ( +
+ No preview available +
+ )} + {screen.deviceType && ( +
+ {screen.deviceType} +
+ )} +
+ + {/* Right: Code & Tokens */} +
+
+ + + +
+ +
+ {activeTab === "code" ? ( +
+ {codeData.html && ( +
+

HTML

+
{codeData.html}
+
+ )} + {codeData.css && ( +
+

CSS

+
{codeData.css}
+
+ )} + {!codeData.html && !codeData.css && ( +
+

No code available for this screen.

+
+ )} +
+ ) : ( +
+ {/* Colors */} + {designContext.colors.length > 0 && ( +
+

Colors

+
+ {designContext.colors.map((color, i) => ( +
+
+ {color.hex} + {color.name} +
+ ))} +
+
+ )} + + {/* Fonts */} + {designContext.fonts.length > 0 && ( +
+

Typography

+
+ {designContext.fonts.map((font, i) => ( +
+ + Aa + +
+ {font.family} + + {font.weight} · {font.size} + +
+
+ ))} +
+
+ )} + + {/* Spacing */} + {designContext.spacing.length > 0 && ( +
+

Spacing

+
+ {designContext.spacing.map((space, i) => ( +
+ {space.name} +
+
+
+ {space.value} +
+ ))} +
+
+ )} + + {/* Layouts */} + {designContext.layouts.length > 0 && ( +
+

Layout Patterns

+
+ {designContext.layouts.map((layout, i) => ( +
+ {layout.type} + + {layout.description} + +
+ ))} +
+
+ )} + + {designContext.colors.length === 0 && + designContext.fonts.length === 0 && + designContext.spacing.length === 0 && + designContext.layouts.length === 0 && ( +
+

No design tokens extracted.

+
+ )} +
+ )} +
+
+
+
+ ); +} diff --git a/examples/stitch-server/src/components/GenerateDesign.tsx b/examples/stitch-server/src/components/GenerateDesign.tsx new file mode 100644 index 000000000..087943e78 --- /dev/null +++ b/examples/stitch-server/src/components/GenerateDesign.tsx @@ -0,0 +1,165 @@ +import type { App } from "@modelcontextprotocol/ext-apps"; +import type { GenerateDesignData, StitchScreen } from "../types"; + +interface GenerateDesignProps { + data: GenerateDesignData; + app: App; + onCallTool: (name: string, args: Record) => Promise; +} + +function extractScreenIds(s: StitchScreen) { + const parts = s.name?.split("/") || []; + return { projectId: parts[1] || "", screenId: parts[3] || "" }; +} + +export default function GenerateDesign({ + data, + app, + onCallTool, +}: GenerateDesignProps) { + const { screen, allScreens, imageData, codeData, suggestions, prompt } = + data; + + const screenName = screen?.title || "Generated Screen"; + const { projectId, screenId } = extractScreenIds(screen); + + const handleViewDetails = () => { + if (projectId && screenId) { + void onCallTool("design-viewer", { projectId, screenId }); + } + }; + + const handleScreenClick = (s: StitchScreen) => { + const ids = extractScreenIds(s); + if (ids.projectId && ids.screenId) { + void onCallTool("design-viewer", { + projectId: ids.projectId, + screenId: ids.screenId, + }); + } + }; + + const handleSuggestionClick = (suggestion: string) => { + if (projectId) { + void onCallTool("generate-design", { projectId, prompt: suggestion }); + } + }; + + const handleOpenInStitch = () => { + if (screen?.name) { + void app.openLink({ + url: `https://stitch.google.com/${screen.name}`, + }); + } + }; + + return ( +
+
+

Generated Design

+
+ {projectId && screenId && ( + + )} + +
+
+ +
+ {/* Prompt */} +
+ Prompt: + {prompt} +
+ + {/* Preview — Multi-screen gallery or single screen */} + {allScreens && allScreens.length > 1 ? ( +
+ {allScreens.map((s, i) => ( +
+ {s.screenshot?.downloadUrl ? ( + {s.title handleScreenClick(s)} + /> + ) : ( +
+

{s.title || `Screen ${i + 1}`}

+
+ )} + + {s.title || `Screen ${i + 1}`} + +
+ ))} +
+ ) : ( +
+ {imageData ? ( + {screenName} + ) : ( +
+

{screenName}

+

Screen generated successfully.

+ {projectId && screenId && ( + + )} +
+ )} +
+ )} + + {/* Code preview */} + {codeData && (codeData.html || codeData.css) && ( +
+

Generated Code

+ {codeData.html && ( +
+                {codeData.html.substring(0, 500)}
+                {codeData.html.length > 500 ? "..." : ""}
+              
+ )} +
+ )} + + {/* Suggestions */} + {suggestions && suggestions.length > 0 && ( +
+

Suggestions

+
+ {suggestions.map((suggestion, i) => ( + + ))} +
+
+ )} +
+
+ ); +} diff --git a/examples/stitch-server/src/components/ProjectList.tsx b/examples/stitch-server/src/components/ProjectList.tsx new file mode 100644 index 000000000..6b9e94cf6 --- /dev/null +++ b/examples/stitch-server/src/components/ProjectList.tsx @@ -0,0 +1,102 @@ +import type { ListProjectsData } from "../types"; + +interface ProjectListProps { + data: ListProjectsData; + onCallTool: (name: string, args: Record) => Promise; +} + +export default function ProjectList({ data, onCallTool }: ProjectListProps) { + const { projects } = data; + + const handleProjectClick = (projectName: string) => { + const projectId = projectName.replace("projects/", ""); + void onCallTool("list-screens", { projectId }); + }; + + const formatDate = (dateStr: string) => { + try { + return new Date(dateStr).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); + } catch { + return dateStr; + } + }; + + if (projects.length === 0) { + return ( +
+
+

Projects

+
+
+

No projects found

+

Create a new project in Stitch to get started.

+
+
+ ); + } + + return ( +
+
+

Projects

+ {projects.length} +
+
+ {projects.map((project) => { + const screenCount = project.screenInstances?.length ?? 0; + return ( + + ); + })} +
+
+ ); +} diff --git a/examples/stitch-server/src/components/ScreenList.tsx b/examples/stitch-server/src/components/ScreenList.tsx new file mode 100644 index 000000000..988171b10 --- /dev/null +++ b/examples/stitch-server/src/components/ScreenList.tsx @@ -0,0 +1,90 @@ +import type { ListScreensData } from "../types"; + +interface ScreenListProps { + data: ListScreensData; + onCallTool: (name: string, args: Record) => Promise; +} + +export default function ScreenList({ data, onCallTool }: ScreenListProps) { + const { projectId, screens } = data; + + const handleScreenClick = (screenName: string) => { + // Extract screen ID from "projects/{pid}/screens/{sid}" + const parts = screenName.split("/"); + const screenId = parts[parts.length - 1] || ""; + void onCallTool("design-viewer", { projectId, screenId }); + }; + + const handleBackClick = () => { + void onCallTool("list-projects", {}); + }; + + const formatDate = (dateStr: string) => { + try { + return new Date(dateStr).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); + } catch { + return dateStr; + } + }; + + return ( +
+
+ +

Screens

+ {screens.length} +
+ + {screens.length === 0 ? ( +
+

No screens yet

+

Generate a new screen design to get started.

+
+ ) : ( +
+ {screens.map((screen) => ( + + ))} +
+ )} +
+ ); +} diff --git a/examples/stitch-server/src/global.css b/examples/stitch-server/src/global.css new file mode 100644 index 000000000..1e9ef5171 --- /dev/null +++ b/examples/stitch-server/src/global.css @@ -0,0 +1,859 @@ +:root { + --color-primary: #4285f4; + --color-primary-dark: #3367d6; + --color-secondary: #6b7280; + --color-success: #34a853; + --color-danger: #ea4335; + --color-warning: #fbbc04; + + --bg-primary: #ffffff; + --bg-secondary: #f8f9fa; + --bg-tertiary: #f1f3f4; + --bg-hover: #e8eaed; + + --text-primary: #202124; + --text-secondary: #5f6368; + --text-tertiary: #9aa0a6; + + --border-color: #dadce0; + --border-radius: 8px; + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.06); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1); + + --spacing-xs: 4px; + --spacing-sm: 8px; + --spacing-md: 16px; + --spacing-lg: 24px; + --spacing-xl: 32px; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg-primary: #1f1f1f; + --bg-secondary: #292929; + --bg-tertiary: #333333; + --bg-hover: #3c3c3c; + + --text-primary: #e8eaed; + --text-secondary: #bdc1c6; + --text-tertiary: #9aa0a6; + + --border-color: #3c4043; + } +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: "Google Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", + Roboto, sans-serif; + -webkit-font-smoothing: antialiased; + background-color: var(--bg-secondary); + color: var(--text-primary); + line-height: 1.5; + font-size: 14px; +} + +/* App Container */ +.stitch-app { + max-width: 1200px; + margin: 0 auto; + padding: var(--spacing-md); +} + +/* View Container */ +.view-container { + min-height: 300px; +} + +.view-header { + display: flex; + align-items: center; + gap: var(--spacing-md); + margin-bottom: var(--spacing-lg); + padding-bottom: var(--spacing-md); + border-bottom: 1px solid var(--border-color); +} + +.view-header h2 { + font-size: 1.25rem; + font-weight: 600; + flex: 1; +} + +.view-header-actions { + display: flex; + gap: var(--spacing-sm); +} + +/* Buttons */ +.btn { + padding: var(--spacing-sm) var(--spacing-md); + border: none; + border-radius: var(--border-radius); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} + +.btn:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-md); +} + +.btn:active { + transform: translateY(0); +} + +.btn-primary { + background-color: var(--color-primary); + color: white; +} + +.btn-primary:hover { + background-color: var(--color-primary-dark); +} + +.btn-secondary { + background-color: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border-color); +} + +.btn-secondary:hover { + background-color: var(--bg-hover); +} + +.btn-sm { + padding: 4px 12px; + font-size: 12px; +} + +/* Badges */ +.badge { + padding: 2px 8px; + border-radius: 12px; + font-size: 12px; + font-weight: 500; + background-color: var(--bg-tertiary); + color: var(--text-secondary); +} + +.device-badge { + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + background-color: var(--color-primary); + color: white; + letter-spacing: 0.5px; +} + +/* Empty & Loading States */ +.empty-state { + text-align: center; + padding: var(--spacing-xl); + color: var(--text-secondary); +} + +.empty-state h2, +.empty-state h3 { + margin-bottom: var(--spacing-sm); + color: var(--text-primary); +} + +.empty-state .hint { + font-size: 13px; + color: var(--text-tertiary); + margin-top: var(--spacing-sm); +} + +.loading-container, +.error-container { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + min-height: 300px; + gap: var(--spacing-md); +} + +.spinner { + width: 32px; + height: 32px; + border: 3px solid var(--border-color); + border-top: 3px solid var(--color-primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* ===== Project List ===== */ +.project-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: var(--spacing-md); +} + +.project-card { + display: flex; + flex-direction: column; + background-color: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + overflow: hidden; + cursor: pointer; + transition: all 0.15s; + text-align: left; + width: 100%; + padding: 0; + font: inherit; + color: inherit; +} + +.project-card:hover { + border-color: var(--color-primary); + box-shadow: var(--shadow-md); + transform: translateY(-2px); +} + +.project-card-thumb { + width: 100%; + height: 160px; + background-color: var(--bg-tertiary); + overflow: hidden; +} + +.project-card-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.project-thumb-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 48px; + font-weight: 700; + color: var(--text-tertiary); + background-color: var(--bg-tertiary); +} + +.project-card-content { + padding: var(--spacing-md); + flex: 1; + min-width: 0; +} + +.project-card-content h3 { + font-size: 15px; + font-weight: 600; + margin-bottom: var(--spacing-sm); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.project-card-badges { + display: flex; + gap: var(--spacing-sm); + align-items: center; + margin-bottom: var(--spacing-xs); + flex-wrap: wrap; +} + +.theme-dot { + width: 12px; + height: 12px; + border-radius: 50%; + border: 1px solid var(--border-color); + flex-shrink: 0; +} + +.project-card-meta { + display: flex; + gap: var(--spacing-md); + font-size: 12px; + color: var(--text-tertiary); +} + +.project-card-arrow { + color: var(--text-tertiary); + font-size: 24px; + flex-shrink: 0; + padding: 0 var(--spacing-md) var(--spacing-md); + text-align: right; +} + +/* ===== Screen List ===== */ +.screen-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: var(--spacing-md); +} + +.screen-card { + background-color: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + overflow: hidden; + cursor: pointer; + transition: all 0.15s; + text-align: left; + width: 100%; + padding: 0; + font: inherit; + color: inherit; +} + +.screen-card:hover { + border-color: var(--color-primary); + box-shadow: var(--shadow-md); +} + +.screen-card-preview { + width: 100%; + aspect-ratio: 9 / 16; + background-color: var(--bg-tertiary); + overflow: hidden; +} + +.screen-card-preview img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.screen-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-tertiary); + font-size: 13px; +} + +.screen-card-info { + padding: var(--spacing-sm) var(--spacing-md); +} + +.screen-card-info h4 { + font-size: 13px; + font-weight: 500; + margin-bottom: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.screen-card-meta { + display: flex; + gap: var(--spacing-sm); + font-size: 11px; + color: var(--text-tertiary); + align-items: center; +} + +/* ===== Design Viewer ===== */ +.design-viewer-layout { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-lg); + min-height: 400px; +} + +.preview-panel { + background-color: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + padding: var(--spacing-md); + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-sm); +} + +.preview-image { + max-width: 100%; + max-height: 500px; + object-fit: contain; + border-radius: 4px; +} + +.preview-placeholder { + width: 100%; + min-height: 300px; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--bg-tertiary); + border-radius: 4px; + color: var(--text-tertiary); +} + +.device-info { + margin-top: var(--spacing-xs); +} + +.detail-panel { + display: flex; + flex-direction: column; + min-width: 0; +} + +/* Tabs */ +.tab-bar { + display: flex; + gap: 0; + border-bottom: 1px solid var(--border-color); + margin-bottom: var(--spacing-md); + align-items: center; +} + +.tab { + padding: var(--spacing-sm) var(--spacing-md); + border: none; + background: none; + font: inherit; + font-size: 14px; + font-weight: 500; + color: var(--text-secondary); + cursor: pointer; + border-bottom: 2px solid transparent; + transition: all 0.15s; +} + +.tab:hover { + color: var(--text-primary); +} + +.tab.active { + color: var(--color-primary); + border-bottom-color: var(--color-primary); +} + +.copy-btn { + margin-left: auto; +} + +.tab-content { + flex: 1; + overflow-y: auto; +} + +/* Code */ +.code-panel { + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +.code-section h4 { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + color: var(--text-secondary); + letter-spacing: 0.5px; + margin-bottom: var(--spacing-sm); +} + +.code-block { + background-color: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: var(--spacing-md); + font-family: "SF Mono", Monaco, Consolas, "Courier New", monospace; + font-size: 12px; + line-height: 1.6; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-word; + max-height: 300px; + overflow-y: auto; +} + +.code-block-sm { + max-height: 150px; +} + +/* Design Tokens */ +.tokens-panel { + display: flex; + flex-direction: column; + gap: var(--spacing-lg); +} + +.token-section h4 { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + color: var(--text-secondary); + letter-spacing: 0.5px; + margin-bottom: var(--spacing-md); +} + +/* Colors */ +.color-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); + gap: var(--spacing-sm); +} + +.color-swatch, +.color-swatch-large { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} + +.swatch-preview { + width: 48px; + height: 48px; + border-radius: 8px; + border: 1px solid var(--border-color); +} + +.swatch-preview-large { + width: 64px; + height: 64px; + border-radius: 8px; + border: 1px solid var(--border-color); +} + +.swatch-info { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} + +.swatch-value { + font-family: "SF Mono", Monaco, Consolas, monospace; + font-size: 11px; + color: var(--text-secondary); +} + +.swatch-name { + font-size: 11px; + color: var(--text-tertiary); +} + +.swatch-usage { + font-size: 10px; + color: var(--text-tertiary); +} + +/* Fonts */ +.font-list { + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +.font-item, +.font-item-large { + display: flex; + align-items: center; + gap: var(--spacing-md); + padding: var(--spacing-sm) var(--spacing-md); + background-color: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); +} + +.font-sample { + font-size: 24px; + min-width: 40px; + text-align: center; +} + +.font-preview { + padding: var(--spacing-md); + background-color: var(--bg-tertiary); + border-radius: 4px; + margin-bottom: var(--spacing-sm); +} + +.font-details { + display: flex; + flex-direction: column; + gap: 2px; +} + +.font-family { + font-weight: 600; + font-size: 13px; +} + +.font-meta { + font-size: 12px; + color: var(--text-tertiary); +} + +.font-usage { + font-size: 11px; + color: var(--text-tertiary); +} + +/* Spacing */ +.spacing-list { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.spacing-item { + display: flex; + align-items: center; + gap: var(--spacing-md); +} + +.spacing-name { + font-size: 13px; + font-weight: 500; + min-width: 80px; +} + +.spacing-bar-container { + flex: 1; + height: 8px; + background-color: var(--bg-tertiary); + border-radius: 4px; + overflow: hidden; +} + +.spacing-bar { + height: 100%; + background-color: var(--color-primary); + border-radius: 4px; + min-width: 4px; + max-width: 100%; +} + +.spacing-value { + font-family: "SF Mono", Monaco, Consolas, monospace; + font-size: 12px; + color: var(--text-secondary); + min-width: 60px; + text-align: right; +} + +/* Layout */ +.layout-list { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.layout-item, +.layout-card { + display: flex; + flex-direction: column; + gap: 4px; + padding: var(--spacing-sm) var(--spacing-md); + background-color: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); +} + +.layout-type { + font-weight: 600; + font-size: 13px; +} + +.layout-desc { + font-size: 12px; + color: var(--text-secondary); +} + +/* ===== Generate Design ===== */ +.generate-result { + display: flex; + flex-direction: column; + gap: var(--spacing-lg); +} + +.prompt-display { + display: flex; + gap: var(--spacing-sm); + padding: var(--spacing-md); + background-color: var(--bg-tertiary); + border-radius: var(--border-radius); + align-items: baseline; +} + +.prompt-label { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + flex-shrink: 0; +} + +.prompt-text { + font-size: 14px; + color: var(--text-primary); +} + +.generate-preview { + background-color: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + padding: var(--spacing-md); + display: flex; + justify-content: center; + overflow-y: auto; + max-height: 700px; +} + +.generate-image { + max-width: 100%; + width: auto; + height: auto; + object-fit: contain; + border-radius: 8px; + box-shadow: var(--shadow-lg); +} + +/* Screen Gallery (multiple generated screens) */ +.screen-gallery { + display: flex; + gap: var(--spacing-md); + overflow-x: auto; + padding: var(--spacing-sm); + background-color: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); +} + +.screen-gallery-item { + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-sm); +} + +.screen-gallery-item img { + max-height: 600px; + width: auto; + border-radius: 8px; + box-shadow: var(--shadow-md); + cursor: pointer; + transition: transform 0.15s; +} + +.screen-gallery-item img:hover { + transform: scale(1.02); +} + +.screen-gallery-title { + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); +} + +.generate-placeholder { + text-align: center; + padding: var(--spacing-xl); + color: var(--text-secondary); +} + +.generate-placeholder h3 { + margin-bottom: var(--spacing-sm); + color: var(--text-primary); +} + +.generate-code-preview h4 { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + color: var(--text-secondary); + margin-bottom: var(--spacing-sm); +} + +/* Suggestions */ +.suggestions h4 { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + color: var(--text-secondary); + margin-bottom: var(--spacing-sm); +} + +.suggestion-chips { + display: flex; + gap: var(--spacing-sm); + flex-wrap: wrap; +} + +.suggestion-chip { + padding: var(--spacing-sm) var(--spacing-md); + background-color: var(--bg-primary); + border: 1px solid var(--color-primary); + border-radius: 20px; + color: var(--color-primary); + font-size: 13px; + cursor: pointer; + transition: all 0.15s; + font: inherit; +} + +.suggestion-chip:hover { + background-color: var(--color-primary); + color: white; +} + +/* ===== Design Context ===== */ +.design-context-grid { + display: flex; + flex-direction: column; + gap: var(--spacing-xl); +} + +.context-section h3 { + font-size: 16px; + font-weight: 600; + margin-bottom: var(--spacing-md); + display: flex; + align-items: center; + gap: var(--spacing-sm); +} + +/* Responsive */ +@media (max-width: 768px) { + .design-viewer-layout { + grid-template-columns: 1fr; + } + + .screen-grid { + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + } + + .color-grid { + grid-template-columns: repeat(auto-fill, minmax(60px, 1fr)); + } + + .view-header { + flex-wrap: wrap; + } +} diff --git a/examples/stitch-server/src/types.ts b/examples/stitch-server/src/types.ts new file mode 100644 index 000000000..863f93546 --- /dev/null +++ b/examples/stitch-server/src/types.ts @@ -0,0 +1,133 @@ +// Stitch API types — matches real API response shapes + +export interface DesignTheme { + colorMode: string; // "DARK" | "LIGHT" + font: string; // "INTER" | "MANROPE" | "LEXEND" etc. + roundness: string; // "ROUND_EIGHT" | "ROUND_TWELVE" | "ROUND_FULL" + customColor: string; // hex e.g. "#137fec" + saturation: number; // 1-3 +} + +export interface FileReference { + name: string; + downloadUrl: string; +} + +export type ThumbnailScreenshot = FileReference; + +export interface ScreenInstance { + id: string; + sourceScreen: string; + x?: number; + y?: number; + width?: number; + height?: number; +} + +export interface StitchProject { + name: string; // "projects/{id}" + title: string; + visibility?: string; // "PRIVATE" | "PUBLIC" + createTime: string; + updateTime: string; + projectType?: string; // "TEXT_TO_UI_PRO" + deviceType?: string; // "MOBILE" | "DESKTOP" + origin?: string; // "STITCH" + thumbnailScreenshot?: ThumbnailScreenshot; + designTheme?: DesignTheme; + screenInstances?: ScreenInstance[]; + metadata?: { userRole?: string }; +} + +export interface StitchScreen { + name: string; // "projects/{pid}/screens/{sid}" + id?: string; + title?: string; // API returns "title", not "displayName" + screenshot?: FileReference; // API returns { name, downloadUrl }, not imageUri + htmlCode?: FileReference; // API returns file reference, not inline string + cssCode?: FileReference; // same pattern + deviceType?: string; + theme?: DesignTheme; + prompt?: string; + width?: string; + height?: string; + screenType?: string; + generatedBy?: string; + createTime?: string; + updateTime?: string; +} + +export interface ColorToken { + name: string; + hex: string; + usage: string; +} + +export interface FontToken { + family: string; + weight: string; + size: string; + usage: string; +} + +export interface SpacingToken { + name: string; + value: string; +} + +export interface LayoutInfo { + type: string; + description: string; +} + +export interface DesignContextData { + colors: ColorToken[]; + fonts: FontToken[]; + spacing: SpacingToken[]; + layouts: LayoutInfo[]; +} + +export interface CodeData { + html: string; + css: string; +} + +// Tool result data types + +export interface DesignViewerData { + screen: StitchScreen; + imageData: string; + codeData: CodeData; + designContext: DesignContextData; +} + +export interface GenerateDesignData { + screen: StitchScreen; + allScreens?: StitchScreen[]; + imageData?: string; + codeData?: CodeData; + suggestions?: string[]; + prompt: string; + autoCreatedProject?: boolean; +} + +export interface CreateProjectData { + project: StitchProject; +} + +export interface ListProjectsData { + projects: StitchProject[]; +} + +export interface ListScreensData { + projectId: string; + screens: StitchScreen[]; +} + +export type ToolResultData = + | { type: "design_viewer"; data: DesignViewerData } + | { type: "generate_design"; data: GenerateDesignData } + | { type: "list_projects"; data: ListProjectsData } + | { type: "list_screens"; data: ListScreensData } + | { type: "design_context"; data: DesignContextData } + | { type: "create_project"; data: CreateProjectData }; diff --git a/examples/stitch-server/stitch-client.ts b/examples/stitch-server/stitch-client.ts new file mode 100644 index 000000000..78fcf5fa7 --- /dev/null +++ b/examples/stitch-server/stitch-client.ts @@ -0,0 +1,590 @@ +import { GoogleAuth } from "google-auth-library"; + +export const STITCH_API_BASE = "https://stitch.googleapis.com/v1"; +export const STITCH_MCP_URL = "https://stitch.googleapis.com/mcp"; + +export interface StitchProject { + name: string; + title: string; + createTime: string; + updateTime: string; +} + +interface FileReference { + name: string; + downloadUrl: string; +} + +export interface StitchScreen { + name: string; + id?: string; + title?: string; + screenshot?: FileReference; + htmlCode?: FileReference; + cssCode?: FileReference; + deviceType?: string; + theme?: Record; + prompt?: string; + width?: string; + height?: string; + screenType?: string; + generatedBy?: string; + createTime?: string; + updateTime?: string; +} + +export interface DesignContext { + colors: Array<{ name: string; hex: string; usage: string }>; + fonts: Array<{ + family: string; + weight: string; + size: string; + usage: string; + }>; + spacing: Array<{ name: string; value: string }>; + layouts: Array<{ type: string; description: string }>; +} + +export interface GenerateScreenOptions { + projectId: string; + prompt: string; + deviceType?: "MOBILE" | "DESKTOP" | "TABLET"; + modelId?: "GEMINI_3_PRO" | "GEMINI_3_FLASH"; +} + +// ===================================================================== +// Pure utility functions (exported for testing) +// ===================================================================== + +export function normalizeProjectId(projectId: string): string { + return projectId.replace(/^projects\//, ""); +} + +export function extractInlineStyles(html: string): string { + const parts: string[] = []; + const styleRegex = /]*>([\s\S]*?)<\/style>/gi; + let match; + while ((match = styleRegex.exec(html)) !== null) { + parts.push(match[1]!); + } + return parts.join("\n"); +} + +export function extractTailwindConfig(html: string): string { + const configRegex = + /tailwind\.config\s*=\s*(\{[\s\S]*?\})\s*;?\s*<\/script>/; + const match = configRegex.exec(html); + return match ? match[1]! : ""; +} + +export function extractTailwindColors( + config: string, +): DesignContext["colors"] { + const colors: DesignContext["colors"] = []; + const colorRegex = + /['"]?(\w+)['"]?\s*:\s*['"]?(#[0-9a-fA-F]{3,8})['"]?/g; + const seen = new Set(); + let match; + while ((match = colorRegex.exec(config)) !== null) { + const name = match[1]!; + const hex = match[2]!; + if (!seen.has(hex.toLowerCase())) { + seen.add(hex.toLowerCase()); + colors.push({ name, hex, usage: "Tailwind theme" }); + } + } + return colors; +} + +export function extractTailwindFonts( + config: string, + html: string, +): DesignContext["fonts"] { + const fonts: DesignContext["fonts"] = []; + const seen = new Set(); + + const fontRegex = + /fontFamily[\s\S]*?['"](\w[\w\s]*)['"](?:\s*,|\s*\])/g; + let match; + while ((match = fontRegex.exec(config)) !== null) { + const family = match[1]!.trim(); + if (family && !seen.has(family.toLowerCase())) { + seen.add(family.toLowerCase()); + fonts.push({ family, weight: "400", size: "16px", usage: "Tailwind theme" }); + } + } + + const googleFontsRegex = + /fonts\.googleapis\.com\/css2?\?family=([^&"'<>\s]+)/g; + while ((match = googleFontsRegex.exec(html)) !== null) { + const family = decodeURIComponent(match[1]!) + .split(":")[0]! + .replace(/\+/g, " ") + .trim(); + if (family && !seen.has(family.toLowerCase())) { + seen.add(family.toLowerCase()); + fonts.push({ family, weight: "400", size: "16px", usage: "Google Fonts" }); + } + } + + return fonts; +} + +export function extractColors(css: string): DesignContext["colors"] { + const colors: DesignContext["colors"] = []; + const colorRegex = + /(#[0-9a-fA-F]{3,8}|rgb[a]?\([^)]+\)|hsl[a]?\([^)]+\))/g; + const seen = new Set(); + + let match; + while ((match = colorRegex.exec(css)) !== null) { + const hex = match[1]!; + if (!seen.has(hex)) { + seen.add(hex); + colors.push({ + name: `color-${colors.length + 1}`, + hex, + usage: "Extracted from CSS", + }); + } + } + return colors; +} + +export function extractFonts(css: string): DesignContext["fonts"] { + const fonts: DesignContext["fonts"] = []; + const fontFamilyRegex = /font-family:\s*([^;]+)/g; + const fontSizeRegex = /font-size:\s*([^;]+)/g; + const fontWeightRegex = /font-weight:\s*([^;]+)/g; + + const families = new Set(); + let match; + + while ((match = fontFamilyRegex.exec(css)) !== null) { + const family = match[1]!.trim().replace(/['"]/g, ""); + if (!families.has(family)) { + families.add(family); + } + } + + const sizes: string[] = []; + while ((match = fontSizeRegex.exec(css)) !== null) { + sizes.push(match[1]!.trim()); + } + + const weights: string[] = []; + while ((match = fontWeightRegex.exec(css)) !== null) { + weights.push(match[1]!.trim()); + } + + for (const family of families) { + fonts.push({ + family, + weight: weights[0] || "400", + size: sizes[0] || "16px", + usage: "Extracted from CSS", + }); + } + + return fonts; +} + +export function extractSpacing(css: string): DesignContext["spacing"] { + const spacing: DesignContext["spacing"] = []; + const spacingRegex = /(margin|padding|gap):\s*([^;]+)/g; + const seen = new Set(); + + let match; + while ((match = spacingRegex.exec(css)) !== null) { + const value = match[2]!.trim(); + const key = `${match[1]}-${value}`; + if (!seen.has(key)) { + seen.add(key); + spacing.push({ + name: match[1]!, + value, + }); + } + } + return spacing; +} + +export function extractLayouts(html: string): DesignContext["layouts"] { + const layouts: DesignContext["layouts"] = []; + + if ( + html.includes("display: flex") || + html.includes("display:flex") || + /\bflex\b/.test(html) + ) { + layouts.push({ + type: "Flexbox", + description: "Uses CSS Flexbox for layout", + }); + } + if ( + html.includes("display: grid") || + html.includes("display:grid") || + /\bgrid\b/.test(html) + ) { + layouts.push({ type: "Grid", description: "Uses CSS Grid for layout" }); + } + if ( + html.includes("position: absolute") || + html.includes("position:absolute") || + /\babsolute\b/.test(html) + ) { + layouts.push({ + type: "Absolute", + description: "Uses absolute positioning", + }); + } + + return layouts; +} + +// ===================================================================== +// StitchClient class (network-dependent methods) +// ===================================================================== + +export class StitchClient { + private auth: GoogleAuth; + private projectId: string; + + constructor() { + this.auth = new GoogleAuth({ + scopes: ["https://www.googleapis.com/auth/cloud-platform"], + }); + this.projectId = process.env["GOOGLE_CLOUD_PROJECT"] || ""; + } + + private async getHeaders(): Promise> { + const client = await this.auth.getClient(); + const token = await client.getAccessToken(); + const headers: Record = { + "Content-Type": "application/json", + }; + if (token.token) { + headers["Authorization"] = `Bearer ${token.token}`; + } + if (this.projectId) { + headers["X-Goog-User-Project"] = this.projectId; + } + return headers; + } + + private async request( + method: string, + path: string, + body?: unknown, + ): Promise { + const headers = await this.getHeaders(); + const url = `${STITCH_API_BASE}${path}`; + + const response = await fetch(url, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Stitch API error ${response.status}: ${errorText}`); + } + + return response.json() as Promise; + } + + /** + * Call a Stitch tool via the MCP JSON-RPC endpoint. + * Some operations (like screen generation) are only available through + * the MCP endpoint, not the REST API. + */ + private async mcpRequest( + toolName: string, + args: Record, + ): Promise { + const headers = await this.getHeaders(); + const body = { + jsonrpc: "2.0", + method: "tools/call", + params: { name: toolName, arguments: args }, + id: Date.now(), + }; + + const response = await fetch(STITCH_MCP_URL, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Stitch MCP error ${response.status}: ${errorText}`); + } + + const data = (await response.json()) as { + result?: T; + error?: { code?: number; message: string }; + }; + if (data.error) { + throw new Error(`Stitch MCP error: ${data.error.message}`); + } + return data.result as T; + } + + async listProjects(filter?: string): Promise { + const params = new URLSearchParams(); + if (filter) { + params.set("filter", filter); + } + const query = params.toString(); + const path = `/projects${query ? `?${query}` : ""}`; + const result = await this.request<{ projects?: StitchProject[] }>( + "GET", + path, + ); + return result.projects || []; + } + + async getProject(name: string): Promise { + return this.request("GET", `/${name}`); + } + + async createProject(title: string): Promise { + return this.request("POST", "/projects", { title }); + } + + async listScreens(projectId: string): Promise { + const pid = normalizeProjectId(projectId); + const result = await this.request<{ screens?: StitchScreen[] }>( + "GET", + `/projects/${pid}/screens`, + ); + return result.screens || []; + } + + async getScreen( + projectId: string, + screenId: string, + ): Promise { + const pid = normalizeProjectId(projectId); + return this.request( + "GET", + `/projects/${pid}/screens/${screenId}`, + ); + } + + async generateScreenFromText(options: GenerateScreenOptions): Promise<{ + screen: StitchScreen; + allScreens: StitchScreen[]; + outputComponents?: Array<{ text?: string; suggestions?: string[] }>; + }> { + const projectId = normalizeProjectId(options.projectId); + const args: Record = { + projectId, + prompt: options.prompt, + }; + if (options.deviceType) args["deviceType"] = options.deviceType; + if (options.modelId) args["modelId"] = options.modelId; + + const mcpResult = await this.mcpRequest>( + "generate_screen_from_text", + args, + ); + + let screen: StitchScreen = { name: "" }; + const allScreens: StitchScreen[] = []; + const suggestions: string[] = []; + + const structured = mcpResult.structuredContent as + | Record + | undefined; + if (structured?.outputComponents) { + const components = structured.outputComponents as Array< + Record + >; + for (const comp of components) { + if (comp.design) { + const design = comp.design as { screens?: StitchScreen[] }; + if (design.screens?.length) { + allScreens.push(...design.screens); + if (!screen.name) { + screen = design.screens[0]!; + } + } + } + if (typeof comp.suggestion === "string") { + suggestions.push(comp.suggestion); + } + } + } + + if (!screen.name) { + const content = mcpResult.content as + | Array<{ type: string; text?: string }> + | undefined; + if (content) { + for (const item of content) { + if (item.type === "text" && item.text) { + try { + const parsed = JSON.parse(item.text); + if (parsed.outputComponents) { + for (const comp of parsed.outputComponents as Array< + Record + >) { + if (comp.design) { + const design = comp.design as { + screens?: StitchScreen[]; + }; + if (design.screens?.length) { + allScreens.push(...design.screens); + if (!screen.name) screen = design.screens[0]!; + } + } + if (typeof comp.suggestion === "string") + suggestions.push(comp.suggestion); + } + } + } catch { + /* not JSON */ + } + } + } + } + } + + const outputComponents: Array<{ + text?: string; + suggestions?: string[]; + }> = []; + if (suggestions.length > 0) { + outputComponents.push({ suggestions }); + } + + return { + screen, + allScreens, + outputComponents: + outputComponents.length > 0 ? outputComponents : undefined, + }; + } + + async extractDesignContext( + projectId: string, + screenId: string, + ): Promise { + const pid = normalizeProjectId(projectId); + const context: DesignContext = { + colors: [], + fonts: [], + spacing: [], + layouts: [], + }; + + const screen = await this.getScreen(pid, screenId); + const { html, css } = await this.fetchScreenCode(pid, screenId); + + const inlineStyles = html ? extractInlineStyles(html) : ""; + const allCss = [css, inlineStyles].filter(Boolean).join("\n"); + + if (allCss) { + context.colors = extractColors(allCss); + context.fonts = extractFonts(allCss); + context.spacing = extractSpacing(allCss); + } + + if (html) { + const tailwindConfig = extractTailwindConfig(html); + if (tailwindConfig) { + context.colors.push(...extractTailwindColors(tailwindConfig)); + context.fonts.push(...extractTailwindFonts(tailwindConfig, html)); + } + context.layouts = extractLayouts(html); + } + + if (screen.theme) { + const theme = screen.theme as Record; + if (theme.customColor && typeof theme.customColor === "string") { + const hasColor = context.colors.some( + (c) => + c.hex.toLowerCase() === + (theme.customColor as string).toLowerCase(), + ); + if (!hasColor) { + context.colors.unshift({ + name: "primary", + hex: theme.customColor as string, + usage: "Theme primary color", + }); + } + } + if (theme.font && typeof theme.font === "string") { + const fontName = (theme.font as string) + .replace(/_/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()); + const hasFont = context.fonts.some((f) => + f.family.toLowerCase().includes(fontName.toLowerCase()), + ); + if (!hasFont) { + context.fonts.unshift({ + family: fontName, + weight: "400", + size: "16px", + usage: "Theme font", + }); + } + } + if (theme.colorMode && typeof theme.colorMode === "string") { + context.layouts.unshift({ + type: theme.colorMode as string, + description: `${theme.colorMode} color mode`, + }); + } + } + + return context; + } + + async fetchScreenImage( + projectId: string, + screenId: string, + ): Promise { + const pid = normalizeProjectId(projectId); + const screen = await this.getScreen(pid, screenId); + return screen.screenshot?.downloadUrl || ""; + } + + async fetchScreenCode( + projectId: string, + screenId: string, + ): Promise<{ html: string; css: string }> { + const pid = normalizeProjectId(projectId); + const screen = await this.getScreen(pid, screenId); + let html = ""; + let css = ""; + + if (screen.htmlCode?.downloadUrl) { + html = await this.downloadFile(screen.htmlCode.downloadUrl); + } + if (screen.cssCode?.downloadUrl) { + css = await this.downloadFile(screen.cssCode.downloadUrl); + } + return { html, css }; + } + + async downloadFile(url: string): Promise { + try { + const needsAuth = !url.includes("usercontent.google.com"); + const options: RequestInit = {}; + if (needsAuth) { + options.headers = await this.getHeaders(); + } + const response = await fetch(url, options); + if (!response.ok) return ""; + return response.text(); + } catch { + return ""; + } + } +} diff --git a/examples/stitch-server/tsconfig.json b/examples/stitch-server/tsconfig.json new file mode 100644 index 000000000..fc3c2101f --- /dev/null +++ b/examples/stitch-server/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "server.ts"] +} diff --git a/examples/stitch-server/tsconfig.server.json b/examples/stitch-server/tsconfig.server.json new file mode 100644 index 000000000..05ddd8ec4 --- /dev/null +++ b/examples/stitch-server/tsconfig.server.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./dist", + "rootDir": ".", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true + }, + "include": ["server.ts"] +} diff --git a/examples/stitch-server/vite.config.ts b/examples/stitch-server/vite.config.ts new file mode 100644 index 000000000..0c39eb996 --- /dev/null +++ b/examples/stitch-server/vite.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { viteSingleFile } from "vite-plugin-singlefile"; + +const INPUT = process.env.INPUT; +if (!INPUT) { + throw new Error("INPUT environment variable is not set"); +} + +const isDevelopment = process.env.NODE_ENV === "development"; + +export default defineConfig({ + plugins: [react(), viteSingleFile()], + build: { + sourcemap: isDevelopment ? "inline" : undefined, + cssMinify: !isDevelopment, + minify: !isDevelopment, + rollupOptions: { + input: INPUT, + }, + outDir: "dist", + emptyOutDir: false, + }, +});