From 9833096dbf0d6318e2fd986d6158d363268a68f6 Mon Sep 17 00:00:00 2001 From: Liad Yosef Date: Sun, 25 Jan 2026 22:36:01 +0200 Subject: [PATCH 1/4] feat: add @modelcontextprotocol/create-mcp-app package Enable scaffolding new MCP App projects via `npm create @modelcontextprotocol/mcp-app`. Features: - Interactive CLI with @clack/prompts for beautiful UX - React and Vanilla JS templates included - Uses tsx for broader compatibility (no bun dependency) - Templates include server, UI, and build configuration - Supports --template and --no-install flags Changes: - Add packages/create-mcp-app/ with CLI and templates - Add packages/* to workspaces in root package.json - Add publish-packages job to npm-publish workflow Co-Authored-By: Claude Opus 4.5 --- .github/workflows/npm-publish.yml | 33 ++++ package-lock.json | 77 ++++++--- package.json | 3 +- packages/create-mcp-app/README.md | 54 ++++++ packages/create-mcp-app/package.json | 31 ++++ packages/create-mcp-app/src/cli.ts | 155 ++++++++++++++++++ packages/create-mcp-app/src/index.ts | 7 + packages/create-mcp-app/src/scaffold.ts | 88 ++++++++++ packages/create-mcp-app/src/utils.ts | 53 ++++++ .../create-mcp-app/templates/base/.gitignore | 2 + .../create-mcp-app/templates/base/main.ts | 88 ++++++++++ .../templates/base/scripts/bundle-server.mjs | 48 ++++++ .../create-mcp-app/templates/base/server.ts | 69 ++++++++ .../templates/base/src/global.css | 18 ++ .../templates/base/tsconfig.server.json | 17 ++ .../templates/react/mcp-app.html | 14 ++ .../templates/react/package.json.tmpl | 37 +++++ .../templates/react/src/mcp-app.module.css | 57 +++++++ .../templates/react/src/mcp-app.tsx | 122 ++++++++++++++ .../templates/react/src/vite-env.d.ts | 1 + .../templates/react/tsconfig.json | 20 +++ .../templates/react/vite.config.ts | 25 +++ .../templates/vanillajs/mcp-app.html | 22 +++ .../templates/vanillajs/package.json.tmpl | 32 ++++ .../templates/vanillajs/src/mcp-app.css | 57 +++++++ .../templates/vanillajs/src/mcp-app.ts | 89 ++++++++++ .../templates/vanillajs/tsconfig.json | 19 +++ .../templates/vanillajs/vite.config.ts | 24 +++ packages/create-mcp-app/tsconfig.json | 16 ++ 29 files changed, 1253 insertions(+), 25 deletions(-) create mode 100644 packages/create-mcp-app/README.md create mode 100644 packages/create-mcp-app/package.json create mode 100644 packages/create-mcp-app/src/cli.ts create mode 100644 packages/create-mcp-app/src/index.ts create mode 100644 packages/create-mcp-app/src/scaffold.ts create mode 100644 packages/create-mcp-app/src/utils.ts create mode 100644 packages/create-mcp-app/templates/base/.gitignore create mode 100644 packages/create-mcp-app/templates/base/main.ts create mode 100644 packages/create-mcp-app/templates/base/scripts/bundle-server.mjs create mode 100644 packages/create-mcp-app/templates/base/server.ts create mode 100644 packages/create-mcp-app/templates/base/src/global.css create mode 100644 packages/create-mcp-app/templates/base/tsconfig.server.json create mode 100644 packages/create-mcp-app/templates/react/mcp-app.html create mode 100644 packages/create-mcp-app/templates/react/package.json.tmpl create mode 100644 packages/create-mcp-app/templates/react/src/mcp-app.module.css create mode 100644 packages/create-mcp-app/templates/react/src/mcp-app.tsx create mode 100644 packages/create-mcp-app/templates/react/src/vite-env.d.ts create mode 100644 packages/create-mcp-app/templates/react/tsconfig.json create mode 100644 packages/create-mcp-app/templates/react/vite.config.ts create mode 100644 packages/create-mcp-app/templates/vanillajs/mcp-app.html create mode 100644 packages/create-mcp-app/templates/vanillajs/package.json.tmpl create mode 100644 packages/create-mcp-app/templates/vanillajs/src/mcp-app.css create mode 100644 packages/create-mcp-app/templates/vanillajs/src/mcp-app.ts create mode 100644 packages/create-mcp-app/templates/vanillajs/tsconfig.json create mode 100644 packages/create-mcp-app/templates/vanillajs/vite.config.ts create mode 100644 packages/create-mcp-app/tsconfig.json diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 3b237cbe1..9b4086144 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -137,3 +137,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/package-lock.json b/package-lock.json index 89d82cfef..5ef689478 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", @@ -937,7 +938,6 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -2346,6 +2346,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": "0.4.1", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-0.4.1.tgz", @@ -2402,7 +2406,6 @@ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.3.tgz", "integrity": "sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ==", "license": "MIT", - "peer": true, "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", @@ -3482,7 +3485,6 @@ "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", @@ -3689,7 +3691,6 @@ "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3714,7 +3715,6 @@ "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4117,7 +4117,6 @@ "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" }, @@ -4459,7 +4458,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5175,7 +5173,6 @@ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "dev": true, "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -5648,7 +5645,6 @@ "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", @@ -7198,7 +7194,6 @@ "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" @@ -7408,7 +7403,6 @@ "integrity": "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -7530,7 +7524,6 @@ "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.0.tgz", "integrity": "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==", "license": "MIT", - "peer": true, "engines": { "node": ">=10" } @@ -7770,7 +7763,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": { @@ -7821,7 +7813,6 @@ "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.11.tgz", "integrity": "sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", @@ -8000,7 +7991,6 @@ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.48.0.tgz", "integrity": "sha512-+NUe82VoFP1RQViZI/esojx70eazGF4u0O/9ucqZ4rPcOZD+n5EVp17uYsqwdzjUjZyTpGKunHbDziW6AIAVkQ==", "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -8328,7 +8318,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -9028,7 +9017,6 @@ "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", @@ -9092,7 +9080,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9186,7 +9173,6 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -9363,7 +9349,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -9482,7 +9467,6 @@ "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", @@ -9643,7 +9627,6 @@ "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "dev": true, "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -9694,7 +9677,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -9707,6 +9689,53 @@ "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", + "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 7d757cbf6..2434e38fb 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 000000000..c6f5f8cbc --- /dev/null +++ b/packages/create-mcp-app/README.md @@ -0,0 +1,54 @@ +# @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 template +npm create @modelcontextprotocol/mcp-app my-app --template react + +# Skip npm install +npm create @modelcontextprotocol/mcp-app my-app --no-install +``` + +## Templates + +- **react** - React + Vite + TypeScript +- **vanillajs** - Vanilla JavaScript + Vite + TypeScript + +## What's Included + +Each generated project includes: + +- MCP server with a sample `get-time` 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 install # if you used --no-install +npm run dev +``` + +Then test with the basic-host: + +```bash +SERVERS='["http://localhost:3001/mcp"]' npx @modelcontextprotocol/basic-host +``` + +## License + +MIT diff --git a/packages/create-mcp-app/package.json b/packages/create-mcp-app/package.json new file mode 100644 index 000000000..d7e7dc3f8 --- /dev/null +++ b/packages/create-mcp-app/package.json @@ -0,0 +1,31 @@ +{ + "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", + "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", + "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 000000000..0cccf0131 --- /dev/null +++ b/packages/create-mcp-app/src/cli.ts @@ -0,0 +1,155 @@ +import * as p from "@clack/prompts"; +import pc from "picocolors"; +import { scaffold } from "./scaffold.js"; +import { + SDK_VERSION, + TEMPLATES, + type TemplateName, + validateProjectName, +} from "./utils.js"; + +interface CliArgs { + projectName?: string; + template?: string; + noInstall?: boolean; + 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 === "--no-install") { + result.noInstall = true; + } else if (arg === "--template" || arg === "-t") { + result.template = 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:")} + -t, --template Template to use (${TEMPLATES.map((t) => t.value).join(", ")}) + --no-install Skip npm install + -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 --template react + npm create @modelcontextprotocol/mcp-app my-app --no-install +`); +} + +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 template = args.template; + const runInstall = !args.noInstall; + + // 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 template if not provided + if (!template) { + const templateResult = await p.select({ + message: "Select a template:", + options: [...TEMPLATES], + }); + + if (p.isCancel(templateResult)) { + p.cancel("Operation cancelled."); + process.exit(0); + } + + template = templateResult as TemplateName; + } else { + const validTemplates = TEMPLATES.map((t) => t.value) as readonly string[]; + if (!validTemplates.includes(template)) { + p.cancel( + `Invalid template "${template}". Valid options: ${validTemplates.join(", ")}`, + ); + process.exit(1); + } + } + + const s = p.spinner(); + + try { + s.start("Creating project..."); + + await scaffold({ + projectName, + template: template!, + targetDir: projectName, + sdkVersion: SDK_VERSION, + }); + + s.stop("Project created!"); + + if (runInstall) { + 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}`, + ...(runInstall ? [] : ["npm install"]), + "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 000000000..c0be5228c --- /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 000000000..cc076bf77 --- /dev/null +++ b/packages/create-mcp-app/src/scaffold.ts @@ -0,0 +1,88 @@ +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; +} + +/** + * 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 } = 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, + }; + + // 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 000000000..78deb11ff --- /dev/null +++ b/packages/create-mcp-app/src/utils.ts @@ -0,0 +1,53 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +/** Current SDK version - used in generated package.json files */ +export const SDK_VERSION = "0.4.1"; + +/** 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 { + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + // Works both in development (src/) and production (dist/) + return path.join(__dirname, "..", "templates"); +} + +/** Validate project name */ +export function validateProjectName(name: string): string | undefined { + if (!name) { + return undefined; // Allow empty for placeholder default + } + + if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i.test(name)) { + return "Project name must be lowercase alphanumeric with optional hyphens"; + } + + if (name.length > 214) { + return "Project name is too long (max 214 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.replace(new RegExp(`\\{\\{${key}\\}\\}`, "g"), 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 000000000..b94707787 --- /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 000000000..0d0f04a69 --- /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 000000000..f7ce2188f --- /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 000000000..fc66e0555 --- /dev/null +++ b/packages/create-mcp-app/templates/base/server.ts @@ -0,0 +1,69 @@ +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"; + +// Works both from source (server.ts) and compiled (dist/server.js) +const DIST_DIR = import.meta.filename.endsWith(".ts") + ? path.join(import.meta.dirname, "dist") + : import.meta.dirname; + +/** + * Creates a new MCP server instance with tools and resources registered. + */ +export function createServer(): McpServer { + const server = new McpServer({ + name: "MCP App Server", + version: "1.0.0", + }); + + // Two-part registration: tool + resource, tied together by the resource URI. + const resourceUri = "ui://get-time/mcp-app.html"; + + // Register a tool with UI metadata. When the host calls this tool, it reads + // `_meta.ui.resourceUri` to know which resource to fetch and render as an + // interactive UI. + registerAppTool( + server, + "get-time", + { + title: "Get Time", + description: "Returns the current server time as an ISO 8601 string.", + inputSchema: {}, + _meta: { ui: { resourceUri } }, // Links this tool to its UI resource + }, + async (): Promise => { + const time = new Date().toISOString(); + return { content: [{ type: "text", text: time }] }; + }, + ); + + // Register the resource, which returns the bundled HTML/JavaScript for the UI. + 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 000000000..961537aa0 --- /dev/null +++ b/packages/create-mcp-app/templates/base/src/global.css @@ -0,0 +1,18 @@ +* { + box-sizing: border-box; +} + +html, +body { + font-family: system-ui, -apple-system, sans-serif; + font-size: 1rem; +} + +code { + font-size: 1em; +} + +#server-time { + flex: 1; + min-width: 0; +} 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 000000000..05ddd8ec4 --- /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"] +} 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 000000000..b5a6eb95e --- /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 000000000..24349ab22 --- /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": "^1.24.0", + "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.module.css b/packages/create-mcp-app/templates/react/src/mcp-app.module.css new file mode 100644 index 000000000..995de04c4 --- /dev/null +++ b/packages/create-mcp-app/templates/react/src/mcp-app.module.css @@ -0,0 +1,57 @@ +.main { + --color-primary: #2563eb; + --color-primary-hover: #1d4ed8; + + width: 100%; + max-width: 425px; + box-sizing: border-box; + padding: 1rem; + + > * { + margin-top: 0; + margin-bottom: 0; + } + + > * + * { + margin-top: 1.5rem; + } +} + +.action { + > * { + margin-top: 0; + margin-bottom: 0; + width: 100%; + } + + > * + * { + margin-top: 0.5rem; + } + + > p { + display: flex; + align-items: baseline; + gap: 0.25em; + } + + textarea, + input { + font-family: inherit; + font-size: inherit; + } + + button { + padding: 0.5rem 1rem; + border: none; + border-radius: 6px; + color: white; + font-weight: bold; + background-color: var(--color-primary); + cursor: pointer; + + &:hover, + &:focus-visible { + background-color: var(--color-primary-hover); + } + } +} 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 000000000..3d5bd8400 --- /dev/null +++ b/packages/create-mcp-app/templates/react/src/mcp-app.tsx @@ -0,0 +1,122 @@ +/** + * MCP App using React + */ +import type { App, McpUiHostContext } from "@modelcontextprotocol/ext-apps"; +import { useApp } from "@modelcontextprotocol/ext-apps/react"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { StrictMode, useCallback, useEffect, useState } from "react"; +import { createRoot } from "react-dom/client"; +import styles from "./mcp-app.module.css"; + +function extractTime(callToolResult: CallToolResult): string { + const textContent = callToolResult.content?.find((c) => c.type === "text"); + return textContent && "text" in textContent ? textContent.text : "[ERROR]"; +} + +function McpApp() { + const [toolResult, setToolResult] = useState(null); + const [hostContext, setHostContext] = useState< + McpUiHostContext | undefined + >(); + + const { app, error } = useApp({ + appInfo: { name: "MCP App", version: "1.0.0" }, + capabilities: {}, + onAppCreated: (app) => { + app.onteardown = async () => { + console.info("App is being torn down"); + return {}; + }; + app.ontoolinput = async (input) => { + console.info("Received tool call input:", input); + }; + app.ontoolresult = async (result) => { + console.info("Received tool call result:", result); + setToolResult(result); + }; + app.ontoolcancelled = (params) => { + console.info("Tool call cancelled:", params.reason); + }; + app.onerror = console.error; + app.onhostcontextchanged = (params) => { + setHostContext((prev) => ({ ...prev, ...params })); + }; + }, + }); + + useEffect(() => { + if (app) { + setHostContext(app.getHostContext()); + } + }, [app]); + + if (error) + return ( +
+ ERROR: {error.message} +
+ ); + if (!app) return
Connecting...
; + + return ( + + ); +} + +interface McpAppInnerProps { + app: App; + toolResult: CallToolResult | null; + hostContext?: McpUiHostContext; +} + +function McpAppInner({ app, toolResult, hostContext }: McpAppInnerProps) { + const [serverTime, setServerTime] = useState("Loading..."); + + useEffect(() => { + if (toolResult) { + setServerTime(extractTime(toolResult)); + } + }, [toolResult]); + + const handleGetTime = useCallback(async () => { + try { + console.info("Calling get-time tool..."); + const result = await app.callServerTool({ + name: "get-time", + arguments: {}, + }); + console.info("get-time result:", result); + setServerTime(extractTime(result)); + } catch (e) { + console.error(e); + setServerTime("[ERROR]"); + } + }, [app]); + + return ( +
+

MCP App

+
+

+ Server Time:{" "} + {serverTime} +

+ +
+
+ ); +} + +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 000000000..11f02fe2a --- /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 000000000..fc3c2101f --- /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", "server.ts"] +} 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 000000000..da0af84e2 --- /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 000000000..2c667f4fe --- /dev/null +++ b/packages/create-mcp-app/templates/vanillajs/mcp-app.html @@ -0,0 +1,22 @@ + + + + + + + MCP App + + +
+

MCP App

+
+

+ Server Time: + Loading... +

+ +
+
+ + + 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 000000000..c04e98b70 --- /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": "^1.24.0", + "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.css b/packages/create-mcp-app/templates/vanillajs/src/mcp-app.css new file mode 100644 index 000000000..995de04c4 --- /dev/null +++ b/packages/create-mcp-app/templates/vanillajs/src/mcp-app.css @@ -0,0 +1,57 @@ +.main { + --color-primary: #2563eb; + --color-primary-hover: #1d4ed8; + + width: 100%; + max-width: 425px; + box-sizing: border-box; + padding: 1rem; + + > * { + margin-top: 0; + margin-bottom: 0; + } + + > * + * { + margin-top: 1.5rem; + } +} + +.action { + > * { + margin-top: 0; + margin-bottom: 0; + width: 100%; + } + + > * + * { + margin-top: 0.5rem; + } + + > p { + display: flex; + align-items: baseline; + gap: 0.25em; + } + + textarea, + input { + font-family: inherit; + font-size: inherit; + } + + button { + padding: 0.5rem 1rem; + border: none; + border-radius: 6px; + color: white; + font-weight: bold; + background-color: var(--color-primary); + cursor: pointer; + + &:hover, + &:focus-visible { + background-color: var(--color-primary-hover); + } + } +} 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 000000000..5eb4a8a99 --- /dev/null +++ b/packages/create-mcp-app/templates/vanillajs/src/mcp-app.ts @@ -0,0 +1,89 @@ +/** + * MCP App using vanilla JavaScript + */ +import { + App, + applyDocumentTheme, + applyHostFonts, + applyHostStyleVariables, + type McpUiHostContext, +} from "@modelcontextprotocol/ext-apps"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import "./global.css"; +import "./mcp-app.css"; + +function extractTime(result: CallToolResult): string { + const textContent = result.content?.find((c) => c.type === "text"); + return textContent && "text" in textContent ? textContent.text : "[ERROR]"; +} + +const mainEl = document.querySelector(".main") as HTMLElement; +const serverTimeEl = document.getElementById("server-time")!; +const getTimeBtn = document.getElementById("get-time-btn")!; + +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); + } + if (ctx.safeAreaInsets) { + mainEl.style.paddingTop = `${ctx.safeAreaInsets.top}px`; + mainEl.style.paddingRight = `${ctx.safeAreaInsets.right}px`; + mainEl.style.paddingBottom = `${ctx.safeAreaInsets.bottom}px`; + mainEl.style.paddingLeft = `${ctx.safeAreaInsets.left}px`; + } +} + +// 1. Create app instance +const app = new App({ name: "MCP App", version: "1.0.0" }); + +// 2. Register handlers BEFORE connecting +app.onteardown = async () => { + console.info("App is being torn down"); + return {}; +}; + +app.ontoolinput = (params) => { + console.info("Received tool call input:", params); +}; + +app.ontoolresult = (result) => { + console.info("Received tool call result:", result); + serverTimeEl.textContent = extractTime(result); +}; + +app.ontoolcancelled = (params) => { + console.info("Tool call cancelled:", params.reason); +}; + +app.onerror = console.error; + +app.onhostcontextchanged = handleHostContextChanged; + +getTimeBtn.addEventListener("click", async () => { + try { + console.info("Calling get-time tool..."); + const result = await app.callServerTool({ + name: "get-time", + arguments: {}, + }); + console.info("get-time result:", result); + serverTimeEl.textContent = extractTime(result); + } catch (e) { + console.error(e); + serverTimeEl.textContent = "[ERROR]"; + } +}); + +// 3. Connect to host +app.connect().then(() => { + 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 000000000..535267b25 --- /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", "server.ts"] +} 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 000000000..6ff6d9979 --- /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/tsconfig.json b/packages/create-mcp-app/tsconfig.json new file mode 100644 index 000000000..c83466948 --- /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"] +} From 50ebcd9d98db0afec8488755d5f05aee6f32ffce Mon Sep 17 00:00:00 2001 From: Liad Yosef Date: Sun, 25 Jan 2026 22:50:47 +0200 Subject: [PATCH 2/4] docs: add create-mcp-app references to README and quickstart - Add Quick Start section to README with npm create command - Add tip callout to quickstart guide for faster project setup Co-Authored-By: Claude Opus 4.5 --- README.md | 12 ++++++++++++ docs/quickstart.md | 3 +++ 2 files changed, 15 insertions(+) diff --git a/README.md b/README.md index 84e080053..150d4723c 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,18 @@ There's no _supported_ host implementation in this repo (beyond the [examples/ba We have [contributed a tentative implementation](https://github.com/MCP-UI-Org/mcp-ui/pull/147) of hosting / iframing / sandboxing logic to the [MCP-UI](https://github.com/idosal/mcp-ui) repository, and expect OSS clients may use it, while other clients might roll their own hosting logic. +## 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/docs/quickstart.md b/docs/quickstart.md index 63e93713a..8d6492b9f 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -21,6 +21,9 @@ We'll use the [MCP TypeScript SDK](https://github.com/modelcontextprotocol/types You'll also need Node.js 18+. +> [!TIP] +> **Want to skip the setup?** Run `npm create @modelcontextprotocol/mcp-app my-app` to scaffold this project automatically, then skip to [Section 3: Build the View](#3-build-the-view). + ## 1. Set up the project We'll set up a minimal TypeScript project with Vite for bundling. From 1df6b8da6d6711e92bfe71a8839afd673b7a0f80 Mon Sep 17 00:00:00 2001 From: Liad Yosef Date: Thu, 29 Jan 2026 23:14:43 +0200 Subject: [PATCH 3/4] create-mcp-app: address PR review feedback - Rename --template flag to --framework - Remove --no-install flag (always install deps) - Read SDK_VERSION dynamically from package.json at runtime - Simplify project name validation (filesystem + npm rules only) - Replace get-time example tool with minimal hello stub - Remove __dirname/import.meta.filename hack in server template - Strip scaffold CSS to minimal layout-only styles - Simplify React scaffold to minimal connected component - Fix tsconfig.json to include main.ts in both templates - Add tsconfig.server.json main.ts include for server code - Fix README.md (remove --no-install, basic-host reference) - Remove quickstart.md scaffold tip (tutorial shouldn't shortcut itself) - Add E2E scaffold test that builds both templates end-to-end Co-Authored-By: Claude Opus 4.5 --- docs/quickstart.md | 3 - packages/create-mcp-app/README.md | 18 +-- packages/create-mcp-app/package.json | 1 + packages/create-mcp-app/src/cli.ts | 62 ++++------ packages/create-mcp-app/src/utils.ts | 28 +++-- .../create-mcp-app/templates/base/server.ts | 26 ++-- .../templates/base/src/global.css | 10 -- .../templates/base/tsconfig.server.json | 2 +- .../templates/react/src/mcp-app.module.css | 54 --------- .../templates/react/src/mcp-app.tsx | 111 ++++-------------- .../templates/react/tsconfig.json | 2 +- .../templates/vanillajs/mcp-app.html | 10 +- .../templates/vanillajs/src/mcp-app.css | 54 --------- .../templates/vanillajs/src/mcp-app.ts | 78 +++--------- .../templates/vanillajs/tsconfig.json | 2 +- .../test/scaffold-build.test.mjs | 79 +++++++++++++ 16 files changed, 175 insertions(+), 365 deletions(-) create mode 100644 packages/create-mcp-app/test/scaffold-build.test.mjs diff --git a/docs/quickstart.md b/docs/quickstart.md index c0c9fce31..8146824a8 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -21,9 +21,6 @@ We'll use the [MCP TypeScript SDK](https://github.com/modelcontextprotocol/types You'll also need Node.js 18+. -> [!TIP] -> **Want to skip the setup?** Run `npm create @modelcontextprotocol/mcp-app my-app` to scaffold this project automatically, then skip to [Section 3: Build the View](#3-build-the-view). - ## 1. Set up the project We'll set up a minimal TypeScript project with Vite for bundling. diff --git a/packages/create-mcp-app/README.md b/packages/create-mcp-app/README.md index c6f5f8cbc..5fa1bebf5 100644 --- a/packages/create-mcp-app/README.md +++ b/packages/create-mcp-app/README.md @@ -11,14 +11,11 @@ npm create @modelcontextprotocol/mcp-app # With project name npm create @modelcontextprotocol/mcp-app my-app -# With template -npm create @modelcontextprotocol/mcp-app my-app --template react - -# Skip npm install -npm create @modelcontextprotocol/mcp-app my-app --no-install +# With framework +npm create @modelcontextprotocol/mcp-app my-app --framework react ``` -## Templates +## Frameworks - **react** - React + Vite + TypeScript - **vanillajs** - Vanilla JavaScript + Vite + TypeScript @@ -27,7 +24,7 @@ npm create @modelcontextprotocol/mcp-app my-app --no-install Each generated project includes: -- MCP server with a sample `get-time` tool +- MCP server with a sample tool - Interactive UI that communicates with the host - Vite build configuration for bundling the UI - TypeScript configuration @@ -39,16 +36,9 @@ After creating your project: ```bash cd my-app -npm install # if you used --no-install npm run dev ``` -Then test with the basic-host: - -```bash -SERVERS='["http://localhost:3001/mcp"]' npx @modelcontextprotocol/basic-host -``` - ## License MIT diff --git a/packages/create-mcp-app/package.json b/packages/create-mcp-app/package.json index d7e7dc3f8..fa1f425ef 100644 --- a/packages/create-mcp-app/package.json +++ b/packages/create-mcp-app/package.json @@ -12,6 +12,7 @@ ], "scripts": { "build": "tsc && chmod +x dist/index.js", + "test": "npm run build && node test/scaffold-build.test.mjs", "prepublishOnly": "npm run build" }, "repository": { diff --git a/packages/create-mcp-app/src/cli.ts b/packages/create-mcp-app/src/cli.ts index 0cccf0131..5c08e56da 100644 --- a/packages/create-mcp-app/src/cli.ts +++ b/packages/create-mcp-app/src/cli.ts @@ -10,8 +10,7 @@ import { interface CliArgs { projectName?: string; - template?: string; - noInstall?: boolean; + framework?: string; help?: boolean; } @@ -22,10 +21,8 @@ function parseArgs(args: string[]): CliArgs { const arg = args[i]; if (arg === "--help" || arg === "-h") { result.help = true; - } else if (arg === "--no-install") { - result.noInstall = true; - } else if (arg === "--template" || arg === "-t") { - result.template = args[++i]; + } else if (arg === "--framework" || arg === "-f") { + result.framework = args[++i]; } else if (!arg.startsWith("-") && !result.projectName) { result.projectName = arg; } @@ -42,15 +39,13 @@ ${pc.bold("Usage:")} npm create @modelcontextprotocol/mcp-app [project-name] [options] ${pc.bold("Options:")} - -t, --template Template to use (${TEMPLATES.map((t) => t.value).join(", ")}) - --no-install Skip npm install - -h, --help Show this help message + -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 --template react - npm create @modelcontextprotocol/mcp-app my-app --no-install + npm create @modelcontextprotocol/mcp-app my-app --framework react `); } @@ -66,8 +61,7 @@ export async function main(): Promise { p.intro(pc.bgCyan(pc.black(" create-mcp-app "))); let projectName = args.projectName; - let template = args.template; - const runInstall = !args.noInstall; + let framework = args.framework; // Prompt for project name if not provided if (!projectName) { @@ -91,24 +85,24 @@ export async function main(): Promise { } } - // Prompt for template if not provided - if (!template) { - const templateResult = await p.select({ - message: "Select a template:", + // Prompt for framework if not provided + if (!framework) { + const frameworkResult = await p.select({ + message: "Select a framework:", options: [...TEMPLATES], }); - if (p.isCancel(templateResult)) { + if (p.isCancel(frameworkResult)) { p.cancel("Operation cancelled."); process.exit(0); } - template = templateResult as TemplateName; + framework = frameworkResult as TemplateName; } else { - const validTemplates = TEMPLATES.map((t) => t.value) as readonly string[]; - if (!validTemplates.includes(template)) { + const validFrameworks = TEMPLATES.map((t) => t.value) as readonly string[]; + if (!validFrameworks.includes(framework)) { p.cancel( - `Invalid template "${template}". Valid options: ${validTemplates.join(", ")}`, + `Invalid framework "${framework}". Valid options: ${validFrameworks.join(", ")}`, ); process.exit(1); } @@ -121,29 +115,23 @@ export async function main(): Promise { await scaffold({ projectName, - template: template!, + template: framework!, targetDir: projectName, sdkVersion: SDK_VERSION, }); s.stop("Project created!"); - if (runInstall) { - s.start("Installing dependencies..."); - const { execSync } = await import("node:child_process"); - execSync("npm install", { - cwd: projectName, - stdio: "ignore", - }); - s.stop("Dependencies installed!"); - } + 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}`, - ...(runInstall ? [] : ["npm install"]), - "npm run dev", - ].join("\n"), + [`cd ${projectName}`, "npm run dev"].join("\n"), "Next steps:", ); diff --git a/packages/create-mcp-app/src/utils.ts b/packages/create-mcp-app/src/utils.ts index 78deb11ff..d82974120 100644 --- a/packages/create-mcp-app/src/utils.ts +++ b/packages/create-mcp-app/src/utils.ts @@ -1,8 +1,18 @@ +import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -/** Current SDK version - used in generated package.json files */ -export const SDK_VERSION = "0.4.1"; +/** 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( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "package.json", + ), + "utf-8", + ), +).version; /** Available templates */ export const TEMPLATES = [ @@ -23,18 +33,18 @@ export function getTemplatesDir(): string { return path.join(__dirname, "..", "templates"); } -/** Validate project name */ -export function validateProjectName(name: string): string | undefined { +/** 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 (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i.test(name)) { - return "Project name must be lowercase alphanumeric with optional hyphens"; + if (/[<>:"/\\|?*\x00-\x1f]/.test(name)) { + return "Project name contains invalid characters"; } - if (name.length > 214) { - return "Project name is too long (max 214 characters)"; + if (name.startsWith(".") || name.startsWith("_")) { + return "Project name cannot start with a dot or underscore"; } return undefined; @@ -47,7 +57,7 @@ export function processTemplate( ): string { let result = content; for (const [key, value] of Object.entries(replacements)) { - result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, "g"), value); + result = result.replaceAll(`{{${key}}}`, value); } return result; } diff --git a/packages/create-mcp-app/templates/base/server.ts b/packages/create-mcp-app/templates/base/server.ts index fc66e0555..5d61fb959 100644 --- a/packages/create-mcp-app/templates/base/server.ts +++ b/packages/create-mcp-app/templates/base/server.ts @@ -11,42 +11,30 @@ import type { import fs from "node:fs/promises"; import path from "node:path"; -// Works both from source (server.ts) and compiled (dist/server.js) -const DIST_DIR = import.meta.filename.endsWith(".ts") - ? path.join(import.meta.dirname, "dist") - : import.meta.dirname; +const DIST_DIR = path.join(import.meta.dirname, "dist"); -/** - * Creates a new MCP server instance with tools and resources registered. - */ export function createServer(): McpServer { const server = new McpServer({ name: "MCP App Server", version: "1.0.0", }); - // Two-part registration: tool + resource, tied together by the resource URI. - const resourceUri = "ui://get-time/mcp-app.html"; + const resourceUri = "ui://hello/mcp-app.html"; - // Register a tool with UI metadata. When the host calls this tool, it reads - // `_meta.ui.resourceUri` to know which resource to fetch and render as an - // interactive UI. registerAppTool( server, - "get-time", + "hello", { - title: "Get Time", - description: "Returns the current server time as an ISO 8601 string.", + title: "Hello", + description: "Returns a greeting.", inputSchema: {}, - _meta: { ui: { resourceUri } }, // Links this tool to its UI resource + _meta: { ui: { resourceUri } }, }, async (): Promise => { - const time = new Date().toISOString(); - return { content: [{ type: "text", text: time }] }; + return { content: [{ type: "text", text: "Hello from the server!" }] }; }, ); - // Register the resource, which returns the bundled HTML/JavaScript for the UI. registerAppResource( server, resourceUri, diff --git a/packages/create-mcp-app/templates/base/src/global.css b/packages/create-mcp-app/templates/base/src/global.css index 961537aa0..05d8e19e7 100644 --- a/packages/create-mcp-app/templates/base/src/global.css +++ b/packages/create-mcp-app/templates/base/src/global.css @@ -5,14 +5,4 @@ html, body { font-family: system-ui, -apple-system, sans-serif; - font-size: 1rem; -} - -code { - font-size: 1em; -} - -#server-time { - flex: 1; - min-width: 0; } diff --git a/packages/create-mcp-app/templates/base/tsconfig.server.json b/packages/create-mcp-app/templates/base/tsconfig.server.json index 05ddd8ec4..a3f14a75f 100644 --- a/packages/create-mcp-app/templates/base/tsconfig.server.json +++ b/packages/create-mcp-app/templates/base/tsconfig.server.json @@ -13,5 +13,5 @@ "esModuleInterop": true, "resolveJsonModule": true }, - "include": ["server.ts"] + "include": ["server.ts", "main.ts"] } diff --git a/packages/create-mcp-app/templates/react/src/mcp-app.module.css b/packages/create-mcp-app/templates/react/src/mcp-app.module.css index 995de04c4..dcd1efd1a 100644 --- a/packages/create-mcp-app/templates/react/src/mcp-app.module.css +++ b/packages/create-mcp-app/templates/react/src/mcp-app.module.css @@ -1,57 +1,3 @@ .main { - --color-primary: #2563eb; - --color-primary-hover: #1d4ed8; - - width: 100%; - max-width: 425px; - box-sizing: border-box; padding: 1rem; - - > * { - margin-top: 0; - margin-bottom: 0; - } - - > * + * { - margin-top: 1.5rem; - } -} - -.action { - > * { - margin-top: 0; - margin-bottom: 0; - width: 100%; - } - - > * + * { - margin-top: 0.5rem; - } - - > p { - display: flex; - align-items: baseline; - gap: 0.25em; - } - - textarea, - input { - font-family: inherit; - font-size: inherit; - } - - button { - padding: 0.5rem 1rem; - border: none; - border-radius: 6px; - color: white; - font-weight: bold; - background-color: var(--color-primary); - cursor: pointer; - - &:hover, - &:focus-visible { - background-color: var(--color-primary-hover); - } - } } diff --git a/packages/create-mcp-app/templates/react/src/mcp-app.tsx b/packages/create-mcp-app/templates/react/src/mcp-app.tsx index 3d5bd8400..595b841ca 100644 --- a/packages/create-mcp-app/templates/react/src/mcp-app.tsx +++ b/packages/create-mcp-app/templates/react/src/mcp-app.tsx @@ -1,116 +1,45 @@ -/** - * MCP App using React - */ -import type { App, McpUiHostContext } from "@modelcontextprotocol/ext-apps"; import { useApp } from "@modelcontextprotocol/ext-apps/react"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { StrictMode, useCallback, useEffect, useState } from "react"; import { createRoot } from "react-dom/client"; import styles from "./mcp-app.module.css"; -function extractTime(callToolResult: CallToolResult): string { - const textContent = callToolResult.content?.find((c) => c.type === "text"); - return textContent && "text" in textContent ? textContent.text : "[ERROR]"; -} - function McpApp() { - const [toolResult, setToolResult] = useState(null); - const [hostContext, setHostContext] = useState< - McpUiHostContext | undefined - >(); + const [message, setMessage] = useState("Connecting..."); const { app, error } = useApp({ appInfo: { name: "MCP App", version: "1.0.0" }, capabilities: {}, onAppCreated: (app) => { - app.onteardown = async () => { - console.info("App is being torn down"); - return {}; - }; - app.ontoolinput = async (input) => { - console.info("Received tool call input:", input); - }; - app.ontoolresult = async (result) => { - console.info("Received tool call result:", result); - setToolResult(result); - }; - app.ontoolcancelled = (params) => { - console.info("Tool call cancelled:", params.reason); - }; - app.onerror = console.error; - app.onhostcontextchanged = (params) => { - setHostContext((prev) => ({ ...prev, ...params })); + app.ontoolresult = (result: CallToolResult) => { + const text = result.content?.find((c) => c.type === "text"); + setMessage(text && "text" in text ? text.text : "[no result]"); }; }, }); - useEffect(() => { - if (app) { - setHostContext(app.getHostContext()); - } + const handleCall = useCallback(async () => { + if (!app) return; + const result = await app.callServerTool({ + name: "hello", + arguments: {}, + }); + const text = result.content?.find((c) => c.type === "text"); + setMessage(text && "text" in text ? text.text : "[no result]"); }, [app]); - if (error) - return ( -
- ERROR: {error.message} -
- ); - if (!app) return
Connecting...
; - - return ( - - ); -} - -interface McpAppInnerProps { - app: App; - toolResult: CallToolResult | null; - hostContext?: McpUiHostContext; -} - -function McpAppInner({ app, toolResult, hostContext }: McpAppInnerProps) { - const [serverTime, setServerTime] = useState("Loading..."); - useEffect(() => { - if (toolResult) { - setServerTime(extractTime(toolResult)); - } - }, [toolResult]); - - const handleGetTime = useCallback(async () => { - try { - console.info("Calling get-time tool..."); - const result = await app.callServerTool({ - name: "get-time", - arguments: {}, - }); - console.info("get-time result:", result); - setServerTime(extractTime(result)); - } catch (e) { - console.error(e); - setServerTime("[ERROR]"); - } + if (app) setMessage("Connected"); }, [app]); + if (error) return
Error: {error.message}
; + return ( -
-

MCP App

-
-

- Server Time:{" "} - {serverTime} -

- -
+
+

{message}

+
); } diff --git a/packages/create-mcp-app/templates/react/tsconfig.json b/packages/create-mcp-app/templates/react/tsconfig.json index fc3c2101f..32ce24649 100644 --- a/packages/create-mcp-app/templates/react/tsconfig.json +++ b/packages/create-mcp-app/templates/react/tsconfig.json @@ -16,5 +16,5 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src", "server.ts"] + "include": ["src", "server.ts", "main.ts"] } diff --git a/packages/create-mcp-app/templates/vanillajs/mcp-app.html b/packages/create-mcp-app/templates/vanillajs/mcp-app.html index 2c667f4fe..6bc715df7 100644 --- a/packages/create-mcp-app/templates/vanillajs/mcp-app.html +++ b/packages/create-mcp-app/templates/vanillajs/mcp-app.html @@ -8,14 +8,8 @@
-

MCP App

-
-

- Server Time: - Loading... -

- -
+

Connecting...

+
diff --git a/packages/create-mcp-app/templates/vanillajs/src/mcp-app.css b/packages/create-mcp-app/templates/vanillajs/src/mcp-app.css index 995de04c4..dcd1efd1a 100644 --- a/packages/create-mcp-app/templates/vanillajs/src/mcp-app.css +++ b/packages/create-mcp-app/templates/vanillajs/src/mcp-app.css @@ -1,57 +1,3 @@ .main { - --color-primary: #2563eb; - --color-primary-hover: #1d4ed8; - - width: 100%; - max-width: 425px; - box-sizing: border-box; padding: 1rem; - - > * { - margin-top: 0; - margin-bottom: 0; - } - - > * + * { - margin-top: 1.5rem; - } -} - -.action { - > * { - margin-top: 0; - margin-bottom: 0; - width: 100%; - } - - > * + * { - margin-top: 0.5rem; - } - - > p { - display: flex; - align-items: baseline; - gap: 0.25em; - } - - textarea, - input { - font-family: inherit; - font-size: inherit; - } - - button { - padding: 0.5rem 1rem; - border: none; - border-radius: 6px; - color: white; - font-weight: bold; - background-color: var(--color-primary); - cursor: pointer; - - &:hover, - &:focus-visible { - background-color: var(--color-primary-hover); - } - } } diff --git a/packages/create-mcp-app/templates/vanillajs/src/mcp-app.ts b/packages/create-mcp-app/templates/vanillajs/src/mcp-app.ts index 5eb4a8a99..cdddbd191 100644 --- a/packages/create-mcp-app/templates/vanillajs/src/mcp-app.ts +++ b/packages/create-mcp-app/templates/vanillajs/src/mcp-app.ts @@ -1,6 +1,3 @@ -/** - * MCP App using vanilla JavaScript - */ import { App, applyDocumentTheme, @@ -8,82 +5,37 @@ import { applyHostStyleVariables, type McpUiHostContext, } from "@modelcontextprotocol/ext-apps"; -import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import "./global.css"; import "./mcp-app.css"; -function extractTime(result: CallToolResult): string { - const textContent = result.content?.find((c) => c.type === "text"); - return textContent && "text" in textContent ? textContent.text : "[ERROR]"; -} - -const mainEl = document.querySelector(".main") as HTMLElement; -const serverTimeEl = document.getElementById("server-time")!; -const getTimeBtn = document.getElementById("get-time-btn")!; +const messageEl = document.getElementById("message")!; +const callBtn = document.getElementById("call-btn")!; 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); - } - if (ctx.safeAreaInsets) { - mainEl.style.paddingTop = `${ctx.safeAreaInsets.top}px`; - mainEl.style.paddingRight = `${ctx.safeAreaInsets.right}px`; - mainEl.style.paddingBottom = `${ctx.safeAreaInsets.bottom}px`; - mainEl.style.paddingLeft = `${ctx.safeAreaInsets.left}px`; - } + if (ctx.theme) applyDocumentTheme(ctx.theme); + if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables); + if (ctx.styles?.css?.fonts) applyHostFonts(ctx.styles.css.fonts); } -// 1. Create app instance const app = new App({ name: "MCP App", version: "1.0.0" }); -// 2. Register handlers BEFORE connecting -app.onteardown = async () => { - console.info("App is being torn down"); - return {}; -}; - -app.ontoolinput = (params) => { - console.info("Received tool call input:", params); -}; - app.ontoolresult = (result) => { - console.info("Received tool call result:", result); - serverTimeEl.textContent = extractTime(result); -}; - -app.ontoolcancelled = (params) => { - console.info("Tool call cancelled:", params.reason); + const text = result.content?.find((c) => c.type === "text"); + messageEl.textContent = text && "text" in text ? text.text : "[no result]"; }; -app.onerror = console.error; - app.onhostcontextchanged = handleHostContextChanged; -getTimeBtn.addEventListener("click", async () => { - try { - console.info("Calling get-time tool..."); - const result = await app.callServerTool({ - name: "get-time", - arguments: {}, - }); - console.info("get-time result:", result); - serverTimeEl.textContent = extractTime(result); - } catch (e) { - console.error(e); - serverTimeEl.textContent = "[ERROR]"; - } +callBtn.addEventListener("click", async () => { + const result = await app.callServerTool({ + name: "hello", + arguments: {}, + }); + const text = result.content?.find((c) => c.type === "text"); + messageEl.textContent = text && "text" in text ? text.text : "[no result]"; }); -// 3. Connect to host app.connect().then(() => { const ctx = app.getHostContext(); - if (ctx) { - handleHostContextChanged(ctx); - } + if (ctx) handleHostContextChanged(ctx); }); diff --git a/packages/create-mcp-app/templates/vanillajs/tsconfig.json b/packages/create-mcp-app/templates/vanillajs/tsconfig.json index 535267b25..6c553b5d7 100644 --- a/packages/create-mcp-app/templates/vanillajs/tsconfig.json +++ b/packages/create-mcp-app/templates/vanillajs/tsconfig.json @@ -15,5 +15,5 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src", "server.ts"] + "include": ["src", "server.ts", "main.ts"] } 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 000000000..e5d4607a9 --- /dev/null +++ b/packages/create-mcp-app/test/scaffold-build.test.mjs @@ -0,0 +1,79 @@ +/** + * End-to-end test: scaffolds each template, runs `npm install` and `npm run build`. + * Verifies that generated code compiles without errors. + */ +import { execSync } 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(cmd, cwd) { + console.log(` $ ${cmd}`); + execSync(cmd, { 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) + run( + `node ${path.join(createMcpAppDir, "dist", "index.js")} ${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!"); From d15a5a1f16e178df6564641755778a35ed64d404 Mon Sep 17 00:00:00 2001 From: Liad Yosef Date: Sat, 31 Jan 2026 15:34:49 +0200 Subject: [PATCH 4/4] create-mcp-app: address PR review feedback - Rename --template to --framework flag - Dynamically resolve MCP SDK version from installed package - Remove dot/underscore name restriction and regex interpolation concern - Simplify scaffold templates: remove example tool UI, use TODO placeholders - Delete CSS module files, use global.css with host style variables - Switch test from execSync to execFileSync to avoid shell injection - Add separate tsconfig.server.json without DOM libs for server code Co-Authored-By: Claude Opus 4.5 --- package-lock.json | 25 +++++++++++++ packages/create-mcp-app/package.json | 1 + packages/create-mcp-app/src/cli.ts | 7 ++-- packages/create-mcp-app/src/scaffold.ts | 4 ++- packages/create-mcp-app/src/utils.ts | 35 ++++++++++++------- .../create-mcp-app/templates/base/server.ts | 10 +++--- .../templates/base/src/global.css | 4 +++ .../templates/react/package.json.tmpl | 2 +- .../templates/react/src/mcp-app.module.css | 3 -- .../templates/react/src/mcp-app.tsx | 26 ++------------ .../templates/react/tsconfig.json | 2 +- .../templates/vanillajs/mcp-app.html | 1 - .../templates/vanillajs/package.json.tmpl | 2 +- .../templates/vanillajs/src/mcp-app.css | 3 -- .../templates/vanillajs/src/mcp-app.ts | 18 ++-------- .../templates/vanillajs/tsconfig.json | 2 +- .../test/scaffold-build.test.mjs | 17 +++++---- 17 files changed, 82 insertions(+), 80 deletions(-) delete mode 100644 packages/create-mcp-app/templates/react/src/mcp-app.module.css delete mode 100644 packages/create-mcp-app/templates/vanillajs/src/mcp-app.css diff --git a/package-lock.json b/package-lock.json index 429b252d5..279499461 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1195,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", @@ -2663,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", @@ -3746,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", @@ -3952,6 +3955,7 @@ "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3976,6 +3980,7 @@ "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4378,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" }, @@ -4719,6 +4725,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5430,6 +5437,7 @@ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "dev": true, "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -5902,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", @@ -7451,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" @@ -7562,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" } @@ -7571,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" }, @@ -7660,6 +7672,7 @@ "integrity": "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -7781,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" } @@ -8070,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", @@ -8248,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", @@ -8575,6 +8591,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9274,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", @@ -9337,6 +9355,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9430,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", @@ -9606,6 +9626,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9724,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", @@ -9884,6 +9906,7 @@ "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "dev": true, "license": "ISC", + "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -9934,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" } @@ -9953,6 +9977,7 @@ "license": "MIT", "dependencies": { "@clack/prompts": "^0.10.0", + "@modelcontextprotocol/sdk": "^1.24.0", "picocolors": "^1.1.0" }, "bin": { diff --git a/packages/create-mcp-app/package.json b/packages/create-mcp-app/package.json index fa1f425ef..e59d53b6c 100644 --- a/packages/create-mcp-app/package.json +++ b/packages/create-mcp-app/package.json @@ -23,6 +23,7 @@ "license": "MIT", "dependencies": { "@clack/prompts": "^0.10.0", + "@modelcontextprotocol/sdk": "^1.24.0", "picocolors": "^1.1.0" }, "devDependencies": { diff --git a/packages/create-mcp-app/src/cli.ts b/packages/create-mcp-app/src/cli.ts index 5c08e56da..37c54e703 100644 --- a/packages/create-mcp-app/src/cli.ts +++ b/packages/create-mcp-app/src/cli.ts @@ -2,6 +2,7 @@ import * as p from "@clack/prompts"; import pc from "picocolors"; import { scaffold } from "./scaffold.js"; import { + MCP_SDK_VERSION, SDK_VERSION, TEMPLATES, type TemplateName, @@ -118,6 +119,7 @@ export async function main(): Promise { template: framework!, targetDir: projectName, sdkVersion: SDK_VERSION, + mcpSdkVersion: MCP_SDK_VERSION, }); s.stop("Project created!"); @@ -130,10 +132,7 @@ export async function main(): Promise { }); s.stop("Dependencies installed!"); - p.note( - [`cd ${projectName}`, "npm run dev"].join("\n"), - "Next steps:", - ); + p.note([`cd ${projectName}`, "npm run dev"].join("\n"), "Next steps:"); p.outro(pc.green("Happy building!")); } catch (error) { diff --git a/packages/create-mcp-app/src/scaffold.ts b/packages/create-mcp-app/src/scaffold.ts index cc076bf77..98e5b3fe0 100644 --- a/packages/create-mcp-app/src/scaffold.ts +++ b/packages/create-mcp-app/src/scaffold.ts @@ -11,6 +11,7 @@ export interface ScaffoldOptions { template: TemplateName | string; targetDir: string; sdkVersion: string; + mcpSdkVersion: string; } /** @@ -56,7 +57,7 @@ async function copyDir( * Scaffold a new MCP App project */ export async function scaffold(options: ScaffoldOptions): Promise { - const { projectName, template, targetDir, sdkVersion } = options; + const { projectName, template, targetDir, sdkVersion, mcpSdkVersion } = options; const templatesDir = getTemplatesDir(); const targetPath = path.resolve(process.cwd(), targetDir); @@ -73,6 +74,7 @@ export async function scaffold(options: ScaffoldOptions): Promise { const replacements = { name: projectName, sdkVersion, + mcpSdkVersion, }; // Create target directory diff --git a/packages/create-mcp-app/src/utils.ts b/packages/create-mcp-app/src/utils.ts index d82974120..aa67e1bca 100644 --- a/packages/create-mcp-app/src/utils.ts +++ b/packages/create-mcp-app/src/utils.ts @@ -2,18 +2,29 @@ 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( - path.dirname(fileURLToPath(import.meta.url)), - "..", - "package.json", - ), - "utf-8", - ), + 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" }, @@ -28,13 +39,14 @@ export type TemplateName = (typeof TEMPLATES)[number]["value"]; /** Get the templates directory path */ export function getTemplatesDir(): string { - const __dirname = path.dirname(fileURLToPath(import.meta.url)); // 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 { +export function validateProjectName( + name: string | undefined, +): string | undefined { if (!name) { return undefined; // Allow empty for placeholder default } @@ -43,9 +55,6 @@ export function validateProjectName(name: string | undefined): string | undefine return "Project name contains invalid characters"; } - if (name.startsWith(".") || name.startsWith("_")) { - return "Project name cannot start with a dot or underscore"; - } return undefined; } diff --git a/packages/create-mcp-app/templates/base/server.ts b/packages/create-mcp-app/templates/base/server.ts index 5d61fb959..a129102db 100644 --- a/packages/create-mcp-app/templates/base/server.ts +++ b/packages/create-mcp-app/templates/base/server.ts @@ -19,19 +19,19 @@ export function createServer(): McpServer { version: "1.0.0", }); - const resourceUri = "ui://hello/mcp-app.html"; + const resourceUri = "ui://my-tool/mcp-app.html"; registerAppTool( server, - "hello", + "my-tool", { - title: "Hello", - description: "Returns a greeting.", + title: "My Tool", + description: "TODO: Describe what this tool does.", inputSchema: {}, _meta: { ui: { resourceUri } }, }, async (): Promise => { - return { content: [{ type: "text", text: "Hello from the server!" }] }; + return { content: [{ type: "text", text: "TODO: Return tool result." }] }; }, ); diff --git a/packages/create-mcp-app/templates/base/src/global.css b/packages/create-mcp-app/templates/base/src/global.css index 05d8e19e7..64f7215f6 100644 --- a/packages/create-mcp-app/templates/base/src/global.css +++ b/packages/create-mcp-app/templates/base/src/global.css @@ -6,3 +6,7 @@ html, body { font-family: system-ui, -apple-system, sans-serif; } + +.main { + padding: 1rem; +} diff --git a/packages/create-mcp-app/templates/react/package.json.tmpl b/packages/create-mcp-app/templates/react/package.json.tmpl index 24349ab22..5ea5a3da6 100644 --- a/packages/create-mcp-app/templates/react/package.json.tmpl +++ b/packages/create-mcp-app/templates/react/package.json.tmpl @@ -12,7 +12,7 @@ }, "dependencies": { "@modelcontextprotocol/ext-apps": "^{{sdkVersion}}", - "@modelcontextprotocol/sdk": "^1.24.0", + "@modelcontextprotocol/sdk": "^{{mcpSdkVersion}}", "cors": "^2.8.5", "express": "^5.1.0", "react": "^19.2.0", diff --git a/packages/create-mcp-app/templates/react/src/mcp-app.module.css b/packages/create-mcp-app/templates/react/src/mcp-app.module.css deleted file mode 100644 index dcd1efd1a..000000000 --- a/packages/create-mcp-app/templates/react/src/mcp-app.module.css +++ /dev/null @@ -1,3 +0,0 @@ -.main { - padding: 1rem; -} diff --git a/packages/create-mcp-app/templates/react/src/mcp-app.tsx b/packages/create-mcp-app/templates/react/src/mcp-app.tsx index 595b841ca..be754b2aa 100644 --- a/packages/create-mcp-app/templates/react/src/mcp-app.tsx +++ b/packages/create-mcp-app/templates/react/src/mcp-app.tsx @@ -1,8 +1,7 @@ import { useApp } from "@modelcontextprotocol/ext-apps/react"; -import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { StrictMode, useCallback, useEffect, useState } from "react"; +import { StrictMode, useEffect, useState } from "react"; import { createRoot } from "react-dom/client"; -import styles from "./mcp-app.module.css"; + function McpApp() { const [message, setMessage] = useState("Connecting..."); @@ -10,24 +9,8 @@ function McpApp() { const { app, error } = useApp({ appInfo: { name: "MCP App", version: "1.0.0" }, capabilities: {}, - onAppCreated: (app) => { - app.ontoolresult = (result: CallToolResult) => { - const text = result.content?.find((c) => c.type === "text"); - setMessage(text && "text" in text ? text.text : "[no result]"); - }; - }, }); - const handleCall = useCallback(async () => { - if (!app) return; - const result = await app.callServerTool({ - name: "hello", - arguments: {}, - }); - const text = result.content?.find((c) => c.type === "text"); - setMessage(text && "text" in text ? text.text : "[no result]"); - }, [app]); - useEffect(() => { if (app) setMessage("Connected"); }, [app]); @@ -35,11 +18,8 @@ function McpApp() { if (error) return
Error: {error.message}
; return ( -
+

{message}

-
); } diff --git a/packages/create-mcp-app/templates/react/tsconfig.json b/packages/create-mcp-app/templates/react/tsconfig.json index 32ce24649..cd56dcfe5 100644 --- a/packages/create-mcp-app/templates/react/tsconfig.json +++ b/packages/create-mcp-app/templates/react/tsconfig.json @@ -16,5 +16,5 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src", "server.ts", "main.ts"] + "include": ["src"] } diff --git a/packages/create-mcp-app/templates/vanillajs/mcp-app.html b/packages/create-mcp-app/templates/vanillajs/mcp-app.html index 6bc715df7..e573d1b78 100644 --- a/packages/create-mcp-app/templates/vanillajs/mcp-app.html +++ b/packages/create-mcp-app/templates/vanillajs/mcp-app.html @@ -9,7 +9,6 @@

Connecting...

-
diff --git a/packages/create-mcp-app/templates/vanillajs/package.json.tmpl b/packages/create-mcp-app/templates/vanillajs/package.json.tmpl index c04e98b70..425df8db2 100644 --- a/packages/create-mcp-app/templates/vanillajs/package.json.tmpl +++ b/packages/create-mcp-app/templates/vanillajs/package.json.tmpl @@ -12,7 +12,7 @@ }, "dependencies": { "@modelcontextprotocol/ext-apps": "^{{sdkVersion}}", - "@modelcontextprotocol/sdk": "^1.24.0", + "@modelcontextprotocol/sdk": "^{{mcpSdkVersion}}", "cors": "^2.8.5", "express": "^5.1.0", "zod": "^4.1.13" diff --git a/packages/create-mcp-app/templates/vanillajs/src/mcp-app.css b/packages/create-mcp-app/templates/vanillajs/src/mcp-app.css deleted file mode 100644 index dcd1efd1a..000000000 --- a/packages/create-mcp-app/templates/vanillajs/src/mcp-app.css +++ /dev/null @@ -1,3 +0,0 @@ -.main { - padding: 1rem; -} diff --git a/packages/create-mcp-app/templates/vanillajs/src/mcp-app.ts b/packages/create-mcp-app/templates/vanillajs/src/mcp-app.ts index cdddbd191..5a10193a0 100644 --- a/packages/create-mcp-app/templates/vanillajs/src/mcp-app.ts +++ b/packages/create-mcp-app/templates/vanillajs/src/mcp-app.ts @@ -6,10 +6,9 @@ import { type McpUiHostContext, } from "@modelcontextprotocol/ext-apps"; import "./global.css"; -import "./mcp-app.css"; + const messageEl = document.getElementById("message")!; -const callBtn = document.getElementById("call-btn")!; function handleHostContextChanged(ctx: McpUiHostContext) { if (ctx.theme) applyDocumentTheme(ctx.theme); @@ -19,23 +18,10 @@ function handleHostContextChanged(ctx: McpUiHostContext) { const app = new App({ name: "MCP App", version: "1.0.0" }); -app.ontoolresult = (result) => { - const text = result.content?.find((c) => c.type === "text"); - messageEl.textContent = text && "text" in text ? text.text : "[no result]"; -}; - app.onhostcontextchanged = handleHostContextChanged; -callBtn.addEventListener("click", async () => { - const result = await app.callServerTool({ - name: "hello", - arguments: {}, - }); - const text = result.content?.find((c) => c.type === "text"); - messageEl.textContent = text && "text" in text ? text.text : "[no result]"; -}); - 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 index 6c553b5d7..4ad349ce4 100644 --- a/packages/create-mcp-app/templates/vanillajs/tsconfig.json +++ b/packages/create-mcp-app/templates/vanillajs/tsconfig.json @@ -15,5 +15,5 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src", "server.ts", "main.ts"] + "include": ["src"] } diff --git a/packages/create-mcp-app/test/scaffold-build.test.mjs b/packages/create-mcp-app/test/scaffold-build.test.mjs index e5d4607a9..9e2195dc1 100644 --- a/packages/create-mcp-app/test/scaffold-build.test.mjs +++ b/packages/create-mcp-app/test/scaffold-build.test.mjs @@ -2,7 +2,7 @@ * End-to-end test: scaffolds each template, runs `npm install` and `npm run build`. * Verifies that generated code compiles without errors. */ -import { execSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -16,9 +16,9 @@ const createMcpAppDir = path.resolve( "..", ); -function run(cmd, cwd) { - console.log(` $ ${cmd}`); - execSync(cmd, { cwd, stdio: "inherit", timeout: TIMEOUT }); +function run(args, cwd) { + console.log(` $ ${args.join(" ")}`); + execFileSync(args[0], args.slice(1), { cwd, stdio: "inherit", timeout: TIMEOUT }); } let failed = false; @@ -32,8 +32,9 @@ for (const template of TEMPLATES) { try { // Scaffold using the CLI directly (built dist) + const cliPath = path.join(createMcpAppDir, "dist", "index.js"); run( - `node ${path.join(createMcpAppDir, "dist", "index.js")} ${projectName} --framework ${template}`, + ["node", cliPath, projectName, "--framework", template], tmpRoot, ); @@ -42,11 +43,13 @@ for (const template of TEMPLATES) { fs.readFileSync(path.join(projectDir, "package.json"), "utf-8"), ); if (pkg.name !== projectName) { - throw new Error(`Expected package name "${projectName}", got "${pkg.name}"`); + throw new Error( + `Expected package name "${projectName}", got "${pkg.name}"`, + ); } // Build (install already happened during scaffold) - run("npm run build", projectDir); + run(["npm", "run", "build"], projectDir); // Verify dist output exists const distDir = path.join(projectDir, "dist");