diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 2d389d79..55cf5380 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -138,3 +138,36 @@ jobs: run: npm publish --workspace examples/${{ matrix.example }} --provenance --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_SECRET }} + + publish-packages: + runs-on: ubuntu-latest + if: github.event_name == 'release' + environment: Release + needs: [publish] + + permissions: + contents: read + id-token: write + + strategy: + fail-fast: false + matrix: + package: + - create-mcp-app + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + registry-url: "https://registry.npmjs.org" + - run: npm ci + + - name: Build package + run: npm run build --workspace packages/${{ matrix.package }} + + - name: Publish package + run: npm publish --workspace packages/${{ matrix.package }} --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_SECRET }} diff --git a/README.md b/README.md index 00fdff06..cacd10f8 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,18 @@ There's no _supported_ host implementation in this repo (beyond the [examples/ba The [MCP-UI](https://github.com/idosal/mcp-ui) client SDK offers a fully-featured MCP Apps framework used by a few hosts. Clients may choose to use it or roll their own implementation. +## Quick Start + +Create a new MCP App project in seconds: + +```bash +npm create @modelcontextprotocol/mcp-app my-app +cd my-app +npm run dev +``` + +Choose from React or Vanilla JS templates. See the [create-mcp-app README](packages/create-mcp-app/README.md) for all options. + ## Installation ```bash diff --git a/package-lock.json b/package-lock.json index 77c65e81..27949946 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "hasInstallScript": true, "license": "MIT", "workspaces": [ - "examples/*" + "examples/*", + "packages/*" ], "devDependencies": { "@boneskull/typedoc-plugin-mermaid": "^0.2.0", @@ -1194,6 +1195,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -2602,6 +2604,10 @@ "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", "license": "MIT" }, + "node_modules/@modelcontextprotocol/create-mcp-app": { + "resolved": "packages/create-mcp-app", + "link": true + }, "node_modules/@modelcontextprotocol/ext-apps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.0.0.tgz", @@ -2658,6 +2664,7 @@ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz", "integrity": "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==", "license": "MIT", + "peer": true, "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", @@ -3741,6 +3748,7 @@ "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.1", @@ -3947,6 +3955,7 @@ "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3971,6 +3980,7 @@ "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4373,6 +4383,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4714,6 +4725,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5425,6 +5437,7 @@ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "dev": true, "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -5897,6 +5910,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -7446,6 +7460,7 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.2.tgz", "integrity": "sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -7557,6 +7572,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -7566,6 +7582,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -7655,6 +7672,7 @@ "integrity": "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -7776,6 +7794,7 @@ "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.4.1.tgz", "integrity": "sha512-9GOc+8T6LN4aByLN75uRvMbrwY5RDBW6lSlknsY4LEa9ZmWcxKcRe1G/Q3HZXjltxMHTrStnvrwAICxZrhldtg==", "license": "MIT", + "peer": true, "engines": { "node": ">=10" } @@ -8015,7 +8034,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true, "license": "MIT" }, "node_modules/slash": { @@ -8066,6 +8084,7 @@ "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.10.tgz", "integrity": "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", @@ -8244,6 +8263,7 @@ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.48.2.tgz", "integrity": "sha512-VPWD+UyoSFZ7Nxix5K/F8yWiKWOiROkLlWYXOZReE0TUycw+58YWB3D6lAKT+57xmN99wRX4H3oZmw0NPy7y3Q==", "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -8571,6 +8591,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9270,6 +9291,7 @@ "integrity": "sha512-x4xW77QC3i5DUFMBp0qjukOTnr/sSg+oEs86nB3LjDslvAmwe/PUGDWbe3GrIqt59oTqoXK5GRK9tAa0sYMiog==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@gerrit0/mini-shiki": "^3.17.0", "lunr": "^2.3.9", @@ -9333,6 +9355,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9426,6 +9449,7 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -9602,6 +9626,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9720,6 +9745,7 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz", "integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.27", "@vue/compiler-sfc": "3.5.27", @@ -9880,6 +9906,7 @@ "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "dev": true, "license": "ISC", + "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -9930,6 +9957,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -9942,6 +9970,54 @@ "peerDependencies": { "zod": "^3.25 || ^4" } + }, + "packages/create-mcp-app": { + "name": "@modelcontextprotocol/create-mcp-app", + "version": "0.4.1", + "license": "MIT", + "dependencies": { + "@clack/prompts": "^0.10.0", + "@modelcontextprotocol/sdk": "^1.24.0", + "picocolors": "^1.1.0" + }, + "bin": { + "create-mcp-app": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.9.3" + } + }, + "packages/create-mcp-app/node_modules/@clack/core": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-0.4.2.tgz", + "integrity": "sha512-NYQfcEy8MWIxrT5Fj8nIVchfRFA26yYKJcvBS7WlUIlw2OmQOY9DhGGXMovyI5J5PpxrCPGkgUi207EBrjpBvg==", + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, + "packages/create-mcp-app/node_modules/@clack/prompts": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-0.10.1.tgz", + "integrity": "sha512-Q0T02vx8ZM9XSv9/Yde0jTmmBQufZhPJfYAg2XrrrxWWaZgq1rr8nU8Hv710BQ1dhoP8rtY7YUdpGej2Qza/cw==", + "license": "MIT", + "dependencies": { + "@clack/core": "0.4.2", + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, + "packages/create-mcp-app/node_modules/@types/node": { + "version": "22.19.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", + "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } } } } diff --git a/package.json b/package.json index 221d428e..151a7467 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,8 @@ "dist" ], "workspaces": [ - "examples/*" + "examples/*", + "packages/*" ], "scripts": { "postinstall": "node scripts/setup-bun.mjs || echo 'setup-bun.mjs failed or not available'", diff --git a/packages/create-mcp-app/README.md b/packages/create-mcp-app/README.md new file mode 100644 index 00000000..5fa1bebf --- /dev/null +++ b/packages/create-mcp-app/README.md @@ -0,0 +1,44 @@ +# @modelcontextprotocol/create-mcp-app + +Scaffold new MCP App projects with one command. + +## Usage + +```bash +# Interactive mode +npm create @modelcontextprotocol/mcp-app + +# With project name +npm create @modelcontextprotocol/mcp-app my-app + +# With framework +npm create @modelcontextprotocol/mcp-app my-app --framework react +``` + +## Frameworks + +- **react** - React + Vite + TypeScript +- **vanillajs** - Vanilla JavaScript + Vite + TypeScript + +## What's Included + +Each generated project includes: + +- MCP server with a sample tool +- Interactive UI that communicates with the host +- Vite build configuration for bundling the UI +- TypeScript configuration +- Development server with hot reload + +## Getting Started + +After creating your project: + +```bash +cd my-app +npm run dev +``` + +## License + +MIT diff --git a/packages/create-mcp-app/package.json b/packages/create-mcp-app/package.json new file mode 100644 index 00000000..e59d53b6 --- /dev/null +++ b/packages/create-mcp-app/package.json @@ -0,0 +1,33 @@ +{ + "name": "@modelcontextprotocol/create-mcp-app", + "version": "0.4.1", + "description": "Create MCP App projects with one command", + "type": "module", + "bin": { + "create-mcp-app": "./dist/index.js" + }, + "files": [ + "dist", + "templates" + ], + "scripts": { + "build": "tsc && chmod +x dist/index.js", + "test": "npm run build && node test/scaffold-build.test.mjs", + "prepublishOnly": "npm run build" + }, + "repository": { + "type": "git", + "url": "https://github.com/modelcontextprotocol/ext-apps", + "directory": "packages/create-mcp-app" + }, + "license": "MIT", + "dependencies": { + "@clack/prompts": "^0.10.0", + "@modelcontextprotocol/sdk": "^1.24.0", + "picocolors": "^1.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.9.3" + } +} diff --git a/packages/create-mcp-app/src/cli.ts b/packages/create-mcp-app/src/cli.ts new file mode 100644 index 00000000..37c54e70 --- /dev/null +++ b/packages/create-mcp-app/src/cli.ts @@ -0,0 +1,142 @@ +import * as p from "@clack/prompts"; +import pc from "picocolors"; +import { scaffold } from "./scaffold.js"; +import { + MCP_SDK_VERSION, + SDK_VERSION, + TEMPLATES, + type TemplateName, + validateProjectName, +} from "./utils.js"; + +interface CliArgs { + projectName?: string; + framework?: string; + help?: boolean; +} + +function parseArgs(args: string[]): CliArgs { + const result: CliArgs = {}; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--help" || arg === "-h") { + result.help = true; + } else if (arg === "--framework" || arg === "-f") { + result.framework = args[++i]; + } else if (!arg.startsWith("-") && !result.projectName) { + result.projectName = arg; + } + } + + return result; +} + +function printHelp(): void { + console.log(` +${pc.bold("create-mcp-app")} - Scaffold MCP App projects + +${pc.bold("Usage:")} + npm create @modelcontextprotocol/mcp-app [project-name] [options] + +${pc.bold("Options:")} + -f, --framework Framework to use (${TEMPLATES.map((t) => t.value).join(", ")}) + -h, --help Show this help message + +${pc.bold("Examples:")} + npm create @modelcontextprotocol/mcp-app + npm create @modelcontextprotocol/mcp-app my-app + npm create @modelcontextprotocol/mcp-app my-app --framework react +`); +} + +export async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + + if (args.help) { + printHelp(); + return; + } + + console.log(); + p.intro(pc.bgCyan(pc.black(" create-mcp-app "))); + + let projectName = args.projectName; + let framework = args.framework; + + // Prompt for project name if not provided + if (!projectName) { + const nameResult = await p.text({ + message: "Project name:", + placeholder: "my-mcp-app", + validate: validateProjectName, + }); + + if (p.isCancel(nameResult)) { + p.cancel("Operation cancelled."); + process.exit(0); + } + + projectName = nameResult || "my-mcp-app"; + } else { + const validation = validateProjectName(projectName); + if (validation) { + p.cancel(validation); + process.exit(1); + } + } + + // Prompt for framework if not provided + if (!framework) { + const frameworkResult = await p.select({ + message: "Select a framework:", + options: [...TEMPLATES], + }); + + if (p.isCancel(frameworkResult)) { + p.cancel("Operation cancelled."); + process.exit(0); + } + + framework = frameworkResult as TemplateName; + } else { + const validFrameworks = TEMPLATES.map((t) => t.value) as readonly string[]; + if (!validFrameworks.includes(framework)) { + p.cancel( + `Invalid framework "${framework}". Valid options: ${validFrameworks.join(", ")}`, + ); + process.exit(1); + } + } + + const s = p.spinner(); + + try { + s.start("Creating project..."); + + await scaffold({ + projectName, + template: framework!, + targetDir: projectName, + sdkVersion: SDK_VERSION, + mcpSdkVersion: MCP_SDK_VERSION, + }); + + s.stop("Project created!"); + + s.start("Installing dependencies..."); + const { execSync } = await import("node:child_process"); + execSync("npm install", { + cwd: projectName, + stdio: "ignore", + }); + s.stop("Dependencies installed!"); + + p.note([`cd ${projectName}`, "npm run dev"].join("\n"), "Next steps:"); + + p.outro(pc.green("Happy building!")); + } catch (error) { + s.stop("Failed!"); + throw error; + } +} diff --git a/packages/create-mcp-app/src/index.ts b/packages/create-mcp-app/src/index.ts new file mode 100644 index 00000000..c0be5228 --- /dev/null +++ b/packages/create-mcp-app/src/index.ts @@ -0,0 +1,7 @@ +#!/usr/bin/env node +import { main } from "./cli.js"; + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/packages/create-mcp-app/src/scaffold.ts b/packages/create-mcp-app/src/scaffold.ts new file mode 100644 index 00000000..98e5b3fe --- /dev/null +++ b/packages/create-mcp-app/src/scaffold.ts @@ -0,0 +1,90 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { + getTemplatesDir, + processTemplate, + type TemplateName, +} from "./utils.js"; + +export interface ScaffoldOptions { + projectName: string; + template: TemplateName | string; + targetDir: string; + sdkVersion: string; + mcpSdkVersion: string; +} + +/** + * Copy a directory recursively, processing .tmpl files + */ +async function copyDir( + src: string, + dest: string, + replacements: Record, +): Promise { + await fs.mkdir(dest, { recursive: true }); + + const entries = await fs.readdir(src, { withFileTypes: true }); + + for (const entry of entries) { + const srcPath = path.join(src, entry.name); + let destName = entry.name; + + // Remove .tmpl extension and process content + const isTmpl = destName.endsWith(".tmpl"); + if (isTmpl) { + destName = destName.slice(0, -5); + } + + const destPath = path.join(dest, destName); + + if (entry.isDirectory()) { + await copyDir(srcPath, destPath, replacements); + } else { + let content = await fs.readFile(srcPath, "utf-8"); + + // Always process templates for .tmpl files + if (isTmpl) { + content = processTemplate(content, replacements); + } + + await fs.writeFile(destPath, content); + } + } +} + +/** + * Scaffold a new MCP App project + */ +export async function scaffold(options: ScaffoldOptions): Promise { + const { projectName, template, targetDir, sdkVersion, mcpSdkVersion } = options; + const templatesDir = getTemplatesDir(); + const targetPath = path.resolve(process.cwd(), targetDir); + + // Check if target directory already exists + try { + await fs.access(targetPath); + throw new Error(`Directory "${targetDir}" already exists`); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + throw err; + } + } + + const replacements = { + name: projectName, + sdkVersion, + mcpSdkVersion, + }; + + // Create target directory + await fs.mkdir(targetPath, { recursive: true }); + + // Copy base template + const baseDir = path.join(templatesDir, "base"); + await copyDir(baseDir, targetPath, replacements); + + // Copy framework-specific template + const frameworkDir = path.join(templatesDir, template); + await copyDir(frameworkDir, targetPath, replacements); +} diff --git a/packages/create-mcp-app/src/utils.ts b/packages/create-mcp-app/src/utils.ts new file mode 100644 index 00000000..aa67e1bc --- /dev/null +++ b/packages/create-mcp-app/src/utils.ts @@ -0,0 +1,72 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +/** Current SDK version - read from create-mcp-app's own package.json at runtime */ +export const SDK_VERSION: string = JSON.parse( + fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf-8"), +).version; + +/** MCP SDK version - read from the installed @modelcontextprotocol/sdk package */ +export const MCP_SDK_VERSION: string = (() => { + // Resolve any entry point in the SDK, then walk up to find the package root + const sdkEntry = fileURLToPath(import.meta.resolve("@modelcontextprotocol/sdk")); + let dir = path.dirname(sdkEntry); + while (dir !== path.dirname(dir)) { + const candidate = path.join(dir, "package.json"); + if (fs.existsSync(candidate)) { + const pkg = JSON.parse(fs.readFileSync(candidate, "utf-8")); + if (pkg.name === "@modelcontextprotocol/sdk") return pkg.version as string; + } + dir = path.dirname(dir); + } + throw new Error("Could not find @modelcontextprotocol/sdk package.json"); +})(); + +/** Available templates */ +export const TEMPLATES = [ + { value: "react", label: "React", hint: "React + Vite + TypeScript" }, + { + value: "vanillajs", + label: "Vanilla JS", + hint: "Vanilla JavaScript + Vite + TypeScript", + }, +] as const; + +export type TemplateName = (typeof TEMPLATES)[number]["value"]; + +/** Get the templates directory path */ +export function getTemplatesDir(): string { + // Works both in development (src/) and production (dist/) + return path.join(__dirname, "..", "templates"); +} + +/** Validate project name - must be a valid directory and npm package name */ +export function validateProjectName( + name: string | undefined, +): string | undefined { + if (!name) { + return undefined; // Allow empty for placeholder default + } + + if (/[<>:"/\\|?*\x00-\x1f]/.test(name)) { + return "Project name contains invalid characters"; + } + + + return undefined; +} + +/** Process template placeholders in content */ +export function processTemplate( + content: string, + replacements: Record, +): string { + let result = content; + for (const [key, value] of Object.entries(replacements)) { + result = result.replaceAll(`{{${key}}}`, value); + } + return result; +} diff --git a/packages/create-mcp-app/templates/base/.gitignore b/packages/create-mcp-app/templates/base/.gitignore new file mode 100644 index 00000000..b9470778 --- /dev/null +++ b/packages/create-mcp-app/templates/base/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/packages/create-mcp-app/templates/base/main.ts b/packages/create-mcp-app/templates/base/main.ts new file mode 100644 index 00000000..0d0f04a6 --- /dev/null +++ b/packages/create-mcp-app/templates/base/main.ts @@ -0,0 +1,88 @@ +/** + * Entry point for running the MCP server. + * Run with: npm run serve + */ + +import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import cors from "cors"; +import type { Request, Response } from "express"; +import { createServer } from "./server.js"; + +/** + * Starts an MCP server with Streamable HTTP transport in stateless mode. + */ +export async function startStreamableHTTPServer( + createServer: () => McpServer, +): Promise { + const port = parseInt(process.env.PORT ?? "3001", 10); + + const app = createMcpExpressApp({ host: "0.0.0.0" }); + app.use(cors()); + + app.all("/mcp", async (req: Request, res: Response) => { + const server = createServer(); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + + res.on("close", () => { + transport.close().catch(() => {}); + server.close().catch(() => {}); + }); + + try { + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error("MCP error:", error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { code: -32603, message: "Internal server error" }, + id: null, + }); + } + } + }); + + const httpServer = app.listen(port, (err) => { + if (err) { + console.error("Failed to start server:", err); + process.exit(1); + } + console.log(`MCP server listening on http://localhost:${port}/mcp`); + }); + + const shutdown = () => { + console.log("\nShutting down..."); + httpServer.close(() => process.exit(0)); + }; + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); +} + +/** + * Starts an MCP server with stdio transport. + */ +export async function startStdioServer( + createServer: () => McpServer, +): Promise { + await createServer().connect(new StdioServerTransport()); +} + +async function main() { + if (process.argv.includes("--stdio")) { + await startStdioServer(createServer); + } else { + await startStreamableHTTPServer(createServer); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/packages/create-mcp-app/templates/base/scripts/bundle-server.mjs b/packages/create-mcp-app/templates/base/scripts/bundle-server.mjs new file mode 100644 index 00000000..f7ce2188 --- /dev/null +++ b/packages/create-mcp-app/templates/base/scripts/bundle-server.mjs @@ -0,0 +1,48 @@ +/** + * Bundle server files using esbuild + */ +import * as esbuild from "esbuild"; + +// Bundle server.ts +await esbuild.build({ + entryPoints: ["server.ts"], + bundle: true, + platform: "node", + target: "node20", + format: "esm", + outdir: "dist", + external: ["@modelcontextprotocol/*", "express", "cors", "zod"], + banner: { + js: ` +import { createRequire } from 'module'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; +const require = createRequire(import.meta.url); +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +`.trim(), + }, +}); + +// Bundle main.ts +await esbuild.build({ + entryPoints: ["main.ts"], + bundle: true, + platform: "node", + target: "node20", + format: "esm", + outfile: "dist/index.js", + external: ["./server.js"], + banner: { + js: `#!/usr/bin/env node +import { createRequire } from 'module'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; +const require = createRequire(import.meta.url); +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +`.trim(), + }, +}); + +console.log("Server bundled successfully!"); diff --git a/packages/create-mcp-app/templates/base/server.ts b/packages/create-mcp-app/templates/base/server.ts new file mode 100644 index 00000000..a129102d --- /dev/null +++ b/packages/create-mcp-app/templates/base/server.ts @@ -0,0 +1,57 @@ +import { + registerAppResource, + registerAppTool, + RESOURCE_MIME_TYPE, +} from "@modelcontextprotocol/ext-apps/server"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { + CallToolResult, + ReadResourceResult, +} from "@modelcontextprotocol/sdk/types.js"; +import fs from "node:fs/promises"; +import path from "node:path"; + +const DIST_DIR = path.join(import.meta.dirname, "dist"); + +export function createServer(): McpServer { + const server = new McpServer({ + name: "MCP App Server", + version: "1.0.0", + }); + + const resourceUri = "ui://my-tool/mcp-app.html"; + + registerAppTool( + server, + "my-tool", + { + title: "My Tool", + description: "TODO: Describe what this tool does.", + inputSchema: {}, + _meta: { ui: { resourceUri } }, + }, + async (): Promise => { + return { content: [{ type: "text", text: "TODO: Return tool result." }] }; + }, + ); + + registerAppResource( + server, + resourceUri, + resourceUri, + { mimeType: RESOURCE_MIME_TYPE }, + async (): Promise => { + const html = await fs.readFile( + path.join(DIST_DIR, "mcp-app.html"), + "utf-8", + ); + return { + contents: [ + { uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }, + ], + }; + }, + ); + + return server; +} diff --git a/packages/create-mcp-app/templates/base/src/global.css b/packages/create-mcp-app/templates/base/src/global.css new file mode 100644 index 00000000..64f7215f --- /dev/null +++ b/packages/create-mcp-app/templates/base/src/global.css @@ -0,0 +1,12 @@ +* { + box-sizing: border-box; +} + +html, +body { + font-family: system-ui, -apple-system, sans-serif; +} + +.main { + padding: 1rem; +} diff --git a/packages/create-mcp-app/templates/base/tsconfig.server.json b/packages/create-mcp-app/templates/base/tsconfig.server.json new file mode 100644 index 00000000..a3f14a75 --- /dev/null +++ b/packages/create-mcp-app/templates/base/tsconfig.server.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./dist", + "rootDir": ".", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true + }, + "include": ["server.ts", "main.ts"] +} diff --git a/packages/create-mcp-app/templates/react/mcp-app.html b/packages/create-mcp-app/templates/react/mcp-app.html new file mode 100644 index 00000000..b5a6eb95 --- /dev/null +++ b/packages/create-mcp-app/templates/react/mcp-app.html @@ -0,0 +1,14 @@ + + + + + + + MCP App + + + +
+ + + diff --git a/packages/create-mcp-app/templates/react/package.json.tmpl b/packages/create-mcp-app/templates/react/package.json.tmpl new file mode 100644 index 00000000..5ea5a3da --- /dev/null +++ b/packages/create-mcp-app/templates/react/package.json.tmpl @@ -0,0 +1,37 @@ +{ + "name": "{{name}}", + "version": "0.0.1", + "type": "module", + "private": true, + "scripts": { + "build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && node scripts/bundle-server.mjs", + "watch": "cross-env INPUT=mcp-app.html vite build --watch", + "serve": "tsx --watch main.ts", + "start": "cross-env NODE_ENV=development npm run build && npm run serve", + "dev": "cross-env NODE_ENV=development concurrently 'npm run watch' 'npm run serve'" + }, + "dependencies": { + "@modelcontextprotocol/ext-apps": "^{{sdkVersion}}", + "@modelcontextprotocol/sdk": "^{{mcpSdkVersion}}", + "cors": "^2.8.5", + "express": "^5.1.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.0", + "@types/node": "^22.0.0", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "@vitejs/plugin-react": "^4.3.4", + "concurrently": "^9.2.1", + "cross-env": "^10.1.0", + "esbuild": "^0.25.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "vite": "^6.0.0", + "vite-plugin-singlefile": "^2.3.0" + } +} diff --git a/packages/create-mcp-app/templates/react/src/mcp-app.tsx b/packages/create-mcp-app/templates/react/src/mcp-app.tsx new file mode 100644 index 00000000..be754b2a --- /dev/null +++ b/packages/create-mcp-app/templates/react/src/mcp-app.tsx @@ -0,0 +1,31 @@ +import { useApp } from "@modelcontextprotocol/ext-apps/react"; +import { StrictMode, useEffect, useState } from "react"; +import { createRoot } from "react-dom/client"; + + +function McpApp() { + const [message, setMessage] = useState("Connecting..."); + + const { app, error } = useApp({ + appInfo: { name: "MCP App", version: "1.0.0" }, + capabilities: {}, + }); + + useEffect(() => { + if (app) setMessage("Connected"); + }, [app]); + + if (error) return
Error: {error.message}
; + + return ( +
+

{message}

+
+ ); +} + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/packages/create-mcp-app/templates/react/src/vite-env.d.ts b/packages/create-mcp-app/templates/react/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/packages/create-mcp-app/templates/react/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/create-mcp-app/templates/react/tsconfig.json b/packages/create-mcp-app/templates/react/tsconfig.json new file mode 100644 index 00000000..cd56dcfe --- /dev/null +++ b/packages/create-mcp-app/templates/react/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/packages/create-mcp-app/templates/react/vite.config.ts b/packages/create-mcp-app/templates/react/vite.config.ts new file mode 100644 index 00000000..da0af84e --- /dev/null +++ b/packages/create-mcp-app/templates/react/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { viteSingleFile } from "vite-plugin-singlefile"; + +const INPUT = process.env.INPUT; +if (!INPUT) { + throw new Error("INPUT environment variable is not set"); +} + +const isDevelopment = process.env.NODE_ENV === "development"; + +export default defineConfig({ + plugins: [react(), viteSingleFile()], + build: { + sourcemap: isDevelopment ? "inline" : undefined, + cssMinify: !isDevelopment, + minify: !isDevelopment, + + rollupOptions: { + input: INPUT, + }, + outDir: "dist", + emptyOutDir: false, + }, +}); diff --git a/packages/create-mcp-app/templates/vanillajs/mcp-app.html b/packages/create-mcp-app/templates/vanillajs/mcp-app.html new file mode 100644 index 00000000..e573d1b7 --- /dev/null +++ b/packages/create-mcp-app/templates/vanillajs/mcp-app.html @@ -0,0 +1,15 @@ + + + + + + + MCP App + + +
+

Connecting...

+
+ + + diff --git a/packages/create-mcp-app/templates/vanillajs/package.json.tmpl b/packages/create-mcp-app/templates/vanillajs/package.json.tmpl new file mode 100644 index 00000000..425df8db --- /dev/null +++ b/packages/create-mcp-app/templates/vanillajs/package.json.tmpl @@ -0,0 +1,32 @@ +{ + "name": "{{name}}", + "version": "0.0.1", + "type": "module", + "private": true, + "scripts": { + "build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && node scripts/bundle-server.mjs", + "watch": "cross-env INPUT=mcp-app.html vite build --watch", + "serve": "tsx --watch main.ts", + "start": "cross-env NODE_ENV=development npm run build && npm run serve", + "dev": "cross-env NODE_ENV=development concurrently 'npm run watch' 'npm run serve'" + }, + "dependencies": { + "@modelcontextprotocol/ext-apps": "^{{sdkVersion}}", + "@modelcontextprotocol/sdk": "^{{mcpSdkVersion}}", + "cors": "^2.8.5", + "express": "^5.1.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.0", + "@types/node": "^22.0.0", + "concurrently": "^9.2.1", + "cross-env": "^10.1.0", + "esbuild": "^0.25.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "vite": "^6.0.0", + "vite-plugin-singlefile": "^2.3.0" + } +} diff --git a/packages/create-mcp-app/templates/vanillajs/src/mcp-app.ts b/packages/create-mcp-app/templates/vanillajs/src/mcp-app.ts new file mode 100644 index 00000000..5a10193a --- /dev/null +++ b/packages/create-mcp-app/templates/vanillajs/src/mcp-app.ts @@ -0,0 +1,27 @@ +import { + App, + applyDocumentTheme, + applyHostFonts, + applyHostStyleVariables, + type McpUiHostContext, +} from "@modelcontextprotocol/ext-apps"; +import "./global.css"; + + +const messageEl = document.getElementById("message")!; + +function handleHostContextChanged(ctx: McpUiHostContext) { + if (ctx.theme) applyDocumentTheme(ctx.theme); + if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables); + if (ctx.styles?.css?.fonts) applyHostFonts(ctx.styles.css.fonts); +} + +const app = new App({ name: "MCP App", version: "1.0.0" }); + +app.onhostcontextchanged = handleHostContextChanged; + +app.connect().then(() => { + messageEl.textContent = "Connected"; + const ctx = app.getHostContext(); + if (ctx) handleHostContextChanged(ctx); +}); diff --git a/packages/create-mcp-app/templates/vanillajs/tsconfig.json b/packages/create-mcp-app/templates/vanillajs/tsconfig.json new file mode 100644 index 00000000..4ad349ce --- /dev/null +++ b/packages/create-mcp-app/templates/vanillajs/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/packages/create-mcp-app/templates/vanillajs/vite.config.ts b/packages/create-mcp-app/templates/vanillajs/vite.config.ts new file mode 100644 index 00000000..6ff6d997 --- /dev/null +++ b/packages/create-mcp-app/templates/vanillajs/vite.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from "vite"; +import { viteSingleFile } from "vite-plugin-singlefile"; + +const INPUT = process.env.INPUT; +if (!INPUT) { + throw new Error("INPUT environment variable is not set"); +} + +const isDevelopment = process.env.NODE_ENV === "development"; + +export default defineConfig({ + plugins: [viteSingleFile()], + build: { + sourcemap: isDevelopment ? "inline" : undefined, + cssMinify: !isDevelopment, + minify: !isDevelopment, + + rollupOptions: { + input: INPUT, + }, + outDir: "dist", + emptyOutDir: false, + }, +}); diff --git a/packages/create-mcp-app/test/scaffold-build.test.mjs b/packages/create-mcp-app/test/scaffold-build.test.mjs new file mode 100644 index 00000000..9e2195dc --- /dev/null +++ b/packages/create-mcp-app/test/scaffold-build.test.mjs @@ -0,0 +1,82 @@ +/** + * End-to-end test: scaffolds each template, runs `npm install` and `npm run build`. + * Verifies that generated code compiles without errors. + */ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEMPLATES = ["react", "vanillajs"]; +const TIMEOUT = 120_000; // 2 minutes per template + +const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "create-mcp-app-test-")); +const createMcpAppDir = path.resolve( + new URL(".", import.meta.url).pathname, + "..", +); + +function run(args, cwd) { + console.log(` $ ${args.join(" ")}`); + execFileSync(args[0], args.slice(1), { cwd, stdio: "inherit", timeout: TIMEOUT }); +} + +let failed = false; + +for (const template of TEMPLATES) { + const projectName = `test-${template}`; + const projectDir = path.join(tmpRoot, projectName); + + console.log(`\n=== Testing template: ${template} ===`); + console.log(` Output: ${projectDir}`); + + try { + // Scaffold using the CLI directly (built dist) + const cliPath = path.join(createMcpAppDir, "dist", "index.js"); + run( + ["node", cliPath, projectName, "--framework", template], + tmpRoot, + ); + + // Verify key files exist + const pkg = JSON.parse( + fs.readFileSync(path.join(projectDir, "package.json"), "utf-8"), + ); + if (pkg.name !== projectName) { + throw new Error( + `Expected package name "${projectName}", got "${pkg.name}"`, + ); + } + + // Build (install already happened during scaffold) + run(["npm", "run", "build"], projectDir); + + // Verify dist output exists + const distDir = path.join(projectDir, "dist"); + if (!fs.existsSync(distDir)) { + throw new Error("dist/ directory not created after build"); + } + if (!fs.existsSync(path.join(distDir, "mcp-app.html"))) { + throw new Error("dist/mcp-app.html not found after build"); + } + + console.log(` PASS: ${template}`); + } catch (err) { + console.error(` FAIL: ${template}`, err.message); + failed = true; + } +} + +// Cleanup +try { + fs.rmSync(tmpRoot, { recursive: true, force: true }); +} catch { + // ignore cleanup errors +} + +if (failed) { + console.error("\nSome templates failed!"); + process.exit(1); +} + +console.log("\nAll templates passed!"); diff --git a/packages/create-mcp-app/tsconfig.json b/packages/create-mcp-app/tsconfig.json new file mode 100644 index 00000000..c8346694 --- /dev/null +++ b/packages/create-mcp-app/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true + }, + "include": ["src"] +}