diff --git a/.gitignore b/.gitignore index 148c62222d..d7f6534936 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,9 @@ modules/**/ts-tmp build/* *.tsbuildinfo +# generated at build time by build:discover +modules/mcp/lib/stories-config.json + # intellij .idea junit.xml diff --git a/modules/mcp/README.md b/modules/mcp/README.md index b5f713c0a0..8afbd741aa 100644 --- a/modules/mcp/README.md +++ b/modules/mcp/README.md @@ -1,6 +1,6 @@ # Canvas Kit MCP -Model Context Protocol (MCP) server that provides Canvas Kit upgrade guides and design token migration documentation to AI assistants. +Our MCP server provides resources and tools to help you work with Canvas Kit components. ## Installation @@ -23,120 +23,101 @@ Add to your MCP servers configuration file: "mcpServers": { "canvas-kit-mcp": { "command": "npx", - "args": [ - "-y", - "@workday/canvas-kit-mcp" - ] + "args": ["-y", "@workday/canvas-kit-mcp"] } } } ``` -Configuration file locations: -- **Cursor**: `~/.cursor-tutor/config.json` (macOS/Linux) or `%APPDATA%\cursor-tutor\config.json` (Windows) -- **Windsurf**: Settings → MCP Servers -- **VS Code**: Cline/Continue extension settings - ### Claude Code CLI ```sh claude mcp add --scope project --transport stdio canvas-kit -- npx -y @workday/canvas-kit-mcp ``` -### Claude Desktop - -Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS): - -```json -{ - "mcpServers": { - "canvas-kit-mcp": { - "command": "npx", - "args": [ - "-y", - "@workday/canvas-kit-mcp" - ] - } - } -} -``` - ## Tools -This MCP server provides the following tools: - ### `get-canvas-kit-upgrade-guides` -Retrieves Canvas Kit version upgrade documentation covering major version migrations. +Returns Canvas Kit upgrade guide documentation (v9 through v14) as resource links. -**Use cases:** -- Upgrading Canvas Kit to a new major version -- Understanding breaking changes between versions -- Finding migration paths for deprecated components -- Learning about new features and components +### `get-canvas-kit-tokens` -**Returns:** Links to upgrade guide resources including version-specific migration steps, deprecation notices, theming guides, and styling migration documentation. +Returns Canvas Kit design token documentation for migrating to `@workday/canvas-tokens-web`. -### `get-canvas-kit-tokens` +### `fetch-component-documentation-example` + +Renders an interactive Canvas Kit component story inline for the user. Accepts a `story` parameter +with an enum of all available component slugs (e.g. `buttons`, `text-input`, `modal`, `tabs`, etc.). -Retrieves Canvas Kit design token migration documentation for transitioning to the modern `@workday/canvas-tokens-web` package. +The tool returns: -**Use cases:** -- Migrating from old token systems to `@workday/canvas-tokens-web` -- Converting deprecated color tokens to the new token system -- Understanding token hierarchy: base tokens, system tokens, and brand tokens -- Finding correct system token replacements -- Learning token naming patterns and semantic color roles -- Migrating spacing, shape, typography, opacity, and depth tokens -- Ensuring WCAG accessibility compliance with color contrast requirements +- The Storybook documentation URL +- A `resource_link` to `docs://examples/{story}` with documentation and code examples +- Story HTML via `_meta` for inline MCP App rendering -**Returns:** Links to token documentation including migration guides, color palettes, color roles, contrast guidelines, and token system references. +**LLMs should read the `docs://examples/{story}` resource first** for documentation and code +examples. Only call `fetch-component-documentation-example` to show the user a live interactive +preview. ## Resources -The server exposes documentation resources organized into two categories: +### `docs://upgrade-guides/*` -### Upgrade Guides -Version-specific migration documentation covering: -- Breaking changes and deprecations -- New components and features -- Styling system migrations (Emotion, CSS variables, `@workday/canvas-kit-styling`) -- Theming and component refactoring -- Accessibility improvements +Markdown upgrade guides for Canvas Kit major versions (v9-v14). -### Token Documentation -Design token migration and usage guides covering: -- Token system migrations (v2 → v3 → v4) -- Color palette and semantic color roles -- Token naming conventions and hierarchy -- Accessibility and contrast guidelines -- Spacing, shape, size, opacity, and depth tokens -- OKLCH color space implementation +### `docs://tokens/*` -All resources are available to AI assistants through MCP resource URIs. +Design token migration guides, color palette, roles, contrast, and scale documentation. -## Source Documentation +### `docs://examples/{slug}` -The documentation files served by this MCP server are maintained in the Canvas Kit repository at [`modules/docs/llm`](../../docs/llm). This directory contains: +Markdown documentation and inline code examples for each component. These are extracted from the MDX +story files at build time, with `ExampleCodeBlock` references replaced by the actual source code of +each example. -- **Upgrade Guides** (`upgrade-guides/`) - Version-specific migration documentation -- **Token Documentation** (`tokens/`) - Design token guides and migration resources -- **LLM-Optimized Files** - Specialized documentation formatted for AI assistants +### `ui://story/{slug}` -To update the documentation served by this MCP server, modify the files in `modules/docs/llm` and rebuild the MCP package. +Interactive HTML previews of Canvas Kit components, served as MCP App resources +(`text/html;profile=mcp-app`). These are compiled from each component's Storybook MDX documentation +and include live, styled component examples. ## Contributing Canvas Kit MCP has two exports: -- `src/cli.js` - Node server that can be invoked via npx for local stdio -- `src/index.js` - Low-level module exports for extending the server or hosting with other transports +- `dist/cli.js` -- a Node server that can be invoked via npx for local stdio +- `dist/index.js` -- module exports for extending the server or hosting with other transports + +### Build Pipeline + +The build runs in stages via `npm run build`: + +1. **`build:discover`** -- scans `modules/react` and `modules/preview-react` for story files, + extracts metadata (title, slug, Storybook URL, MDX path, prose with inlined code examples), and + writes `lib/stories-config.json` +2. **`build:apps`** -- compiles each MDX story into a self-contained single-file HTML app using + Vite, bundling React, Emotion, Canvas Tokens CSS, and lightweight Storybook stubs +3. **`build:copy`** -- copies static resources (upgrade guides, token docs) into `dist/lib` +4. **`build:types`** -- generates TypeScript declarations +5. **`build:mcp`** -- bundles `lib/index.ts` and `lib/cli.ts` with esbuild + +### Key build files + +- `build/vite-plugins.ts` -- shared Vite plugins (`canvasKitSourceResolver`) and + `CANVAS_KIT_PACKAGE_MAP` for monorepo package resolution +- `build/discover-stories.ts` -- story discovery and `stories-config.json` generation +- `build/build-story-apps.ts` -- MDX-to-HTML compilation with Vite +- `build/harness.html` -- HTML template for MCP App stories (MCP bridge, base typography, font + loading) +- `build/storybook-stubs/` -- lightweight replacements for Storybook components (`Meta`, + `ExampleCodeBlock`, `SymbolDoc`, etc.) used in MDX files -### Testing Locally +### To test locally #### MCP Inspector -The inspector requires Node.js v22+: +The inspector requires Node >= 22 so you will need to temporarily switch: ```bash nvm use 22 @@ -153,10 +134,8 @@ Add an entry to your MCP servers configuration pointing to your local build: "mcpServers": { "canvas-kit-mcp-local": { "command": "node", - "args": [ - "/absolute/path/to/canvas-kit/modules/mcp/dist/cli.js" - ] } + "args": ["/absolute/path/to/canvas-kit/modules/mcp/dist/cli.js"] } } ``` diff --git a/modules/mcp/build/build-story-apps.ts b/modules/mcp/build/build-story-apps.ts new file mode 100644 index 0000000000..e7f3830f3c --- /dev/null +++ b/modules/mcp/build/build-story-apps.ts @@ -0,0 +1,207 @@ +import {build, type Plugin} from 'vite'; +import react from '@vitejs/plugin-react'; +import {viteSingleFile} from 'vite-plugin-singlefile'; +import mdx from '@mdx-js/rollup'; +import remarkGfm from 'remark-gfm'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {canvasKitSourceResolver} from './vite-plugins'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +interface StoriesConfig { + stories: Record< + string, + { + title: string; + storybookUrl: string; + mdxPath: string; + } + >; +} + +function extractExports(source: string): string[] { + const exportPattern = + /export (?:default|const|var|function)(?: class)?(?: function)? ([^:\s<();]*)/; + return (source.match(new RegExp(exportPattern, 'g')) || []) + .map(match => match.match(exportPattern)![1] || 'Example') + .filter(name => name.charAt(0).toUpperCase() === name.charAt(0)) + .filter((value, index, self) => self.indexOf(value) === index); +} + +function wholeSourcePlugin(): Plugin { + return { + name: 'whole-source-raw', + enforce: 'pre', + transform(code, id) { + if (!/\/examples\/[^/]+\.tsx$/.test(id)) { + return null; + } + const raw = JSON.stringify(code) + .replace(/\u2028/g, '\\u2028') + .replace(/\u2029/g, '\\u2029'); + const exports = extractExports(code); + if (exports.length === 0) { + return null; + } + const rewritten = code.includes('export default (') + ? code.replace('export default (', 'const Example = (') + '\nexport default Example;' + : code; + return `${rewritten}\n${exports.map(name => `${name}.__RAW__ = ${raw};`).join('\n')}\n`; + }, + }; +} + +function generateEntryHtml(entryFile: string): string { + const harnessPath = path.join(__dirname, 'harness.html'); + if (!fs.existsSync(harnessPath)) { + throw new Error(`Harness template not found at ${harnessPath}`); + } + + let harness = fs.readFileSync(harnessPath, 'utf-8'); + const appScript = ``; + harness = harness.replace('', `${appScript}\n`); + return harness; +} + +function generateEntryTsx(mdxRelativePath: string): string { + return `import React from 'react'; +import {createRoot} from 'react-dom/client'; +import {MDXProvider} from '@mdx-js/react'; +import '@workday/canvas-tokens-web/css/base/_variables.css'; +import '@workday/canvas-tokens-web/css/brand/_variables.css'; +import '@workday/canvas-tokens-web/css/system/_variables.css'; +import MDXContent from '${mdxRelativePath}'; +import {Meta} from '@storybook/blocks'; +import {ExampleCodeBlock, SymbolDoc, SymbolDescription, Specifications, InformationHighlight} from '@workday/canvas-kit-docs'; + +const mdxComponents = {Meta, ExampleCodeBlock, SymbolDoc, SymbolDescription, Specifications, InformationHighlight}; + +function App() { + return ( + + + + ); +} + +createRoot(document.getElementById('root')!).render( + + + +); +`; +} + +async function buildStoryApps() { + const configPath = path.resolve(__dirname, '../lib/stories-config.json'); + if (!fs.existsSync(configPath)) { + console.error('stories-config.json not found. Run discover-stories first.'); + process.exit(1); + } + + const config: StoriesConfig = JSON.parse(fs.readFileSync(configPath, 'utf8')); + const outDir = path.resolve(__dirname, '../dist/apps'); + const repoRoot = path.resolve(__dirname, '../../..'); + const stubsDir = path.resolve(__dirname, 'storybook-stubs'); + + fs.mkdirSync(outDir, {recursive: true}); + + const slugs = Object.keys(config.stories); + console.log(`Building ${slugs.length} story apps...`); + + let built = 0; + let failed = 0; + + for (const slug of slugs) { + const story = config.stories[slug]; + const mdxAbsPath = path.resolve(repoRoot, story.mdxPath); + + if (!fs.existsSync(mdxAbsPath)) { + console.warn(` SKIP ${slug}: MDX not found at ${mdxAbsPath}`); + failed++; + continue; + } + + const mdxDir = path.dirname(mdxAbsPath); + const tempEntryPath = path.join(mdxDir, `__mcp_entry_${slug}.tsx`); + const tempHtmlPath = path.join(mdxDir, `__mcp_index_${slug}.html`); + + const mdxBaseName = path.basename(mdxAbsPath); + const entryTsx = generateEntryTsx(`./${mdxBaseName}`); + const entryHtml = generateEntryHtml(`__mcp_entry_${slug}.tsx`); + + fs.writeFileSync(tempEntryPath, entryTsx); + fs.writeFileSync(tempHtmlPath, entryHtml); + + try { + await build({ + root: mdxDir, + base: './', + plugins: [ + canvasKitSourceResolver(repoRoot), + wholeSourcePlugin(), + { + enforce: 'pre', + ...mdx({remarkPlugins: [remarkGfm], providerImportSource: '@mdx-js/react'}), + }, + react({include: /\.(mdx|js|jsx|ts|tsx)$/}), + viteSingleFile(), + ], + resolve: { + alias: { + '@workday/canvas-kit-docs': path.join(stubsDir, 'canvas-kit-docs.tsx'), + '@storybook/blocks': path.join(stubsDir, 'storybook-blocks.tsx'), + '@storybook/react': path.join(stubsDir, 'storybook-react.tsx'), + }, + }, + build: { + outDir, + emptyOutDir: false, + rollupOptions: { + input: tempHtmlPath, + output: { + entryFileNames: `${slug}.js`, + assetFileNames: `${slug}.[ext]`, + }, + }, + minify: true, + sourcemap: false, + }, + logLevel: 'silent', + }); + + const outputHtml = path.join(outDir, `__mcp_index_${slug}.html`); + const finalHtml = path.join(outDir, `${slug}.html`); + if (fs.existsSync(outputHtml)) { + fs.renameSync(outputHtml, finalHtml); + } + + built++; + console.log(` OK ${slug}`); + } catch (error) { + failed++; + console.error(` FAIL ${slug}:`, error instanceof Error ? error.message : error); + } finally { + try { + fs.unlinkSync(tempEntryPath); + } catch { + // Ignore + } + try { + fs.unlinkSync(tempHtmlPath); + } catch { + // Ignore + } + } + } + + console.log(`\nBuild complete: ${built} succeeded, ${failed} failed out of ${slugs.length}`); +} + +buildStoryApps().catch((error: unknown) => { + console.error('Build failed:', error); + process.exit(1); +}); diff --git a/modules/mcp/build/discover-stories.ts b/modules/mcp/build/discover-stories.ts new file mode 100644 index 0000000000..613e4ed773 --- /dev/null +++ b/modules/mcp/build/discover-stories.ts @@ -0,0 +1,184 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {glob} from 'glob'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const STORYBOOK_BASE_URL = 'https://workday.github.io/canvas-kit/'; + +interface StoryEntry { + title: string; + storybookUrl: string; + mdxPath: string; + mdxProse: string; +} + +function titleToStorybookPath(title: string): string { + return title.toLowerCase().replace(/\//g, '-').replace(/\s+/g, '-'); +} + +function titleToSlug(title: string): string { + const parts = title.split('/'); + const leaf = parts[parts.length - 1]; + return leaf.toLowerCase().replace(/\s+/g, '-'); +} + +function extractTitle(filePath: string): string | null { + const content = fs.readFileSync(filePath, 'utf8'); + const match = content.match(/title:\s*['"`]([^'"`]+)['"`]/); + return match ? match[1] : null; +} + +function extractMdxProse(mdxFilePath: string, exampleSources: Record): string { + const content = fs.readFileSync(mdxFilePath, 'utf8'); + const withoutImports = content.replace( + /import\s+(?:(?:\{[\s\S]*?\}|\*\s+as\s+\w+|[\w]+)\s+from\s+)?['"][^'"]+['"];?\n?/g, + '' + ); + return withoutImports + .split('\n') + .map(line => { + const codeBlockMatch = line.match(/^\s*/); + if (codeBlockMatch) { + const name = codeBlockMatch[1]; + const source = exampleSources[name]; + if (source) { + return `\`\`\`tsx\n${source.trimEnd()}\n\`\`\``; + } + return ''; + } + if ( + /^\s*<(Meta|SymbolDoc|SymbolDescription|Specifications|InformationHighlight)\b/.test(line) + ) { + return ''; + } + return line; + }) + .join('\n') + .replace(/^\n+/, '') + .replace(/\n{3,}/g, '\n\n'); +} + +function findExampleSources(mdxFilePath: string): Record { + const mdxDir = path.dirname(mdxFilePath); + const examplesDir = path.join(mdxDir, 'examples'); + if (!fs.existsSync(examplesDir) || !fs.statSync(examplesDir).isDirectory()) { + return {}; + } + + const sources: Record = {}; + const entries = fs.readdirSync(examplesDir).filter(f => f.endsWith('.tsx') || f.endsWith('.ts')); + + for (const entry of entries) { + const name = entry.replace(/\.(tsx?|ts)$/, ''); + sources[name] = fs.readFileSync(path.join(examplesDir, entry), 'utf8'); + } + + return sources; +} + +function findMdxFile(storyFilePath: string): string | null { + const dir = path.dirname(storyFilePath); + const entries = fs.readdirSync(dir); + const mdxFiles = entries.filter(e => e.endsWith('.mdx')); + + if (mdxFiles.length === 0) { + return null; + } + + const storyBaseName = path.basename(storyFilePath).replace(/\.stories\.(ts|tsx)$/, ''); + const exactMatch = mdxFiles.find(f => f.replace('.mdx', '') === storyBaseName); + if (exactMatch) { + return path.join(dir, exactMatch); + } + + return path.join(dir, mdxFiles[0]); +} + +async function main() { + const repoModules = path.resolve(__dirname, '../..'); + const outputPath = path.resolve(__dirname, '../lib/stories-config.json'); + + const storyFiles = await glob('**/stories/**/*.stories.{ts,tsx}', { + cwd: repoModules, + absolute: true, + ignore: ['**/node_modules/**', '**/visual-testing/**'], + }); + + const stories: Record = {}; + const slugCounts = new Map(); + + const TITLE_PREFIXES = ['Components/', 'Preview/', 'Labs/']; + const candidates: Array<{slug: string; title: string; storyFile: string; mdxPath: string}> = []; + + for (const storyFile of storyFiles) { + const title = extractTitle(storyFile); + if (!title) { + continue; + } + if (!TITLE_PREFIXES.some(prefix => title.startsWith(prefix))) { + continue; + } + + const mdxPath = findMdxFile(storyFile); + if (!mdxPath) { + continue; + } + + const mdxContent = fs.readFileSync(mdxPath, 'utf8'); + if (mdxContent.includes('type="deprecated"')) { + continue; + } + + const slug = titleToSlug(title); + const count = slugCounts.get(slug) || 0; + slugCounts.set(slug, count + 1); + + const relativeMdxPath = path.relative(path.resolve(__dirname, '../../..'), mdxPath); + candidates.push({slug, title, storyFile, mdxPath: relativeMdxPath}); + } + + for (const candidate of candidates) { + let slug = candidate.slug; + const count = slugCounts.get(slug) || 0; + + if (count > 1) { + const parts = candidate.title.split('/'); + slug = parts.join('-').toLowerCase().replace(/\s+/g, '-'); + } + + if (stories[slug]) { + console.warn(`Duplicate slug "${slug}" for "${candidate.title}", skipping`); + continue; + } + + const storybookPath = titleToStorybookPath(candidate.title); + const repoRoot = path.resolve(__dirname, '../../..'); + const absoluteMdxPath = path.resolve(repoRoot, candidate.mdxPath); + const exampleSources = findExampleSources(absoluteMdxPath); + stories[slug] = { + title: candidate.title, + storybookUrl: `${STORYBOOK_BASE_URL}?path=/docs/${storybookPath}--docs`, + mdxPath: candidate.mdxPath, + mdxProse: extractMdxProse(absoluteMdxPath, exampleSources), + }; + } + + const outputDir = path.dirname(outputPath); + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, {recursive: true}); + } + + fs.writeFileSync(outputPath, JSON.stringify({stories}, null, 2) + '\n'); + + const slugList = Object.keys(stories); + console.log(`Discovered ${slugList.length} component stories:`); + slugList.forEach(slug => console.log(` - ${slug}: ${stories[slug].title}`)); +} + +main().catch((error: unknown) => { + console.error('Discovery failed:', error); + process.exit(1); +}); diff --git a/modules/mcp/build/harness.html b/modules/mcp/build/harness.html new file mode 100644 index 0000000000..cbd27d0e73 --- /dev/null +++ b/modules/mcp/build/harness.html @@ -0,0 +1,294 @@ + + + + + + Canvas Kit Story + + + + + + +
+ + + diff --git a/modules/mcp/build/index.ts b/modules/mcp/build/index.ts index 4168d45a6d..2144d5ae09 100644 --- a/modules/mcp/build/index.ts +++ b/modules/mcp/build/index.ts @@ -42,4 +42,15 @@ allFiles.forEach(file => console.log(` - ${file}`)); allFiles.forEach(file => copyFile(file)); +const storyViewerSrc = path.resolve(__dirname, 'story-viewer.html'); +const storyViewerDest = path.resolve(__dirname, '../dist/apps/story-viewer.html'); +if (fs.existsSync(storyViewerSrc)) { + const appsDir = path.dirname(storyViewerDest); + if (!fs.existsSync(appsDir)) { + fs.mkdirSync(appsDir, {recursive: true}); + } + fs.copyFileSync(storyViewerSrc, storyViewerDest); + console.log(' - story-viewer.html -> dist/apps/'); +} + console.log('\nCopy completed successfully!'); diff --git a/modules/mcp/build/story-viewer.html b/modules/mcp/build/story-viewer.html new file mode 100644 index 0000000000..210673f4ca --- /dev/null +++ b/modules/mcp/build/story-viewer.html @@ -0,0 +1,112 @@ + + + + + + Canvas Kit Story Viewer + + + +
Loading story...
+ + + diff --git a/modules/mcp/build/storybook-stubs/canvas-kit-docs.tsx b/modules/mcp/build/storybook-stubs/canvas-kit-docs.tsx new file mode 100644 index 0000000000..f2da188f7c --- /dev/null +++ b/modules/mcp/build/storybook-stubs/canvas-kit-docs.tsx @@ -0,0 +1,162 @@ +import React from 'react'; +import {Prism as SyntaxHighlighter} from 'react-syntax-highlighter'; +import {vscDarkPlus} from 'react-syntax-highlighter/dist/esm/styles/prism'; + +declare global { + interface Window { + __MCP_BRIDGE__?: { + request(method: string, params: unknown): Promise; + notify(method: string, params: unknown): void; + on(event: string, callback: (data: unknown) => void): () => void; + initialize(appInfo?: unknown): Promise; + updateModelContext(content: unknown): void; + applyHostStyles(hostContext: unknown): void; + }; + } +} + +interface ExampleComponent extends React.ComponentType { + __RAW__?: string; +} + +export function ExampleCodeBlock({code}: {code: ExampleComponent}) { + const [showCode, setShowCode] = React.useState(false); + const [sent, setSent] = React.useState(false); + const timerRef = React.useRef>(); + const raw = code?.__RAW__; + + const handleSendToLLM = () => { + if (!raw) { + return; + } + if (timerRef.current) { + clearTimeout(timerRef.current); + } + setSent(true); + timerRef.current = setTimeout(() => setSent(false), 2000); + + const bridge = window.__MCP_BRIDGE__; + if (!bridge) { + return; + } + + bridge.request('ui/message', { + role: 'user', + content: [ + {type: 'text', text: `Here is a Canvas Kit code example:\n\n\`\`\`tsx\n${raw}\n\`\`\``}, + ], + }); + }; + + const Component = code; + + return ( +
+
+ + {raw && ( +
+ +
+ )} +
+ {showCode && raw && ( +
+ + {raw} + + +
+ )} +
+ ); +} + +export function SymbolDoc(_props: {name?: string; fileName?: string}) { + return null; +} + +export function SymbolDescription(_props: {name?: string; fileName?: string}) { + return null; +} + +export function Specifications(_props: {file?: string; name?: string}) { + return null; +} + +export function InformationHighlight({ + children, +}: React.PropsWithChildren<{variant?: string; className?: string; cs?: unknown}>) { + return ( +
+ {children} +
+ ); +} diff --git a/modules/mcp/build/storybook-stubs/storybook-blocks.tsx b/modules/mcp/build/storybook-stubs/storybook-blocks.tsx new file mode 100644 index 0000000000..dbbbd2c60b --- /dev/null +++ b/modules/mcp/build/storybook-stubs/storybook-blocks.tsx @@ -0,0 +1,3 @@ +export function Meta(_props: {of?: unknown}) { + return null; +} diff --git a/modules/mcp/build/storybook-stubs/storybook-react.tsx b/modules/mcp/build/storybook-stubs/storybook-react.tsx new file mode 100644 index 0000000000..3eaecfbee4 --- /dev/null +++ b/modules/mcp/build/storybook-stubs/storybook-react.tsx @@ -0,0 +1,11 @@ +export type Meta = { + title?: string; + component?: T; + tags?: string[]; + parameters?: Record; +}; + +export type StoryObj = { + render?: React.ComponentType; + args?: Partial; +}; diff --git a/modules/mcp/build/vite-plugins.ts b/modules/mcp/build/vite-plugins.ts new file mode 100644 index 0000000000..32910c7356 --- /dev/null +++ b/modules/mcp/build/vite-plugins.ts @@ -0,0 +1,44 @@ +import type {Plugin} from 'vite'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +export const CANVAS_KIT_PACKAGE_MAP: Record = { + '@workday/canvas-kit-react': 'modules/react', + '@workday/canvas-kit-preview-react': 'modules/preview-react', + '@workday/canvas-kit-labs-react': 'modules/labs-react', + '@workday/canvas-kit-styling': 'modules/styling', + '@workday/canvas-kit-styling-transform': 'modules/styling-transform', + '@workday/canvas-kit-popup-stack': 'modules/popup-stack', + '@workday/canvas-kit-react-fonts': 'modules/react-fonts', +}; + +export function canvasKitSourceResolver(repoRoot: string): Plugin { + return { + name: 'canvas-kit-source-resolver', + enforce: 'pre', + resolveId(source) { + for (const [pkg, modulePath] of Object.entries(CANVAS_KIT_PACKAGE_MAP)) { + if (source === pkg) { + const resolved = path.join(repoRoot, modulePath, 'index.ts'); + if (fs.existsSync(resolved)) { + return resolved; + } + } + + if (source.startsWith(pkg + '/')) { + const subpath = source.slice(pkg.length + 1); + for (const candidate of [ + path.join(repoRoot, modulePath, subpath, 'index.ts'), + path.join(repoRoot, modulePath, subpath + '.ts'), + path.join(repoRoot, modulePath, subpath + '.tsx'), + ]) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + } + } + return null; + }, + }; +} diff --git a/modules/mcp/lib/index.ts b/modules/mcp/lib/index.ts index e8e27d0f37..e3167aa404 100644 --- a/modules/mcp/lib/index.ts +++ b/modules/mcp/lib/index.ts @@ -3,8 +3,10 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import {fileURLToPath} from 'node:url'; +import {z} from 'zod'; import packageJson from '../package.json'; import fileNames from './config.json'; +import storiesConfig from './stories-config.json'; import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js'; const __filename = fileURLToPath(import.meta.url); @@ -468,5 +470,161 @@ Returns links to token documentation resources including migration guides, color }; } ); + + interface StoryConfig { + title: string; + storybookUrl: string; + mdxPath: string; + mdxProse: string; + } + + const stories = storiesConfig.stories as Record; + const storySlugs: string[] = []; + + for (const [slug, story] of Object.entries(stories)) { + const appPath = path.resolve(__dirname, 'apps', `${slug}.html`); + const appExists = fs.existsSync(appPath); + + if (appExists) { + storySlugs.push(slug); + server.registerResource( + story.title, + `ui://story/${slug}`, + { + title: story.title, + description: `Interactive preview of the ${story.title} Canvas Kit component`, + mimeType: 'text/html;profile=mcp-app', + }, + async (uri: URL) => ({ + contents: [ + { + uri: uri.href, + text: fs.readFileSync(appPath, 'utf8'), + mimeType: 'text/html;profile=mcp-app', + _meta: { + ui: { + csp: { + resourceDomains: ['https://fonts.googleapis.com', 'https://fonts.gstatic.com'], + }, + }, + }, + }, + ], + }) + ); + } + + if (story.mdxProse) { + server.registerResource( + `${story.title} Documentation & Sample Code`, + `docs://examples/${slug}`, + { + title: `${story.title} Documentation & Sample Code`, + description: `Documentation and source code for all ${story.title} component examples.`, + mimeType: 'text/markdown', + }, + async (uri: URL) => ({ + contents: [ + { + uri: uri.href, + text: story.mdxProse, + }, + ], + }) + ); + } + } + + const storyViewerPath = path.resolve(__dirname, 'apps', 'story-viewer.html'); + if (storySlugs.length > 0 && fs.existsSync(storyViewerPath)) { + const slugEnum = storySlugs as [string, ...string[]]; + + server.registerResource( + 'Canvas Kit Story Viewer', + 'ui://story-viewer', + { + title: 'Canvas Kit Story Viewer', + description: 'Wrapper app that renders Canvas Kit component story previews.', + mimeType: 'text/html;profile=mcp-app', + }, + async (uri: URL) => ({ + contents: [ + { + uri: uri.href, + text: fs.readFileSync(storyViewerPath, 'utf8'), + mimeType: 'text/html;profile=mcp-app', + _meta: { + ui: { + csp: { + resourceDomains: ['https://fonts.googleapis.com', 'https://fonts.gstatic.com'], + }, + }, + }, + }, + ], + }) + ); + + const fetchStoryHandler = async ({story}: {story: string}) => { + const config = stories[story]; + if (!config) { + throw new Error(`Unknown story "${story}". Valid stories: ${storySlugs.join(', ')}`); + } + const appPath = path.resolve(__dirname, 'apps', `${story}.html`); + const storyHtml = fs.readFileSync(appPath, 'utf8'); + const hasDocs = !!config.mdxProse; + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + displayGuide: + 'Present the Storybook URL as a markdown link. If you need code examples, read the resource_link.', + title: config.title, + storybookUrl: config.storybookUrl, + }), + }, + ...(hasDocs + ? [ + { + type: 'resource_link' as const, + uri: `docs://examples/${story}`, + name: `${config.title} Documentation & Sample Code`, + mimeType: 'text/markdown', + description: `Documentation and code examples for ${config.title}. Read this if you need to write code.`, + }, + ] + : []), + ], + _meta: { + storyHtml, + }, + }; + }; + + (server.registerTool as Function)( + 'fetch-component-documentation-example', + { + title: 'Fetch Canvas Kit Component Documentation and Storybook example', + description: + 'Renders an interactive Canvas Kit component story inline for the user to see.\n\n' + + 'Before Calling:\n' + + '1. Read the docs://examples/{story} resource for documentation and code examples\n' + + '2. Only call this tool if the user needs to see the documentation or code examples\n' + + '3. Do not call this tool just to learn about a component — read the resource instead', + inputSchema: { + story: z.enum(slugEnum).describe('The component story slug to preview'), + }, + annotations: { + readOnlyHint: true, + }, + _meta: { + ui: {resourceUri: 'ui://story-viewer'}, + }, + }, + fetchStoryHandler + ); + } + return server; } diff --git a/modules/mcp/package.json b/modules/mcp/package.json index 40d4c98438..dbc10459af 100644 --- a/modules/mcp/package.json +++ b/modules/mcp/package.json @@ -30,10 +30,12 @@ "dist" ], "scripts": { + "build:discover": "tsx ./build/discover-stories.ts", + "build:apps": "tsx ./build/build-story-apps.ts", "build:copy": "tsx ./build/index.ts", "build:types": "tsc --project tsconfig.build.json -d true --declarationDir dist/types --emitDeclarationOnly --pretty", "build:mcp": "esbuild lib/index.ts --bundle --platform=node --packages=external --outfile=dist/index.js --format=esm --sourcemap && esbuild lib/cli.ts --bundle --platform=node --packages=external --outfile=dist/cli.js --format=esm --sourcemap", - "build": "npm-run-all build:copy build:types build:mcp", + "build": "npm-run-all build:discover build:apps build:copy build:types build:mcp", "clean": "rimraf dist && rimraf .build-info && mkdirp dist" }, "keywords": [ @@ -43,14 +45,22 @@ "mcp" ], "dependencies": { - "@modelcontextprotocol/sdk": "^1.20.2" + "@modelcontextprotocol/sdk": "^1.26.0", + "zod": "^3.23.0" }, "devDependencies": { + "@mdx-js/react": "^3.1.0", + "@mdx-js/rollup": "^3.1.0", "@types/node": "^22.0.0", + "@vitejs/plugin-react": "^4.3.0", "esbuild": "^0.25.11", + "glob": "^10.5.0", "mkdirp": "^1.0.3", + "remark-gfm": "^4.0.0", "rimraf": "^5.0.0", "tsx": "^4.7.0", - "typescript": "5.0" + "typescript": "5.0", + "vite": "^5.0.0", + "vite-plugin-singlefile": "^2.0.0" } } diff --git a/modules/preview-react/color-picker/index.ts b/modules/preview-react/color-picker/index.ts index 2bae520224..43d9a3f60e 100644 --- a/modules/preview-react/color-picker/index.ts +++ b/modules/preview-react/color-picker/index.ts @@ -1 +1 @@ -export {ColorPicker, ColorPickerProps} from './lib/ColorPicker'; +export {ColorPicker, type ColorPickerProps} from './lib/ColorPicker'; diff --git a/modules/preview-react/select/index.ts b/modules/preview-react/select/index.ts index a29681e4e6..130902712b 100644 --- a/modules/preview-react/select/index.ts +++ b/modules/preview-react/select/index.ts @@ -1,6 +1,6 @@ export * from './lib/Select'; -export { +export type { Option, RenderableOption, RenderOptionFunction, diff --git a/modules/react/badge/index.ts b/modules/react/badge/index.ts index 6867cda6d6..e90a41f084 100644 --- a/modules/react/badge/index.ts +++ b/modules/react/badge/index.ts @@ -1 +1 @@ -export {CountBadge, CountBadgeProps} from './lib/CountBadge'; +export {CountBadge, type CountBadgeProps} from './lib/CountBadge'; diff --git a/modules/react/checkbox/lib/Checkbox.tsx b/modules/react/checkbox/lib/Checkbox.tsx index 0a78bbe2dd..43c00e94c3 100644 --- a/modules/react/checkbox/lib/Checkbox.tsx +++ b/modules/react/checkbox/lib/Checkbox.tsx @@ -8,7 +8,7 @@ import { import {CheckboxRipple} from './CheckboxRipple'; import {CheckboxContainer} from './CheckboxContainer'; import {CheckboxCheck} from './CheckboxCheck'; -import {CheckboxInput, CheckboxProps} from './CheckboxInput'; +import {CheckboxInput, type CheckboxProps} from './CheckboxInput'; export const Checkbox = createComponent('input')({ displayName: 'Checkbox', diff --git a/modules/react/icon/index.ts b/modules/react/icon/index.ts index af3173fe90..840562bbb8 100644 --- a/modules/react/icon/index.ts +++ b/modules/react/icon/index.ts @@ -3,4 +3,4 @@ export * from './lib/AppletIcon'; export * from './lib/SystemIcon'; export * from './lib/SystemIconCircle'; export * from './lib/Graphic'; -export {Svg, SvgProps, svgStencil} from './lib/Svg'; +export {Svg, type SvgProps, svgStencil} from './lib/Svg'; diff --git a/modules/react/layout/index.ts b/modules/react/layout/index.ts index fa90a52f12..188212415f 100644 --- a/modules/react/layout/index.ts +++ b/modules/react/layout/index.ts @@ -3,7 +3,7 @@ export * from './lib/utils/mergeStyles'; export * from './lib/utils/background'; export * from './lib/utils/border'; export * from './lib/utils/color'; -export {DepthStyleProps, depthStyleFnConfigs} from './lib/utils/depth'; +export {type DepthStyleProps, depthStyleFnConfigs} from './lib/utils/depth'; export * from './lib/utils/flex'; export * from './lib/utils/flexItem'; export * from './lib/utils/grid'; @@ -12,12 +12,12 @@ export * from './lib/utils/layout'; export * from './lib/utils/other'; export * from './lib/utils/systemProps'; export * from './lib/utils/position'; -export {SpaceStyleProps, spaceStyleFnConfigs} from './lib/utils/space'; -export {AllStyleProps, CommonStyleProps} from './lib/utils/styleProps'; +export {type SpaceStyleProps, spaceStyleFnConfigs} from './lib/utils/space'; +export {type AllStyleProps, type CommonStyleProps} from './lib/utils/styleProps'; export * from './lib/utils/text'; export * from './lib/Flex'; export * from './lib/Grid'; export type {FlexStyleProps} from './lib/utils/flex'; export type {GridStyleProps} from './lib/utils/grid'; -export {GridItemStyleProps, gridItemStyleFnConfigs} from './lib/utils/gridItem'; +export {type GridItemStyleProps, gridItemStyleFnConfigs} from './lib/utils/gridItem'; export * from './lib/utils/systemProps';