diff --git a/BETA_RELEASES.md b/BETA_RELEASES.md new file mode 100644 index 00000000..59e4948a --- /dev/null +++ b/BETA_RELEASES.md @@ -0,0 +1,252 @@ +# Beta Release Workflow + +This guide explains how to release beta versions using the `beta` branch and automated GitHub Actions. + +## 🎯 Overview + +- **`main` branch** → Stable releases (automated via `release.yml`) +- **`beta` branch** → Beta/prerelease versions (automated via `release-beta.yml`) +- Feature branches → Work in progress (no releases) + +## 🚀 Quick Start: Releasing a Beta + +### 1. Create and Push to Beta Branch + +```bash +# From your feature branch (e.g., feat/mcp-ui-apps) +git checkout -b beta + +# Push to trigger the beta release workflow +git push origin beta +``` + +The GitHub Action will automatically: +- Enter prerelease mode (creates `.changeset/pre.json`) +- Create a "Version Packages (beta)" PR +- Publish beta versions when the PR is merged + +### 2. Make Changes and Create Changesets + +```bash +# Make your changes +# ... edit files ... + +# Create a changeset +pnpm changeset + +# Commit and push +git add . +git commit -m "feat: add new feature" +git push origin beta +``` + +### 3. The Automated Flow + +When you push to `beta`: + +1. **If no changesets exist**: Nothing happens (waiting for changesets) +2. **If changesets exist**: + - A PR is created with version bumps (e.g., `0.2.1-beta.0`) + - Review and merge the PR + - On merge, packages are automatically published to npm with `@beta` tag + +## 📝 Manual Beta Release (Alternative) + +If you prefer manual control: + +```bash +# On beta branch +git checkout beta + +# Enter prerelease mode (first time only) +pnpm changeset pre enter beta + +# Create changesets +pnpm changeset + +# Version packages +pnpm version + +# Commit version changes +git add . +git commit -m "chore: version packages (beta)" +git push + +# Publish to npm +pnpm release +``` + +## 🔄 Continuous Beta Releases + +While on the `beta` branch, you can continue making changes: + +```bash +# Make more changes +# ... edit code ... + +# Create another changeset +pnpm changeset + +# Push to trigger versioning +git push origin beta +``` + +Each release will increment the beta number: `0.2.1-beta.0` → `0.2.1-beta.1` → `0.2.1-beta.2`, etc. + +## ✅ Promoting Beta to Stable + +When beta testing is complete and you're ready for a stable release: + +### Option 1: Merge Beta to Main (Recommended) + +```bash +# Switch to main and merge beta +git checkout main +git pull origin main +git merge beta + +# Exit prerelease mode +pnpm changeset pre exit + +# Commit the pre.json removal +git add .changeset/pre.json +git commit -m "chore: exit prerelease mode" + +# Push to main (triggers stable release workflow) +git push origin main +``` + +The stable release workflow will: +- Version packages as stable (e.g., `0.2.1`) +- Publish to npm with `@latest` tag + +### Option 2: Cherry-pick Changes + +If you only want specific changes from beta: + +```bash +git checkout main +git cherry-pick +# ... resolve any conflicts ... +git push origin main +``` + +## 📦 Installing Beta Versions + +Users can install beta versions: + +```bash +# Install latest beta +npm install mcp-use@beta +npm install @mcp-use/cli@beta + +# Install specific beta version +npm install mcp-use@0.2.1-beta.0 +``` + +## 🔍 Checking Beta Releases + +View published versions and tags: + +```bash +# See all versions +npm view mcp-use versions + +# See dist-tags +npm view mcp-use dist-tags +# { +# latest: '0.2.0', +# beta: '0.2.1-beta.0' +# } +``` + +## 🛠️ Workflow Features + +The `release-beta.yml` workflow includes: + +- ✅ Automatic prerelease mode entry +- ✅ Version PR creation +- ✅ Automatic publishing on merge +- ✅ Comment on commits with published versions +- ✅ Manual trigger via GitHub UI (workflow_dispatch) + +## 🔧 Manual Trigger + +You can manually trigger a beta release from GitHub: + +1. Go to **Actions** tab +2. Select **Release Beta** workflow +3. Click **Run workflow** +4. Select `beta` branch +5. Click **Run workflow** button + +## 📋 Best Practices + +1. **Keep beta branch up to date with main** + ```bash + git checkout beta + git merge main + git push + ``` + +2. **Create meaningful changesets** + - Describe what changed from a user's perspective + - Mark breaking changes clearly + +3. **Test beta versions thoroughly** + - Install beta versions in test projects + - Verify all packages work together + - Check for breaking changes + +4. **Clean up after stable release** + ```bash + # After merging to main and releasing stable + git checkout beta + git merge main # Sync beta with main + git push + ``` + +## 🐛 Troubleshooting + +### "Already in prerelease mode" Error + +The workflow handles this automatically, but if you see this message, it means `.changeset/pre.json` already exists. This is normal and expected. + +### Beta Branch Out of Sync + +```bash +# Reset beta branch to match a starting point +git checkout beta +git reset --hard main # or feat/your-feature +git push --force origin beta +``` + +### Want to Start Fresh + +```bash +# Exit prerelease mode +pnpm changeset pre exit + +# Remove all pending changesets +rm -rf .changeset/*.md + +# Commit changes +git add . +git commit -m "chore: reset changesets" +git push +``` + +### Workflow Not Triggering + +Check: +1. Branch name is exactly `beta` +2. You have changesets in `.changeset/*.md` +3. GitHub Actions is enabled in your repository +4. `NPM_TOKEN` secret is configured in GitHub Settings + +## 📚 Resources + +- [Changesets Prerelease Documentation](https://github.com/changesets/changesets/blob/main/docs/prereleases.md) +- [Main Release Workflow](./VERSIONING.md) +- [Changeset Workflow Guide](./CHANGESET_WORKFLOW.md) + diff --git a/CHANGESET_WORKFLOW.md b/CHANGESET_WORKFLOW.md new file mode 100644 index 00000000..7d0021f7 --- /dev/null +++ b/CHANGESET_WORKFLOW.md @@ -0,0 +1,251 @@ +# Changeset Workflow Quick Reference + +## 📦 Publishable Packages + +- `mcp-use` - Main MCP integration library +- `@mcp-use/cli` - CLI tool for building MCP widgets +- `@mcp-use/inspector` - MCP Inspector UI +- `create-mcp-use-app` - Project scaffolding tool + +## 🚀 Common Commands + +### Development Workflow + +```bash +# 1. Make your changes to the codebase +# ... edit files ... + +# 2. Build to verify everything works +pnpm build + +# 3. Create a changeset +pnpm changeset +# Follow prompts: +# - Select affected packages +# - Choose version bump (patch/minor/major) +# - Write a summary + +# 4. Commit your changes + changeset +git add . +git commit -m "feat: your feature description" + +# 5. Push and create PR +git push +``` + +### Release Workflow (Maintainers) + +```bash +# After PRs with changesets are merged to main: + +# 1. Check what will be released +pnpm version:check + +# 2. Apply changesets (bump versions, update CHANGELOGs) +pnpm version + +# 3. Review the changes +git diff + +# 4. Commit version bumps +git add . +git commit -m "chore: version packages" +git push + +# 5. Publish to npm (requires npm auth) +pnpm release +``` + +## 📝 Changeset Types + +### Patch (0.0.X) - Bug Fixes + +```bash +pnpm changeset +``` + +```md +--- +"mcp-use": patch +--- + +Fixed memory leak in MCPSession cleanup +``` + +### Minor (0.X.0) - New Features + +```bash +pnpm changeset +``` + +```md +--- +"mcp-use": minor +"@mcp-use/cli": minor +--- + +Added support for custom headers in HTTP connections +``` + +### Major (X.0.0) - Breaking Changes + +```bash +pnpm changeset +``` + +```md +--- +"mcp-use": major +--- + +BREAKING: Renamed `createSession()` to `connect()` for consistency +``` + +### Multiple Packages + +```md +--- +"mcp-use": minor +"@mcp-use/cli": patch +"@mcp-use/inspector": patch +"create-mcp-use-app": patch +--- + +- Added React hooks for MCP connections (mcp-use) +- Fixed build output paths (cli) +- Updated dependencies (inspector, create-mcp-use-app) +``` + +### Empty Changeset (No Release) + +For changes that don't need a release (docs, tests, internal): + +```bash +pnpm changeset --empty +``` + +## 🔍 Useful Commands + +```bash +# Check status of pending changesets +pnpm version:check + +# Create a changeset interactively +pnpm changeset + +# Create an empty changeset +pnpm changeset --empty + +# Apply changesets (version bump) +pnpm version + +# Build and publish +pnpm release + +# Check what would be published +pnpm changeset status --verbose +``` + +## 📋 Checklist for Contributors + +Before submitting a PR: + +- [ ] Code changes are complete +- [ ] Tests pass (`pnpm test`) +- [ ] Linting passes (`pnpm lint`) +- [ ] Build succeeds (`pnpm build`) +- [ ] **Changeset created** (`pnpm changeset`) - if changes affect public API +- [ ] Changeset committed with PR + +## 📋 Checklist for Maintainers + +Before releasing: + +- [ ] All PRs with changesets are merged +- [ ] Check pending changesets (`pnpm version:check`) +- [ ] Apply versions (`pnpm version`) +- [ ] Review generated CHANGELOGs +- [ ] Commit version changes +- [ ] Push to main +- [ ] Publish packages (`pnpm release`) +- [ ] Verify packages on npm +- [ ] Create GitHub release (optional) + +## 🎯 Best Practices + +1. **Create changesets with your PRs** + - Always add a changeset for user-facing changes + - Skip changesets for internal-only changes (add `--empty` if needed) + +2. **Write clear summaries** + - Focus on what changed from a user's perspective + - Include migration steps for breaking changes + - Link to relevant issues/PRs if applicable + +3. **Use appropriate version bumps** + - **Patch**: Bug fixes, performance improvements, internal changes + - **Minor**: New features, new exports, enhancements + - **Major**: Breaking API changes, removed features + +4. **Review before publishing** + - Always review the generated CHANGELOGs + - Verify version numbers make sense + - Test packages locally before publishing + +## 🔄 Automated Releases (GitHub Actions) + +The repository includes automated release workflows: + +### `.github/workflows/release.yml` + +- Runs on push to `main` +- Creates a "Version Packages" PR automatically +- Publishes packages when the Version PR is merged +- Requires `NPM_TOKEN` secret in GitHub + +### `.github/workflows/ci.yml` + +- Runs on all PRs +- Checks for lint errors +- Runs tests +- Verifies builds +- Reminds to add changesets + +## 🐛 Troubleshooting + +### "No changesets present" + +You need to create a changeset first: +```bash +pnpm changeset +``` + +### "Package X is not found in the project" + +The package name in ignore list doesn't match. Check: +1. Package name in `package.json` +2. Name in `.changeset/config.json` ignore list + +### "Published packages are missing" + +Make sure packages have: +1. `"private": false` (or omit it) +2. `"publishConfig": { "access": "public" }` +3. Not in the `ignore` list + +### Dry Run + +To test versioning without actually changing files: + +```bash +# Preview what changesets would do +pnpm changeset status --verbose --since=main +``` + +## 📚 Resources + +- [Changesets Documentation](https://github.com/changesets/changesets) +- [Semantic Versioning](https://semver.org/) +- [Project VERSIONING.md](./VERSIONING.md) - Detailed guide +- [Changesets Tutorial](https://github.com/changesets/changesets/blob/main/docs/intro-to-using-changesets.md) + diff --git a/VERSIONING.md b/VERSIONING.md new file mode 100644 index 00000000..2038bd96 --- /dev/null +++ b/VERSIONING.md @@ -0,0 +1,342 @@ +# Version Management with Changesets + +This monorepo uses [Changesets](https://github.com/changesets/changesets) for coordinated version management and automatic changelog generation across all packages. + +## Packages + +The following packages are published to npm: + +- **mcp-use** - Main library for MCP integration +- **@mcp-use/cli** - CLI tool for building MCP widgets +- **@mcp-use/inspector** - MCP Inspector UI +- **create-mcp-use-app** - Project scaffolding tool + +## Workflow + +### 1. Making Changes + +When you make changes that should be published: + +```bash +# After making your changes, create a changeset +pnpm changeset +``` + +This will: +1. Ask which packages were affected +2. Ask whether it's a **major**, **minor**, or **patch** change +3. Ask you to write a summary of the changes + +The changeset file will be created in `.changeset/` directory. + +### 2. Version Types + +Follow [Semantic Versioning](https://semver.org/): + +- **Major** (x.0.0) - Breaking changes +- **Minor** (0.x.0) - New features (backward compatible) +- **Patch** (0.0.x) - Bug fixes (backward compatible) + +### 3. Creating a Changeset + +```bash +# Interactive mode (recommended) +pnpm changeset + +# Example prompts: +# ? Which packages would you like to include? +# ✔ mcp-use +# ✔ @mcp-use/cli +# +# ? What kind of change is this for mcp-use? +# ○ major (breaking) +# ● minor (feature) +# ○ patch (fix) +# +# ? Please enter a summary for this change: +# Added support for custom headers in HTTP connections +``` + +### 4. Versioning Packages + +When you're ready to release: + +```bash +# Consume all changesets and update package versions +pnpm version + +# This will: +# - Update version numbers in package.json files +# - Update CHANGELOG.md files +# - Delete consumed changeset files +# - Update pnpm-lock.yaml +``` + +### 5. Publishing to npm + +```bash +# Build and publish all updated packages +pnpm release + +# This will: +# - Build all packages +# - Publish updated packages to npm +# - Create git tags +``` + +### 6. Complete Release Flow + +```bash +# 1. Make your changes +git checkout -b feat/my-new-feature + +# 2. Create a changeset +pnpm changeset + +# 3. Commit the changeset +git add .changeset +git commit -m "feat: add my new feature" + +# 4. Push and create PR +git push origin feat/my-new-feature + +# 5. After PR is merged to main, on main branch: +git checkout main +git pull + +# 6. Version packages +pnpm version + +# 7. Commit version changes +git add . +git commit -m "chore: version packages" +git push + +# 8. Publish to npm +pnpm release +``` + +## Changeset Examples + +### Adding a Feature + +```bash +pnpm changeset +``` + +``` +--- +"mcp-use": minor +--- + +Added support for WebSocket reconnection with exponential backoff +``` + +### Fixing a Bug + +```bash +pnpm changeset +``` + +``` +--- +"@mcp-use/cli": patch +--- + +Fixed TypeScript compilation errors in widget builder +``` + +### Breaking Change + +```bash +pnpm changeset +``` + +``` +--- +"mcp-use": major +--- + +BREAKING: Changed `createMCPServer` API to require explicit configuration object +``` + +### Multiple Packages + +```bash +pnpm changeset +``` + +``` +--- +"mcp-use": minor +"@mcp-use/cli": patch +"@mcp-use/inspector": patch +--- + +- Added new React hooks for MCP connections +- Fixed CLI build output paths +- Updated inspector UI dependencies +``` + +## Commands Reference + +| Command | Description | +|---------|-------------| +| `pnpm changeset` | Create a new changeset (interactive) | +| `pnpm version` | Apply changesets and update versions | +| `pnpm release` | Build and publish packages to npm | +| `pnpm version:check` | Check which packages have changesets | + +## Advanced Configuration + +### Linked Packages + +If you want packages to always be versioned together: + +```json +{ + "linked": [ + ["mcp-use", "@mcp-use/cli"] + ] +} +``` + +### Fixed Packages + +If you want packages to always have the same version: + +```json +{ + "fixed": [ + ["@mcp-use/*"] + ] +} +``` + +### Ignore Packages + +Packages already ignored (in `.changeset/config.json`): +- `test-ui` (test application, not published) + +## CI/CD Integration + +### GitHub Actions Example + +Create `.github/workflows/release.yml`: + +```yaml +name: Release + +on: + push: + branches: + - main + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +jobs: + release: + name: Release + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v3 + with: + version: 10.6.1 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build packages + run: pnpm build + + - name: Create Release Pull Request or Publish + id: changesets + uses: changesets/action@v1 + with: + publish: pnpm release + version: pnpm version + commit: 'chore: version packages' + title: 'chore: version packages' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} +``` + +## Best Practices + +1. **Create changesets with your PRs** - Add a changeset file with every PR that changes package behavior +2. **Be descriptive** - Write clear, user-focused changeset summaries +3. **Version appropriately** - Follow semver strictly to avoid breaking users +4. **Batch releases** - Accumulate multiple changesets before versioning +5. **Review CHANGELOGs** - Check generated changelogs before publishing + +## Troubleshooting + +### "No changesets present" + +If you run `pnpm version` and see this message, you need to create changesets first: + +```bash +pnpm changeset +``` + +### "Unable to find a workspace package" + +Make sure all packages are in the workspace config (`pnpm-workspace.yaml`): + +```yaml +packages: + - 'packages/*' + - 'test_app' +``` + +### "Package is not published" + +Ensure package has `publishConfig.access` set to `"public"` in its `package.json`. + +## Examples of Generated CHANGELOGs + +After running `pnpm version`, your CHANGELOG.md files will look like: + +```markdown +# mcp-use + +## 0.3.0 + +### Minor Changes + +- abc1234: Added support for WebSocket reconnection with exponential backoff + +### Patch Changes + +- def5678: Fixed memory leak in session cleanup + +## 0.2.0 + +### Minor Changes + +- ghi9012: Added React hooks for MCP connections +``` + +## Package Dependencies + +When updating internal dependencies (workspace packages), Changesets will automatically: + +- Update version ranges in dependent packages +- Create appropriate changeset entries +- Maintain workspace protocol (`workspace:*`) in development + +Example: If you update `mcp-use` with a minor change, and `@mcp-use/cli` depends on it, `@mcp-use/cli` will get a **patch** version bump (configured by `updateInternalDependencies: "patch"`). + diff --git a/packages/create-mcp-use-app/src/templates/ui/resources/data-visualization.tsx b/packages/create-mcp-use-app/src/templates/ui/resources/data-visualization.tsx new file mode 100644 index 00000000..a67ff8af --- /dev/null +++ b/packages/create-mcp-use-app/src/templates/ui/resources/data-visualization.tsx @@ -0,0 +1,475 @@ +import React, { useEffect, useState } from 'react' +import { createRoot } from 'react-dom/client' + +interface DataPoint { + label: string + value: number + color?: string +} + +interface DataVisualizationProps { + initialData?: DataPoint[] + chartType?: 'bar' | 'line' | 'pie' +} + +const DataVisualization: React.FC = ({ + initialData = [], + chartType = 'bar', +}) => { + const [data, setData] = useState(initialData) + const [currentChartType, setCurrentChartType] = useState<'bar' | 'line' | 'pie'>(chartType) + const [newDataPoint, setNewDataPoint] = useState({ label: '', value: 0 }) + + // Load data from URL parameters or use defaults + useEffect(() => { + const urlParams = new URLSearchParams(window.location.search) + const dataParam = urlParams.get('data') + const typeParam = urlParams.get('chartType') + + if (dataParam) { + try { + const parsedData = JSON.parse(decodeURIComponent(dataParam)) + setData(parsedData) + } + catch (error) { + console.error('Error parsing data from URL:', error) + } + } + else { + // Default data for demo + setData([ + { label: 'January', value: 65, color: '#3498db' }, + { label: 'February', value: 59, color: '#e74c3c' }, + { label: 'March', value: 80, color: '#2ecc71' }, + { label: 'April', value: 81, color: '#f39c12' }, + { label: 'May', value: 56, color: '#9b59b6' }, + { label: 'June', value: 55, color: '#1abc9c' }, + ]) + } + + if (typeParam) { + setCurrentChartType(typeParam as 'bar' | 'line' | 'pie') + } + }, []) + + const addDataPoint = () => { + if (newDataPoint.label.trim() && newDataPoint.value > 0) { + const colors = ['#3498db', '#e74c3c', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#34495e', '#e67e22'] + const newPoint: DataPoint = { + ...newDataPoint, + color: colors[data.length % colors.length], + } + setData([...data, newPoint]) + setNewDataPoint({ label: '', value: 0 }) + } + } + + const removeDataPoint = (index: number) => { + setData(data.filter((_, i) => i !== index)) + } + + const getMaxValue = () => { + return Math.max(...data.map(d => d.value), 0) + } + + const getTotalValue = () => { + return data.reduce((sum, d) => sum + d.value, 0) + } + + const renderBarChart = () => { + const maxValue = getMaxValue() + + return ( +
+

Bar Chart

+
+ {data.map((point, index) => ( +
+
+
+ {point.value} +
+
+
+ {point.label} +
+
+ ))} +
+
+ ) + } + + const renderLineChart = () => { + const maxValue = getMaxValue() + const width = 600 + const height = 300 + const padding = 40 + + const points = data.map((point, index) => ({ + x: padding + (index * (width - 2 * padding)) / (data.length - 1), + y: padding + ((maxValue - point.value) / maxValue) * (height - 2 * padding), + })) + + const pathData = points.map((point, index) => + `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`, + ).join(' ') + + return ( +
+

Line Chart

+ + {/* Grid lines */} + {[0, 0.25, 0.5, 0.75, 1].map(ratio => ( + + ))} + + {/* Line */} + + + {/* Data points */} + {points.map((point, index) => ( + + ))} + + {/* Labels */} + {data.map((point, index) => ( + + {point.label} + + ))} + +
+ ) + } + + const renderPieChart = () => { + const total = getTotalValue() + let currentAngle = 0 + + return ( +
+

Pie Chart

+
+ + {data.map((point, index) => { + const percentage = point.value / total + const angle = percentage * 360 + const startAngle = currentAngle + const endAngle = currentAngle + angle + currentAngle += angle + + const centerX = 150 + const centerY = 150 + const radius = 120 + + const startAngleRad = (startAngle - 90) * (Math.PI / 180) + const endAngleRad = (endAngle - 90) * (Math.PI / 180) + + const x1 = centerX + radius * Math.cos(startAngleRad) + const y1 = centerY + radius * Math.sin(startAngleRad) + const x2 = centerX + radius * Math.cos(endAngleRad) + const y2 = centerY + radius * Math.sin(endAngleRad) + + const largeArcFlag = angle > 180 ? 1 : 0 + + const pathData = [ + `M ${centerX} ${centerY}`, + `L ${x1} ${y1}`, + `A ${radius} ${radius} 0 ${largeArcFlag} 1 ${x2} ${y2}`, + 'Z', + ].join(' ') + + return ( + + ) + })} + + +
+

Legend

+ {data.map((point, index) => ( +
+
+ {point.label} + + {point.value} + +
+ ))} +
+
+
+ ) + } + + return ( +
+
+

Data Visualization

+ + {/* Controls */} +
+
+
+ + +
+
+ +
+ setNewDataPoint({ ...newDataPoint, label: e.target.value })} + style={{ + padding: '8px 12px', + border: '1px solid #ddd', + borderRadius: '4px', + minWidth: '120px', + }} + /> + setNewDataPoint({ ...newDataPoint, value: Number(e.target.value) })} + style={{ + padding: '8px 12px', + border: '1px solid #ddd', + borderRadius: '4px', + minWidth: '100px', + }} + /> + +
+
+ + {/* Chart */} +
+ {data.length === 0 + ? ( +
+ No data to visualize. Add some data points above! +
+ ) + : ( + <> + {currentChartType === 'bar' && renderBarChart()} + {currentChartType === 'line' && renderLineChart()} + {currentChartType === 'pie' && renderPieChart()} + + )} +
+ + {/* Data table */} + {data.length > 0 && ( +
+
+

Data Table

+
+
+ + + + + + + + + + + + {data.map((point, index) => ( + + + + + + + + ))} + +
LabelValuePercentageColorActions
{point.label}{point.value} + {((point.value / getTotalValue()) * 100).toFixed(1)} + % + +
+
+ +
+
+
+ )} +
+
+ ) +} + +// Mount the component +const container = document.getElementById('widget-root') +if (container) { + const root = createRoot(container) + root.render() +} diff --git a/packages/create-mcp-use-app/src/templates/ui/resources/todo-list.tsx b/packages/create-mcp-use-app/src/templates/ui/resources/todo-list.tsx new file mode 100644 index 00000000..673a7592 --- /dev/null +++ b/packages/create-mcp-use-app/src/templates/ui/resources/todo-list.tsx @@ -0,0 +1,408 @@ +import React, { useEffect, useState } from 'react' +import { createRoot } from 'react-dom/client' + +interface Todo { + id: string + text: string + completed: boolean + priority: 'low' | 'medium' | 'high' + dueDate?: string + category?: string +} + +interface TodoListProps { + initialTodos?: Todo[] +} + +const TodoList: React.FC = ({ initialTodos = [] }) => { + const [todos, setTodos] = useState(initialTodos) + const [newTodo, setNewTodo] = useState('') + const [filter, setFilter] = useState<'all' | 'active' | 'completed'>('all') + const [sortBy, setSortBy] = useState<'priority' | 'dueDate' | 'created'>('priority') + + // Load todos from URL parameters or use defaults + useEffect(() => { + const urlParams = new URLSearchParams(window.location.search) + const todosParam = urlParams.get('todos') + + if (todosParam) { + try { + const parsedTodos = JSON.parse(decodeURIComponent(todosParam)) + setTodos(parsedTodos) + } + catch (error) { + console.error('Error parsing todos from URL:', error) + } + } + else { + // Default todos for demo + setTodos([ + { id: '1', text: 'Complete project proposal', completed: false, priority: 'high', dueDate: '2024-01-15', category: 'Work' }, + { id: '2', text: 'Buy groceries', completed: false, priority: 'medium', dueDate: '2024-01-12', category: 'Personal' }, + { id: '3', text: 'Call dentist', completed: true, priority: 'low', category: 'Health' }, + { id: '4', text: 'Read React documentation', completed: false, priority: 'medium', category: 'Learning' }, + { id: '5', text: 'Plan weekend trip', completed: false, priority: 'low', dueDate: '2024-01-20', category: 'Personal' }, + ]) + } + }, []) + + const addTodo = () => { + if (newTodo.trim()) { + const todo: Todo = { + id: Date.now().toString(), + text: newTodo, + completed: false, + priority: 'medium', + } + setTodos([...todos, todo]) + setNewTodo('') + } + } + + const toggleTodo = (id: string) => { + setTodos(todos.map(todo => + todo.id === id ? { ...todo, completed: !todo.completed } : todo, + )) + } + + const deleteTodo = (id: string) => { + setTodos(todos.filter(todo => todo.id !== id)) + } + + const updateTodoPriority = (id: string, priority: Todo['priority']) => { + setTodos(todos.map(todo => + todo.id === id ? { ...todo, priority } : todo, + )) + } + + const getFilteredTodos = () => { + let filtered = todos + + // Filter by status + switch (filter) { + case 'active': + filtered = filtered.filter(todo => !todo.completed) + break + case 'completed': + filtered = filtered.filter(todo => todo.completed) + break + default: + break + } + + // Sort todos + return filtered.sort((a, b) => { + switch (sortBy) { + case 'priority': + const priorityOrder = { high: 3, medium: 2, low: 1 } + return priorityOrder[b.priority] - priorityOrder[a.priority] + case 'dueDate': + if (!a.dueDate && !b.dueDate) + return 0 + if (!a.dueDate) + return 1 + if (!b.dueDate) + return -1 + return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime() + case 'created': + default: + return Number.parseInt(b.id) - Number.parseInt(a.id) + } + }) + } + + // const getPriorityColor = (priority: Todo['priority']) => { + // switch (priority) { + // case 'high': return '#e74c3c' + // case 'medium': return '#f39c12' + // case 'low': return '#27ae60' + // default: return '#95a5a6' + // } + // } + + const getPriorityIcon = (priority: Todo['priority']) => { + switch (priority) { + case 'high': return '🔴' + case 'medium': return '🟡' + case 'low': return '🟢' + default: return '⚪' + } + } + + const completedCount = todos.filter(todo => todo.completed).length + const totalCount = todos.length + const progressPercentage = totalCount > 0 ? (completedCount / totalCount) * 100 : 0 + + return ( +
+
+

Todo List

+ + {/* Progress bar */} +
+
+ Progress + + {completedCount} + {' '} + of + {' '} + {totalCount} + {' '} + completed + +
+
+
+
+
+ + {/* Add new todo */} +
+
+ setNewTodo(e.target.value)} + onKeyPress={e => e.key === 'Enter' && addTodo()} + style={{ + flex: '1', + padding: '12px 16px', + border: '1px solid #ddd', + borderRadius: '6px', + fontSize: '16px', + }} + /> + +
+
+ + {/* Filters and sorting */} +
+
+
+ + +
+ +
+ + +
+
+
+
+ + {/* Todo list */} +
+ {getFilteredTodos().map(todo => ( +
+ toggleTodo(todo.id)} + style={{ + width: '20px', + height: '20px', + cursor: 'pointer', + }} + /> + +
+
+ + {todo.text} + + + {todo.category && ( + + {todo.category} + + )} +
+ + {todo.dueDate && ( +
+ 📅 Due: + {' '} + {new Date(todo.dueDate).toLocaleDateString()} +
+ )} +
+ +
+ + + + {getPriorityIcon(todo.priority)} + + + +
+
+ ))} + + {getFilteredTodos().length === 0 && ( +
+ {filter === 'all' + ? 'No todos yet. Add one above!' + : filter === 'active' + ? 'No active todos!' + : 'No completed todos!'} +
+ )} +
+
+ ) +} + +// Mount the component +const container = document.getElementById('widget-root') +if (container) { + const root = createRoot(container) + root.render() +} diff --git a/packages/inspector/src/server/favicon-proxy.ts b/packages/inspector/src/server/favicon-proxy.ts new file mode 100644 index 00000000..89eef725 --- /dev/null +++ b/packages/inspector/src/server/favicon-proxy.ts @@ -0,0 +1,153 @@ +import { Hono } from 'hono' +import { cors } from 'hono/cors' + +const app = new Hono() + +// Enable CORS for all routes +app.use('*', cors()) + +// In-memory cache for favicons +interface CacheEntry { + data: ArrayBuffer + contentType: string + timestamp: number + ttl: number +} + +const faviconCache = new Map() +const CACHE_TTL = 24 * 60 * 60 * 1000 // 24 hours in milliseconds +const MAX_CACHE_SIZE = 1000 // Maximum number of cached favicons + +// Clean up expired cache entries +function cleanupCache() { + const now = Date.now() + for (const [key, entry] of faviconCache.entries()) { + if (now - entry.timestamp > entry.ttl) { + faviconCache.delete(key) + } + } +} + +// Run cleanup every hour +setInterval(cleanupCache, 60 * 60 * 1000) + +// Get cache key for a URL +function getCacheKey(url: string): string { + try { + const urlObj = new URL(url) + return urlObj.hostname.toLowerCase() + } + catch { + return url.toLowerCase() + } +} + +// Favicon proxy endpoint +app.get('/:url', async (c) => { + const url = c.req.param('url') + + if (!url) { + return c.json({ error: 'URL parameter is required' }, 400) + } + + try { + // Decode the URL + const decodedUrl = decodeURIComponent(url) + + // Add protocol if missing + let fullUrl = decodedUrl + if (!decodedUrl.startsWith('http://') && !decodedUrl.startsWith('https://')) { + fullUrl = `https://${decodedUrl}` + } + + // Validate URL + const urlObj = new URL(fullUrl) + const cacheKey = getCacheKey(fullUrl) + + // Check cache first + const cachedEntry = faviconCache.get(cacheKey) + if (cachedEntry && (Date.now() - cachedEntry.timestamp) < cachedEntry.ttl) { + return new Response(cachedEntry.data, { + headers: { + 'Content-Type': cachedEntry.contentType, + 'Cache-Control': 'public, max-age=86400, immutable', // Cache for 24 hours + 'Access-Control-Allow-Origin': '*', + 'X-Cache': 'HIT', + }, + }) + } + + const protocol = urlObj.protocol + const baseDomain = `${protocol}//${urlObj.origin.split('.').slice(-2).join('.')}` + + // Try to fetch favicon from common locations + const faviconUrls = [ + `${urlObj.origin}/favicon.ico`, + `${urlObj.origin}/favicon.png`, + `${urlObj.origin}/apple-touch-icon.png`, + `${urlObj.origin}/icon.png`, + `${baseDomain}/favicon.ico`, + `${baseDomain}/favicon.png`, + `${baseDomain}/apple-touch-icon.png`, + `${baseDomain}/icon.png`, + ] + + for (const faviconUrl of faviconUrls) { + try { + const response = await fetch(faviconUrl, { + headers: { + 'User-Agent': 'Mozilla/5.0 (compatible; MCP-Inspector/1.0)', + }, + }) + + if (response.ok) { + const contentType = response.headers.get('content-type') || 'image/x-icon' + const buffer = await response.arrayBuffer() + + // Cache the result + if (faviconCache.size >= MAX_CACHE_SIZE) { + // Remove oldest entries if cache is full + const entries = Array.from(faviconCache.entries()) + entries.sort((a, b) => a[1].timestamp - b[1].timestamp) + const toRemove = entries.slice(0, Math.floor(MAX_CACHE_SIZE / 4)) + toRemove.forEach(([key]) => faviconCache.delete(key)) + } + + faviconCache.set(cacheKey, { + data: buffer, + contentType, + timestamp: Date.now(), + ttl: CACHE_TTL, + }) + + return new Response(buffer, { + headers: { + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=86400, immutable', // Cache for 24 hours + 'Access-Control-Allow-Origin': '*', + 'X-Cache': 'MISS', + }, + }) + } + } + catch { + // Continue to next URL + continue + } + } + + // If no favicon found, return a default icon + return c.json({ error: 'No favicon found' }, 404) + } + catch (error) { + console.error('Favicon proxy error:', error) + return c.json({ error: 'Invalid URL or fetch failed' }, 400) + } +}) + +// Health check endpoint +app.get('/health', (c) => { + return c.json({ status: 'ok', service: 'favicon-proxy' }) +}) + +export default app diff --git a/packages/inspector/src/server/unified.ts b/packages/inspector/src/server/unified.ts new file mode 100644 index 00000000..2986b38a --- /dev/null +++ b/packages/inspector/src/server/unified.ts @@ -0,0 +1,337 @@ +import { exec } from 'node:child_process' +import { existsSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { serve } from '@hono/node-server' +import { Hono } from 'hono' +import { cors } from 'hono/cors' +import { logger } from 'hono/logger' +import faviconProxy from './favicon-proxy.js' +import { MCPInspector } from './mcp-inspector.js' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) +const execAsync = promisify(exec) + +// Check if a specific port is available +async function isPortAvailable(port: number): Promise { + const net = await import('node:net') + + return new Promise((resolve) => { + const server = net.createServer() + server.listen(port, () => { + server.close(() => resolve(true)) + }) + server.on('error', () => resolve(false)) + }) +} + +const app = new Hono() + +// Middleware +app.use('*', cors()) +app.use('*', logger()) + +// Mount favicon proxy +app.route('/api/favicon', faviconProxy) + +// Health check +app.get('/health', (c) => { + return c.json({ status: 'ok', timestamp: new Date().toISOString() }) +}) + +// MCP Inspector routes +const mcpInspector = new MCPInspector() + +// List available MCP servers +app.get('/api/servers', async (c) => { + try { + const servers = await mcpInspector.listServers() + return c.json({ servers }) + } + catch { + return c.json({ error: 'Failed to list servers' }, 500) + } +}) + +// Connect to an MCP server +app.post('/api/servers/connect', async (c) => { + try { + const { url, command } = await c.req.json() + const server = await mcpInspector.connectToServer(url, command) + return c.json({ server }) + } + catch { + return c.json({ error: 'Failed to connect to server' }, 500) + } +}) + +// Get server details +app.get('/api/servers/:id', async (c) => { + try { + const id = c.req.param('id') + const server = await mcpInspector.getServer(id) + if (!server) { + return c.json({ error: 'Server not found' }, 404) + } + return c.json({ server }) + } + catch { + return c.json({ error: 'Failed to get server details' }, 500) + } +}) + +// Execute a tool on a server +app.post('/api/servers/:id/tools/:toolName/execute', async (c) => { + try { + const id = c.req.param('id') + const toolName = c.req.param('toolName') + const input = await c.req.json() + + const result = await mcpInspector.executeTool(id, toolName, input) + return c.json({ result }) + } + catch { + return c.json({ error: 'Failed to execute tool' }, 500) + } +}) + +// Get server tools +app.get('/api/servers/:id/tools', async (c) => { + try { + const id = c.req.param('id') + const tools = await mcpInspector.getServerTools(id) + return c.json({ tools }) + } + catch { + return c.json({ error: 'Failed to get server tools' }, 500) + } +}) + +// Get server resources +app.get('/api/servers/:id/resources', async (c) => { + try { + const id = c.req.param('id') + const resources = await mcpInspector.getServerResources(id) + return c.json({ resources }) + } + catch { + return c.json({ error: 'Failed to get server resources' }, 500) + } +}) + +// Disconnect from a server +app.delete('/api/servers/:id', async (c) => { + try { + const id = c.req.param('id') + await mcpInspector.disconnectServer(id) + return c.json({ success: true }) + } + catch { + return c.json({ error: 'Failed to disconnect server' }, 500) + } +}) + +// Check if we're in development mode (Vite dev server running) +const isDev = process.env.NODE_ENV === 'development' || process.env.VITE_DEV === 'true' + +// Serve static assets from the built client +const clientDistPath = join(__dirname, '../../dist/client') + +if (isDev) { + // Development mode: proxy client requests to Vite dev server + console.warn('🔧 Development mode: Proxying client requests to Vite dev server') + + // Proxy all non-API requests to Vite dev server + app.get('*', async (c) => { + const path = c.req.path + + // Skip API routes + if (path.startsWith('/api/')) { + return c.notFound() + } + + try { + // Vite dev server should be running on port 3000 + const viteUrl = `http://localhost:3000${path}` + const response = await fetch(viteUrl, { + signal: AbortSignal.timeout(1000), // 1 second timeout + }) + + if (response.ok) { + const content = await response.text() + const contentType = response.headers.get('content-type') || 'text/html' + + c.header('Content-Type', contentType) + return c.html(content) + } + } + catch (error) { + console.warn(`Failed to proxy to Vite dev server: ${error}`) + } + + // Fallback HTML if Vite dev server is not running + return c.html(` + + + + MCP Inspector - Development + + +

MCP Inspector - Development Mode

+

Vite dev server is not running. Please start it with:

+
yarn dev:client
+

API is available at /api/servers

+ + + `) + }) +} +else if (existsSync(clientDistPath)) { + // Production mode: serve static assets from built client + // Serve static assets from /inspector/assets/* (matching Vite's base path) + app.get('/inspector/assets/*', async (c) => { + const path = c.req.path.replace('/inspector/assets/', 'assets/') + const fullPath = join(clientDistPath, path) + + if (existsSync(fullPath)) { + const content = await import('node:fs').then(fs => fs.readFileSync(fullPath)) + + // Set appropriate content type based on file extension + if (path.endsWith('.js')) { + c.header('Content-Type', 'application/javascript') + } + else if (path.endsWith('.css')) { + c.header('Content-Type', 'text/css') + } + else if (path.endsWith('.svg')) { + c.header('Content-Type', 'image/svg+xml') + } + + return c.body(content) + } + + return c.notFound() + }) + + // Redirect root path to /inspector + app.get('/', (c) => { + return c.redirect('/inspector') + }) + + // Serve the main HTML file for /inspector and all other routes (SPA routing) + app.get('*', (c) => { + const indexPath = join(clientDistPath, 'index.html') + if (existsSync(indexPath)) { + const content = import('node:fs').then(fs => fs.readFileSync(indexPath, 'utf-8')) + return c.html(content) + } + return c.html(` + + + + MCP Inspector + + +

MCP Inspector

+

Client files not found. Please run 'yarn build' to build the UI.

+

API is available at /api/servers

+ + + `) + }) +} +else { + console.warn(`⚠️ MCP Inspector client files not found at ${clientDistPath}`) + console.warn(` Run 'yarn build' in the inspector package to build the UI`) + + // Fallback for when client is not built + app.get('*', (c) => { + return c.html(` + + + + MCP Inspector + + +

MCP Inspector

+

Client files not found. Please run 'yarn build' to build the UI.

+

API is available at /api/servers

+ + + `) + }) +} + +// Start the server +async function startServer() { + try { + // In development mode, use port 3001 for API server + // In production/standalone mode, try 3001 first, then 3002 as fallback + const isDev = process.env.NODE_ENV === 'development' || process.env.VITE_DEV === 'true' + + let port = 3001 + const available = await isPortAvailable(port) + + if (!available) { + if (isDev) { + console.error(`❌ Port ${port} is not available. Please stop the process using this port and try again.`) + process.exit(1) + } + else { + // In standalone mode, try fallback port + const fallbackPort = 3002 + console.warn(`⚠️ Port ${port} is not available, trying ${fallbackPort}`) + const fallbackAvailable = await isPortAvailable(fallbackPort) + + if (!fallbackAvailable) { + console.error(`❌ Neither port ${port} nor ${fallbackPort} is available. Please stop the processes using these ports and try again.`) + process.exit(1) + } + + port = fallbackPort + } + } + + serve({ + fetch: app.fetch, + port, + }) + + if (isDev) { + console.warn(`🚀 MCP Inspector API server running on http://localhost:${port}`) + console.warn(`🌐 Vite dev server should be running on http://localhost:3000`) + } + else { + console.warn(`🚀 MCP Inspector running on http://localhost:${port}`) + } + + // Auto-open browser in development + if (process.env.NODE_ENV !== 'production') { + try { + const command = process.platform === 'win32' ? 'start' : process.platform === 'darwin' ? 'open' : 'xdg-open' + const url = isDev ? 'http://localhost:3000' : `http://localhost:${port}` + await execAsync(`${command} ${url}`) + console.warn(`🌐 Browser opened automatically`) + } + catch { + const url = isDev ? 'http://localhost:3000' : `http://localhost:${port}` + console.warn(`🌐 Please open ${url} in your browser`) + } + } + + return { port, fetch: app.fetch } + } + catch (error) { + console.error('Failed to start server:', error) + process.exit(1) + } +} + +// Start the server if this file is run directly +if (import.meta.url === `file://${process.argv[1]}`) { + startServer() +} + +export default { startServer } diff --git a/packages/mcp-use/index.ts b/packages/mcp-use/index.ts index b447e02d..c368536f 100644 --- a/packages/mcp-use/index.ts +++ b/packages/mcp-use/index.ts @@ -20,6 +20,19 @@ export * from './src/managers/tools/index.js' // Export observability utilities export { type ObservabilityConfig, ObservabilityManager } from './src/observability/index.js' +// Export server utilities +export { createMCPServer, McpServer } from './src/server/index.js' + +export type { + InputDefinition, + PromptDefinition, + PromptHandler, + ResourceDefinition, + ResourceHandler, + ServerConfig, + ToolDefinition, + ToolHandler, +} from './src/server/types.js' // Export telemetry utilities export { setTelemetrySource, Telemetry } from './src/telemetry/index.js' diff --git a/test_app/README.md b/test_app/README.md new file mode 100644 index 00000000..f8995b2d --- /dev/null +++ b/test_app/README.md @@ -0,0 +1,454 @@ +# UI MCP Server + +An MCP server with React UI widgets created with `create-mcp-app` that provides interactive web components for MCP clients. + +## Features + +- **🎨 React Components**: Interactive UI widgets built with React +- **🔥 Hot Reloading**: Development server with instant updates +- **📦 Production Builds**: Optimized bundles for production +- **🌐 Web Endpoints**: Serve widgets at `/mcp-use/widgets/{widget-name}` +- **🛠️ Development Tools**: Full TypeScript support and modern tooling + +## Getting Started + +### Development + +```bash +# Install dependencies +npm install + +# Start development server with hot reloading +npm run dev +``` + +This will start: + +- MCP server on port 3000 +- Vite dev server on port 3001 with hot reloading + +### Production + +```bash +# Build the server and UI components +npm run build + +# Run the built server +npm start +``` + +## Available Widgets + +### 1. Kanban Board (`/mcp-use/widgets/kanban-board`) + +Interactive Kanban board for task management. + +**Features:** + +- Drag and drop tasks between columns +- Add/remove tasks +- Priority levels and assignees +- Real-time updates + +**Usage:** + +```typescript +mcp.tool({ + name: 'show-kanban', + inputs: [{ name: 'tasks', type: 'string', required: true }], + fn: async ({ tasks }) => { + // Display Kanban board with tasks + } +}) +``` + +### 2. Todo List (`/mcp-use/widgets/todo-list`) + +Interactive todo list with filtering and sorting. + +**Features:** + +- Add/complete/delete todos +- Filter by status (all/active/completed) +- Sort by priority, due date, or creation time +- Progress tracking +- Categories and due dates + +**Usage:** + +```typescript +mcp.tool({ + name: 'show-todo-list', + inputs: [{ name: 'todos', type: 'string', required: true }], + fn: async ({ todos }) => { + // Display todo list with todos + } +}) +``` + +### 3. Data Visualization (`/mcp-use/widgets/data-visualization`) + +Interactive charts and data visualization. + +**Features:** + +- Bar charts, line charts, and pie charts +- Add/remove data points +- Interactive legends +- Data table view +- Multiple chart types + +**Usage:** + +```typescript +mcp.tool({ + name: 'show-data-viz', + inputs: [ + { name: 'data', type: 'string', required: true }, + { name: 'chartType', type: 'string', required: false } + ], + fn: async ({ data, chartType }) => { + // Display data visualization + } +}) +``` + +## Development Workflow + +### 1. Create a New Widget + +1. **Create the React component:** + + ```bash + # Create resources/my-widget.tsx + touch resources/my-widget.tsx + ``` + +2. **Create the HTML entry point:** + + ```bash + # Create resources/my-widget.html + touch resources/my-widget.html + ``` + +3. **Add to Vite config:** + + ```typescript + // vite.config.ts + rollupOptions: { + input: { + 'my-widget': resolve(__dirname, 'resources/my-widget.html') + } + } + ``` + +4. **Add MCP resource:** + ```typescript + // src/server.ts + mcp.resource({ + uri: 'ui://widget/my-widget', + name: 'My Widget', + mimeType: 'text/html+skybridge', + fn: async () => { + const widgetUrl = `http://localhost:${PORT}/mcp-use/widgets/my-widget` + return `
` + } + }) + ``` + +### 2. Development with Hot Reloading + +```bash +# Start development +npm run dev + +# Visit your widget +open http://localhost:3001/my-widget.html +``` + +Changes to your React components will automatically reload! + +### 3. Widget Development Best Practices + +#### Component Structure + +```typescript +import React, { useState, useEffect } from 'react' +import { createRoot } from 'react-dom/client' + +interface MyWidgetProps { + initialData?: any +} + +const MyWidget: React.FC = ({ initialData = [] }) => { + const [data, setData] = useState(initialData) + + // Load data from URL parameters + useEffect(() => { + const urlParams = new URLSearchParams(window.location.search) + const dataParam = urlParams.get('data') + + if (dataParam) { + try { + const parsedData = JSON.parse(decodeURIComponent(dataParam)) + setData(parsedData) + } catch (error) { + console.error('Error parsing data:', error) + } + } + }, []) + + return ( +
+ {/* Your widget content */} +
+ ) +} + +// Render the component +const container = document.getElementById('my-widget-root') +if (container) { + const root = createRoot(container) + root.render() +} +``` + +#### Styling Guidelines + +- Use inline styles for simplicity +- Follow the existing design system +- Ensure responsive design +- Use consistent color palette + +#### Data Handling + +- Accept data via URL parameters +- Provide sensible defaults +- Handle errors gracefully +- Use TypeScript for type safety + +### 4. Production Deployment + +```bash +# Build everything +npm run build + +# The built files will be in dist/ +# - dist/server.js (MCP server) +# - dist/resources/ (UI widgets) +``` + +## Project Structure + +``` +my-ui-server/ +├── src/ +│ └── server.ts # MCP server with UI endpoints +├── resources/ # React components and HTML entry points +│ ├── kanban-board.html +│ ├── kanban-board.tsx +│ ├── todo-list.html +│ ├── todo-list.tsx +│ ├── data-visualization.html +│ └── data-visualization.tsx +├── dist/ # Built files +│ ├── server.js +│ └── resources/ +├── package.json +├── tsconfig.json +├── vite.config.ts +└── README.md +``` + +## API Reference + +### MCP Resources + +All UI widgets are available as MCP resources: + +- `ui://status` - Server status and available widgets +- `ui://widget/kanban-board` - Kanban board widget +- `ui://widget/todo-list` - Todo list widget +- `ui://widget/data-visualization` - Data visualization widget + +### MCP Tools + +- `show-kanban` - Display Kanban board with tasks +- `show-todo-list` - Display todo list with items +- `show-data-viz` - Display data visualization + +### MCP Prompts + +- `ui-development` - Generate UI development guidance + +## Customization + +### Adding New Dependencies + +```bash +# Add React libraries +npm install @types/react-router-dom react-router-dom + +# Add UI libraries +npm install @mui/material @emotion/react @emotion/styled +``` + +### Environment Variables + +Create a `.env` file: + +```bash +# Server configuration +PORT=3000 +NODE_ENV=development + +# UI configuration +VITE_API_URL=http://localhost:3000 +``` + +### Custom Build Configuration + +```typescript +// vite.config.ts +export default defineConfig({ + plugins: [react()], + build: { + rollupOptions: { + input: { + 'my-custom-widget': resolve(__dirname, 'resources/my-custom-widget.html') + } + } + } +}) +``` + +## Troubleshooting + +### Common Issues + +1. **"Cannot find module" errors** + + - Make sure all dependencies are installed: `npm install` + - Check that TypeScript paths are correct + +2. **Hot reloading not working** + + - Ensure Vite dev server is running on port 3001 + - Check that the widget HTML file exists + +3. **Widget not loading** + + - Verify the widget is added to vite.config.ts + - Check that the MCP resource is properly configured + +4. **Build errors** + - Run `npm run build` to see detailed error messages + - Check that all imports are correct + +### Development Tips + +- Use browser dev tools to debug React components +- Check the Network tab for failed requests +- Use React DevTools browser extension +- Monitor console for errors + +## Learn More + +- [React Documentation](https://react.dev/) +- [Vite Documentation](https://vitejs.dev/) +- [MCP Documentation](https://modelcontextprotocol.io) +- [mcp-use Documentation](https://docs.mcp-use.io) + +## Examples + +### Simple Counter Widget + +```typescript +// resources/counter.tsx +import React, { useState } from 'react' +import { createRoot } from 'react-dom/client' + +const Counter: React.FC = () => { + const [count, setCount] = useState(0) + + return ( +
+

Counter: {count}

+ + +
+ ) +} + +const container = document.getElementById('counter-root') +if (container) { + const root = createRoot(container) + root.render() +} +``` + +### Data Table Widget + +```typescript +// resources/data-table.tsx +import React, { useState, useEffect } from 'react' +import { createRoot } from 'react-dom/client' + +interface TableData { + id: string + name: string + value: number +} + +const DataTable: React.FC = () => { + const [data, setData] = useState([]) + + useEffect(() => { + // Load data from URL or use defaults + const urlParams = new URLSearchParams(window.location.search) + const dataParam = urlParams.get('data') + + if (dataParam) { + try { + setData(JSON.parse(decodeURIComponent(dataParam))) + } catch (error) { + console.error('Error parsing data:', error) + } + } + }, []) + + return ( +
+

Data Table

+ + + + + + + + + {data.map(row => ( + + + + + ))} + +
NameValue
{row.name}{row.value}
+
+ ) +} + +const container = document.getElementById('data-table-root') +if (container) { + const root = createRoot(container) + root.render() +} +``` + +Happy coding! 🚀 diff --git a/test_app/package.json b/test_app/package.json new file mode 100644 index 00000000..ebaff800 --- /dev/null +++ b/test_app/package.json @@ -0,0 +1,49 @@ +{ + "name": "test-ui", + "type": "module", + "version": "1.0.0", + "description": "MCP server: test-ui", + "author": "", + "license": "MIT", + "keywords": [ + "mcp", + "server", + "ui", + "react", + "widgets", + "ai", + "tools" + ], + "main": "dist/server.js", + "scripts": { + "build": "mcp-use build", + "dev": "mcp-use dev", + "start": "mcp-use start" + }, + "dependencies": { + "@mcp-ui/server": "^5.11.0", + "@mcp-use/inspector": "^0.3.2", + "cors": "^2.8.5", + "express": "^4.18.0", + "mcp-use": "^1.0.0" + }, + "private": true, + "publishConfig": { + "access": "restricted" + }, + "devDependencies": { + "@mcp-use/cli": "^2.1.2", + "@types/cors": "^2.8.0", + "@types/express": "^4.17.0", + "@types/node": "^20.0.0", + "@types/react": "^18.0.0", + "@types/react-dom": "^18.0.0", + "concurrently": "^8.2.2", + "esbuild": "^0.23.0", + "globby": "^14.0.2", + "react": "^18.0.0", + "react-dom": "^18.0.0", + "tsx": "^4.0.0", + "typescript": "^5.0.0" + } +} \ No newline at end of file diff --git a/test_app/resources/data-visualization.tsx b/test_app/resources/data-visualization.tsx new file mode 100644 index 00000000..4ee2e2dc --- /dev/null +++ b/test_app/resources/data-visualization.tsx @@ -0,0 +1,477 @@ +import React, { useEffect, useState } from 'react' +import { createRoot } from 'react-dom/client' + +interface DataPoint { + label: string + value: number + color?: string +} + +interface DataVisualizationProps { + initialData?: DataPoint[] + chartType?: 'bar' | 'line' | 'pie' +} + +const DataVisualization: React.FC = ({ + initialData = [], + chartType = 'bar', +}) => { + const [data, setData] = useState(initialData) + const [currentChartType, setCurrentChartType] = useState<'bar' | 'line' | 'pie'>(chartType) + const [newDataPoint, setNewDataPoint] = useState({ label: '', value: 0 }) + + // Load data from URL parameters or use defaults + useEffect(() => { + const urlParams = new URLSearchParams(window.location.search) + const dataParam = urlParams.get('data') + const typeParam = urlParams.get('chartType') + + if (dataParam) { + try { + const parsedData = JSON.parse(decodeURIComponent(dataParam)) + setData(parsedData) + } + catch (error) { + console.error('Error parsing data from URL:', error) + } + } + else { + // Default data for demo + setData([ + { label: 'January', value: 65, color: '#3498db' }, + { label: 'February', value: 59, color: '#e74c3c' }, + { label: 'March', value: 80, color: '#2ecc71' }, + { label: 'April', value: 81, color: '#f39c12' }, + { label: 'May', value: 56, color: '#9b59b6' }, + { label: 'June', value: 55, color: '#1abc9c' }, + ]) + } + + if (typeParam) { + setCurrentChartType(typeParam as 'bar' | 'line' | 'pie') + } + }, []) + + const addDataPoint = () => { + if (newDataPoint.label.trim() && newDataPoint.value > 0) { + const colors = ['#3498db', '#e74c3c', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#34495e', '#e67e22'] + const newPoint: DataPoint = { + ...newDataPoint, + color: colors[data.length % colors.length], + } + setData([...data, newPoint]) + setNewDataPoint({ label: '', value: 0 }) + } + } + + const removeDataPoint = (index: number) => { + setData(data.filter((_, i) => i !== index)) + } + + const getMaxValue = () => { + return Math.max(...data.map(d => d.value), 0) + } + + const getTotalValue = () => { + return data.reduce((sum, d) => sum + d.value, 0) + } + + const renderBarChart = () => { + const maxValue = getMaxValue() + + return ( +
+

Bar Chart

+
+ {data.map((point, index) => ( +
+
+
+ {point.value} +
+
+
+ {point.label} +
+
+ ))} +
+
+ ) + } + + const renderLineChart = () => { + const maxValue = getMaxValue() + const width = 600 + const height = 300 + const padding = 40 + + const points = data.map((point, index) => ({ + x: padding + (index * (width - 2 * padding)) / (data.length - 1), + y: padding + ((maxValue - point.value) / maxValue) * (height - 2 * padding), + })) + + const pathData = points.map((point, index) => + `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`, + ).join(' ') + + return ( +
+

Line Chart

+ + {/* Grid lines */} + {[0, 0.25, 0.5, 0.75, 1].map(ratio => ( + + ))} + + {/* Line */} + + + {/* Data points */} + {points.map((point, index) => ( + + {`${data[index].label}: ${data[index].value}`} + + ))} + + {/* Labels */} + {data.map((point, index) => ( + + {point.label} + + ))} + +
+ ) + } + + const renderPieChart = () => { + const total = getTotalValue() + let currentAngle = 0 + + return ( +
+

Pie Chart

+
+ + {data.map((point, index) => { + const percentage = point.value / total + const angle = percentage * 360 + const startAngle = currentAngle + const endAngle = currentAngle + angle + currentAngle += angle + + const centerX = 150 + const centerY = 150 + const radius = 120 + + const startAngleRad = (startAngle - 90) * (Math.PI / 180) + const endAngleRad = (endAngle - 90) * (Math.PI / 180) + + const x1 = centerX + radius * Math.cos(startAngleRad) + const y1 = centerY + radius * Math.sin(startAngleRad) + const x2 = centerX + radius * Math.cos(endAngleRad) + const y2 = centerY + radius * Math.sin(endAngleRad) + + const largeArcFlag = angle > 180 ? 1 : 0 + + const pathData = [ + `M ${centerX} ${centerY}`, + `L ${x1} ${y1}`, + `A ${radius} ${radius} 0 ${largeArcFlag} 1 ${x2} ${y2}`, + 'Z', + ].join(' ') + + return ( + + {`${point.label}: ${point.value} (${(percentage * 100).toFixed(1)}%)`} + + ) + })} + + +
+

Legend

+ {data.map((point, index) => ( +
+
+ {point.label} + + {point.value} + +
+ ))} +
+
+
+ ) + } + + return ( +
+
+

Data Visualization

+ + {/* Controls */} +
+
+
+ + +
+
+ +
+ setNewDataPoint({ ...newDataPoint, label: e.target.value })} + style={{ + padding: '8px 12px', + border: '1px solid #ddd', + borderRadius: '4px', + minWidth: '120px', + }} + /> + setNewDataPoint({ ...newDataPoint, value: Number(e.target.value) })} + style={{ + padding: '8px 12px', + border: '1px solid #ddd', + borderRadius: '4px', + minWidth: '100px', + }} + /> + +
+
+ + {/* Chart */} +
+ {data.length === 0 + ? ( +
+ No data to visualize. Add some data points above! +
+ ) + : ( + <> + {currentChartType === 'bar' && renderBarChart()} + {currentChartType === 'line' && renderLineChart()} + {currentChartType === 'pie' && renderPieChart()} + + )} +
+ + {/* Data table */} + {data.length > 0 && ( +
+
+

Data Table

+
+
+ + + + + + + + + + + + {data.map((point, index) => ( + + + + + + + + ))} + +
LabelValuePercentageColorActions
{point.label}{point.value} + {((point.value / getTotalValue()) * 100).toFixed(1)} + % + +
+
+ +
+
+
+ )} +
+
+ ) +} + +// Mount the component +const container = document.getElementById('widget-root') +if (container) { + const root = createRoot(container) + root.render() +} diff --git a/test_app/resources/kanban-board.tsx b/test_app/resources/kanban-board.tsx new file mode 100644 index 00000000..a7bfcbae --- /dev/null +++ b/test_app/resources/kanban-board.tsx @@ -0,0 +1,306 @@ +import React, { useEffect, useState } from 'react' +import { createRoot } from 'react-dom/client' + +interface Task { + id: string + title: string + description: string + status: 'todo' | 'in-progress' | 'done' + priority: 'low' | 'medium' | 'high' + assignee?: string +} + +interface KanbanBoardProps { + initialTasks?: Task[] +} + +const KanbanBoard: React.FC = ({ initialTasks = [] }) => { + const [tasks, setTasks] = useState(initialTasks) + const [newTask, setNewTask] = useState<{ title: string; description: string; priority: Task['priority'] }>({ title: '', description: '', priority: 'medium' }) + + // Load tasks from URL parameters or use defaults + useEffect(() => { + const urlParams = new URLSearchParams(window.location.search) + const tasksParam = urlParams.get('tasks') + + if (tasksParam) { + try { + const parsedTasks = JSON.parse(decodeURIComponent(tasksParam)) + setTasks(parsedTasks) + } + catch (error) { + console.error('Error parsing tasks from URL:', error) + } + } + else { + // Default tasks for demo + setTasks([ + { id: '1', title: 'Design UI mockups', description: 'Create wireframes for the new dashboard', status: 'todo', priority: 'high', assignee: 'Alice' }, + { id: '2', title: 'Implement authentication', description: 'Add login and registration functionality', status: 'in-progress', priority: 'high', assignee: 'Bob' }, + { id: '3', title: 'Write documentation', description: 'Document the API endpoints', status: 'done', priority: 'medium', assignee: 'Charlie' }, + { id: '4', title: 'Setup CI/CD', description: 'Configure automated testing and deployment', status: 'todo', priority: 'medium' }, + { id: '5', title: 'Code review', description: 'Review pull requests from the team', status: 'in-progress', priority: 'low', assignee: 'David' }, + ]) + } + }, []) + + const addTask = () => { + if (newTask.title.trim()) { + const task: Task = { + id: Date.now().toString(), + title: newTask.title, + description: newTask.description, + status: 'todo', + priority: newTask.priority, + } + setTasks([...tasks, task]) + setNewTask({ title: '', description: '', priority: 'medium' }) + } + } + + const moveTask = (taskId: string, newStatus: Task['status']) => { + setTasks(tasks.map(task => + task.id === taskId ? { ...task, status: newStatus } : task, + )) + } + + const deleteTask = (taskId: string) => { + setTasks(tasks.filter(task => task.id !== taskId)) + } + + const getTasksByStatus = (status: Task['status']) => { + return tasks.filter(task => task.status === status) + } + + const getPriorityColor = (priority: Task['priority']) => { + switch (priority) { + case 'high': return '#ff4757' + case 'medium': return '#ffa502' + case 'low': return '#2ed573' + default: return '#57606f' + } + } + + const columns = [ + { id: 'todo', title: 'To Do', color: '#57606f' }, + { id: 'in-progress', title: 'In Progress', color: '#ffa502' }, + { id: 'done', title: 'Done', color: '#2ed573' }, + ] as const + + return ( +
+
+

Kanban Board

+ + {/* Add new task form */} +
+

Add New Task

+
+ setNewTask({ ...newTask, title: e.target.value })} + style={{ + padding: '8px 12px', + border: '1px solid #ddd', + borderRadius: '4px', + flex: '1', + minWidth: '200px', + }} + /> + setNewTask({ ...newTask, description: e.target.value })} + style={{ + padding: '8px 12px', + border: '1px solid #ddd', + borderRadius: '4px', + flex: '1', + minWidth: '200px', + }} + /> + + +
+
+
+ + {/* Kanban columns */} +
+ {columns.map(column => ( +
+
+ {column.title} + + {getTasksByStatus(column.id).length} + +
+ +
+ {getTasksByStatus(column.id).map(task => ( +
{ + e.dataTransfer.setData('text/plain', task.id) + }} + onDragOver={e => e.preventDefault()} + onDrop={(e) => { + e.preventDefault() + const taskId = e.dataTransfer.getData('text/plain') + if (taskId === task.id) { + // Move to next column + const currentIndex = columns.findIndex(col => col.id === column.id) + const nextColumn = columns[currentIndex + 1] + if (nextColumn) { + moveTask(taskId, nextColumn.id) + } + } + }} + > +
+

{task.title}

+ +
+ + {task.description && ( +

+ {task.description} +

+ )} + +
+
+ {task.priority.toUpperCase()} +
+ + {task.assignee && ( + + {task.assignee} + + )} +
+
+ ))} + + {getTasksByStatus(column.id).length === 0 && ( +
+ No tasks in this column +
+ )} +
+
+ ))} +
+
+ ) +} + +// Mount the component +const container = document.getElementById('widget-root') +if (container) { + const root = createRoot(container) + root.render() +} diff --git a/test_app/resources/todo-list.tsx b/test_app/resources/todo-list.tsx new file mode 100644 index 00000000..673a7592 --- /dev/null +++ b/test_app/resources/todo-list.tsx @@ -0,0 +1,408 @@ +import React, { useEffect, useState } from 'react' +import { createRoot } from 'react-dom/client' + +interface Todo { + id: string + text: string + completed: boolean + priority: 'low' | 'medium' | 'high' + dueDate?: string + category?: string +} + +interface TodoListProps { + initialTodos?: Todo[] +} + +const TodoList: React.FC = ({ initialTodos = [] }) => { + const [todos, setTodos] = useState(initialTodos) + const [newTodo, setNewTodo] = useState('') + const [filter, setFilter] = useState<'all' | 'active' | 'completed'>('all') + const [sortBy, setSortBy] = useState<'priority' | 'dueDate' | 'created'>('priority') + + // Load todos from URL parameters or use defaults + useEffect(() => { + const urlParams = new URLSearchParams(window.location.search) + const todosParam = urlParams.get('todos') + + if (todosParam) { + try { + const parsedTodos = JSON.parse(decodeURIComponent(todosParam)) + setTodos(parsedTodos) + } + catch (error) { + console.error('Error parsing todos from URL:', error) + } + } + else { + // Default todos for demo + setTodos([ + { id: '1', text: 'Complete project proposal', completed: false, priority: 'high', dueDate: '2024-01-15', category: 'Work' }, + { id: '2', text: 'Buy groceries', completed: false, priority: 'medium', dueDate: '2024-01-12', category: 'Personal' }, + { id: '3', text: 'Call dentist', completed: true, priority: 'low', category: 'Health' }, + { id: '4', text: 'Read React documentation', completed: false, priority: 'medium', category: 'Learning' }, + { id: '5', text: 'Plan weekend trip', completed: false, priority: 'low', dueDate: '2024-01-20', category: 'Personal' }, + ]) + } + }, []) + + const addTodo = () => { + if (newTodo.trim()) { + const todo: Todo = { + id: Date.now().toString(), + text: newTodo, + completed: false, + priority: 'medium', + } + setTodos([...todos, todo]) + setNewTodo('') + } + } + + const toggleTodo = (id: string) => { + setTodos(todos.map(todo => + todo.id === id ? { ...todo, completed: !todo.completed } : todo, + )) + } + + const deleteTodo = (id: string) => { + setTodos(todos.filter(todo => todo.id !== id)) + } + + const updateTodoPriority = (id: string, priority: Todo['priority']) => { + setTodos(todos.map(todo => + todo.id === id ? { ...todo, priority } : todo, + )) + } + + const getFilteredTodos = () => { + let filtered = todos + + // Filter by status + switch (filter) { + case 'active': + filtered = filtered.filter(todo => !todo.completed) + break + case 'completed': + filtered = filtered.filter(todo => todo.completed) + break + default: + break + } + + // Sort todos + return filtered.sort((a, b) => { + switch (sortBy) { + case 'priority': + const priorityOrder = { high: 3, medium: 2, low: 1 } + return priorityOrder[b.priority] - priorityOrder[a.priority] + case 'dueDate': + if (!a.dueDate && !b.dueDate) + return 0 + if (!a.dueDate) + return 1 + if (!b.dueDate) + return -1 + return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime() + case 'created': + default: + return Number.parseInt(b.id) - Number.parseInt(a.id) + } + }) + } + + // const getPriorityColor = (priority: Todo['priority']) => { + // switch (priority) { + // case 'high': return '#e74c3c' + // case 'medium': return '#f39c12' + // case 'low': return '#27ae60' + // default: return '#95a5a6' + // } + // } + + const getPriorityIcon = (priority: Todo['priority']) => { + switch (priority) { + case 'high': return '🔴' + case 'medium': return '🟡' + case 'low': return '🟢' + default: return '⚪' + } + } + + const completedCount = todos.filter(todo => todo.completed).length + const totalCount = todos.length + const progressPercentage = totalCount > 0 ? (completedCount / totalCount) * 100 : 0 + + return ( +
+
+

Todo List

+ + {/* Progress bar */} +
+
+ Progress + + {completedCount} + {' '} + of + {' '} + {totalCount} + {' '} + completed + +
+
+
+
+
+ + {/* Add new todo */} +
+
+ setNewTodo(e.target.value)} + onKeyPress={e => e.key === 'Enter' && addTodo()} + style={{ + flex: '1', + padding: '12px 16px', + border: '1px solid #ddd', + borderRadius: '6px', + fontSize: '16px', + }} + /> + +
+
+ + {/* Filters and sorting */} +
+
+
+ + +
+ +
+ + +
+
+
+
+ + {/* Todo list */} +
+ {getFilteredTodos().map(todo => ( +
+ toggleTodo(todo.id)} + style={{ + width: '20px', + height: '20px', + cursor: 'pointer', + }} + /> + +
+
+ + {todo.text} + + + {todo.category && ( + + {todo.category} + + )} +
+ + {todo.dueDate && ( +
+ 📅 Due: + {' '} + {new Date(todo.dueDate).toLocaleDateString()} +
+ )} +
+ +
+ + + + {getPriorityIcon(todo.priority)} + + + +
+
+ ))} + + {getFilteredTodos().length === 0 && ( +
+ {filter === 'all' + ? 'No todos yet. Add one above!' + : filter === 'active' + ? 'No active todos!' + : 'No completed todos!'} +
+ )} +
+
+ ) +} + +// Mount the component +const container = document.getElementById('widget-root') +if (container) { + const root = createRoot(container) + root.render() +} diff --git a/test_app/src/server.ts b/test_app/src/server.ts new file mode 100644 index 00000000..7eaec7c9 --- /dev/null +++ b/test_app/src/server.ts @@ -0,0 +1,184 @@ +import { createMCPServer } from 'mcp-use' +import { createUIResource } from '@mcp-ui/server'; + +// Create an MCP server (which is also an Express app) +// The MCP Inspector is automatically mounted at /inspector +const server = createMCPServer('ui-mcp-server', { + version: '1.0.0', + description: 'An MCP server with React UI widgets', +}) + +const PORT = process.env.PORT || 3000 + + +server.tool({ + name: 'test-tool', + description: 'Test tool', + inputs: [ + { + name: 'test', + type: 'string', + description: 'Test input', + required: true, + }, + ], + fn: async () => { + const uiResource = createUIResource({ + uri: 'ui://widget/kanban-board', + content: { + type: 'externalUrl', + iframeUrl: `http://localhost:${PORT}/mcp-use/widgets/kanban-board` + }, + encoding: 'text', + }) + return { + content: [uiResource] + } + }, +}) + +// MCP Resource for server status +server.resource({ + uri: 'ui://status', + name: 'UI Server Status', + description: 'Status of the UI MCP server', + mimeType: 'application/json', + fn: async () => { + return JSON.stringify({ + name: 'ui-mcp-server', + version: '1.0.0', + status: 'running', + transport: process.env.MCP_TRANSPORT || 'http', + uiEndpoint: `http://localhost:${PORT}/mcp-use/widgets`, + mcpEndpoint: process.env.MCP_TRANSPORT === 'stdio' ? 'stdio' : `http://localhost:${PORT}/mcp`, + availableWidgets: ['kanban-board', 'todo-list', 'data-visualization'], + timestamp: new Date().toISOString(), + }, null, 2) + }, +}) + + +// MCP Resource for Kanban Board widget +server.resource({ + uri: 'ui://widget/kanban-board', + name: 'Kanban Board Widget', + description: 'Interactive Kanban board widget', + mimeType: 'text/html+skybridge', + fn: async () => { + const widgetUrl = `http://localhost:${PORT}/mcp-use/widgets/kanban-board` + return ` +
+ + `.trim() + }, +}) + +// MCP Resource for Todo List widget +server.resource({ + uri: 'ui://widget/todo-list', + name: 'Todo List Widget', + description: 'Interactive todo list widget', + mimeType: 'text/html+skybridge', + fn: async () => { + const widgetUrl = `http://localhost:${PORT}/mcp-use/widgets/todo-list` + return ` +
+ + `.trim() + }, +}) + +// MCP Resource for Data Visualization widget +server.resource({ + uri: 'ui://widget/data-visualization', + name: 'Data Visualization Widget', + description: 'Interactive data visualization widget', + mimeType: 'text/html+skybridge', + fn: async () => { + const widgetUrl = `http://localhost:${PORT}/mcp-use/widgets/data-visualization` + return ` +
+ + `.trim() + }, +}) + +// Tool for showing Kanban Board +server.tool({ + name: 'show-kanban', + description: 'Display an interactive Kanban board', + inputs: [ + { + name: 'tasks', + type: 'string', + description: 'JSON string of tasks to display', + required: true, + }, + ], + fn: async (params: Record) => { + const { tasks } = params + try { + const taskData = JSON.parse(tasks) + return { + content: [ + { + type: 'text', + text: `Displayed Kanban board with ${taskData.length || 0} tasks at http://localhost:${PORT}/mcp-use/widgets/kanban-board` + } + ] + } + } + catch (error) { + return { + content: [ + { + type: 'text', + text: `Error parsing tasks: ${error instanceof Error ? error.message : 'Invalid JSON'}` + } + ] + } + } + }, +}) + +// Tool for showing Todo List +server.tool({ + name: 'show-todo-list', + description: 'Display an interactive todo list', + inputs: [ + { + name: 'todos', + type: 'string', + description: 'JSON string of todos to display', + required: true, + }, + ], + fn: async (params: Record) => { + const { todos } = params + try { + const todoData = JSON.parse(todos) + return { + content: [ + { + type: 'text', + text: `Displayed Todo list with ${todoData.length || 0} items at http://localhost:${PORT}/mcp-use/widgets/todo-list` + } + ] + } + } + catch (error) { + return { + content: [ + { + type: 'text', + text: `Error parsing todos: ${error instanceof Error ? error.message : 'Invalid JSON'}` + } + ] + } + } + }, +}) + + +// Start the server (MCP endpoints auto-mounted at /mcp) +server.listen(PORT) diff --git a/test_app/tsconfig.json b/test_app/tsconfig.json new file mode 100644 index 00000000..c28fa523 --- /dev/null +++ b/test_app/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "node", + "allowJs": true, + "strict": true, + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "sourceMap": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + }, + "include": ["src/**/*", "resources/**/*"], + "exclude": ["node_modules", "dist"] +}