From e506853812f72a3ac5473e15a961aa50389aaff9 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 22 Jan 2026 18:36:28 -0300 Subject: [PATCH 01/20] Add Slides MCP - AI-powered presentation builder Browser-based presentation system with real JSX (via Babel Standalone), no build step. Architecture: - brands/ folder: Reusable design systems (source of truth) - decks/ folder: Presentations that reference brands Tools: - DECK_INIT: Create brand or deck (with brand reference) - DECK_BUNDLE: Bundle deck into standalone HTML - SLIDE_CREATE/UPDATE/DELETE/REORDER: Manage slides Prompts: - SLIDES_SETUP_BRAND: Create new brand design system - SLIDES_NEW_DECK: Create deck from existing brand - SLIDES_ADD_CONTENT: Add slides to deck - SLIDES_QUICK_START: Fast one-off presentations Features: - CDN-based: React 18, GSAP, Babel Standalone - JSX components without build step - Design system viewer at /design.html - Keyboard navigation (arrows, F for fullscreen) - Bundling for portable single-file presentations --- bun.lock | 19 +- package.json | 3 +- slides/.gitignore | 1 + slides/app.json | 19 + slides/assets/engine.js | 716 ++++++++++ slides/assets/index.html | 58 + slides/assets/manifest-template.json | 7 + slides/assets/slide-template.json | 13 + slides/assets/style-template.md | 52 + slides/assets/styles.css | 549 ++++++++ slides/package.json | 32 + slides/server/main.ts | 64 + slides/server/prompts.ts | 539 +++++++ slides/server/tools/deck.ts | 1245 +++++++++++++++++ slides/server/tools/index.ts | 34 + slides/server/tools/slides.ts | 540 +++++++ slides/server/tools/style.ts | 230 +++ slides/test-presentation/engine.js | 1154 +++++++++++++++ slides/test-presentation/index.html | 22 + .../slides/001-introduction.json | 10 + .../slides/002-key-metrics.json | 15 + .../slides/003-features.json | 18 + .../slides/004-comparison.json | 30 + .../test-presentation/slides/005-quote.json | 9 + slides/test-presentation/slides/manifest.json | 38 + slides/test-presentation/styles.css | 217 +++ slides/tsconfig.json | 35 + 27 files changed, 5667 insertions(+), 2 deletions(-) create mode 100644 slides/.gitignore create mode 100644 slides/app.json create mode 100644 slides/assets/engine.js create mode 100644 slides/assets/index.html create mode 100644 slides/assets/manifest-template.json create mode 100644 slides/assets/slide-template.json create mode 100644 slides/assets/style-template.md create mode 100644 slides/assets/styles.css create mode 100644 slides/package.json create mode 100644 slides/server/main.ts create mode 100644 slides/server/prompts.ts create mode 100644 slides/server/tools/deck.ts create mode 100644 slides/server/tools/index.ts create mode 100644 slides/server/tools/slides.ts create mode 100644 slides/server/tools/style.ts create mode 100644 slides/test-presentation/engine.js create mode 100644 slides/test-presentation/index.html create mode 100644 slides/test-presentation/slides/001-introduction.json create mode 100644 slides/test-presentation/slides/002-key-metrics.json create mode 100644 slides/test-presentation/slides/003-features.json create mode 100644 slides/test-presentation/slides/004-comparison.json create mode 100644 slides/test-presentation/slides/005-quote.json create mode 100644 slides/test-presentation/slides/manifest.json create mode 100644 slides/test-presentation/styles.css create mode 100644 slides/tsconfig.json diff --git a/bun.lock b/bun.lock index ccf22e11..54f64509 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 1, "workspaces": { "": { "name": "@decocms/mcps", @@ -608,6 +607,22 @@ "typescript": "^5.7.2", }, }, + "slides": { + "name": "@decocms/slides", + "version": "1.0.0", + "dependencies": { + "@decocms/bindings": "^1.0.7", + "@decocms/runtime": "^1.1.3", + "zod": "^4.0.0", + }, + "devDependencies": { + "@decocms/mcps-shared": "1.0.0", + "@mastra/core": "^0.24.0", + "@modelcontextprotocol/sdk": "1.25.1", + "deco-cli": "^0.28.0", + "typescript": "^5.7.2", + }, + }, "sora": { "name": "sora", "version": "1.0.0", @@ -950,6 +965,8 @@ "@decocms/runtime": ["@decocms/runtime@1.2.2", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.0.7", "@modelcontextprotocol/sdk": "1.25.2", "hono": "^4.10.7", "jose": "^6.0.11", "zod": "^4.0.0" } }, "sha512-eloJk2HOFbrI5X7rw+ZyIW7sqmYbBUz5mvvBkLlmLCUlvwqsxv80ZNBqzqn01NT8kbbdhvumGeXF5nTX+H40rw=="], + "@decocms/slides": ["@decocms/slides@workspace:slides"], + "@decocms/vite-plugin": ["@decocms/vite-plugin@1.0.0-alpha.1", "", { "dependencies": { "@cloudflare/vite-plugin": "^1.13.4", "vite": "7.2.0" } }, "sha512-DI9zNH49pVk8aQ+7rNYwqTZhjQ4RZDA+kA1t3ifwc4RLJsOtYv8LOXERRZnou7CcKVTdXPB06M8gbMWPpSaq8w=="], "@discordjs/builders": ["@discordjs/builders@1.13.1", "", { "dependencies": { "@discordjs/formatters": "^0.6.2", "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.33", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w=="], diff --git a/package.json b/package.json index eaca317d..c073f1c6 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,8 @@ }, "workspaces": [ "apify", - "content-scraper", "blog-post-generator", + "content-scraper", "data-for-seo", "datajud", "deco-llm", @@ -54,6 +54,7 @@ "replicate", "shared", "slack-mcp", + "slides", "sora", "template-minimal", "template-with-view", diff --git a/slides/.gitignore b/slides/.gitignore new file mode 100644 index 00000000..babca1bb --- /dev/null +++ b/slides/.gitignore @@ -0,0 +1 @@ +.dev.vars diff --git a/slides/app.json b/slides/app.json new file mode 100644 index 00000000..996a3507 --- /dev/null +++ b/slides/app.json @@ -0,0 +1,19 @@ +{ + "scopeName": "deco", + "name": "slides", + "friendlyName": "Slides", + "connection": { + "type": "HTTP", + "url": "https://sites-slides.decocache.com/mcp" + }, + "description": "AI-powered slide presentation builder with beautiful animations and GSAP transitions.", + "icon": "https://assets.decocache.com/decocms/slides-icon.png", + "unlisted": false, + "metadata": { + "categories": ["Productivity", "Design"], + "official": false, + "tags": ["slides", "presentation", "deck", "gsap", "animations", "react"], + "short_description": "Create beautiful slide presentations through AI conversation.", + "mesh_description": "The Slides MCP enables AI agents to create, edit, and manage beautiful slide presentations through natural conversation. It provides tools for initializing presentation decks, creating slides with various layouts (title, content, stats, two-column, list, quote, image, custom), managing style guides, and bundling presentations into single portable HTML files. The presentation engine uses React and GSAP for smooth animations, requires no build step (CDN-loaded dependencies), and supports responsive scaling. Ideal for investor updates, sales pitches, educational content, conference talks, or any scenario requiring professional presentations." + } +} diff --git a/slides/assets/engine.js b/slides/assets/engine.js new file mode 100644 index 00000000..6a2aec0a --- /dev/null +++ b/slides/assets/engine.js @@ -0,0 +1,716 @@ +/** + * Cogna Presentation Engine + * Styled for Cogna Educação brand guidelines + */ + +const { useState, useEffect, useRef } = React; + +// Base dimensions (16:9 aspect ratio matching Cogna template) +const BASE_WIDTH = 1366; +const BASE_HEIGHT = 768; + +/** + * Cogna Logo Component + */ +function CognaLogo({ size = "normal", className = "" }) { + const isSmall = size === "small"; + + return React.createElement( + "div", + { + className: `logo-cogna ${isSmall ? "logo-small" : ""} ${className}`, + }, + [ + React.createElement( + "span", + { + key: "wordmark", + className: "logo-cogna-wordmark", + }, + [ + "cogn", + React.createElement("span", { key: "dot", className: "dot" }, "a"), + ], + ), + React.createElement( + "span", + { + key: "tagline", + className: "logo-cogna-tagline", + }, + "EDUCAÇÃO", + ), + ], + ); +} + +/** + * Title Slide with blob shapes + */ +function TitleSlide({ slide }) { + return React.createElement( + "div", + { + className: "slide slide--title", + style: { opacity: 1 }, + }, + [ + // Charcoal blob + React.createElement("div", { key: "blob1", className: "blob-primary" }), + // Purple circle + React.createElement("div", { key: "blob2", className: "blob-accent" }), + // Content + React.createElement( + "div", + { key: "content", className: "slide-content" }, + React.createElement("h1", { className: "title-hero" }, slide.title), + ), + // Logo + React.createElement( + "div", + { key: "logo", className: "logo-container" }, + React.createElement(CognaLogo, { size: "normal" }), + ), + ], + ); +} + +/** + * Content Slide with bullets and sections + */ +function ContentSlide({ slide }) { + const items = slide.items || []; + + const renderBullets = (bullets) => { + return React.createElement( + "ul", + { className: "bullet-list" }, + bullets.map((bullet, idx) => + React.createElement( + "li", + { key: idx }, + bullet.highlight + ? React.createElement( + "span", + { className: "text-bold" }, + bullet.text, + ) + : bullet.text, + ), + ), + ); + }; + + const renderNestedBullets = (bullets) => { + return React.createElement( + "ul", + { className: "bullet-list bullet-list--nested" }, + bullets.map((bullet, idx) => + React.createElement("li", { key: idx }, bullet.text), + ), + ); + }; + + return React.createElement( + "div", + { + className: "slide slide--content", + style: { opacity: 1 }, + }, + [ + // Header with logo + React.createElement( + "header", + { key: "header", className: "slide-header" }, + React.createElement(CognaLogo, { size: "small" }), + ), + // Body + React.createElement("main", { key: "body", className: "slide-body" }, [ + // Title + React.createElement( + "h1", + { key: "title", className: "slide-title" }, + slide.title, + ), + // Items + ...items.map((item, idx) => { + const elements = []; + + if (item.title) { + elements.push( + React.createElement( + "h2", + { + key: `section-${idx}`, + className: "section-heading", + }, + item.title, + ), + ); + } + + if (item.bullets) { + elements.push( + React.createElement( + "div", + { key: `bullets-${idx}` }, + renderBullets(item.bullets), + ), + ); + } + + if (item.nestedBullets) { + elements.push( + React.createElement( + "div", + { key: `nested-${idx}` }, + renderNestedBullets(item.nestedBullets), + ), + ); + } + + return React.createElement("div", { key: idx }, elements); + }), + ]), + // Footer + slide.source && + React.createElement( + "footer", + { key: "footer", className: "slide-footer" }, + [ + React.createElement( + "span", + { key: "source", className: "footer-text" }, + `Fonte: ${slide.source}`, + ), + slide.label && + React.createElement( + "span", + { key: "label", className: "footer-label" }, + slide.label, + ), + React.createElement("div", { key: "dot", className: "footer-dot" }), + ], + ), + ], + ); +} + +/** + * Stats Slide with large numbers + */ +function StatsSlide({ slide }) { + const items = slide.items || []; + + return React.createElement( + "div", + { + className: "slide slide--content slide--stats", + style: { opacity: 1 }, + }, + [ + // Header + React.createElement( + "header", + { key: "header", className: "slide-header" }, + React.createElement(CognaLogo, { size: "small" }), + ), + // Title + slide.tag && + React.createElement( + "span", + { + key: "tag", + style: { + fontSize: "12px", + fontWeight: 500, + color: "#9CA3AF", + letterSpacing: "0.1em", + textTransform: "uppercase", + marginBottom: "8px", + }, + }, + slide.tag, + ), + React.createElement( + "h1", + { key: "title", className: "slide-title" }, + slide.title, + ), + // Stats grid + React.createElement( + "div", + { key: "grid", className: "stats-grid" }, + items.map((item, idx) => + React.createElement("div", { key: idx, className: "stat-item" }, [ + React.createElement( + "div", + { key: "value", className: "stat-value" }, + item.value, + ), + React.createElement( + "div", + { key: "label", className: "stat-label" }, + item.label, + ), + ]), + ), + ), + ], + ); +} + +/** + * Two Column Slide + */ +function TwoColumnSlide({ slide }) { + const items = slide.items || []; + const leftItem = items[0]; + const rightItem = items[1]; + + const renderColumn = (item) => { + if (!item) return null; + + return React.createElement("div", { className: "column" }, [ + item.title && + React.createElement( + "h3", + { + key: "title", + className: "column-title", + }, + item.title, + ), + item.bullets && + React.createElement( + "ul", + { + key: "bullets", + className: "bullet-list", + }, + item.bullets.map((bullet, idx) => + React.createElement( + "li", + { key: idx }, + bullet.highlight + ? React.createElement( + "span", + { className: "text-bold" }, + bullet.text, + ) + : bullet.text, + ), + ), + ), + ]); + }; + + return React.createElement( + "div", + { + className: "slide slide--content slide--two-column", + style: { opacity: 1 }, + }, + [ + // Header + React.createElement( + "header", + { key: "header", className: "slide-header" }, + React.createElement(CognaLogo, { size: "small" }), + ), + // Body + React.createElement("main", { key: "body", className: "slide-body" }, [ + slide.tag && + React.createElement( + "span", + { + key: "tag", + style: { + fontSize: "12px", + fontWeight: 500, + color: "#9CA3AF", + letterSpacing: "0.1em", + textTransform: "uppercase", + marginBottom: "8px", + display: "block", + }, + }, + slide.tag, + ), + React.createElement( + "h1", + { key: "title", className: "slide-title" }, + slide.title, + ), + React.createElement("div", { key: "columns", className: "columns" }, [ + React.createElement("div", { key: "left" }, renderColumn(leftItem)), + React.createElement("div", { key: "right" }, renderColumn(rightItem)), + ]), + ]), + ], + ); +} + +/** + * List Slide with 2x2 grid + */ +function ListSlide({ slide }) { + const items = slide.items || []; + + return React.createElement( + "div", + { + className: "slide slide--content slide--list", + style: { opacity: 1 }, + }, + [ + // Header + React.createElement( + "header", + { key: "header", className: "slide-header" }, + React.createElement(CognaLogo, { size: "small" }), + ), + // Body + React.createElement("main", { key: "body", className: "slide-body" }, [ + slide.tag && + React.createElement( + "span", + { + key: "tag", + style: { + fontSize: "12px", + fontWeight: 500, + color: "#9CA3AF", + letterSpacing: "0.1em", + textTransform: "uppercase", + marginBottom: "8px", + display: "block", + }, + }, + slide.tag, + ), + React.createElement( + "h1", + { key: "title", className: "slide-title" }, + slide.title, + ), + slide.subtitle && + React.createElement( + "p", + { + key: "subtitle", + style: { + fontSize: "16px", + color: "#6B7280", + marginBottom: "16px", + }, + }, + slide.subtitle, + ), + React.createElement( + "div", + { key: "grid", className: "list-grid" }, + items.map((item, idx) => + React.createElement("div", { key: idx, className: "list-item" }, [ + React.createElement("div", { + key: "dot", + className: "list-item-dot", + }), + React.createElement( + "div", + { key: "content", className: "list-item-content" }, + [ + React.createElement("h4", { key: "title" }, item.title), + item.subtitle && + React.createElement("p", { key: "sub" }, item.subtitle), + ], + ), + ]), + ), + ), + ]), + ], + ); +} + +/** + * Comparison Box Component (for content slides) + */ +function ComparisonBox({ columns }) { + return React.createElement( + "div", + { className: "comparison-box" }, + columns.map((col, idx) => { + // Check if this is an operator + if (col.operator) { + return React.createElement( + "div", + { + key: idx, + className: "comparison-box__operator", + }, + col.operator, + ); + } + + return React.createElement( + "div", + { + key: idx, + className: "comparison-box__column", + }, + [ + React.createElement( + "h4", + { + key: "title", + className: "comparison-box__title", + }, + col.title, + ), + col.items && + React.createElement( + "ul", + { + key: "list", + className: "comparison-box__list", + }, + col.items.map((item, i) => + React.createElement("li", { key: i }, item), + ), + ), + col.note && + React.createElement( + "span", + { + key: "note", + className: "comparison-box__note", + }, + col.note, + ), + ], + ); + }), + ); +} + +/** + * Main Presentation Component + */ +function Presentation({ slides, title, subtitle }) { + const [currentSlide, setCurrentSlide] = useState(0); + const [isAnimating, setIsAnimating] = useState(false); + const [scale, setScale] = useState(1); + const containerRef = useRef(null); + + // Calculate scale to fit viewport + useEffect(() => { + const calculateScale = () => { + if (!containerRef.current) return; + const container = containerRef.current; + const scaleX = container.clientWidth / BASE_WIDTH; + const scaleY = container.clientHeight / BASE_HEIGHT; + setScale(Math.min(scaleX, scaleY) * 0.95); + }; + + calculateScale(); + window.addEventListener("resize", calculateScale); + return () => window.removeEventListener("resize", calculateScale); + }, []); + + // Keyboard navigation + useEffect(() => { + const handleKeyDown = (e) => { + if (isAnimating) return; + + if (e.key === "ArrowRight" || e.key === "ArrowDown" || e.key === " ") { + e.preventDefault(); + goToSlide(Math.min(currentSlide + 1, slides.length - 1)); + } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { + e.preventDefault(); + goToSlide(Math.max(currentSlide - 1, 0)); + } else if (e.key === "Home") { + e.preventDefault(); + goToSlide(0); + } else if (e.key === "End") { + e.preventDefault(); + goToSlide(slides.length - 1); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [currentSlide, isAnimating, slides.length]); + + const goToSlide = (index) => { + if (index === currentSlide || isAnimating) return; + setIsAnimating(true); + setCurrentSlide(index); + setTimeout(() => setIsAnimating(false), 500); + }; + + const renderSlide = (slide, index) => { + const isActive = index === currentSlide; + + if (!isActive) return null; + + const slideStyle = { + transform: `scale(${scale})`, + opacity: 1, + }; + + let SlideComponent; + switch (slide.layout) { + case "title": + SlideComponent = TitleSlide; + break; + case "stats": + SlideComponent = StatsSlide; + break; + case "two-column": + SlideComponent = TwoColumnSlide; + break; + case "list": + SlideComponent = ListSlide; + break; + case "content": + default: + SlideComponent = ContentSlide; + break; + } + + return React.createElement( + "div", + { + key: slide.id, + style: slideStyle, + }, + React.createElement(SlideComponent, { slide }), + ); + }; + + return React.createElement( + "div", + { + ref: containerRef, + className: "presentation-container", + }, + [ + // Slides + ...slides.map(renderSlide), + + // Navigation controls + React.createElement("div", { key: "nav", className: "nav-controls" }, [ + // Slide indicator + React.createElement( + "span", + { key: "indicator", className: "nav-indicator" }, + `${String(currentSlide + 1).padStart(2, "0")} / ${String(slides.length).padStart(2, "0")}`, + ), + // First slide button + React.createElement( + "button", + { + key: "first", + className: "nav-btn", + onClick: () => goToSlide(0), + disabled: currentSlide === 0 || isAnimating, + title: "First slide", + }, + "⏮", + ), + // Previous button + React.createElement( + "button", + { + key: "prev", + className: "nav-btn", + onClick: () => goToSlide(currentSlide - 1), + disabled: currentSlide === 0 || isAnimating, + title: "Previous slide", + }, + "←", + ), + // Next button + React.createElement( + "button", + { + key: "next", + className: "nav-btn nav-btn--primary", + onClick: () => goToSlide(currentSlide + 1), + disabled: currentSlide === slides.length - 1 || isAnimating, + title: "Next slide", + }, + "→", + ), + // Fullscreen button + React.createElement( + "button", + { + key: "fs", + className: "nav-btn", + onClick: () => { + if (document.fullscreenElement) { + document.exitFullscreen(); + } else { + document.documentElement.requestFullscreen(); + } + }, + title: "Toggle fullscreen", + }, + "⛶", + ), + ]), + ], + ); +} + +/** + * Initialize presentation from manifest + */ +async function initPresentation(manifestPath = "./slides/manifest.json") { + try { + const response = await fetch(manifestPath); + const manifest = await response.json(); + + // Load all slides + const slides = await Promise.all( + manifest.slides.map(async (slideInfo) => { + try { + const slideResponse = await fetch(`./slides/${slideInfo.file}`); + const slideData = await slideResponse.json(); + return { ...slideInfo, ...slideData }; + } catch (e) { + console.error(`Failed to load slide: ${slideInfo.file}`, e); + return slideInfo; + } + }), + ); + + // Render presentation + const root = ReactDOM.createRoot(document.getElementById("root")); + root.render( + React.createElement(Presentation, { + slides, + title: manifest.title || "Presentation", + subtitle: manifest.subtitle || "", + }), + ); + } catch (e) { + console.error("Failed to initialize presentation:", e); + document.getElementById("root").innerHTML = ` +
+

Presentation not found

+

+ Create a manifest.json in the slides directory to get started. +

+
+ `; + } +} + +// Export for global access +window.Presentation = Presentation; +window.initPresentation = initPresentation; +window.CognaLogo = CognaLogo; +window.ComparisonBox = ComparisonBox; diff --git a/slides/assets/index.html b/slides/assets/index.html new file mode 100644 index 00000000..611f5bab --- /dev/null +++ b/slides/assets/index.html @@ -0,0 +1,58 @@ + + + + + + {{TITLE}} + + + + + + + + + + + + + + + + + +
+
Loading presentation
+
+ + + + diff --git a/slides/assets/manifest-template.json b/slides/assets/manifest-template.json new file mode 100644 index 00000000..456d934a --- /dev/null +++ b/slides/assets/manifest-template.json @@ -0,0 +1,7 @@ +{ + "title": "Presentation Title", + "subtitle": "Subtitle or date", + "createdAt": "", + "updatedAt": "", + "slides": [] +} diff --git a/slides/assets/slide-template.json b/slides/assets/slide-template.json new file mode 100644 index 00000000..5342cff2 --- /dev/null +++ b/slides/assets/slide-template.json @@ -0,0 +1,13 @@ +{ + "id": "", + "layout": "title", + "title": "", + "subtitle": "", + "tag": "", + "backgroundColor": "dc-950", + "textColor": "light", + "accent": "green", + "items": [], + "backgroundImage": "", + "customHtml": "" +} diff --git a/slides/assets/style-template.md b/slides/assets/style-template.md new file mode 100644 index 00000000..132b71e9 --- /dev/null +++ b/slides/assets/style-template.md @@ -0,0 +1,52 @@ +# Presentation Style Guide + +This document defines the visual style, tone, and design system for this presentation. + +## Brand Colors + +- **Primary Accent**: Green (#c4df1b) - Used for emphasis and key points +- **Secondary Accent**: Purple (#d4a5ff) - Used for alternative sections +- **Tertiary Accent**: Yellow (#ffd666) - Used for highlights + +## Background Theme + +- **Primary Background**: Dark (#0f0e0d) - Main slide background +- **Secondary Background**: Slightly lighter dark (#1a1918) +- **Accent Backgrounds**: Green, purple, or yellow for section breaks + +## Typography + +- **Headlines**: Large, bold, with tight letter-spacing (-4px for main titles) +- **Body Text**: Clean, readable, 17-18px for content +- **Monospace**: Used for tags, labels, and technical content +- **Uppercase Tags**: Small, tracking-wide, for section labels + +## Layout Principles + +1. **Generous Whitespace**: Slides should breathe, avoid clutter +2. **Clear Hierarchy**: Use size and color to establish importance +3. **Consistent Padding**: 80-96px padding on content slides +4. **Grid-Based**: Use 2-column or multi-column grids for complex content + +## Tone & Voice + +- Professional but approachable +- Data-driven when presenting statistics +- Clear, concise language +- Action-oriented conclusions + +## Slide Types to Use + +- **Title Slides**: For section introductions +- **Content Slides**: For detailed information with bullet points +- **Stats Slides**: For numerical data with animated counters +- **Two-Column**: For comparisons or side-by-side information +- **List Slides**: For action items or key points +- **Quote Slides**: For testimonials or important statements +- **Image Slides**: For visual impact + +## Animation Style + +- **Subtle**: Smooth fade-in with slight upward movement +- **Staggered**: Elements appear sequentially for better readability +- **Count-Up**: Numbers animate from 0 for impact diff --git a/slides/assets/styles.css b/slides/assets/styles.css new file mode 100644 index 00000000..3f4d52a8 --- /dev/null +++ b/slides/assets/styles.css @@ -0,0 +1,549 @@ +/* Cogna Educação Presentation Styles */ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); + +:root { + /* Cogna Brand Colors */ + --cogna-purple: #8B5CF6; + --cogna-purple-light: #A78BFA; + --cogna-purple-pale: #EDE9FE; + + /* Neutrals */ + --charcoal: #3D3D3D; + --charcoal-dark: #2D2D2D; + --gray-light: #E5E5E5; + --gray-medium: #9CA3AF; + --white: #FFFFFF; + --black: #1A1A1A; + + /* Semantic */ + --text-primary: #1A1A1A; + --text-secondary: #6B7280; + --text-on-dark: #FFFFFF; + --border-color: #E5E5E5; + + /* Slide dimensions */ + --slide-width: 1366px; + --slide-height: 768px; + --margin-x: 48px; + --margin-y: 40px; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +html, body { + width: 100%; + height: 100%; + overflow: hidden; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: #1a1a1a; + -webkit-font-smoothing: antialiased; +} + +#root { width: 100%; height: 100%; } + +/* Presentation Container */ +.presentation-container { + width: 100vw; + height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: #1a1a1a; + position: relative; +} + +/* Slide Base */ +.slide { + width: var(--slide-width); + height: var(--slide-height); + position: absolute; + overflow: hidden; + transform-origin: center center; +} + +/* ============================================ + TITLE SLIDE - With blob shapes + ============================================ */ +.slide--title { + background: var(--gray-light); + position: relative; +} + +/* Large charcoal blob - left side with curved right edge */ +.blob-primary { + position: absolute; + width: 70%; + height: 130%; + background: var(--charcoal); + border-radius: 0 50% 50% 0; + left: 0; + top: -15%; + z-index: 1; +} + +/* Purple circle - overlapping on right */ +.blob-accent { + position: absolute; + width: 380px; + height: 380px; + background: var(--cogna-purple); + border-radius: 50%; + right: 12%; + top: -8%; + z-index: 2; +} + +.slide--title .slide-content { + position: relative; + z-index: 3; + padding: 0 var(--margin-x); + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; +} + +.slide--title .title-hero { + font-size: 72px; + font-weight: 700; + color: var(--white); + line-height: 1.1; + letter-spacing: -0.02em; + text-transform: uppercase; + max-width: 60%; +} + +.slide--title .logo-container { + position: absolute; + bottom: var(--margin-y); + right: var(--margin-x); + z-index: 3; +} + +/* Cogna Logo */ +.logo-cogna { + display: flex; + flex-direction: column; + align-items: flex-start; +} + +.logo-cogna-wordmark { + font-family: 'Inter', sans-serif; + font-size: 42px; + font-weight: 700; + color: var(--charcoal); + letter-spacing: -0.02em; + line-height: 1; +} + +.logo-cogna-wordmark .dot { + color: var(--cogna-purple); +} + +.logo-cogna-tagline { + font-family: 'Inter', sans-serif; + font-size: 11px; + font-weight: 500; + color: var(--charcoal); + letter-spacing: 0.2em; + text-transform: uppercase; + margin-top: 2px; +} + +/* ============================================ + CONTENT SLIDE - White background + ============================================ */ +.slide--content { + background: var(--white); + padding: var(--margin-y) var(--margin-x); + display: flex; + flex-direction: column; +} + +.slide--content .slide-header { + display: flex; + justify-content: flex-end; + margin-bottom: 16px; + flex-shrink: 0; +} + +.slide--content .logo-small { + height: 36px; +} + +.slide--content .logo-small .logo-cogna-wordmark { + font-size: 28px; +} + +.slide--content .logo-small .logo-cogna-tagline { + font-size: 8px; +} + +.slide--content .slide-body { + flex: 1; + overflow: hidden; +} + +.slide--content .slide-title { + font-size: 36px; + font-weight: 700; + color: var(--cogna-purple); + line-height: 1.2; + margin-bottom: 20px; +} + +.slide--content .section-heading { + font-size: 24px; + font-weight: 600; + color: var(--cogna-purple); + line-height: 1.3; + margin-top: 28px; + margin-bottom: 16px; +} + +.slide--content .body-text { + font-size: 16px; + font-weight: 400; + color: var(--text-primary); + line-height: 1.6; +} + +/* Bullet Lists */ +.bullet-list { + list-style: none; + padding: 0; + margin: 0 0 16px 0; +} + +.bullet-list > li { + position: relative; + padding-left: 24px; + margin-bottom: 14px; + font-size: 16px; + line-height: 1.5; + color: var(--text-primary); +} + +.bullet-list > li::before { + content: ''; + position: absolute; + left: 0; + top: 8px; + width: 8px; + height: 8px; + background: var(--cogna-purple); + border-radius: 50%; +} + +/* Nested bullets - hollow circles */ +.bullet-list--nested { + margin-left: 32px; + margin-top: 10px; + margin-bottom: 10px; +} + +.bullet-list--nested > li::before { + background: transparent; + border: 2px solid var(--cogna-purple); + width: 6px; + height: 6px; + top: 7px; +} + +/* Bold purple text */ +.text-bold { + font-weight: 600; + color: var(--cogna-purple); +} + +/* ============================================ + COMPARISON BOX COMPONENT + ============================================ */ +.comparison-box { + display: flex; + align-items: stretch; + gap: 0; + margin: 24px 0; + background: var(--white); + border: 1px solid var(--border-color); + border-radius: 8px; + overflow: hidden; +} + +.comparison-box__column { + flex: 1; + padding: 20px 24px; + border-right: 1px solid var(--border-color); +} + +.comparison-box__column:last-child { + border-right: none; +} + +.comparison-box__title { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 12px; +} + +.comparison-box__list { + list-style: disc; + padding-left: 20px; + margin: 0; +} + +.comparison-box__list li { + font-size: 14px; + color: var(--cogna-purple); + margin-bottom: 4px; +} + +.comparison-box__note { + font-size: 13px; + color: var(--cogna-purple); + margin-top: 8px; +} + +.comparison-box__operator { + display: flex; + align-items: center; + justify-content: center; + padding: 0 16px; + font-size: 16px; + color: var(--text-secondary); + background: var(--gray-light); + min-width: 50px; +} + +/* ============================================ + SLIDE FOOTER + ============================================ */ +.slide-footer { + display: flex; + align-items: center; + gap: 16px; + padding-top: 16px; + margin-top: auto; + flex-shrink: 0; + position: relative; +} + +.footer-text { + font-size: 11px; + font-weight: 400; + color: var(--text-secondary); +} + +.footer-label { + font-size: 11px; + font-weight: 500; + color: var(--text-secondary); +} + +.footer-dot { + position: absolute; + right: 0; + bottom: 0; + width: 40px; + height: 40px; + background: var(--cogna-purple-light); + border-radius: 50%; + opacity: 0.7; +} + +/* ============================================ + STATS SLIDE + ============================================ */ +.slide--stats { + background: var(--white); + padding: var(--margin-y) var(--margin-x); + display: flex; + flex-direction: column; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 32px; + margin-top: auto; + margin-bottom: auto; +} + +.stat-item { + text-align: center; +} + +.stat-value { + font-size: 64px; + font-weight: 700; + color: var(--cogna-purple); + line-height: 1; + margin-bottom: 8px; +} + +.stat-label { + font-size: 16px; + font-weight: 500; + color: var(--text-secondary); +} + +/* ============================================ + TWO COLUMN LAYOUT + ============================================ */ +.slide--two-column .columns { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 48px; + margin-top: 24px; +} + +.slide--two-column .column-title { + font-size: 20px; + font-weight: 600; + color: var(--cogna-purple); + margin-bottom: 16px; +} + +/* ============================================ + LIST SLIDE + ============================================ */ +.slide--list .list-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 32px 48px; + margin-top: 32px; +} + +.list-item { + display: flex; + align-items: flex-start; + gap: 16px; +} + +.list-item-dot { + width: 10px; + height: 10px; + background: var(--cogna-purple); + border-radius: 50%; + flex-shrink: 0; + margin-top: 6px; +} + +.list-item-content h4 { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 4px; +} + +.list-item-content p { + font-size: 14px; + color: var(--text-secondary); + line-height: 1.4; +} + +/* ============================================ + NAVIGATION CONTROLS + ============================================ */ +.nav-controls { + position: fixed; + bottom: 24px; + right: 24px; + display: flex; + align-items: center; + gap: 12px; + z-index: 100; +} + +.nav-indicator { + font-size: 13px; + color: rgba(255, 255, 255, 0.5); + font-variant-numeric: tabular-nums; + margin-right: 8px; +} + +.nav-btn { + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + color: rgba(255, 255, 255, 0.8); + cursor: pointer; + transition: all 0.2s; + font-size: 16px; +} + +.nav-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.2); + border-color: rgba(255, 255, 255, 0.3); +} + +.nav-btn:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.nav-btn--primary { + background: var(--cogna-purple); + border-color: var(--cogna-purple); + color: white; +} + +.nav-btn--primary:hover:not(:disabled) { + background: var(--cogna-purple-light); + border-color: var(--cogna-purple-light); +} + +/* ============================================ + ANIMATIONS + ============================================ */ +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.animate-item { + animation: fadeInUp 0.5s ease-out forwards; +} + +/* Loading state */ +.loading { + display: flex; + align-items: center; + justify-content: center; + height: 100vh; + color: var(--cogna-purple); + font-family: 'Inter', sans-serif; + font-size: 16px; +} + +.loading::after { + content: ''; + width: 24px; + height: 24px; + margin-left: 12px; + border: 2px solid var(--gray-light); + border-top-color: var(--cogna-purple); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Fullscreen */ +:fullscreen { background: #1a1a1a; } +:-webkit-full-screen { background: #1a1a1a; } +:-moz-full-screen { background: #1a1a1a; } diff --git a/slides/package.json b/slides/package.json new file mode 100644 index 00000000..c3a51399 --- /dev/null +++ b/slides/package.json @@ -0,0 +1,32 @@ +{ + "name": "@decocms/slides", + "version": "1.0.0", + "description": "AI-powered slide presentation builder with beautiful animations", + "private": true, + "type": "module", + "scripts": { + "dev": "bun run --hot server/main.ts", + "build:server": "NODE_ENV=production bun build server/main.ts --target=bun --outfile=dist/server/main.js", + "build": "bun run build:server", + "publish": "cat app.json | deco registry publish -w /shared/deco -y", + "check": "tsc --noEmit" + }, + "exports": { + "./tools": "./server/tools/index.ts" + }, + "dependencies": { + "@decocms/bindings": "^1.0.7", + "@decocms/runtime": "^1.1.3", + "zod": "^4.0.0" + }, + "devDependencies": { + "@decocms/mcps-shared": "1.0.0", + "@mastra/core": "^0.24.0", + "@modelcontextprotocol/sdk": "1.25.1", + "deco-cli": "^0.28.0", + "typescript": "^5.7.2" + }, + "engines": { + "node": ">=22.0.0" + } +} diff --git a/slides/server/main.ts b/slides/server/main.ts new file mode 100644 index 00000000..7314482f --- /dev/null +++ b/slides/server/main.ts @@ -0,0 +1,64 @@ +/** + * Slides MCP - AI-Powered Presentation Builder + * + * This MCP provides tools and prompts for creating beautiful slide presentations + * through natural conversation with an AI agent. + * + * ## Workflow + * + * ### Phase 1: Brand Setup (one-time) + * Use SLIDES_SETUP_BRAND prompt to: + * 1. Research brand identity + * 2. Create design system (design-system.jsx, styles.css) + * 3. Preview at /design.html + * 4. Iterate until approved + * + * ### Phase 2: Create Presentations + * Use SLIDES_NEW_DECK prompt to: + * 1. Copy design system from template + * 2. Create slides with content + * 3. Preview and iterate + * 4. Bundle for sharing + * + * ## Quick Start + * Use SLIDES_QUICK_START for fast, simple presentations without brand setup. + */ +import type { Registry } from "@decocms/mcps-shared/registry"; +import { type DefaultEnv, withRuntime } from "@decocms/runtime"; +import { z } from "zod"; +import { tools } from "./tools/index.ts"; +import { prompts } from "./prompts.ts"; +import { $ } from "bun"; + +const PORT = process.env.PORT || 8007; +const MCP_URL = `http://localhost:${PORT}/mcp`; + +const StateSchema = z.object({}); + +/** + * Environment type for the Slides MCP. + */ +export type Env = DefaultEnv; + +const runtime = withRuntime({ + configuration: { + scopes: [], + state: StateSchema, + }, + tools, + prompts, +}); + +// Start server +Bun.serve({ + idleTimeout: 0, + port: PORT, + hostname: "0.0.0.0", + fetch: runtime.fetch, + development: process.env.NODE_ENV !== "production", +}); + +// Log and copy to clipboard +console.log(`\n🎯 Slides MCP running at: ${MCP_URL}\n`); +await $`echo ${MCP_URL} | pbcopy`.quiet(); +console.log(`📋 MCP URL copied to clipboard!\n`); diff --git a/slides/server/prompts.ts b/slides/server/prompts.ts new file mode 100644 index 00000000..f4e3f421 --- /dev/null +++ b/slides/server/prompts.ts @@ -0,0 +1,539 @@ +/** + * Slides MCP Prompts + * + * These prompts guide the workflow for creating presentations. + * They work WITH the tools to establish a clear flow: + * + * ## File Structure + * + * ~/slides/ # Root workspace (configurable) + * ├── brands/ # Reusable design systems + * │ ├── {brand-name}/ + * │ │ ├── design-system.jsx # Brand components (JSX) + * │ │ ├── styles.css # Brand styles + * │ │ ├── style.md # AI style guide + * │ │ └── design.html # Design system viewer + * │ └── ... + * └── decks/ # Presentations + * ├── {deck-name}/ + * │ ├── index.html # Entry point + * │ ├── engine.jsx # Presentation engine + * │ ├── design-system.jsx # (copied from brand) + * │ ├── styles.css # (copied from brand) + * │ └── slides/ + * │ ├── manifest.json + * │ └── *.json # Individual slides + * └── ... + * + * ## Workflow + * + * Phase 1: SETUP (one-time per brand) + * 1. SLIDES_SETUP_BRAND - Research brand and create design system + * 2. Show design system viewer to user for approval + * 3. Save approved design system in brands/{brand}/ + * + * Phase 2: CREATE (per presentation) + * 1. SLIDES_NEW_DECK - Create a new deck using saved design system + * 2. SLIDES_ADD_CONTENT - Add slides with content + */ + +// Default workspace location +const DEFAULT_WORKSPACE = "~/slides"; + +import { createPrompt, type GetPromptResult } from "@decocms/runtime"; +import { z } from "zod"; +import type { Env } from "./main.ts"; + +/** + * SLIDES_SETUP_BRAND - Research and create a brand design system + */ +export const createSetupBrandPrompt = (_env: Env) => + createPrompt({ + name: "SLIDES_SETUP_BRAND", + title: "Setup Brand Design System", + description: `Create a new brand design system for presentations. This is the FIRST step for a new brand. + +The agent should: +1. Research the brand (website, existing materials, style guides) +2. Extract colors, typography, logo treatment, and visual style +3. Create a customized design system +4. Generate sample slides showing all layouts +5. Show the design system viewer (/design.html) for user approval + +Files are saved to: {workspace}/brands/{brand-slug}/ +Once approved, the design system can be reused for all future presentations.`, + argsSchema: { + brandName: z + .string() + .describe("Company or brand name (e.g., 'Acme Corp')"), + brandWebsite: z + .string() + .optional() + .describe("Brand website URL for research (e.g., 'https://acme.com')"), + styleNotes: z + .string() + .optional() + .describe("Any specific style notes or preferences"), + workspace: z + .string() + .optional() + .describe("Workspace root (default: ~/slides)"), + }, + execute: ({ args }): GetPromptResult => { + const { brandName, brandWebsite, styleNotes } = args; + const workspace = args.workspace || DEFAULT_WORKSPACE; + const brandSlug = brandName + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/-+$/, ""); + const brandPath = `${workspace}/brands/${brandSlug}`; + + return { + description: `Create a design system for ${brandName}`, + messages: [ + { + role: "user", + content: { + type: "text", + text: `# Create Brand Design System: ${brandName} + +## File Structure +This will create a reusable brand at: +\`\`\` +${brandPath}/ +├── design-system.jsx # Brand components (logo, slides) +├── styles.css # Brand colors, typography +├── style.md # AI style guide +└── design.html # Component viewer +\`\`\` + +## Your Task +Create a complete presentation design system for **${brandName}**. + +## Research Phase +${ + brandWebsite + ? `1. Visit ${brandWebsite} to understand the brand identity +2. Extract: primary colors, secondary colors, typography, logo usage, visual style` + : "1. Ask the user for brand colors, fonts, and style preferences" +} +${styleNotes ? `\nUser notes: ${styleNotes}` : ""} + +## Creation Phase + +### Step 1: Ensure workspace exists +\`\`\`bash +mkdir -p ${brandPath} +\`\`\` + +### Step 2: Generate brand files +Call \`DECK_INIT\` with: +- title: "${brandName} Design System" +- brandName: "${brandName}" +- brandTagline: (extract from research or ask user) +- brandColor: (primary brand color from research) + +### Step 3: Save ONLY brand files +From DECK_INIT output, write these to ${brandPath}/: +- design-system.jsx +- styles.css +- style.md +- design.html + +(Do NOT save index.html, engine.jsx, or slides/ - those go in decks) + +### Step 4: Start preview server +\`\`\`bash +cd ${brandPath} && npx serve +\`\`\` +(Or: \`python -m http.server 8890\`) + +### Step 5: Show design system viewer +Navigate to http://localhost:8890/design.html + +Ask: "Here's the design system for ${brandName}. Does this match your brand?" + +## Iteration +If changes needed: +- Edit design-system.jsx for component changes +- Edit styles.css for colors/typography +- Refresh design.html to preview + +## Completion +When approved: +"✓ Brand '${brandName}' saved to ${brandPath}/ +You can now create presentations with: SLIDES_NEW_DECK(brand: '${brandSlug}')"`, + }, + }, + ], + }; + }, + }); + +/** + * SLIDES_NEW_DECK - Create a new presentation using an existing design system + */ +export const createNewDeckPrompt = (_env: Env) => + createPrompt({ + name: "SLIDES_NEW_DECK", + title: "Create New Presentation", + description: `Create a new slide deck using an existing brand design system. + +Prerequisites: +- Brand design system already created (via SLIDES_SETUP_BRAND) +- Brand exists at {workspace}/brands/{brand}/ + +The agent will: +1. Copy the design system from the brand +2. Initialize a new deck with the presentation title +3. Create slides based on user content +4. Preview and iterate until satisfied`, + argsSchema: { + title: z + .string() + .describe("Presentation title (e.g., 'Q4 2025 Results')"), + brand: z + .string() + .describe("Brand slug (e.g., 'acme' - must exist in brands/)"), + deckName: z + .string() + .optional() + .describe("Deck folder name (default: generated from title)"), + outline: z + .string() + .optional() + .describe("Optional slide outline or key points to cover"), + workspace: z + .string() + .optional() + .describe("Workspace root (default: ~/slides)"), + }, + execute: ({ args }): GetPromptResult => { + const { title, brand, outline } = args; + const workspace = args.workspace || DEFAULT_WORKSPACE; + const deckSlug = + args.deckName || + title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/-+$/, ""); + const brandPath = `${workspace}/brands/${brand}`; + const deckPath = `${workspace}/decks/${deckSlug}`; + + return { + description: `Create presentation: ${title}`, + messages: [ + { + role: "user", + content: { + type: "text", + text: `# Create New Presentation: ${title} + +## File Structure +\`\`\` +${deckPath}/ +├── index.html # Entry point +├── engine.jsx # Presentation engine +├── design-system.jsx # (from brand) +├── styles.css # (from brand) +└── slides/ + ├── manifest.json + └── *.json # Slide content +\`\`\` + +## Step 1: Verify brand exists +\`\`\`bash +ls ${brandPath}/design-system.jsx +\`\`\` +If brand doesn't exist, tell user to run SLIDES_SETUP_BRAND first. + +## Step 2: Create deck directory +\`\`\`bash +mkdir -p ${deckPath}/slides +\`\`\` + +## Step 3: Copy brand files +\`\`\`bash +cp ${brandPath}/design-system.jsx ${deckPath}/ +cp ${brandPath}/styles.css ${deckPath}/ +\`\`\` + +## Step 4: Generate deck files +Call \`DECK_INIT\` with: +- title: "${title}" +- (brandName/brandColor not needed - using existing brand) + +Write to ${deckPath}/: +- index.html +- engine.jsx +- slides/manifest.json + +(Do NOT overwrite design-system.jsx and styles.css - they came from brand) + +## Step 5: Create slides +${ + outline + ? `Create slides based on this outline: +${outline}` + : "Ask the user what slides they need." +} + +Use \`SLIDE_CREATE\` for each slide: +- **title**: Opening slide +- **content**: Main points with bullets +- **stats**: Metrics and KPIs +- **two-column**: Comparisons +- **list**: Feature grids + +Write each slide to ${deckPath}/slides/ +Update manifest.json with slide order. + +## Step 6: Preview +\`\`\`bash +cd ${deckPath} && npx serve +\`\`\` +(Or: \`python -m http.server 8891\`) + +Navigate to http://localhost:8891/ and walk through with user. + +## Finalize +When satisfied: +"✓ Deck saved to ${deckPath}/ +Want me to bundle it into a single portable HTML? (DECK_BUNDLE)"`, + }, + }, + ], + }; + }, + }); + +/** + * SLIDES_ADD_CONTENT - Add slides to an existing deck + */ +export const createAddContentPrompt = (_env: Env) => + createPrompt({ + name: "SLIDES_ADD_CONTENT", + title: "Add Slides to Deck", + description: `Add new slides to an existing presentation. + +Use this when: +- Adding more slides to a deck in progress +- User provides new content to add +- Expanding on existing topics`, + argsSchema: { + deck: z.string().describe("Deck name (e.g., 'q4-results')"), + content: z + .string() + .describe("Content to add (can be notes, bullet points, data, etc.)"), + workspace: z + .string() + .optional() + .describe("Workspace root (default: ~/slides)"), + }, + execute: ({ args }): GetPromptResult => { + const { deck, content } = args; + const workspace = args.workspace || DEFAULT_WORKSPACE; + const deckPath = `${workspace}/decks/${deck}`; + + return { + description: "Add slides with provided content", + messages: [ + { + role: "user", + content: { + type: "text", + text: `# Add Slides to: ${deck} + +## Deck Location +${deckPath}/ + +## Content to Add +${content} + +## Instructions +1. Read ${deckPath}/slides/manifest.json to see existing slides +2. Analyze content and choose layouts: + - Data/numbers → stats + - Comparisons → two-column + - Features/lists → list + - General content → content + +3. Use \`SLIDE_CREATE\` for each new slide +4. Write slide JSON files to ${deckPath}/slides/ +5. Update manifest.json with new slides + +## Preview +Refresh browser to see new slides. Ask if adjustments needed.`, + }, + }, + ], + }; + }, + }); + +/** + * SLIDES_QUICK_START - Fast path for simple presentations + */ +export const createQuickStartPrompt = (_env: Env) => + createPrompt({ + name: "SLIDES_QUICK_START", + title: "Quick Start Presentation", + description: `Create a presentation quickly with minimal setup. + +Use when: +- User wants a quick presentation without brand setup +- Simple one-off presentations +- Demos and prototypes + +Uses a generic brand and creates deck directly.`, + argsSchema: { + title: z.string().describe("Presentation title"), + topic: z.string().describe("What the presentation is about"), + slideCount: z + .string() + .optional() + .describe("Approximate number of slides (default: 5-7)"), + workspace: z + .string() + .optional() + .describe("Workspace root (default: ~/slides)"), + }, + execute: ({ args }): GetPromptResult => { + const { title, topic } = args; + const workspace = args.workspace || DEFAULT_WORKSPACE; + const count = args.slideCount || "5-7"; + const deckSlug = title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/-+$/, ""); + const deckPath = `${workspace}/decks/${deckSlug}`; + + return { + description: `Quick presentation: ${title}`, + messages: [ + { + role: "user", + content: { + type: "text", + text: `# Quick Start: ${title} + +## Target +${deckPath}/ + +## Create a ${count} slide presentation about: ${topic} + +### Step 1: Create deck directory +\`\`\`bash +mkdir -p ${deckPath}/slides +\`\`\` + +### Step 2: Initialize with generic brand +Call \`DECK_INIT\` with: +- title: "${title}" +- brandName: "Presenter" +- brandColor: "#3B82F6" + +Write ALL files to ${deckPath}/ + +### Step 3: Create slides +Generate ${count} slides covering ${topic}: +1. **title** - Opening: "${title}" +2. **content** - Key points about ${topic} +3. **stats** - (if data available) +4. **content** or **list** - Details +5. **title** or **content** - Closing/summary + +Use \`SLIDE_CREATE\` for each, write to ${deckPath}/slides/ + +### Step 4: Preview +\`\`\`bash +cd ${deckPath} && npx serve +\`\`\` +(Or: \`python -m http.server 8892\`) + +Open http://localhost:8892/ + +### Step 5: Iterate +"Here's your presentation. What would you like to change?"`, + }, + }, + ], + }; + }, + }); + +/** + * SLIDES_LIST - List available brands and decks + */ +export const createListPrompt = (_env: Env) => + createPrompt({ + name: "SLIDES_LIST", + title: "List Brands and Decks", + description: `Show available brands and existing decks in the workspace. + +Use this to: +- See what brands are available +- Find existing decks +- Understand the current workspace state`, + argsSchema: { + workspace: z + .string() + .optional() + .describe("Workspace root (default: ~/slides)"), + }, + execute: ({ args }): GetPromptResult => { + const workspace = args.workspace || DEFAULT_WORKSPACE; + + return { + description: "List available brands and decks", + messages: [ + { + role: "user", + content: { + type: "text", + text: `# List Slides Workspace + +## Workspace +${workspace}/ + +## Check Brands +\`\`\`bash +echo "=== BRANDS ===" && ls -la ${workspace}/brands/ 2>/dev/null || echo "(no brands yet)" +\`\`\` + +## Check Decks +\`\`\`bash +echo "=== DECKS ===" && ls -la ${workspace}/decks/ 2>/dev/null || echo "(no decks yet)" +\`\`\` + +## Summary +For each brand, show: +- Brand name +- Primary color (from styles.css) + +For each deck, show: +- Deck name +- Number of slides (from manifest.json) +- Brand used (if identifiable) + +## Suggest Next Action +If no brands: "Create a brand with SLIDES_SETUP_BRAND" +If brands exist but no decks: "Create a deck with SLIDES_NEW_DECK" +If both exist: "Ready to create presentations!"`, + }, + }, + ], + }; + }, + }); + +/** + * Export all prompt factories + */ +export const prompts = (env: Env) => [ + createSetupBrandPrompt(env), + createNewDeckPrompt(env), + createAddContentPrompt(env), + createQuickStartPrompt(env), + createListPrompt(env), +]; diff --git a/slides/server/tools/deck.ts b/slides/server/tools/deck.ts new file mode 100644 index 00000000..f75725f7 --- /dev/null +++ b/slides/server/tools/deck.ts @@ -0,0 +1,1245 @@ +/** + * Deck management tools for slide presentations. + * + * These tools handle initialization, info, preview, and bundling of slide decks. + * The system uses JSX with Babel Standalone for browser transpilation. + */ +import { createTool } from "@decocms/runtime/tools"; +import { z } from "zod"; +import type { Env } from "../main.ts"; + +// Default style guide template +const DEFAULT_STYLE_TEMPLATE = `# Presentation Style Guide + +This document defines the visual style, tone, and design system for this presentation. +Edit this file to customize the look and feel of your slides. + +## Brand Identity + +**Company/Project:** [Your Company Name] +**Style:** Modern, professional, clean +**Primary Color:** #8B5CF6 (Purple) + +## Color Palette + +\`\`\`css +:root { + /* Primary brand color - used for accents, headings, bullets */ + --brand-primary: #8B5CF6; + --brand-primary-light: #A78BFA; + + /* Background colors */ + --bg-dark: #1a1a1a; + --bg-light: #FFFFFF; + --bg-gray: #E5E5E5; + + /* Text colors */ + --text-dark: #1A1A1A; + --text-light: #FFFFFF; + --text-muted: #6B7280; + + /* Accent for blobs/shapes */ + --shape-dark: #3D3D3D; +} +\`\`\` + +## Typography + +- **Font Family:** Inter, system-ui, sans-serif +- **Title Slides:** 72px, bold, uppercase for impact +- **Content Titles:** 36px, bold, brand color +- **Section Headings:** 24px, semibold, brand color +- **Body Text:** 16px, regular, dark color +- **Tags/Labels:** 12px, uppercase, letter-spacing 0.1em + +## Slide Layouts + +### Title Slide +- Large background shape (blob) with curved edge +- Overlapping accent circle in brand color +- Bold uppercase title +- Logo in bottom-right corner + +### Content Slide +- White/light background +- Logo in top-right header +- Purple/brand colored title and section headings +- Bullet points with brand-colored dots +- Bold key terms in brand color +- Footer with source citations + +### Stats Slide +- Large numbers in brand color +- Labels below each stat +- Grid layout for 3-4 stats + +### Two-Column Slide +- Side-by-side comparison +- Column titles in brand color +- Bullet lists in each column + +### List Slide +- 2x2 grid of items +- Dot + title + description pattern + +## Customizing the Design System + +Edit \`design-system.jsx\` to customize components. Key components: +- \`BrandLogo\`: Your company logo +- \`SlideWrapper\`: Base slide container +- \`TitleSlide\`, \`ContentSlide\`, etc.: Individual layouts + +All styling uses CSS classes from \`styles.css\` with CSS variables for easy theming. +`; + +// Design System JSX - Real JSX syntax +const getDesignSystemJSX = (brandName = "Brand", tagline = "TAGLINE") => `/** + * Design System + * Brand components for presentations using real JSX + * Requires: React, @babel/standalone + */ + +(() => { + // ============================================================================ + // BRAND: Logo + // ============================================================================ + + function BrandLogo({ size = "normal", className = "" }) { + const isSmall = size === "small"; + + return ( +
+ ${brandName} + ${tagline} +
+ ); + } + + // ============================================================================ + // LAYOUT: Slide Wrapper + // ============================================================================ + + function SlideWrapper({ children, variant = "content", className = "" }) { + return ( +
+ {children} +
+ ); + } + + function SlideHeader() { + return ( +
+ +
+ ); + } + + function SlideFooter({ source, label }) { + if (!source) return null; + + return ( +
+ Source: {source} + {label && {label}} +
+
+ ); + } + + function Tag({ children }) { + if (!children) return null; + return {children}; + } + + // ============================================================================ + // CONTENT: Bullets + // ============================================================================ + + function BulletList({ items, nested = false }) { + if (!items?.length) return null; + + return ( +
    + {items.map((item, idx) => ( +
  • + {item.highlight ? ( + {item.text} + ) : ( + item.text + )} +
  • + ))} +
+ ); + } + + function Section({ title, bullets, nestedBullets }) { + return ( +
+ {title &&

{title}

} + + +
+ ); + } + + // ============================================================================ + // SLIDES: Title + // ============================================================================ + + function TitleSlide({ slide }) { + return ( + +
+
+
+

{slide.title}

+
+
+ +
+ + ); + } + + // ============================================================================ + // SLIDES: Content + // ============================================================================ + + function ContentSlide({ slide }) { + const items = slide.items || []; + + return ( + + +
+

{slide.title}

+ {items.map((item, idx) => ( +
+ ))} +
+ +
+ ); + } + + // ============================================================================ + // SLIDES: Stats + // ============================================================================ + + function StatItem({ value, label }) { + return ( +
+
{value}
+
{label}
+
+ ); + } + + function StatsSlide({ slide }) { + const items = slide.items || []; + + return ( + + + {slide.tag} +

{slide.title}

+
+ {items.map((item, idx) => ( + + ))} +
+
+ ); + } + + // ============================================================================ + // SLIDES: Two Column + // ============================================================================ + + function Column({ title, bullets }) { + return ( +
+ {title &&

{title}

} + +
+ ); + } + + function TwoColumnSlide({ slide }) { + const [left, right] = slide.items || []; + + return ( + + +
+ {slide.tag} +

{slide.title}

+
+ + +
+
+
+ ); + } + + // ============================================================================ + // SLIDES: List (2x2 Grid) + // ============================================================================ + + function ListItem({ title, subtitle }) { + return ( +
+
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+
+ ); + } + + function ListSlide({ slide }) { + const items = slide.items || []; + + return ( + + +
+ {slide.tag} +

{slide.title}

+ {slide.subtitle &&

{slide.subtitle}

} +
+ {items.map((item, idx) => ( + + ))} +
+
+
+ ); + } + + // ============================================================================ + // COMPONENT REGISTRY - Expose globally + // ============================================================================ + + window.DesignSystem = { + // Slide components + SlideComponents: { + title: TitleSlide, + content: ContentSlide, + stats: StatsSlide, + "two-column": TwoColumnSlide, + list: ListSlide, + }, + // Individual components for custom slides + BrandLogo, + TitleSlide, + ContentSlide, + StatsSlide, + TwoColumnSlide, + ListSlide, + // Building blocks + SlideWrapper, + SlideHeader, + SlideFooter, + BulletList, + Section, + Tag, + }; + + console.log("✓ Design System loaded"); +})(); +`; + +// Engine JSX - Real JSX syntax +const getEngineJSX = () => `/** + * Presentation Engine + * Core logic for slide navigation, scaling, and rendering + * Requires: React, ReactDOM, design-system.jsx loaded first + */ + +(() => { + const { useState, useEffect, useRef } = React; + + // Base dimensions (16:9 aspect ratio) + const BASE_WIDTH = 1366; + const BASE_HEIGHT = 768; + + // ============================================================================ + // NAVIGATION + // ============================================================================ + + function Navigation({ current, total, onNavigate, disabled }) { + const goFirst = () => onNavigate(0); + const goPrev = () => onNavigate(current - 1); + const goNext = () => onNavigate(current + 1); + + const toggleFullscreen = () => { + if (document.fullscreenElement) { + document.exitFullscreen(); + } else { + document.documentElement.requestFullscreen(); + } + }; + + const indicator = \`\${String(current + 1).padStart(2, "0")} / \${String(total).padStart(2, "0")}\`; + + return ( +
+ {indicator} + + + + +
+ ); + } + + // ============================================================================ + // PRESENTATION + // ============================================================================ + + function Presentation({ slides, title, subtitle }) { + const [currentSlide, setCurrentSlide] = useState(0); + const [isAnimating, setIsAnimating] = useState(false); + const [scale, setScale] = useState(1); + const containerRef = useRef(null); + + const { SlideComponents } = window.DesignSystem; + + // Calculate scale to fit viewport + useEffect(() => { + const calculateScale = () => { + if (!containerRef.current) return; + const { clientWidth, clientHeight } = containerRef.current; + const scaleX = clientWidth / BASE_WIDTH; + const scaleY = clientHeight / BASE_HEIGHT; + setScale(Math.min(scaleX, scaleY) * 0.95); + }; + + calculateScale(); + window.addEventListener("resize", calculateScale); + return () => window.removeEventListener("resize", calculateScale); + }, []); + + // Keyboard navigation + useEffect(() => { + const handleKeyDown = (e) => { + if (isAnimating) return; + + const actions = { + ArrowRight: () => goToSlide(Math.min(currentSlide + 1, slides.length - 1)), + ArrowDown: () => goToSlide(Math.min(currentSlide + 1, slides.length - 1)), + " ": () => goToSlide(Math.min(currentSlide + 1, slides.length - 1)), + ArrowLeft: () => goToSlide(Math.max(currentSlide - 1, 0)), + ArrowUp: () => goToSlide(Math.max(currentSlide - 1, 0)), + Home: () => goToSlide(0), + End: () => goToSlide(slides.length - 1), + }; + + if (actions[e.key]) { + e.preventDefault(); + actions[e.key](); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [currentSlide, isAnimating, slides.length]); + + const goToSlide = (index) => { + if (index === currentSlide || isAnimating) return; + setIsAnimating(true); + setCurrentSlide(index); + setTimeout(() => setIsAnimating(false), 300); + }; + + // Render current slide + const slide = slides[currentSlide]; + const SlideComponent = SlideComponents[slide?.layout] || SlideComponents.content; + + const displayWidth = BASE_WIDTH * scale; + const displayHeight = BASE_HEIGHT * scale; + + return ( +
+
+
+ {slide && } +
+
+ + +
+ ); + } + + // ============================================================================ + // INITIALIZATION + // ============================================================================ + + async function initPresentation(manifestPath = "./slides/manifest.json") { + try { + const response = await fetch(manifestPath); + const manifest = await response.json(); + + const slides = await Promise.all( + manifest.slides.map(async (slideInfo) => { + try { + const slideResponse = await fetch(\`./slides/\${slideInfo.file}\`); + const slideData = await slideResponse.json(); + return { ...slideInfo, ...slideData }; + } catch (e) { + console.error(\`Failed to load slide: \${slideInfo.file}\`, e); + return slideInfo; + } + }) + ); + + const root = ReactDOM.createRoot(document.getElementById("root")); + root.render( + + ); + + console.log(\`✓ Presentation loaded: \${slides.length} slides\`); + } catch (e) { + console.error("Failed to initialize presentation:", e); + document.getElementById("root").innerHTML = \` +
+

Presentation not found

+

Create slides/manifest.json to get started.

+
+ \`; + } + } + + window.Presentation = Presentation; + window.initPresentation = initPresentation; + + console.log("✓ Engine loaded"); +})(); +`; + +// Design System Viewer HTML +const getDesignViewerHTML = (brandColor = "#8B5CF6") => ` + + + + + Design System Viewer + + + + + + + +
+ + + +`; + +// CSS with variables for easy customization +const getStylesCSS = () => `/* Slides Presentation Styles */ +/* Customize by editing the CSS variables below */ + +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); + +:root { + /* Brand Colors - CUSTOMIZE THESE */ + --brand-primary: #8B5CF6; + --brand-primary-light: #A78BFA; + + /* Background Colors */ + --bg-shape-dark: #3D3D3D; + --bg-gray: #E5E5E5; + --bg-white: #FFFFFF; + --bg-dark: #1a1a1a; + + /* Text Colors */ + --text-dark: #1A1A1A; + --text-light: #FFFFFF; + --text-muted: #6B7280; + --text-secondary: #9CA3AF; + + /* Slide Dimensions */ + --slide-width: 1366px; + --slide-height: 768px; + --margin-x: 48px; + --margin-y: 40px; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } +html, body { + width: 100%; height: 100%; overflow: hidden; + font-family: 'Inter', system-ui, sans-serif; + background: var(--bg-dark); + -webkit-font-smoothing: antialiased; +} +#root { width: 100%; height: 100%; } + +.presentation-container { + width: 100vw; height: 100vh; + display: flex; align-items: center; justify-content: center; + background: var(--bg-dark); +} + +.slide { + width: var(--slide-width); height: var(--slide-height); + position: absolute; overflow: hidden; + transform-origin: center center; +} + +/* Title Slide */ +.slide--title { background: var(--bg-gray); } +.blob-primary { + position: absolute; width: 70%; height: 130%; + background: var(--bg-shape-dark); + border-radius: 0 50% 50% 0; left: 0; top: -15%; z-index: 1; +} +.blob-accent { + position: absolute; width: 380px; height: 380px; + background: var(--brand-primary); + border-radius: 50%; right: 12%; top: -8%; z-index: 2; +} +.slide--title .slide-content { + position: relative; z-index: 3; + padding: 0 var(--margin-x); height: 100%; + display: flex; flex-direction: column; justify-content: center; +} +.title-hero { + font-size: 72px; font-weight: 700; color: var(--text-light); + line-height: 1.1; letter-spacing: -0.02em; + text-transform: uppercase; max-width: 60%; +} +.logo-container { + position: absolute; bottom: var(--margin-y); + right: var(--margin-x); z-index: 3; +} + +/* Brand Logo */ +.logo-brand { display: flex; flex-direction: column; font-size: 42px; } +.logo-brand.logo-small { font-size: 28px; } +.logo-brand-wordmark { + font-weight: 700; color: var(--bg-shape-dark); letter-spacing: -0.02em; +} +.logo-brand-tagline { + font-size: 11px; font-weight: 500; color: var(--bg-shape-dark); + letter-spacing: 0.2em; text-transform: uppercase; margin-top: 2px; +} + +/* Content Slide */ +.slide--content { + background: var(--bg-white); + padding: var(--margin-y) var(--margin-x); + display: flex; flex-direction: column; +} +.slide-header { + display: flex; justify-content: flex-end; margin-bottom: 16px; +} +.slide-body { flex: 1; overflow: hidden; } +.slide-title { + font-size: 36px; font-weight: 700; + color: var(--brand-primary); line-height: 1.2; margin-bottom: 20px; +} +.slide-tag { + font-size: 12px; font-weight: 500; color: var(--text-secondary); + letter-spacing: 0.1em; text-transform: uppercase; + margin-bottom: 8px; display: block; +} +.slide-subtitle { + font-size: 16px; color: var(--text-muted); margin-bottom: 16px; +} +.section-heading { + font-size: 24px; font-weight: 600; + color: var(--brand-primary); line-height: 1.3; + margin-top: 28px; margin-bottom: 16px; +} + +/* Bullet Lists */ +.bullet-list { list-style: none; padding: 0; margin: 0 0 16px 0; } +.bullet-list > li { + position: relative; padding-left: 24px; margin-bottom: 14px; + font-size: 16px; line-height: 1.5; color: var(--text-dark); +} +.bullet-list > li::before { + content: ''; position: absolute; left: 0; top: 8px; + width: 8px; height: 8px; background: var(--brand-primary); border-radius: 50%; +} +.bullet-list--nested { margin-left: 32px; margin-top: 10px; } +.bullet-list--nested > li::before { + background: transparent; border: 2px solid var(--brand-primary); + width: 6px; height: 6px; +} +.text-bold { font-weight: 600; color: var(--brand-primary); } + +/* Footer */ +.slide-footer { + display: flex; align-items: center; gap: 16px; + padding-top: 16px; margin-top: auto; position: relative; +} +.footer-text { font-size: 11px; color: var(--text-muted); } +.footer-label { font-size: 11px; font-weight: 500; color: var(--text-muted); } +.footer-dot { + position: absolute; right: 0; bottom: 0; + width: 40px; height: 40px; background: var(--brand-primary-light); + border-radius: 50%; opacity: 0.7; +} + +/* Stats Slide */ +.stats-grid { + display: grid; grid-template-columns: repeat(4, 1fr); + gap: 32px; margin-top: auto; margin-bottom: auto; +} +.stat-item { text-align: center; } +.stat-value { + font-size: 64px; font-weight: 700; + color: var(--brand-primary); line-height: 1; margin-bottom: 8px; +} +.stat-label { font-size: 16px; font-weight: 500; color: var(--text-muted); } + +/* Two Column */ +.columns { display: grid; grid-template-columns: 1fr 1fr; gap: 48px; margin-top: 24px; } +.column-title { + font-size: 20px; font-weight: 600; + color: var(--brand-primary); margin-bottom: 16px; +} + +/* List Slide */ +.list-grid { + display: grid; grid-template-columns: repeat(2, 1fr); + gap: 32px 48px; margin-top: 32px; +} +.list-item { display: flex; align-items: flex-start; gap: 16px; } +.list-item-dot { + width: 10px; height: 10px; background: var(--brand-primary); + border-radius: 50%; flex-shrink: 0; margin-top: 6px; +} +.list-item-content h4 { + font-size: 18px; font-weight: 600; color: var(--text-dark); margin-bottom: 4px; +} +.list-item-content p { font-size: 14px; color: var(--text-muted); line-height: 1.4; } + +/* Navigation */ +.nav-controls { + position: fixed; bottom: 24px; right: 24px; + display: flex; align-items: center; gap: 12px; z-index: 100; +} +.nav-indicator { + font-size: 13px; color: rgba(255,255,255,0.5); + font-variant-numeric: tabular-nums; margin-right: 8px; +} +.nav-btn { + width: 40px; height: 40px; border-radius: 50%; + display: flex; align-items: center; justify-content: center; + background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); + color: rgba(255,255,255,0.8); cursor: pointer; transition: all 0.2s; +} +.nav-btn:hover:not(:disabled) { background: rgba(255,255,255,0.2); } +.nav-btn:disabled { opacity: 0.3; cursor: not-allowed; } +.nav-btn--primary { + background: var(--brand-primary); border-color: var(--brand-primary); color: white; +} + +/* Error & Loading states */ +.error { + display: flex; flex-direction: column; align-items: center; justify-content: center; + height: 100vh; color: var(--brand-primary); font-family: Inter, sans-serif; +} +.error h1 { font-size: 36px; font-weight: 700; } +.error p { font-size: 16px; color: var(--text-muted); margin-top: 8px; } +.loading { + display: flex; align-items: center; justify-content: center; + height: 100vh; color: var(--text-muted); font-family: Inter, sans-serif; +} +`; + +/** + * DECK_INIT - Initialize a new slide deck with JSX architecture + */ +export const createDeckInitTool = (_env: Env) => + createTool({ + id: "DECK_INIT", + description: `Initialize a new slide deck or brand. + +**Two modes:** + +1. **Create a BRAND** (no 'brand' parameter): + Creates all files for a reusable brand in brands/{brandSlug}/ + - design-system.jsx, styles.css, design.html, style.md + +2. **Create a DECK** (with 'brand' parameter): + Creates only deck files in decks/{deckSlug}/, references brand + - index.html (loads from ../../brands/{brand}/) + - engine.jsx + - slides/manifest.json + +The deck's index.html will load design-system.jsx and styles.css from the brand folder.`, + inputSchema: z.object({ + title: z.string().describe("Presentation or brand title"), + subtitle: z.string().optional().describe("Presentation subtitle or date"), + brand: z + .string() + .optional() + .describe( + "Brand slug to use (e.g., 'cogna'). If provided, creates a deck referencing this brand. If not provided, creates a new brand.", + ), + brandName: z + .string() + .optional() + .describe("Brand name for logo (only for new brands)"), + brandTagline: z + .string() + .optional() + .describe("Brand tagline (only for new brands)"), + brandColor: z + .string() + .optional() + .describe("Primary brand color in hex (only for new brands)"), + }), + outputSchema: z.object({ + files: z + .array( + z.object({ + path: z.string().describe("Relative file path"), + content: z.string().describe("File content"), + }), + ) + .describe("Files to create"), + message: z.string(), + mode: z + .enum(["brand", "deck"]) + .describe("Whether this created a brand or deck"), + }), + execute: async ({ context }) => { + const { title, subtitle, brand, brandName, brandTagline, brandColor } = + context; + const now = new Date().toISOString(); + + // MODE: Create a DECK referencing an existing brand + if (brand) { + const brandPath = `../../brands/${brand}`; + + const manifest = { + title: title || "Presentation", + subtitle: subtitle || "", + brand: brand, + createdAt: now, + updatedAt: now, + slides: [], + }; + + const deckIndexHtml = ` + + + + + ${title || "Presentation"} + + + + + + + + + + + + + + + +
Loading...
+ + + + + + + + + + +`; + + return { + files: [ + { path: "index.html", content: deckIndexHtml }, + { path: "engine.jsx", content: getEngineJSX() }, + { + path: "slides/manifest.json", + content: JSON.stringify(manifest, null, 2), + }, + ], + message: `Deck "${title}" created using brand "${brand}". Save to decks/{deck-name}/. Serve with: npx serve`, + mode: "deck" as const, + }; + } + + // MODE: Create a new BRAND + let stylesCSS = getStylesCSS(); + if (brandColor) { + stylesCSS = stylesCSS.replace(/#8B5CF6/g, brandColor); + } + + return { + files: [ + { + path: "design-system.jsx", + content: getDesignSystemJSX( + brandName || "Brand", + brandTagline || "TAGLINE", + ), + }, + { path: "styles.css", content: stylesCSS }, + { + path: "design.html", + content: getDesignViewerHTML(brandColor || "#8B5CF6"), + }, + { path: "style.md", content: DEFAULT_STYLE_TEMPLATE }, + ], + message: `Brand "${brandName || title}" created. Save to brands/{brand-slug}/. Preview design system at /design.html`, + mode: "brand" as const, + }; + }, + }); + +/** + * DECK_INFO - Get information about a slide deck + */ +export const createDeckInfoTool = (_env: Env) => + createTool({ + id: "DECK_INFO", + description: "Get information about a slide deck from its manifest.", + inputSchema: z.object({ + manifest: z.string().describe("Content of manifest.json"), + }), + outputSchema: z.object({ + title: z.string(), + subtitle: z.string(), + slideCount: z.number(), + slides: z.array( + z.object({ id: z.string(), title: z.string(), layout: z.string() }), + ), + }), + execute: async ({ context }) => { + const manifest = JSON.parse(context.manifest); + return { + title: manifest.title || "Untitled", + subtitle: manifest.subtitle || "", + slideCount: manifest.slides?.length || 0, + slides: (manifest.slides || []).map((s: any) => ({ + id: s.id || s.file, + title: s.title || "Untitled", + layout: s.layout || "content", + })), + }; + }, + }); + +/** + * DECK_BUNDLE - Bundle all slides into a single HTML file + */ +export const createDeckBundleTool = (_env: Env) => + createTool({ + id: "DECK_BUNDLE", + description: + "Bundle the entire slide deck into a single portable HTML file with embedded JSX.", + inputSchema: z.object({ + manifest: z.string().describe("Content of manifest.json"), + slides: z.array( + z.object({ + file: z.string(), + content: z.string().describe("JSON content of slide file"), + }), + ), + stylesCss: z.string().describe("Content of styles.css"), + designSystemJsx: z.string().describe("Content of design-system.jsx"), + }), + outputSchema: z.object({ + html: z.string().describe("Complete bundled HTML file"), + slideCount: z.number(), + }), + execute: async ({ context }) => { + const manifest = JSON.parse(context.manifest); + const slides = context.slides.map((s) => JSON.parse(s.content)); + + const bundledHtml = ` + + + + + ${manifest.title || "Presentation"} + + + + + + +
+ + + + +`; + + return { html: bundledHtml, slideCount: slides.length }; + }, + }); + +/** + * DECK_GET_ENGINE - Get the presentation engine JSX + */ +export const createDeckGetEngineTool = (_env: Env) => + createTool({ + id: "DECK_GET_ENGINE", + description: + "Get the presentation engine JSX. Save as engine.jsx in the deck directory.", + inputSchema: z.object({}), + outputSchema: z.object({ + content: z.string().describe("JSX engine content"), + }), + execute: async () => ({ content: getEngineJSX() }), + }); + +/** + * DECK_GET_DESIGN_SYSTEM - Get the design system JSX + */ +export const createDeckGetDesignSystemTool = (_env: Env) => + createTool({ + id: "DECK_GET_DESIGN_SYSTEM", + description: + "Get the design system JSX template. Customize brand name/tagline and save as design-system.jsx.", + inputSchema: z.object({ + brandName: z.string().optional().describe("Brand name for logo"), + brandTagline: z.string().optional().describe("Brand tagline"), + }), + outputSchema: z.object({ + content: z.string().describe("JSX design system content"), + }), + execute: async ({ context }) => ({ + content: getDesignSystemJSX( + context.brandName || "Brand", + context.brandTagline || "TAGLINE", + ), + }), + }); + +export const deckTools = [ + createDeckInitTool, + createDeckInfoTool, + createDeckBundleTool, + createDeckGetEngineTool, + createDeckGetDesignSystemTool, +]; diff --git a/slides/server/tools/index.ts b/slides/server/tools/index.ts new file mode 100644 index 00000000..fc937476 --- /dev/null +++ b/slides/server/tools/index.ts @@ -0,0 +1,34 @@ +/** + * Central export point for all slides MCP tools. + * + * This file aggregates all tools from different domains: + * - Deck tools: initialization, info, bundling, engine + * - Style tools: style guide management + * - Slide tools: CRUD operations for slides + */ +import type { Env } from "../main.ts"; +import { deckTools } from "./deck.ts"; +import { styleTools } from "./style.ts"; +import { slideTools } from "./slides.ts"; + +// Type for tool factory functions +type ToolFactory = ( + env: Env, +) => ReturnType; + +// Combine all tool factories +const allToolFactories: ToolFactory[] = [ + ...deckTools, + ...styleTools, + ...slideTools, +]; + +// Export tools as a function that takes env and returns all tools +export const tools = (env: TEnv) => { + return allToolFactories.map((factory) => factory(env)); +}; + +// Re-export individual tool modules for direct access +export { deckTools } from "./deck.ts"; +export { styleTools } from "./style.ts"; +export { slideTools } from "./slides.ts"; diff --git a/slides/server/tools/slides.ts b/slides/server/tools/slides.ts new file mode 100644 index 00000000..870ea002 --- /dev/null +++ b/slides/server/tools/slides.ts @@ -0,0 +1,540 @@ +/** + * Slide management tools for slide presentations. + * + * These tools handle CRUD operations for individual slides including + * creation, updates, deletion, listing, and reordering. + * + * SLIDE LAYOUTS: + * - title: Opening slide with large title, decorative shapes, and logo + * - content: Main content slide with title, sections, bullets, and footer + * - stats: Large numbers in a grid (3-4 stats) + * - two-column: Side-by-side comparison with column titles and bullets + * - list: 2x2 grid of items with title + description + * - quote: Centered quote with attribution + * - image: Full background image with overlay text + * - custom: Raw HTML content + */ +import { createTool } from "@decocms/runtime/tools"; +import { z } from "zod"; +import type { Env } from "../main.ts"; + +// Shared schemas +const LayoutSchema = z.enum([ + "title", + "content", + "two-column", + "stats", + "list", + "quote", + "image", + "custom", +]); + +const BulletSchema = z.object({ + text: z.string().describe("Bullet point text"), + highlight: z + .boolean() + .optional() + .describe("Highlight in brand color (for key terms)"), +}); + +const SlideItemSchema = z.object({ + title: z.string().optional().describe("Section title or heading"), + subtitle: z.string().optional().describe("Description or subtext"), + value: z + .string() + .optional() + .describe("For stats: the number (e.g., '2,847', '89%', 'R$42M')"), + label: z.string().optional().describe("For stats: label below the number"), + bullets: z + .array(BulletSchema) + .optional() + .describe("Bullet points with optional highlighting"), + nestedBullets: z + .array(BulletSchema) + .optional() + .describe("Second-level bullets (hollow circles)"), +}); + +const SlideDataSchema = z.object({ + id: z.string().describe("Unique slide identifier"), + layout: LayoutSchema.describe("Slide layout type"), + title: z.string().describe("Main slide title"), + subtitle: z.string().optional().describe("Subtitle or description"), + tag: z + .string() + .optional() + .describe("Small uppercase label above title (e.g., 'METHODOLOGY')"), + items: z + .array(SlideItemSchema) + .optional() + .describe("Content items (sections, stats, list items)"), + source: z + .string() + .optional() + .describe( + "Source citation for footer (e.g., 'Company Report - July 2023')", + ), + label: z + .string() + .optional() + .describe("Footer label (e.g., 'Public', 'Confidential')"), + backgroundImage: z + .string() + .optional() + .describe("URL for background image (image layout)"), + customHtml: z + .string() + .optional() + .describe("Raw HTML content (custom layout only)"), +}); + +/** + * SLIDE_CREATE - Create a new slide + */ +export const createSlideCreateTool = (_env: Env) => + createTool({ + id: "SLIDE_CREATE", + description: `Create a new slide. Returns slide JSON to save and updated manifest. + +LAYOUT EXAMPLES: + +**title** - Opening slide + { layout: "title", title: "PRESENTATION TITLE" } + +**content** - Main content with bullets + { layout: "content", title: "What is X?", tag: "OVERVIEW", + items: [{ title: "Key Points", bullets: [{ text: "First point" }, { text: "Important", highlight: true }] }], + source: "Company Report 2023", label: "Public" } + +**stats** - Large numbers + { layout: "stats", title: "Key Metrics", tag: "RESULTS", + items: [{ value: "2,847", label: "Total Users" }, { value: "89%", label: "Success Rate" }] } + +**two-column** - Side-by-side + { layout: "two-column", title: "Comparison", tag: "ANALYSIS", + items: [{ title: "Option A", bullets: [...] }, { title: "Option B", bullets: [...] }] } + +**list** - 2x2 grid of items + { layout: "list", title: "Our Services", subtitle: "What we offer", + items: [{ title: "Service 1", subtitle: "Description" }, ...] }`, + inputSchema: z.object({ + manifest: z.string().describe("Current manifest.json content"), + layout: LayoutSchema.describe("Slide layout type"), + title: z.string().describe("Main slide title"), + subtitle: z.string().optional().describe("Subtitle or description"), + tag: z.string().optional().describe("Uppercase label above title"), + items: z.array(SlideItemSchema).optional().describe("Content items"), + source: z.string().optional().describe("Footer source citation"), + label: z.string().optional().describe("Footer label"), + backgroundImage: z.string().optional().describe("Background image URL"), + customHtml: z.string().optional().describe("Raw HTML (custom layout)"), + position: z.number().optional().describe("Insert position (0-indexed)"), + }), + outputSchema: z.object({ + slideFile: z.object({ + filename: z + .string() + .describe("Filename for the slide (e.g., '001-title.json')"), + content: z.string().describe("JSON content to write to the slide file"), + }), + updatedManifest: z.string().describe("Updated manifest.json content"), + slideId: z.string().describe("ID of the created slide"), + position: z.number().describe("Position of the slide in the deck"), + }), + execute: async ({ context }) => { + const { manifest: manifestStr, position, ...slideData } = context; + const manifest = JSON.parse(manifestStr); + + // Generate slide ID and filename + const slideIndex = manifest.slides?.length || 0; + const slideNum = String(slideIndex + 1).padStart(3, "0"); + const slideId = `slide-${slideNum}-${Date.now()}`; + const slugTitle = slideData.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .substring(0, 20); + const filename = `${slideNum}-${slugTitle || slideData.layout}.json`; + + // Create slide object (only include defined fields) + const slide: Record = { + id: slideId, + layout: slideData.layout, + title: slideData.title, + }; + + if (slideData.subtitle) slide.subtitle = slideData.subtitle; + if (slideData.tag) slide.tag = slideData.tag; + if (slideData.items?.length) slide.items = slideData.items; + if (slideData.source) slide.source = slideData.source; + if (slideData.label) slide.label = slideData.label; + if (slideData.backgroundImage) + slide.backgroundImage = slideData.backgroundImage; + if (slideData.customHtml) slide.customHtml = slideData.customHtml; + + // Update manifest + const manifestEntry = { + id: slideId, + file: filename, + title: slideData.title, + layout: slideData.layout, + }; + + if (!manifest.slides) { + manifest.slides = []; + } + + const insertPosition = + position !== undefined && + position >= 0 && + position <= manifest.slides.length + ? position + : manifest.slides.length; + + manifest.slides.splice(insertPosition, 0, manifestEntry); + manifest.updatedAt = new Date().toISOString(); + + return { + slideFile: { + filename, + content: JSON.stringify(slide, null, 2), + }, + updatedManifest: JSON.stringify(manifest, null, 2), + slideId, + position: insertPosition, + }; + }, + }); + +/** + * SLIDE_UPDATE - Update an existing slide + */ +export const createSlideUpdateTool = (_env: Env) => + createTool({ + id: "SLIDE_UPDATE", + description: + "Update an existing slide. Returns the updated slide content and manifest.", + inputSchema: z.object({ + manifest: z.string().describe("Current manifest.json content"), + slideId: z.string().describe("ID of the slide to update"), + currentSlideContent: z + .string() + .describe("Current JSON content of the slide file"), + updates: z + .object({ + layout: LayoutSchema.optional(), + title: z.string().optional(), + subtitle: z.string().optional(), + tag: z.string().optional(), + items: z.array(SlideItemSchema).optional(), + source: z.string().optional(), + label: z.string().optional(), + backgroundImage: z.string().optional(), + customHtml: z.string().optional(), + }) + .describe("Fields to update"), + }), + outputSchema: z.object({ + updatedSlideContent: z + .string() + .describe("Updated JSON content for the slide file"), + updatedManifest: z.string().describe("Updated manifest.json content"), + filename: z.string().describe("Filename of the updated slide"), + }), + execute: async ({ context }) => { + const { + manifest: manifestStr, + slideId, + currentSlideContent, + updates, + } = context; + const manifest = JSON.parse(manifestStr); + const currentSlide = JSON.parse(currentSlideContent); + + // Find slide in manifest + const slideIndex = manifest.slides?.findIndex( + (s: any) => s.id === slideId, + ); + if (slideIndex === -1 || slideIndex === undefined) { + throw new Error(`Slide with ID "${slideId}" not found in manifest`); + } + + // Merge updates with current slide + const updatedSlide = { + ...currentSlide, + ...updates, + id: slideId, // Preserve ID + }; + + // Update manifest entry if title or layout changed + if (updates.title || updates.layout) { + manifest.slides[slideIndex] = { + ...manifest.slides[slideIndex], + title: updates.title || manifest.slides[slideIndex].title, + layout: updates.layout || manifest.slides[slideIndex].layout, + }; + } + manifest.updatedAt = new Date().toISOString(); + + return { + updatedSlideContent: JSON.stringify(updatedSlide, null, 2), + updatedManifest: JSON.stringify(manifest, null, 2), + filename: manifest.slides[slideIndex].file, + }; + }, + }); + +/** + * SLIDE_DELETE - Delete a slide + */ +export const createSlideDeleteTool = (_env: Env) => + createTool({ + id: "SLIDE_DELETE", + description: + "Delete a slide from the deck. Returns the updated manifest and the filename to delete.", + inputSchema: z.object({ + manifest: z.string().describe("Current manifest.json content"), + slideId: z.string().describe("ID of the slide to delete"), + }), + outputSchema: z.object({ + updatedManifest: z.string().describe("Updated manifest.json content"), + deletedFilename: z.string().describe("Filename of the slide to delete"), + message: z.string().describe("Success message"), + }), + execute: async ({ context }) => { + const { manifest: manifestStr, slideId } = context; + const manifest = JSON.parse(manifestStr); + + // Find slide in manifest + const slideIndex = manifest.slides?.findIndex( + (s: any) => s.id === slideId, + ); + if (slideIndex === -1 || slideIndex === undefined) { + throw new Error(`Slide with ID "${slideId}" not found in manifest`); + } + + const deletedSlide = manifest.slides[slideIndex]; + manifest.slides.splice(slideIndex, 1); + manifest.updatedAt = new Date().toISOString(); + + return { + updatedManifest: JSON.stringify(manifest, null, 2), + deletedFilename: deletedSlide.file, + message: `Slide "${deletedSlide.title}" deleted successfully.`, + }; + }, + }); + +/** + * SLIDE_GET - Get a slide's data + */ +export const createSlideGetTool = (_env: Env) => + createTool({ + id: "SLIDE_GET", + description: "Parse and return structured data from a slide file.", + inputSchema: z.object({ + slideContent: z.string().describe("JSON content of the slide file"), + }), + outputSchema: SlideDataSchema, + execute: async ({ context }) => { + const slide = JSON.parse(context.slideContent); + return slide; + }, + }); + +/** + * SLIDE_LIST - List all slides in the deck + */ +export const createSlideListTool = (_env: Env) => + createTool({ + id: "SLIDE_LIST", + description: "List all slides in the deck with their metadata.", + inputSchema: z.object({ + manifest: z.string().describe("Content of manifest.json"), + }), + outputSchema: z.object({ + slides: z.array( + z.object({ + id: z.string(), + file: z.string(), + title: z.string(), + layout: z.string(), + position: z.number(), + }), + ), + total: z.number(), + }), + execute: async ({ context }) => { + const manifest = JSON.parse(context.manifest); + const slides = (manifest.slides || []).map((s: any, i: number) => ({ + id: s.id, + file: s.file, + title: s.title, + layout: s.layout, + position: i, + })); + + return { + slides, + total: slides.length, + }; + }, + }); + +/** + * SLIDE_REORDER - Reorder slides in the deck + */ +export const createSlideReorderTool = (_env: Env) => + createTool({ + id: "SLIDE_REORDER", + description: + "Reorder slides in the deck by moving a slide to a new position.", + inputSchema: z.object({ + manifest: z.string().describe("Current manifest.json content"), + slideId: z.string().describe("ID of the slide to move"), + newPosition: z + .number() + .describe("New position for the slide (0-indexed)"), + }), + outputSchema: z.object({ + updatedManifest: z.string().describe("Updated manifest.json content"), + message: z.string().describe("Success message"), + newOrder: z + .array( + z.object({ + id: z.string(), + title: z.string(), + position: z.number(), + }), + ) + .describe("New slide order"), + }), + execute: async ({ context }) => { + const { manifest: manifestStr, slideId, newPosition } = context; + const manifest = JSON.parse(manifestStr); + + if (!manifest.slides || manifest.slides.length === 0) { + throw new Error("No slides in deck"); + } + + // Find current position + const currentIndex = manifest.slides.findIndex( + (s: any) => s.id === slideId, + ); + if (currentIndex === -1) { + throw new Error(`Slide with ID "${slideId}" not found`); + } + + // Validate new position + const validPosition = Math.max( + 0, + Math.min(newPosition, manifest.slides.length - 1), + ); + + // Move slide + const [slide] = manifest.slides.splice(currentIndex, 1); + manifest.slides.splice(validPosition, 0, slide); + manifest.updatedAt = new Date().toISOString(); + + const newOrder = manifest.slides.map((s: any, i: number) => ({ + id: s.id, + title: s.title, + position: i, + })); + + return { + updatedManifest: JSON.stringify(manifest, null, 2), + message: `Slide "${slide.title}" moved to position ${validPosition + 1}.`, + newOrder, + }; + }, + }); + +/** + * SLIDE_DUPLICATE - Duplicate an existing slide + */ +export const createSlideDuplicateTool = (_env: Env) => + createTool({ + id: "SLIDE_DUPLICATE", + description: "Duplicate an existing slide. Creates a copy with a new ID.", + inputSchema: z.object({ + manifest: z.string().describe("Current manifest.json content"), + slideId: z.string().describe("ID of the slide to duplicate"), + slideContent: z + .string() + .describe("JSON content of the slide to duplicate"), + }), + outputSchema: z.object({ + slideFile: z.object({ + filename: z.string().describe("Filename for the new slide"), + content: z.string().describe("JSON content to write"), + }), + updatedManifest: z.string().describe("Updated manifest.json content"), + newSlideId: z.string().describe("ID of the new slide"), + position: z.number().describe("Position of the new slide"), + }), + execute: async ({ context }) => { + const { manifest: manifestStr, slideId, slideContent } = context; + const manifest = JSON.parse(manifestStr); + const originalSlide = JSON.parse(slideContent); + + // Find original slide position + const originalIndex = manifest.slides?.findIndex( + (s: any) => s.id === slideId, + ); + if (originalIndex === -1 || originalIndex === undefined) { + throw new Error(`Slide with ID "${slideId}" not found`); + } + + // Generate new ID and filename + const slideNum = String(manifest.slides.length + 1).padStart(3, "0"); + const newSlideId = `slide-${slideNum}-${Date.now()}`; + const slugTitle = originalSlide.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .substring(0, 20); + const filename = `${slideNum}-${slugTitle || originalSlide.layout}-copy.json`; + + // Create new slide with new ID + const newSlide = { + ...originalSlide, + id: newSlideId, + title: `${originalSlide.title} (Copy)`, + }; + + // Add to manifest after original + const manifestEntry = { + id: newSlideId, + file: filename, + title: newSlide.title, + layout: newSlide.layout, + }; + + const insertPosition = originalIndex + 1; + manifest.slides.splice(insertPosition, 0, manifestEntry); + manifest.updatedAt = new Date().toISOString(); + + return { + slideFile: { + filename, + content: JSON.stringify(newSlide, null, 2), + }, + updatedManifest: JSON.stringify(manifest, null, 2), + newSlideId, + position: insertPosition, + }; + }, + }); + +// Export all slide tools +export const slideTools = [ + createSlideCreateTool, + createSlideUpdateTool, + createSlideDeleteTool, + createSlideGetTool, + createSlideListTool, + createSlideReorderTool, + createSlideDuplicateTool, +]; diff --git a/slides/server/tools/style.ts b/slides/server/tools/style.ts new file mode 100644 index 00000000..55fd1826 --- /dev/null +++ b/slides/server/tools/style.ts @@ -0,0 +1,230 @@ +/** + * Style guide management tools for slide presentations. + * + * These tools handle reading and updating the style.md file that + * defines the visual style, tone, and design system for presentations. + */ +import { createTool } from "@decocms/runtime/tools"; +import { z } from "zod"; +import type { Env } from "../main.ts"; + +/** + * STYLE_GET - Get the current style guide content + */ +export const createStyleGetTool = (_env: Env) => + createTool({ + id: "STYLE_GET", + description: + "Get the current style guide content. The style.md file defines the visual style, tone, and design system for the presentation.", + inputSchema: z.object({ + content: z.string().describe("Current content of the style.md file"), + }), + outputSchema: z.object({ + styleGuide: z.string().describe("The style guide content"), + sections: z + .array( + z.object({ + heading: z.string(), + content: z.string(), + }), + ) + .optional() + .describe("Parsed sections of the style guide"), + }), + execute: async ({ context }) => { + const { content } = context; + + // Parse sections from markdown + const lines = content.split("\n"); + const sections: { heading: string; content: string }[] = []; + let currentHeading = ""; + let currentContent: string[] = []; + + for (const line of lines) { + if (line.startsWith("## ")) { + if (currentHeading) { + sections.push({ + heading: currentHeading, + content: currentContent.join("\n").trim(), + }); + } + currentHeading = line.replace("## ", "").trim(); + currentContent = []; + } else if (currentHeading) { + currentContent.push(line); + } + } + + // Add last section + if (currentHeading) { + sections.push({ + heading: currentHeading, + content: currentContent.join("\n").trim(), + }); + } + + return { + styleGuide: content, + sections, + }; + }, + }); + +/** + * STYLE_SET - Update the style guide content + */ +export const createStyleSetTool = (_env: Env) => + createTool({ + id: "STYLE_SET", + description: + "Update the style guide content. This defines the visual style, tone, and design system for the presentation. Returns the new content to write to style.md.", + inputSchema: z.object({ + content: z + .string() + .describe("New style guide content in markdown format"), + }), + outputSchema: z.object({ + content: z + .string() + .describe("The style guide content to write to style.md"), + message: z.string().describe("Success message"), + }), + execute: async ({ context }) => { + const { content } = context; + + // Validate that it looks like a style guide + if (!content.includes("#") && content.length < 50) { + throw new Error( + "Style guide should be in markdown format with sections (using # or ## headings)", + ); + } + + return { + content, + message: "Style guide updated successfully.", + }; + }, + }); + +/** + * STYLE_SUGGEST - Generate style guide suggestions based on presentation topic + */ +export const createStyleSuggestTool = (_env: Env) => + createTool({ + id: "STYLE_SUGGEST", + description: + "Generate style guide suggestions based on the presentation topic and purpose.", + inputSchema: z.object({ + topic: z.string().describe("The main topic or theme of the presentation"), + purpose: z + .enum(["investor", "sales", "educational", "internal", "conference"]) + .describe("The purpose of the presentation"), + tone: z + .enum(["formal", "casual", "technical", "inspirational"]) + .optional() + .default("formal") + .describe("Desired tone of the presentation"), + }), + outputSchema: z.object({ + suggestedStyle: z.string().describe("Suggested style guide content"), + recommendations: z + .array(z.string()) + .describe("Key recommendations for this type of presentation"), + }), + execute: async ({ context }) => { + const { topic, purpose, tone } = context; + + const toneDescriptions = { + formal: "Professional and polished, suitable for executive audiences", + casual: "Relaxed and approachable, with conversational language", + technical: "Detailed and precise, with technical terminology", + inspirational: "Motivational and engaging, with powerful statements", + }; + + const purposeLayouts = { + investor: ["title", "stats", "timeline", "two-column", "list"], + sales: ["title", "stats", "image", "quote", "list"], + educational: ["title", "content", "two-column", "list", "image"], + internal: ["title", "content", "list", "stats", "two-column"], + conference: ["title", "image", "quote", "stats", "content"], + }; + + const recommendations = { + investor: [ + "Lead with key metrics and growth numbers", + "Use timeline slides to show company journey", + "Include clear asks and next steps", + "Keep slides data-driven but not cluttered", + ], + sales: [ + "Focus on customer value and outcomes", + "Include testimonials or case studies", + "Use stats to build credibility", + "End with clear call to action", + ], + educational: [ + "Break complex topics into digestible chunks", + "Use examples and illustrations", + "Include key takeaways on each section", + "Maintain consistent terminology", + ], + internal: [ + "Be transparent about challenges and solutions", + "Include actionable next steps", + "Credit team contributions", + "Keep it focused and time-efficient", + ], + conference: [ + "Start with a hook that captures attention", + "Use large, readable text for distant audiences", + "Include memorable quotes or statistics", + "End with a strong, shareable takeaway", + ], + }; + + const suggestedStyle = `# ${topic} - Presentation Style Guide + +## Purpose & Audience +${purpose.charAt(0).toUpperCase() + purpose.slice(1)} presentation +${toneDescriptions[tone]} + +## Brand Colors + +- **Primary Accent**: Green (#c4df1b) - Used for emphasis and key points +- **Secondary Accent**: Purple (#d4a5ff) - Used for alternative sections +- **Tertiary Accent**: Yellow (#ffd666) - Used for highlights + +## Typography + +- **Headlines**: Large, bold, with tight letter-spacing +- **Body Text**: Clean, readable, 17-18px +- **Monospace**: For tags and technical content + +## Recommended Slide Layouts + +${purposeLayouts[purpose].map((l, i) => `${i + 1}. **${l}** - Use for ${l === "title" ? "section breaks" : l === "stats" ? "key metrics" : l === "image" ? "visual impact" : l === "quote" ? "testimonials" : l === "list" ? "action items" : "detailed content"}`).join("\n")} + +## Tone & Voice + +- ${toneDescriptions[tone]} +- Clear, concise language +- ${purpose === "investor" ? "Data-driven with strategic context" : purpose === "sales" ? "Value-focused and persuasive" : purpose === "educational" ? "Explanatory and progressive" : purpose === "conference" ? "Engaging and memorable" : "Direct and actionable"} + +## Best Practices + +${recommendations[purpose].map((r) => `- ${r}`).join("\n")} +`; + + return { + suggestedStyle, + recommendations: recommendations[purpose], + }; + }, + }); + +// Export all style tools +export const styleTools = [ + createStyleGetTool, + createStyleSetTool, + createStyleSuggestTool, +]; diff --git a/slides/test-presentation/engine.js b/slides/test-presentation/engine.js new file mode 100644 index 00000000..ddb58333 --- /dev/null +++ b/slides/test-presentation/engine.js @@ -0,0 +1,1154 @@ +/** + * Slides Presentation Engine + * Adapted from deco.cx Investor Updates presentation + * Works with CDN-loaded React and GSAP - no build required + */ + +// Base dimensions (16:9 aspect ratio) +const BASE_WIDTH = 1920; +const BASE_HEIGHT = 1080; + +// Color system +const colors = { + // Background colors + bg: { + "dc-950": "#0f0e0d", + "dc-900": "#1a1918", + "dc-800": "#2a2927", + "primary-light": "#c4df1b", + "purple-light": "#d4a5ff", + "yellow-light": "#ffd666", + }, + // Text colors + text: { + "dc-50": "#faf9f7", + "dc-100": "#f0eeeb", + "dc-200": "#e1ddd8", + "dc-300": "#c9c4bc", + "dc-400": "#a39d94", + "dc-500": "#6d6a66", + "dc-600": "#52504c", + "dc-700": "#3a3936", + "dc-800": "#2a2927", + "dc-900": "#1a1918", + }, + // Accent colors + accent: { + green: "#c4df1b", + purple: "#d4a5ff", + yellow: "#ffd666", + }, +}; + +// CSS class mappings +const bgColorMap = { + "dc-950": "bg-dc-950", + "dc-900": "bg-dc-900", + "dc-800": "bg-dc-800", + "primary-light": "bg-primary-light", + "purple-light": "bg-purple-light", + "yellow-light": "bg-yellow-light", +}; + +const accentTextClass = { + green: "text-accent-green", + purple: "text-accent-purple", + yellow: "text-accent-yellow", +}; + +const accentBgClass = { + green: "bg-accent-green", + purple: "bg-accent-purple", + yellow: "bg-accent-yellow", +}; + +/** + * Main Presentation Component + */ +function Presentation({ slides, title, subtitle, styleGuide }) { + const [currentSlide, setCurrentSlide] = React.useState(0); + const [isAnimating, setIsAnimating] = React.useState(false); + const [scale, setScale] = React.useState(1); + const [isFullscreen, setIsFullscreen] = React.useState(false); + const containerRef = React.useRef(null); + const slideRefs = React.useRef([]); + + const totalSlides = slides.length + 1; // +1 for cover slide + + // Calculate scale based on viewport + React.useEffect(() => { + const updateScale = () => { + const vw = window.innerWidth; + const vh = window.innerHeight; + const scaleX = vw / BASE_WIDTH; + const scaleY = vh / BASE_HEIGHT; + setScale(Math.min(scaleX, scaleY)); + }; + + updateScale(); + window.addEventListener("resize", updateScale); + return () => window.removeEventListener("resize", updateScale); + }, []); + + // Keyboard navigation + React.useEffect(() => { + const handleKeyDown = (e) => { + if (isAnimating) return; + + switch (e.key) { + case "ArrowRight": + case "ArrowDown": + case " ": + e.preventDefault(); + goToNextSlide(); + break; + case "ArrowLeft": + case "ArrowUp": + e.preventDefault(); + goToPrevSlide(); + break; + case "Home": + e.preventDefault(); + goToSlide(0); + break; + case "End": + e.preventDefault(); + goToSlide(totalSlides - 1); + break; + case "f": + case "F": + e.preventDefault(); + toggleFullscreen(); + break; + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isAnimating, currentSlide, totalSlides]); + + // GSAP slide transition + const animateSlideTransition = async (fromIndex, toIndex) => { + if (typeof gsap === "undefined") { + setCurrentSlide(toIndex); + return; + } + + setIsAnimating(true); + + // Animate out current slide + const currentEl = slideRefs.current[fromIndex]; + if (currentEl) { + const items = currentEl.querySelectorAll(".animate-item"); + await gsap.to(items, { + y: -20, + opacity: 0, + duration: 0.2, + stagger: 0.02, + ease: "power2.in", + }); + } + + // Update slide + setCurrentSlide(toIndex); + + // Wait for React to render + await new Promise((r) => setTimeout(r, 50)); + + // Animate in new slide + const newEl = slideRefs.current[toIndex]; + if (newEl) { + const items = newEl.querySelectorAll(".animate-item"); + gsap.set(items, { y: 40, opacity: 0 }); + await gsap.to(items, { + y: 0, + opacity: 1, + duration: 0.4, + stagger: 0.05, + ease: "power2.out", + }); + } + + setIsAnimating(false); + }; + + const goToSlide = (index) => { + if ( + isAnimating || + index === currentSlide || + index < 0 || + index >= totalSlides + ) + return; + animateSlideTransition(currentSlide, index); + }; + + const goToNextSlide = () => { + if (currentSlide < totalSlides - 1) { + goToSlide(currentSlide + 1); + } + }; + + const goToPrevSlide = () => { + if (currentSlide > 0) { + goToSlide(currentSlide - 1); + } + }; + + const toggleFullscreen = () => { + if (!document.fullscreenElement) { + containerRef.current?.requestFullscreen(); + setIsFullscreen(true); + } else { + document.exitFullscreen(); + setIsFullscreen(false); + } + }; + + // Touch gesture handling + const touchStartRef = React.useRef({ x: 0, y: 0 }); + + const handleTouchStart = (e) => { + touchStartRef.current = { + x: e.touches[0].clientX, + y: e.touches[0].clientY, + }; + }; + + const handleTouchEnd = (e) => { + const deltaX = e.changedTouches[0].clientX - touchStartRef.current.x; + const deltaY = e.changedTouches[0].clientY - touchStartRef.current.y; + + if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 50) { + if (deltaX < 0) { + goToNextSlide(); + } else { + goToPrevSlide(); + } + } + }; + + // Render slide content based on layout + const renderSlideContent = (slide, index, isActive) => { + const bgClass = + bgColorMap[slide.backgroundColor || "dc-950"] || "bg-dc-950"; + const textColorClass = + slide.textColor === "dark" ? "text-dc-900" : "text-dc-50"; + const accent = slide.accent || "green"; + + switch (slide.layout) { + case "title": + return renderTitleSlide(slide, bgClass, textColorClass, accent); + case "content": + return renderContentSlide(slide, bgClass, textColorClass, accent); + case "two-column": + return renderTwoColumnSlide(slide, bgClass, textColorClass, accent); + case "stats": + return renderStatsSlide( + slide, + bgClass, + textColorClass, + accent, + isActive, + ); + case "list": + return renderListSlide(slide, bgClass, textColorClass, accent); + case "image": + return renderImageSlide(slide, bgClass, textColorClass, accent); + case "quote": + return renderQuoteSlide(slide, bgClass, textColorClass, accent); + case "custom": + return renderCustomSlide(slide, bgClass, textColorClass); + default: + return renderTitleSlide(slide, bgClass, textColorClass, accent); + } + }; + + // Title slide layout + const renderTitleSlide = (slide, bgClass, textColorClass, accent) => + React.createElement( + "div", + { + className: `w-full h-full flex flex-col ${bgClass} ${textColorClass}`, + style: { padding: "64px 80px" }, + }, + slide.tag && + React.createElement( + "span", + { + className: + "animate-item font-mono uppercase tracking-widest opacity-50", + style: { + fontSize: "11px", + marginBottom: "16px", + letterSpacing: "0.2em", + }, + }, + slide.tag, + ), + React.createElement( + "div", + { className: "flex-1 flex flex-col justify-end" }, + React.createElement( + "h1", + { + className: "animate-item leading-none", + style: { fontSize: "180px", letterSpacing: "-4px" }, + }, + slide.title, + ), + slide.subtitle && + React.createElement( + "p", + { + className: "animate-item opacity-50", + style: { fontSize: "24px", marginTop: "24px" }, + }, + slide.subtitle, + ), + ), + ); + + // Content slide layout + const renderContentSlide = (slide, bgClass, textColorClass, accent) => + React.createElement( + "div", + { + className: `w-full h-full flex flex-col ${bgClass} ${textColorClass}`, + style: { padding: "80px 96px" }, + }, + React.createElement( + "div", + { style: { marginBottom: "72px" } }, + slide.tag && + React.createElement( + "span", + { + className: + "animate-item font-mono uppercase tracking-widest text-dc-500", + style: { + fontSize: "12px", + marginBottom: "16px", + display: "block", + letterSpacing: "0.2em", + }, + }, + slide.tag, + ), + React.createElement( + "h2", + { + className: "animate-item text-dc-200", + style: { + fontSize: "32px", + letterSpacing: "-0.5px", + lineHeight: 1.2, + }, + }, + slide.title, + ), + ), + slide.items && + React.createElement( + "div", + { + className: "flex-1 flex flex-col", + style: { gap: "48px" }, + }, + slide.items.map((item, i) => + React.createElement( + "div", + { key: i, className: "animate-item" }, + item.title && + React.createElement( + "h3", + { + className: "text-dc-300", + style: { fontSize: "18px", marginBottom: "20px" }, + }, + item.title, + ), + item.bullets && + React.createElement( + "ul", + { + style: { + display: "flex", + flexDirection: "column", + gap: "16px", + }, + }, + item.bullets.map((bullet, j) => + React.createElement( + "li", + { + key: j, + className: "flex items-start", + style: { + fontSize: "17px", + gap: "16px", + lineHeight: 1.5, + }, + }, + React.createElement( + "span", + { + className: accentTextClass[accent], + style: { marginTop: "6px", fontSize: "8px" }, + }, + "●", + ), + React.createElement( + "span", + { + className: bullet.highlight + ? accentTextClass[accent] + : "text-dc-300", + }, + bullet.text, + ), + ), + ), + ), + ), + ), + ), + ); + + // Two-column slide layout + const renderTwoColumnSlide = (slide, bgClass, textColorClass, accent) => + React.createElement( + "div", + { + className: `w-full h-full flex flex-col ${bgClass} ${textColorClass}`, + style: { padding: "80px 96px" }, + }, + React.createElement( + "div", + { style: { marginBottom: "72px" } }, + slide.tag && + React.createElement( + "span", + { + className: + "animate-item font-mono uppercase tracking-widest text-dc-500", + style: { + fontSize: "12px", + marginBottom: "16px", + display: "block", + letterSpacing: "0.2em", + }, + }, + slide.tag, + ), + React.createElement( + "h2", + { + className: "animate-item text-dc-200", + style: { + fontSize: "32px", + letterSpacing: "-0.5px", + lineHeight: 1.2, + }, + }, + slide.title, + ), + slide.subtitle && + React.createElement( + "p", + { + className: "animate-item text-dc-400", + style: { fontSize: "17px", marginTop: "16px" }, + }, + slide.subtitle, + ), + ), + slide.items && + React.createElement( + "div", + { + className: "flex-1 grid", + style: { gridTemplateColumns: "1fr 1fr", gap: "80px" }, + }, + slide.items.map((item, i) => + React.createElement( + "div", + { key: i, className: "animate-item" }, + item.title && + React.createElement( + "h3", + { + className: accentTextClass[accent], + style: { fontSize: "18px", marginBottom: "24px" }, + }, + item.title, + ), + item.bullets && + React.createElement( + "ul", + { + style: { + display: "flex", + flexDirection: "column", + gap: "14px", + }, + }, + item.bullets.map((bullet, j) => + React.createElement( + "li", + { + key: j, + className: bullet.highlight + ? accentTextClass[accent] + : "text-dc-400", + style: { fontSize: "15px", lineHeight: 1.5 }, + }, + bullet.text, + ), + ), + ), + ), + ), + ), + ); + + // Stats slide layout with count-up animation + const renderStatsSlide = (slide, bgClass, textColorClass, accent, isActive) => + React.createElement( + "div", + { + className: `w-full h-full flex flex-col ${bgClass} ${textColorClass}`, + style: { padding: "80px 96px" }, + }, + React.createElement( + "div", + { style: { marginBottom: "80px" } }, + slide.tag && + React.createElement( + "span", + { + className: + "animate-item font-mono uppercase tracking-widest text-dc-500", + style: { + fontSize: "12px", + marginBottom: "16px", + display: "block", + letterSpacing: "0.2em", + }, + }, + slide.tag, + ), + React.createElement( + "h2", + { + className: "animate-item text-dc-200", + style: { + fontSize: "32px", + letterSpacing: "-0.5px", + lineHeight: 1.2, + }, + }, + slide.title, + ), + ), + slide.items && + React.createElement( + "div", + { + className: "flex-1 grid items-center", + style: { + gridTemplateColumns: `repeat(${Math.min(slide.items.length, 4)}, 1fr)`, + gap: "64px", + }, + }, + slide.items.map((item, i) => + React.createElement( + "div", + { key: i, className: "animate-item text-center" }, + item.value && + React.createElement(CountUp, { + end: parseInt(item.value.replace(/[^0-9]/g, "")) || 0, + prefix: item.value.match(/^[^0-9]*/)?.[0] || "", + suffix: item.value.match(/[^0-9]*$/)?.[0] || "", + isActive: isActive, + className: `block ${accentTextClass[accent]}`, + style: { + fontSize: "56px", + marginBottom: "16px", + letterSpacing: "-1px", + }, + }), + item.label && + React.createElement( + "span", + { + className: "text-dc-400", + style: { fontSize: "16px" }, + }, + item.label, + ), + ), + ), + ), + ); + + // List slide layout + const renderListSlide = (slide, bgClass, textColorClass, accent) => + React.createElement( + "div", + { + className: `w-full h-full flex flex-col ${bgClass} ${textColorClass}`, + style: { padding: "80px 96px" }, + }, + React.createElement( + "div", + { style: { marginBottom: "72px" } }, + slide.tag && + React.createElement( + "span", + { + className: + "animate-item font-mono uppercase tracking-widest text-dc-500", + style: { + fontSize: "12px", + marginBottom: "16px", + display: "block", + letterSpacing: "0.2em", + }, + }, + slide.tag, + ), + React.createElement( + "h2", + { + className: "animate-item text-dc-200", + style: { + fontSize: "32px", + letterSpacing: "-0.5px", + lineHeight: 1.2, + }, + }, + slide.title, + ), + slide.subtitle && + React.createElement( + "p", + { + className: "animate-item text-dc-400", + style: { fontSize: "17px", marginTop: "16px", maxWidth: "800px" }, + }, + slide.subtitle, + ), + ), + slide.items && + React.createElement( + "div", + { + className: "flex-1 grid", + style: { + gridTemplateColumns: "1fr 1fr", + columnGap: "80px", + rowGap: "40px", + }, + }, + slide.items.map((item, i) => + React.createElement( + "div", + { + key: i, + className: "animate-item flex items-start", + style: { gap: "20px" }, + }, + React.createElement("div", { + className: accentBgClass[accent], + style: { + width: "6px", + height: "6px", + borderRadius: "50%", + marginTop: "8px", + flexShrink: 0, + }, + }), + React.createElement( + "div", + null, + item.title && + React.createElement( + "span", + { + className: "text-dc-100 block", + style: { fontSize: "17px", lineHeight: 1.5 }, + }, + item.title, + ), + item.subtitle && + React.createElement( + "span", + { + className: "text-dc-500 block", + style: { fontSize: "15px", marginTop: "6px" }, + }, + item.subtitle, + ), + ), + ), + ), + ), + ); + + // Image slide layout + const renderImageSlide = (slide, bgClass, textColorClass, accent) => + React.createElement( + "div", + { + className: `w-full h-full relative ${bgClass} ${textColorClass}`, + style: { overflow: "hidden" }, + }, + slide.backgroundImage && + React.createElement("img", { + src: slide.backgroundImage, + alt: "", + style: { + position: "absolute", + inset: 0, + width: "100%", + height: "100%", + objectFit: "cover", + }, + }), + React.createElement("div", { + style: { + position: "absolute", + inset: 0, + background: + "linear-gradient(to top, rgba(15,14,13,0.9) 0%, rgba(15,14,13,0.3) 50%, transparent 100%)", + }, + }), + React.createElement( + "div", + { + className: "absolute bottom-0 left-0 right-0", + style: { padding: "80px 96px" }, + }, + slide.tag && + React.createElement( + "span", + { + className: + "animate-item font-mono uppercase tracking-widest text-dc-400", + style: { + fontSize: "12px", + marginBottom: "16px", + display: "block", + letterSpacing: "0.2em", + }, + }, + slide.tag, + ), + React.createElement( + "h2", + { + className: "animate-item text-dc-50", + style: { + fontSize: "48px", + letterSpacing: "-1px", + lineHeight: 1.2, + maxWidth: "900px", + }, + }, + slide.title, + ), + slide.subtitle && + React.createElement( + "p", + { + className: "animate-item text-dc-300", + style: { fontSize: "20px", marginTop: "24px", maxWidth: "700px" }, + }, + slide.subtitle, + ), + ), + ); + + // Quote slide layout + const renderQuoteSlide = (slide, bgClass, textColorClass, accent) => + React.createElement( + "div", + { + className: `w-full h-full flex flex-col items-center justify-center ${bgClass} ${textColorClass}`, + style: { padding: "96px" }, + }, + React.createElement( + "blockquote", + { + className: "animate-item text-center", + style: { maxWidth: "1200px" }, + }, + React.createElement( + "span", + { + className: accentTextClass[accent], + style: { + fontSize: "120px", + lineHeight: 0.5, + display: "block", + marginBottom: "24px", + }, + }, + '"', + ), + React.createElement( + "p", + { + style: { + fontSize: "42px", + lineHeight: 1.4, + letterSpacing: "-0.5px", + }, + }, + slide.title, + ), + slide.subtitle && + React.createElement( + "cite", + { + className: "text-dc-400 block not-italic", + style: { fontSize: "18px", marginTop: "48px" }, + }, + "— ", + slide.subtitle, + ), + ), + ); + + // Custom HTML slide layout + const renderCustomSlide = (slide, bgClass, textColorClass) => + React.createElement("div", { + className: `w-full h-full ${bgClass} ${textColorClass}`, + dangerouslySetInnerHTML: { __html: slide.customHtml || "" }, + }); + + // Cover slide + const renderCoverSlide = () => + React.createElement( + "div", + { + className: + "w-full h-full flex flex-col justify-between bg-dc-950 text-dc-50", + style: { padding: "90px 82px" }, + }, + React.createElement( + "div", + null, + React.createElement( + "div", + { + className: "animate-item", + style: { fontSize: "32px", fontWeight: 600 }, + }, + "◆", + ), + ), + React.createElement( + "div", + { + className: "flex flex-col", + style: { gap: "22px", maxWidth: "1175px" }, + }, + subtitle && + React.createElement( + "p", + { + className: "animate-item font-mono uppercase text-dc-400", + style: { fontSize: "24px", letterSpacing: "1.2px" }, + }, + subtitle, + ), + React.createElement( + "h1", + { + className: "animate-item text-dc-50 leading-none", + style: { fontSize: "140px", letterSpacing: "-2.8px" }, + }, + title || "Presentation", + ), + ), + ); + + // Calculate display dimensions + const displayWidth = BASE_WIDTH * scale; + const displayHeight = BASE_HEIGHT * scale; + + return React.createElement( + "div", + { + ref: containerRef, + className: "fixed inset-0 w-screen h-screen bg-dc-950 overflow-hidden", + onTouchStart: handleTouchStart, + onTouchEnd: handleTouchEnd, + }, + // Hide animate-item elements initially + React.createElement( + "style", + null, + ` + .animate-item { + opacity: 0; + transform: translateY(40px); + } + `, + ), + + // Centered container for scaled presentation + React.createElement( + "div", + { + className: "absolute inset-0 flex items-center justify-center", + }, + React.createElement( + "div", + { + style: { + width: `${displayWidth}px`, + height: `${displayHeight}px`, + position: "relative", + overflow: "hidden", + }, + }, + // Scaled presentation container + React.createElement( + "div", + { + style: { + width: `${BASE_WIDTH}px`, + height: `${BASE_HEIGHT}px`, + transform: `scale(${scale})`, + transformOrigin: "top left", + position: "absolute", + top: 0, + left: 0, + }, + }, + // Cover slide + React.createElement( + "div", + { + ref: (el) => (slideRefs.current[0] = el), + className: `absolute inset-0 transition-opacity duration-300 ${ + currentSlide === 0 + ? "opacity-100 pointer-events-auto" + : "opacity-0 pointer-events-none" + }`, + }, + renderCoverSlide(), + ), + + // Content slides + slides.map((slide, index) => + React.createElement( + "div", + { + key: index, + ref: (el) => (slideRefs.current[index + 1] = el), + className: `absolute inset-0 transition-opacity duration-300 ${ + currentSlide === index + 1 + ? "opacity-100 pointer-events-auto" + : "opacity-0 pointer-events-none" + }`, + }, + renderSlideContent(slide, index, currentSlide === index + 1), + ), + ), + + // Navigation controls + React.createElement( + "div", + { + className: "absolute flex items-center z-50", + style: { bottom: "40px", right: "48px", gap: "12px" }, + }, + // Slide counter + React.createElement( + "span", + { + className: "text-dc-600 font-mono", + style: { fontSize: "12px", marginRight: "12px" }, + }, + `${String(currentSlide + 1).padStart(2, "0")} / ${String(totalSlides).padStart(2, "0")}`, + ), + + // First button + React.createElement( + "button", + { + type: "button", + onClick: () => goToSlide(0), + disabled: currentSlide === 0 || isAnimating, + className: `rounded-full flex items-center justify-center transition-all border ${ + currentSlide === 0 + ? "border-dc-800 text-dc-700 cursor-not-allowed" + : "border-dc-700 hover:border-dc-600 text-dc-400 hover:text-dc-300 cursor-pointer" + }`, + style: { width: "36px", height: "36px" }, + }, + "⏮", + ), + + // Previous button + React.createElement( + "button", + { + type: "button", + onClick: goToPrevSlide, + disabled: currentSlide === 0 || isAnimating, + className: `rounded-full flex items-center justify-center transition-all border ${ + currentSlide === 0 + ? "border-dc-800 text-dc-700 cursor-not-allowed" + : "border-dc-700 hover:border-dc-600 text-dc-400 hover:text-dc-300 cursor-pointer" + }`, + style: { width: "36px", height: "36px" }, + }, + "←", + ), + + // Next button + React.createElement( + "button", + { + type: "button", + onClick: goToNextSlide, + disabled: currentSlide === totalSlides - 1 || isAnimating, + className: `rounded-full flex items-center justify-center transition-all ${ + currentSlide === totalSlides - 1 + ? "border border-dc-800 text-dc-700 cursor-not-allowed" + : "bg-primary-light/10 border border-primary-light/30 hover:bg-primary-light/20 text-primary-light cursor-pointer" + }`, + style: { width: "36px", height: "36px" }, + }, + "→", + ), + + // Fullscreen button + React.createElement( + "button", + { + type: "button", + onClick: toggleFullscreen, + className: + "rounded-full flex items-center justify-center transition-all border border-dc-700 hover:border-dc-600 text-dc-400 hover:text-dc-300 cursor-pointer", + style: { width: "36px", height: "36px", marginLeft: "8px" }, + title: isFullscreen ? "Exit fullscreen" : "Enter fullscreen", + }, + isFullscreen ? "⛶" : "⛶", + ), + ), + ), + ), + ), + ); +} + +/** + * CountUp Component for stats animation + */ +function CountUp({ + end, + prefix = "", + suffix = "", + duration = 2000, + className = "", + style = {}, + isActive = false, +}) { + const [count, setCount] = React.useState(0); + const hasAnimatedRef = React.useRef(false); + + React.useEffect(() => { + if (isActive && !hasAnimatedRef.current) { + hasAnimatedRef.current = true; + const startTime = Date.now(); + + const animate = () => { + const elapsed = Date.now() - startTime; + const progress = Math.min(elapsed / duration, 1); + // Ease out cubic + const eased = 1 - Math.pow(1 - progress, 3); + setCount(Math.floor(eased * end)); + + if (progress < 1) { + requestAnimationFrame(animate); + } else { + setCount(end); + } + }; + + requestAnimationFrame(animate); + } + }, [isActive, end, duration]); + + // Reset when becoming inactive + React.useEffect(() => { + if (!isActive) { + hasAnimatedRef.current = false; + setCount(0); + } + }, [isActive]); + + return React.createElement( + "span", + { className, style }, + prefix, + count.toLocaleString(), + suffix, + ); +} + +/** + * Initialize presentation from manifest + */ +async function initPresentation(manifestPath = "./slides/manifest.json") { + try { + const response = await fetch(manifestPath); + const manifest = await response.json(); + + // Load all slides + const slides = await Promise.all( + manifest.slides.map(async (slideInfo) => { + try { + const slideResponse = await fetch(`./slides/${slideInfo.file}`); + const slideData = await slideResponse.json(); + return { ...slideInfo, ...slideData }; + } catch (e) { + console.error(`Failed to load slide: ${slideInfo.file}`, e); + return slideInfo; + } + }), + ); + + // Render presentation + const root = ReactDOM.createRoot(document.getElementById("root")); + root.render( + React.createElement(Presentation, { + slides, + title: manifest.title || "Presentation", + subtitle: manifest.subtitle || "", + styleGuide: manifest.styleGuide || "", + }), + ); + } catch (e) { + console.error("Failed to initialize presentation:", e); + document.getElementById("root").innerHTML = ` +
+

Presentation not found

+

+ Create a manifest.json in the slides directory to get started. +

+
+ `; + } +} + +// Export for use in HTML +window.Presentation = Presentation; +window.CountUp = CountUp; +window.initPresentation = initPresentation; diff --git a/slides/test-presentation/index.html b/slides/test-presentation/index.html new file mode 100644 index 00000000..5df3b001 --- /dev/null +++ b/slides/test-presentation/index.html @@ -0,0 +1,22 @@ + + + + + + Test Presentation + + + + + + + + +
Loading presentation
+ + + diff --git a/slides/test-presentation/slides/001-introduction.json b/slides/test-presentation/slides/001-introduction.json new file mode 100644 index 00000000..9e3c11c3 --- /dev/null +++ b/slides/test-presentation/slides/001-introduction.json @@ -0,0 +1,10 @@ +{ + "id": "slide-001", + "layout": "title", + "title": "Introduction", + "subtitle": "Getting started with Slides MCP", + "tag": "OVERVIEW", + "backgroundColor": "dc-950", + "textColor": "light", + "accent": "green" +} diff --git a/slides/test-presentation/slides/002-key-metrics.json b/slides/test-presentation/slides/002-key-metrics.json new file mode 100644 index 00000000..90c41759 --- /dev/null +++ b/slides/test-presentation/slides/002-key-metrics.json @@ -0,0 +1,15 @@ +{ + "id": "slide-002", + "layout": "stats", + "title": "Key Metrics", + "tag": "PERFORMANCE", + "backgroundColor": "dc-950", + "textColor": "light", + "accent": "green", + "items": [ + { "value": "2.5M", "label": "Users" }, + { "value": "99.9%", "label": "Uptime" }, + { "value": "150ms", "label": "Response Time" }, + { "value": "$1.2M", "label": "Revenue" } + ] +} diff --git a/slides/test-presentation/slides/003-features.json b/slides/test-presentation/slides/003-features.json new file mode 100644 index 00000000..f1ca3d0f --- /dev/null +++ b/slides/test-presentation/slides/003-features.json @@ -0,0 +1,18 @@ +{ + "id": "slide-003", + "layout": "list", + "title": "Key Features", + "subtitle": "Everything you need to create beautiful presentations", + "tag": "FEATURES", + "backgroundColor": "dc-950", + "textColor": "light", + "accent": "green", + "items": [ + { "title": "Multiple Layouts", "subtitle": "Title, content, stats, two-column, list, quote, image, and custom" }, + { "title": "GSAP Animations", "subtitle": "Smooth, professional transitions powered by GSAP" }, + { "title": "No Build Required", "subtitle": "Uses CDN-loaded React and GSAP - just serve and present" }, + { "title": "Portable Output", "subtitle": "Bundle to a single HTML file that works anywhere" }, + { "title": "Style Guide", "subtitle": "Define your design system in a simple markdown file" }, + { "title": "AI-Powered", "subtitle": "Create presentations through natural conversation" } + ] +} diff --git a/slides/test-presentation/slides/004-comparison.json b/slides/test-presentation/slides/004-comparison.json new file mode 100644 index 00000000..129619e5 --- /dev/null +++ b/slides/test-presentation/slides/004-comparison.json @@ -0,0 +1,30 @@ +{ + "id": "slide-004", + "layout": "two-column", + "title": "Before vs After", + "subtitle": "The transformation with Slides MCP", + "tag": "COMPARISON", + "backgroundColor": "dc-950", + "textColor": "light", + "accent": "purple", + "items": [ + { + "title": "Traditional Approach", + "bullets": [ + { "text": "Manual slide creation in PowerPoint" }, + { "text": "Time-consuming formatting" }, + { "text": "Inconsistent styling across slides" }, + { "text": "Difficult to version control" } + ] + }, + { + "title": "With Slides MCP", + "bullets": [ + { "text": "AI-assisted content creation", "highlight": true }, + { "text": "Automatic beautiful formatting", "highlight": true }, + { "text": "Consistent design system", "highlight": true }, + { "text": "Git-friendly JSON files", "highlight": true } + ] + } + ] +} diff --git a/slides/test-presentation/slides/005-quote.json b/slides/test-presentation/slides/005-quote.json new file mode 100644 index 00000000..4955b112 --- /dev/null +++ b/slides/test-presentation/slides/005-quote.json @@ -0,0 +1,9 @@ +{ + "id": "slide-005", + "layout": "quote", + "title": "This is the future of presentation creation. I can't imagine going back to the old way.", + "subtitle": "Happy User, CEO of Example Corp", + "backgroundColor": "dc-950", + "textColor": "light", + "accent": "yellow" +} diff --git a/slides/test-presentation/slides/manifest.json b/slides/test-presentation/slides/manifest.json new file mode 100644 index 00000000..c6ab0e47 --- /dev/null +++ b/slides/test-presentation/slides/manifest.json @@ -0,0 +1,38 @@ +{ + "title": "Test Presentation", + "subtitle": "January 2026", + "createdAt": "2026-01-22T00:00:00.000Z", + "updatedAt": "2026-01-22T00:00:00.000Z", + "slides": [ + { + "id": "slide-001", + "file": "001-introduction.json", + "title": "Introduction", + "layout": "title" + }, + { + "id": "slide-002", + "file": "002-key-metrics.json", + "title": "Key Metrics", + "layout": "stats" + }, + { + "id": "slide-003", + "file": "003-features.json", + "title": "Key Features", + "layout": "list" + }, + { + "id": "slide-004", + "file": "004-comparison.json", + "title": "Comparison", + "layout": "two-column" + }, + { + "id": "slide-005", + "file": "005-quote.json", + "title": "Customer Quote", + "layout": "quote" + } + ] +} diff --git a/slides/test-presentation/styles.css b/slides/test-presentation/styles.css new file mode 100644 index 00000000..e233157c --- /dev/null +++ b/slides/test-presentation/styles.css @@ -0,0 +1,217 @@ +/** + * Slides Presentation Styles + * Color system and utilities adapted from deco.cx design system + */ + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + width: 100%; + height: 100%; + overflow: hidden; + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: #0f0e0d; + color: #faf9f7; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +#root { + width: 100%; + height: 100%; +} + +/* Background colors */ +.bg-dc-950 { background-color: #0f0e0d; } +.bg-dc-900 { background-color: #1a1918; } +.bg-dc-800 { background-color: #2a2927; } +.bg-primary-light { background-color: #c4df1b; } +.bg-purple-light { background-color: #d4a5ff; } +.bg-yellow-light { background-color: #ffd666; } + +/* Accent background colors */ +.bg-accent-green { background-color: #c4df1b; } +.bg-accent-purple { background-color: #d4a5ff; } +.bg-accent-yellow { background-color: #ffd666; } + +/* Text colors */ +.text-dc-50 { color: #faf9f7; } +.text-dc-100 { color: #f0eeeb; } +.text-dc-200 { color: #e1ddd8; } +.text-dc-300 { color: #c9c4bc; } +.text-dc-400 { color: #a39d94; } +.text-dc-500 { color: #6d6a66; } +.text-dc-600 { color: #52504c; } +.text-dc-700 { color: #3a3936; } +.text-dc-800 { color: #2a2927; } +.text-dc-900 { color: #1a1918; } + +/* Accent text colors */ +.text-accent-green { color: #c4df1b; } +.text-accent-purple { color: #d4a5ff; } +.text-accent-yellow { color: #ffd666; } +.text-primary-light { color: #c4df1b; } + +/* Border colors */ +.border-dc-600 { border-color: #52504c; } +.border-dc-700 { border-color: #3a3936; } +.border-dc-800 { border-color: #2a2927; } +.border-primary-light { border-color: #c4df1b; } +.border-primary-light\/30 { border-color: rgba(196, 223, 27, 0.3); } + +/* Layout utilities */ +.fixed { position: fixed; } +.absolute { position: absolute; } +.relative { position: relative; } +.inset-0 { top: 0; right: 0; bottom: 0; left: 0; } + +.flex { display: flex; } +.grid { display: grid; } +.block { display: block; } +.inline-block { display: inline-block; } + +.flex-col { flex-direction: column; } +.flex-1 { flex: 1 1 0%; } +.flex-shrink-0 { flex-shrink: 0; } + +.items-start { align-items: flex-start; } +.items-center { align-items: center; } +.items-end { align-items: flex-end; } +.justify-center { justify-content: center; } +.justify-between { justify-content: space-between; } +.justify-end { justify-content: flex-end; } + +.text-center { text-align: center; } +.text-left { text-align: left; } + +/* Sizing */ +.w-full { width: 100%; } +.h-full { height: 100%; } +.w-screen { width: 100vw; } +.h-screen { height: 100vh; } + +/* Overflow */ +.overflow-hidden { overflow: hidden; } + +/* Cursor */ +.cursor-pointer { cursor: pointer; } +.cursor-not-allowed { cursor: not-allowed; } + +/* Pointer events */ +.pointer-events-auto { pointer-events: auto; } +.pointer-events-none { pointer-events: none; } + +/* Border radius */ +.rounded-full { border-radius: 9999px; } +.rounded-lg { border-radius: 8px; } +.rounded-xl { border-radius: 12px; } + +/* Border */ +.border { border-width: 1px; border-style: solid; } + +/* Transitions */ +.transition-all { transition: all 150ms ease; } +.transition-opacity { transition: opacity 150ms ease; } +.transition-colors { transition: color 150ms ease, background-color 150ms ease, border-color 150ms ease; } +.duration-300 { transition-duration: 300ms; } +.duration-200 { transition-duration: 200ms; } + +/* Opacity */ +.opacity-0 { opacity: 0; } +.opacity-50 { opacity: 0.5; } +.opacity-100 { opacity: 1; } + +/* Z-index */ +.z-50 { z-index: 50; } + +/* Font utilities */ +.font-mono { font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace; } +.uppercase { text-transform: uppercase; } +.tracking-widest { letter-spacing: 0.1em; } +.leading-none { line-height: 1; } +.leading-tight { line-height: 1.25; } +.not-italic { font-style: normal; } + +/* Hover states */ +.hover\:border-dc-600:hover { border-color: #52504c; } +.hover\:text-dc-300:hover { color: #c9c4bc; } +.hover\:bg-primary-light\/20:hover { background-color: rgba(196, 223, 27, 0.2); } +.hover\:opacity-80:hover { opacity: 0.8; } + +/* Alpha backgrounds */ +.bg-primary-light\/10 { background-color: rgba(196, 223, 27, 0.1); } +.bg-primary-light\/20 { background-color: rgba(196, 223, 27, 0.2); } + +/* Button reset */ +button { + background: none; + border: none; + cursor: pointer; + font: inherit; + color: inherit; +} + +button:disabled { + cursor: not-allowed; +} + +/* Blockquote reset */ +blockquote { + margin: 0; + padding: 0; +} + +/* List reset */ +ul, li { + list-style: none; + margin: 0; + padding: 0; +} + +/* Image reset */ +img { + max-width: 100%; + display: block; +} + +/* Selection styling */ +::selection { + background: rgba(196, 223, 27, 0.3); + color: #faf9f7; +} + +/* Scrollbar styling */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: #1a1918; +} + +::-webkit-scrollbar-thumb { + background: #3a3936; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #52504c; +} + +/* Fullscreen styling */ +:fullscreen { + background: #0f0e0d; +} + +:-webkit-full-screen { + background: #0f0e0d; +} + +:-moz-full-screen { + background: #0f0e0d; +} diff --git a/slides/tsconfig.json b/slides/tsconfig.json new file mode 100644 index 00000000..66bcf5ae --- /dev/null +++ b/slides/tsconfig.json @@ -0,0 +1,35 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "verbatimModuleSyntax": false, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "allowJs": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + + /* Path Aliases */ + "baseUrl": ".", + "paths": { + "server/*": ["./server/*"] + } + }, + "include": [ + "server" + ] +} From 1ba0c689850ed1f171f46550a2d15fad2c6ff78d Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 24 Jan 2026 17:55:30 -0300 Subject: [PATCH 02/20] feat(slides): Add brand research, MCP Apps UI, and documentation - Add optional Perplexity and Firecrawl bindings for automatic brand research - Implement MCP Apps resources for interactive slide previews: - ui://slides-viewer - Full presentation viewer with navigation - ui://design-system - Brand design system preview - ui://slide - Single slide preview - Add BRAND_RESEARCH and BRAND_RESEARCH_STATUS tools - Add SLIDES_PREVIEW tool for multi-slide preview - Support image-based logos (primary, light, dark variants) - Add comprehensive README with workflow documentation - Fix runtime version to 1.2.0 (1.2.2 has SSE response bug) --- bun.lock | 6 +- slides/README.md | 188 +++++++++ slides/package.json | 4 +- slides/server/main.ts | 59 +-- slides/server/prompts.ts | 158 +++++++- slides/server/resources/index.ts | 538 +++++++++++++++++++++++++ slides/server/tools/brand-research.ts | 554 ++++++++++++++++++++++++++ slides/server/tools/deck.ts | 451 +++++++++++++++++++-- slides/server/tools/index.ts | 23 +- slides/server/tools/slides.ts | 59 ++- slides/server/tools/style.ts | 2 +- slides/server/types/env.ts | 53 +++ 12 files changed, 1977 insertions(+), 118 deletions(-) create mode 100644 slides/README.md create mode 100644 slides/server/resources/index.ts create mode 100644 slides/server/tools/brand-research.ts create mode 100644 slides/server/types/env.ts diff --git a/bun.lock b/bun.lock index 54f64509..3d82c157 100644 --- a/bun.lock +++ b/bun.lock @@ -611,8 +611,8 @@ "name": "@decocms/slides", "version": "1.0.0", "dependencies": { - "@decocms/bindings": "^1.0.7", - "@decocms/runtime": "^1.1.3", + "@decocms/bindings": "^1.0.9", + "@decocms/runtime": "1.2.0", "zod": "^4.0.0", }, "devDependencies": { @@ -2877,6 +2877,8 @@ "@decocms/runtime/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.2", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww=="], + "@decocms/slides/@decocms/runtime": ["@decocms/runtime@1.2.0", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.0.7", "@modelcontextprotocol/sdk": "1.25.1", "hono": "^4.10.7", "jose": "^6.0.11", "zod": "^4.0.0" } }, "sha512-HkhKBrCGYPlKyYW+iZxl7hmSwvzeJbBkFYE1+/0y5Ok1rlf/pxHHBHfiFlVgs1pr/seyFjEsqi2LnK8bMnDOEg=="], + "@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], "@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], diff --git a/slides/README.md b/slides/README.md new file mode 100644 index 00000000..4a9928d5 --- /dev/null +++ b/slides/README.md @@ -0,0 +1,188 @@ +# Slides MCP + +AI-powered presentation builder that creates beautiful, animated slide decks through natural conversation. + +## Features + +- **Brand-Aware Design Systems** - Create reusable design systems with your brand colors, typography, and logos +- **Multiple Slide Layouts** - Title, content, stats, two-column, list, quote, image, and custom layouts +- **Automatic Brand Research** - Optionally integrate Perplexity and Firecrawl to automatically discover brand assets +- **MCP Apps UI** - Interactive slide viewer and design system preview via MCP Apps resources +- **JSX + Babel** - Modern component-based slides with browser-side transpilation + +## Quick Start + +```bash +# Start the server +bun run dev + +# The MCP will be available at: +# http://localhost:8001/mcp +``` + +## Tools + +### Deck Management +| Tool | Description | +|------|-------------| +| `DECK_INIT` | Initialize a new presentation deck with brand assets | +| `DECK_INFO` | Get information about an existing deck | +| `DECK_BUNDLE` | Bundle a deck for sharing/export | +| `DECK_GET_ENGINE` | Get the presentation engine JSX | +| `DECK_GET_DESIGN_SYSTEM` | Get the design system JSX for a brand | + +### Slide Operations +| Tool | Description | +|------|-------------| +| `SLIDE_CREATE` | Create a new slide with layout and content | +| `SLIDE_UPDATE` | Update an existing slide | +| `SLIDE_DELETE` | Delete a slide | +| `SLIDE_GET` | Get a single slide by ID | +| `SLIDE_LIST` | List all slides in a deck | +| `SLIDE_REORDER` | Reorder slides in a deck | +| `SLIDE_DUPLICATE` | Duplicate an existing slide | +| `SLIDES_PREVIEW` | Preview multiple slides in the viewer | + +### Style Management +| Tool | Description | +|------|-------------| +| `STYLE_GET` | Get the style guide for a brand | +| `STYLE_SET` | Update the style guide | +| `STYLE_SUGGEST` | Get AI suggestions for style improvements | + +### Brand Research (Optional) +| Tool | Description | +|------|-------------| +| `BRAND_RESEARCH` | Automatically discover brand assets from websites | +| `BRAND_RESEARCH_STATUS` | Check which research bindings are available | +| `BRAND_ASSETS_VALIDATE` | Validate and suggest missing brand assets | + +## Prompts + +| Prompt | Description | +|--------|-------------| +| `SLIDES_SETUP_BRAND` | Guide for creating a new brand design system | +| `SLIDES_NEW_DECK` | Create a new presentation deck | +| `SLIDES_ADD_CONTENT` | Add content slides to an existing deck | +| `SLIDES_QUICK_START` | Fast path for simple presentations | +| `SLIDES_LIST` | List available brands and decks | + +## MCP Apps (UI Resources) + +The MCP exposes interactive UI resources for displaying presentations: + +| Resource URI | Description | +|--------------|-------------| +| `ui://slides-viewer` | Full presentation viewer with navigation | +| `ui://design-system` | Brand design system preview | +| `ui://slide` | Single slide preview | + +These resources receive data via the `ui/initialize` message and render interactive HTML/JS applications. + +## Slide Layouts + +### Title Slide +Large background shape with brand accent, bold uppercase title, and logo. + +### Content Slide +Main content with title, sections, bullet points, and footer. + +### Stats Slide +Grid of 3-4 large numbers with labels (e.g., "2,847 Users", "89% Growth"). + +### Two-Column Slide +Side-by-side comparison with column titles and bullets. + +### List Slide +2x2 grid of items with title and description. + +### Quote Slide +Centered quote with attribution. + +### Image Slide +Full background image with overlay text. + +### Custom Slide +Raw HTML content for complete flexibility. + +## Optional Bindings + +Configure these bindings for automatic brand research: + +### Perplexity (`@deco/perplexity`) +- Search for brand logo URLs +- Research brand colors and guidelines +- Find brand taglines and descriptions +- Discover press kits and media pages + +### Firecrawl (`@deco/firecrawl`) +- Extract brand colors from website CSS +- Identify typography and fonts +- Find logo images in page source +- Capture full brand identity from live websites + +When configured, use `BRAND_RESEARCH` before `DECK_INIT` to automatically populate brand assets. + +## File Structure + +``` +~/slides/ +├── brands/ # Reusable design systems +│ └── {brand-name}/ +│ ├── design-system.jsx # Brand components (JSX) +│ ├── styles.css # Brand styles +│ ├── style.md # AI style guide +│ ├── brand-assets.json # Logo URLs, colors +│ └── design.html # Design system viewer +└── decks/ # Presentations + └── {deck-name}/ + ├── index.html # Entry point + ├── engine.jsx # Presentation engine + ├── design-system.jsx # (copied from brand) + ├── styles.css # (copied from brand) + └── slides/ + ├── manifest.json # Slide order and metadata + └── *.json # Individual slides +``` + +## Workflow + +### Phase 1: Brand Setup (one-time) +1. Use `SLIDES_SETUP_BRAND` prompt +2. Optionally run `BRAND_RESEARCH` to auto-discover assets +3. Create design system with `DECK_INIT` +4. Preview and iterate on brand styling + +### Phase 2: Create Presentations +1. Use `SLIDES_NEW_DECK` prompt with existing brand +2. Add slides with `SLIDE_CREATE` +3. Preview with `SLIDES_PREVIEW` +4. Bundle for sharing with `DECK_BUNDLE` + +## Development + +```bash +# Install dependencies +bun install + +# Run development server with hot reload +bun run dev + +# Type check +bun run check + +# Build for production +bun run build +``` + +## Configuration + +The MCP uses the following environment variables: + +| Variable | Default | Description | +|----------|---------|-------------| +| `PORT` | `8001` | Server port | + +## License + +MIT diff --git a/slides/package.json b/slides/package.json index c3a51399..e14e2fd0 100644 --- a/slides/package.json +++ b/slides/package.json @@ -15,8 +15,8 @@ "./tools": "./server/tools/index.ts" }, "dependencies": { - "@decocms/bindings": "^1.0.7", - "@decocms/runtime": "^1.1.3", + "@decocms/bindings": "^1.0.9", + "@decocms/runtime": "1.2.0", "zod": "^4.0.0" }, "devDependencies": { diff --git a/slides/server/main.ts b/slides/server/main.ts index 7314482f..3676f990 100644 --- a/slides/server/main.ts +++ b/slides/server/main.ts @@ -1,64 +1,23 @@ /** * Slides MCP - AI-Powered Presentation Builder - * - * This MCP provides tools and prompts for creating beautiful slide presentations - * through natural conversation with an AI agent. - * - * ## Workflow - * - * ### Phase 1: Brand Setup (one-time) - * Use SLIDES_SETUP_BRAND prompt to: - * 1. Research brand identity - * 2. Create design system (design-system.jsx, styles.css) - * 3. Preview at /design.html - * 4. Iterate until approved - * - * ### Phase 2: Create Presentations - * Use SLIDES_NEW_DECK prompt to: - * 1. Copy design system from template - * 2. Create slides with content - * 3. Preview and iterate - * 4. Bundle for sharing - * - * ## Quick Start - * Use SLIDES_QUICK_START for fast, simple presentations without brand setup. */ -import type { Registry } from "@decocms/mcps-shared/registry"; -import { type DefaultEnv, withRuntime } from "@decocms/runtime"; -import { z } from "zod"; +import { serve } from "@decocms/mcps-shared/serve"; +import { withRuntime } from "@decocms/runtime"; import { tools } from "./tools/index.ts"; import { prompts } from "./prompts.ts"; -import { $ } from "bun"; +import { resources } from "./resources/index.ts"; +import { StateSchema, type Env, type Registry } from "./types/env.ts"; -const PORT = process.env.PORT || 8007; -const MCP_URL = `http://localhost:${PORT}/mcp`; +export { StateSchema }; -const StateSchema = z.object({}); - -/** - * Environment type for the Slides MCP. - */ -export type Env = DefaultEnv; - -const runtime = withRuntime({ +const runtime = withRuntime({ configuration: { - scopes: [], + scopes: ["PERPLEXITY::*", "FIRECRAWL::*"], state: StateSchema, }, tools, prompts, + resources, }); -// Start server -Bun.serve({ - idleTimeout: 0, - port: PORT, - hostname: "0.0.0.0", - fetch: runtime.fetch, - development: process.env.NODE_ENV !== "production", -}); - -// Log and copy to clipboard -console.log(`\n🎯 Slides MCP running at: ${MCP_URL}\n`); -await $`echo ${MCP_URL} | pbcopy`.quiet(); -console.log(`📋 MCP URL copied to clipboard!\n`); +serve(runtime.fetch); diff --git a/slides/server/prompts.ts b/slides/server/prompts.ts index f4e3f421..ac118abd 100644 --- a/slides/server/prompts.ts +++ b/slides/server/prompts.ts @@ -42,7 +42,7 @@ const DEFAULT_WORKSPACE = "~/slides"; import { createPrompt, type GetPromptResult } from "@decocms/runtime"; import { z } from "zod"; -import type { Env } from "./main.ts"; +import type { Env } from "./types/env.ts"; /** * SLIDES_SETUP_BRAND - Research and create a brand design system @@ -56,9 +56,10 @@ export const createSetupBrandPrompt = (_env: Env) => The agent should: 1. Research the brand (website, existing materials, style guides) 2. Extract colors, typography, logo treatment, and visual style -3. Create a customized design system -4. Generate sample slides showing all layouts -5. Show the design system viewer (/design.html) for user approval +3. **COLLECT BRAND ASSETS** (logo images are essential!) +4. Create a customized design system +5. Generate sample slides showing all layouts +6. Show the design system viewer (/design.html) for user approval Files are saved to: {workspace}/brands/{brand-slug}/ Once approved, the design system can be reused for all future presentations.`, @@ -70,6 +71,21 @@ Once approved, the design system can be reused for all future presentations.`, .string() .optional() .describe("Brand website URL for research (e.g., 'https://acme.com')"), + logoUrl: z + .string() + .optional() + .describe( + "Primary logo image URL (horizontal format, PNG/SVG preferred)", + ), + logoLightUrl: z + .string() + .optional() + .describe("Light version of logo for dark backgrounds"), + logoDarkUrl: z + .string() + .optional() + .describe("Dark version of logo for light backgrounds"), + iconUrl: z.string().optional().describe("Square icon/favicon URL"), styleNotes: z .string() .optional() @@ -80,14 +96,24 @@ Once approved, the design system can be reused for all future presentations.`, .describe("Workspace root (default: ~/slides)"), }, execute: ({ args }): GetPromptResult => { - const { brandName, brandWebsite, styleNotes } = args; + const { + brandName, + brandWebsite, + styleNotes, + logoUrl, + logoLightUrl, + logoDarkUrl, + iconUrl, + } = args; const workspace = args.workspace || DEFAULT_WORKSPACE; - const brandSlug = brandName + const brandSlug = (brandName || "brand") .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/-+$/, ""); const brandPath = `${workspace}/brands/${brandSlug}`; + const hasProvidedAssets = Boolean(logoUrl); + return { description: `Create a design system for ${brandName}`, messages: [ @@ -104,18 +130,82 @@ ${brandPath}/ ├── design-system.jsx # Brand components (logo, slides) ├── styles.css # Brand colors, typography ├── style.md # AI style guide +├── brand-assets.json # Asset configuration └── design.html # Component viewer \`\`\` ## Your Task Create a complete presentation design system for **${brandName}**. +## CRITICAL: Brand Assets Required + +Professional presentations need proper brand assets. ${ + hasProvidedAssets + ? ` +✅ Logo provided: ${logoUrl}${logoLightUrl ? `\n✅ Light logo: ${logoLightUrl}` : ""}${logoDarkUrl ? `\n✅ Dark logo: ${logoDarkUrl}` : ""}${iconUrl ? `\n✅ Icon: ${iconUrl}` : ""}` + : ` +⚠️ **No logo images provided!** + +### STEP 1: Try Automatic Brand Research (Recommended) + +First, check if research bindings are available: +\`\`\` +Call BRAND_RESEARCH_STATUS to check available bindings +\`\`\` + +If PERPLEXITY or FIRECRAWL bindings are configured, use automatic research: +\`\`\` +Call BRAND_RESEARCH with: +- brandName: "${brandName || "brand"}" +- websiteUrl: "${brandWebsite || "(brand website URL)"}" +\`\`\` + +This will automatically discover: +- Logo image URLs +- Brand colors (hex values) +- Typography/fonts +- Brand tagline and description + +### STEP 2: Manual Collection (if no bindings or research incomplete) + +If automatic research is unavailable or incomplete, collect manually: + +1. **Primary Logo** (REQUIRED for professional brands): + - Horizontal/wide format preferred + - PNG with transparent background or SVG + - URL or file path + +2. **Light Logo** (optional but recommended): + - White or light-colored version + - For use on dark backgrounds (title slides) + +3. **Dark Logo** (optional): + - Black or dark-colored version + - For use on light backgrounds (content slides) + +4. **Icon** (optional): + - Square format (1:1 ratio) + - For favicon and small spaces + +### How to Get Assets Manually +- Ask the user directly: "Please provide your logo image URL or file" +- If they have a website, look for logos in: + - \`/logo.png\`, \`/logo.svg\` + - \`/images/logo-*\` + - The \`\` tag + - The favicon (\`/favicon.ico\`, \`/favicon.png\`) +- Extract from their brand guidelines PDF if provided + +**DO NOT proceed without at least a primary logo URL!**` + } + ## Research Phase ${ brandWebsite ? `1. Visit ${brandWebsite} to understand the brand identity -2. Extract: primary colors, secondary colors, typography, logo usage, visual style` - : "1. Ask the user for brand colors, fonts, and style preferences" +2. **Find logo images** (check /logo.png, favicon, og:image) +3. Extract: primary colors, secondary colors, typography, visual style` + : "1. Ask the user for brand colors, fonts, and style preferences\n2. **Request logo image URLs** (this is essential!)" } ${styleNotes ? `\nUser notes: ${styleNotes}` : ""} @@ -132,13 +222,20 @@ Call \`DECK_INIT\` with: - brandName: "${brandName}" - brandTagline: (extract from research or ask user) - brandColor: (primary brand color from research) - -### Step 3: Save ONLY brand files +- assets: { + logoUrl: "${logoUrl || "(URL from research or user)"}", + logoLightUrl: "${logoLightUrl || "(optional - for dark backgrounds)"}", + logoDarkUrl: "${logoDarkUrl || "(optional - for light backgrounds)"}", + iconUrl: "${iconUrl || "(optional - for favicon)"}" + } + +### Step 3: Save brand files From DECK_INIT output, write these to ${brandPath}/: - design-system.jsx - styles.css - style.md - design.html +- brand-assets.json (Do NOT save index.html, engine.jsx, or slides/ - those go in decks) @@ -213,7 +310,7 @@ The agent will: const workspace = args.workspace || DEFAULT_WORKSPACE; const deckSlug = args.deckName || - title + (title || "presentation") .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/-+$/, ""); @@ -385,10 +482,15 @@ Use when: - Simple one-off presentations - Demos and prototypes -Uses a generic brand and creates deck directly.`, +Uses a generic brand and creates deck directly. For professional presentations +with custom branding, use SLIDES_SETUP_BRAND instead.`, argsSchema: { title: z.string().describe("Presentation title"), topic: z.string().describe("What the presentation is about"), + logoUrl: z + .string() + .optional() + .describe("Optional: Logo image URL for professional look"), slideCount: z .string() .optional() @@ -399,10 +501,10 @@ Uses a generic brand and creates deck directly.`, .describe("Workspace root (default: ~/slides)"), }, execute: ({ args }): GetPromptResult => { - const { title, topic } = args; + const { title, topic, logoUrl } = args; const workspace = args.workspace || DEFAULT_WORKSPACE; const count = args.slideCount || "5-7"; - const deckSlug = title + const deckSlug = (title || "presentation") .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/-+$/, ""); @@ -431,9 +533,20 @@ mkdir -p ${deckPath}/slides Call \`DECK_INIT\` with: - title: "${title}" - brandName: "Presenter" -- brandColor: "#3B82F6" +- brandColor: "#3B82F6"${ + logoUrl + ? ` +- assets: { logoUrl: "${logoUrl}" }` + : "" + } Write ALL files to ${deckPath}/ +${ + !logoUrl + ? ` +💡 **Tip**: For a more professional look, provide a logoUrl with your logo image.` + : "" +} ### Step 3: Create slides Generate ${count} slides covering ${topic}: @@ -528,12 +641,13 @@ If both exist: "Ready to create presentations!"`, }); /** - * Export all prompt factories + * All prompt factory functions. + * Each factory takes env and returns a prompt definition. */ -export const prompts = (env: Env) => [ - createSetupBrandPrompt(env), - createNewDeckPrompt(env), - createAddContentPrompt(env), - createQuickStartPrompt(env), - createListPrompt(env), +export const prompts = [ + createSetupBrandPrompt, + createNewDeckPrompt, + createAddContentPrompt, + createQuickStartPrompt, + createListPrompt, ]; diff --git a/slides/server/resources/index.ts b/slides/server/resources/index.ts new file mode 100644 index 00000000..f85747c2 --- /dev/null +++ b/slides/server/resources/index.ts @@ -0,0 +1,538 @@ +/** + * MCP Apps Resources for Slides MCP + * + * Implements SEP-1865 MCP Apps for displaying slide presentations as UIs. + * + * Resources: + * - ui://slides-viewer - Full presentation viewer with navigation + * - ui://design-system - Brand design system preview + * - ui://slide - Single slide preview + */ +import { createPublicResource } from "@decocms/runtime"; +import type { Env } from "../types/env.ts"; + +/** + * Slide Viewer App - Full presentation viewer with navigation + * + * Receives via ui/initialize: + * - toolInput.slides: Array of slide objects + * - toolInput.title: Presentation title + * - toolInput.brand: Brand configuration + */ +const SLIDES_VIEWER_HTML = ` + + + + + Slides Viewer + + + +
+
+ Presentation + 0 / 0 +
+
+
+
+
+

No slides

+

Waiting for presentation data...

+
+
+
+ +
+
+
+ + +`; + +/** + * Design System Viewer App - Preview brand design system + * + * Receives via ui/initialize: + * - toolInput.brandName: Brand name + * - toolInput.brandColor: Primary brand color + * - toolInput.assets: Logo URLs + */ +const DESIGN_SYSTEM_HTML = ` + + + + + Design System + + + +
+

Design System

+

Brand components for presentations

+
+ +
+

Colors

+
+
+
+ Primary + #8B5CF6 +
+
+
+ +
+

Logo

+
+
+
+ Brand +
+
+
Light Background
+
For content slides
+
+
+
+
+ Brand +
+
+
Dark Background
+
For title slides
+
+
+
+ +
+ +
+

Sample Slides

+
+
+
+
Title Slide
+
+
+
Title Slide
+
Opening slide with brand shapes
+
+
+
+
+
Content Slide
+
+
+
Content Slide
+
Main content with bullets
+
+
+
+
+ + + +`; + +/** + * Single Slide Preview App - Shows one slide + */ +const SINGLE_SLIDE_HTML = ` + + + + + Slide Preview + + + +
+
+
+

Waiting for slide data...

+
+
+
+ + +`; + +/** + * Create resources for MCP Apps + */ +export const createSlidesViewerResource = (_env: Env) => + createPublicResource({ + uri: "ui://slides-viewer", + name: "Slides Viewer", + description: + "Interactive slide presentation viewer with navigation and thumbnails", + mimeType: "text/html;profile=mcp-app", + read: () => ({ + uri: "ui://slides-viewer", + mimeType: "text/html;profile=mcp-app", + text: SLIDES_VIEWER_HTML, + }), + }); + +export const createDesignSystemResource = (_env: Env) => + createPublicResource({ + uri: "ui://design-system", + name: "Design System Preview", + description: + "Brand design system preview showing colors, logos, and components", + mimeType: "text/html;profile=mcp-app", + read: () => ({ + uri: "ui://design-system", + mimeType: "text/html;profile=mcp-app", + text: DESIGN_SYSTEM_HTML, + }), + }); + +export const createSlidePreviewResource = (_env: Env) => + createPublicResource({ + uri: "ui://slide", + name: "Slide Preview", + description: "Single slide preview", + mimeType: "text/html;profile=mcp-app", + read: () => ({ + uri: "ui://slide", + mimeType: "text/html;profile=mcp-app", + text: SINGLE_SLIDE_HTML, + }), + }); + +/** + * All resource factory functions. + * Each factory takes env and returns a resource definition. + */ +export const resources = [ + createSlidesViewerResource, + createDesignSystemResource, + createSlidePreviewResource, +]; diff --git a/slides/server/tools/brand-research.ts b/slides/server/tools/brand-research.ts new file mode 100644 index 00000000..bc383533 --- /dev/null +++ b/slides/server/tools/brand-research.ts @@ -0,0 +1,554 @@ +/** + * Brand research tools that use optional Perplexity and Firecrawl bindings. + * + * These tools automatically discover brand assets (logos, colors, typography) + * from websites when the corresponding bindings are configured. + */ +import { createTool } from "@decocms/runtime/tools"; +import { z } from "zod"; +import type { Env } from "../types/env.ts"; + +/** + * Schema for extracted brand assets from research. + */ +const BrandResearchResultSchema = z.object({ + brandName: z.string().describe("Official brand name"), + tagline: z.string().optional().describe("Brand tagline or slogan"), + description: z.string().optional().describe("Brief brand description"), + + // Colors + colors: z + .object({ + primary: z.string().optional().describe("Primary brand color (hex)"), + secondary: z.string().optional().describe("Secondary brand color (hex)"), + accent: z.string().optional().describe("Accent color (hex)"), + background: z + .string() + .optional() + .describe("Background color preference (hex)"), + text: z.string().optional().describe("Primary text color (hex)"), + palette: z + .array(z.string()) + .optional() + .describe("Full color palette (hex values)"), + }) + .optional() + .describe("Brand color palette"), + + // Logo URLs + logos: z + .object({ + primary: z.string().optional().describe("Primary logo URL"), + light: z + .string() + .optional() + .describe("Light/white logo URL for dark backgrounds"), + dark: z + .string() + .optional() + .describe("Dark/black logo URL for light backgrounds"), + icon: z.string().optional().describe("Square icon/favicon URL"), + alternates: z + .array(z.string()) + .optional() + .describe("Other logo variants found"), + }) + .optional() + .describe("Logo image URLs discovered"), + + // Typography + typography: z + .object({ + headingFont: z.string().optional().describe("Primary heading font"), + bodyFont: z.string().optional().describe("Body text font"), + fontWeights: z + .array(z.string()) + .optional() + .describe("Common font weights used"), + }) + .optional() + .describe("Typography information"), + + // Visual style + style: z + .object({ + aesthetic: z + .string() + .optional() + .describe("Overall visual aesthetic (e.g., modern, minimal, bold)"), + industry: z.string().optional().describe("Industry or sector"), + mood: z.string().optional().describe("Brand mood/tone"), + }) + .optional() + .describe("Visual style attributes"), + + // Sources + sources: z + .array(z.string()) + .optional() + .describe("URLs where information was found"), + + // Research metadata + researchMethod: z + .enum(["perplexity", "firecrawl", "both", "none"]) + .describe("Which binding(s) were used for research"), + confidence: z + .enum(["high", "medium", "low"]) + .describe("Confidence level in the extracted data"), + rawData: z.unknown().optional().describe("Raw data from research tools"), +}); + +/** + * BRAND_RESEARCH - Automatically research and discover brand assets + * + * This tool uses Perplexity and/or Firecrawl bindings (when configured) to: + * 1. Research brand information from the web + * 2. Extract brand identity from the website (colors, fonts, logos) + * 3. Find logo image URLs + * + * If no bindings are configured, returns instructions for manual asset collection. + */ +export const createBrandResearchTool = (env: Env) => + createTool({ + id: "BRAND_RESEARCH", + description: `Automatically research and discover brand assets from a website. + +**Requires:** At least one of PERPLEXITY or FIRECRAWL bindings to be configured. + +**What it does:** +1. **With FIRECRAWL:** Scrapes the website to extract brand identity (colors, typography, logos) + - Uses the 'branding' format for comprehensive design system extraction + - Finds logo images in the page source + +2. **With PERPLEXITY:** Searches for additional brand information + - Finds official logo URLs from brand asset pages + - Discovers brand guidelines and color palettes + - Gets brand taglines and descriptions + +3. **With both:** Combines results for most comprehensive brand research + +**Returns:** Structured brand data including logos, colors, typography, and style. + +**Use before DECK_INIT** to automatically populate brand assets.`, + inputSchema: z.object({ + brandName: z.string().describe("Brand/company name to research"), + websiteUrl: z + .string() + .optional() + .describe("Brand website URL (e.g., 'https://example.com')"), + includeCompetitorAnalysis: z + .boolean() + .optional() + .describe("Also research competitor brands for comparison"), + }), + outputSchema: z.object({ + result: BrandResearchResultSchema, + bindingsAvailable: z.object({ + perplexity: z.boolean(), + firecrawl: z.boolean(), + }), + instructions: z + .string() + .optional() + .describe("Manual instructions if no bindings available"), + }), + execute: async ({ context }) => { + const { brandName, websiteUrl } = context; + + // Check which bindings are available (accessed via closure from env) + const perplexity = env.MESH_REQUEST_CONTEXT?.state?.PERPLEXITY; + const firecrawl = env.MESH_REQUEST_CONTEXT?.state?.FIRECRAWL; + + const hasPerplexity = Boolean(perplexity); + const hasFirecrawl = Boolean(firecrawl); + + // If no bindings, return instructions for manual collection + if (!hasPerplexity && !hasFirecrawl) { + return { + result: { + brandName, + researchMethod: "none" as const, + confidence: "low" as const, + }, + bindingsAvailable: { + perplexity: false, + firecrawl: false, + }, + instructions: `No research bindings configured. To enable automatic brand research: + +1. **Add Perplexity binding** (recommended for logo discovery): + - Searches the web for brand logos and guidelines + - Finds official logo asset pages + +2. **Add Firecrawl binding** (recommended for brand identity): + - Extracts colors, fonts, and typography from websites + - Finds logo URLs in page source + +**Manual alternative:** +Ask the user for: +- Logo image URL (PNG/SVG with transparent background) +- Primary brand color (hex, e.g., #8B5CF6) +- Light logo version for dark backgrounds (optional) +- Dark logo version for light backgrounds (optional) + +${websiteUrl ? `\nYou can also manually check ${websiteUrl} for:\n- /logo.png or /logo.svg\n- Favicon at /favicon.ico\n- tag\n- CSS for brand colors` : ""}`, + }; + } + + // Initialize result + let result: z.infer = { + brandName, + researchMethod: + hasPerplexity && hasFirecrawl + ? "both" + : hasFirecrawl + ? "firecrawl" + : "perplexity", + confidence: "medium", + sources: [], + }; + + // FIRECRAWL: Extract brand identity from website + if (hasFirecrawl && websiteUrl && firecrawl) { + try { + // Use the 'branding' format to extract brand identity + const brandingResult = await firecrawl.firecrawl_scrape({ + url: websiteUrl, + formats: ["branding", "links"], + onlyMainContent: false, + maxAge: 86400000, // 24 hour cache + }); + + if (brandingResult) { + const data = brandingResult as Record; + + // Extract branding data + if (data.branding) { + const branding = data.branding as Record; + + // Extract colors + if (branding.colors || branding.colorPalette) { + const colors = (branding.colors || branding.colorPalette) as + | string[] + | Record; + if (Array.isArray(colors)) { + result.colors = { + primary: colors[0], + secondary: colors[1], + accent: colors[2], + palette: colors, + }; + } else if (typeof colors === "object") { + result.colors = { + primary: colors.primary || colors.main, + secondary: colors.secondary, + accent: colors.accent, + background: colors.background, + text: colors.text, + }; + } + } + + // Extract typography + if (branding.fonts || branding.typography) { + const fonts = (branding.fonts || branding.typography) as + | string[] + | Record; + if (Array.isArray(fonts)) { + result.typography = { + headingFont: fonts[0], + bodyFont: fonts[1] || fonts[0], + }; + } else if (typeof fonts === "object") { + result.typography = { + headingFont: fonts.heading || fonts.primary, + bodyFont: fonts.body || fonts.secondary, + }; + } + } + + // Extract logo if available + if (branding.logo || branding.logos) { + const logos = branding.logo || branding.logos; + if (typeof logos === "string") { + result.logos = { primary: logos }; + } else if (typeof logos === "object") { + const logosObj = logos as Record; + result.logos = { + primary: + logosObj.primary || logosObj.main || logosObj.default, + light: logosObj.light || logosObj.white, + dark: logosObj.dark || logosObj.black, + icon: logosObj.icon || logosObj.favicon, + }; + } + } + } + + // Try to find logo in links + if (data.links && Array.isArray(data.links)) { + const logoLinks = (data.links as string[]).filter( + (link) => + /logo/i.test(link) && + /\.(png|svg|jpg|jpeg|webp)$/i.test(link), + ); + if (logoLinks.length > 0 && !result.logos?.primary) { + result.logos = { + ...result.logos, + primary: logoLinks[0], + alternates: logoLinks.slice(1), + }; + } + } + + result.sources = [...(result.sources || []), websiteUrl]; + result.rawData = { firecrawl: data }; + } + } catch (error) { + console.error("Firecrawl brand extraction failed:", error); + } + } + + // PERPLEXITY: Search for additional brand information + if (hasPerplexity && perplexity) { + try { + // Research query for brand assets + const researchPrompt = `Research the brand "${brandName}"${websiteUrl ? ` (website: ${websiteUrl})` : ""} and provide: + +1. **Official Logo URLs**: Find direct URLs to the brand's logo images. Look for: + - Press kit or media kit pages + - Brand guidelines pages + - About pages with downloadable assets + - SVG or PNG logo files + +2. **Brand Colors**: Find the exact hex color codes for: + - Primary brand color + - Secondary colors + - Accent colors + +3. **Brand Identity**: + - Official tagline or slogan + - Brand description + - Visual style (modern, minimal, corporate, playful, etc.) + +4. **Typography**: + - Primary fonts used + - Any custom or brand-specific fonts + +Please provide specific URLs and exact hex color codes where possible. +Format logo URLs as full URLs (e.g., https://example.com/logo.svg).`; + + const researchResult = (await perplexity.perplexity_research({ + messages: [ + { + role: "system", + content: + "You are a brand research expert. Find specific, actionable brand assets including logo URLs and color codes. Always provide full URLs for images.", + }, + { + role: "user", + content: researchPrompt, + }, + ], + strip_thinking: true, + })) as { response?: string } | undefined; + + if (researchResult?.response) { + const response = researchResult.response; + + // Parse colors from response (look for hex patterns) + const hexPattern = /#[0-9A-Fa-f]{6}\b/g; + const foundColors = response.match(hexPattern); + if (foundColors && foundColors.length > 0) { + result.colors = { + ...result.colors, + primary: result.colors?.primary || foundColors[0], + secondary: result.colors?.secondary || foundColors[1], + accent: result.colors?.accent || foundColors[2], + palette: [ + ...(result.colors?.palette || []), + ...foundColors.filter( + (c: string) => !result.colors?.palette?.includes(c), + ), + ].slice(0, 8), + }; + } + + // Parse logo URLs from response + const urlPattern = + /https?:\/\/[^\s<>"{}|\\^`\[\]]+\.(png|svg|jpg|jpeg|webp)/gi; + const foundUrls = response.match(urlPattern); + if (foundUrls && foundUrls.length > 0) { + const logoUrls = foundUrls.filter( + (url: string) => + /logo|brand|icon|mark/i.test(url) || + /assets|media|press|brand/i.test(url), + ); + if (logoUrls.length > 0) { + result.logos = { + ...result.logos, + primary: result.logos?.primary || logoUrls[0], + alternates: [ + ...(result.logos?.alternates || []), + ...logoUrls.slice(1), + ], + }; + } + } + + // Try to extract tagline + const taglineMatch = response.match( + /tagline[:\s]+["']?([^"'\n.]+)["']?/i, + ); + if (taglineMatch) { + result.tagline = result.tagline || taglineMatch[1].trim(); + } + + // Extract description + const descMatch = response.match( + /(?:description|about|is a)[:\s]+([^.]+\.)/i, + ); + if (descMatch) { + result.description = result.description || descMatch[1].trim(); + } + + result.sources = [...(result.sources || []), "perplexity-research"]; + result.rawData = { + ...((result.rawData as Record) || {}), + perplexity: response, + }; + } + } catch (error) { + console.error("Perplexity research failed:", error); + } + + // Also do a targeted search for logo URLs + try { + const searchResult = (await perplexity.perplexity_search({ + query: `${brandName} logo SVG PNG download official`, + max_results: 5, + })) as { results?: string } | undefined; + + if (searchResult?.results) { + // Parse any URLs from search results + const urlPattern = /https?:\/\/[^\s<>"{}|\\^`\[\]]+\.(png|svg)/gi; + const foundUrls = searchResult.results.match(urlPattern); + if (foundUrls && foundUrls.length > 0) { + result.logos = { + ...result.logos, + alternates: [ + ...(result.logos?.alternates || []), + ...foundUrls.filter( + (url: string) => + url !== result.logos?.primary && + !result.logos?.alternates?.includes(url), + ), + ].slice(0, 5), + }; + } + } + } catch (error) { + console.error("Perplexity logo search failed:", error); + } + } + + // Determine confidence level + const hasLogo = Boolean(result.logos?.primary); + const hasColors = Boolean(result.colors?.primary); + + result.confidence = + hasLogo && hasColors ? "high" : hasLogo || hasColors ? "medium" : "low"; + + return { + result, + bindingsAvailable: { + perplexity: hasPerplexity, + firecrawl: hasFirecrawl, + }, + instructions: + result.confidence === "low" + ? `Limited brand data found. Consider: +1. Manually check ${websiteUrl || "the brand website"} for logo files +2. Ask the user for brand guidelines or logo files +3. Check the brand's press kit or media page` + : undefined, + }; + }, + }); + +/** + * BRAND_RESEARCH_STATUS - Check if research bindings are available + */ +export const createBrandResearchStatusTool = (env: Env) => + createTool({ + id: "BRAND_RESEARCH_STATUS", + description: `Check which brand research bindings are available. + +Returns the status of PERPLEXITY and FIRECRAWL bindings, and explains +what capabilities are available for automatic brand research.`, + inputSchema: z.object({}), + outputSchema: z.object({ + bindings: z.object({ + perplexity: z.object({ + available: z.boolean(), + capabilities: z.array(z.string()), + }), + firecrawl: z.object({ + available: z.boolean(), + capabilities: z.array(z.string()), + }), + }), + canResearchBrands: z.boolean(), + recommendation: z.string(), + }), + execute: async () => { + // Access bindings via closure from env + const perplexity = env.MESH_REQUEST_CONTEXT?.state?.PERPLEXITY; + const firecrawl = env.MESH_REQUEST_CONTEXT?.state?.FIRECRAWL; + + const hasPerplexity = Boolean(perplexity); + const hasFirecrawl = Boolean(firecrawl); + + return { + bindings: { + perplexity: { + available: hasPerplexity, + capabilities: hasPerplexity + ? [ + "Search for brand logo URLs", + "Research brand colors and guidelines", + "Find brand taglines and descriptions", + "Discover press kits and media pages", + ] + : [], + }, + firecrawl: { + available: hasFirecrawl, + capabilities: hasFirecrawl + ? [ + "Extract brand colors from website CSS", + "Identify typography and fonts", + "Find logo images in page source", + "Capture full brand identity from live website", + ] + : [], + }, + }, + canResearchBrands: hasPerplexity || hasFirecrawl, + recommendation: + hasPerplexity && hasFirecrawl + ? "Full brand research available! Use BRAND_RESEARCH tool to automatically discover logos, colors, and typography." + : hasFirecrawl + ? "Firecrawl available for website brand extraction. Add Perplexity for better logo URL discovery." + : hasPerplexity + ? "Perplexity available for brand research. Add Firecrawl for direct website brand extraction." + : "No research bindings configured. Add PERPLEXITY or FIRECRAWL bindings to enable automatic brand research. See documentation for setup instructions.", + }; + }, + }); + +// Export all brand research tools +export const brandResearchTools = [ + createBrandResearchTool, + createBrandResearchStatusTool, +]; diff --git a/slides/server/tools/deck.ts b/slides/server/tools/deck.ts index f75725f7..509435b4 100644 --- a/slides/server/tools/deck.ts +++ b/slides/server/tools/deck.ts @@ -6,7 +6,7 @@ */ import { createTool } from "@decocms/runtime/tools"; import { z } from "zod"; -import type { Env } from "../main.ts"; +import type { Env } from "../types/env.ts"; // Default style guide template const DEFAULT_STYLE_TEMPLATE = `# Presentation Style Guide @@ -92,25 +92,118 @@ Edit \`design-system.jsx\` to customize components. Key components: All styling uses CSS classes from \`styles.css\` with CSS variables for easy theming. `; +// Brand assets configuration type +interface BrandAssets { + logoUrl?: string; // Primary logo (usually horizontal, for headers) + logoLightUrl?: string; // Light version for dark backgrounds + logoDarkUrl?: string; // Dark version for light backgrounds + iconUrl?: string; // Square icon (for favicons, small spaces) + brandName: string; // Fallback text if no logo + tagline?: string; // Brand tagline +} + // Design System JSX - Real JSX syntax -const getDesignSystemJSX = (brandName = "Brand", tagline = "TAGLINE") => `/** +const getDesignSystemJSX = (assets: BrandAssets) => { + const { + brandName, + tagline = "", + logoUrl, + logoLightUrl, + logoDarkUrl, + iconUrl, + } = assets; + + // Determine which logo URLs to embed + const primaryLogo = logoUrl || ""; + const lightLogo = logoLightUrl || primaryLogo; + const darkLogo = logoDarkUrl || primaryLogo; + const icon = iconUrl || primaryLogo; + + return `/** * Design System * Brand components for presentations using real JSX * Requires: React, @babel/standalone + * + * Brand Assets: + * - Primary Logo: ${primaryLogo || "(text fallback)"} + * - Light Logo: ${lightLogo || "(text fallback)"} + * - Dark Logo: ${darkLogo || "(text fallback)"} + * - Icon: ${icon || "(text fallback)"} */ (() => { // ============================================================================ - // BRAND: Logo + // BRAND ASSETS CONFIGURATION // ============================================================================ - function BrandLogo({ size = "normal", className = "" }) { + const BRAND = { + name: "${brandName}", + tagline: "${tagline}", + logos: { + primary: "${primaryLogo}", + light: "${lightLogo}", // For dark backgrounds + dark: "${darkLogo}", // For light backgrounds + icon: "${icon}", // Square icon + }, + hasImageLogo: ${Boolean(primaryLogo)}, + }; + + // ============================================================================ + // BRAND: Logo Component + // ============================================================================ + + function BrandLogo({ size = "normal", variant = "auto", className = "" }) { const isSmall = size === "small"; + // Determine which logo to use based on variant + // auto: uses primary, light/dark: uses specific version + const logoSrc = variant === "light" + ? BRAND.logos.light + : variant === "dark" + ? BRAND.logos.dark + : BRAND.logos.primary; + + // If we have an image logo, render it + if (BRAND.hasImageLogo && logoSrc) { + return ( +
+ {BRAND.name} +
+ ); + } + + // Fallback to text logo + return ( +
+ {BRAND.name} + {BRAND.tagline && {BRAND.tagline}} +
+ ); + } + + function BrandIcon({ size = 32, className = "" }) { + if (BRAND.logos.icon) { + return ( + {BRAND.name} + ); + } + + // Fallback: first letter of brand name return ( -
- ${brandName} - ${tagline} +
+ {BRAND.name.charAt(0).toUpperCase()}
); } @@ -334,6 +427,8 @@ const getDesignSystemJSX = (brandName = "Brand", tagline = "TAGLINE") => `/** // ============================================================================ window.DesignSystem = { + // Brand configuration + BRAND, // Slide components SlideComponents: { title: TitleSlide, @@ -344,6 +439,7 @@ const getDesignSystemJSX = (brandName = "Brand", tagline = "TAGLINE") => `/** }, // Individual components for custom slides BrandLogo, + BrandIcon, TitleSlide, ContentSlide, StatsSlide, @@ -359,8 +455,11 @@ const getDesignSystemJSX = (brandName = "Brand", tagline = "TAGLINE") => `/** }; console.log("✓ Design System loaded"); + console.log(" Brand:", BRAND.name); + console.log(" Has image logo:", BRAND.hasImageLogo); })(); `; +}; // Engine JSX - Real JSX syntax const getEngineJSX = () => `/** @@ -624,7 +723,7 @@ const getDesignViewerHTML = (brandColor = "#8B5CF6") => ` + +`, + + // ============================================================================ + // Metric Widget + // Collapsed: Horizontal - metric value prominent, label beside + // Expanded: Vertical - centered with trend indicator + // ============================================================================ + "ui://metric": ` + + + + + +
+
+ Metric + + +
+
+ + 12% +
+
Compared to previous period
+
+ + +`, + + // ============================================================================ + // Progress Widget + // Collapsed: Horizontal bar with percentage + // Expanded: Vertical with label, bar, and details + // ============================================================================ + "ui://progress": ` + + + + + +
+
+ Progress + 0% +
+
+
+
+
0 of 100 completed
+
+ + +`, + + // ============================================================================ + // ADDITIONAL WIDGETS + // ============================================================================ + + // Greeting App + "ui://greeting-app": ` + + + + + +
+
👋
+
+
Hello!
+
Welcome to MCP Apps
+
+
Interactive greeting card
+
+ + +`, + + // Chart App (original) + "ui://chart-app": ` + + + + + +
+

Favorite Fruits Survey

+
+
+
+
+
+ + +`, + + // ============================================================================ + // Timer Widget + // ============================================================================ + "ui://timer": ` + + + + + +
+ Timer + 00:00 +
+ + +
+
+ + +`, + + // ============================================================================ + // Status Badge Widget + // ============================================================================ + "ui://status": ` + + + + + +
+
+
+
All Systems Operational
+
No issues detected
+
+
Just now
+
+ + +`, + + // ============================================================================ + // Quote Widget + // ============================================================================ + "ui://quote": ` + + + + + +
+
"
+
+
The best way to predict the future is to invent it.
+
Alan Kay
+
+
+ + +`, + + // ============================================================================ + // Sparkline Widget + // ============================================================================ + "ui://sparkline": ` + + + + + +
+
+ Requests + 1,234 +
+
+ ↑ 12% +
+ + +`, + + // ============================================================================ + // Code Snippet Widget + // ============================================================================ + "ui://code": ` + + + + + +
+
+ javascript + +
+
function hello() {
+  console.log("Hello, World!");
+}
+
+ + +`, +}; + +export function getResourceHtml(uri: string): string | undefined { + return apps[uri]; +} diff --git a/mcp-apps-testbed/server/main.ts b/mcp-apps-testbed/server/main.ts new file mode 100644 index 00000000..b8996e7a --- /dev/null +++ b/mcp-apps-testbed/server/main.ts @@ -0,0 +1,370 @@ +#!/usr/bin/env bun +/** + * MCP Apps Testbed Server + * + * A simple MCP server for testing MCP Apps (SEP-1865) in Mesh. + * Uses stdio transport - no auth required. + * + * Usage: + * bun server/main.ts + * + * In Mesh, add as STDIO connection: + * Command: bun + * Args: /path/to/mcp-apps-testbed/server/main.ts + */ +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + ListResourcesRequestSchema, + ReadResourceRequestSchema, +} from "@modelcontextprotocol/sdk/types.js"; +import { resources, getResourceHtml } from "./lib/resources.ts"; + +// Tool definitions with UI associations +const tools = [ + // Core widgets + { + name: "counter", + description: + "An interactive counter widget. Set an initial value and use the UI to adjust it.", + inputSchema: { + type: "object" as const, + properties: { + initialValue: { + type: "number", + default: 0, + description: "Initial counter value", + }, + label: { + type: "string", + default: "Counter", + description: "Label for the counter", + }, + }, + }, + _meta: { "ui/resourceUri": "ui://counter-app" }, + }, + { + name: "show_metric", + description: "Display a key metric with optional trend indicator.", + inputSchema: { + type: "object" as const, + properties: { + label: { type: "string", description: "Metric label" }, + value: { type: "number", description: "The metric value" }, + unit: { + type: "string", + description: "Unit of measurement (e.g., 'ms', 'GB', '$')", + }, + trend: { + type: "number", + description: "Trend percentage (positive = up, negative = down)", + }, + description: { type: "string", description: "Additional context" }, + }, + required: ["label", "value"], + }, + _meta: { "ui/resourceUri": "ui://metric" }, + }, + { + name: "show_progress", + description: "Display a progress bar with label and percentage.", + inputSchema: { + type: "object" as const, + properties: { + label: { + type: "string", + default: "Progress", + description: "Progress label", + }, + value: { type: "number", description: "Current progress value" }, + total: { type: "number", default: 100, description: "Total/max value" }, + }, + required: ["value"], + }, + _meta: { "ui/resourceUri": "ui://progress" }, + }, + // Additional tools + { + name: "greet", + description: + "Generate a personalized greeting displayed in an elegant card.", + inputSchema: { + type: "object" as const, + properties: { + name: { type: "string", description: "Name to greet" }, + message: { type: "string", description: "Optional custom message" }, + }, + required: ["name"], + }, + _meta: { "ui/resourceUri": "ui://greeting-app" }, + }, + { + name: "show_chart", + description: "Display data as an animated bar chart.", + inputSchema: { + type: "object" as const, + properties: { + title: { type: "string", default: "Chart", description: "Chart title" }, + data: { + type: "array", + items: { + type: "object", + properties: { + label: { type: "string" }, + value: { type: "number" }, + }, + required: ["label", "value"], + }, + description: "Data points", + }, + }, + required: ["data"], + }, + _meta: { "ui/resourceUri": "ui://chart-app" }, + }, + // New widgets + { + name: "start_timer", + description: "Display an interactive timer with start/pause controls.", + inputSchema: { + type: "object" as const, + properties: { + seconds: { type: "number", default: 0, description: "Initial seconds" }, + label: { type: "string", default: "Timer", description: "Timer label" }, + }, + }, + _meta: { "ui/resourceUri": "ui://timer" }, + }, + { + name: "show_status", + description: "Display a status badge with icon indicator.", + inputSchema: { + type: "object" as const, + properties: { + status: { type: "string", description: "Status text" }, + description: { type: "string", description: "Additional details" }, + type: { + type: "string", + enum: ["success", "warning", "error", "info"], + default: "success", + }, + timestamp: { type: "string", description: "Timestamp text" }, + }, + required: ["status"], + }, + _meta: { "ui/resourceUri": "ui://status" }, + }, + { + name: "show_quote", + description: "Display a quote with attribution.", + inputSchema: { + type: "object" as const, + properties: { + text: { type: "string", description: "The quote text" }, + author: { type: "string", description: "Quote attribution" }, + }, + required: ["text"], + }, + _meta: { "ui/resourceUri": "ui://quote" }, + }, + { + name: "show_sparkline", + description: "Display a compact trend chart with current value.", + inputSchema: { + type: "object" as const, + properties: { + label: { type: "string", description: "Metric label" }, + value: { type: "string", description: "Current value to display" }, + data: { + type: "array", + items: { type: "number" }, + description: "Array of values for the chart", + }, + trend: { type: "number", description: "Trend percentage" }, + }, + required: ["value", "data"], + }, + _meta: { "ui/resourceUri": "ui://sparkline" }, + }, + { + name: "show_code", + description: "Display a code snippet with syntax highlighting.", + inputSchema: { + type: "object" as const, + properties: { + code: { type: "string", description: "The code to display" }, + language: { + type: "string", + default: "javascript", + description: "Programming language", + }, + }, + required: ["code"], + }, + _meta: { "ui/resourceUri": "ui://code" }, + }, +]; + +// Tool handlers +const toolHandlers: Record< + string, + (args: Record) => { + content: Array<{ type: string; text: string }>; + _meta?: Record; + } +> = { + // Core widgets + counter: (args) => ({ + content: [ + { + type: "text", + text: `Counter "${args.label || "Counter"}" initialized at ${args.initialValue ?? 0}`, + }, + ], + _meta: { "ui/resourceUri": "ui://counter-app" }, + }), + + show_metric: (args) => ({ + content: [ + { + type: "text", + text: JSON.stringify({ + label: args.label, + value: args.value, + unit: args.unit, + trend: args.trend, + }), + }, + ], + _meta: { "ui/resourceUri": "ui://metric" }, + }), + + show_progress: (args) => ({ + content: [ + { + type: "text", + text: `Progress: ${args.value}/${args.total || 100} (${Math.round(((args.value as number) / ((args.total as number) || 100)) * 100)}%)`, + }, + ], + _meta: { "ui/resourceUri": "ui://progress" }, + }), + + // Additional widgets + greet: (args) => ({ + content: [ + { + type: "text", + text: args.message + ? `Hello ${args.name}! ${args.message}` + : `Hello ${args.name}!`, + }, + ], + _meta: { "ui/resourceUri": "ui://greeting-app" }, + }), + + show_chart: (args) => ({ + content: [ + { + type: "text", + text: `Chart "${args.title ?? "Chart"}" with ${(args.data as Array)?.length ?? 0} data points`, + }, + ], + _meta: { "ui/resourceUri": "ui://chart-app" }, + }), + + // New widget handlers + start_timer: (args) => ({ + content: [ + { type: "text", text: `Timer started at ${args.seconds ?? 0} seconds` }, + ], + _meta: { "ui/resourceUri": "ui://timer" }, + }), + + show_status: (args) => ({ + content: [ + { + type: "text", + text: `Status: ${args.status} (${args.type ?? "success"})`, + }, + ], + _meta: { "ui/resourceUri": "ui://status" }, + }), + + show_quote: (args) => ({ + content: [ + { type: "text", text: `"${args.text}" — ${args.author ?? "Unknown"}` }, + ], + _meta: { "ui/resourceUri": "ui://quote" }, + }), + + show_sparkline: (args) => ({ + content: [ + { type: "text", text: `${args.label ?? "Value"}: ${args.value}` }, + ], + _meta: { "ui/resourceUri": "ui://sparkline" }, + }), + + show_code: (args) => ({ + content: [ + { + type: "text", + text: `\`\`\`${args.language ?? "javascript"}\n${args.code}\n\`\`\``, + }, + ], + _meta: { "ui/resourceUri": "ui://code" }, + }), +}; + +async function main() { + const server = new Server( + { name: "mcp-apps-testbed", version: "1.0.0" }, + { capabilities: { tools: {}, resources: {} } }, + ); + + // Handle tools/list + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools, + })); + + // Handle tools/call + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + const handler = toolHandlers[name]; + if (!handler) { + throw new Error(`Unknown tool: ${name}`); + } + return handler(args ?? {}); + }); + + // Handle resources/list + server.setRequestHandler(ListResourcesRequestSchema, async () => ({ + resources, + })); + + // Handle resources/read + server.setRequestHandler(ReadResourceRequestSchema, async (request) => { + const { uri } = request.params; + const html = getResourceHtml(uri); + if (!html) { + throw new Error(`Resource not found: ${uri}`); + } + return { + contents: [{ uri, mimeType: "text/html;profile=mcp-app", text: html }], + }; + }); + + // Connect to stdio transport + const transport = new StdioServerTransport(); + await server.connect(transport); + + console.error("[mcp-apps-testbed] MCP server running via stdio"); + console.error("[mcp-apps-testbed] 10 tools with interactive UI widgets"); +} + +main().catch((error) => { + console.error("Fatal error:", error); + process.exit(1); +}); diff --git a/mcp-apps-testbed/tsconfig.json b/mcp-apps-testbed/tsconfig.json new file mode 100644 index 00000000..1b3c1e72 --- /dev/null +++ b/mcp-apps-testbed/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "allowImportingTsExtensions": true + }, + "include": ["server/**/*.ts"], + "exclude": ["node_modules", "dist"] +} From c14bc9319f1089b01f4d1a3a64295b26ad61f683 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sun, 25 Jan 2026 08:24:50 -0300 Subject: [PATCH 04/20] feat: Add Brand MCP - AI-powered brand research & design system generator A complete toolset for brand discovery and design system creation: ## Tools - BRAND_SCRAPE - Extract brand identity from websites using Firecrawl - BRAND_RESEARCH - Deep research using Perplexity AI - BRAND_DISCOVER - Combined scraping + research for complete identity - BRAND_STATUS - Check available research capabilities - BRAND_GENERATE - Generate CSS, JSX, and style guide from identity - BRAND_CREATE - Full workflow: discover + generate in one step ## MCP Apps UIs - ui://brand-preview - Interactive brand identity preview - ui://brand-list - Grid view of created brands ## Output Formats - CSS variables file with full color palette - JSX design system with brand components - Markdown style guide with complete documentation ## Dependencies - Requires FIRECRAWL and/or PERPLEXITY bindings - Uses @decocms/runtime 1.2.0 (same as slides) --- brand/README.md | 221 +++++++++ brand/package.json | 29 ++ brand/server/main.ts | 37 ++ brand/server/resources/index.ts | 572 ++++++++++++++++++++++ brand/server/tools/generator.ts | 816 ++++++++++++++++++++++++++++++++ brand/server/tools/index.ts | 13 + brand/server/tools/research.ts | 663 ++++++++++++++++++++++++++ brand/server/types/env.ts | 46 ++ brand/tsconfig.json | 17 + bun.lock | 76 +-- package.json | 1 + 11 files changed, 2439 insertions(+), 52 deletions(-) create mode 100644 brand/README.md create mode 100644 brand/package.json create mode 100644 brand/server/main.ts create mode 100644 brand/server/resources/index.ts create mode 100644 brand/server/tools/generator.ts create mode 100644 brand/server/tools/index.ts create mode 100644 brand/server/tools/research.ts create mode 100644 brand/server/types/env.ts create mode 100644 brand/tsconfig.json diff --git a/brand/README.md b/brand/README.md new file mode 100644 index 00000000..fb6fbcbf --- /dev/null +++ b/brand/README.md @@ -0,0 +1,221 @@ +# Brand MCP + +AI-powered brand research and design system generator. Automatically discover brand identity from websites and generate complete design systems. + +## Features + +- **Website Scraping** - Extract colors, fonts, logos directly from websites using Firecrawl +- **AI Research** - Deep brand research using Perplexity AI +- **Design System Generation** - CSS variables, JSX components, markdown style guides +- **MCP Apps UI** - Interactive brand previews in Mesh admin +- **One-Step Creation** - Full workflow from URL to complete design system + +## Quick Start + +```bash +# Start the server +bun run dev + +# The MCP will be available at: +# http://localhost:8001/mcp +``` + +## Required Bindings + +Configure at least one binding for brand research: + +### Firecrawl (`@deco/firecrawl`) +- Extract colors from website CSS +- Identify typography and fonts +- Find logo images in page source +- Capture visual style and aesthetics +- Take screenshots + +### Perplexity (`@deco/perplexity`) +- Research brand history and background +- Find brand guidelines and press kits +- Discover logo URLs and assets +- Analyze brand voice and personality +- Find color palettes from various sources + +## Tools + +### Research Tools + +| Tool | Description | +|------|-------------| +| `BRAND_SCRAPE` | Scrape a website to extract brand identity using Firecrawl | +| `BRAND_RESEARCH` | Deep research on a brand using Perplexity AI | +| `BRAND_DISCOVER` | Combined scraping + research for complete identity | +| `BRAND_STATUS` | Check available research capabilities | + +### Generator Tools + +| Tool | Description | +|------|-------------| +| `BRAND_GENERATE` | Generate design system from brand identity | +| `BRAND_CREATE` | Full workflow: discover + generate in one step | + +## MCP Apps (UI Resources) + +| Resource URI | Description | +|--------------|-------------| +| `ui://brand-preview` | Interactive brand identity preview | +| `ui://brand-list` | Grid view of all created brands | + +## Workflow + +### Quick: One-Step Brand Creation + +``` +BRAND_CREATE(brandName: "Acme Corp", websiteUrl: "https://acme.com") +``` + +Returns: +- Complete brand identity object +- CSS variables file +- JSX design system +- Markdown style guide + +### Detailed: Step-by-Step + +1. **Check Status** + ``` + BRAND_STATUS() + ``` + Verify which bindings are available. + +2. **Discover Brand** + ``` + BRAND_DISCOVER(brandName: "Acme", websiteUrl: "https://acme.com") + ``` + Combines scraping and research for complete identity. + +3. **Generate Design System** + ``` + BRAND_GENERATE(identity: {...}, outputFormat: "all") + ``` + Creates CSS, JSX, and style guide. + +## Output Formats + +### CSS Variables + +```css +:root { + --brand-primary: #8B5CF6; + --brand-primary-light: #A78BFA; + --brand-secondary: #10B981; + --bg-dark: #1a1a1a; + --font-heading: 'Inter', system-ui, sans-serif; + /* ... */ +} +``` + +### JSX Design System + +```jsx +// Brand configuration +const BRAND = { + name: "Acme Corp", + colors: { primary: "#8B5CF6", ... }, + logos: { primary: "https://...", ... }, +}; + +// Components +function BrandLogo({ variant, height }) { ... } +function Heading({ level, children }) { ... } +function Button({ variant, children }) { ... } +function Card({ children }) { ... } +``` + +### Markdown Style Guide + +Complete documentation including: +- Color palette with hex codes +- Typography specifications +- Logo usage guidelines +- Visual style rules +- Brand voice description + +## Brand Identity Schema + +```typescript +interface BrandIdentity { + name: string; + tagline?: string; + description?: string; + industry?: string; + + colors: { + primary: string; // Main brand color + secondary?: string; // Supporting color + accent?: string; // Highlight color + background?: string; // Background color + text?: string; // Text color + palette?: string[]; // Full palette + }; + + logos?: { + primary?: string; // Main logo URL + light?: string; // For dark backgrounds + dark?: string; // For light backgrounds + icon?: string; // Square icon + }; + + typography?: { + headingFont?: string; + bodyFont?: string; + monoFont?: string; + }; + + style?: { + aesthetic?: string; // e.g., "modern", "minimal" + mood?: string; // e.g., "professional", "playful" + keywords?: string[]; + }; + + voice?: { + tone?: string; + personality?: string[]; + values?: string[]; + }; + + confidence: "high" | "medium" | "low"; + sources: string[]; +} +``` + +## Integration with Slides MCP + +The Brand MCP is designed to work seamlessly with the Slides MCP: + +1. Create a brand with `BRAND_CREATE` +2. Use the generated identity with Slides' `DECK_INIT` +3. The design system JSX can be used directly + +## Development + +```bash +# Install dependencies +bun install + +# Run development server +bun run dev + +# Type check +bun run check + +# Build for production +bun run build +``` + +## Configuration + +| Variable | Default | Description | +|----------|---------|-------------| +| `PORT` | `8001` | Server port | + +## License + +MIT diff --git a/brand/package.json b/brand/package.json new file mode 100644 index 00000000..732b37b0 --- /dev/null +++ b/brand/package.json @@ -0,0 +1,29 @@ +{ + "name": "@decocms/brand", + "version": "1.0.0", + "description": "AI-powered brand research and design system generator", + "private": true, + "type": "module", + "scripts": { + "dev": "bun run --hot server/main.ts", + "build:server": "NODE_ENV=production bun build server/main.ts --target=bun --outfile=dist/server/main.js", + "build": "bun run build:server", + "check": "tsc --noEmit" + }, + "exports": { + "./tools": "./server/tools/index.ts" + }, + "dependencies": { + "@decocms/bindings": "^1.0.9", + "@decocms/runtime": "1.2.0", + "zod": "^4.0.0" + }, + "devDependencies": { + "@decocms/mcps-shared": "workspace:*", + "@modelcontextprotocol/sdk": "1.25.1", + "typescript": "^5.7.2" + }, + "engines": { + "node": ">=22.0.0" + } +} diff --git a/brand/server/main.ts b/brand/server/main.ts new file mode 100644 index 00000000..36be938f --- /dev/null +++ b/brand/server/main.ts @@ -0,0 +1,37 @@ +/** + * Brand MCP - AI-Powered Brand Research & Design System Generator + * + * A complete toolset for discovering brand identity and generating design systems. + * + * ## Features + * + * - **Brand Scraping** - Extract colors, fonts, logos from websites using Firecrawl + * - **Brand Research** - Deep research using Perplexity AI + * - **Design System Generation** - CSS variables, JSX components, style guides + * - **MCP Apps UI** - Interactive brand previews + * + * ## Required Bindings + * + * Configure at least one of these for full functionality: + * - **FIRECRAWL** - For website scraping and brand extraction + * - **PERPLEXITY** - For AI-powered brand research + */ +import { serve } from "@decocms/mcps-shared/serve"; +import { withRuntime } from "@decocms/runtime"; +import { tools } from "./tools/index.ts"; +import { resources } from "./resources/index.ts"; +import { StateSchema, type Env, type Registry } from "./types/env.ts"; + +export { StateSchema }; + +const runtime = withRuntime({ + configuration: { + scopes: ["PERPLEXITY::*", "FIRECRAWL::*"], + state: StateSchema, + }, + tools, + prompts: [], + resources, +}); + +serve(runtime.fetch); diff --git a/brand/server/resources/index.ts b/brand/server/resources/index.ts new file mode 100644 index 00000000..5a3abfaf --- /dev/null +++ b/brand/server/resources/index.ts @@ -0,0 +1,572 @@ +/** + * MCP Apps Resources for Brand MCP + * + * Interactive UI resources for displaying brand identities and design systems. + */ +import { createPublicResource } from "@decocms/runtime"; +import type { Env } from "../types/env.ts"; + +/** + * Brand Preview App - Shows brand identity with colors, logos, typography + */ +const BRAND_PREVIEW_HTML = ` + + + + + Brand Preview + + + +
+
+

Brand Preview

+

Waiting for brand data...

+
+
+ + + +`; + +/** + * Brand List App - Shows all created brands + */ +const BRAND_LIST_HTML = ` + + + + + Brand List + + + +
+

Brands

+
+
+

No brands yet. Use BRAND_CREATE to create one.

+
+
+
+ + + +`; + +export const createBrandPreviewResource = (_env: Env) => + createPublicResource({ + uri: "ui://brand-preview", + name: "Brand Preview", + description: "Interactive preview of brand identity and design system", + mimeType: "text/html;profile=mcp-app", + read: () => ({ + uri: "ui://brand-preview", + mimeType: "text/html;profile=mcp-app", + text: BRAND_PREVIEW_HTML, + }), + }); + +export const createBrandListResource = (_env: Env) => + createPublicResource({ + uri: "ui://brand-list", + name: "Brand List", + description: "View all created brands", + mimeType: "text/html;profile=mcp-app", + read: () => ({ + uri: "ui://brand-list", + mimeType: "text/html;profile=mcp-app", + text: BRAND_LIST_HTML, + }), + }); + +export const resources = [createBrandPreviewResource, createBrandListResource]; diff --git a/brand/server/tools/generator.ts b/brand/server/tools/generator.ts new file mode 100644 index 00000000..ecf8b22f --- /dev/null +++ b/brand/server/tools/generator.ts @@ -0,0 +1,816 @@ +/** + * Design System Generator Tools + * + * Generates design system JSX and style guides from brand identity. + */ +import { createTool } from "@decocms/runtime/tools"; +import { z } from "zod"; +import type { Env } from "../types/env.ts"; +import { type BrandIdentity, BrandIdentitySchema } from "./research.ts"; + +/** + * Generate CSS variables from brand colors + */ +function generateCSSVariables(identity: BrandIdentity): string { + const colors = identity.colors || { primary: "#8B5CF6" }; + const typography = identity.typography || {}; + + return `/** + * ${identity.name} Design System + * Generated by Brand MCP + */ + +:root { + /* Primary Colors */ + --brand-primary: ${colors.primary}; + --brand-primary-light: ${lightenColor(colors.primary, 20)}; + --brand-primary-dark: ${darkenColor(colors.primary, 20)}; + + /* Secondary Colors */ + --brand-secondary: ${colors.secondary || colors.primary}; + --brand-accent: ${colors.accent || colors.primary}; + + /* Background Colors */ + --bg-dark: ${colors.background || "#1a1a1a"}; + --bg-light: #FFFFFF; + --bg-gray: #F5F5F5; + + /* Text Colors */ + --text-primary: ${colors.text || "#1A1A1A"}; + --text-secondary: #6B7280; + --text-light: #FFFFFF; + --text-muted: #9CA3AF; + + /* Typography */ + --font-heading: ${typography.headingFont || "'Inter', system-ui, sans-serif"}; + --font-body: ${typography.bodyFont || "'Inter', system-ui, sans-serif"}; + --font-mono: ${typography.monoFont || "'JetBrains Mono', monospace"}; + + /* Spacing */ + --spacing-xs: 0.25rem; + --spacing-sm: 0.5rem; + --spacing-md: 1rem; + --spacing-lg: 1.5rem; + --spacing-xl: 2rem; + --spacing-2xl: 3rem; + + /* Border Radius */ + --radius-sm: 0.25rem; + --radius-md: 0.5rem; + --radius-lg: 1rem; + --radius-full: 9999px; + + /* Shadows */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1); +} + +/* Dark Mode */ +[data-theme="dark"] { + --bg-dark: #0f0f0f; + --bg-light: #1a1a1a; + --bg-gray: #2a2a2a; + --text-primary: #FFFFFF; + --text-secondary: #9CA3AF; +} +`; +} + +/** + * Generate JSX design system components + */ +function generateDesignSystemJSX(identity: BrandIdentity): string { + const colors = identity.colors || { primary: "#8B5CF6" }; + const logos = identity.logos || {}; + const typography = identity.typography || {}; + + return `/** + * ${identity.name} Design System + * + * Auto-generated design system with brand components. + * Use with Babel Standalone for browser transpilation. + */ + +// Brand configuration +const BRAND = { + name: "${identity.name}", + tagline: "${identity.tagline || ""}", + + colors: { + primary: "${colors.primary}", + primaryLight: "${lightenColor(colors.primary, 20)}", + primaryDark: "${darkenColor(colors.primary, 20)}", + secondary: "${colors.secondary || colors.primary}", + accent: "${colors.accent || colors.primary}", + background: "${colors.background || "#1a1a1a"}", + text: "${colors.text || "#1A1A1A"}", + textLight: "#FFFFFF", + textMuted: "#6B7280", + }, + + logos: { + primary: ${logos.primary ? `"${logos.primary}"` : "null"}, + light: ${logos.light ? `"${logos.light}"` : "null"}, + dark: ${logos.dark ? `"${logos.dark}"` : "null"}, + icon: ${logos.icon ? `"${logos.icon}"` : "null"}, + }, + + typography: { + heading: "${typography.headingFont || "Inter, system-ui, sans-serif"}", + body: "${typography.bodyFont || "Inter, system-ui, sans-serif"}", + mono: "${typography.monoFont || "JetBrains Mono, monospace"}", + }, + + hasImageLogo: ${Boolean(logos.primary)}, +}; + +/** + * Brand Logo Component + * Renders image logo if available, falls back to text + */ +function BrandLogo({ variant = "primary", className = "", height = 40 }) { + const logoUrl = variant === "light" ? BRAND.logos.light : + variant === "dark" ? BRAND.logos.dark : + BRAND.logos.primary; + + if (logoUrl) { + return ( + {BRAND.name} + ); + } + + // Text fallback + return ( + + {BRAND.name} + + ); +} + +/** + * Brand Icon Component + */ +function BrandIcon({ size = 32, className = "" }) { + if (BRAND.logos.icon) { + return ( + {BRAND.name} + ); + } + + // Fallback to first letter + return ( +
+ {BRAND.name[0]} +
+ ); +} + +/** + * Heading Component + */ +function Heading({ level = 1, children, className = "", color = "primary" }) { + const Tag = \`h\${level}\`; + const sizes = { + 1: "3rem", + 2: "2.25rem", + 3: "1.875rem", + 4: "1.5rem", + 5: "1.25rem", + 6: "1rem", + }; + + const colors = { + primary: BRAND.colors.text, + brand: BRAND.colors.primary, + light: BRAND.colors.textLight, + muted: BRAND.colors.textMuted, + }; + + return ( + + {children} + + ); +} + +/** + * Text Component + */ +function Text({ size = "base", children, className = "", color = "primary", weight = "normal" }) { + const sizes = { + xs: "0.75rem", + sm: "0.875rem", + base: "1rem", + lg: "1.125rem", + xl: "1.25rem", + }; + + const colors = { + primary: BRAND.colors.text, + secondary: BRAND.colors.textMuted, + brand: BRAND.colors.primary, + light: BRAND.colors.textLight, + }; + + return ( +

+ {children} +

+ ); +} + +/** + * Button Component + */ +function Button({ variant = "primary", size = "md", children, className = "", ...props }) { + const variants = { + primary: { + backgroundColor: BRAND.colors.primary, + color: BRAND.colors.textLight, + border: "none", + }, + secondary: { + backgroundColor: "transparent", + color: BRAND.colors.primary, + border: \`2px solid \${BRAND.colors.primary}\`, + }, + ghost: { + backgroundColor: "transparent", + color: BRAND.colors.text, + border: "none", + }, + }; + + const sizes = { + sm: { padding: "0.5rem 1rem", fontSize: "0.875rem" }, + md: { padding: "0.75rem 1.5rem", fontSize: "1rem" }, + lg: { padding: "1rem 2rem", fontSize: "1.125rem" }, + }; + + return ( + + ); +} + +/** + * Card Component + */ +function Card({ children, className = "", variant = "default" }) { + const variants = { + default: { + backgroundColor: "#FFFFFF", + border: "1px solid #E5E7EB", + }, + elevated: { + backgroundColor: "#FFFFFF", + boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)", + }, + brand: { + backgroundColor: BRAND.colors.primary, + color: BRAND.colors.textLight, + }, + }; + + return ( +
+ {children} +
+ ); +} + +/** + * Color Swatch Component + */ +function ColorSwatch({ color, name, className = "" }) { + return ( +
+
+ {name} + {color} +
+ ); +} + +// Export components +if (typeof window !== "undefined") { + window.BRAND = BRAND; + window.BrandLogo = BrandLogo; + window.BrandIcon = BrandIcon; + window.Heading = Heading; + window.Text = Text; + window.Button = Button; + window.Card = Card; + window.ColorSwatch = ColorSwatch; +} +`; +} + +/** + * Generate style guide markdown + */ +function generateStyleGuide(identity: BrandIdentity): string { + const colors = identity.colors || { primary: "#8B5CF6" }; + const typography = identity.typography || {}; + const style = identity.style || {}; + const voice = identity.voice || {}; + + return `# ${identity.name} Brand Style Guide + +${identity.description ? `> ${identity.description}` : ""} + +${identity.tagline ? `**Tagline:** "${identity.tagline}"` : ""} + +--- + +## Brand Identity + +**Name:** ${identity.name} +${identity.industry ? `**Industry:** ${identity.industry}` : ""} +${identity.founded ? `**Founded:** ${identity.founded}` : ""} + +--- + +## Color Palette + +### Primary Colors + +| Color | Hex | Usage | +|-------|-----|-------| +| Primary | \`${colors.primary}\` | Main brand color, CTAs, links | +| Primary Light | \`${lightenColor(colors.primary, 20)}\` | Hover states, backgrounds | +| Primary Dark | \`${darkenColor(colors.primary, 20)}\` | Active states, emphasis | + +${ + colors.secondary + ? `### Secondary Colors + +| Color | Hex | Usage | +|-------|-----|-------| +| Secondary | \`${colors.secondary}\` | Supporting elements | +${colors.accent ? `| Accent | \`${colors.accent}\` | Highlights, notifications |` : ""}` + : "" +} + +### Background & Text + +| Color | Hex | Usage | +|-------|-----|-------| +| Background | \`${colors.background || "#1a1a1a"}\` | Dark backgrounds | +| Text | \`${colors.text || "#1A1A1A"}\` | Primary text | +| Text Muted | \`#6B7280\` | Secondary text | + +${ + colors.palette?.length + ? `### Full Palette + +${colors.palette.map((c) => `- \`${c}\``).join("\n")}` + : "" +} + +--- + +## Typography + +### Font Families + +- **Headings:** ${typography.headingFont || "Inter, system-ui, sans-serif"} +- **Body:** ${typography.bodyFont || "Inter, system-ui, sans-serif"} +${typography.monoFont ? `- **Monospace:** ${typography.monoFont}` : ""} + +### Type Scale + +| Element | Size | Weight | +|---------|------|--------| +| H1 | 3rem (48px) | Bold (700) | +| H2 | 2.25rem (36px) | Bold (700) | +| H3 | 1.875rem (30px) | Semibold (600) | +| H4 | 1.5rem (24px) | Semibold (600) | +| Body | 1rem (16px) | Regular (400) | +| Small | 0.875rem (14px) | Regular (400) | + +--- + +## Logo Usage + +${ + identity.logos?.primary + ? `### Primary Logo + +![${identity.name} Logo](${identity.logos.primary}) + +- Use on light backgrounds +- Maintain clear space equal to the height of the logo +- Minimum size: 100px width` + : `### Logo + +*Logo URL not available. Please provide logo assets.*` +} + +${ + identity.logos?.light + ? `### Light Logo (for dark backgrounds) + +![${identity.name} Light Logo](${identity.logos.light})` + : "" +} + +${ + identity.logos?.dark + ? `### Dark Logo (for light backgrounds) + +![${identity.name} Dark Logo](${identity.logos.dark})` + : "" +} + +${ + identity.logos?.icon + ? `### Icon/Favicon + +![${identity.name} Icon](${identity.logos.icon}) + +- Use for favicons, app icons, social media +- Square format (1:1 ratio)` + : "" +} + +--- + +## Visual Style + +${style.aesthetic ? `**Aesthetic:** ${style.aesthetic}` : ""} +${style.mood ? `**Mood:** ${style.mood}` : ""} +${style.keywords?.length ? `**Keywords:** ${style.keywords.join(", ")}` : ""} + +### UI Elements + +- **Border Radius:** ${style.borderRadius || "0.5rem (8px)"} +- **Shadows:** ${style.shadows || "Subtle, layered shadows"} + +--- + +${ + voice.tone || voice.personality?.length || voice.values?.length + ? `## Brand Voice + +${voice.tone ? `**Tone:** ${voice.tone}` : ""} + +${ + voice.personality?.length + ? `### Personality +${voice.personality.map((p) => `- ${p}`).join("\n")}` + : "" +} + +${ + voice.values?.length + ? `### Core Values +${voice.values.map((v) => `- ${v}`).join("\n")}` + : "" +} + +---` + : "" +} + +## Usage Guidelines + +1. **Consistency:** Always use the exact hex values specified +2. **Contrast:** Ensure sufficient contrast for accessibility (WCAG 2.1 AA) +3. **Spacing:** Use the spacing scale (0.25rem increments) +4. **Typography:** Maintain the type hierarchy +5. **Logo:** Never stretch, distort, or recolor the logo + +--- + +*Generated by Brand MCP* +`; +} + +// Helper functions +function lightenColor(hex: string, percent: number): string { + const num = parseInt(hex.replace("#", ""), 16); + const amt = Math.round(2.55 * percent); + const R = Math.min(255, (num >> 16) + amt); + const G = Math.min(255, ((num >> 8) & 0x00ff) + amt); + const B = Math.min(255, (num & 0x0000ff) + amt); + return `#${((1 << 24) + (R << 16) + (G << 8) + B).toString(16).slice(1)}`; +} + +function darkenColor(hex: string, percent: number): string { + const num = parseInt(hex.replace("#", ""), 16); + const amt = Math.round(2.55 * percent); + const R = Math.max(0, (num >> 16) - amt); + const G = Math.max(0, ((num >> 8) & 0x00ff) - amt); + const B = Math.max(0, (num & 0x0000ff) - amt); + return `#${((1 << 24) + (R << 16) + (G << 8) + B).toString(16).slice(1)}`; +} + +/** + * BRAND_GENERATE - Generate complete design system from brand identity + */ +export const createBrandGenerateTool = (_env: Env) => + createTool({ + id: "BRAND_GENERATE", + _meta: { "ui/resourceUri": "ui://brand-preview" }, + description: `Generate a complete design system from brand identity. + +Creates: +- CSS variables file +- JSX component library +- Markdown style guide + +**Input:** Brand identity object (from BRAND_DISCOVER or manual) +**Output:** Complete design system files ready to use`, + inputSchema: z.object({ + identity: BrandIdentitySchema.describe("Brand identity to generate from"), + outputFormat: z + .enum(["all", "css", "jsx", "styleguide"]) + .optional() + .describe("Which outputs to generate (default: all)"), + }), + outputSchema: z.object({ + brandName: z.string(), + css: z.string().optional().describe("CSS variables file content"), + jsx: z.string().optional().describe("JSX design system content"), + styleGuide: z.string().optional().describe("Markdown style guide"), + }), + execute: async ({ context }) => { + const { identity, outputFormat = "all" } = context; + + const result: { + brandName: string; + css?: string; + jsx?: string; + styleGuide?: string; + } = { + brandName: identity.name, + }; + + if (outputFormat === "all" || outputFormat === "css") { + result.css = generateCSSVariables(identity); + } + + if (outputFormat === "all" || outputFormat === "jsx") { + result.jsx = generateDesignSystemJSX(identity); + } + + if (outputFormat === "all" || outputFormat === "styleguide") { + result.styleGuide = generateStyleGuide(identity); + } + + return result; + }, + }); + +/** + * BRAND_CREATE - Full workflow: discover + generate + */ +export const createBrandCreateTool = (env: Env) => + createTool({ + id: "BRAND_CREATE", + _meta: { "ui/resourceUri": "ui://brand-preview" }, + description: `Complete brand creation workflow. + +This is the main tool - it: +1. Discovers brand identity from website + research +2. Generates complete design system +3. Returns everything ready to use + +**Best for:** One-step brand creation from a website URL.`, + inputSchema: z.object({ + brandName: z.string().describe("Brand or company name"), + websiteUrl: z.string().url().describe("Brand website URL"), + }), + outputSchema: z.object({ + success: z.boolean(), + identity: BrandIdentitySchema.optional(), + css: z.string().optional(), + jsx: z.string().optional(), + styleGuide: z.string().optional(), + error: z.string().optional(), + }), + execute: async ({ context }) => { + const { brandName, websiteUrl } = context; + + const firecrawl = env.MESH_REQUEST_CONTEXT?.state?.FIRECRAWL; + const perplexity = env.MESH_REQUEST_CONTEXT?.state?.PERPLEXITY; + + if (!firecrawl && !perplexity) { + return { + success: false, + error: + "No research bindings available. Configure FIRECRAWL and/or PERPLEXITY bindings.", + }; + } + + // Step 1: Discover brand identity (reuse logic from research.ts) + const identity: BrandIdentity = { + name: brandName, + colors: { primary: "#8B5CF6" }, + sources: [], + confidence: "low", + }; + + // Scrape website + if (firecrawl) { + try { + const result = (await firecrawl.firecrawl_scrape({ + url: websiteUrl, + formats: ["branding", "links"], + })) as { branding?: Record }; + + if (result?.branding) { + const branding = result.branding; + + if (branding.colors && typeof branding.colors === "object") { + const colors = branding.colors as Record; + identity.colors = { + primary: colors.primary || colors.main || "#8B5CF6", + secondary: colors.secondary, + accent: colors.accent, + background: colors.background, + text: colors.text, + palette: Object.values(colors).filter( + (c) => typeof c === "string" && c.startsWith("#"), + ), + }; + } + + if (branding.logos) { + if (Array.isArray(branding.logos)) { + identity.logos = { + primary: branding.logos[0] as string, + alternates: branding.logos.slice(1) as string[], + }; + } else if (typeof branding.logos === "object") { + const logos = branding.logos as Record; + identity.logos = { + primary: logos.primary || logos.main, + light: logos.light, + dark: logos.dark, + icon: logos.icon, + }; + } + } + + if (branding.fonts && typeof branding.fonts === "object") { + const fonts = branding.fonts as Record; + identity.typography = { + headingFont: fonts.heading || fonts.title, + bodyFont: fonts.body || fonts.text, + monoFont: fonts.mono, + }; + } + + identity.sources?.push(websiteUrl); + } + } catch (error) { + console.error("Scraping error:", error); + } + } + + // Research with Perplexity + if (perplexity) { + try { + const result = (await perplexity.perplexity_research({ + messages: [ + { + role: "user", + content: `Brief info on ${brandName} (${websiteUrl}): tagline, primary color hex, and brand personality in 2-3 sentences.`, + }, + ], + strip_thinking: true, + })) as { response?: string }; + + if (result?.response) { + const response = result.response; + + // Extract tagline + const taglineMatch = response.match( + /(?:tagline|slogan)[:\s]+["']?([^"'\n.]+)["']?/i, + ); + if (taglineMatch) { + identity.tagline = taglineMatch[1].trim(); + } + + // Extract colors if we don't have good ones + if (identity.colors.primary === "#8B5CF6") { + const hexColors = response.match(/#[0-9A-Fa-f]{6}/g); + if (hexColors?.length) { + identity.colors.primary = hexColors[0]; + } + } + + identity.sources?.push("perplexity-research"); + } + } catch (error) { + console.error("Research error:", error); + } + } + + // Determine confidence + const hasLogo = Boolean(identity.logos?.primary); + const hasColors = identity.colors.primary !== "#8B5CF6"; + identity.confidence = + hasLogo && hasColors ? "high" : hasLogo || hasColors ? "medium" : "low"; + + // Step 2: Generate design system + const css = generateCSSVariables(identity); + const jsx = generateDesignSystemJSX(identity); + const styleGuide = generateStyleGuide(identity); + + return { + success: true, + identity, + css, + jsx, + styleGuide, + }; + }, + }); + +export const generatorTools = [createBrandGenerateTool, createBrandCreateTool]; diff --git a/brand/server/tools/index.ts b/brand/server/tools/index.ts new file mode 100644 index 00000000..0674db8c --- /dev/null +++ b/brand/server/tools/index.ts @@ -0,0 +1,13 @@ +/** + * Brand MCP Tools + * + * Complete toolset for brand research and design system generation. + */ +import { researchTools } from "./research.ts"; +import { generatorTools } from "./generator.ts"; + +export const tools = [...researchTools, ...generatorTools]; + +export { researchTools } from "./research.ts"; +export { generatorTools } from "./generator.ts"; +export { BrandIdentitySchema, type BrandIdentity } from "./research.ts"; diff --git a/brand/server/tools/research.ts b/brand/server/tools/research.ts new file mode 100644 index 00000000..ec2514ae --- /dev/null +++ b/brand/server/tools/research.ts @@ -0,0 +1,663 @@ +/** + * Brand Research Tools + * + * Uses Firecrawl and Perplexity to research and extract brand identity. + */ +import { createTool } from "@decocms/runtime/tools"; +import { z } from "zod"; +import type { Env } from "../types/env.ts"; + +/** + * Brand identity schema + */ +export const BrandIdentitySchema = z.object({ + name: z.string().describe("Official brand name"), + tagline: z.string().optional().describe("Brand tagline or slogan"), + description: z.string().optional().describe("Brief brand description"), + industry: z.string().optional().describe("Industry or sector"), + founded: z.string().optional().describe("Year founded"), + headquarters: z.string().optional().describe("Company headquarters"), + + colors: z + .object({ + primary: z.string().describe("Primary brand color (hex)"), + secondary: z.string().optional().describe("Secondary brand color (hex)"), + accent: z.string().optional().describe("Accent color (hex)"), + background: z.string().optional().describe("Background color (hex)"), + text: z.string().optional().describe("Primary text color (hex)"), + palette: z + .array(z.string()) + .optional() + .describe("Full color palette (hex values)"), + }) + .describe("Brand color palette"), + + logos: z + .object({ + primary: z.string().optional().describe("Primary logo URL"), + light: z.string().optional().describe("Light logo for dark backgrounds"), + dark: z.string().optional().describe("Dark logo for light backgrounds"), + icon: z.string().optional().describe("Square icon/favicon URL"), + alternates: z + .array(z.string()) + .optional() + .describe("Other logo variants"), + }) + .optional() + .describe("Logo image URLs"), + + typography: z + .object({ + headingFont: z.string().optional().describe("Primary heading font"), + bodyFont: z.string().optional().describe("Body text font"), + monoFont: z.string().optional().describe("Monospace font"), + fontWeights: z.array(z.string()).optional().describe("Common weights"), + letterSpacing: z.string().optional().describe("Letter spacing style"), + }) + .optional() + .describe("Typography information"), + + style: z + .object({ + aesthetic: z.string().optional().describe("Visual aesthetic"), + mood: z.string().optional().describe("Brand mood/tone"), + keywords: z.array(z.string()).optional().describe("Style keywords"), + borderRadius: z.string().optional().describe("Border radius style"), + shadows: z.string().optional().describe("Shadow style"), + }) + .optional() + .describe("Visual style attributes"), + + voice: z + .object({ + tone: z.string().optional().describe("Communication tone"), + personality: z + .array(z.string()) + .optional() + .describe("Brand personality traits"), + values: z.array(z.string()).optional().describe("Core values"), + }) + .optional() + .describe("Brand voice and personality"), + + sources: z.array(z.string()).optional().describe("Research sources"), + confidence: z.enum(["high", "medium", "low"]).describe("Confidence level"), + rawData: z.unknown().optional().describe("Raw research data"), +}); + +export type BrandIdentity = z.infer; + +/** + * BRAND_SCRAPE - Extract brand identity from a website using Firecrawl + */ +export const createBrandScrapeTool = (env: Env) => + createTool({ + id: "BRAND_SCRAPE", + description: `Scrape a website to extract brand identity using Firecrawl. + +Uses Firecrawl's 'branding' format to extract: +- Colors (primary, secondary, accent, background) +- Typography (fonts, weights, spacing) +- Logo images found on the page +- Visual style (aesthetic, shadows, border radius) + +**Requires:** FIRECRAWL binding to be configured. + +**Best for:** When you have the brand's website URL and want to extract their actual design system.`, + inputSchema: z.object({ + url: z.string().url().describe("Website URL to scrape"), + includeScreenshot: z + .boolean() + .optional() + .describe("Also capture a screenshot"), + }), + outputSchema: z.object({ + success: z.boolean(), + identity: BrandIdentitySchema.optional(), + screenshot: z.string().optional().describe("Screenshot URL if requested"), + error: z.string().optional(), + }), + execute: async ({ context }) => { + const { url, includeScreenshot } = context; + + const firecrawl = env.MESH_REQUEST_CONTEXT?.state?.FIRECRAWL; + if (!firecrawl) { + return { + success: false, + error: + "FIRECRAWL binding not configured. Add the Firecrawl binding to enable website scraping.", + }; + } + + try { + const formats: string[] = ["branding", "links"]; + if (includeScreenshot) { + formats.push("screenshot"); + } + + const result = (await firecrawl.firecrawl_scrape({ + url, + formats, + })) as { + branding?: { + colors?: Record; + fonts?: Record; + typography?: Record; + logos?: Record | string[]; + style?: Record; + }; + links?: string[]; + screenshot?: string; + }; + + if (!result?.branding) { + return { + success: false, + error: "No branding data extracted from the page", + }; + } + + const branding = result.branding; + + // Parse colors + const colors: BrandIdentity["colors"] = { + primary: + branding.colors?.primary || branding.colors?.main || "#000000", + secondary: branding.colors?.secondary, + accent: branding.colors?.accent, + background: branding.colors?.background || branding.colors?.bg, + text: branding.colors?.text, + palette: Object.values(branding.colors || {}).filter( + (c): c is string => typeof c === "string" && c.startsWith("#"), + ), + }; + + // Parse logos + let logos: BrandIdentity["logos"]; + if (branding.logos) { + if (Array.isArray(branding.logos)) { + logos = { + primary: branding.logos[0], + alternates: branding.logos.slice(1), + }; + } else { + logos = { + primary: branding.logos.primary || branding.logos.main, + light: branding.logos.light || branding.logos.white, + dark: branding.logos.dark || branding.logos.black, + icon: branding.logos.icon || branding.logos.favicon, + }; + } + } + + // Parse typography + const typography: BrandIdentity["typography"] = { + headingFont: + branding.fonts?.heading || + branding.fonts?.title || + (branding.typography?.headingFont as string), + bodyFont: + branding.fonts?.body || + branding.fonts?.text || + (branding.typography?.bodyFont as string), + monoFont: branding.fonts?.mono || branding.fonts?.code, + }; + + // Parse style + const style: BrandIdentity["style"] = { + borderRadius: branding.style?.borderRadius as string, + shadows: branding.style?.shadows as string, + }; + + // Extract brand name from URL + const urlObj = new URL(url); + const brandName = + urlObj.hostname.replace("www.", "").split(".")[0] || "Unknown Brand"; + + const identity: BrandIdentity = { + name: brandName.charAt(0).toUpperCase() + brandName.slice(1), + colors, + logos, + typography, + style, + sources: [url], + confidence: + logos?.primary && colors.primary !== "#000000" ? "high" : "medium", + }; + + return { + success: true, + identity, + screenshot: result.screenshot, + }; + } catch (error) { + return { + success: false, + error: `Scraping failed: ${error instanceof Error ? error.message : String(error)}`, + }; + } + }, + }); + +/** + * BRAND_RESEARCH - Deep research on a brand using Perplexity + */ +export const createBrandResearchTool = (env: Env) => + createTool({ + id: "BRAND_RESEARCH", + description: `Research a brand using Perplexity AI to gather comprehensive information. + +Discovers: +- Brand history and background +- Color palette and visual identity +- Logo variations and assets +- Typography and design guidelines +- Brand voice and personality +- Industry positioning + +**Requires:** PERPLEXITY binding to be configured. + +**Best for:** When you need comprehensive brand information beyond what's visible on their website.`, + inputSchema: z.object({ + brandName: z.string().describe("Brand or company name to research"), + websiteUrl: z.string().url().optional().describe("Brand website URL"), + focusAreas: z + .array(z.enum(["visual", "voice", "history", "guidelines", "assets"])) + .optional() + .describe("Specific areas to focus research on"), + }), + outputSchema: z.object({ + success: z.boolean(), + identity: BrandIdentitySchema.optional(), + research: z.string().optional().describe("Full research text"), + error: z.string().optional(), + }), + execute: async ({ context }) => { + const { brandName, websiteUrl, focusAreas } = context; + + const perplexity = env.MESH_REQUEST_CONTEXT?.state?.PERPLEXITY; + if (!perplexity) { + return { + success: false, + error: + "PERPLEXITY binding not configured. Add the Perplexity binding to enable brand research.", + }; + } + + try { + const focusText = focusAreas?.length + ? `Focus especially on: ${focusAreas.join(", ")}.` + : ""; + + const websiteText = websiteUrl + ? ` Their website is ${websiteUrl}.` + : ""; + + const result = (await perplexity.perplexity_research({ + messages: [ + { + role: "system", + content: `You are a brand research expert. Extract comprehensive brand identity information in a structured way. Include specific hex color codes, font names, and URLs when found.`, + }, + { + role: "user", + content: `Research the brand "${brandName}".${websiteText} + +I need comprehensive information about: + +1. **Brand Identity** + - Official brand name and tagline + - Industry and market position + - Company history and founding + +2. **Visual Identity** + - Primary, secondary, and accent colors (with hex codes if available) + - Logo variations and where to find them + - Typography (heading fonts, body fonts) + - Overall visual style and aesthetic + +3. **Brand Voice** + - Tone of communication + - Key brand values + - Personality traits + +4. **Design Guidelines** + - Any public brand guidelines or press kits + - Logo usage rules + - Color usage guidelines + +${focusText} + +Please provide specific details like hex color codes (#RRGGBB), font names, and URLs to logo assets when you can find them.`, + }, + ], + strip_thinking: true, + })) as { response?: string }; + + if (!result?.response) { + return { + success: false, + error: "No research response received", + }; + } + + // Parse the research response to extract structured data + const response = result.response; + + // Try to extract colors from the response + const hexColors = response.match(/#[0-9A-Fa-f]{6}/g) || []; + const colors: BrandIdentity["colors"] = { + primary: hexColors[0] || "#000000", + secondary: hexColors[1], + accent: hexColors[2], + palette: [...new Set(hexColors)], + }; + + // Try to extract logo URLs + const logoUrls = + response.match(/https?:\/\/[^\s<>"]+\.(png|svg|jpg|jpeg|webp)/gi) || + []; + const logos: BrandIdentity["logos"] = logoUrls.length + ? { + primary: logoUrls[0], + alternates: logoUrls.slice(1), + } + : undefined; + + // Extract description (first paragraph-like content) + const descMatch = response.match(/is\s+(?:a|an)\s+([^.]+\.)/i); + const description = descMatch ? descMatch[0] : undefined; + + const identity: BrandIdentity = { + name: brandName, + description, + colors, + logos, + sources: ["perplexity-research"], + confidence: hexColors.length > 0 ? "medium" : "low", + rawData: { research: response }, + }; + + return { + success: true, + identity, + research: response, + }; + } catch (error) { + return { + success: false, + error: `Research failed: ${error instanceof Error ? error.message : String(error)}`, + }; + } + }, + }); + +/** + * BRAND_DISCOVER - Combined scraping and research for complete brand identity + */ +export const createBrandDiscoverTool = (env: Env) => + createTool({ + id: "BRAND_DISCOVER", + description: `Comprehensive brand discovery combining web scraping and AI research. + +This is the most complete tool - it: +1. Scrapes the brand website for visual identity (if FIRECRAWL available) +2. Researches the brand using AI (if PERPLEXITY available) +3. Combines results into a complete brand identity + +**Best for:** Creating a complete brand profile with maximum information.`, + inputSchema: z.object({ + brandName: z.string().describe("Brand or company name"), + websiteUrl: z.string().url().describe("Brand website URL"), + }), + outputSchema: z.object({ + success: z.boolean(), + identity: BrandIdentitySchema.optional(), + scrapeData: z.unknown().optional(), + researchData: z.unknown().optional(), + error: z.string().optional(), + }), + execute: async ({ context }) => { + const { brandName, websiteUrl } = context; + + const firecrawl = env.MESH_REQUEST_CONTEXT?.state?.FIRECRAWL; + const perplexity = env.MESH_REQUEST_CONTEXT?.state?.PERPLEXITY; + + if (!firecrawl && !perplexity) { + return { + success: false, + error: + "No research bindings available. Configure FIRECRAWL and/or PERPLEXITY bindings.", + }; + } + + const identity: Partial = { + name: brandName, + sources: [], + confidence: "low", + }; + + let scrapeData: unknown; + let researchData: unknown; + + // Step 1: Scrape the website + if (firecrawl) { + try { + const scrapeResult = (await firecrawl.firecrawl_scrape({ + url: websiteUrl, + formats: ["branding", "links"], + })) as { branding?: Record; links?: string[] }; + + if (scrapeResult?.branding) { + scrapeData = scrapeResult.branding; + const branding = scrapeResult.branding; + + // Extract colors + if (branding.colors && typeof branding.colors === "object") { + const colors = branding.colors as Record; + identity.colors = { + primary: colors.primary || colors.main || "#000000", + secondary: colors.secondary, + accent: colors.accent, + background: colors.background, + text: colors.text, + palette: Object.values(colors).filter( + (c) => typeof c === "string" && c.startsWith("#"), + ), + }; + } + + // Extract logos + if (branding.logos) { + if (Array.isArray(branding.logos)) { + identity.logos = { + primary: branding.logos[0] as string, + alternates: branding.logos.slice(1) as string[], + }; + } else if (typeof branding.logos === "object") { + const logos = branding.logos as Record; + identity.logos = { + primary: logos.primary || logos.main, + light: logos.light, + dark: logos.dark, + icon: logos.icon, + }; + } + } + + // Extract typography + if (branding.fonts && typeof branding.fonts === "object") { + const fonts = branding.fonts as Record; + identity.typography = { + headingFont: fonts.heading || fonts.title, + bodyFont: fonts.body || fonts.text, + monoFont: fonts.mono, + }; + } + + identity.sources?.push(websiteUrl); + identity.confidence = identity.logos?.primary ? "high" : "medium"; + } + } catch (error) { + console.error("Scraping error:", error); + } + } + + // Step 2: Research the brand + if (perplexity) { + try { + const researchResult = (await perplexity.perplexity_research({ + messages: [ + { + role: "system", + content: + "You are a brand research expert. Provide concise, factual information about the brand.", + }, + { + role: "user", + content: `Tell me about ${brandName} (${websiteUrl}). Include: +1. Tagline or slogan +2. Industry and founding year +3. Brand colors (hex codes if known) +4. Brand personality and values +5. Any known brand guidelines or press kit URLs`, + }, + ], + strip_thinking: true, + })) as { response?: string }; + + if (researchResult?.response) { + researchData = researchResult.response; + const response = researchResult.response; + + // Extract tagline + const taglineMatch = response.match( + /(?:tagline|slogan)[:\s]+["']?([^"'\n.]+)["']?/i, + ); + if (taglineMatch) { + identity.tagline = taglineMatch[1].trim(); + } + + // Extract description + if (!identity.description) { + const descMatch = response.match(/is\s+(?:a|an)\s+([^.]+\.)/i); + if (descMatch) { + identity.description = descMatch[0]; + } + } + + // Extract any colors we missed + if ( + !identity.colors?.primary || + identity.colors.primary === "#000000" + ) { + const hexColors = response.match(/#[0-9A-Fa-f]{6}/g); + if (hexColors?.length) { + identity.colors = { + ...(identity.colors || {}), + primary: hexColors[0], + palette: [...new Set(hexColors)], + }; + } + } + + // Extract industry + const industryMatch = response.match( + /(?:industry|sector|space)[:\s]+([^.]+)/i, + ); + if (industryMatch) { + identity.industry = industryMatch[1].trim(); + } + + identity.sources?.push("perplexity-research"); + } + } catch (error) { + console.error("Research error:", error); + } + } + + // Determine final confidence + const hasLogo = Boolean(identity.logos?.primary); + const hasColors = Boolean( + identity.colors?.primary && identity.colors.primary !== "#000000", + ); + identity.confidence = + hasLogo && hasColors ? "high" : hasLogo || hasColors ? "medium" : "low"; + + return { + success: true, + identity: identity as BrandIdentity, + scrapeData, + researchData, + }; + }, + }); + +/** + * BRAND_STATUS - Check available research capabilities + */ +export const createBrandStatusTool = (env: Env) => + createTool({ + id: "BRAND_STATUS", + description: `Check which brand research capabilities are available. + +Returns the status of FIRECRAWL and PERPLEXITY bindings and what each enables.`, + inputSchema: z.object({}), + outputSchema: z.object({ + firecrawl: z.object({ + available: z.boolean(), + capabilities: z.array(z.string()), + }), + perplexity: z.object({ + available: z.boolean(), + capabilities: z.array(z.string()), + }), + recommendation: z.string(), + }), + execute: async () => { + const firecrawl = env.MESH_REQUEST_CONTEXT?.state?.FIRECRAWL; + const perplexity = env.MESH_REQUEST_CONTEXT?.state?.PERPLEXITY; + + return { + firecrawl: { + available: Boolean(firecrawl), + capabilities: firecrawl + ? [ + "Extract colors from website CSS", + "Identify typography and fonts", + "Find logo images", + "Capture visual style", + "Take screenshots", + ] + : [], + }, + perplexity: { + available: Boolean(perplexity), + capabilities: perplexity + ? [ + "Research brand history", + "Find brand guidelines", + "Discover logo URLs", + "Analyze brand voice", + "Find color palettes", + ] + : [], + }, + recommendation: + firecrawl && perplexity + ? "Full capabilities available! Use BRAND_DISCOVER for complete brand profiles." + : firecrawl + ? "Firecrawl available for website scraping. Add Perplexity for deeper research." + : perplexity + ? "Perplexity available for research. Add Firecrawl for direct website extraction." + : "No bindings configured. Add FIRECRAWL and/or PERPLEXITY bindings.", + }; + }, + }); + +export const researchTools = [ + createBrandScrapeTool, + createBrandResearchTool, + createBrandDiscoverTool, + createBrandStatusTool, +]; diff --git a/brand/server/types/env.ts b/brand/server/types/env.ts new file mode 100644 index 00000000..3582304d --- /dev/null +++ b/brand/server/types/env.ts @@ -0,0 +1,46 @@ +/** + * Type definitions for the Brand MCP environment. + * + * This MCP requires Perplexity and Firecrawl bindings for brand research. + */ +import { + BindingOf, + type DefaultEnv, + type BindingRegistry, +} from "@decocms/runtime"; +import { z } from "zod"; + +/** + * State schema with required bindings for brand research. + */ +export const StateSchema = z.object({ + /** + * Perplexity binding for web research. + * Used to search for brand information, logo URLs, and brand guidelines. + */ + PERPLEXITY: BindingOf("@deco/perplexity") + .optional() + .describe( + "Perplexity AI binding for brand research - searches for logos, brand colors, and brand information", + ), + + /** + * Firecrawl binding for web scraping. + * Used to extract brand identity (colors, fonts, typography) directly from websites. + */ + FIRECRAWL: BindingOf("@deco/firecrawl") + .optional() + .describe( + "Firecrawl binding for web scraping - extracts brand identity (colors, typography, logos) from websites", + ), +}); + +/** + * Registry type for the bindings. + */ +export type Registry = BindingRegistry; + +/** + * Environment type for the Brand MCP. + */ +export type Env = DefaultEnv; diff --git a/brand/tsconfig.json b/brand/tsconfig.json new file mode 100644 index 00000000..fc56183c --- /dev/null +++ b/brand/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true, + "noEmit": true, + "resolveJsonModule": true, + "isolatedModules": true, + "types": ["bun-types"] + }, + "include": ["server/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/bun.lock b/bun.lock index 3d82c157..116c619a 100644 --- a/bun.lock +++ b/bun.lock @@ -46,6 +46,20 @@ "typescript": "^5.7.2", }, }, + "brand": { + "name": "@decocms/brand", + "version": "1.0.0", + "dependencies": { + "@decocms/bindings": "^1.0.9", + "@decocms/runtime": "1.2.0", + "zod": "^4.0.0", + }, + "devDependencies": { + "@decocms/mcps-shared": "workspace:*", + "@modelcontextprotocol/sdk": "1.25.1", + "typescript": "^5.7.2", + }, + }, "content-scraper": { "name": "content-scraper", "version": "1.0.0", @@ -959,11 +973,13 @@ "@decocms/bindings": ["@decocms/bindings@1.0.9", "", { "dependencies": { "@modelcontextprotocol/sdk": "1.25.2", "@tanstack/react-router": "1.139.7", "react": "^19.2.0", "zod": "^4.0.0", "zod-from-json-schema": "^0.5.2" } }, "sha512-lwfuk7lZtqOUJVb7PFzfUo/eR0ZdS7vex5JAOAhM93IiIrHDakrTehKwl2WUq6Ks1P60MAUFtM5kJT+YQAq+oA=="], + "@decocms/brand": ["@decocms/brand@workspace:brand"], + "@decocms/mcps-shared": ["@decocms/mcps-shared@workspace:shared"], "@decocms/openrouter": ["@decocms/openrouter@workspace:openrouter"], - "@decocms/runtime": ["@decocms/runtime@1.2.2", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.0.7", "@modelcontextprotocol/sdk": "1.25.2", "hono": "^4.10.7", "jose": "^6.0.11", "zod": "^4.0.0" } }, "sha512-eloJk2HOFbrI5X7rw+ZyIW7sqmYbBUz5mvvBkLlmLCUlvwqsxv80ZNBqzqn01NT8kbbdhvumGeXF5nTX+H40rw=="], + "@decocms/runtime": ["@decocms/runtime@1.2.0", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.0.7", "@modelcontextprotocol/sdk": "1.25.1", "hono": "^4.10.7", "jose": "^6.0.11", "zod": "^4.0.0" } }, "sha512-HkhKBrCGYPlKyYW+iZxl7hmSwvzeJbBkFYE1+/0y5Ok1rlf/pxHHBHfiFlVgs1pr/seyFjEsqi2LnK8bMnDOEg=="], "@decocms/slides": ["@decocms/slides@workspace:slides"], @@ -2873,11 +2889,7 @@ "@decocms/bindings/@tanstack/react-router": ["@tanstack/react-router@1.139.7", "", { "dependencies": { "@tanstack/history": "1.139.0", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.139.7", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-5vhwIAwoxWl7oeIZRNgk5wh9TCkaAinK9qbfdKuKzwGtMHqnv1bRrfKwam3/MaMwHCmvnNfnFj0RYfnBA/ilEg=="], - "@decocms/openrouter/@decocms/runtime": ["@decocms/runtime@1.2.0", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.0.7", "@modelcontextprotocol/sdk": "1.25.1", "hono": "^4.10.7", "jose": "^6.0.11", "zod": "^4.0.0" } }, "sha512-HkhKBrCGYPlKyYW+iZxl7hmSwvzeJbBkFYE1+/0y5Ok1rlf/pxHHBHfiFlVgs1pr/seyFjEsqi2LnK8bMnDOEg=="], - - "@decocms/runtime/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.2", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww=="], - - "@decocms/slides/@decocms/runtime": ["@decocms/runtime@1.2.0", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.0.7", "@modelcontextprotocol/sdk": "1.25.1", "hono": "^4.10.7", "jose": "^6.0.11", "zod": "^4.0.0" } }, "sha512-HkhKBrCGYPlKyYW+iZxl7hmSwvzeJbBkFYE1+/0y5Ok1rlf/pxHHBHfiFlVgs1pr/seyFjEsqi2LnK8bMnDOEg=="], + "@decocms/mcps-shared/@decocms/runtime": ["@decocms/runtime@1.2.2", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.0.7", "@modelcontextprotocol/sdk": "1.25.2", "hono": "^4.10.7", "jose": "^6.0.11", "zod": "^4.0.0" } }, "sha512-eloJk2HOFbrI5X7rw+ZyIW7sqmYbBUz5mvvBkLlmLCUlvwqsxv80ZNBqzqn01NT8kbbdhvumGeXF5nTX+H40rw=="], "@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], @@ -3123,16 +3135,12 @@ "bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], - "blog-post-generator/@decocms/runtime": ["@decocms/runtime@1.2.0", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.0.7", "@modelcontextprotocol/sdk": "1.25.1", "hono": "^4.10.7", "jose": "^6.0.11", "zod": "^4.0.0" } }, "sha512-HkhKBrCGYPlKyYW+iZxl7hmSwvzeJbBkFYE1+/0y5Ok1rlf/pxHHBHfiFlVgs1pr/seyFjEsqi2LnK8bMnDOEg=="], - "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "cloudflare/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], "concurrently/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "content-scraper/@decocms/runtime": ["@decocms/runtime@1.2.0", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.0.7", "@modelcontextprotocol/sdk": "1.25.1", "hono": "^4.10.7", "jose": "^6.0.11", "zod": "^4.0.0" } }, "sha512-HkhKBrCGYPlKyYW+iZxl7hmSwvzeJbBkFYE1+/0y5Ok1rlf/pxHHBHfiFlVgs1pr/seyFjEsqi2LnK8bMnDOEg=="], - "content-scraper/deco-cli": ["deco-cli@0.26.0", "", { "dependencies": { "@deco-cx/warp-node": "0.3.16", "@modelcontextprotocol/sdk": "^1.19.1", "@supabase/ssr": "0.6.1", "@supabase/supabase-js": "2.50.0", "chalk": "^5.3.0", "commander": "^12.0.0", "glob": "^10.3.10", "ignore": "^7.0.5", "inquirer": "^9.2.15", "inquirer-search-checkbox": "^1.0.0", "inquirer-search-list": "^1.2.6", "jose": "^6.0.11", "json-schema-to-typescript": "^15.0.4", "object-hash": "^3.0.0", "prettier": "^3.6.2", "semver": "^7.6.0", "smol-toml": "^1.3.4", "ws": "^8.16.0", "zod": "^3.25.76" }, "bin": { "deco": "dist/cli.js", "deconfig": "dist/deconfig.js" } }, "sha512-fkYKYO81cK3NE4hb3zcPdMksKJiYM2mon0lKGBuvEOruVUfbhK0I7V777NZDrmaxVQXxDx0fa9i6fARjxT7muQ=="], "data-for-seo/@decocms/runtime": ["@decocms/runtime@0.24.0", "", { "dependencies": { "@cloudflare/workers-types": "^4.20250617.0", "@deco/mcp": "npm:@jsr/deco__mcp@0.5.5", "@mastra/cloudflare-d1": "^0.13.4", "@mastra/core": "^0.20.2", "@modelcontextprotocol/sdk": "^1.19.1", "bidc": "0.0.3", "drizzle-orm": "^0.44.5", "jose": "^6.0.11", "mime-db": "1.52.0", "zod": "^3.25.76", "zod-from-json-schema": "^0.0.5", "zod-to-json-schema": "^3.24.4" } }, "sha512-ZWa9z6I0dl4LtVnv3NUDvxuVYU0Aka1gpUEkpJP0tW2ETCGQkmDx50MdFqEksXiL1RHoNZuv45Fz8u9FkdTKJg=="], @@ -3151,12 +3159,8 @@ "deco-cli/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "deco-llm/@decocms/runtime": ["@decocms/runtime@1.2.0", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.0.7", "@modelcontextprotocol/sdk": "1.25.1", "hono": "^4.10.7", "jose": "^6.0.11", "zod": "^4.0.0" } }, "sha512-HkhKBrCGYPlKyYW+iZxl7hmSwvzeJbBkFYE1+/0y5Ok1rlf/pxHHBHfiFlVgs1pr/seyFjEsqi2LnK8bMnDOEg=="], - "defaults/clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], - "discord-read/@decocms/runtime": ["@decocms/runtime@1.2.0", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.0.7", "@modelcontextprotocol/sdk": "1.25.1", "hono": "^4.10.7", "jose": "^6.0.11", "zod": "^4.0.0" } }, "sha512-HkhKBrCGYPlKyYW+iZxl7hmSwvzeJbBkFYE1+/0y5Ok1rlf/pxHHBHfiFlVgs1pr/seyFjEsqi2LnK8bMnDOEg=="], - "external-editor/chardet": ["chardet@0.4.2", "", {}, "sha512-j/Toj7f1z98Hh2cYo2BVr85EpIRWqUi7rtRSGxh/cqUjqrnJe9l9UE7IUGd2vQ2p+kSHLkSzObQPZPLUC6TQwg=="], "external-editor/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], @@ -3173,27 +3177,9 @@ "gemini-pro-vision/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "github/@types/node": ["@types/node@22.19.7", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw=="], - - "google-big-query/@decocms/runtime": ["@decocms/runtime@1.2.0", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.0.7", "@modelcontextprotocol/sdk": "1.25.1", "hono": "^4.10.7", "jose": "^6.0.11", "zod": "^4.0.0" } }, "sha512-HkhKBrCGYPlKyYW+iZxl7hmSwvzeJbBkFYE1+/0y5Ok1rlf/pxHHBHfiFlVgs1pr/seyFjEsqi2LnK8bMnDOEg=="], + "github/@decocms/runtime": ["@decocms/runtime@1.2.2", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.0.7", "@modelcontextprotocol/sdk": "1.25.2", "hono": "^4.10.7", "jose": "^6.0.11", "zod": "^4.0.0" } }, "sha512-eloJk2HOFbrI5X7rw+ZyIW7sqmYbBUz5mvvBkLlmLCUlvwqsxv80ZNBqzqn01NT8kbbdhvumGeXF5nTX+H40rw=="], - "google-calendar/@decocms/runtime": ["@decocms/runtime@1.2.0", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.0.7", "@modelcontextprotocol/sdk": "1.25.1", "hono": "^4.10.7", "jose": "^6.0.11", "zod": "^4.0.0" } }, "sha512-HkhKBrCGYPlKyYW+iZxl7hmSwvzeJbBkFYE1+/0y5Ok1rlf/pxHHBHfiFlVgs1pr/seyFjEsqi2LnK8bMnDOEg=="], - - "google-docs/@decocms/runtime": ["@decocms/runtime@1.2.0", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.0.7", "@modelcontextprotocol/sdk": "1.25.1", "hono": "^4.10.7", "jose": "^6.0.11", "zod": "^4.0.0" } }, "sha512-HkhKBrCGYPlKyYW+iZxl7hmSwvzeJbBkFYE1+/0y5Ok1rlf/pxHHBHfiFlVgs1pr/seyFjEsqi2LnK8bMnDOEg=="], - - "google-drive/@decocms/runtime": ["@decocms/runtime@1.2.0", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.0.7", "@modelcontextprotocol/sdk": "1.25.1", "hono": "^4.10.7", "jose": "^6.0.11", "zod": "^4.0.0" } }, "sha512-HkhKBrCGYPlKyYW+iZxl7hmSwvzeJbBkFYE1+/0y5Ok1rlf/pxHHBHfiFlVgs1pr/seyFjEsqi2LnK8bMnDOEg=="], - - "google-forms/@decocms/runtime": ["@decocms/runtime@1.2.0", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.0.7", "@modelcontextprotocol/sdk": "1.25.1", "hono": "^4.10.7", "jose": "^6.0.11", "zod": "^4.0.0" } }, "sha512-HkhKBrCGYPlKyYW+iZxl7hmSwvzeJbBkFYE1+/0y5Ok1rlf/pxHHBHfiFlVgs1pr/seyFjEsqi2LnK8bMnDOEg=="], - - "google-gmail/@decocms/runtime": ["@decocms/runtime@1.2.0", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.0.7", "@modelcontextprotocol/sdk": "1.25.1", "hono": "^4.10.7", "jose": "^6.0.11", "zod": "^4.0.0" } }, "sha512-HkhKBrCGYPlKyYW+iZxl7hmSwvzeJbBkFYE1+/0y5Ok1rlf/pxHHBHfiFlVgs1pr/seyFjEsqi2LnK8bMnDOEg=="], - - "google-meet/@decocms/runtime": ["@decocms/runtime@1.2.0", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.0.7", "@modelcontextprotocol/sdk": "1.25.1", "hono": "^4.10.7", "jose": "^6.0.11", "zod": "^4.0.0" } }, "sha512-HkhKBrCGYPlKyYW+iZxl7hmSwvzeJbBkFYE1+/0y5Ok1rlf/pxHHBHfiFlVgs1pr/seyFjEsqi2LnK8bMnDOEg=="], - - "google-sheets/@decocms/runtime": ["@decocms/runtime@1.2.0", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.0.7", "@modelcontextprotocol/sdk": "1.25.1", "hono": "^4.10.7", "jose": "^6.0.11", "zod": "^4.0.0" } }, "sha512-HkhKBrCGYPlKyYW+iZxl7hmSwvzeJbBkFYE1+/0y5Ok1rlf/pxHHBHfiFlVgs1pr/seyFjEsqi2LnK8bMnDOEg=="], - - "google-slides/@decocms/runtime": ["@decocms/runtime@1.2.0", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.0.7", "@modelcontextprotocol/sdk": "1.25.1", "hono": "^4.10.7", "jose": "^6.0.11", "zod": "^4.0.0" } }, "sha512-HkhKBrCGYPlKyYW+iZxl7hmSwvzeJbBkFYE1+/0y5Ok1rlf/pxHHBHfiFlVgs1pr/seyFjEsqi2LnK8bMnDOEg=="], - - "google-tag-manager/@decocms/runtime": ["@decocms/runtime@1.2.0", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.0.7", "@modelcontextprotocol/sdk": "1.25.1", "hono": "^4.10.7", "jose": "^6.0.11", "zod": "^4.0.0" } }, "sha512-HkhKBrCGYPlKyYW+iZxl7hmSwvzeJbBkFYE1+/0y5Ok1rlf/pxHHBHfiFlVgs1pr/seyFjEsqi2LnK8bMnDOEg=="], + "github/@types/node": ["@types/node@22.19.7", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw=="], "grain/@decocms/bindings": ["@decocms/bindings@1.0.1-alpha.23", "", { "dependencies": { "@modelcontextprotocol/sdk": "1.20.2", "zod": "^3.25.76", "zod-from-json-schema": "^0.0.5" } }, "sha512-CVvfLeQPzD0RwkIznl4CPg8yLcdz5O2bgMmaL+lyHe+azO2qkGpQkTp1pX5fDhdr7jvWtjTcThUNoiCno58cSw=="], @@ -3203,8 +3189,6 @@ "grain/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "hyperdx/@decocms/runtime": ["@decocms/runtime@1.2.0", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.0.7", "@modelcontextprotocol/sdk": "1.25.1", "hono": "^4.10.7", "jose": "^6.0.11", "zod": "^4.0.0" } }, "sha512-HkhKBrCGYPlKyYW+iZxl7hmSwvzeJbBkFYE1+/0y5Ok1rlf/pxHHBHfiFlVgs1pr/seyFjEsqi2LnK8bMnDOEg=="], - "hyperdx/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "inquirer-search-checkbox/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], @@ -3217,8 +3201,6 @@ "log-symbols/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "mcp-studio/@decocms/runtime": ["@decocms/runtime@1.2.0", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.0.7", "@modelcontextprotocol/sdk": "1.25.1", "hono": "^4.10.7", "jose": "^6.0.11", "zod": "^4.0.0" } }, "sha512-HkhKBrCGYPlKyYW+iZxl7hmSwvzeJbBkFYE1+/0y5Ok1rlf/pxHHBHfiFlVgs1pr/seyFjEsqi2LnK8bMnDOEg=="], - "mcp-template-minimal/@decocms/runtime": ["@decocms/runtime@0.25.1", "", { "dependencies": { "@cloudflare/workers-types": "^4.20250617.0", "@deco/mcp": "npm:@jsr/deco__mcp@0.5.5", "@mastra/cloudflare-d1": "^0.13.4", "@mastra/core": "^0.20.2", "@modelcontextprotocol/sdk": "^1.19.1", "bidc": "0.0.3", "drizzle-orm": "^0.44.5", "jose": "^6.0.11", "mime-db": "1.52.0", "zod": "^3.25.76", "zod-from-json-schema": "^0.0.5", "zod-to-json-schema": "^3.24.4" } }, "sha512-G1J09NpHkuOcBQMPDi7zJDwtNweH33/39sOsR/mpA+sRWn2W3CX51FXeB5dp06oAmCe9BoBpYnyvb896hSQ+Jg=="], "mcp-template-minimal/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.20.2", "", { "dependencies": { "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-6rqTdFt67AAAzln3NOKsXRmv5ZzPkgbfaebKBqUbts7vK1GZudqnrun5a8d3M/h955cam9RHZ6Jb4Y1XhnmFPg=="], @@ -3231,8 +3213,6 @@ "mcp-template-with-view/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "meta-ads/@decocms/runtime": ["@decocms/runtime@1.2.0", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.0.7", "@modelcontextprotocol/sdk": "1.25.1", "hono": "^4.10.7", "jose": "^6.0.11", "zod": "^4.0.0" } }, "sha512-HkhKBrCGYPlKyYW+iZxl7hmSwvzeJbBkFYE1+/0y5Ok1rlf/pxHHBHfiFlVgs1pr/seyFjEsqi2LnK8bMnDOEg=="], - "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], @@ -3247,8 +3227,6 @@ "nanobanana/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "object-storage/@decocms/runtime": ["@decocms/runtime@1.2.0", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.0.7", "@modelcontextprotocol/sdk": "1.25.1", "hono": "^4.10.7", "jose": "^6.0.11", "zod": "^4.0.0" } }, "sha512-HkhKBrCGYPlKyYW+iZxl7hmSwvzeJbBkFYE1+/0y5Ok1rlf/pxHHBHfiFlVgs1pr/seyFjEsqi2LnK8bMnDOEg=="], - "ora/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], @@ -3283,8 +3261,6 @@ "reddit/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "registry/@decocms/runtime": ["@decocms/runtime@1.2.0", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.0.7", "@modelcontextprotocol/sdk": "1.25.1", "hono": "^4.10.7", "jose": "^6.0.11", "zod": "^4.0.0" } }, "sha512-HkhKBrCGYPlKyYW+iZxl7hmSwvzeJbBkFYE1+/0y5Ok1rlf/pxHHBHfiFlVgs1pr/seyFjEsqi2LnK8bMnDOEg=="], - "registry/@types/node": ["@types/node@22.19.7", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw=="], "replicate/@decocms/runtime": ["@decocms/runtime@0.25.1", "", { "dependencies": { "@cloudflare/workers-types": "^4.20250617.0", "@deco/mcp": "npm:@jsr/deco__mcp@0.5.5", "@mastra/cloudflare-d1": "^0.13.4", "@mastra/core": "^0.20.2", "@modelcontextprotocol/sdk": "^1.19.1", "bidc": "0.0.3", "drizzle-orm": "^0.44.5", "jose": "^6.0.11", "mime-db": "1.52.0", "zod": "^3.25.76", "zod-from-json-schema": "^0.0.5", "zod-to-json-schema": "^3.24.4" } }, "sha512-G1J09NpHkuOcBQMPDi7zJDwtNweH33/39sOsR/mpA+sRWn2W3CX51FXeB5dp06oAmCe9BoBpYnyvb896hSQ+Jg=="], @@ -3299,8 +3275,6 @@ "router/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], - "slack-mcp/@decocms/runtime": ["@decocms/runtime@1.2.0", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.0.7", "@modelcontextprotocol/sdk": "1.25.1", "hono": "^4.10.7", "jose": "^6.0.11", "zod": "^4.0.0" } }, "sha512-HkhKBrCGYPlKyYW+iZxl7hmSwvzeJbBkFYE1+/0y5Ok1rlf/pxHHBHfiFlVgs1pr/seyFjEsqi2LnK8bMnDOEg=="], - "sora/@decocms/runtime": ["@decocms/runtime@0.25.1", "", { "dependencies": { "@cloudflare/workers-types": "^4.20250617.0", "@deco/mcp": "npm:@jsr/deco__mcp@0.5.5", "@mastra/cloudflare-d1": "^0.13.4", "@mastra/core": "^0.20.2", "@modelcontextprotocol/sdk": "^1.19.1", "bidc": "0.0.3", "drizzle-orm": "^0.44.5", "jose": "^6.0.11", "mime-db": "1.52.0", "zod": "^3.25.76", "zod-from-json-schema": "^0.0.5", "zod-to-json-schema": "^3.24.4" } }, "sha512-G1J09NpHkuOcBQMPDi7zJDwtNweH33/39sOsR/mpA+sRWn2W3CX51FXeB5dp06oAmCe9BoBpYnyvb896hSQ+Jg=="], "sora/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.20.2", "", { "dependencies": { "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-6rqTdFt67AAAzln3NOKsXRmv5ZzPkgbfaebKBqUbts7vK1GZudqnrun5a8d3M/h955cam9RHZ6Jb4Y1XhnmFPg=="], @@ -3311,16 +3285,12 @@ "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], - "tiktok-ads/@decocms/runtime": ["@decocms/runtime@1.2.0", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.0.7", "@modelcontextprotocol/sdk": "1.25.1", "hono": "^4.10.7", "jose": "^6.0.11", "zod": "^4.0.0" } }, "sha512-HkhKBrCGYPlKyYW+iZxl7hmSwvzeJbBkFYE1+/0y5Ok1rlf/pxHHBHfiFlVgs1pr/seyFjEsqi2LnK8bMnDOEg=="], - "veo/@decocms/runtime": ["@decocms/runtime@0.25.1", "", { "dependencies": { "@cloudflare/workers-types": "^4.20250617.0", "@deco/mcp": "npm:@jsr/deco__mcp@0.5.5", "@mastra/cloudflare-d1": "^0.13.4", "@mastra/core": "^0.20.2", "@modelcontextprotocol/sdk": "^1.19.1", "bidc": "0.0.3", "drizzle-orm": "^0.44.5", "jose": "^6.0.11", "mime-db": "1.52.0", "zod": "^3.25.76", "zod-from-json-schema": "^0.0.5", "zod-to-json-schema": "^3.24.4" } }, "sha512-G1J09NpHkuOcBQMPDi7zJDwtNweH33/39sOsR/mpA+sRWn2W3CX51FXeB5dp06oAmCe9BoBpYnyvb896hSQ+Jg=="], "veo/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.20.2", "", { "dependencies": { "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-6rqTdFt67AAAzln3NOKsXRmv5ZzPkgbfaebKBqUbts7vK1GZudqnrun5a8d3M/h955cam9RHZ6Jb4Y1XhnmFPg=="], "veo/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "whatsappagent/@decocms/runtime": ["@decocms/runtime@1.2.0", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.0.7", "@modelcontextprotocol/sdk": "1.25.1", "hono": "^4.10.7", "jose": "^6.0.11", "zod": "^4.0.0" } }, "sha512-HkhKBrCGYPlKyYW+iZxl7hmSwvzeJbBkFYE1+/0y5Ok1rlf/pxHHBHfiFlVgs1pr/seyFjEsqi2LnK8bMnDOEg=="], - "whatsappagent/deco-cli": ["deco-cli@0.28.5", "", { "dependencies": { "@deco-cx/warp-node": "0.3.16", "@modelcontextprotocol/sdk": "1.20.2", "@supabase/ssr": "0.6.1", "@supabase/supabase-js": "2.50.0", "chalk": "^5.3.0", "commander": "^12.0.0", "glob": "^10.3.10", "ignore": "^7.0.5", "inquirer": "^9.2.15", "inquirer-search-checkbox": "^1.0.0", "inquirer-search-list": "^1.2.6", "jose": "^6.0.11", "json-schema-to-typescript": "^15.0.4", "object-hash": "^3.0.0", "prettier": "^3.6.2", "semver": "^7.6.0", "smol-toml": "^1.3.4", "zod": "^3.25.76" }, "bin": { "deco": "dist/cli.js", "deconfig": "dist/deconfig.js" } }, "sha512-DDzOPKrvMhoS6lu9u5nM8bP7LABClh8RKsVa6wHY+I6PUOtjKuk/mAgxJOt1uO9q2Ku9sgPle9FOyE/crM0Iqg=="], "whisper/@decocms/runtime": ["@decocms/runtime@0.24.0", "", { "dependencies": { "@cloudflare/workers-types": "^4.20250617.0", "@deco/mcp": "npm:@jsr/deco__mcp@0.5.5", "@mastra/cloudflare-d1": "^0.13.4", "@mastra/core": "^0.20.2", "@modelcontextprotocol/sdk": "^1.19.1", "bidc": "0.0.3", "drizzle-orm": "^0.44.5", "jose": "^6.0.11", "mime-db": "1.52.0", "zod": "^3.25.76", "zod-from-json-schema": "^0.0.5", "zod-to-json-schema": "^3.24.4" } }, "sha512-ZWa9z6I0dl4LtVnv3NUDvxuVYU0Aka1gpUEkpJP0tW2ETCGQkmDx50MdFqEksXiL1RHoNZuv45Fz8u9FkdTKJg=="], @@ -3377,6 +3347,8 @@ "@decocms/bindings/@tanstack/react-router/@tanstack/router-core": ["@tanstack/router-core@1.139.7", "", { "dependencies": { "@tanstack/history": "1.139.0", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.0", "seroval-plugins": "^1.4.0", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-mqgsJi4/B2Jo6PXRUs1AsWA+06nqiqVZe1aXioA3vR6PesNeKUSXWfmIoYF6wOx3osiV0BnwB1JCBrInCOQSWA=="], + "@decocms/mcps-shared/@decocms/runtime/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.2", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww=="], + "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], @@ -3517,6 +3489,8 @@ "gemini-pro-vision/@modelcontextprotocol/sdk/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + "github/@decocms/runtime/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.2", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww=="], + "github/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "grain/@decocms/bindings/zod-from-json-schema": ["zod-from-json-schema@0.0.5", "", { "dependencies": { "zod": "^3.24.2" } }, "sha512-zYEoo86M1qpA1Pq6329oSyHLS785z/mTwfr9V1Xf/ZLhuuBGaMlDGu/pDVGVUe4H4oa1EFgWZT53DP0U3oT9CQ=="], @@ -3529,8 +3503,6 @@ "grain/@modelcontextprotocol/sdk/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], - "hyperdx/@decocms/runtime/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], - "inquirer-search-checkbox/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], "inquirer-search-checkbox/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], diff --git a/package.json b/package.json index c073f1c6..41475261 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "workspaces": [ "apify", "blog-post-generator", + "brand", "content-scraper", "data-for-seo", "datajud", From 472e60eb7ae4ceaf1fe0114b3781bb77faba8ed9 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sun, 25 Jan 2026 10:28:40 -0300 Subject: [PATCH 05/20] fix(brand): Improve server logging and set port to 8003 - Add detailed startup logs with tool and resource counts - Log incoming requests (tool calls, MCP methods) - Log response times for slow requests - Display available tools and MCP Apps resources - Copy MCP URL to clipboard on startup - Set default port to 8003 --- brand/server/main.ts | 86 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 84 insertions(+), 2 deletions(-) diff --git a/brand/server/main.ts b/brand/server/main.ts index 36be938f..580ee742 100644 --- a/brand/server/main.ts +++ b/brand/server/main.ts @@ -16,7 +16,6 @@ * - **FIRECRAWL** - For website scraping and brand extraction * - **PERPLEXITY** - For AI-powered brand research */ -import { serve } from "@decocms/mcps-shared/serve"; import { withRuntime } from "@decocms/runtime"; import { tools } from "./tools/index.ts"; import { resources } from "./resources/index.ts"; @@ -24,6 +23,13 @@ import { StateSchema, type Env, type Registry } from "./types/env.ts"; export { StateSchema }; +const PORT = process.env.PORT || 8003; + +console.log("[brand-mcp] Starting server..."); +console.log("[brand-mcp] Port:", PORT); +console.log("[brand-mcp] Tools count:", tools.length); +console.log("[brand-mcp] Resources count:", resources.length); + const runtime = withRuntime({ configuration: { scopes: ["PERPLEXITY::*", "FIRECRAWL::*"], @@ -34,4 +40,80 @@ const runtime = withRuntime({ resources, }); -serve(runtime.fetch); +console.log("[brand-mcp] Runtime initialized"); + +/** + * Fetch handler with logging + */ +const fetchWithLogging = async (req: Request): Promise => { + const url = new URL(req.url); + const startTime = Date.now(); + + // Log incoming request + if (req.method === "POST" && url.pathname === "/mcp") { + try { + const body = await req.clone().json(); + const method = body?.method || "unknown"; + const toolName = body?.params?.name; + + if (method === "tools/call" && toolName) { + console.log(`[brand-mcp] 🔧 Tool call: ${toolName}`); + } else if (method !== "unknown") { + console.log(`[brand-mcp] 📨 Request: ${method}`); + } + } catch { + // Ignore JSON parse errors + } + } + + // Call the runtime + const response = await runtime.fetch(req); + + // Log response time for tool calls + const duration = Date.now() - startTime; + if (duration > 100) { + console.log(`[brand-mcp] ⏱️ Response in ${duration}ms`); + } + + return response; +}; + +// Start the server +Bun.serve({ + port: PORT, + hostname: "0.0.0.0", + idleTimeout: 0, // Required for SSE + fetch: fetchWithLogging, + development: process.env.NODE_ENV !== "production", +}); + +console.log(""); +console.log("🎨 Brand MCP running at: http://localhost:" + PORT + "/mcp"); +console.log(""); +console.log("[brand-mcp] Available tools:"); +console.log(" - BRAND_SCRAPE - Extract brand identity from websites"); +console.log(" - BRAND_RESEARCH - Deep research using Perplexity AI"); +console.log(" - BRAND_DISCOVER - Combined scraping + research"); +console.log(" - BRAND_STATUS - Check available capabilities"); +console.log(" - BRAND_GENERATE - Generate design system from identity"); +console.log(" - BRAND_CREATE - Full workflow: discover + generate"); +console.log(""); +console.log("[brand-mcp] MCP Apps (UI Resources):"); +console.log(" - ui://brand-preview - Interactive brand preview"); +console.log(" - ui://brand-list - Grid view of brands"); +console.log(""); +console.log("[brand-mcp] Required bindings: FIRECRAWL, PERPLEXITY"); + +// Copy URL to clipboard on macOS +if (process.platform === "darwin") { + try { + const proc = Bun.spawn(["pbcopy"], { + stdin: "pipe", + }); + proc.stdin.write(`http://localhost:${PORT}/mcp`); + proc.stdin.end(); + console.log("[brand-mcp] 📋 MCP URL copied to clipboard!"); + } catch { + // Ignore clipboard errors + } +} From ed67b7dcb0346516426c0fbbd85a8aa959301e33 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sun, 25 Jan 2026 10:30:11 -0300 Subject: [PATCH 06/20] fix(slides): Improve server logging and startup messages - Add detailed startup logs with tool, prompt, and resource counts - Log incoming requests (tool calls, MCP methods) - Log response times for slow requests - Display available tools and MCP Apps resources - Copy MCP URL to clipboard on startup --- slides/server/main.ts | 99 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 97 insertions(+), 2 deletions(-) diff --git a/slides/server/main.ts b/slides/server/main.ts index 3676f990..e52c7c25 100644 --- a/slides/server/main.ts +++ b/slides/server/main.ts @@ -1,7 +1,16 @@ /** * Slides MCP - AI-Powered Presentation Builder + * + * Create beautiful, animated slide decks through natural conversation. + * + * ## Features + * + * - **Brand-Aware Design Systems** - Create reusable design systems with brand colors, typography, and logos + * - **Multiple Slide Layouts** - Title, content, stats, two-column, list, quote, image, and custom + * - **Automatic Brand Research** - Optionally integrate Perplexity and Firecrawl for brand discovery + * - **MCP Apps UI** - Interactive slide viewer and design system preview + * - **JSX + Babel** - Modern component-based slides with browser-side transpilation */ -import { serve } from "@decocms/mcps-shared/serve"; import { withRuntime } from "@decocms/runtime"; import { tools } from "./tools/index.ts"; import { prompts } from "./prompts.ts"; @@ -10,6 +19,14 @@ import { StateSchema, type Env, type Registry } from "./types/env.ts"; export { StateSchema }; +const PORT = process.env.PORT || 8001; + +console.log("[slides-mcp] Starting server..."); +console.log("[slides-mcp] Port:", PORT); +console.log("[slides-mcp] Tools count:", tools.length); +console.log("[slides-mcp] Prompts count:", prompts.length); +console.log("[slides-mcp] Resources count:", resources.length); + const runtime = withRuntime({ configuration: { scopes: ["PERPLEXITY::*", "FIRECRAWL::*"], @@ -20,4 +37,82 @@ const runtime = withRuntime({ resources, }); -serve(runtime.fetch); +console.log("[slides-mcp] Runtime initialized"); + +/** + * Fetch handler with logging + */ +const fetchWithLogging = async (req: Request): Promise => { + const url = new URL(req.url); + const startTime = Date.now(); + + // Log incoming request + if (req.method === "POST" && url.pathname === "/mcp") { + try { + const body = await req.clone().json(); + const method = body?.method || "unknown"; + const toolName = body?.params?.name; + + if (method === "tools/call" && toolName) { + console.log(`[slides-mcp] 🔧 Tool call: ${toolName}`); + } else if (method !== "unknown") { + console.log(`[slides-mcp] 📨 Request: ${method}`); + } + } catch { + // Ignore JSON parse errors + } + } + + // Call the runtime + const response = await runtime.fetch(req); + + // Log response time for tool calls + const duration = Date.now() - startTime; + if (duration > 100) { + console.log(`[slides-mcp] ⏱️ Response in ${duration}ms`); + } + + return response; +}; + +// Start the server +Bun.serve({ + port: PORT, + hostname: "0.0.0.0", + idleTimeout: 0, // Required for SSE + fetch: fetchWithLogging, + development: process.env.NODE_ENV !== "production", +}); + +console.log(""); +console.log("🎯 Slides MCP running at: http://localhost:" + PORT + "/mcp"); +console.log(""); +console.log("[slides-mcp] Available tools:"); +console.log(" - DECK_INIT - Initialize a new presentation"); +console.log(" - DECK_GET - Get current deck state"); +console.log(" - SLIDE_CREATE - Add slides to presentation"); +console.log(" - SLIDE_UPDATE - Modify existing slides"); +console.log(" - SLIDE_DELETE - Remove slides"); +console.log(" - SLIDES_PREVIEW - Preview multiple slides"); +console.log(" - BRAND_RESEARCH - Research brand identity (optional)"); +console.log(""); +console.log("[slides-mcp] MCP Apps (UI Resources):"); +console.log(" - ui://slides-viewer - Full presentation viewer"); +console.log(" - ui://design-system - Brand design system preview"); +console.log(" - ui://slide - Single slide preview"); +console.log(""); +console.log("[slides-mcp] Optional bindings: FIRECRAWL, PERPLEXITY"); + +// Copy URL to clipboard on macOS +if (process.platform === "darwin") { + try { + const proc = Bun.spawn(["pbcopy"], { + stdin: "pipe", + }); + proc.stdin.write(`http://localhost:${PORT}/mcp`); + proc.stdin.end(); + console.log("[slides-mcp] 📋 MCP URL copied to clipboard!"); + } catch { + // Ignore clipboard errors + } +} From 5f4805f35ddf97e880fa19d86027b054a6e71bc0 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sun, 25 Jan 2026 10:36:54 -0300 Subject: [PATCH 07/20] fix(brand,slides): Use inline binding schemas for Perplexity/Firecrawl The bindings now include __binding property with tool definitions, allowing Mesh admin to correctly match connections by tool names: - PERPLEXITY: Matches perplexity_research, perplexity_ask, perplexity_search - FIRECRAWL: Matches firecrawl_scrape, firecrawl_crawl, firecrawl_search This fixes the "No connections found" issue in the Mesh admin binding selectors. --- brand/server/types/env.ts | 112 ++++++++++++++++++++++++++++++++----- slides/server/types/env.ts | 104 ++++++++++++++++++++++++++++++---- 2 files changed, 192 insertions(+), 24 deletions(-) diff --git a/brand/server/types/env.ts b/brand/server/types/env.ts index 3582304d..3949c988 100644 --- a/brand/server/types/env.ts +++ b/brand/server/types/env.ts @@ -3,36 +3,122 @@ * * This MCP requires Perplexity and Firecrawl bindings for brand research. */ -import { - BindingOf, - type DefaultEnv, - type BindingRegistry, -} from "@decocms/runtime"; +import { type DefaultEnv, type BindingRegistry } from "@decocms/runtime"; import { z } from "zod"; +/** + * Perplexity binding schema - defines the tools to match + */ +const PerplexityBindingSchema = [ + { + name: "perplexity_research", + inputSchema: { + type: "object", + properties: { + messages: { type: "array" }, + strip_thinking: { type: "boolean" }, + }, + required: ["messages"], + }, + }, + { + name: "perplexity_ask", + inputSchema: { + type: "object", + properties: { + messages: { type: "array" }, + }, + required: ["messages"], + }, + }, + { + name: "perplexity_search", + inputSchema: { + type: "object", + properties: { + query: { type: "string" }, + }, + required: ["query"], + }, + }, +] as const; + +/** + * Firecrawl binding schema - defines the tools to match + */ +const FirecrawlBindingSchema = [ + { + name: "firecrawl_scrape", + inputSchema: { + type: "object", + properties: { + url: { type: "string" }, + formats: { type: "array" }, + }, + required: ["url"], + }, + }, + { + name: "firecrawl_crawl", + inputSchema: { + type: "object", + properties: { + url: { type: "string" }, + }, + required: ["url"], + }, + }, + { + name: "firecrawl_search", + inputSchema: { + type: "object", + properties: { + query: { type: "string" }, + }, + required: ["query"], + }, + }, +] as const; + /** * State schema with required bindings for brand research. + * + * The bindings use __binding to define tool patterns for matching connections: + * - PERPLEXITY: Matches connections with perplexity_* tools + * - FIRECRAWL: Matches connections with firecrawl_* tools */ export const StateSchema = z.object({ /** * Perplexity binding for web research. - * Used to search for brand information, logo URLs, and brand guidelines. + * Matches connections with tools: perplexity_research, perplexity_ask, etc. */ - PERPLEXITY: BindingOf("@deco/perplexity") + PERPLEXITY: z + .object({ + __type: z.literal("@deco/perplexity").default("@deco/perplexity"), + __binding: z + .literal(PerplexityBindingSchema) + .default(PerplexityBindingSchema), + value: z.string(), + }) .optional() .describe( - "Perplexity AI binding for brand research - searches for logos, brand colors, and brand information", + "Perplexity AI for brand research - requires perplexity_research tool", ), /** * Firecrawl binding for web scraping. - * Used to extract brand identity (colors, fonts, typography) directly from websites. + * Matches connections with tools: firecrawl_scrape, firecrawl_crawl, etc. */ - FIRECRAWL: BindingOf("@deco/firecrawl") + FIRECRAWL: z + .object({ + __type: z.literal("@deco/firecrawl").default("@deco/firecrawl"), + __binding: z + .literal(FirecrawlBindingSchema) + .default(FirecrawlBindingSchema), + value: z.string(), + }) .optional() - .describe( - "Firecrawl binding for web scraping - extracts brand identity (colors, typography, logos) from websites", - ), + .describe("Firecrawl for web scraping - requires firecrawl_scrape tool"), }); /** diff --git a/slides/server/types/env.ts b/slides/server/types/env.ts index cd5a8d37..be1436f6 100644 --- a/slides/server/types/env.ts +++ b/slides/server/types/env.ts @@ -4,13 +4,83 @@ * This file defines the state schema including optional bindings * for external services like Perplexity (research) and Firecrawl (web scraping). */ -import { - BindingOf, - type DefaultEnv, - type BindingRegistry, -} from "@decocms/runtime"; +import { type DefaultEnv, type BindingRegistry } from "@decocms/runtime"; import { z } from "zod"; +/** + * Perplexity binding schema - defines the tools to match + */ +const PerplexityBindingSchema = [ + { + name: "perplexity_research", + inputSchema: { + type: "object", + properties: { + messages: { type: "array" }, + strip_thinking: { type: "boolean" }, + }, + required: ["messages"], + }, + }, + { + name: "perplexity_ask", + inputSchema: { + type: "object", + properties: { + messages: { type: "array" }, + }, + required: ["messages"], + }, + }, + { + name: "perplexity_search", + inputSchema: { + type: "object", + properties: { + query: { type: "string" }, + }, + required: ["query"], + }, + }, +] as const; + +/** + * Firecrawl binding schema - defines the tools to match + */ +const FirecrawlBindingSchema = [ + { + name: "firecrawl_scrape", + inputSchema: { + type: "object", + properties: { + url: { type: "string" }, + formats: { type: "array" }, + }, + required: ["url"], + }, + }, + { + name: "firecrawl_crawl", + inputSchema: { + type: "object", + properties: { + url: { type: "string" }, + }, + required: ["url"], + }, + }, + { + name: "firecrawl_search", + inputSchema: { + type: "object", + properties: { + query: { type: "string" }, + }, + required: ["query"], + }, + }, +] as const; + /** * State schema with optional bindings for brand research. * @@ -23,21 +93,33 @@ export const StateSchema = z.object({ * Optional Perplexity binding for web research. * Used to search for brand information, logo URLs, and brand guidelines. */ - PERPLEXITY: BindingOf("@deco/perplexity") + PERPLEXITY: z + .object({ + __type: z.literal("@deco/perplexity").default("@deco/perplexity"), + __binding: z + .literal(PerplexityBindingSchema) + .default(PerplexityBindingSchema), + value: z.string(), + }) .optional() .describe( - "Perplexity AI binding for brand research - searches for logos, brand colors, and brand information", + "Perplexity AI for brand research - requires perplexity_research tool", ), /** * Optional Firecrawl binding for web scraping. * Used to extract brand identity (colors, fonts, typography) directly from websites. */ - FIRECRAWL: BindingOf("@deco/firecrawl") + FIRECRAWL: z + .object({ + __type: z.literal("@deco/firecrawl").default("@deco/firecrawl"), + __binding: z + .literal(FirecrawlBindingSchema) + .default(FirecrawlBindingSchema), + value: z.string(), + }) .optional() - .describe( - "Firecrawl binding for web scraping - extracts brand identity (colors, typography, logos) from websites", - ), + .describe("Firecrawl for web scraping - requires firecrawl_scrape tool"), }); /** From 1a73ec22fac819d31b507208329fe6e5de4ed20f Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sun, 25 Jan 2026 10:39:55 -0300 Subject: [PATCH 08/20] fix(slides): Change default port to 8004 --- brand/server/tools/generator-utils.ts | 266 +++++++++++++ brand/server/tools/index.ts | 5 +- brand/server/tools/projects.ts | 534 ++++++++++++++++++++++++++ slides/server/main.ts | 2 +- 4 files changed, 805 insertions(+), 2 deletions(-) create mode 100644 brand/server/tools/generator-utils.ts create mode 100644 brand/server/tools/projects.ts diff --git a/brand/server/tools/generator-utils.ts b/brand/server/tools/generator-utils.ts new file mode 100644 index 00000000..3f71e34a --- /dev/null +++ b/brand/server/tools/generator-utils.ts @@ -0,0 +1,266 @@ +/** + * Design System Generator Utilities + * + * Pure functions for generating CSS, JSX, and style guides from brand identity. + */ +import type { BrandIdentity } from "./research.ts"; + +// Helper functions +export function lightenColor(hex: string, percent: number): string { + const num = parseInt(hex.replace("#", ""), 16); + const amt = Math.round(2.55 * percent); + const R = Math.min(255, (num >> 16) + amt); + const G = Math.min(255, ((num >> 8) & 0x00ff) + amt); + const B = Math.min(255, (num & 0x0000ff) + amt); + return `#${((1 << 24) + (R << 16) + (G << 8) + B).toString(16).slice(1)}`; +} + +export function darkenColor(hex: string, percent: number): string { + const num = parseInt(hex.replace("#", ""), 16); + const amt = Math.round(2.55 * percent); + const R = Math.max(0, (num >> 16) - amt); + const G = Math.max(0, ((num >> 8) & 0x00ff) - amt); + const B = Math.max(0, (num & 0x0000ff) - amt); + return `#${((1 << 24) + (R << 16) + (G << 8) + B).toString(16).slice(1)}`; +} + +/** + * Generate CSS variables from brand identity + */ +export function generateCSSVariables(identity: BrandIdentity): string { + const colors = identity.colors || { primary: "#8B5CF6" }; + const typography = identity.typography || {}; + + return `/** + * ${identity.name} Design System + * Generated by Brand MCP + */ + +:root { + /* Primary Colors */ + --brand-primary: ${colors.primary}; + --brand-primary-light: ${lightenColor(colors.primary, 20)}; + --brand-primary-dark: ${darkenColor(colors.primary, 20)}; + + /* Secondary Colors */ + --brand-secondary: ${colors.secondary || colors.primary}; + --brand-accent: ${colors.accent || colors.primary}; + + /* Background Colors */ + --bg-dark: ${colors.background || "#1a1a1a"}; + --bg-light: #FFFFFF; + --bg-gray: #F5F5F5; + + /* Text Colors */ + --text-primary: ${colors.text || "#1A1A1A"}; + --text-secondary: #6B7280; + --text-light: #FFFFFF; + --text-muted: #9CA3AF; + + /* Typography */ + --font-heading: ${typography.headingFont || "'Inter', system-ui, sans-serif"}; + --font-body: ${typography.bodyFont || "'Inter', system-ui, sans-serif"}; + --font-mono: ${typography.monoFont || "'JetBrains Mono', monospace"}; + + /* Spacing */ + --spacing-xs: 0.25rem; + --spacing-sm: 0.5rem; + --spacing-md: 1rem; + --spacing-lg: 1.5rem; + --spacing-xl: 2rem; + --spacing-2xl: 3rem; + + /* Border Radius */ + --radius-sm: 0.25rem; + --radius-md: 0.5rem; + --radius-lg: 1rem; + --radius-full: 9999px; + + /* Shadows */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1); +} + +/* Dark Mode */ +[data-theme="dark"] { + --bg-dark: #0f0f0f; + --bg-light: #1a1a1a; + --bg-gray: #2a2a2a; + --text-primary: #FFFFFF; + --text-secondary: #9CA3AF; +} +`; +} + +/** + * Generate JSX design system components + */ +export function generateDesignSystemJSX(identity: BrandIdentity): string { + const colors = identity.colors || { primary: "#8B5CF6" }; + const logos = identity.logos || {}; + const typography = identity.typography || {}; + + return `/** + * ${identity.name} Design System + * + * Auto-generated design system with brand components. + */ + +const BRAND = { + name: "${identity.name}", + tagline: "${identity.tagline || ""}", + + colors: { + primary: "${colors.primary}", + primaryLight: "${lightenColor(colors.primary, 20)}", + primaryDark: "${darkenColor(colors.primary, 20)}", + secondary: "${colors.secondary || colors.primary}", + accent: "${colors.accent || colors.primary}", + background: "${colors.background || "#1a1a1a"}", + text: "${colors.text || "#1A1A1A"}", + textLight: "#FFFFFF", + textMuted: "#6B7280", + }, + + logos: { + primary: ${logos.primary ? `"${logos.primary}"` : "null"}, + light: ${logos.light ? `"${logos.light}"` : "null"}, + dark: ${logos.dark ? `"${logos.dark}"` : "null"}, + icon: ${logos.icon ? `"${logos.icon}"` : "null"}, + }, + + typography: { + heading: "${typography.headingFont || "Inter, system-ui, sans-serif"}", + body: "${typography.bodyFont || "Inter, system-ui, sans-serif"}", + mono: "${typography.monoFont || "JetBrains Mono, monospace"}", + }, + + hasImageLogo: ${Boolean(logos.primary)}, +}; + +function BrandLogo({ variant = "primary", height = 40 }) { + const logoUrl = variant === "light" ? BRAND.logos.light : + variant === "dark" ? BRAND.logos.dark : + BRAND.logos.primary; + + if (logoUrl) { + return {BRAND.name}; + } + + return ( + + {BRAND.name} + + ); +} + +function Heading({ level = 1, children, color = "primary" }) { + const Tag = \`h\${level}\`; + const sizes = { 1: "3rem", 2: "2.25rem", 3: "1.875rem", 4: "1.5rem" }; + const colors = { primary: BRAND.colors.text, brand: BRAND.colors.primary, light: "#FFFFFF" }; + + return {children}; +} + +function Button({ variant = "primary", children }) { + const styles = { + primary: { backgroundColor: BRAND.colors.primary, color: "#FFFFFF" }, + secondary: { backgroundColor: "transparent", color: BRAND.colors.primary, border: \`2px solid \${BRAND.colors.primary}\` }, + }; + + return ; +} + +export { BRAND, BrandLogo, Heading, Button }; +`; +} + +/** + * Generate style guide markdown + */ +export function generateStyleGuide(identity: BrandIdentity): string { + const colors = identity.colors || { primary: "#8B5CF6" }; + const typography = identity.typography || {}; + const voice = identity.voice || {}; + + return `# ${identity.name} Brand Style Guide + +${identity.description ? `> ${identity.description}` : ""} +${identity.tagline ? `**Tagline:** "${identity.tagline}"` : ""} + +--- + +## Color Palette + +### Primary Colors + +| Color | Hex | Usage | +|-------|-----|-------| +| Primary | \`${colors.primary}\` | Main brand color, CTAs, links | +| Primary Light | \`${lightenColor(colors.primary, 20)}\` | Hover states, backgrounds | +| Primary Dark | \`${darkenColor(colors.primary, 20)}\` | Active states, emphasis | + +${ + colors.secondary + ? `### Secondary Colors + +| Color | Hex | Usage | +|-------|-----|-------| +| Secondary | \`${colors.secondary}\` | Supporting elements | +${colors.accent ? `| Accent | \`${colors.accent}\` | Highlights, notifications |` : ""}` + : "" +} + +--- + +## Typography + +### Font Families + +- **Headings:** ${typography.headingFont || "Inter, system-ui, sans-serif"} +- **Body:** ${typography.bodyFont || "Inter, system-ui, sans-serif"} +${typography.monoFont ? `- **Monospace:** ${typography.monoFont}` : ""} + +### Type Scale + +| Element | Size | Weight | +|---------|------|--------| +| H1 | 3rem (48px) | Bold (700) | +| H2 | 2.25rem (36px) | Bold (700) | +| H3 | 1.875rem (30px) | Semibold (600) | +| Body | 1rem (16px) | Regular (400) | + +--- + +## Logo Usage + +${ + identity.logos?.primary + ? `### Primary Logo + +![${identity.name} Logo](${identity.logos.primary}) + +- Use on light backgrounds +- Maintain clear space equal to the height of the logo` + : `### Logo + +*Logo URL not available. Please provide logo assets.*` +} + +--- + +${ + voice.tone || voice.personality?.length + ? `## Brand Voice + +${voice.tone ? `**Tone:** ${voice.tone}` : ""} +${voice.personality?.length ? `**Personality:** ${voice.personality.join(", ")}` : ""} + +---` + : "" +} + +*Generated by Brand MCP* +`; +} diff --git a/brand/server/tools/index.ts b/brand/server/tools/index.ts index 0674db8c..aa5ad4d8 100644 --- a/brand/server/tools/index.ts +++ b/brand/server/tools/index.ts @@ -5,9 +5,12 @@ */ import { researchTools } from "./research.ts"; import { generatorTools } from "./generator.ts"; +import { projectTools } from "./projects.ts"; -export const tools = [...researchTools, ...generatorTools]; +export const tools = [...projectTools, ...researchTools, ...generatorTools]; export { researchTools } from "./research.ts"; export { generatorTools } from "./generator.ts"; +export { projectTools } from "./projects.ts"; export { BrandIdentitySchema, type BrandIdentity } from "./research.ts"; +export { BrandProjectSchema, type BrandProject } from "./projects.ts"; diff --git a/brand/server/tools/projects.ts b/brand/server/tools/projects.ts new file mode 100644 index 00000000..81e979ad --- /dev/null +++ b/brand/server/tools/projects.ts @@ -0,0 +1,534 @@ +/** + * Brand Project Management Tools + * + * CRUD operations for brand projects with state persistence. + * Projects are saved immediately when created and can be resumed. + */ +import { createTool } from "@decocms/runtime/tools"; +import { z } from "zod"; +import type { Env } from "../types/env.ts"; +import { BrandIdentitySchema, type BrandIdentity } from "./research.ts"; + +/** + * Project status enum + */ +const ProjectStatusSchema = z.enum([ + "draft", // Just created, no research yet + "researching", // Brand research in progress + "designing", // Design system being generated + "complete", // All done +]); + +export type ProjectStatus = z.infer; + +/** + * Brand project schema + */ +export const BrandProjectSchema = z.object({ + id: z.string().describe("Unique project ID"), + name: z.string().describe("Project name"), + prompt: z.string().optional().describe("Initial prompt/description"), + websiteUrl: z.string().url().optional().describe("Brand website URL"), + status: ProjectStatusSchema.describe("Current project status"), + wizardStep: z + .number() + .default(0) + .describe("Current step in the creation wizard"), + identity: BrandIdentitySchema.optional().describe( + "Discovered brand identity", + ), + css: z.string().optional().describe("Generated CSS variables"), + jsx: z.string().optional().describe("Generated JSX design system"), + styleGuide: z.string().optional().describe("Generated style guide"), + createdAt: z.string().describe("ISO timestamp of creation"), + updatedAt: z.string().describe("ISO timestamp of last update"), +}); + +export type BrandProject = z.infer; + +/** + * In-memory project store + * In production, this would be backed by a database + */ +const projectStore: Map = new Map(); + +/** + * Generate a unique project ID + */ +function generateId(): string { + return `brand_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; +} + +/** + * PROJECT_CREATE - Create a new brand project + */ +export const createProjectCreateTool = (_env: Env) => + createTool({ + id: "PROJECT_CREATE", + _meta: { "ui/resourceUri": "ui://project-wizard" }, + description: `Create a new brand project. + +The project is saved immediately and can be resumed later. +Returns the project with its ID for tracking. + +**Use this as the first step** when starting a new brand.`, + inputSchema: z.object({ + name: z.string().describe("Project/brand name"), + prompt: z + .string() + .optional() + .describe("Description or prompt for the brand"), + websiteUrl: z.string().url().optional().describe("Brand website URL"), + }), + outputSchema: z.object({ + success: z.boolean(), + project: BrandProjectSchema.optional(), + error: z.string().optional(), + }), + execute: async ({ context }) => { + const { name, prompt, websiteUrl } = context; + + const now = new Date().toISOString(); + const project: BrandProject = { + id: generateId(), + name, + prompt, + websiteUrl, + status: "draft", + wizardStep: 1, // Move to step 1 after creation + createdAt: now, + updatedAt: now, + }; + + projectStore.set(project.id, project); + + console.log(`[brand-mcp] 📁 Created project: ${project.id} (${name})`); + + return { + success: true, + project, + }; + }, + }); + +/** + * PROJECT_LIST - List all brand projects + */ +export const createProjectListTool = (_env: Env) => + createTool({ + id: "PROJECT_LIST", + _meta: { "ui/resourceUri": "ui://brand-list" }, + description: `List all brand projects. + +Returns projects sorted by last updated (most recent first).`, + inputSchema: z.object({ + status: ProjectStatusSchema.optional().describe("Filter by status"), + }), + outputSchema: z.object({ + projects: z.array(BrandProjectSchema), + total: z.number(), + }), + execute: async ({ context }) => { + const { status } = context; + + let projects = Array.from(projectStore.values()); + + // Filter by status if provided + if (status) { + projects = projects.filter((p) => p.status === status); + } + + // Sort by updatedAt descending + projects.sort( + (a, b) => + new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + ); + + return { + projects, + total: projects.length, + }; + }, + }); + +/** + * PROJECT_GET - Get a specific project by ID + */ +export const createProjectGetTool = (_env: Env) => + createTool({ + id: "PROJECT_GET", + _meta: { "ui/resourceUri": "ui://project-wizard" }, + description: `Get a brand project by ID. + +Use this to resume a project from where you left off.`, + inputSchema: z.object({ + projectId: z.string().describe("Project ID to retrieve"), + }), + outputSchema: z.object({ + success: z.boolean(), + project: BrandProjectSchema.optional(), + error: z.string().optional(), + }), + execute: async ({ context }) => { + const { projectId } = context; + + const project = projectStore.get(projectId); + + if (!project) { + return { + success: false, + error: `Project not found: ${projectId}`, + }; + } + + return { + success: true, + project, + }; + }, + }); + +/** + * PROJECT_UPDATE - Update a project's state + */ +export const createProjectUpdateTool = (_env: Env) => + createTool({ + id: "PROJECT_UPDATE", + _meta: { "ui/resourceUri": "ui://project-wizard" }, + description: `Update a brand project. + +Use this to save progress at any step of the wizard.`, + inputSchema: z.object({ + projectId: z.string().describe("Project ID to update"), + name: z.string().optional().describe("Update project name"), + prompt: z.string().optional().describe("Update prompt"), + websiteUrl: z.string().url().optional().describe("Update website URL"), + status: ProjectStatusSchema.optional().describe("Update status"), + wizardStep: z.number().optional().describe("Update wizard step"), + identity: BrandIdentitySchema.optional().describe("Set brand identity"), + css: z.string().optional().describe("Set generated CSS"), + jsx: z.string().optional().describe("Set generated JSX"), + styleGuide: z.string().optional().describe("Set generated style guide"), + }), + outputSchema: z.object({ + success: z.boolean(), + project: BrandProjectSchema.optional(), + error: z.string().optional(), + }), + execute: async ({ context }) => { + const { projectId, ...updates } = context; + + const project = projectStore.get(projectId); + + if (!project) { + return { + success: false, + error: `Project not found: ${projectId}`, + }; + } + + // Apply updates + const updatedProject: BrandProject = { + ...project, + ...Object.fromEntries( + Object.entries(updates).filter(([_, v]) => v !== undefined), + ), + updatedAt: new Date().toISOString(), + }; + + projectStore.set(projectId, updatedProject); + + console.log( + `[brand-mcp] 📝 Updated project: ${projectId} (step ${updatedProject.wizardStep}, status: ${updatedProject.status})`, + ); + + return { + success: true, + project: updatedProject, + }; + }, + }); + +/** + * PROJECT_DELETE - Delete a project + */ +export const createProjectDeleteTool = (_env: Env) => + createTool({ + id: "PROJECT_DELETE", + description: `Delete a brand project. + +This action cannot be undone.`, + inputSchema: z.object({ + projectId: z.string().describe("Project ID to delete"), + }), + outputSchema: z.object({ + success: z.boolean(), + error: z.string().optional(), + }), + execute: async ({ context }) => { + const { projectId } = context; + + if (!projectStore.has(projectId)) { + return { + success: false, + error: `Project not found: ${projectId}`, + }; + } + + projectStore.delete(projectId); + + console.log(`[brand-mcp] 🗑️ Deleted project: ${projectId}`); + + return { + success: true, + }; + }, + }); + +/** + * PROJECT_RESEARCH - Research brand for a project + */ +export const createProjectResearchTool = (env: Env) => + createTool({ + id: "PROJECT_RESEARCH", + _meta: { "ui/resourceUri": "ui://project-wizard" }, + description: `Research and discover brand identity for a project. + +This combines scraping and AI research, then saves the results to the project.`, + inputSchema: z.object({ + projectId: z.string().describe("Project ID"), + websiteUrl: z.string().url().optional().describe("Website to research"), + }), + outputSchema: z.object({ + success: z.boolean(), + project: BrandProjectSchema.optional(), + error: z.string().optional(), + }), + execute: async ({ context }) => { + const { projectId, websiteUrl } = context; + + const project = projectStore.get(projectId); + + if (!project) { + return { + success: false, + error: `Project not found: ${projectId}`, + }; + } + + const url = websiteUrl || project.websiteUrl; + if (!url) { + return { + success: false, + error: "No website URL provided for research", + }; + } + + // Update status to researching + project.status = "researching"; + project.websiteUrl = url; + project.updatedAt = new Date().toISOString(); + projectStore.set(projectId, project); + + const firecrawl = env.MESH_REQUEST_CONTEXT?.state?.FIRECRAWL; + const perplexity = env.MESH_REQUEST_CONTEXT?.state?.PERPLEXITY; + + const identity: Partial = { + name: project.name, + sources: [], + confidence: "low", + }; + + // Scrape with Firecrawl + if (firecrawl) { + try { + const result = (await firecrawl.firecrawl_scrape({ + url, + formats: ["branding", "links"], + })) as { branding?: Record }; + + if (result?.branding) { + const branding = result.branding; + + if (branding.colors && typeof branding.colors === "object") { + const colors = branding.colors as Record; + identity.colors = { + primary: colors.primary || colors.main || "#8B5CF6", + secondary: colors.secondary, + accent: colors.accent, + background: colors.background, + text: colors.text, + }; + } + + if (branding.logos) { + if (Array.isArray(branding.logos)) { + identity.logos = { primary: branding.logos[0] as string }; + } else if (typeof branding.logos === "object") { + const logos = branding.logos as Record; + identity.logos = { + primary: logos.primary || logos.main, + light: logos.light, + dark: logos.dark, + icon: logos.icon, + }; + } + } + + if (branding.fonts && typeof branding.fonts === "object") { + const fonts = branding.fonts as Record; + identity.typography = { + headingFont: fonts.heading || fonts.title, + bodyFont: fonts.body || fonts.text, + monoFont: fonts.mono, + }; + } + + identity.sources?.push(url); + } + } catch (error) { + console.error("[brand-mcp] Scraping error:", error); + } + } + + // Research with Perplexity + if (perplexity) { + try { + const result = (await perplexity.perplexity_research({ + messages: [ + { + role: "user", + content: `Brief info on ${project.name} (${url}): tagline, primary color hex, and brand personality in 2-3 sentences.`, + }, + ], + strip_thinking: true, + })) as { response?: string }; + + if (result?.response) { + const response = result.response; + + // Extract tagline + const taglineMatch = response.match( + /(?:tagline|slogan)[:\s]+["']?([^"'\n.]+)["']?/i, + ); + if (taglineMatch) { + identity.tagline = taglineMatch[1].trim(); + } + + // Extract colors if we don't have good ones + if (!identity.colors || identity.colors.primary === "#8B5CF6") { + const hexColors = response.match(/#[0-9A-Fa-f]{6}/g); + if (hexColors?.length) { + identity.colors = { + ...(identity.colors || {}), + primary: hexColors[0], + }; + } + } + + identity.sources?.push("perplexity-research"); + } + } catch (error) { + console.error("[brand-mcp] Research error:", error); + } + } + + // Set default colors if none found + if (!identity.colors) { + identity.colors = { primary: "#8B5CF6" }; + } + + // Determine confidence + const hasLogo = Boolean(identity.logos?.primary); + const hasColors = identity.colors.primary !== "#8B5CF6"; + identity.confidence = + hasLogo && hasColors ? "high" : hasLogo || hasColors ? "medium" : "low"; + + // Update project with identity + project.identity = identity as BrandIdentity; + project.status = "designing"; + project.wizardStep = 2; + project.updatedAt = new Date().toISOString(); + projectStore.set(projectId, project); + + console.log( + `[brand-mcp] 🔍 Researched project: ${projectId} (confidence: ${identity.confidence})`, + ); + + return { + success: true, + project, + }; + }, + }); + +/** + * PROJECT_GENERATE - Generate design system for a project + */ +export const createProjectGenerateTool = (_env: Env) => + createTool({ + id: "PROJECT_GENERATE", + _meta: { "ui/resourceUri": "ui://brand-preview" }, + description: `Generate design system files for a project. + +Creates CSS, JSX, and style guide from the project's brand identity.`, + inputSchema: z.object({ + projectId: z.string().describe("Project ID"), + }), + outputSchema: z.object({ + success: z.boolean(), + project: BrandProjectSchema.optional(), + error: z.string().optional(), + }), + execute: async ({ context }) => { + const { projectId } = context; + + const project = projectStore.get(projectId); + + if (!project) { + return { + success: false, + error: `Project not found: ${projectId}`, + }; + } + + if (!project.identity) { + return { + success: false, + error: "Project has no brand identity. Run research first.", + }; + } + + // Import generator functions + const { + generateCSSVariables, + generateDesignSystemJSX, + generateStyleGuide, + } = await import("./generator-utils.ts"); + + project.css = generateCSSVariables(project.identity); + project.jsx = generateDesignSystemJSX(project.identity); + project.styleGuide = generateStyleGuide(project.identity); + project.status = "complete"; + project.wizardStep = 3; + project.updatedAt = new Date().toISOString(); + projectStore.set(projectId, project); + + console.log(`[brand-mcp] ✨ Generated design system for: ${projectId}`); + + return { + success: true, + project, + }; + }, + }); + +export const projectTools = [ + createProjectCreateTool, + createProjectListTool, + createProjectGetTool, + createProjectUpdateTool, + createProjectDeleteTool, + createProjectResearchTool, + createProjectGenerateTool, +]; diff --git a/slides/server/main.ts b/slides/server/main.ts index e52c7c25..8fa756ed 100644 --- a/slides/server/main.ts +++ b/slides/server/main.ts @@ -19,7 +19,7 @@ import { StateSchema, type Env, type Registry } from "./types/env.ts"; export { StateSchema }; -const PORT = process.env.PORT || 8001; +const PORT = process.env.PORT || 8004; console.log("[slides-mcp] Starting server..."); console.log("[slides-mcp] Port:", PORT); From 1e321e5a0f40600012b3f234e7ec301d70b518e7 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sun, 25 Jan 2026 10:48:12 -0300 Subject: [PATCH 09/20] Remove testbed --- mcp-apps-testbed/.gitignore | 2 - mcp-apps-testbed/README.md | 174 ---- mcp-apps-testbed/bun.lock | 207 ---- mcp-apps-testbed/package.json | 23 - mcp-apps-testbed/server/lib/resources.ts | 1185 ---------------------- mcp-apps-testbed/server/main.ts | 370 ------- mcp-apps-testbed/tsconfig.json | 14 - 7 files changed, 1975 deletions(-) delete mode 100644 mcp-apps-testbed/.gitignore delete mode 100644 mcp-apps-testbed/README.md delete mode 100644 mcp-apps-testbed/bun.lock delete mode 100644 mcp-apps-testbed/package.json delete mode 100644 mcp-apps-testbed/server/lib/resources.ts delete mode 100644 mcp-apps-testbed/server/main.ts delete mode 100644 mcp-apps-testbed/tsconfig.json diff --git a/mcp-apps-testbed/.gitignore b/mcp-apps-testbed/.gitignore deleted file mode 100644 index b9470778..00000000 --- a/mcp-apps-testbed/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules/ -dist/ diff --git a/mcp-apps-testbed/README.md b/mcp-apps-testbed/README.md deleted file mode 100644 index 5f451fc2..00000000 --- a/mcp-apps-testbed/README.md +++ /dev/null @@ -1,174 +0,0 @@ -# MCP Apps Testbed - -A reference implementation for testing [MCP Apps (SEP-1865)](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1865) — interactive user interfaces for the Model Context Protocol. - -## What is this? - -This is a simple MCP server that provides example tools with associated UI widgets. It's designed to: - -1. **Test MCP Apps integration** in host applications like Mesh -2. **Demonstrate responsive design patterns** for MCP App UIs -3. **Serve as a reference** for building your own MCP Apps - -## Quick Start - -```bash -# Install dependencies -bun install - -# Run the server (uses stdio transport) -bun run dev -``` - -Then add it as a connection in Mesh: - -- **Transport**: STDIO -- **Command**: `bun` -- **Args**: `/path/to/mcp-apps-testbed/server/main.ts` - -## Available Widgets (10 total) - -| Tool | Description | UI Resource | -|------|-------------|-------------| -| `counter` | Interactive counter with +/- controls | `ui://counter-app` | -| `show_metric` | Display a key metric with trend indicator | `ui://metric` | -| `show_progress` | Visual progress bar with percentage | `ui://progress` | -| `greet` | Personalized greeting card | `ui://greeting-app` | -| `show_chart` | Animated bar chart visualization | `ui://chart-app` | -| `start_timer` | Countdown timer with start/pause | `ui://timer` | -| `show_status` | Status badge with colored indicator | `ui://status` | -| `show_quote` | Quote display with attribution | `ui://quote` | -| `show_sparkline` | Compact inline trend chart | `ui://sparkline` | -| `show_code` | Code snippet with syntax styling | `ui://code` | - -## Responsive Design - -All widgets adapt to three display modes: - -| Mode | Height | Layout | Use Case | -|------|--------|--------|----------| -| **Collapsed** | < 450px | Horizontal/compact | Default in chat | -| **Expanded** | 450-750px | Vertical/spacious | Expanded view in chat | -| **View** | > 750px | Full experience | Resource preview | - -### Design Philosophy - -- **Collapsed = Compact**: Content arranged horizontally, essential elements only -- **Expanded = Breathable**: Content stacked vertically, more details visible -- **View = Complete**: All information displayed, full interactivity - -This mirrors how iOS widgets adapt between compact and expanded states. - -### CSS Breakpoints - -```css -/* Default: Collapsed (horizontal) */ -.container { - display: flex; - flex-direction: row; -} - -/* Expanded: Vertical layout */ -@media (min-height: 450px) { - .container { - flex-direction: column; - } -} - -/* View: Full experience */ -@media (min-height: 750px) { - /* Additional details, larger typography */ -} -``` - -## Design Tokens - -The widgets use a consistent design system: - -```javascript -{ - bg: "#ffffff", - bgSubtle: "#f9fafb", - border: "#e5e7eb", - text: "#111827", - textMuted: "#6b7280", - textSubtle: "#9ca3af", - primary: "#6366f1", - success: "#10b981", - destructive: "#ef4444", -} -``` - -## Project Structure - -``` -mcp-apps-testbed/ -├── server/ -│ ├── main.ts # MCP server entry point -│ └── lib/ -│ └── resources.ts # UI widget HTML definitions -├── package.json -└── README.md -``` - -## How MCP Apps Work - -1. **Tools declare UI associations** via `_meta["ui/resourceUri"]` -2. **Resources provide HTML content** with MIME type `text/html;profile=mcp-app` -3. **Host renders in sandboxed iframe** and communicates via JSON-RPC postMessage -4. **UI receives tool input/result** via `ui/initialize` and can call tools back - -### Example Tool Definition - -```typescript -{ - name: "counter", - description: "An interactive counter", - inputSchema: { - type: "object", - properties: { - initialValue: { type: "number", default: 0 } - } - }, - _meta: { "ui/resourceUri": "ui://counter" } -} -``` - -### Example UI Message Handling - -```javascript -window.addEventListener('message', e => { - const msg = JSON.parse(e.data); - - if (msg.method === 'ui/initialize') { - // Access tool input and result - const { toolInput, toolResult, toolName } = msg.params; - - // Initialize your UI... - - // Respond to host - parent.postMessage(JSON.stringify({ - jsonrpc: '2.0', - id: msg.id, - result: {} - }), '*'); - } -}); -``` - -## Development - -```bash -# Run with hot reload -bun run dev - -# Type check -bun run check - -# Build for production -bun run build -``` - -## License - -MIT diff --git a/mcp-apps-testbed/bun.lock b/mcp-apps-testbed/bun.lock deleted file mode 100644 index b5576c5e..00000000 --- a/mcp-apps-testbed/bun.lock +++ /dev/null @@ -1,207 +0,0 @@ -{ - "lockfileVersion": 1, - "workspaces": { - "": { - "name": "@decocms/mcp-apps-testbed", - "dependencies": { - "@modelcontextprotocol/sdk": "^1.25.1", - "zod": "^3.24.0", - }, - "devDependencies": { - "@types/bun": "^1.2.0", - "typescript": "^5.7.2", - }, - }, - }, - "packages": { - "@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], - - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.3", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ=="], - - "@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], - - "@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="], - - "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], - - "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], - - "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], - - "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], - - "bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], - - "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], - - "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], - - "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], - - "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], - - "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], - - "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], - - "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], - - "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], - - "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], - - "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - - "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - - "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], - - "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], - - "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], - - "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], - - "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], - - "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], - - "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], - - "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], - - "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], - - "express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="], - - "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - - "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], - - "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], - - "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], - - "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], - - "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], - - "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], - - "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], - - "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], - - "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], - - "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], - - "hono": ["hono@4.11.5", "", {}, "sha512-WemPi9/WfyMwZs+ZUXdiwcCh9Y+m7L+8vki9MzDw3jJ+W9Lc+12HGsd368Qc1vZi1xwW8BWMMsnK5efYKPdt4g=="], - - "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], - - "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], - - "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - - "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], - - "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], - - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - - "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], - - "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - - "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], - - "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], - - "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], - - "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], - - "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], - - "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], - - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - - "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - - "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], - - "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - - "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], - - "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - - "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], - - "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - - "path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], - - "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], - - "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], - - "qs": ["qs@6.14.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ=="], - - "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], - - "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], - - "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], - - "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], - - "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - - "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], - - "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], - - "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], - - "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], - - "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - - "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], - - "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], - - "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], - - "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], - - "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], - - "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], - - "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], - - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - - "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], - - "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - - "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - - "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - - "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], - } -} diff --git a/mcp-apps-testbed/package.json b/mcp-apps-testbed/package.json deleted file mode 100644 index 140191d2..00000000 --- a/mcp-apps-testbed/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "@decocms/mcp-apps-testbed", - "version": "1.0.0", - "description": "Testbed for MCP Apps - Interactive User Interfaces", - "private": true, - "type": "module", - "scripts": { - "dev": "bun run --hot server/main.ts", - "build": "bun build server/main.ts --target=bun --outfile=dist/server/main.js", - "check": "tsc --noEmit" - }, - "dependencies": { - "@modelcontextprotocol/sdk": "^1.25.1", - "zod": "^3.24.0" - }, - "devDependencies": { - "@types/bun": "^1.2.0", - "typescript": "^5.7.2" - }, - "engines": { - "node": ">=22.0.0" - } -} diff --git a/mcp-apps-testbed/server/lib/resources.ts b/mcp-apps-testbed/server/lib/resources.ts deleted file mode 100644 index f8c49323..00000000 --- a/mcp-apps-testbed/server/lib/resources.ts +++ /dev/null @@ -1,1185 +0,0 @@ -/** - * UI Resources for MCP Apps Testbed - * - * Elegant, responsive widgets that adapt to available space: - * - * - Collapsed (< 450px): Horizontal/compact layout - * - Expanded (>= 450px): Vertical/spacious layout - * - View (>= 750px): Full experience with all details - * - * Design follows Mesh's aesthetic: clean, minimal, subtle. - */ - -export const resources = [ - // Core widgets - { - uri: "ui://counter-app", - name: "Counter", - description: "Interactive counter with increment/decrement controls", - mimeType: "text/html;profile=mcp-app", - }, - { - uri: "ui://metric", - name: "Metric Display", - description: "Display a key metric with label and optional trend", - mimeType: "text/html;profile=mcp-app", - }, - { - uri: "ui://progress", - name: "Progress Tracker", - description: "Visual progress bar with percentage and label", - mimeType: "text/html;profile=mcp-app", - }, - { - uri: "ui://greeting-app", - name: "Greeting Card", - description: "Animated personalized greeting", - mimeType: "text/html;profile=mcp-app", - }, - { - uri: "ui://chart-app", - name: "Bar Chart", - description: "Animated bar chart visualization", - mimeType: "text/html;profile=mcp-app", - }, - // New widgets - { - uri: "ui://timer", - name: "Timer", - description: "Countdown timer with start/pause controls", - mimeType: "text/html;profile=mcp-app", - }, - { - uri: "ui://status", - name: "Status Badge", - description: "Status indicator with icon and label", - mimeType: "text/html;profile=mcp-app", - }, - { - uri: "ui://quote", - name: "Quote", - description: "Display a quote or text with attribution", - mimeType: "text/html;profile=mcp-app", - }, - { - uri: "ui://sparkline", - name: "Sparkline", - description: "Compact inline trend chart", - mimeType: "text/html;profile=mcp-app", - }, - { - uri: "ui://code", - name: "Code Snippet", - description: "Syntax-highlighted code display", - mimeType: "text/html;profile=mcp-app", - }, -]; - -// Design tokens matching Mesh's aesthetic -const tokens = { - bg: "#ffffff", - bgSubtle: "#f9fafb", - border: "#e5e7eb", - borderSubtle: "rgba(0,0,0,0.06)", - text: "#111827", - textMuted: "#6b7280", - textSubtle: "#9ca3af", - primary: "#6366f1", // indigo-500 - primaryLight: "#eef2ff", // indigo-50 - success: "#10b981", // emerald-500 - successLight: "#ecfdf5", - destructive: "#ef4444", - destructiveLight: "#fef2f2", -}; - -const apps: Record = { - // ============================================================================ - // Counter Widget - // Collapsed: Horizontal - value on left, controls on right - // Expanded: Vertical - centered with larger controls - // ============================================================================ - "ui://counter-app": ` - - - - - -
-
- 0 - Counter -
-
- - -
-
Click buttons to adjust
-
- - -`, - - // ============================================================================ - // Metric Widget - // Collapsed: Horizontal - metric value prominent, label beside - // Expanded: Vertical - centered with trend indicator - // ============================================================================ - "ui://metric": ` - - - - - -
-
- Metric - - -
-
- - 12% -
-
Compared to previous period
-
- - -`, - - // ============================================================================ - // Progress Widget - // Collapsed: Horizontal bar with percentage - // Expanded: Vertical with label, bar, and details - // ============================================================================ - "ui://progress": ` - - - - - -
-
- Progress - 0% -
-
-
-
-
0 of 100 completed
-
- - -`, - - // ============================================================================ - // ADDITIONAL WIDGETS - // ============================================================================ - - // Greeting App - "ui://greeting-app": ` - - - - - -
-
👋
-
-
Hello!
-
Welcome to MCP Apps
-
-
Interactive greeting card
-
- - -`, - - // Chart App (original) - "ui://chart-app": ` - - - - - -
-

Favorite Fruits Survey

-
-
-
-
-
- - -`, - - // ============================================================================ - // Timer Widget - // ============================================================================ - "ui://timer": ` - - - - - -
- Timer - 00:00 -
- - -
-
- - -`, - - // ============================================================================ - // Status Badge Widget - // ============================================================================ - "ui://status": ` - - - - - -
-
-
-
All Systems Operational
-
No issues detected
-
-
Just now
-
- - -`, - - // ============================================================================ - // Quote Widget - // ============================================================================ - "ui://quote": ` - - - - - -
-
"
-
-
The best way to predict the future is to invent it.
-
Alan Kay
-
-
- - -`, - - // ============================================================================ - // Sparkline Widget - // ============================================================================ - "ui://sparkline": ` - - - - - -
-
- Requests - 1,234 -
-
- ↑ 12% -
- - -`, - - // ============================================================================ - // Code Snippet Widget - // ============================================================================ - "ui://code": ` - - - - - -
-
- javascript - -
-
function hello() {
-  console.log("Hello, World!");
-}
-
- - -`, -}; - -export function getResourceHtml(uri: string): string | undefined { - return apps[uri]; -} diff --git a/mcp-apps-testbed/server/main.ts b/mcp-apps-testbed/server/main.ts deleted file mode 100644 index b8996e7a..00000000 --- a/mcp-apps-testbed/server/main.ts +++ /dev/null @@ -1,370 +0,0 @@ -#!/usr/bin/env bun -/** - * MCP Apps Testbed Server - * - * A simple MCP server for testing MCP Apps (SEP-1865) in Mesh. - * Uses stdio transport - no auth required. - * - * Usage: - * bun server/main.ts - * - * In Mesh, add as STDIO connection: - * Command: bun - * Args: /path/to/mcp-apps-testbed/server/main.ts - */ -import { Server } from "@modelcontextprotocol/sdk/server/index.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { - CallToolRequestSchema, - ListToolsRequestSchema, - ListResourcesRequestSchema, - ReadResourceRequestSchema, -} from "@modelcontextprotocol/sdk/types.js"; -import { resources, getResourceHtml } from "./lib/resources.ts"; - -// Tool definitions with UI associations -const tools = [ - // Core widgets - { - name: "counter", - description: - "An interactive counter widget. Set an initial value and use the UI to adjust it.", - inputSchema: { - type: "object" as const, - properties: { - initialValue: { - type: "number", - default: 0, - description: "Initial counter value", - }, - label: { - type: "string", - default: "Counter", - description: "Label for the counter", - }, - }, - }, - _meta: { "ui/resourceUri": "ui://counter-app" }, - }, - { - name: "show_metric", - description: "Display a key metric with optional trend indicator.", - inputSchema: { - type: "object" as const, - properties: { - label: { type: "string", description: "Metric label" }, - value: { type: "number", description: "The metric value" }, - unit: { - type: "string", - description: "Unit of measurement (e.g., 'ms', 'GB', '$')", - }, - trend: { - type: "number", - description: "Trend percentage (positive = up, negative = down)", - }, - description: { type: "string", description: "Additional context" }, - }, - required: ["label", "value"], - }, - _meta: { "ui/resourceUri": "ui://metric" }, - }, - { - name: "show_progress", - description: "Display a progress bar with label and percentage.", - inputSchema: { - type: "object" as const, - properties: { - label: { - type: "string", - default: "Progress", - description: "Progress label", - }, - value: { type: "number", description: "Current progress value" }, - total: { type: "number", default: 100, description: "Total/max value" }, - }, - required: ["value"], - }, - _meta: { "ui/resourceUri": "ui://progress" }, - }, - // Additional tools - { - name: "greet", - description: - "Generate a personalized greeting displayed in an elegant card.", - inputSchema: { - type: "object" as const, - properties: { - name: { type: "string", description: "Name to greet" }, - message: { type: "string", description: "Optional custom message" }, - }, - required: ["name"], - }, - _meta: { "ui/resourceUri": "ui://greeting-app" }, - }, - { - name: "show_chart", - description: "Display data as an animated bar chart.", - inputSchema: { - type: "object" as const, - properties: { - title: { type: "string", default: "Chart", description: "Chart title" }, - data: { - type: "array", - items: { - type: "object", - properties: { - label: { type: "string" }, - value: { type: "number" }, - }, - required: ["label", "value"], - }, - description: "Data points", - }, - }, - required: ["data"], - }, - _meta: { "ui/resourceUri": "ui://chart-app" }, - }, - // New widgets - { - name: "start_timer", - description: "Display an interactive timer with start/pause controls.", - inputSchema: { - type: "object" as const, - properties: { - seconds: { type: "number", default: 0, description: "Initial seconds" }, - label: { type: "string", default: "Timer", description: "Timer label" }, - }, - }, - _meta: { "ui/resourceUri": "ui://timer" }, - }, - { - name: "show_status", - description: "Display a status badge with icon indicator.", - inputSchema: { - type: "object" as const, - properties: { - status: { type: "string", description: "Status text" }, - description: { type: "string", description: "Additional details" }, - type: { - type: "string", - enum: ["success", "warning", "error", "info"], - default: "success", - }, - timestamp: { type: "string", description: "Timestamp text" }, - }, - required: ["status"], - }, - _meta: { "ui/resourceUri": "ui://status" }, - }, - { - name: "show_quote", - description: "Display a quote with attribution.", - inputSchema: { - type: "object" as const, - properties: { - text: { type: "string", description: "The quote text" }, - author: { type: "string", description: "Quote attribution" }, - }, - required: ["text"], - }, - _meta: { "ui/resourceUri": "ui://quote" }, - }, - { - name: "show_sparkline", - description: "Display a compact trend chart with current value.", - inputSchema: { - type: "object" as const, - properties: { - label: { type: "string", description: "Metric label" }, - value: { type: "string", description: "Current value to display" }, - data: { - type: "array", - items: { type: "number" }, - description: "Array of values for the chart", - }, - trend: { type: "number", description: "Trend percentage" }, - }, - required: ["value", "data"], - }, - _meta: { "ui/resourceUri": "ui://sparkline" }, - }, - { - name: "show_code", - description: "Display a code snippet with syntax highlighting.", - inputSchema: { - type: "object" as const, - properties: { - code: { type: "string", description: "The code to display" }, - language: { - type: "string", - default: "javascript", - description: "Programming language", - }, - }, - required: ["code"], - }, - _meta: { "ui/resourceUri": "ui://code" }, - }, -]; - -// Tool handlers -const toolHandlers: Record< - string, - (args: Record) => { - content: Array<{ type: string; text: string }>; - _meta?: Record; - } -> = { - // Core widgets - counter: (args) => ({ - content: [ - { - type: "text", - text: `Counter "${args.label || "Counter"}" initialized at ${args.initialValue ?? 0}`, - }, - ], - _meta: { "ui/resourceUri": "ui://counter-app" }, - }), - - show_metric: (args) => ({ - content: [ - { - type: "text", - text: JSON.stringify({ - label: args.label, - value: args.value, - unit: args.unit, - trend: args.trend, - }), - }, - ], - _meta: { "ui/resourceUri": "ui://metric" }, - }), - - show_progress: (args) => ({ - content: [ - { - type: "text", - text: `Progress: ${args.value}/${args.total || 100} (${Math.round(((args.value as number) / ((args.total as number) || 100)) * 100)}%)`, - }, - ], - _meta: { "ui/resourceUri": "ui://progress" }, - }), - - // Additional widgets - greet: (args) => ({ - content: [ - { - type: "text", - text: args.message - ? `Hello ${args.name}! ${args.message}` - : `Hello ${args.name}!`, - }, - ], - _meta: { "ui/resourceUri": "ui://greeting-app" }, - }), - - show_chart: (args) => ({ - content: [ - { - type: "text", - text: `Chart "${args.title ?? "Chart"}" with ${(args.data as Array)?.length ?? 0} data points`, - }, - ], - _meta: { "ui/resourceUri": "ui://chart-app" }, - }), - - // New widget handlers - start_timer: (args) => ({ - content: [ - { type: "text", text: `Timer started at ${args.seconds ?? 0} seconds` }, - ], - _meta: { "ui/resourceUri": "ui://timer" }, - }), - - show_status: (args) => ({ - content: [ - { - type: "text", - text: `Status: ${args.status} (${args.type ?? "success"})`, - }, - ], - _meta: { "ui/resourceUri": "ui://status" }, - }), - - show_quote: (args) => ({ - content: [ - { type: "text", text: `"${args.text}" — ${args.author ?? "Unknown"}` }, - ], - _meta: { "ui/resourceUri": "ui://quote" }, - }), - - show_sparkline: (args) => ({ - content: [ - { type: "text", text: `${args.label ?? "Value"}: ${args.value}` }, - ], - _meta: { "ui/resourceUri": "ui://sparkline" }, - }), - - show_code: (args) => ({ - content: [ - { - type: "text", - text: `\`\`\`${args.language ?? "javascript"}\n${args.code}\n\`\`\``, - }, - ], - _meta: { "ui/resourceUri": "ui://code" }, - }), -}; - -async function main() { - const server = new Server( - { name: "mcp-apps-testbed", version: "1.0.0" }, - { capabilities: { tools: {}, resources: {} } }, - ); - - // Handle tools/list - server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools, - })); - - // Handle tools/call - server.setRequestHandler(CallToolRequestSchema, async (request) => { - const { name, arguments: args } = request.params; - const handler = toolHandlers[name]; - if (!handler) { - throw new Error(`Unknown tool: ${name}`); - } - return handler(args ?? {}); - }); - - // Handle resources/list - server.setRequestHandler(ListResourcesRequestSchema, async () => ({ - resources, - })); - - // Handle resources/read - server.setRequestHandler(ReadResourceRequestSchema, async (request) => { - const { uri } = request.params; - const html = getResourceHtml(uri); - if (!html) { - throw new Error(`Resource not found: ${uri}`); - } - return { - contents: [{ uri, mimeType: "text/html;profile=mcp-app", text: html }], - }; - }); - - // Connect to stdio transport - const transport = new StdioServerTransport(); - await server.connect(transport); - - console.error("[mcp-apps-testbed] MCP server running via stdio"); - console.error("[mcp-apps-testbed] 10 tools with interactive UI widgets"); -} - -main().catch((error) => { - console.error("Fatal error:", error); - process.exit(1); -}); diff --git a/mcp-apps-testbed/tsconfig.json b/mcp-apps-testbed/tsconfig.json deleted file mode 100644 index 1b3c1e72..00000000 --- a/mcp-apps-testbed/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ESNext", - "module": "ESNext", - "moduleResolution": "bundler", - "esModuleInterop": true, - "strict": true, - "skipLibCheck": true, - "noEmit": true, - "allowImportingTsExtensions": true - }, - "include": ["server/**/*.ts"], - "exclude": ["node_modules", "dist"] -} From 74cec838739c6e49c8eb359e6b041ff3e0e3d0dd Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sun, 25 Jan 2026 10:49:31 -0300 Subject: [PATCH 10/20] refactor(slides): Remove brand research, add Brand MCP binding - Remove brand-research.ts and BRAND_RESEARCH/BRAND_RESEARCH_STATUS tools - Remove PERPLEXITY and FIRECRAWL direct bindings - Add BRAND binding to connect to Brand MCP - Brand research is now handled by the separate Brand MCP Slides MCP now focuses on presentation creation while Brand MCP handles all brand discovery and design system generation. To use brand research with Slides: 1. Install Brand MCP 2. Configure the BRAND binding in Slides settings 3. Use Brand MCP tools to discover brand, then pass to Slides --- slides/server/main.ts | 8 +- slides/server/tools/brand-research.ts | 554 -------------------------- slides/server/tools/index.ts | 13 +- slides/server/types/env.ts | 107 ++--- 4 files changed, 40 insertions(+), 642 deletions(-) delete mode 100644 slides/server/tools/brand-research.ts diff --git a/slides/server/main.ts b/slides/server/main.ts index 8fa756ed..c9955437 100644 --- a/slides/server/main.ts +++ b/slides/server/main.ts @@ -7,7 +7,7 @@ * * - **Brand-Aware Design Systems** - Create reusable design systems with brand colors, typography, and logos * - **Multiple Slide Layouts** - Title, content, stats, two-column, list, quote, image, and custom - * - **Automatic Brand Research** - Optionally integrate Perplexity and Firecrawl for brand discovery + * - **Brand MCP Integration** - Connect to Brand MCP for automatic brand discovery * - **MCP Apps UI** - Interactive slide viewer and design system preview * - **JSX + Babel** - Modern component-based slides with browser-side transpilation */ @@ -29,7 +29,7 @@ console.log("[slides-mcp] Resources count:", resources.length); const runtime = withRuntime({ configuration: { - scopes: ["PERPLEXITY::*", "FIRECRAWL::*"], + scopes: ["BRAND::*"], state: StateSchema, }, tools, @@ -94,14 +94,14 @@ console.log(" - SLIDE_CREATE - Add slides to presentation"); console.log(" - SLIDE_UPDATE - Modify existing slides"); console.log(" - SLIDE_DELETE - Remove slides"); console.log(" - SLIDES_PREVIEW - Preview multiple slides"); -console.log(" - BRAND_RESEARCH - Research brand identity (optional)"); console.log(""); console.log("[slides-mcp] MCP Apps (UI Resources):"); console.log(" - ui://slides-viewer - Full presentation viewer"); console.log(" - ui://design-system - Brand design system preview"); console.log(" - ui://slide - Single slide preview"); console.log(""); -console.log("[slides-mcp] Optional bindings: FIRECRAWL, PERPLEXITY"); +console.log("[slides-mcp] Optional binding: BRAND (Brand MCP)"); +console.log(" Connect Brand MCP for automatic brand discovery"); // Copy URL to clipboard on macOS if (process.platform === "darwin") { diff --git a/slides/server/tools/brand-research.ts b/slides/server/tools/brand-research.ts deleted file mode 100644 index bc383533..00000000 --- a/slides/server/tools/brand-research.ts +++ /dev/null @@ -1,554 +0,0 @@ -/** - * Brand research tools that use optional Perplexity and Firecrawl bindings. - * - * These tools automatically discover brand assets (logos, colors, typography) - * from websites when the corresponding bindings are configured. - */ -import { createTool } from "@decocms/runtime/tools"; -import { z } from "zod"; -import type { Env } from "../types/env.ts"; - -/** - * Schema for extracted brand assets from research. - */ -const BrandResearchResultSchema = z.object({ - brandName: z.string().describe("Official brand name"), - tagline: z.string().optional().describe("Brand tagline or slogan"), - description: z.string().optional().describe("Brief brand description"), - - // Colors - colors: z - .object({ - primary: z.string().optional().describe("Primary brand color (hex)"), - secondary: z.string().optional().describe("Secondary brand color (hex)"), - accent: z.string().optional().describe("Accent color (hex)"), - background: z - .string() - .optional() - .describe("Background color preference (hex)"), - text: z.string().optional().describe("Primary text color (hex)"), - palette: z - .array(z.string()) - .optional() - .describe("Full color palette (hex values)"), - }) - .optional() - .describe("Brand color palette"), - - // Logo URLs - logos: z - .object({ - primary: z.string().optional().describe("Primary logo URL"), - light: z - .string() - .optional() - .describe("Light/white logo URL for dark backgrounds"), - dark: z - .string() - .optional() - .describe("Dark/black logo URL for light backgrounds"), - icon: z.string().optional().describe("Square icon/favicon URL"), - alternates: z - .array(z.string()) - .optional() - .describe("Other logo variants found"), - }) - .optional() - .describe("Logo image URLs discovered"), - - // Typography - typography: z - .object({ - headingFont: z.string().optional().describe("Primary heading font"), - bodyFont: z.string().optional().describe("Body text font"), - fontWeights: z - .array(z.string()) - .optional() - .describe("Common font weights used"), - }) - .optional() - .describe("Typography information"), - - // Visual style - style: z - .object({ - aesthetic: z - .string() - .optional() - .describe("Overall visual aesthetic (e.g., modern, minimal, bold)"), - industry: z.string().optional().describe("Industry or sector"), - mood: z.string().optional().describe("Brand mood/tone"), - }) - .optional() - .describe("Visual style attributes"), - - // Sources - sources: z - .array(z.string()) - .optional() - .describe("URLs where information was found"), - - // Research metadata - researchMethod: z - .enum(["perplexity", "firecrawl", "both", "none"]) - .describe("Which binding(s) were used for research"), - confidence: z - .enum(["high", "medium", "low"]) - .describe("Confidence level in the extracted data"), - rawData: z.unknown().optional().describe("Raw data from research tools"), -}); - -/** - * BRAND_RESEARCH - Automatically research and discover brand assets - * - * This tool uses Perplexity and/or Firecrawl bindings (when configured) to: - * 1. Research brand information from the web - * 2. Extract brand identity from the website (colors, fonts, logos) - * 3. Find logo image URLs - * - * If no bindings are configured, returns instructions for manual asset collection. - */ -export const createBrandResearchTool = (env: Env) => - createTool({ - id: "BRAND_RESEARCH", - description: `Automatically research and discover brand assets from a website. - -**Requires:** At least one of PERPLEXITY or FIRECRAWL bindings to be configured. - -**What it does:** -1. **With FIRECRAWL:** Scrapes the website to extract brand identity (colors, typography, logos) - - Uses the 'branding' format for comprehensive design system extraction - - Finds logo images in the page source - -2. **With PERPLEXITY:** Searches for additional brand information - - Finds official logo URLs from brand asset pages - - Discovers brand guidelines and color palettes - - Gets brand taglines and descriptions - -3. **With both:** Combines results for most comprehensive brand research - -**Returns:** Structured brand data including logos, colors, typography, and style. - -**Use before DECK_INIT** to automatically populate brand assets.`, - inputSchema: z.object({ - brandName: z.string().describe("Brand/company name to research"), - websiteUrl: z - .string() - .optional() - .describe("Brand website URL (e.g., 'https://example.com')"), - includeCompetitorAnalysis: z - .boolean() - .optional() - .describe("Also research competitor brands for comparison"), - }), - outputSchema: z.object({ - result: BrandResearchResultSchema, - bindingsAvailable: z.object({ - perplexity: z.boolean(), - firecrawl: z.boolean(), - }), - instructions: z - .string() - .optional() - .describe("Manual instructions if no bindings available"), - }), - execute: async ({ context }) => { - const { brandName, websiteUrl } = context; - - // Check which bindings are available (accessed via closure from env) - const perplexity = env.MESH_REQUEST_CONTEXT?.state?.PERPLEXITY; - const firecrawl = env.MESH_REQUEST_CONTEXT?.state?.FIRECRAWL; - - const hasPerplexity = Boolean(perplexity); - const hasFirecrawl = Boolean(firecrawl); - - // If no bindings, return instructions for manual collection - if (!hasPerplexity && !hasFirecrawl) { - return { - result: { - brandName, - researchMethod: "none" as const, - confidence: "low" as const, - }, - bindingsAvailable: { - perplexity: false, - firecrawl: false, - }, - instructions: `No research bindings configured. To enable automatic brand research: - -1. **Add Perplexity binding** (recommended for logo discovery): - - Searches the web for brand logos and guidelines - - Finds official logo asset pages - -2. **Add Firecrawl binding** (recommended for brand identity): - - Extracts colors, fonts, and typography from websites - - Finds logo URLs in page source - -**Manual alternative:** -Ask the user for: -- Logo image URL (PNG/SVG with transparent background) -- Primary brand color (hex, e.g., #8B5CF6) -- Light logo version for dark backgrounds (optional) -- Dark logo version for light backgrounds (optional) - -${websiteUrl ? `\nYou can also manually check ${websiteUrl} for:\n- /logo.png or /logo.svg\n- Favicon at /favicon.ico\n- tag\n- CSS for brand colors` : ""}`, - }; - } - - // Initialize result - let result: z.infer = { - brandName, - researchMethod: - hasPerplexity && hasFirecrawl - ? "both" - : hasFirecrawl - ? "firecrawl" - : "perplexity", - confidence: "medium", - sources: [], - }; - - // FIRECRAWL: Extract brand identity from website - if (hasFirecrawl && websiteUrl && firecrawl) { - try { - // Use the 'branding' format to extract brand identity - const brandingResult = await firecrawl.firecrawl_scrape({ - url: websiteUrl, - formats: ["branding", "links"], - onlyMainContent: false, - maxAge: 86400000, // 24 hour cache - }); - - if (brandingResult) { - const data = brandingResult as Record; - - // Extract branding data - if (data.branding) { - const branding = data.branding as Record; - - // Extract colors - if (branding.colors || branding.colorPalette) { - const colors = (branding.colors || branding.colorPalette) as - | string[] - | Record; - if (Array.isArray(colors)) { - result.colors = { - primary: colors[0], - secondary: colors[1], - accent: colors[2], - palette: colors, - }; - } else if (typeof colors === "object") { - result.colors = { - primary: colors.primary || colors.main, - secondary: colors.secondary, - accent: colors.accent, - background: colors.background, - text: colors.text, - }; - } - } - - // Extract typography - if (branding.fonts || branding.typography) { - const fonts = (branding.fonts || branding.typography) as - | string[] - | Record; - if (Array.isArray(fonts)) { - result.typography = { - headingFont: fonts[0], - bodyFont: fonts[1] || fonts[0], - }; - } else if (typeof fonts === "object") { - result.typography = { - headingFont: fonts.heading || fonts.primary, - bodyFont: fonts.body || fonts.secondary, - }; - } - } - - // Extract logo if available - if (branding.logo || branding.logos) { - const logos = branding.logo || branding.logos; - if (typeof logos === "string") { - result.logos = { primary: logos }; - } else if (typeof logos === "object") { - const logosObj = logos as Record; - result.logos = { - primary: - logosObj.primary || logosObj.main || logosObj.default, - light: logosObj.light || logosObj.white, - dark: logosObj.dark || logosObj.black, - icon: logosObj.icon || logosObj.favicon, - }; - } - } - } - - // Try to find logo in links - if (data.links && Array.isArray(data.links)) { - const logoLinks = (data.links as string[]).filter( - (link) => - /logo/i.test(link) && - /\.(png|svg|jpg|jpeg|webp)$/i.test(link), - ); - if (logoLinks.length > 0 && !result.logos?.primary) { - result.logos = { - ...result.logos, - primary: logoLinks[0], - alternates: logoLinks.slice(1), - }; - } - } - - result.sources = [...(result.sources || []), websiteUrl]; - result.rawData = { firecrawl: data }; - } - } catch (error) { - console.error("Firecrawl brand extraction failed:", error); - } - } - - // PERPLEXITY: Search for additional brand information - if (hasPerplexity && perplexity) { - try { - // Research query for brand assets - const researchPrompt = `Research the brand "${brandName}"${websiteUrl ? ` (website: ${websiteUrl})` : ""} and provide: - -1. **Official Logo URLs**: Find direct URLs to the brand's logo images. Look for: - - Press kit or media kit pages - - Brand guidelines pages - - About pages with downloadable assets - - SVG or PNG logo files - -2. **Brand Colors**: Find the exact hex color codes for: - - Primary brand color - - Secondary colors - - Accent colors - -3. **Brand Identity**: - - Official tagline or slogan - - Brand description - - Visual style (modern, minimal, corporate, playful, etc.) - -4. **Typography**: - - Primary fonts used - - Any custom or brand-specific fonts - -Please provide specific URLs and exact hex color codes where possible. -Format logo URLs as full URLs (e.g., https://example.com/logo.svg).`; - - const researchResult = (await perplexity.perplexity_research({ - messages: [ - { - role: "system", - content: - "You are a brand research expert. Find specific, actionable brand assets including logo URLs and color codes. Always provide full URLs for images.", - }, - { - role: "user", - content: researchPrompt, - }, - ], - strip_thinking: true, - })) as { response?: string } | undefined; - - if (researchResult?.response) { - const response = researchResult.response; - - // Parse colors from response (look for hex patterns) - const hexPattern = /#[0-9A-Fa-f]{6}\b/g; - const foundColors = response.match(hexPattern); - if (foundColors && foundColors.length > 0) { - result.colors = { - ...result.colors, - primary: result.colors?.primary || foundColors[0], - secondary: result.colors?.secondary || foundColors[1], - accent: result.colors?.accent || foundColors[2], - palette: [ - ...(result.colors?.palette || []), - ...foundColors.filter( - (c: string) => !result.colors?.palette?.includes(c), - ), - ].slice(0, 8), - }; - } - - // Parse logo URLs from response - const urlPattern = - /https?:\/\/[^\s<>"{}|\\^`\[\]]+\.(png|svg|jpg|jpeg|webp)/gi; - const foundUrls = response.match(urlPattern); - if (foundUrls && foundUrls.length > 0) { - const logoUrls = foundUrls.filter( - (url: string) => - /logo|brand|icon|mark/i.test(url) || - /assets|media|press|brand/i.test(url), - ); - if (logoUrls.length > 0) { - result.logos = { - ...result.logos, - primary: result.logos?.primary || logoUrls[0], - alternates: [ - ...(result.logos?.alternates || []), - ...logoUrls.slice(1), - ], - }; - } - } - - // Try to extract tagline - const taglineMatch = response.match( - /tagline[:\s]+["']?([^"'\n.]+)["']?/i, - ); - if (taglineMatch) { - result.tagline = result.tagline || taglineMatch[1].trim(); - } - - // Extract description - const descMatch = response.match( - /(?:description|about|is a)[:\s]+([^.]+\.)/i, - ); - if (descMatch) { - result.description = result.description || descMatch[1].trim(); - } - - result.sources = [...(result.sources || []), "perplexity-research"]; - result.rawData = { - ...((result.rawData as Record) || {}), - perplexity: response, - }; - } - } catch (error) { - console.error("Perplexity research failed:", error); - } - - // Also do a targeted search for logo URLs - try { - const searchResult = (await perplexity.perplexity_search({ - query: `${brandName} logo SVG PNG download official`, - max_results: 5, - })) as { results?: string } | undefined; - - if (searchResult?.results) { - // Parse any URLs from search results - const urlPattern = /https?:\/\/[^\s<>"{}|\\^`\[\]]+\.(png|svg)/gi; - const foundUrls = searchResult.results.match(urlPattern); - if (foundUrls && foundUrls.length > 0) { - result.logos = { - ...result.logos, - alternates: [ - ...(result.logos?.alternates || []), - ...foundUrls.filter( - (url: string) => - url !== result.logos?.primary && - !result.logos?.alternates?.includes(url), - ), - ].slice(0, 5), - }; - } - } - } catch (error) { - console.error("Perplexity logo search failed:", error); - } - } - - // Determine confidence level - const hasLogo = Boolean(result.logos?.primary); - const hasColors = Boolean(result.colors?.primary); - - result.confidence = - hasLogo && hasColors ? "high" : hasLogo || hasColors ? "medium" : "low"; - - return { - result, - bindingsAvailable: { - perplexity: hasPerplexity, - firecrawl: hasFirecrawl, - }, - instructions: - result.confidence === "low" - ? `Limited brand data found. Consider: -1. Manually check ${websiteUrl || "the brand website"} for logo files -2. Ask the user for brand guidelines or logo files -3. Check the brand's press kit or media page` - : undefined, - }; - }, - }); - -/** - * BRAND_RESEARCH_STATUS - Check if research bindings are available - */ -export const createBrandResearchStatusTool = (env: Env) => - createTool({ - id: "BRAND_RESEARCH_STATUS", - description: `Check which brand research bindings are available. - -Returns the status of PERPLEXITY and FIRECRAWL bindings, and explains -what capabilities are available for automatic brand research.`, - inputSchema: z.object({}), - outputSchema: z.object({ - bindings: z.object({ - perplexity: z.object({ - available: z.boolean(), - capabilities: z.array(z.string()), - }), - firecrawl: z.object({ - available: z.boolean(), - capabilities: z.array(z.string()), - }), - }), - canResearchBrands: z.boolean(), - recommendation: z.string(), - }), - execute: async () => { - // Access bindings via closure from env - const perplexity = env.MESH_REQUEST_CONTEXT?.state?.PERPLEXITY; - const firecrawl = env.MESH_REQUEST_CONTEXT?.state?.FIRECRAWL; - - const hasPerplexity = Boolean(perplexity); - const hasFirecrawl = Boolean(firecrawl); - - return { - bindings: { - perplexity: { - available: hasPerplexity, - capabilities: hasPerplexity - ? [ - "Search for brand logo URLs", - "Research brand colors and guidelines", - "Find brand taglines and descriptions", - "Discover press kits and media pages", - ] - : [], - }, - firecrawl: { - available: hasFirecrawl, - capabilities: hasFirecrawl - ? [ - "Extract brand colors from website CSS", - "Identify typography and fonts", - "Find logo images in page source", - "Capture full brand identity from live website", - ] - : [], - }, - }, - canResearchBrands: hasPerplexity || hasFirecrawl, - recommendation: - hasPerplexity && hasFirecrawl - ? "Full brand research available! Use BRAND_RESEARCH tool to automatically discover logos, colors, and typography." - : hasFirecrawl - ? "Firecrawl available for website brand extraction. Add Perplexity for better logo URL discovery." - : hasPerplexity - ? "Perplexity available for brand research. Add Firecrawl for direct website brand extraction." - : "No research bindings configured. Add PERPLEXITY or FIRECRAWL bindings to enable automatic brand research. See documentation for setup instructions.", - }; - }, - }); - -// Export all brand research tools -export const brandResearchTools = [ - createBrandResearchTool, - createBrandResearchStatusTool, -]; diff --git a/slides/server/tools/index.ts b/slides/server/tools/index.ts index 65c9cf17..9d4e1f4c 100644 --- a/slides/server/tools/index.ts +++ b/slides/server/tools/index.ts @@ -5,27 +5,22 @@ * - Deck tools: initialization, info, bundling, engine * - Style tools: style guide management * - Slide tools: CRUD operations for slides - * - Brand research tools: automatic brand discovery (requires bindings) + * + * Note: Brand research is handled by the separate Brand MCP. + * Configure the BRAND binding to use brand discovery features. */ import { deckTools } from "./deck.ts"; import { styleTools } from "./style.ts"; import { slideTools } from "./slides.ts"; -import { brandResearchTools } from "./brand-research.ts"; /** * All tool factory functions. * Each factory takes env and returns a tool definition. * The runtime will call these with the environment. */ -export const tools = [ - ...deckTools, - ...styleTools, - ...slideTools, - ...brandResearchTools, -]; +export const tools = [...deckTools, ...styleTools, ...slideTools]; // Re-export individual tool modules for direct access export { deckTools } from "./deck.ts"; export { styleTools } from "./style.ts"; export { slideTools } from "./slides.ts"; -export { brandResearchTools } from "./brand-research.ts"; diff --git a/slides/server/types/env.ts b/slides/server/types/env.ts index be1436f6..a3decc45 100644 --- a/slides/server/types/env.ts +++ b/slides/server/types/env.ts @@ -1,131 +1,88 @@ /** * Type definitions for the Slides MCP environment. * - * This file defines the state schema including optional bindings - * for external services like Perplexity (research) and Firecrawl (web scraping). + * This file defines the state schema including optional binding + * for the Brand MCP which handles brand research and design system generation. */ import { type DefaultEnv, type BindingRegistry } from "@decocms/runtime"; import { z } from "zod"; /** - * Perplexity binding schema - defines the tools to match + * Brand MCP binding schema - defines the tools to match + * + * The Brand MCP provides comprehensive brand research and design system + * generation capabilities. Slides can use these to automatically create + * brand-aware presentations. */ -const PerplexityBindingSchema = [ - { - name: "perplexity_research", - inputSchema: { - type: "object", - properties: { - messages: { type: "array" }, - strip_thinking: { type: "boolean" }, - }, - required: ["messages"], - }, - }, - { - name: "perplexity_ask", - inputSchema: { - type: "object", - properties: { - messages: { type: "array" }, - }, - required: ["messages"], - }, - }, +const BrandBindingSchema = [ { - name: "perplexity_search", + name: "BRAND_CREATE", inputSchema: { type: "object", properties: { - query: { type: "string" }, + brandName: { type: "string" }, + websiteUrl: { type: "string" }, }, - required: ["query"], + required: ["brandName", "websiteUrl"], }, }, -] as const; - -/** - * Firecrawl binding schema - defines the tools to match - */ -const FirecrawlBindingSchema = [ { - name: "firecrawl_scrape", + name: "BRAND_DISCOVER", inputSchema: { type: "object", properties: { - url: { type: "string" }, - formats: { type: "array" }, + brandName: { type: "string" }, + websiteUrl: { type: "string" }, }, - required: ["url"], + required: ["brandName", "websiteUrl"], }, }, { - name: "firecrawl_crawl", + name: "BRAND_GENERATE", inputSchema: { type: "object", properties: { - url: { type: "string" }, + identity: { type: "object" }, + outputFormat: { type: "string" }, }, - required: ["url"], + required: ["identity"], }, }, { - name: "firecrawl_search", + name: "BRAND_STATUS", inputSchema: { type: "object", - properties: { - query: { type: "string" }, - }, - required: ["query"], + properties: {}, }, }, ] as const; /** - * State schema with optional bindings for brand research. + * State schema with optional Brand MCP binding. * - * When PERPLEXITY or FIRECRAWL bindings are configured, the BRAND_RESEARCH - * tool becomes available to automatically discover brand assets (logos, colors, etc.) - * from websites. + * When the BRAND binding is configured, Slides can use brand research + * capabilities from the Brand MCP to automatically discover brand identity + * and generate design systems for presentations. */ export const StateSchema = z.object({ /** - * Optional Perplexity binding for web research. - * Used to search for brand information, logo URLs, and brand guidelines. + * Optional Brand MCP binding for brand research. + * Provides: BRAND_CREATE, BRAND_DISCOVER, BRAND_GENERATE, BRAND_STATUS */ - PERPLEXITY: z + BRAND: z .object({ - __type: z.literal("@deco/perplexity").default("@deco/perplexity"), - __binding: z - .literal(PerplexityBindingSchema) - .default(PerplexityBindingSchema), + __type: z.literal("@deco/brand").default("@deco/brand"), + __binding: z.literal(BrandBindingSchema).default(BrandBindingSchema), value: z.string(), }) .optional() .describe( - "Perplexity AI for brand research - requires perplexity_research tool", + "Brand MCP for brand research and design system generation - requires BRAND_CREATE tool", ), - - /** - * Optional Firecrawl binding for web scraping. - * Used to extract brand identity (colors, fonts, typography) directly from websites. - */ - FIRECRAWL: z - .object({ - __type: z.literal("@deco/firecrawl").default("@deco/firecrawl"), - __binding: z - .literal(FirecrawlBindingSchema) - .default(FirecrawlBindingSchema), - value: z.string(), - }) - .optional() - .describe("Firecrawl for web scraping - requires firecrawl_scrape tool"), }); /** * Registry type for the bindings. - * We use BindingRegistry directly since the actual binding types - * are determined at runtime by the configured MCP servers. */ export type Registry = BindingRegistry; From 222dd01295e963013c8f24e2f5928ef8455bf382 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sun, 25 Jan 2026 10:58:08 -0300 Subject: [PATCH 11/20] fix(brand,slides): Use correct tool names in binding schemas Brand MCP: - Rename FIRECRAWL binding to SCRAPER (matches content-scraper MCP) - Update Perplexity binding to use ASK/CHAT tool names (not perplexity_*) - Update all tool references from firecrawl to scraper Slides MCP: - Update Brand binding schema with correct BRAND_* tool names The __binding property now contains the actual tool names and schemas that the Mesh admin uses to match available connections. --- brand/server/main.ts | 14 +++--- brand/server/tools/generator.ts | 10 ++-- brand/server/tools/projects.ts | 8 +-- brand/server/tools/research.ts | 44 ++++++++--------- brand/server/types/env.ts | 86 ++++++++++++--------------------- slides/server/types/env.ts | 10 ++-- 6 files changed, 73 insertions(+), 99 deletions(-) diff --git a/brand/server/main.ts b/brand/server/main.ts index 580ee742..8866b2e8 100644 --- a/brand/server/main.ts +++ b/brand/server/main.ts @@ -5,16 +5,16 @@ * * ## Features * - * - **Brand Scraping** - Extract colors, fonts, logos from websites using Firecrawl + * - **Brand Scraping** - Extract colors, fonts, logos from websites using Content Scraper * - **Brand Research** - Deep research using Perplexity AI * - **Design System Generation** - CSS variables, JSX components, style guides * - **MCP Apps UI** - Interactive brand previews * - * ## Required Bindings + * ## Optional Bindings * - * Configure at least one of these for full functionality: - * - **FIRECRAWL** - For website scraping and brand extraction - * - **PERPLEXITY** - For AI-powered brand research + * Configure for full functionality: + * - **SCRAPER** - For website scraping and brand extraction (Content Scraper MCP) + * - **PERPLEXITY** - For AI-powered brand research (Perplexity MCP) */ import { withRuntime } from "@decocms/runtime"; import { tools } from "./tools/index.ts"; @@ -32,7 +32,7 @@ console.log("[brand-mcp] Resources count:", resources.length); const runtime = withRuntime({ configuration: { - scopes: ["PERPLEXITY::*", "FIRECRAWL::*"], + scopes: ["PERPLEXITY::*", "SCRAPER::*"], state: StateSchema, }, tools, @@ -102,7 +102,7 @@ console.log("[brand-mcp] MCP Apps (UI Resources):"); console.log(" - ui://brand-preview - Interactive brand preview"); console.log(" - ui://brand-list - Grid view of brands"); console.log(""); -console.log("[brand-mcp] Required bindings: FIRECRAWL, PERPLEXITY"); +console.log("[brand-mcp] Optional bindings: SCRAPER, PERPLEXITY"); // Copy URL to clipboard on macOS if (process.platform === "darwin") { diff --git a/brand/server/tools/generator.ts b/brand/server/tools/generator.ts index ecf8b22f..94e34f01 100644 --- a/brand/server/tools/generator.ts +++ b/brand/server/tools/generator.ts @@ -676,14 +676,14 @@ This is the main tool - it: execute: async ({ context }) => { const { brandName, websiteUrl } = context; - const firecrawl = env.MESH_REQUEST_CONTEXT?.state?.FIRECRAWL; + const scraper = env.MESH_REQUEST_CONTEXT?.state?.SCRAPER; const perplexity = env.MESH_REQUEST_CONTEXT?.state?.PERPLEXITY; - if (!firecrawl && !perplexity) { + if (!scraper && !perplexity) { return { success: false, error: - "No research bindings available. Configure FIRECRAWL and/or PERPLEXITY bindings.", + "No research bindings available. Configure SCRAPER and/or PERPLEXITY bindings.", }; } @@ -696,9 +696,9 @@ This is the main tool - it: }; // Scrape website - if (firecrawl) { + if (scraper) { try { - const result = (await firecrawl.firecrawl_scrape({ + const result = (await scraper.scrape_content({ url: websiteUrl, formats: ["branding", "links"], })) as { branding?: Record }; diff --git a/brand/server/tools/projects.ts b/brand/server/tools/projects.ts index 81e979ad..5fb1da7d 100644 --- a/brand/server/tools/projects.ts +++ b/brand/server/tools/projects.ts @@ -330,7 +330,7 @@ This combines scraping and AI research, then saves the results to the project.`, project.updatedAt = new Date().toISOString(); projectStore.set(projectId, project); - const firecrawl = env.MESH_REQUEST_CONTEXT?.state?.FIRECRAWL; + const scraper = env.MESH_REQUEST_CONTEXT?.state?.SCRAPER; const perplexity = env.MESH_REQUEST_CONTEXT?.state?.PERPLEXITY; const identity: Partial = { @@ -339,10 +339,10 @@ This combines scraping and AI research, then saves the results to the project.`, confidence: "low", }; - // Scrape with Firecrawl - if (firecrawl) { + // Scrape with Content Scraper + if (scraper) { try { - const result = (await firecrawl.firecrawl_scrape({ + const result = (await scraper.scrape_content({ url, formats: ["branding", "links"], })) as { branding?: Record }; diff --git a/brand/server/tools/research.ts b/brand/server/tools/research.ts index ec2514ae..b4bec7eb 100644 --- a/brand/server/tools/research.ts +++ b/brand/server/tools/research.ts @@ -101,7 +101,7 @@ Uses Firecrawl's 'branding' format to extract: - Logo images found on the page - Visual style (aesthetic, shadows, border radius) -**Requires:** FIRECRAWL binding to be configured. +**Requires:** SCRAPER binding to be configured. **Best for:** When you have the brand's website URL and want to extract their actual design system.`, inputSchema: z.object({ @@ -120,12 +120,12 @@ Uses Firecrawl's 'branding' format to extract: execute: async ({ context }) => { const { url, includeScreenshot } = context; - const firecrawl = env.MESH_REQUEST_CONTEXT?.state?.FIRECRAWL; - if (!firecrawl) { + const scraper = env.MESH_REQUEST_CONTEXT?.state?.SCRAPER; + if (!scraper) { return { success: false, error: - "FIRECRAWL binding not configured. Add the Firecrawl binding to enable website scraping.", + "SCRAPER binding not configured. Add the Content Scraper binding to enable website scraping.", }; } @@ -135,7 +135,7 @@ Uses Firecrawl's 'branding' format to extract: formats.push("screenshot"); } - const result = (await firecrawl.firecrawl_scrape({ + const result = (await scraper.scrape_content({ url, formats, })) as { @@ -401,7 +401,7 @@ export const createBrandDiscoverTool = (env: Env) => description: `Comprehensive brand discovery combining web scraping and AI research. This is the most complete tool - it: -1. Scrapes the brand website for visual identity (if FIRECRAWL available) +1. Scrapes the brand website for visual identity (if SCRAPER available) 2. Researches the brand using AI (if PERPLEXITY available) 3. Combines results into a complete brand identity @@ -420,14 +420,14 @@ This is the most complete tool - it: execute: async ({ context }) => { const { brandName, websiteUrl } = context; - const firecrawl = env.MESH_REQUEST_CONTEXT?.state?.FIRECRAWL; + const scraper = env.MESH_REQUEST_CONTEXT?.state?.SCRAPER; const perplexity = env.MESH_REQUEST_CONTEXT?.state?.PERPLEXITY; - if (!firecrawl && !perplexity) { + if (!scraper && !perplexity) { return { success: false, error: - "No research bindings available. Configure FIRECRAWL and/or PERPLEXITY bindings.", + "No research bindings available. Configure SCRAPER and/or PERPLEXITY bindings.", }; } @@ -441,9 +441,9 @@ This is the most complete tool - it: let researchData: unknown; // Step 1: Scrape the website - if (firecrawl) { + if (scraper) { try { - const scrapeResult = (await firecrawl.firecrawl_scrape({ + const scrapeResult = (await scraper.scrape_content({ url: websiteUrl, formats: ["branding", "links"], })) as { branding?: Record; links?: string[] }; @@ -601,10 +601,10 @@ export const createBrandStatusTool = (env: Env) => id: "BRAND_STATUS", description: `Check which brand research capabilities are available. -Returns the status of FIRECRAWL and PERPLEXITY bindings and what each enables.`, +Returns the status of SCRAPER and PERPLEXITY bindings and what each enables.`, inputSchema: z.object({}), outputSchema: z.object({ - firecrawl: z.object({ + scraper: z.object({ available: z.boolean(), capabilities: z.array(z.string()), }), @@ -615,13 +615,13 @@ Returns the status of FIRECRAWL and PERPLEXITY bindings and what each enables.`, recommendation: z.string(), }), execute: async () => { - const firecrawl = env.MESH_REQUEST_CONTEXT?.state?.FIRECRAWL; + const scraper = env.MESH_REQUEST_CONTEXT?.state?.SCRAPER; const perplexity = env.MESH_REQUEST_CONTEXT?.state?.PERPLEXITY; return { - firecrawl: { - available: Boolean(firecrawl), - capabilities: firecrawl + scraper: { + available: Boolean(scraper), + capabilities: scraper ? [ "Extract colors from website CSS", "Identify typography and fonts", @@ -644,13 +644,13 @@ Returns the status of FIRECRAWL and PERPLEXITY bindings and what each enables.`, : [], }, recommendation: - firecrawl && perplexity + scraper && perplexity ? "Full capabilities available! Use BRAND_DISCOVER for complete brand profiles." - : firecrawl - ? "Firecrawl available for website scraping. Add Perplexity for deeper research." + : scraper + ? "Content Scraper available for website scraping. Add Perplexity for deeper research." : perplexity - ? "Perplexity available for research. Add Firecrawl for direct website extraction." - : "No bindings configured. Add FIRECRAWL and/or PERPLEXITY bindings.", + ? "Perplexity available for research. Add Content Scraper for direct website extraction." + : "No bindings configured. Add SCRAPER and/or PERPLEXITY bindings.", }; }, }); diff --git a/brand/server/types/env.ts b/brand/server/types/env.ts index 3949c988..7cf0c84f 100644 --- a/brand/server/types/env.ts +++ b/brand/server/types/env.ts @@ -1,96 +1,70 @@ /** * Type definitions for the Brand MCP environment. * - * This MCP requires Perplexity and Firecrawl bindings for brand research. + * This MCP uses Perplexity for AI research and Content Scraper for web scraping. */ import { type DefaultEnv, type BindingRegistry } from "@decocms/runtime"; import { z } from "zod"; /** - * Perplexity binding schema - defines the tools to match + * Perplexity binding schema - matches the actual tool names from Perplexity MCP + * + * Perplexity exposes: ASK, CHAT (from @decocms/mcps-shared/search-ai) */ const PerplexityBindingSchema = [ { - name: "perplexity_research", + name: "ASK", inputSchema: { type: "object", properties: { - messages: { type: "array" }, - strip_thinking: { type: "boolean" }, + prompt: { type: "string" }, + model: { type: "string" }, }, - required: ["messages"], + required: ["prompt"], }, }, { - name: "perplexity_ask", + name: "CHAT", inputSchema: { type: "object", properties: { messages: { type: "array" }, + model: { type: "string" }, }, required: ["messages"], }, }, - { - name: "perplexity_search", - inputSchema: { - type: "object", - properties: { - query: { type: "string" }, - }, - required: ["query"], - }, - }, ] as const; /** - * Firecrawl binding schema - defines the tools to match + * Content Scraper binding schema - matches the actual tool names + * + * Content Scraper exposes: scrape_content, get_content_scrape */ -const FirecrawlBindingSchema = [ +const ContentScraperBindingSchema = [ { - name: "firecrawl_scrape", + name: "scrape_content", inputSchema: { type: "object", properties: { url: { type: "string" }, - formats: { type: "array" }, }, required: ["url"], }, }, - { - name: "firecrawl_crawl", - inputSchema: { - type: "object", - properties: { - url: { type: "string" }, - }, - required: ["url"], - }, - }, - { - name: "firecrawl_search", - inputSchema: { - type: "object", - properties: { - query: { type: "string" }, - }, - required: ["query"], - }, - }, ] as const; /** - * State schema with required bindings for brand research. + * State schema with optional bindings for brand research. * * The bindings use __binding to define tool patterns for matching connections: - * - PERPLEXITY: Matches connections with perplexity_* tools - * - FIRECRAWL: Matches connections with firecrawl_* tools + * - PERPLEXITY: Matches connections with ASK/CHAT tools (from search-ai) + * - SCRAPER: Matches connections with scrape_content tool */ export const StateSchema = z.object({ /** * Perplexity binding for web research. - * Matches connections with tools: perplexity_research, perplexity_ask, etc. + * Matches connections with tools: ASK, CHAT */ PERPLEXITY: z .object({ @@ -101,24 +75,26 @@ export const StateSchema = z.object({ value: z.string(), }) .optional() - .describe( - "Perplexity AI for brand research - requires perplexity_research tool", - ), + .describe("Perplexity AI for brand research - requires ASK tool"), /** - * Firecrawl binding for web scraping. - * Matches connections with tools: firecrawl_scrape, firecrawl_crawl, etc. + * Content Scraper binding for web scraping. + * Matches connections with tools: scrape_content */ - FIRECRAWL: z + SCRAPER: z .object({ - __type: z.literal("@deco/firecrawl").default("@deco/firecrawl"), + __type: z + .literal("@deco/content-scraper") + .default("@deco/content-scraper"), __binding: z - .literal(FirecrawlBindingSchema) - .default(FirecrawlBindingSchema), + .literal(ContentScraperBindingSchema) + .default(ContentScraperBindingSchema), value: z.string(), }) .optional() - .describe("Firecrawl for web scraping - requires firecrawl_scrape tool"), + .describe( + "Content Scraper for web scraping - requires scrape_content tool", + ), }); /** diff --git a/slides/server/types/env.ts b/slides/server/types/env.ts index a3decc45..d3c50fcd 100644 --- a/slides/server/types/env.ts +++ b/slides/server/types/env.ts @@ -8,11 +8,9 @@ import { type DefaultEnv, type BindingRegistry } from "@decocms/runtime"; import { z } from "zod"; /** - * Brand MCP binding schema - defines the tools to match + * Brand MCP binding schema - matches the actual tool names from Brand MCP * - * The Brand MCP provides comprehensive brand research and design system - * generation capabilities. Slides can use these to automatically create - * brand-aware presentations. + * Brand MCP exposes: BRAND_CREATE, BRAND_DISCOVER, BRAND_GENERATE, BRAND_STATUS */ const BrandBindingSchema = [ { @@ -23,7 +21,7 @@ const BrandBindingSchema = [ brandName: { type: "string" }, websiteUrl: { type: "string" }, }, - required: ["brandName", "websiteUrl"], + required: ["brandName"], }, }, { @@ -34,7 +32,7 @@ const BrandBindingSchema = [ brandName: { type: "string" }, websiteUrl: { type: "string" }, }, - required: ["brandName", "websiteUrl"], + required: ["brandName"], }, }, { From 5492993e7e49a53de284ded7d53eab0155c11068 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sun, 25 Jan 2026 21:29:49 -0300 Subject: [PATCH 12/20] fix(brand,slides): Match binding schemas to actual tool definitions Brand MCP: - PERPLEXITY binding: ASK tool with prompt (required) - SCRAPER binding: scrape_content with empty input Slides MCP: - BRAND binding: BRAND_CREATE with brandName and websiteUrl (both required) The schemas now exactly match what the target MCPs expose. --- brand/server/types/env.ts | 41 ++++++++++++++----------------------- slides/server/types/env.ts | 42 +++++++------------------------------- 2 files changed, 22 insertions(+), 61 deletions(-) diff --git a/brand/server/types/env.ts b/brand/server/types/env.ts index 7cf0c84f..b604642c 100644 --- a/brand/server/types/env.ts +++ b/brand/server/types/env.ts @@ -7,9 +7,10 @@ import { type DefaultEnv, type BindingRegistry } from "@decocms/runtime"; import { z } from "zod"; /** - * Perplexity binding schema - matches the actual tool names from Perplexity MCP + * Perplexity binding schema - matches the actual tools from Perplexity MCP * - * Perplexity exposes: ASK, CHAT (from @decocms/mcps-shared/search-ai) + * Perplexity MCP exposes ASK and CHAT tools (from @decocms/mcps-shared/search-ai) + * The input schemas must match exactly what the tools expect. */ const PerplexityBindingSchema = [ { @@ -17,39 +18,28 @@ const PerplexityBindingSchema = [ inputSchema: { type: "object", properties: { - prompt: { type: "string" }, - model: { type: "string" }, + prompt: { + type: "string", + description: "The question or prompt to ask", + }, }, required: ["prompt"], }, }, - { - name: "CHAT", - inputSchema: { - type: "object", - properties: { - messages: { type: "array" }, - model: { type: "string" }, - }, - required: ["messages"], - }, - }, ] as const; /** - * Content Scraper binding schema - matches the actual tool names + * Content Scraper binding schema - matches the actual tools from content-scraper MCP * - * Content Scraper exposes: scrape_content, get_content_scrape + * Content Scraper exposes scrape_content with an empty input schema + * (configuration comes from MCP state, not tool input) */ const ContentScraperBindingSchema = [ { name: "scrape_content", inputSchema: { type: "object", - properties: { - url: { type: "string" }, - }, - required: ["url"], + properties: {}, }, }, ] as const; @@ -57,14 +47,13 @@ const ContentScraperBindingSchema = [ /** * State schema with optional bindings for brand research. * - * The bindings use __binding to define tool patterns for matching connections: - * - PERPLEXITY: Matches connections with ASK/CHAT tools (from search-ai) - * - SCRAPER: Matches connections with scrape_content tool + * Bindings are matched by checking if a connection's tools satisfy the binding requirements. + * The __binding property contains the tool patterns used for matching. */ export const StateSchema = z.object({ /** * Perplexity binding for web research. - * Matches connections with tools: ASK, CHAT + * Matches connections with the ASK tool (prompt input required). */ PERPLEXITY: z .object({ @@ -79,7 +68,7 @@ export const StateSchema = z.object({ /** * Content Scraper binding for web scraping. - * Matches connections with tools: scrape_content + * Matches connections with the scrape_content tool. */ SCRAPER: z .object({ diff --git a/slides/server/types/env.ts b/slides/server/types/env.ts index d3c50fcd..a6bb7795 100644 --- a/slides/server/types/env.ts +++ b/slides/server/types/env.ts @@ -8,9 +8,10 @@ import { type DefaultEnv, type BindingRegistry } from "@decocms/runtime"; import { z } from "zod"; /** - * Brand MCP binding schema - matches the actual tool names from Brand MCP + * Brand MCP binding schema - matches the actual tools from Brand MCP * - * Brand MCP exposes: BRAND_CREATE, BRAND_DISCOVER, BRAND_GENERATE, BRAND_STATUS + * Brand MCP exposes BRAND_CREATE which is the main entry point. + * The input schema must match exactly what the tool expects. */ const BrandBindingSchema = [ { @@ -18,39 +19,10 @@ const BrandBindingSchema = [ inputSchema: { type: "object", properties: { - brandName: { type: "string" }, - websiteUrl: { type: "string" }, + brandName: { type: "string", description: "Brand or company name" }, + websiteUrl: { type: "string", description: "Brand website URL" }, }, - required: ["brandName"], - }, - }, - { - name: "BRAND_DISCOVER", - inputSchema: { - type: "object", - properties: { - brandName: { type: "string" }, - websiteUrl: { type: "string" }, - }, - required: ["brandName"], - }, - }, - { - name: "BRAND_GENERATE", - inputSchema: { - type: "object", - properties: { - identity: { type: "object" }, - outputFormat: { type: "string" }, - }, - required: ["identity"], - }, - }, - { - name: "BRAND_STATUS", - inputSchema: { - type: "object", - properties: {}, + required: ["brandName", "websiteUrl"], }, }, ] as const; @@ -65,7 +37,7 @@ const BrandBindingSchema = [ export const StateSchema = z.object({ /** * Optional Brand MCP binding for brand research. - * Provides: BRAND_CREATE, BRAND_DISCOVER, BRAND_GENERATE, BRAND_STATUS + * Matches connections with the BRAND_CREATE tool. */ BRAND: z .object({ From b82552c9de86a621ea254079514acf9342823cc2 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Mon, 26 Jan 2026 16:26:34 -0300 Subject: [PATCH 13/20] fix(brand): Use correct perplexity tool names and multi-element binding - Changed from ASK to perplexity_research (actual tool name) - Added perplexity_ask as second element so Zod serializes as enum array - Input schema now matches actual tool: messages array required --- brand/server/types/env.ts | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/brand/server/types/env.ts b/brand/server/types/env.ts index b604642c..5d766f34 100644 --- a/brand/server/types/env.ts +++ b/brand/server/types/env.ts @@ -7,23 +7,31 @@ import { type DefaultEnv, type BindingRegistry } from "@decocms/runtime"; import { z } from "zod"; /** - * Perplexity binding schema - matches the actual tools from Perplexity MCP + * Perplexity binding schema - matches actual tools from perplexity-agent * - * Perplexity MCP exposes ASK and CHAT tools (from @decocms/mcps-shared/search-ai) - * The input schemas must match exactly what the tools expect. + * From screenshot: perplexity_ask, perplexity_research, perplexity_reason, perplexity_search + * Brand uses perplexity_research with messages array + * + * We include two tools so Zod serializes as enum (array) instead of const (single object) */ const PerplexityBindingSchema = [ { - name: "ASK", + name: "perplexity_research", + inputSchema: { + type: "object", + properties: { + messages: { type: "array" }, + }, + required: ["messages"], + }, + }, + { + name: "perplexity_ask", inputSchema: { type: "object", properties: { - prompt: { - type: "string", - description: "The question or prompt to ask", - }, + query: { type: "string" }, }, - required: ["prompt"], }, }, ] as const; From f0d5f363f0ed43868e6f8144615c1ee6fa84e721 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Mon, 26 Jan 2026 16:43:40 -0300 Subject: [PATCH 14/20] fix(brand): Use permissive binding schema for any Perplexity MCP Now matches both: - Official @perplexity-ai/mcp-server (STDIO) - Custom HTTP implementations Just checks for tool existence (perplexity_ask, perplexity_research), not strict input schema validation. --- brand/server/types/env.ts | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/brand/server/types/env.ts b/brand/server/types/env.ts index 5d766f34..49893c18 100644 --- a/brand/server/types/env.ts +++ b/brand/server/types/env.ts @@ -7,31 +7,28 @@ import { type DefaultEnv, type BindingRegistry } from "@decocms/runtime"; import { z } from "zod"; /** - * Perplexity binding schema - matches actual tools from perplexity-agent + * Perplexity binding schema - matches any Perplexity MCP * - * From screenshot: perplexity_ask, perplexity_research, perplexity_reason, perplexity_search - * Brand uses perplexity_research with messages array + * Works with: + * - Official @perplexity-ai/mcp-server (uses query) + * - Custom implementations (may use messages) * - * We include two tools so Zod serializes as enum (array) instead of const (single object) + * We just check for tool existence, not strict input validation. + * Two tools ensure Zod serializes as enum (array). */ const PerplexityBindingSchema = [ { - name: "perplexity_research", + name: "perplexity_ask", inputSchema: { type: "object", - properties: { - messages: { type: "array" }, - }, - required: ["messages"], + properties: {}, }, }, { - name: "perplexity_ask", + name: "perplexity_research", inputSchema: { type: "object", - properties: { - query: { type: "string" }, - }, + properties: {}, }, }, ] as const; From 19b4409760f9f1498c6123050c950eb65d46c888 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Mon, 26 Jan 2026 18:29:43 -0300 Subject: [PATCH 15/20] refactor(brand,slides): Use generic binding type matched by tool format - Changed __type from "@deco/perplexity" to "binding" - Bindings now match ANY MCP with the required tools - No longer tied to specific MCP implementations Brand expects: perplexity_ask, perplexity_research, scrape_content/firecrawl_scrape Slides expects: BRAND_CREATE, BRAND_DISCOVER --- brand/server/types/env.ts | 64 ++++++++++++-------------------------- slides/server/types/env.ts | 36 +++++++-------------- 2 files changed, 31 insertions(+), 69 deletions(-) diff --git a/brand/server/types/env.ts b/brand/server/types/env.ts index 49893c18..ef11394e 100644 --- a/brand/server/types/env.ts +++ b/brand/server/types/env.ts @@ -1,94 +1,70 @@ /** * Type definitions for the Brand MCP environment. * - * This MCP uses Perplexity for AI research and Content Scraper for web scraping. + * This MCP uses bindings matched by tool format, not specific MCP names. */ import { type DefaultEnv, type BindingRegistry } from "@decocms/runtime"; import { z } from "zod"; /** - * Perplexity binding schema - matches any Perplexity MCP - * - * Works with: - * - Official @perplexity-ai/mcp-server (uses query) - * - Custom implementations (may use messages) - * - * We just check for tool existence, not strict input validation. - * Two tools ensure Zod serializes as enum (array). + * Perplexity binding - matches any MCP with perplexity_ask and perplexity_research tools */ const PerplexityBindingSchema = [ { name: "perplexity_ask", - inputSchema: { - type: "object", - properties: {}, - }, + inputSchema: { type: "object", properties: {} }, }, { name: "perplexity_research", - inputSchema: { - type: "object", - properties: {}, - }, + inputSchema: { type: "object", properties: {} }, }, ] as const; /** - * Content Scraper binding schema - matches the actual tools from content-scraper MCP - * - * Content Scraper exposes scrape_content with an empty input schema - * (configuration comes from MCP state, not tool input) + * Scraper binding - matches any MCP with scrape_content tool */ -const ContentScraperBindingSchema = [ +const ScraperBindingSchema = [ { name: "scrape_content", - inputSchema: { - type: "object", - properties: {}, - }, + inputSchema: { type: "object", properties: {} }, + }, + { + name: "firecrawl_scrape", + inputSchema: { type: "object", properties: {} }, }, ] as const; /** - * State schema with optional bindings for brand research. + * State schema with bindings matched by tool format. * - * Bindings are matched by checking if a connection's tools satisfy the binding requirements. - * The __binding property contains the tool patterns used for matching. + * Uses generic "binding" type so any connection with matching tools is shown. */ export const StateSchema = z.object({ /** - * Perplexity binding for web research. - * Matches connections with the ASK tool (prompt input required). + * Perplexity binding - any MCP with perplexity_ask/perplexity_research tools */ PERPLEXITY: z .object({ - __type: z.literal("@deco/perplexity").default("@deco/perplexity"), + __type: z.literal("binding").default("binding"), __binding: z .literal(PerplexityBindingSchema) .default(PerplexityBindingSchema), value: z.string(), }) .optional() - .describe("Perplexity AI for brand research - requires ASK tool"), + .describe("AI research - requires perplexity_ask and perplexity_research"), /** - * Content Scraper binding for web scraping. - * Matches connections with the scrape_content tool. + * Scraper binding - any MCP with scrape_content or firecrawl_scrape tools */ SCRAPER: z .object({ - __type: z - .literal("@deco/content-scraper") - .default("@deco/content-scraper"), - __binding: z - .literal(ContentScraperBindingSchema) - .default(ContentScraperBindingSchema), + __type: z.literal("binding").default("binding"), + __binding: z.literal(ScraperBindingSchema).default(ScraperBindingSchema), value: z.string(), }) .optional() - .describe( - "Content Scraper for web scraping - requires scrape_content tool", - ), + .describe("Web scraping - requires scrape_content or firecrawl_scrape"), }); /** diff --git a/slides/server/types/env.ts b/slides/server/types/env.ts index a6bb7795..273acb15 100644 --- a/slides/server/types/env.ts +++ b/slides/server/types/env.ts @@ -1,54 +1,40 @@ /** * Type definitions for the Slides MCP environment. * - * This file defines the state schema including optional binding - * for the Brand MCP which handles brand research and design system generation. + * Uses bindings matched by tool format, not specific MCP names. */ import { type DefaultEnv, type BindingRegistry } from "@decocms/runtime"; import { z } from "zod"; /** - * Brand MCP binding schema - matches the actual tools from Brand MCP - * - * Brand MCP exposes BRAND_CREATE which is the main entry point. - * The input schema must match exactly what the tool expects. + * Brand MCP binding - matches any MCP with BRAND_CREATE tool */ const BrandBindingSchema = [ { name: "BRAND_CREATE", - inputSchema: { - type: "object", - properties: { - brandName: { type: "string", description: "Brand or company name" }, - websiteUrl: { type: "string", description: "Brand website URL" }, - }, - required: ["brandName", "websiteUrl"], - }, + inputSchema: { type: "object", properties: {} }, + }, + { + name: "BRAND_DISCOVER", + inputSchema: { type: "object", properties: {} }, }, ] as const; /** - * State schema with optional Brand MCP binding. - * - * When the BRAND binding is configured, Slides can use brand research - * capabilities from the Brand MCP to automatically discover brand identity - * and generate design systems for presentations. + * State schema with bindings matched by tool format. */ export const StateSchema = z.object({ /** - * Optional Brand MCP binding for brand research. - * Matches connections with the BRAND_CREATE tool. + * Brand MCP binding - any MCP with BRAND_CREATE/BRAND_DISCOVER tools */ BRAND: z .object({ - __type: z.literal("@deco/brand").default("@deco/brand"), + __type: z.literal("binding").default("binding"), __binding: z.literal(BrandBindingSchema).default(BrandBindingSchema), value: z.string(), }) .optional() - .describe( - "Brand MCP for brand research and design system generation - requires BRAND_CREATE tool", - ), + .describe("Brand research - requires BRAND_CREATE and BRAND_DISCOVER"), }); /** From 5172eee60cdc58758b6594439404e67cefbf4b7a Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Mon, 26 Jan 2026 18:31:02 -0300 Subject: [PATCH 16/20] refactor(brand,slides): Use BindingOf with Registry like mcp-studio - Define Registry interface with tool signatures for each binding type - Use BindingOf for type-safe bindings - Follows same pattern as mcp-studio Brand Registry: - @deco/perplexity: perplexity_ask, perplexity_research (opt) - @deco/scraper: scrape_content (opt), firecrawl_scrape (opt) Slides Registry: - @deco/brand: BRAND_CREATE, BRAND_DISCOVER (opt) --- brand/server/types/env.ts | 97 +++++++++++++++----------------------- slides/server/types/env.ts | 55 ++++++++++----------- 2 files changed, 64 insertions(+), 88 deletions(-) diff --git a/brand/server/types/env.ts b/brand/server/types/env.ts index ef11394e..8f7d6c20 100644 --- a/brand/server/types/env.ts +++ b/brand/server/types/env.ts @@ -1,77 +1,58 @@ /** - * Type definitions for the Brand MCP environment. + * Environment Type Definitions for Brand MCP * - * This MCP uses bindings matched by tool format, not specific MCP names. + * Uses BindingOf with tool-based matching via Registry. */ -import { type DefaultEnv, type BindingRegistry } from "@decocms/runtime"; +import { + BindingOf, + type DefaultEnv, + type BindingRegistry, +} from "@decocms/runtime"; import { z } from "zod"; /** - * Perplexity binding - matches any MCP with perplexity_ask and perplexity_research tools + * Registry defines the tool signatures for each binding type. + * Connections with matching tools will be shown as options. */ -const PerplexityBindingSchema = [ - { - name: "perplexity_ask", - inputSchema: { type: "object", properties: {} }, - }, - { - name: "perplexity_research", - inputSchema: { type: "object", properties: {} }, - }, -] as const; +export interface Registry extends BindingRegistry { + "@deco/perplexity": [ + { + name: "perplexity_ask"; + inputSchema: z.ZodType<{ query?: string; messages?: unknown[] }>; + }, + { + name: "perplexity_research"; + inputSchema: z.ZodType<{ query?: string; messages?: unknown[] }>; + opt?: true; + }, + ]; + "@deco/scraper": [ + { + name: "scrape_content"; + inputSchema: z.ZodType<{ url?: string }>; + opt?: true; + }, + { + name: "firecrawl_scrape"; + inputSchema: z.ZodType<{ url?: string }>; + opt?: true; + }, + ]; +} /** - * Scraper binding - matches any MCP with scrape_content tool - */ -const ScraperBindingSchema = [ - { - name: "scrape_content", - inputSchema: { type: "object", properties: {} }, - }, - { - name: "firecrawl_scrape", - inputSchema: { type: "object", properties: {} }, - }, -] as const; - -/** - * State schema with bindings matched by tool format. - * - * Uses generic "binding" type so any connection with matching tools is shown. + * State schema using BindingOf for clean binding declarations. */ export const StateSchema = z.object({ - /** - * Perplexity binding - any MCP with perplexity_ask/perplexity_research tools - */ - PERPLEXITY: z - .object({ - __type: z.literal("binding").default("binding"), - __binding: z - .literal(PerplexityBindingSchema) - .default(PerplexityBindingSchema), - value: z.string(), - }) + PERPLEXITY: BindingOf("@deco/perplexity") .optional() - .describe("AI research - requires perplexity_ask and perplexity_research"), + .describe("AI research - any MCP with perplexity_ask tool"), - /** - * Scraper binding - any MCP with scrape_content or firecrawl_scrape tools - */ - SCRAPER: z - .object({ - __type: z.literal("binding").default("binding"), - __binding: z.literal(ScraperBindingSchema).default(ScraperBindingSchema), - value: z.string(), - }) + SCRAPER: BindingOf("@deco/scraper") .optional() - .describe("Web scraping - requires scrape_content or firecrawl_scrape"), + .describe("Web scraping - any MCP with scrape_content or firecrawl_scrape"), }); -/** - * Registry type for the bindings. - */ -export type Registry = BindingRegistry; - /** * Environment type for the Brand MCP. */ diff --git a/slides/server/types/env.ts b/slides/server/types/env.ts index 273acb15..8c91a4ac 100644 --- a/slides/server/types/env.ts +++ b/slides/server/types/env.ts @@ -1,47 +1,42 @@ /** - * Type definitions for the Slides MCP environment. + * Environment Type Definitions for Slides MCP * - * Uses bindings matched by tool format, not specific MCP names. + * Uses BindingOf with tool-based matching via Registry. */ -import { type DefaultEnv, type BindingRegistry } from "@decocms/runtime"; +import { + BindingOf, + type DefaultEnv, + type BindingRegistry, +} from "@decocms/runtime"; import { z } from "zod"; /** - * Brand MCP binding - matches any MCP with BRAND_CREATE tool + * Registry defines the tool signatures for each binding type. + * Connections with matching tools will be shown as options. */ -const BrandBindingSchema = [ - { - name: "BRAND_CREATE", - inputSchema: { type: "object", properties: {} }, - }, - { - name: "BRAND_DISCOVER", - inputSchema: { type: "object", properties: {} }, - }, -] as const; +export interface Registry extends BindingRegistry { + "@deco/brand": [ + { + name: "BRAND_CREATE"; + inputSchema: z.ZodType<{ brandName: string; websiteUrl?: string }>; + }, + { + name: "BRAND_DISCOVER"; + inputSchema: z.ZodType<{ brandName: string; websiteUrl?: string }>; + opt?: true; + }, + ]; +} /** - * State schema with bindings matched by tool format. + * State schema using BindingOf for clean binding declarations. */ export const StateSchema = z.object({ - /** - * Brand MCP binding - any MCP with BRAND_CREATE/BRAND_DISCOVER tools - */ - BRAND: z - .object({ - __type: z.literal("binding").default("binding"), - __binding: z.literal(BrandBindingSchema).default(BrandBindingSchema), - value: z.string(), - }) + BRAND: BindingOf("@deco/brand") .optional() - .describe("Brand research - requires BRAND_CREATE and BRAND_DISCOVER"), + .describe("Brand research - any MCP with BRAND_CREATE tool"), }); -/** - * Registry type for the bindings. - */ -export type Registry = BindingRegistry; - /** * Environment type for the Slides MCP. */ From aeca62b1bcdc1564eeb1dd21ba89ed0b232b5864 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Mon, 26 Jan 2026 18:31:52 -0300 Subject: [PATCH 17/20] simplify(brand,slides): Use BindingOf directly like mcp-studio --- brand/server/types/env.ts | 49 ++++---------------------------------- slides/server/types/env.ts | 32 +++---------------------- 2 files changed, 8 insertions(+), 73 deletions(-) diff --git a/brand/server/types/env.ts b/brand/server/types/env.ts index 8f7d6c20..a3ca3c79 100644 --- a/brand/server/types/env.ts +++ b/brand/server/types/env.ts @@ -1,7 +1,5 @@ /** * Environment Type Definitions for Brand MCP - * - * Uses BindingOf with tool-based matching via Registry. */ import { BindingOf, @@ -10,50 +8,13 @@ import { } from "@decocms/runtime"; import { z } from "zod"; -/** - * Registry defines the tool signatures for each binding type. - * Connections with matching tools will be shown as options. - */ -export interface Registry extends BindingRegistry { - "@deco/perplexity": [ - { - name: "perplexity_ask"; - inputSchema: z.ZodType<{ query?: string; messages?: unknown[] }>; - }, - { - name: "perplexity_research"; - inputSchema: z.ZodType<{ query?: string; messages?: unknown[] }>; - opt?: true; - }, - ]; - "@deco/scraper": [ - { - name: "scrape_content"; - inputSchema: z.ZodType<{ url?: string }>; - opt?: true; - }, - { - name: "firecrawl_scrape"; - inputSchema: z.ZodType<{ url?: string }>; - opt?: true; - }, - ]; -} - -/** - * State schema using BindingOf for clean binding declarations. - */ export const StateSchema = z.object({ - PERPLEXITY: BindingOf("@deco/perplexity") + PERPLEXITY: BindingOf("@deco/perplexity") .optional() - .describe("AI research - any MCP with perplexity_ask tool"), - - SCRAPER: BindingOf("@deco/scraper") + .describe("AI research - any MCP with perplexity tools"), + SCRAPER: BindingOf("@deco/scraper") .optional() - .describe("Web scraping - any MCP with scrape_content or firecrawl_scrape"), + .describe("Web scraping - any MCP with scrape tools"), }); -/** - * Environment type for the Brand MCP. - */ -export type Env = DefaultEnv; +export type Env = DefaultEnv; diff --git a/slides/server/types/env.ts b/slides/server/types/env.ts index 8c91a4ac..857a7779 100644 --- a/slides/server/types/env.ts +++ b/slides/server/types/env.ts @@ -1,7 +1,5 @@ /** * Environment Type Definitions for Slides MCP - * - * Uses BindingOf with tool-based matching via Registry. */ import { BindingOf, @@ -10,34 +8,10 @@ import { } from "@decocms/runtime"; import { z } from "zod"; -/** - * Registry defines the tool signatures for each binding type. - * Connections with matching tools will be shown as options. - */ -export interface Registry extends BindingRegistry { - "@deco/brand": [ - { - name: "BRAND_CREATE"; - inputSchema: z.ZodType<{ brandName: string; websiteUrl?: string }>; - }, - { - name: "BRAND_DISCOVER"; - inputSchema: z.ZodType<{ brandName: string; websiteUrl?: string }>; - opt?: true; - }, - ]; -} - -/** - * State schema using BindingOf for clean binding declarations. - */ export const StateSchema = z.object({ - BRAND: BindingOf("@deco/brand") + BRAND: BindingOf("@deco/brand") .optional() - .describe("Brand research - any MCP with BRAND_CREATE tool"), + .describe("Brand research - any MCP with BRAND tools"), }); -/** - * Environment type for the Slides MCP. - */ -export type Env = DefaultEnv; +export type Env = DefaultEnv; From a69e5d56c7a9a90af7e4f24af7e98a16c3465795 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Mon, 26 Jan 2026 18:37:55 -0300 Subject: [PATCH 18/20] feat(shared): Add @deco/perplexity to Registry Adds perplexity binding matching official @perplexity-ai/mcp-server: - perplexity_ask: messages array input - perplexity_research: messages array input (opt) - perplexity_reason: messages array input (opt) --- shared/registry.ts | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/shared/registry.ts b/shared/registry.ts index 67b96f41..529d72e2 100644 --- a/shared/registry.ts +++ b/shared/registry.ts @@ -96,4 +96,44 @@ export interface Registry extends BindingRegistry { }>; }, ]; + + /** + * Perplexity binding - matches official @perplexity-ai/mcp-server + * + * Tools: perplexity_ask, perplexity_reason, perplexity_research + * All accept messages array with role/content + */ + "@deco/perplexity": [ + { + name: "perplexity_ask"; + inputSchema: z.ZodType<{ + messages: Array<{ role: string; content: string }>; + }>; + outputSchema: z.ZodType<{ + content: string; + citations?: string[]; + }>; + }, + { + name: "perplexity_research"; + inputSchema: z.ZodType<{ + messages: Array<{ role: string; content: string }>; + }>; + outputSchema: z.ZodType<{ + content: string; + citations?: string[]; + }>; + opt?: true; + }, + { + name: "perplexity_reason"; + inputSchema: z.ZodType<{ + messages: Array<{ role: string; content: string }>; + }>; + outputSchema: z.ZodType<{ + content: string; + }>; + opt?: true; + }, + ]; } From a2ae9bb33ac17c0340d74b1d94e39addb00c0af4 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Mon, 26 Jan 2026 18:43:06 -0300 Subject: [PATCH 19/20] rename: @deco/perplexity -> @deco/perplexity-ai --- brand/server/types/env.ts | 2 +- shared/registry.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/brand/server/types/env.ts b/brand/server/types/env.ts index a3ca3c79..19e3df2c 100644 --- a/brand/server/types/env.ts +++ b/brand/server/types/env.ts @@ -9,7 +9,7 @@ import { import { z } from "zod"; export const StateSchema = z.object({ - PERPLEXITY: BindingOf("@deco/perplexity") + PERPLEXITY: BindingOf("@deco/perplexity-ai") .optional() .describe("AI research - any MCP with perplexity tools"), SCRAPER: BindingOf("@deco/scraper") diff --git a/shared/registry.ts b/shared/registry.ts index 529d72e2..7892d867 100644 --- a/shared/registry.ts +++ b/shared/registry.ts @@ -103,7 +103,7 @@ export interface Registry extends BindingRegistry { * Tools: perplexity_ask, perplexity_reason, perplexity_research * All accept messages array with role/content */ - "@deco/perplexity": [ + "@deco/perplexity-ai": [ { name: "perplexity_ask"; inputSchema: z.ZodType<{ From 7a4ad929711b8d68592979c2cc61c9a5098dc89e Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Mon, 26 Jan 2026 18:55:14 -0300 Subject: [PATCH 20/20] refactor(brand): Use @deco/firecrawl binding instead of @deco/scraper Updated Brand MCP to use Firecrawl binding: - Renamed SCRAPER to FIRECRAWL in StateSchema - Updated scopes from SCRAPER::* to FIRECRAWL::* - Changed tool calls from scrape_content to firecrawl_scrape - Updated all messages and documentation Also added @deco/firecrawl to shared registry. --- brand/server/main.ts | 8 +++--- brand/server/tools/generator.ts | 10 +++---- brand/server/tools/projects.ts | 6 ++-- brand/server/tools/research.ts | 44 ++++++++++++++--------------- brand/server/types/env.ts | 10 +++++-- shared/registry.ts | 49 +++++++++++++++++++++++++++++++++ 6 files changed, 90 insertions(+), 37 deletions(-) diff --git a/brand/server/main.ts b/brand/server/main.ts index 8866b2e8..974ac8eb 100644 --- a/brand/server/main.ts +++ b/brand/server/main.ts @@ -5,7 +5,7 @@ * * ## Features * - * - **Brand Scraping** - Extract colors, fonts, logos from websites using Content Scraper + * - **Brand Scraping** - Extract colors, fonts, logos from websites using Firecrawl * - **Brand Research** - Deep research using Perplexity AI * - **Design System Generation** - CSS variables, JSX components, style guides * - **MCP Apps UI** - Interactive brand previews @@ -13,8 +13,8 @@ * ## Optional Bindings * * Configure for full functionality: - * - **SCRAPER** - For website scraping and brand extraction (Content Scraper MCP) - * - **PERPLEXITY** - For AI-powered brand research (Perplexity MCP) + * - **FIRECRAWL** - For website scraping and brand extraction (firecrawl-mcp) + * - **PERPLEXITY** - For AI-powered brand research (@perplexity-ai/mcp-server) */ import { withRuntime } from "@decocms/runtime"; import { tools } from "./tools/index.ts"; @@ -32,7 +32,7 @@ console.log("[brand-mcp] Resources count:", resources.length); const runtime = withRuntime({ configuration: { - scopes: ["PERPLEXITY::*", "SCRAPER::*"], + scopes: ["PERPLEXITY::*", "FIRECRAWL::*"], state: StateSchema, }, tools, diff --git a/brand/server/tools/generator.ts b/brand/server/tools/generator.ts index 94e34f01..ecf8b22f 100644 --- a/brand/server/tools/generator.ts +++ b/brand/server/tools/generator.ts @@ -676,14 +676,14 @@ This is the main tool - it: execute: async ({ context }) => { const { brandName, websiteUrl } = context; - const scraper = env.MESH_REQUEST_CONTEXT?.state?.SCRAPER; + const firecrawl = env.MESH_REQUEST_CONTEXT?.state?.FIRECRAWL; const perplexity = env.MESH_REQUEST_CONTEXT?.state?.PERPLEXITY; - if (!scraper && !perplexity) { + if (!firecrawl && !perplexity) { return { success: false, error: - "No research bindings available. Configure SCRAPER and/or PERPLEXITY bindings.", + "No research bindings available. Configure FIRECRAWL and/or PERPLEXITY bindings.", }; } @@ -696,9 +696,9 @@ This is the main tool - it: }; // Scrape website - if (scraper) { + if (firecrawl) { try { - const result = (await scraper.scrape_content({ + const result = (await firecrawl.firecrawl_scrape({ url: websiteUrl, formats: ["branding", "links"], })) as { branding?: Record }; diff --git a/brand/server/tools/projects.ts b/brand/server/tools/projects.ts index 5fb1da7d..a267997a 100644 --- a/brand/server/tools/projects.ts +++ b/brand/server/tools/projects.ts @@ -330,7 +330,7 @@ This combines scraping and AI research, then saves the results to the project.`, project.updatedAt = new Date().toISOString(); projectStore.set(projectId, project); - const scraper = env.MESH_REQUEST_CONTEXT?.state?.SCRAPER; + const firecrawl = env.MESH_REQUEST_CONTEXT?.state?.FIRECRAWL; const perplexity = env.MESH_REQUEST_CONTEXT?.state?.PERPLEXITY; const identity: Partial = { @@ -340,9 +340,9 @@ This combines scraping and AI research, then saves the results to the project.`, }; // Scrape with Content Scraper - if (scraper) { + if (firecrawl) { try { - const result = (await scraper.scrape_content({ + const result = (await firecrawl.firecrawl_scrape({ url, formats: ["branding", "links"], })) as { branding?: Record }; diff --git a/brand/server/tools/research.ts b/brand/server/tools/research.ts index b4bec7eb..ec2514ae 100644 --- a/brand/server/tools/research.ts +++ b/brand/server/tools/research.ts @@ -101,7 +101,7 @@ Uses Firecrawl's 'branding' format to extract: - Logo images found on the page - Visual style (aesthetic, shadows, border radius) -**Requires:** SCRAPER binding to be configured. +**Requires:** FIRECRAWL binding to be configured. **Best for:** When you have the brand's website URL and want to extract their actual design system.`, inputSchema: z.object({ @@ -120,12 +120,12 @@ Uses Firecrawl's 'branding' format to extract: execute: async ({ context }) => { const { url, includeScreenshot } = context; - const scraper = env.MESH_REQUEST_CONTEXT?.state?.SCRAPER; - if (!scraper) { + const firecrawl = env.MESH_REQUEST_CONTEXT?.state?.FIRECRAWL; + if (!firecrawl) { return { success: false, error: - "SCRAPER binding not configured. Add the Content Scraper binding to enable website scraping.", + "FIRECRAWL binding not configured. Add the Firecrawl binding to enable website scraping.", }; } @@ -135,7 +135,7 @@ Uses Firecrawl's 'branding' format to extract: formats.push("screenshot"); } - const result = (await scraper.scrape_content({ + const result = (await firecrawl.firecrawl_scrape({ url, formats, })) as { @@ -401,7 +401,7 @@ export const createBrandDiscoverTool = (env: Env) => description: `Comprehensive brand discovery combining web scraping and AI research. This is the most complete tool - it: -1. Scrapes the brand website for visual identity (if SCRAPER available) +1. Scrapes the brand website for visual identity (if FIRECRAWL available) 2. Researches the brand using AI (if PERPLEXITY available) 3. Combines results into a complete brand identity @@ -420,14 +420,14 @@ This is the most complete tool - it: execute: async ({ context }) => { const { brandName, websiteUrl } = context; - const scraper = env.MESH_REQUEST_CONTEXT?.state?.SCRAPER; + const firecrawl = env.MESH_REQUEST_CONTEXT?.state?.FIRECRAWL; const perplexity = env.MESH_REQUEST_CONTEXT?.state?.PERPLEXITY; - if (!scraper && !perplexity) { + if (!firecrawl && !perplexity) { return { success: false, error: - "No research bindings available. Configure SCRAPER and/or PERPLEXITY bindings.", + "No research bindings available. Configure FIRECRAWL and/or PERPLEXITY bindings.", }; } @@ -441,9 +441,9 @@ This is the most complete tool - it: let researchData: unknown; // Step 1: Scrape the website - if (scraper) { + if (firecrawl) { try { - const scrapeResult = (await scraper.scrape_content({ + const scrapeResult = (await firecrawl.firecrawl_scrape({ url: websiteUrl, formats: ["branding", "links"], })) as { branding?: Record; links?: string[] }; @@ -601,10 +601,10 @@ export const createBrandStatusTool = (env: Env) => id: "BRAND_STATUS", description: `Check which brand research capabilities are available. -Returns the status of SCRAPER and PERPLEXITY bindings and what each enables.`, +Returns the status of FIRECRAWL and PERPLEXITY bindings and what each enables.`, inputSchema: z.object({}), outputSchema: z.object({ - scraper: z.object({ + firecrawl: z.object({ available: z.boolean(), capabilities: z.array(z.string()), }), @@ -615,13 +615,13 @@ Returns the status of SCRAPER and PERPLEXITY bindings and what each enables.`, recommendation: z.string(), }), execute: async () => { - const scraper = env.MESH_REQUEST_CONTEXT?.state?.SCRAPER; + const firecrawl = env.MESH_REQUEST_CONTEXT?.state?.FIRECRAWL; const perplexity = env.MESH_REQUEST_CONTEXT?.state?.PERPLEXITY; return { - scraper: { - available: Boolean(scraper), - capabilities: scraper + firecrawl: { + available: Boolean(firecrawl), + capabilities: firecrawl ? [ "Extract colors from website CSS", "Identify typography and fonts", @@ -644,13 +644,13 @@ Returns the status of SCRAPER and PERPLEXITY bindings and what each enables.`, : [], }, recommendation: - scraper && perplexity + firecrawl && perplexity ? "Full capabilities available! Use BRAND_DISCOVER for complete brand profiles." - : scraper - ? "Content Scraper available for website scraping. Add Perplexity for deeper research." + : firecrawl + ? "Firecrawl available for website scraping. Add Perplexity for deeper research." : perplexity - ? "Perplexity available for research. Add Content Scraper for direct website extraction." - : "No bindings configured. Add SCRAPER and/or PERPLEXITY bindings.", + ? "Perplexity available for research. Add Firecrawl for direct website extraction." + : "No bindings configured. Add FIRECRAWL and/or PERPLEXITY bindings.", }; }, }); diff --git a/brand/server/types/env.ts b/brand/server/types/env.ts index 19e3df2c..d2c619d5 100644 --- a/brand/server/types/env.ts +++ b/brand/server/types/env.ts @@ -11,10 +11,14 @@ import { z } from "zod"; export const StateSchema = z.object({ PERPLEXITY: BindingOf("@deco/perplexity-ai") .optional() - .describe("AI research - any MCP with perplexity tools"), - SCRAPER: BindingOf("@deco/scraper") + .describe( + "Perplexity AI for brand research - searches for logos, colors, brand identity", + ), + FIRECRAWL: BindingOf("@deco/firecrawl") .optional() - .describe("Web scraping - any MCP with scrape tools"), + .describe( + "Firecrawl for web scraping - extracts brand assets from websites", + ), }); export type Env = DefaultEnv; diff --git a/shared/registry.ts b/shared/registry.ts index 7892d867..3241e3e5 100644 --- a/shared/registry.ts +++ b/shared/registry.ts @@ -136,4 +136,53 @@ export interface Registry extends BindingRegistry { opt?: true; }, ]; + + /** + * Firecrawl binding - matches official firecrawl-mcp + * + * Tools: firecrawl_scrape, firecrawl_crawl, firecrawl_map, firecrawl_search, etc. + */ + "@deco/firecrawl": [ + { + name: "firecrawl_scrape"; + inputSchema: z.ZodType<{ + url: string; + formats?: string[]; + onlyMainContent?: boolean; + }>; + outputSchema: z.ZodType<{ + success: boolean; + data?: unknown; + error?: string; + }>; + }, + { + name: "firecrawl_crawl"; + inputSchema: z.ZodType<{ + url: string; + maxDepth?: number; + limit?: number; + }>; + outputSchema: z.ZodType<{ + success: boolean; + data?: unknown; + error?: string; + }>; + opt?: true; + }, + { + name: "firecrawl_map"; + inputSchema: z.ZodType<{ + url: string; + search?: string; + limit?: number; + }>; + outputSchema: z.ZodType<{ + success: boolean; + data?: unknown; + error?: string; + }>; + opt?: true; + }, + ]; }